Crear música electrónica con JavaScript y ToneJs

Aprende a crear música electrónica con JavaScript y ToneJs, un framework de generación de música a través del navegador web.

Al final de este post habrás programado un secuenciador capaz de generar música a tiempo real con JavaScript, gracias a la librería ToneJs. Puedes ver el proyecto terminado haciendo clic en la siguiente imagen.

Ejercicio terminado para crear música electrónica con JavaScript y ToneJs. Haz click en la imagen para verlo en una ventana nueva.
Ejercicio terminado para crear música electrónica con JavaScript y ToneJs. Haz click en la imagen para verlo en una ventana nueva.

En los primeros párrafos de esta publicación repasaremos las bases teóricas de este apasionante tema, si prefieres ir a la parte práctica directamente, haz click aquí

En la publicación de hoy daremos rienda suelta al músico que llevamos dentro, creando impresionantes piezas de música electrónica a través de código JavaScript.

Así es, haciendo uso de APIs y tecnologías de desarrollo web, vamos a generar música directamente en nuestro navegador.

¿Cómo es eso posible? te preguntarás.

Crear música electrónica con JavaScript y ToneJs
Crear música electrónica con JavaScript y ToneJs

Pues gracias a ToneJs, un framework que pone a nuestra disposición todo un abanico de herramientas para la creación de música electrónica.

Señales digitales, analógicas y síntesis de audio.

Para trabajar con ToneJs, primero es importante asentar ciertos conceptos clave en la generación de audio procedural.

Si ya estás familiarizado con lo que explico a continuación, y prefieres ir a la acción, no dudes en saltar directamente a la parte del ejercicio práctico.

En primer lugar, debemos preguntarnos ¿cómo logra generar sonidos la tarjeta de audio de un dispositivo?

Ese será el punto de partida de nuestro aprendizaje.

A grandes rasgos lo que hace una tarjeta de audio, es convertir una señal digital a otra analógica que puede ser reproducida por un altavoz.

Antes de ser procesada, la señal digital actúa como una secuencia de números que representan la amplitud de la onda en cada instante.

Una vez convertida a analógica, ésta señal pasa a ser una corriente eléctrica que hace vibrar el altavoz, produciendo el sonido.

Para generar una señal digital, podemos usar diferentes técnicas de síntesis de audio.

Estas técnicas consisten en manipular y combinar varios tipos de ondas, tales como ondas senoidales, triangulares, cuadradas o de diente de sierra, según su forma.

El elemento encargado de generar dichas ondas se conoce generalmente como oscilador.

Tal y como veremos más adelante, cada onda tiene un timbre característico y unas propiedades que podemos modificar, como la frecuencia, la amplitud o la fase.

A través de este proceso de creación de ondas personalizadas, podemos obtener diferentes sonidos.

En cierto modo, podríamos considerar el sonido producido por una onda, como el recurso elemental en la creación de música electrónica.

Ejemplo de onda sinoidal
Ejemplo de onda sinoidal

No obstante, si deseamos generar un sonido más interesante, el proceso no termina ahí. Además, deberemos dotar de expresión a ese sonido.

Para lograrlo recurriremos al uso de envolventes.

Una envolvente describe cómo se comporta un sonido desde que se inicia, hasta que termina.

En la naturaleza, cualquier sonido necesita un determinado tiempo para reproducirse, estabilizarse y apagarse.

Para tratar de ilustrar este concepto, es útil pensar en un piano analógico. Tras presionar cualquiera de sus teclas, suena una nota que va desde el silencio absoluto hacia su punto más alto.

Después baja su intensidad y se mantiene estable hasta el momento de soltar la tecla.

En ese momento disminuye hasta llegar de nuevo al silencio.

Una envolvente, entonces, se encarga de emular ese mismo comportamiento, modelando las siguientes propiedades y la transición entre ellas:

  • Attack: El tiempo que transcurre desde que se ejecuta el sonido y llega a su volumen más alto.
  • Decay: La transición entre el punto anterior y el volumen «estable».
  • Sustain: El espacio de tiempo en el que se puede mantener el volumen del sonido de forma estable.
  • Release: El intérvalo de tiempo que transcurre desde que se inicia el descenso del sonido, hasta que se silencia.
Esquema de reproducción del sonido
Esquema de reproducción del sonido

Ya conocemos el papel que juegan los osciladores y las envolventes a la hora de generar y manipular el sonido.

Ahora toca ver cómo se combinan ambos para construir lo que podríamos entender como un instrumento digital.

Me refiero, por supuesto, a los sintetizadores.

Existen muchos tipos de sintetizadores, cada uno con un conjunto de propiedades que los hacen únicos.

No obstante, la característica común en muchos de ellos, es su capacidad de interpretar y reproducir notas musicales.

Dada una nota, su octava en la escala musical y un tiempo de ejecución, será capaz de reproducirla mediante el oscilador y la envolvente.

En el ejercicio de mas adelante, utilizaremos un tipo de sintetizador básico que ajustaremos a nuestras necesidades.

Pero ya llegaremos a eso, de momento volvamos al protagonista de este post, ToneJs.

Como ya habrás imaginado, ToneJs nos ayuda en el proceso creativo de generación de síntesis de audio.

Esta biblioteca JavaScript utiliza la API de Audio HTML5 del navegador, la cual se conecta directamente con el hardware del dispositivo (la tarjeta de audio de tu dispositivo).

Su objetivo es resolver a bajo nivel distintos procesos técnicos, y ofrecer una capa de abstracción que nos permita trabajar directamente con los conceptos antes descritos.

Con esta herramienta podemos generar sonidos complejos y variados, así como controlar el tiempo, el ritmo y los efectos en nuestra música.

Pero además, por si esto fuera poco, también ofrece herramientas adicionales como “samplers”, efectos, “players” o conexión MIDI.

Aunque en esta publicación no entro en detalle a ver estos recursos, a continuación te dejo una breve descripción de todos, por si te apetece indagar un poco más por tu cuenta.

  • Sampler: Un sistema para mapear archivos de audio para asignarlos a las distintas notas de una escala musical. De este modo, podrías por ejemplo cargar un “la” de un instrumento real, obtenido de una grabación, y reproducirlo con ToneJs a voluntad.
  • Efecto: Permite aplicar una capa de transformación adicional al audio que se está ejecutando. Por ejemplo, para generar un efecto de distorsión o eco.
  • Player: Una interfaz de programación que permite cargar y ejecutar pistas de audio, ofreciendo la posibilidad de pausar, reanudar o detener.
  • Protocolo MIDI: Se trata de un protocolo estándar que establece una comunicación bidireccional entre nuestro programa y hardware externo, como por ejemplo, instrumentos o mezcladores. Este sistema abre un mundo de posibilidades, con él puedes crear música apoyándote en dispositivos específicamente creados para ese fín.

Bien, ahora que ya sabemos qué hace ToneJs, ha llegado el momento de ver el cómo.

En los próximos párrafos estudiaremos brevemente su API, para conocer sus métodos y propiedades mas relevantes.

ToneJs, la librería JavaScript para producir y manipular audios.

ToneJs es una librería open source activamente mantenida desde hace más de cuatro años.

Tiene una valoración de más de 12.7K estrellas en su repositorio de Github, y se descarga aproximadamente unas 14.860 veces a la semana, según datos de npmjs.com

Se puede instalar como un módulo NPM a través del siguiente comando:

npm i tone

La primera consideración que hay que tener en cuenta trabajando con ToneJs, o mejor dicho, con cualquier audio reproducido en el navegador, es la restricción de autoreproducción que aplican los navegadores.

Esa restricción impuesta por el navegador se encarga de evitar que cualquier sonido se pueda ejecutar de forma automática sin previa interacción por parte del usuario.

Si lo piensas, en realidad tiene mucho sentido, la experiencia de usuario se puede ver muy afectada, si un sitio web empieza a reproducir un audio, antes siquiera de que el visitante haya hecho un simple click.

Es por eso que ToneJs expone una función llamada “start”.

Importando y ejecutando este método, podemos verificar que se ha respetado la restricción, y se puede ejecutar la librería sin problemas.

import * as Tone from "tone";
const { start } = Tone;
const btn = document.querySelector('#play-btn');
btn.addEventListener('click', async ()=>{
  await start();
  console.log('Listo para reproducir sonidos');
});

La versión “Hello world” de ToneJs consiste en importar un sintetizador simple y pedirle que ejecute la nota “F4” (un “la” en la cuarta octava) durante un período de tiempo.

import * as Tone from "tone";
const { Synth } = Tone;
const synth = new Synth().toDestination();
synth.triggerAttackRelease("F4", "8n");

El método “toDestination” es necesario para indicar al objeto sintetizador que envíe el sonido generado directamente al “output” del dispositivo, osea, los altavoces.

Por otra parte, el método “triggerAttackRelease” ejecuta la nota indicada en el primer parámetro durante el tiempo indicado en el segundo parámetro.

Cabe destacar que el segundo parámetro admite múltiples formatos, concretamente el del ejemplo describe el tiempo en “beats por minuto” y los valores relativos de signatura de tiempo. Por supuesto, también puedes enviarle un valor numérico representado en segundos.

Como ya te dije anteriormente, existe una gran variedad de instrumentos (a.k.a sintetizadores) preconfigurados en ToneJs, te animo a visitar los ejemplos de la web y experimentar con cada uno de ellos.

Encontrarás el enlace al final del artículo.

Del mismo modo, también pone a tu disposición muchos efectos que puedes aplicar a tu música. Veamos rápidamente cómo trabajar con ellos.

import * as Tone from "tone";
const { Synth, Filter, Distortion } = Tone;
const filter = new Filter(400, 'lowpass').toDestination();
const effect = new Distortion(0.4).toDestination()
const synth = new Synth();
synth.connect(filter).connect(effect);
synth.triggerAttackRelease("F4", "8n");

Aquí tenemos un ejemplo que aplica un filtro pasa-bajos y un efecto de distorsión.

Presta especial atención a cómo se conectan estos componentes al destino final. 

Verás que son los efectos los que llaman a “toDestination”, mientras que el sintetizador se conecta a ellos mediante el método connect.

Otra clase de la librería que es importante estudiar es Transport.

Transport forma parte del “core” de la biblioteca ToneJs y se encarga entre otras cosas de manejar ajustes de tiempo, atender a la escucha de eventos, e incluso programar repeticiones para ejecutar sonidos cada X tiempo.

En el siguiente ejemplo vemos cómo ajustar el tempo y programar un pitido que suena repetidamente acorde al tempo y al intérvalo de ejecución.

import * as Tone from "tone";
const { Oscillator, Transport } = Tone;
const osc = new Oscillator().toDestination();
Transport.scheduleRepeat((time) => {
   osc.start(time).stop(time + 0.1);
}, "8n");
Transport.bpm.value = 10;
Transport.start();

Cerramos este apartado repasando otro módulo igualmente relevante, me refiero a  Destination.

Destination es una representación del output al que se envía el sonido, podemos entenderlo como los altavoces.

El objeto Destination, por consiguiente, nos va a permitir, entre otras cosas, ajustar el volumen de la composición final.

import * as Tone from "tone";
const { Destination } = Tone;
Destination.volume.rampTo(-10, 0.001);

Con esto ya estamos en condiciones de crear música electrónica con JavaScript y ToneJs.

Crear música electrónica con JavaScript y ToneJs.

Si has hecho click en la imagen del inicio del post ya sabrás qué proyecto vamos a crear, espero que este tutorial te resulte tan interesante como lo fue para mí prepararlo.

Se trata de un “Step sequencer” o un secuenciador, un dispositivo capaz de reproducir música de forma secuencial.

El objetivo de este programa será ofrecer a los usuarios una interfaz web para que puedan crear piezas de música electrónica mediante sintetizadores.

Iremos punto por punto, pero en cualquier caso, puedes acceder al código fuente final subido en el repositorio de Github de libreriasjs.

https://github.com/Danivalldo/libreriasjs/tree/master/ToneJs

Empezamos instalando todas las dependencias del proyecto.

npm i tone sass

Adicionalmente a ToneJs, he añadido SASS para trabajar los estilos de forma más cómoda.

En este punto, vamos a preparar un “layout” básico y colocar los componentes de la interfaz.

Crea un archivo index.html y dentro de la etiqueta “body” incluímos los siguientes elementos.

<div class="header">
  <img src="./imgs/graphic_eq_FILL0_wght300_GRAD0_opsz48.svg" alt="" />
  <h1>Music creator <span>by librerias.js</span></h1>
</div>

Una cabecera con el título y un logo.

<div class="play-btns-container">
  <button id="play-song-btn" class="active-play">
    <span class="icon icon-play"
      ><img src="./imgs/play_circle_FILL0_wght300_GRAD0_opsz48.svg" alt=""
    /></span>
    <span class="icon icon-stop"
      ><img src="./imgs/stop_circle_FILL0_wght300_GRAD0_opsz48.svg" alt=""
    /></span>
  </button>
  <button id="clear-song-btn">
    <span class="icon icon-clear">
      <img src="./imgs/delete_FILL0_wght300_GRAD0_opsz48.svg" alt="" />
    </span>
  </button>
</div>

Un contenedor con los botones de “play” / “stop”, y el botón de resetear la composición.

En mi caso, he rellenado los botones con iconos de estilo “material desgin”, siéntete libre de darle el aspecto gráfico que mejor consideres.

Dentro de un contenedor con la clase “board” anidamos las siguientes líneas HTML.

<div class="board">
  <div>
    <div id="step-sequencer-container"></div>
  </div>
</div>

Esta DIV vacía actuará de marco donde cargaremos el secuenciador generado de forma dinámica con JS.

<div class="tempo-and-synth-props">
  <p class="disclaimer">
    * Necesitas cascos o altavoces para utilizar este proyecto
  </p>
  <h2>Tempo:</h2>
  <input
    type="range"
    min="50"
    max="200"
    value="120"
    step="1"
    id="tempo"
  />
  <div>
    <h2>Oscilador:</h2>
    <label for="oscillator-type">Tipo</label>
    <select name="oscillator-type" id="oscillator-type">
      <option value="triangle8">Triangular</option>
      <option value="square8">Cuadrada</option>
      <option value="sine8">Senoidal</option>
      <option value="sawtooth8">Diente de sierra</option>
    </select>
    <div class="envelop-sliders">
      <h3>Envolvente</h3>
      <label for="envelop-attack">Ataque</label>
      <input
        id="envelop-attack"
        name="attack"
        type="range"
        min="0"
        value="0.1"
        max="1"
        step="0.1"
      />
      <label for="envelop-decay">Decadencia</label>
      <input
        id="envelop-decay"
        name="decay"
        type="range"
        min="0"
        value="0.1"
        max="1"
        step="0.1"
      />
      <label for="envelop-release">Liberación</label>
      <input
        id="envelop-release"
        name="release"
        type="range"
        min="0"
        value="0.1"
        max="1"
        step="0.1"
      />
      <label for="envelop-sustain">Sustentar</label>
      <input
        id="envelop-sustain"
        name="sustain"
        type="range"
        min="0"
        value="0.1"
        max="1"
        step="0.1"
      />
    </div>
  </div>
</div>

Seguidamente incluimos una serie de “inputs” y selectores que van a permitir al usuario editar las propiedades del sintetizador y el tempo de la composición.

Listo, acto seguido, generamos un archivo style.scss para dotar de estilos la UI.

:root {
  font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
  font-size: 16px;
  line-height: 24px;
  font-weight: 400;
  color-scheme: dark;
  color: rgba(255, 255, 255, 0.87);
  background-color: #242424;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  -webkit-text-size-adjust: 100%;
  --mainColorDarker: #710071;
  --mainColor: rgb(181, 0, 181);
  --mainColorLigher: #df37df;
}
body {
  margin: 0;
}
.header {
  background-color: var(--mainColor);
  display: flex;
  padding: 5px;
  border-bottom: solid 3px var(--mainColorDarker);
  align-items: flex-end;
  h1 {
    margin: 0 0 0 10px;
    font-size: 1rem;
    font-weight: 700;
    span {
      font-size: 0.7rem;
      font-weight: 300;
    }
  }
}
h2,
h3 {
  margin: 0;
  margin-bottom: 20px;
}
.disclaimer {
  margin: 0 0 20px 0;
  font-size: 0.9rem;
  color: #909090;
}
.play-btns-container {
  position: fixed;
  z-index: 1000;
  bottom: 5px;
  right: 5px;
  padding: 20px 20px 0 0;
  overflow: hidden;
  button {
    background-color: transparent;
    border: none;
    border-radius: 500px;
    background-color: var(--mainColor);
    display: flex;
    justify-content: center;
    align-items: center;
    border: solid 2px var(--mainColorDarker);
    box-shadow: 0 2px 3px rgba(0, 0, 0, 0.2);
    &:hover {
      background-color: var(--mainColorLigher);
    }
  }
}
#clear-song-btn {
  width: 40px;
  height: 40px;
  position: absolute;
  cursor: pointer;
  z-index: 10;
  top: 0;
  right: 0;
  span {
    display: block;
    width: 100%;
    height: auto;
    img {
      display: block;
      width: 100%;
      height: auto;
    }
  }
}
#play-song-btn {
  cursor: pointer;
  width: 70px;
  height: 70px;
  background-color: var(--mainColorLigher);
  span.icon-play {
    display: none;
  }
  span.icon-stop {
    display: block;
  }
  &.active-play {
    background-color: var(--mainColor);
    &:hover {
      background-color: var(--mainColorLigher);
    }
    span.icon-play {
      display: block;
    }
    span.icon-stop {
      display: none;
    }
  }
}
input[type="range"] {
  display: block;
  width: 100%;
}
select {
  display: block;
  width: 100%;
  padding: 10px;
  border: solid 1px var(--mainColor);
  border-radius: 10px;
  background-color: #1a1a1a;
  font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
  margin-bottom: 20px;
}
input[type="range"]::-webkit-slider-runnable-track {
  background-color: #212121;
  border-radius: 50px;
}
input[type="range"]::-moz-slider-runnable-track {
  background-color: #212121;
  border-radius: 50px;
}
.board {
  display: flex;
  padding-top: 10px;
  margin: auto;
  & > :nth-child(1) {
    flex: 1;
    overflow-x: scroll;
  }
  .step-sequencer__main-container {
    min-width: 600px;
    padding: 10px;
    .notes-row {
      display: flex;
      button {
        flex: 1;
        position: relative;
        height: 100px;
        cursor: pointer;
        background-color: rgb(51, 51, 51);
        will-change: background-color;
        transition: background-color 0.1s ease;
        padding: 10px;
        border: solid 1px #191919;
        border-radius: 5px;
        &.step-active {
          background-color: rgb(63, 63, 63);
        }
        &.active {
          background-color: var(--mainColor);
          &.step-active {
            background-color: var(--mainColorLigher);
          }
        }
        span {
          pointer-events: none;
          font-size: 0.9em;
          position: absolute;
          top: 0;
          left: 0;
          background-color: #222222;
          padding: 5px;
          border-bottom-right-radius: 5px;
        }
        input[type="range"] {
          display: block;
          width: 80%;
          position: absolute;
          bottom: 0;
          left: 10%;
        }
      }
    }
  }
  .tempo-and-synth-props {
    min-width: 300px;
    padding: 20px 30px 30px 40px;
    input[type="range"] {
      margin-bottom: 20px;
    }
    input[type="range"]::-webkit-slider-runnable-track {
      background-color: #161616;
    }
    input[type="range"]::-moz-slider-runnable-track {
      background-color: #161616;
    }
  }
}
label {
  display: block;
  margin-bottom: 20px;
}
@media screen and (max-width: 1000px) {
  .board {
    display: block;
  }
  .step-sequencer__main-container {
    .notes-row {
      min-height: 80px;
      max-height: 400px;
      button {
        flex: none;
        width: 12.5%;
        height: 100%;
      }
    }
  }
}

Puedes dedicar unos minutos a analizar y editar estas instrucciones para ajustarlas a tu proyecto.

Finalmente, pasamos a la creación del secuenciador con JavaScript y la librería ToneJs.

He dividido el programa en tres archivos JS:

  • StepSequencer: Una clase capaz de generar el grid con los botones 
  • SynthConfigurator: Una clase que conecta los inputs del sidebar con los parámetros del sintetizador.
  • main.js: El script principal que conecta toda la lógica entre sí.

Prepara el archivo StepSequencer.js dentro del directorio “src”, y vayamos método a método viendo cómo funciona.

class StepSequencer {
  constructor(containerSelector) {
    this.onClickButtonCallback = undefined;
    this.container = document.querySelector(containerSelector);
    this.mainContainer = document.createElement("div");
    this.mainContainer.classList.add("step-sequencer__main-container");
    this.container.appendChild(this.mainContainer);
    this.tracks = 8;
    this.selectedNotes = Array.from({ length: this.tracks }, () => []);
    this.createGrid();
    this.addListeners();
  }
}
export default StepSequencer;

Definimos la clase y en el constructor declaramos una serie de propiedades.

  • onClickButtonCallback: Almacenará una función de “callback” que se ejecutará cada vez que un usuario hace click en cualquiera de las teclas del grid principal.
  • container: una referencia al elemento del DOM donde construiremos dinámicamente el secuenciador.
  • mainContainer: Un nodo del DOM adicional, al que vamos a asignar la clase “step-sequencer__main-container” y que será hijo de “container”
  • tracks: El número de columnas de nuestro secuenciador, por defecto 8.
  • selectedNotes: Preparamos una array de dos dimensiones donde almacenaremos qué notas están activas en cada columna del grid.

En el propio constructor llamamos a createGrid y addListeners, métodos que definiremos más adelante.

createGrid() {
  const notes = ["C", "D", "E", "F", "G", "A", "B"];
  for (let i = 0, j = notes.length; i < j; i++) {
    const note = notes[i];
    const row = document.createElement("div");
    row.classList.add("notes-row");
    for (let n = 0; n < this.tracks; n++) {
      const noteBtn = document.createElement("button");
      const octaveSlider = document.createElement("input");
      octaveSlider.setAttribute("type", "range");
      octaveSlider.setAttribute("min", 2);
      octaveSlider.setAttribute("max", 5);
      octaveSlider.setAttribute("step", 1);
      octaveSlider.setAttribute("value", 4);
      noteBtn.setAttribute("data-note-name", note);
      noteBtn.setAttribute("data-note-position", n);
      noteBtn.setAttribute("data-octave", 4);
      noteBtn.innerHTML = `<span>${note}4</span>`;
      noteBtn.appendChild(octaveSlider);
      row.appendChild(noteBtn);
      this.mainContainer.appendChild(row);
    }
  }
}

CreateGrid se encarga de generar las filas y los botones en función del número de notas musicales de una escala y del número de columnas que hemos asignado en “tracks”.

Observa como cada botón guarda en los atributos “data-note-name”, “data-note-position” y “data-octave”, la nota, la posición que ocupa en el grid y la escala a la que pertenece.

También destacar que además vamos a incluir un “slider” en cada botón para modificar la octava a la que pertenece la nota asignada.

addListeners() {
  this.mainContainer.addEventListener(
    "click",
    this.handleOnPressNote.bind(this)
  );
  this.mainContainer.addEventListener(
    "input",
    this.handleOnChangeOctave.bind(this)
  );
}

Con el método addListeners capturamos la interacción de los eventos “click” y “input” sobre el contenedor principal. Vinculamos dichos eventos a los métodos “handleOnPressNote” y “handleOnChangeOctave” respectivamente.

La técnica de asignar el evento a todo el contenedor en vez de a cada nodo del DOM se llama “event delegation”.

handleOnPressNote(e) {
  const button = e.target;
  const noteName = button.dataset.noteName;
  const octave = button.dataset.octave;
  const notePosition = button.dataset.notePosition;
  if (!noteName) return;
  const noteIndex = this.selectedNotes[notePosition].findIndex(
    (note) => note.noteName === noteName
  );
  if (typeof this.onClickButtonCallback === "function") {
    this.onClickButtonCallback(`${noteName}${octave}`, noteIndex >= 0);
  }
  if (noteIndex >= 0) {
    button.classList.remove("active");
    return this.selectedNotes[notePosition].splice(noteIndex, 1);
  }
  button.classList.add("active");
  this.selectedNotes[notePosition].push({ noteName, octave });
}

La lógica encerrada en este método se encarga de actualizar la propiedad “this.selectedNotes” añadiendo o quitando la nota sobre la que se ha hecho click. Para ello tiene que tener en cuenta la posición que ocupa en el grid y el nombre de la nota.

Aprovechamos también para modificar los estilos del botón seleccionado, y (si existe) llamamos al callback “onClickButtonCallback” pasando la nota y su octava.

handleOnChangeOctave(e) {
  e.preventDefault();
  const slider = e.target;
  if (slider.nodeName.toLowerCase() !== "input") return;
  const btn = slider.closest("button");
  const noteName = btn.dataset.noteName;
  const notePosition = Number(btn.dataset.notePosition);
  const indexNote = this.selectedNotes[notePosition].findIndex(
    (note) => note.noteName === noteName
  );
  if (indexNote >= 0) {
    this.selectedNotes[notePosition][indexNote].octave = slider.value;
  }
  btn.setAttribute("data-octave", slider.value);
  btn.querySelector("span").innerHTML = `${noteName}${slider.value}`;
}

HandleOnChangeOctave se ejecuta cada vez que el usuario modifica el valor del input de tipo “range” de cualquier nota.

Asignamos la nueva octava al atributo “data-octave” del botón que enmarca el slider, y si es preciso actualizamos la propiedad “selectedNotes”.

getSelectedNotes() {
  return this.selectedNotes;
}

Una sencilla función para obtener “getSelectedNotes” desde una capa superior de nuestro programa.

getNotesByStep(step) {
  if (step > this.tracks - 1 || step < 0) return;
  return this.selectedNotes[step];
}

Otro método “getter” parecido al anterior, para obtener solo las notas activas de una determinada columna del grid.

updateButtonsStepState(step) {
  this.container
    .querySelectorAll(`button[data-note-position]`)
    .forEach((btn) => {
      const stepBtn = Number(btn.dataset.notePosition);
      if (stepBtn === step) {
        btn.classList.add("step-active");
        return;
      }
      btn.classList.remove("step-active");
    });
}

Usaremos “updateButtonsStepState” para actualizar las clases CSS de los botones de cada columna para resaltar la columna activa en cada paso del secuenciador.

onClickButton(cb) {
  this.onClickButtonCallback = cb;
}

Método a través del cual permitimos a la capa superior del programa vincular una función de callback a la propiedad “onClickButtonCallback”.

clear() {
  this.mainContainer.querySelectorAll("button").forEach((btn) => {
    btn.classList.remove("active");
  });
  this.selectedNotes.forEach((_, i) => {
    this.selectedNotes[i] = [];
  });
}

Un simple método para resetear las clases de los botones del grid, así como las notas seleccionadas y guardadas en selectedNotes

Seguimos con la clase SynthConfigurator, crea un archivo con el mismo nombre dentro de la carpeta “src” y agrega el siguiente código.

import * as Tone from "tone";
const { Synth } = Tone;
class SynthConfigurator {
  constructor() {
    this.synths = [];
    this.createSynths(7);
    this.addListeners();
  }
}
export default SynthConfigurator;

Como ves, este servicio comienza importando la librería Tone y extrayendo solo la clase Synth.

En el constructor declaramos la propiedad “synths” donde más adelante almacenaremos varias instancias de un mismo sintetizador.

Llamamos a los métodos createSynths y addListeners.

createSynths(numSynths) {
  for (let i = 0; i < numSynths; i++) {
    this.synths.push(
      new Synth({
        oscillator: {
          type: "triangle8",
        },
        envelope: {
          attack: 0.1,
          decay: 0.1,
          sustain: 0.1,
          release: 0.1,
        },
      }).toDestination()
    );
  }
}

CreateSynths recibe el número de sintetizadores que queremos tener a nuestra disposición en cada columna, osea uno para cada nota.

Recorremos ese número e instanciamos un objeto de la clase “Synth” conectado a la salida de audio con el método “toDestination”.

Fíjate en las propiedades de inicio de estos sintes.

playNote(note, duration) {
  this.synths[0].triggerAttackRelease(note, duration);
}
playNotes(notes, duration) {
  for (let i = 0, j = notes.length; i < j; i++) {
    const note = notes[i];
    this.synths[i].triggerAttackRelease(
      `${note.noteName}${note.octave}`,
      duration
    );
  }
}

A través de de playNote y playNotes ejecutamos el método “triggerAttackRelease” haciendo uso de los argumentos recibidos.

La única diferencia entre estos dos métodos es que el primero solo reproduce la nota indicada en el primer sintetizador del array. Mientras que el segundo lo hace para todos los sintes dada una array de notas posibles.

changeOscillatorType = (type) => {
  this.synths.forEach((synth) => {
    synth.oscillator.type = type;
  });
};

ChangeOscillatorType se encarga de actualizar el tipo de onda asignada a todos los sintetizadores creados.

addListeners() {
  document
    .querySelector(".envelop-sliders")
    .addEventListener("input", (e) => {
      const slider = e.target;
      if (slider.nodeName.toLowerCase() !== "input") return;
      this.synths.forEach((synth) => {
        const envelopPropertyName = slider.name;
        const envelopPropertyValue = Number(slider.value);
        synth.envelope[envelopPropertyName] = envelopPropertyValue;
      });
    });
  document
    .querySelector("#oscillator-type")
    .addEventListener("change", (e) => {
      const type = e.target.value;
      this.changeOscillatorType(type);
    });
}

Finalizamos la clase SynthConfiguator declarando un método para connectar la escucha de eventos del sidebar construido en el HTML, con las propiedades de los sintetizadores.

Ya tan solo queda combinar las dos clases en un mismo script, llamaremos a este archivo “main.js”

import "./style.scss";
import * as Tone from "tone";
import StepSequencer from "./src/StepSequencer";
import SynthConfigurator from "./src/SynthConfigurator";
const { Transport, start, Destination } = Tone;

En las primeras líneas importamos las dependencias necesarias y obtenemos los recursos “Transport”, “start” y “Destination” de ToneJs.

const playSongBtn = document.querySelector("#play-song-btn");
const clearSongBtn = document.querySelector("#clear-song-btn");
const tempoSlider = document.querySelector("#tempo");

Guardamos en variables una serie de referencias a elementos del DOM.

const stepSequencer = new StepSequencer("#step-sequencer-container");
const synthConfigurator = new SynthConfigurator();

Instanciamos un objeto de cada una de nuestras clases. A la clase StepSequencer le pasamos un string con el id del contenedor donde cargar el secuenciador.

let index = 0;
let tempo = 120;

Las variables index y tempo determinarán el índice de la columna que se debe reproducir y la velocidad con la que debe hacerlo, respectivamente.

const playColumn = (time) => {
  const notes = stepSequencer.getNotesByStep(index);
  stepSequencer.updateButtonsStepState(index);
  synthConfigurator.playNotes(notes, "8n");
  index = (index + 1) % stepSequencer.tracks;
};

Declaramos la función playColumn donde se ejecutarán las siguientes instrucciones:

  • Obtenemos las notas seleccionadas en una columna del grid a partir del índice.
  • Actualizamos el estado de los botones de dicha columna.
  • Reproducimos las notas obtenidas a través de los sintetizadores
  • Actualizamos el valor del siguiente índice.
const toggleSong = async () => {
  if (Transport.state === "started") {
    Transport.cancel();
    Transport.stop();
    index = 0;
    playSongBtn.classList.add("active-play");
    stepSequencer.updateButtonsStepState(-1);
    return;
  }
  await Transport.start();
  Transport.scheduleRepeat(playColumn, "8n");
  Transport.bpm.value = tempo;
  start();
  Destination.volume.rampTo(-10, 0.001);
  playSongBtn.classList.remove("active-play");
};

Haciendo uso de la propiedad “state” de Transport comprobamos si se está reproduciendo nuestra composición o no.

En caso de que sí, detenemos la reproducción con “cancel” y “stop”, seteamos la variable index a cero y actualizamos la interfaz acorde a este estado.

Por contra, iniciamos Transport y programamos un repetidor con el método «scheduleRepeat” que llamará a “playColumn” cada vez.

También ajustamos el tempo según la variable, nivelamos el volumen con Destination, y actualizamos el aspecto del botón play.

const onChangeSilderTempo = (e) => {
  tempo = e.target.value;
  Transport.bpm.value = tempo;
};

Una simple función de retorno encargada de redefinir el valor de los bpm (o sea el tempo) de la composición.

playSongBtn.addEventListener("click", toggleSong);
clearSongBtn.addEventListener("click", stepSequencer.clear.bind(stepSequencer));
tempoSlider.addEventListener("change", onChangeSilderTempo);

Terminamos el proyecto asignando la escucha de eventos a los elementos de la interfaz como los botones de reproducción y reset, así como el slider del tempo.

stepSequencer.onClickButton((note, active) => {
  if (Transport.state === "started" || active) return;
  Destination.volume.rampTo(-10, 0.001);
  synthConfigurator.playNote(`${note}`, "4n");
});

Aunque esto es opcional, también puedes programar que se reproduzca la nota en el momento de seleccionar una nota en el grid. Esto dará un mejor feedback al usuario y mejorará su experiencia.

Sonoriza tus proyectos y aplicaciones web con ToneJs.

La librería ToneJs ofrece muchísimos otros recursos con los que programar con JavaScript, detallar absolutamente todo sería demasiado largo, así que he preferido destacar lo que a mí me ha parecido más relevante o interesante.

No dudes en acudir a la documentación oficial para ampliar tu aprendizaje, te dejo una serie de enlaces a continuación.

Por cierto, si no se te ocurre ningún proyecto con el que implementar ToneJs aquí tienes un pequeño reto. Trata de sonorizar el juego que hicimos hace un tiempo con código HTML5

Crear juegos con PhaserJs

Solo me queda agradecerte que leas estas publicaciones, espero que te sean de utilidad, hasta la próxima.

Deja un comentario