Tutorial e exemplo: Teclado de Sintetizador Simples

Este artigo apresenta o código e uma demonstração funcional de um teclado que você pode tocar usando seu mouse. O teclado lhe permite alternar entre formas de onda padrões e customizadas. Esse exemplo utiliza das seguintes interfaces de Web API: AudioContext, OscillatorNode, PeriodicWave, e GainNode.

Já que OscillatorNode é baseado no AudioScheduledSourceNode, isso até certo ponto também é um exemplo pra isto.

O Teclado Visual

HTML

Existem três componentes primários para o display do nosso teclado virtual. O primeito do qual é o teclado musical em si. Nós extraimos em um par de elementos <div> aninhados para permitir a rolagem horizontal caso as teclas não encaixem na tela.

O Teclado

Primeiro, criamos o espaço no qual construiremos o teclado. Estaremos construindo o teclado programaticamente, considerando que ao fazer desse jeito teremos a flexibilidade de configurar cada tecla conforme determinamos as informações apropriadas para tecla correspondente. No nosso caso, pegamos a frequência de cada tecla através de uma tabela, mas poderia ser calculado de forma algoritmica também.

<div class="container">
  <div class="keyboard"></div>
</div>

<div> nomeado de "container" é a barra de rolagem que permite o teclado ser rolado horizontalmente se for largo demais para o espaço disponivel. As teclas em si serão inseridas no bloco de classe "keyboard".

A barra de opções

Abaixo do teclado, colocaremos alguns controles para configurar o camada. Por enquanto, teremos dois controles: Um para controlar o volume e outro para selecionar a forma de onda periodica usada ao gerar as notas.

O controle de volume

Primeiro criamos o <div> para conter a barra de opções, para ser personalizado conforme preciso. Então estabelecemos uma caixa que será apresentada no lado esquerdo da barra e colocar um rotulo e um elemento <input> do tipo "range". O elemento range será tipicamente apresentado como o controle da barra de rolagem ; configuramos ele para permitir qualquer valor entre 0.0 e 1.0 em cada posição.

<div class="settingsBar">
  <div class="left">
    <span>Volume: </span>
    <input type="range" min="0.0" max="1.0" step="0.01"
        value="0.5" list="volumes" name="volume">
    <datalist id="volumes">
      <option value="0.0" label="Mute">
      <option value="1.0" label="100%">
    </datalist>
  </div>

Especificamos um valor padrão de 0.5, e provemos um elemento <datalist> no qual é conectado ao range usando o atributo name para achar uma lista de opções cujo ID encaixa; nesse caso, o conjunto de informações é nomeado de "volume". isso nos permite prover um conjunto de valores comuns e strings especiais que o browser pode de forma opcional escolher mostrar de alguma maneira; e então atribuimos nomes aos valores 0.0 ("Mute") e 1.0 ("100%").

A seleção de forma de onda

E no lado da barra de configurações, colocamos um rótulo e um elemento <select> nomeado de "waveform" cujas opções correspondem as formas de onda disponiveis.

  <div class="right">
    <span>Current waveform: </span>
    <select name="waveform">
      <option value="sine">Sine</option>
      <option value="square" selected>Square</option>
      <option value="sawtooth">Sawtooth</option>
      <option value="triangle">Triangle</option>
      <option value="custom">Custom</option>
    </select>
  </div>
</div>

JavaScript

O código em JavaScript começa inicializando algumas váriaveis.

let audioContext = new (window.AudioContext || window.webkitAudioContext)();
let oscList = [];
let masterGainNode = null;
  1. audioContext é colocado para referenciar o objeto global AudioContext (ou webkitAudioContext se  necessário).
  2. oscillators está colocado para conter uma lista de todos os osciladores atualmente tocando. Ele começa nulo, afinal não há nenhum oscilador tocando ainda.
  3. masterGainNode é colocado como nulo; durante o processo de setup, ele será configurado para contar um GainNode no quall todos os osciladores irão se conectar para permitir o volume geral a ser controlado por apenas uma barra de rolagem.
let keyboard = document.querySelector(".keyboard");
let wavePicker = document.querySelector("select[name='waveform']");
let volumeControl = document.querySelector("input[name='volume']");

Referencias aos elementos que precisaremos acessar são obtidas através dp:

  • keyboard que é o elemento que irá alojar as teclas.
  • wavePicker é o elemento <select> usado para seleção da forma de onda das notas.
  • volumeControl É o elemento <input> (do tipo "range") usado para controlar o volume geral.
let noteFreq = null;
let customWaveform = null;
let sineTerms = null;
let cosineTerms = null;

Enfim, variaveis globais que serão usadas quando as formas de onda são criadas:

  • noteFreq será uma matriz de matrizes; cada matriz representa uma oitava, cada uma possuindo um valor nota daquela oitava. O valor de cada é a frequência, em Hertz, do tom da nota.
  • customWaveform será arrumado como um PeriodicWave descrevendo a forma de onda quando o usuário selecionar "Custom" na seleção de forma de onda.
  • sineTerms e cosineTerms será utilizado para guardar a informação para gerar a forma de onda; cada um irá conter uma matriz que será gerada caso o usuário escolha "Custom".

Criando a tabela de notas

A função createNoteTable() constrói a matriz noteFreq para conter uma matriz de objetos representando cada oitava. Cada oitava, possui uma propriedade para cada nota nessa oitava; O nome dessa propriedade é o nome da nota (utilizando da notação em inglês, como "C" para representar "dó"), e o valor é a frequência, em Hertz, daquela nota.

function createNoteTable() {
  let noteFreq = [];
  for (let i=0; i< 9; i++) {
    noteFreq[i] = [];
  }

  noteFreq[0]["A"] = 27.500000000000000;
  noteFreq[0]["A#"] = 29.135235094880619;
  noteFreq[0]["B"] = 30.867706328507756;

  noteFreq[1]["C"] = 32.703195662574829;
  noteFreq[1]["C#"] = 34.647828872109012;
  noteFreq[1]["D"] = 36.708095989675945;
  noteFreq[1]["D#"] = 38.890872965260113;
  noteFreq[1]["E"] = 41.203444614108741;
  noteFreq[1]["F"] = 43.653528929125485;
  noteFreq[1]["F#"] = 46.249302838954299;
  noteFreq[1]["G"] = 48.999429497718661;
  noteFreq[1]["G#"] = 51.913087197493142;
  noteFreq[1]["A"] = 55.000000000000000;
  noteFreq[1]["A#"] = 58.270470189761239;
  noteFreq[1]["B"] = 61.735412657015513;

... várias oitavas não mostradas para manter breve ...

  noteFreq[7]["C"] = 2093.004522404789077;
  noteFreq[7]["C#"] = 2217.461047814976769;
  noteFreq[7]["D"] = 2349.318143339260482;
  noteFreq[7]["D#"] = 2489.015869776647285;
  noteFreq[7]["E"] = 2637.020455302959437;
  noteFreq[7]["F"] = 2793.825851464031075;
  noteFreq[7]["F#"] = 2959.955381693075191;
  noteFreq[7]["G"] = 3135.963487853994352;
  noteFreq[7]["G#"] = 3322.437580639561108;
  noteFreq[7]["A"] = 3520.000000000000000;
  noteFreq[7]["A#"] = 3729.310092144719331;
  noteFreq[7]["B"] = 3951.066410048992894;

  noteFreq[8]["C"] = 4186.009044809578154;
  return noteFreq;
}

O resultado é uma matriz, noteFreq, com um objeto para cada oitava. Cada objeto de oitava tem propriedades nomeadas nela onde a propriedade é o nome da nota com a notação em inglês (Como "C" para representar "dó") e o valor da propriedade é a frequência da nota em Hertz.. o objeto resultando se parece com isso:

Octave Notes
0 "A" ⇒ 27.5 "A#" ⇒ 29.14 "B" ⇒ 30.87
1 "C" ⇒ 32.70 "C#" ⇒ 34.65 "D" ⇒ 36.71 "D#" ⇒ 38.89 "E" ⇒ 41.20 "F" ⇒ 43.65 "F#" ⇒ 46.25 "G" ⇒ 49 "G#" ⇒ 51.9 "A" ⇒ 55 "A#" ⇒ 58.27 "B" ⇒ 61.74
2 . . .

Com esta tabela no lugar, podemos descobrir a frequência para uma dada nota em uma oitava particular relativamente fácil. Se queremos a frequência pra nota G# na primeira oitava, nós simplesmente usamos  noteFreq[1]["G#"] e conseguimos o valor 51.9 como resultado.

Os valores na tabela de exemplo acima foram arredondados para duas casas decimais.

Construindo o teclado

A função setup() é responsavel por construir o teclado e preparar a aplicação para tocar a música.

function setup() {
  noteFreq = createNoteTable();

  volumeControl.addEventListener("change", changeVolume, false);

  masterGainNode = audioContext.createGain();
  masterGainNode.connect(audioContext.destination);
  masterGainNode.gain.value = volumeControl.value;

  // Create the keys; skip any that are sharp or flat; for
  // our purposes we don't need them. Each octave is inserted
  // into a <div> of class "octave".

  noteFreq.forEach(function(keys, idx) {
    let keyList = Object.entries(keys);
    let octaveElem = document.createElement("div");
    octaveElem.className = "octave";

    keyList.forEach(function(key) {
      if (key[0].length == 1) {
        octaveElem.appendChild(createKey(key[0], idx, key[1]));
      }
    });

    keyboard.appendChild(octaveElem);
  });

  document.querySelector("div[data-note='B'][data-octave='5']").scrollIntoView(false);

  sineTerms = new Float32Array([0, 0, 1, 0, 1]);
  cosineTerms = new Float32Array(sineTerms.length);
  customWaveform = audioContext.createPeriodicWave(cosineTerms, sineTerms);

  for (i=0; i<9; i++) {
      oscList[i] = {};
  }
}

setup();
  1. A tabela que mapeia o nome e oitavas das notas para suas respectivas frequências é criado ao chamar createNoteTable().
  2. Um manipulador de eventos é estabelecido ao chamar nosso velho amigo addEventListener() para cuidar dos eventos do change no controle de ganho geral. Isso vai simplesmente atualizar o módulo de ganho de volume para o novo valor.
  3. Em seguida, nós replicamos cada oitava na tabela de frequências das notas. Para cada oitava, usamos Object.entries() para conseguir uma lista de notas daquela oitava.
  4. Criar um <div> para contar as notas daquela oitava (para ter um pouco de espaço entre as oitavas), e mudar o nome de classe para "octave".
  5. Para cada tecla na oitava, checamos para ver se o nome daquela nota há mais de um caractere. Nós pulamos essas, pois estamos deixando notas sustenidas de fora deste exemplo. Do contrário, chamamos createKey(), especificando uma string, oitava, e frequência. O elemento retornado é anexado na elemento da oitava criada no passo 4.
  6. Quando o elemento da oitava é construido, é então anexada ao teclado.
  7. Uma vez que o teclado foi construido, nós rolamos para nota "B" na quinta oitava; isso tem o efeito de garantir que o C médio é visivel junto das notas ao redor.
  8. Então uma forma de onda customizada é construida usando AudioContext.createPeriodicWave(). Essa forma de onda será usada toda vez que o usuário selecionar "Custom" da seleção de formas de onda.
  9. Enfim, a lista de osciladores é iniciada para garantir que está pronta para receber informação identificando quais osciladores estão associados com que teclas.

Criando uma tecla

A função createKey()  é chamada toda vez que queremos que uma tecla seja apresentada no nosso teclado virtual. Ela cria elementos da tecla e seu rótulo, adiciona informação dos atributos ao elemento para uso posterior, e coloca modificadores de eventos para os eventos que nos importam.

function createKey(note, octave, freq) {
  let keyElement = document.createElement("div");
  let labelElement = document.createElement("div");

  keyElement.className = "key";
  keyElement.dataset["octave"] = octave;
  keyElement.dataset["note"] = note;
  keyElement.dataset["frequency"] = freq;

  labelElement.innerHTML = note + "<sub>" + octave + "</sub>";
  keyElement.appendChild(labelElement);

  keyElement.addEventListener("mousedown", notePressed, false);
  keyElement.addEventListener("mouseup", noteReleased, false);
  keyElement.addEventListener("mouseover", notePressed, false);
  keyElement.addEventListener("mouseleave", noteReleased, false);

  return keyElement;
}

Após criar  os elementos representando as teclas e seus rótulos, nós configuramos o elemento das teclas ao configurar sua classe para "key" (Que estabelece a aparência). Então adicionamos atributos data-*  que contém a string da oitava da nota (attribute data-octave), representando a nota a ser tocada (attribute data-note), e frequência (attribute data-frequency) em Hertz. Isso irá nos permitir facilmente pegar informação conforme necessário ao cuidar de eventos.

Fazendo música

Tocando um tom

O trabalho da função playTone() é tocar um tom em uma dada frequência. Isso será usado pelo modificador para eventos acionados nas teclas do teclado, para que toquem as notas apropriadas.

function playTone(freq) {
  let osc = audioContext.createOscillator();
  osc.connect(masterGainNode);

  let type = wavePicker.options[wavePicker.selectedIndex].value;

  if (type == "custom") {
    osc.setPeriodicWave(customWaveform);
  } else {
    osc.type = type;
  }

  osc.frequency.value = freq;
  osc.start();

  return osc;
}

playTone() começa criando um novo OscillatorNode ao chamar o método AudioContext.createOscillator(). Então conectamos ele para o módulo de ganha geral ao chamar o novo método de osciladores OscillatorNode.connect() method;, Que determina ao oscilador onde ele irá mandar seu output. Ao fazer isso, mudar o valor do ganho do módulo de ganho geral irá mudar o volume de todos os toms gerados.

Então conseguimos o tipo de forma de onda para usar ao checar o valor do controle de seleção de formas de onda na barra de opções. Se o usuário estiver colocado como "custom", chamamos OscillatorNode.setPeriodicWave() para configurar os osciladores para usar nossa forma de onda customizada. Fazer isso automáticamente coloca o type do oscilador como custom. Se qualquer outro tipo de forma de onda é selecionado na seleção de formas de ondas, nós simplesmente colocamos os tipos de osciladores no valor da seleção, esse valor será um entre sine, square, triangle, e sawtooth.

A frequência do oscilador é colocada no valor especificado no paramêtro freq ao colocar o valor dos objetos Oscillator.frequency AudioParam . Então, enfim, o oscilador é iniciado e começa a produzir sons ao chamar o método AudioScheduledSourceNode.start() .

Tocando um tom

Quando o evento mousedown ou mouseover ocorre em uma tecla, queremos que toque a nota correspondente. A função notePressed() é usada como o modificador de eventos para esses eventos.

function notePressed(event) {
  if (event.buttons & 1) {
    let dataset = event.target.dataset;

    if (!dataset["pressed"]) {
      let octave = +dataset["octave"];
      oscList[octave][dataset["note"]] = playTone(dataset["frequency"]);
      dataset["pressed"] = "yes";
    }
  }
}

Começamos checando se o botão esquerdo do mouse é pressionado, por dois motivos. Primeiro, queremos que apenas o botão esquerdo acione as notas. Segundo, e mais importante, estamos usando isso para cuidar do mouseover para casos onde o usuário arrasta de tecla a tecla, e só queremos tocar uma nota se o mouse estiver pressionado quando entrar no elemento.

Se o botão do mouse estiver de fato sendo pressionado, recebemos o atributo de tecla pressionada dataset ; isso torna fácil o acesso das informações de atributo customizadas no elemento. Procuramos por um atributo data-pressed ; caso não haja um(o que indica que a nota não está tocando ainda), chamamos playTone() para começar a tocar a nota, passando no valor dos elementos do atributo data-frequency. O valor retornado do oscilador é guardado no oscList para refêrencia futura, e data-pressed é colocado como yes para indicar que a nota está tocando para que não iniciemos novamente na próxima vez que isso for chamado.

Parando um tom

A função noteReleased() é o modificador de eventos chamado quando o usuário solta o botão do mouse ou move o mouse para fora da tecla que ele está tocando.

function noteReleased(event) {
  let dataset = event.target.dataset;

  if (dataset && dataset["pressed"]) {
    let octave = +dataset["octave"];
    oscList[octave][dataset["note"]].stop();
    delete oscList[octave][dataset["note"]];
    delete dataset["pressed"];
  }
}

noteReleased() usa os atributos customizados data-octave and data-note  para procurar os osciladores das teclas, e então chama o método de oscilador stop() para parar de tocar a nota. Finalmente, a entrada oscList para nota é limpa e o atributo data-pressed é removido do elemento da tecla (como identificado pelo event.target), para indicar que a nota não está tocando no momento.

Mudando o volume geral

A barra de rolagem do volume na barra de opções dá uma simples interface para mudar o valor do ganho no módulo de ganho geral, então mudando o volume de todas as notas sendo tocadas. O metódo changeVolume() é o modificador do evento change na barra de rolagem.

function changeVolume(event) {
  masterGainNode.gain.value = volumeControl.value
}

Isso simplesmente coloca o valor do módulo de ganho geral gain AudioParam para o novo valor na barra de rolagem.

Resultado

Coloque tudo junto, o resultado é um simples e funcional teclado virtual que funciona com o clique:

Veja também