Con un poco de conocimiento de teoría musical, podemos usar HTML, CSS y JavaScript normales, sin bibliotecas ni muestras de audio, para crear un instrumento digital simple. Pongámoslo en práctica y exploremos un método para crear un sintetizador digital que se pueda reproducir y alojar en Internet.
Esto es lo que estamos haciendo:
Usaremos la API AudioContext para crear nuestros sonidos digitalmente, sin recurrir a muestras. Pero primero, trabajemos en la apariencia del teclado.
La estructura HTML
Admitiremos un teclado occidental estándar en el que cada letra entre A
y ;
corresponde a una nota natural jugable (las teclas blancas), mientras que la fila de arriba se puede usar para los sostenidos y bemoles (las teclas negras). Esto significa que nuestro teclado cubre poco más de una octava, comenzando en C₃ y terminando en E₄. (Para cualquiera que no esté familiarizado con la notación musical, los números de subíndice indican la octava).
Una cosa útil que podemos hacer es almacenar el valor de la nota en un archivo personalizado. note
atributo por lo que es fácil de acceder en nuestro JavaScript. Imprimiré las letras del teclado de la computadora, para ayudar a nuestros usuarios a entender qué presionar.
<ul id="keyboard">
<li note="C" class="white">A</li>
<li note="C#" class="black">W</li>
<li note="D" class="white offset">S</li>
<li note="D#" class="black">E</li>
<li note="E" class="white offset">D</li>
<li note="F" class="white">F</li>
<li note="F#" class="black">T</li>
<li note="G" class="white offset">G</li>
<li note="G#" class="black">Y</li>
<li note="A" class="white offset">H</li>
<li note="A#" class="black">U</li>
<li note="B" class="white offset">J</li>
<li note="C2" class="white">K</li>
<li note="C#2" class="black">O</li>
<li note="D2" class="white offset">L</li>
<li note="D#2" class="black">P</li>
<li note="E2" class="white offset">;</li>
</ul>
El estilo CSS
Comenzaremos nuestro CSS con algunos repetitivos:
html {
box-sizing: border-box;
}
*,
*:before,
*:after {
box-sizing: inherit;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}
body {
margin: 0;
}
Especifiquemos variables CSS para algunos de los colores que usaremos. ¡Siéntete libre de cambiarlos a lo que prefieras!
:root {
--keyboard: hsl(300, 100%, 16%);
--keyboard-shadow: hsla(19, 50%, 66%, 0.2);
--keyboard-border: hsl(20, 91%, 5%);
--black-10: hsla(0, 0%, 0%, 0.1);
--black-20: hsla(0, 0%, 0%, 0.2);
--black-30: hsla(0, 0%, 0%, 0.3);
--black-50: hsla(0, 0%, 0%, 0.5);
--black-60: hsla(0, 0%, 0%, 0.6);
--white-20: hsla(0, 0%, 100%, 0.2);
--white-50: hsla(0, 0%, 100%, 0.5);
--white-80: hsla(0, 0%, 100%, 0.8);
}
En particular, cambiando el --keyboard
y --keyboard-border
Las variables cambiarán el resultado final dramáticamente.
Para diseñar las teclas y el teclado, especialmente en los estados presionados, debo gran parte de mi inspiración a este CodePen de zastrow. Primero, especificamos el CSS compartido por todas las claves:
.white,
.black {
position: relative;
float: left;
display: flex;
justify-content: center;
align-items: flex-end;
padding: 0.5rem 0;
user-select: none;
cursor: pointer;
}
El uso de un radio de borde específico en la primera y última clave ayuda a que el diseño se vea más orgánico. Sin redondear, las esquinas superior izquierda y superior derecha de las teclas se ven un poco antinaturales. Aquí hay un diseño final, menos cualquier redondeo adicional en la primera y la última clave.
Agreguemos algo de CSS para mejorar esto.
#keyboard li:first-child {
border-radius: 5px 0 5px 5px;
}
#keyboard li:last-child {
border-radius: 0 5px 5px 5px;
}
La diferencia es sutil pero efectiva:
A continuación, aplicamos los estilos que diferencian las teclas blancas y negras. Observe que las teclas blancas tienen un z-index
de 1
y las teclas negras tienen un z-index
de 2
:
.white {
height: 12.5rem;
width: 3.5rem;
z-index: 1;
border-left: 1px solid hsl(0, 0%, 73%);
border-bottom: 1px solid hsl(0, 0%, 73%);
border-radius: 0 0 5px 5px;
box-shadow: -1px 0 0 var(--white-80) inset, 0 0 5px hsl(0, 0%, 80%) inset,
0 0 3px var(--black-20);
background: linear-gradient(to bottom, hsl(0, 0%, 93%) 0%, white 100%);
color: var(--black-30);
}
.black {
height: 8rem;
width: 2rem;
margin: 0 0 0 -1rem;
z-index: 2;
border: 1px solid black;
border-radius: 0 0 3px 3px;
box-shadow: -1px -1px 2px var(--white-20) inset,
0 -5px 2px 3px var(--black-60) inset, 0 2px 4px var(--black-50);
background: linear-gradient(45deg, hsl(0, 0%, 13%) 0%, hsl(0, 0%, 33%) 100%);
color: var(--white-50);
}
Cuando se presiona una tecla, usaremos JavaScript para agregar una clase de "pressed"
a lo relevante li
elemento. Por ahora, podemos probar esto agregando la clase directamente a nuestros elementos HTML.
.white.pressed {
border-top: 1px solid hsl(0, 0%, 47%);
border-left: 1px solid hsl(0, 0%, 60%);
border-bottom: 1px solid hsl(0, 0%, 60%);
box-shadow: 2px 0 3px var(--black-10) inset,
-5px 5px 20px var(--black-20) inset, 0 0 3px var(--black-20);
background: linear-gradient(to bottom, white 0%, hsl(0, 0%, 91%) 100%);
outline: none;
}
.black.pressed {
box-shadow: -1px -1px 2px var(--white-20) inset,
0 -2px 2px 3px var(--black-60) inset, 0 1px 2px var(--black-50);
background: linear-gradient(
to right,
hsl(0, 0%, 27%) 0%,
hsl(0, 0%, 13%) 100%
);
outline: none;
}
Ciertas teclas blancas deben moverse hacia la izquierda para que queden debajo de las teclas negras. Les damos a estos una clase de "offset"
en nuestro HTML, para que podamos mantener el CSS simple:
.offset {
margin: 0 0 0 -1rem;
}
Si has seguido el CSS hasta este punto, deberías tener algo como esto:
Finalmente, diseñaremos el propio teclado:
#keyboard {
height: 15.25rem;
width: 41rem;
margin: 0.5rem auto;
padding: 3rem 0 0 3rem;
position: relative;
border: 1px solid var(--keyboard-border);
border-radius: 1rem;
background-color: var(--keyboard);
box-shadow: 0 0 50px var(--black-50) inset, 0 1px var(--keyboard-shadow) inset,
0 5px 15px var(--black-50);
}
Ahora tenemos un teclado CSS atractivo, pero no es interactivo y no emite ningún sonido. Para hacer esto, necesitaremos JavaScript.
JavaScript musical
Para crear los sonidos de nuestro sintetizador, no queremos depender de muestras de audio, ¡eso sería hacer trampa! En su lugar, podemos usar la API AudioContext de la web, que tiene herramientas que pueden ayudarnos a convertir formas de onda digitales en sonidos.
Para crear un nuevo contexto de audio, podemos usar:
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
Antes de usar nuestro audioContext
será útil seleccionar todos nuestros elementos de nota en el HTML. Podemos usar este ayudante para consultar fácilmente los elementos:
const getElementByNote = (note) =>
note && document.querySelector(`[note="${note}"]`);
Luego podemos almacenar los elementos en un objeto, donde la tecla del objeto es la tecla que un usuario presionaría en el teclado para tocar esa nota.
const keys = {
A: { element: getElementByNote("C"), note: "C", octaveOffset: 0 },
W: { element: getElementByNote("C#"), note: "C#", octaveOffset: 0 },
S: { element: getElementByNote("D"), note: "D", octaveOffset: 0 },
E: { element: getElementByNote("D#"), note: "D#", octaveOffset: 0 },
D: { element: getElementByNote("E"), note: "E", octaveOffset: 0 },
F: { element: getElementByNote("F"), note: "F", octaveOffset: 0 },
T: { element: getElementByNote("F#"), note: "F#", octaveOffset: 0 },
G: { element: getElementByNote("G"), note: "G", octaveOffset: 0 },
Y: { element: getElementByNote("G#"), note: "G#", octaveOffset: 0 },
H: { element: getElementByNote("A"), note: "A", octaveOffset: 1 },
U: { element: getElementByNote("A#"), note: "A#", octaveOffset: 1 },
J: { element: getElementByNote("B"), note: "B", octaveOffset: 1 },
K: { element: getElementByNote("C2"), note: "C", octaveOffset: 1 },
O: { element: getElementByNote("C#2"), note: "C#", octaveOffset: 1 },
L: { element: getElementByNote("D2"), note: "D", octaveOffset: 1 },
P: { element: getElementByNote("D#2"), note: "D#", octaveOffset: 1 },
semicolon: { element: getElementByNote("E2"), note: "E", octaveOffset: 1 }
};
Encontré útil especificar el nombre de la nota aquí, así como un octaveOffset
, que necesitaremos cuando trabajemos en el terreno de juego.
Necesitamos proporcionar un tono en Hz. La ecuación utilizada para determinar el tono es x * 2^(y / 12)
donde x
es el valor Hz de una nota elegida, generalmente A₄, que tiene un tono de 440 Hz, y y
es el número de notas por encima o por debajo de ese tono.
Eso nos da algo como esto en el código:
const getHz = (note = "A", octave = 4) => {
const A4 = 440;
let N = 0;
switch (note) {
default:
case "A":
N = 0;
break;
case "A#":
case "Bb":
N = 1;
break;
case "B":
N = 2;
break;
case "C":
N = 3;
break;
case "C#":
case "Db":
N = 4;
break;
case "D":
N = 5;
break;
case "D#":
case "Eb":
N = 6;
break;
case "E":
N = 7;
break;
case "F":
N = 8;
break;
case "F#":
case "Gb":
N = 9;
break;
case "G":
N = 10;
break;
case "G#":
case "Ab":
N = 11;
break;
}
N += 12 * (octave - 4);
return A4 * Math.pow(2, N / 12);
};
Aunque solo usamos objetos punzantes en el resto de nuestro código, decidí incluir bemoles aquí también, por lo que esta función podría reutilizarse fácilmente en un contexto diferente.
Para cualquiera que no esté seguro acerca de la notación musical, las notas A#
y Bb
, por ejemplo, describen exactamente el mismo tono. Podríamos elegir uno sobre otro si estamos tocando en una clave en particular, pero para nuestros propósitos, la diferencia no importa.
tocando notas
¡Estamos listos para empezar a tocar algunas notas!
Primero, necesitamos alguna forma de saber qué notas se están reproduciendo en un momento dado. usemos un Map
para hacer esto, ya que su restricción clave única puede ayudar a evitar que activemos la misma nota varias veces en una sola pulsación. Además, un usuario solo puede hacer clic en una tecla a la vez, por lo que podemos almacenarla como una cadena.
const pressedNotes = new Map();
let clickedKey = "";
Necesitamos dos funciones, una para tocar una tecla, que activaremos keydown
omousedown
– y otro para dejar de tocar la tecla – que activaremos en keyup
o mouseup
.
Cada tecla se reproducirá en su propio oscilador con su propio nodo de ganancia (utilizado para controlar el volumen) y su propio tipo de forma de onda (utilizado para determinar el timbre del sonido). estoy optando por un "triangle"
forma de onda, pero puede usar lo que prefiera de "sine"
, "triangle"
, "sawtooth"
y "square"
. La especificación ofrece un poco más de información sobre estos valores.
const playKey = (key) => {
if (!keys[key]) {
return;
}
const osc = audioContext.createOscillator();
const noteGainNode = audioContext.createGain();
noteGainNode.connect(audioContext.destination);
noteGainNode.gain.value = 0.5;
osc.connect(noteGainNode);
osc.type = "triangle";
const freq = getHz(keys[key].note, (keys[key].octaveOffset || 0) + 4);
if (Number.isFinite(freq)) {
osc.frequency.value = freq;
}
keys[key].element.classList.add("pressed");
pressedNotes.set(key, osc);
pressedNotes.get(key).start();
};
A nuestro sonido le vendría bien algo de refinamiento. ¡Por el momento, tiene una cualidad de zumbador de microondas ligeramente penetrante! Pero esto es suficiente para empezar. ¡Regresaremos y haremos algunos ajustes al final!
Detener una tecla es una tarea más sencilla. Necesitamos dejar que cada nota “suene” durante un tiempo después de que el usuario levante el dedo (dos segundos es lo correcto), así como hacer el cambio visual necesario.
const stopKey = (key) => {
if (!keys[key]) {
return;
}
keys[key].element.classList.remove("pressed");
const osc = pressedNotes.get(key);
if (osc) {
setTimeout(() => {
osc.stop();
}, 2000);
pressedNotes.delete(key);
}
};
Todo lo que queda es agregar nuestros detectores de eventos:
document.addEventListener("keydown", (e) => {
const eventKey = e.key.toUpperCase();
const key = eventKey === ";" ? "semicolon" : eventKey;
if (!key || pressedNotes.get(key)) {
return;
}
playKey(key);
});
document.addEventListener("keyup", (e) => {
const eventKey = e.key.toUpperCase();
const key = eventKey === ";" ? "semicolon" : eventKey;
if (!key) {
return;
}
stopKey(key);
});
for (const [key, { element }] of Object.entries(keys)) {
element.addEventListener("mousedown", () => {
playKey(key);
clickedKey = key;
});
}
document.addEventListener("mouseup", () => {
stopKey(clickedKey);
});
Tenga en cuenta que, si bien la mayoría de nuestros detectores de eventos se agregan al código HTML document
, podemos usar nuestro keys
object para agregar detectores de clics a los elementos específicos que ya hemos consultado. También debemos darle un tratamiento especial a nuestra nota más alta, asegurándonos de convertir el ";"
clave en el deletreado "semicolon"
utilizado en nuestro keys
objeto.
¡Ahora podemos tocar las teclas en nuestro sintetizador! Solo hay un problema. ¡El sonido sigue siendo bastante estridente! Podríamos querer derribar la octava del teclado cambiando la expresión que asignamos a la freq
constante:
const freq = getHz(keys[key].note, (keys[key].octaveOffset || 0) + 3);
También puede escuchar un “clic” al principio y al final del sonido. Podemos resolver esto desvaneciendo rápidamente y gradualmente desapareciendo de cada sonido.
En producción musical, usamos el término ataque para describir la rapidez con la que un sonido pasa de la nada a su volumen máximo, y “liberar” para describir el tiempo que tarda un sonido en desvanecerse una vez que ya no se reproduce. Otro concepto útil es decaer, el tiempo que tarda el sonido en pasar de su volumen máximo a su volumen sostenido. Afortunadamente, nuestro noteGainNode
tiene un gain
propiedad con un método llamado exponentialRampToValueAtTime
, que podemos usar para controlar el ataque, la liberación y el decaimiento. Si reemplazamos nuestro anterior playKey
función con la siguiente, obtendremos un sonido mucho más agradable:
const playKey = (key) => {
if (!keys[key]) {
return;
}
const osc = audioContext.createOscillator();
const noteGainNode = audioContext.createGain();
noteGainNode.connect(audioContext.destination);
const zeroGain = 0.00001;
const maxGain = 0.5;
const sustainedGain = 0.001;
noteGainNode.gain.value = zeroGain;
const setAttack = () =>
noteGainNode.gain.exponentialRampToValueAtTime(
maxGain,
audioContext.currentTime + 0.01
);
const setDecay = () =>
noteGainNode.gain.exponentialRampToValueAtTime(
sustainedGain,
audioContext.currentTime + 1
);
const setRelease = () =>
noteGainNode.gain.exponentialRampToValueAtTime(
zeroGain,
audioContext.currentTime + 2
);
setAttack();
setDecay();
setRelease();
osc.connect(noteGainNode);
osc.type = "triangle";
const freq = getHz(keys[key].note, (keys[key].octaveOffset || 0) - 1);
if (Number.isFinite(freq)) {
osc.frequency.value = freq;
}
keys[key].element.classList.add("pressed");
pressedNotes.set(key, osc);
pressedNotes.get(key).start();
};
¡Deberíamos tener un sintetizador en funcionamiento y listo para la web en este punto!
Los números dentro de nuestro setAttack
, setDecay
y setRelease
Las funciones pueden parecer un poco aleatorias, pero en realidad son solo elecciones estilísticas. Intente cambiarlos y vea qué sucede con el sonido. ¡Puedes terminar con algo que prefieras!
Si está interesado en llevar el proyecto más allá, hay muchas formas de mejorarlo. ¿Quizás un control de volumen, una forma de cambiar entre octavas o una forma de elegir entre formas de onda? Podríamos añadir reverberación o un filtro de paso bajo. ¿O tal vez cada sonido podría estar compuesto por múltiples osciladores?
Para cualquier persona interesada en comprender más acerca de cómo implementar conceptos de teoría musical en la web, recomiendo consultar el código fuente del paquete tonal npm.