Este artigo foi traduzido por máquina.

Fator DirectX

Construindo osciladores de áudio para o Windows 8

Charles Petzold

Baixar o código de exemplo

Tenho vindo a fazer instrumentos de música eletrônica como um hobby por cerca de 35 anos. Comecei no final dos anos 1970 fiação até chips CMOS e TTL e mais tarde foi o caminho de software — primeiro com as extensões de multimídia para Windows em 1991 e, mais recentemente, com a biblioteca NAudio Windows Presentation Foundation (WPF) e a classe MediaStreamSource em Silverlight e Windows Phone 7. Apenas no ano passado, dediquei algumas parcelas do meu toque & Acesse coluna aplicativos para Windows Phone que reproduzir som e música.

Eu provavelmente deveria estar cansado por este tempo e talvez relutante para explorar outra API de geração de som. Mas eu não sou, porque eu acho que o Windows 8 é provavelmente a melhor plataforma de Windows ainda para a confecção de instrumentos musicais. Windows 8 combina uma API de áudio de alto desempenho — o componente XAudio2 do DirectX — com telas sensíveis ao toque de mão comprimidos. Esta combinação oferece muito potencial, e estou particularmente interessado em explorar como o toque pode ser explorada como uma interface subtil e íntima de um instrumento musical implementado inteiramente em software.

Osciladores, Exemplos e freqüência

No coração da facilidade para a geração de som de qualquer sintetizador de música são múltiplos osciladores, assim chamados porque eles geram uma forma de onda oscilação mais ou menos periódica determinada frequência e volume. Na geração de sons para música, osciladores que criar formas de onda periódicas leais geralmente soam bastante aborrecidos. Osciladores mais interessantes incorporam vibrato, tremolo ou alterando timbres, e eles só são aproximadamente periódicos.

Um programa que pretende criar osciladores utilizando XAudio2 começa chamando a função XAudio2Create. Isso fornece um objeto que implementa a interface IXAudio2. Daquele objeto, você pode chamar CreateMasteringVoice apenas uma vez para obter uma instância de IXAudio2MasteringVoice, que funciona como o mixer de áudio principal. Apenas um IXAudio2MasteringVoice existe a qualquer momento. Em contraste, você geralmente chamará CreateSourceVoice várias vezes para criar várias instâncias da interface IXAudio2SourceVoice. Cada uma dessas instâncias de IXAudio2SourceVoice pode funcionar como um oscilador independente. Combine múltiplos osciladores para um instrumento de multiphonic, um conjunto ou uma orquestra completa.

Um objeto IXAudio2SourceVoice gera som criando e enviando buffers contendo uma sequência de números que descrevem uma forma de onda. Esses números são frequentemente chamados de amostras. Eles são muitas vezes 16 bits de largura (o padrão para CD de áudio) e eles vêm em uma taxa constante — geralmente de 44.100 Hz (e também o padrão para áudio de CD) ou por aí. Esta técnica tem o nome fantasia Pulse Code Modulation, ou PCM.

Embora esta seqüência de amostras pode descrever uma forma de onda muito complexa, muitas vezes um sintetizador gera um fluxo bastante simples de amostras — mais comumente uma onda quadrada, uma onda triangular ou um dente de serra — com uma periodicidade correspondente à freqüência na forma de onda (percebido como breu) e uma amplitude média que é percebida como volume.

Por exemplo, se a taxa de amostragem é de 44.100 Hz, e cada ciclo de 100 amostras tem valores que obtém progressivamente maior, então menor, então negativa e volta a zero, a freqüência do som resultante é 44.100 dividido por 100, ou 441 Hz — uma freqüência perto do centro de percepção da gama audível para os seres humanos. (Uma freqüência de 440 Hz é o A acima C médio e é usada como um ajuste padrão).

A interface IXAudio2SourceVoice herda um método chamado SetVolume de IXAudio2Voice e define um método do próprio nomeado SetFrequencyRatio. Eu particularmente fiquei intrigado com este último método, porque parecia fornecer uma maneira para criar um oscilador que gera uma determinada forma de onda periódica em uma freqüência variável com um mínimo de barulho.

Figura 1 mostra a maior parte de uma classe chamada SawtoothOscillator1 que implementa esta técnica. Embora eu use amostras familiar inteiro de 16 bits para definir a forma de onda, internamente XAudio2 usa amostras de ponto flutuante de 32 bits. Para aplicativos críticos de desempenho, você provavelmente vai querer explorar as diferenças de desempenho entre inteiro e ponto flutuante.

Figura 1 muito da classe SawtoothOscillator1

 

SawtoothOscillator1::SawtoothOscillator1(IXAudio2* pXAudio2)
{
  // Create a source voice
  WAVEFORMATEX waveFormat;
  waveFormat.wFormatTag = WAVE_FORMAT_PCM;
  waveFormat.nChannels = 1;
  waveFormat.nSamplesPerSec = 44100;
  waveFormat.nAvgBytesPerSec = 44100 * 2;
  waveFormat.nBlockAlign = 2;
  waveFormat.wBitsPerSample = 16;
  waveFormat.cbSize = 0;
  HRESULT hr = pXAudio2->CreateSourceVoice(&pSourceVoice, &waveFormat,
                                           0, XAUDIO2_MAX_FREQ_RATIO);
  if (FAILED(hr))
    throw ref new COMException(hr, "CreateSourceVoice failure");
  // Initialize the waveform buffer
  for (int sample = 0; sample < BUFFER_LENGTH; sample++)
    waveformBuffer[sample] =
      (short)(65535 * sample / BUFFER_LENGTH - 32768);
  // Submit the waveform buffer
  XAUDIO2_BUFFER buffer = {0};
  buffer.AudioBytes = 2 * BUFFER_LENGTH;
  buffer.pAudioData = (byte *)waveformBuffer;
  buffer.Flags = XAUDIO2_END_OF_STREAM;
  buffer.PlayBegin = 0;
  buffer.PlayLength = BUFFER_LENGTH;
  buffer.LoopBegin = 0;
  buffer.LoopLength = BUFFER_LENGTH;
  buffer.LoopCount = XAUDIO2_LOOP_INFINITE;
  hr = pSourceVoice->SubmitSourceBuffer(&buffer);
  if (FAILED(hr))
    throw ref new COMException(hr, "SubmitSourceBuffer failure");
  // Start the voice playing
  pSourceVoice->Start();
}
void SawtoothOscillator1::SetFrequency(float freq)
{
  pSourceVoice->SetFrequencyRatio(freq / BASE_FREQ);
}
void SawtoothOscillator1::SetAmplitude(float amp)
{
  pSourceVoice->SetVolume(amp);
}

No arquivo de cabeçalho, uma freqüência base é definida que divide corretamente a taxa de amostragem de 44.100. De que, um tamanho de buffer pode ser calculado que é o comprimento de um ciclo único de um forma de onda do que a freqüência:

static const int BASE_FREQ = 441; static const int BUFFER_LENGTH = (44100 / BASE_FREQ);

Também no cabeçalho do arquivo é a definição de que reserva como um campo:

 

short waveformBuffer[BUFFER_LENGTH];

Depois de criar o objeto IXAudio2SourceVoice, o Sawtooth­Oscillator1 Construtor preenche um buffer com um ciclo de uma forma de onda dente de serra — uma simples forma de onda que vai de uma amplitude de -32.768 a uma amplitude de 32.767. Esse buffer é enviado para o IXAudio2SourceVoice com as instruções que ele deve ser repetido para sempre.

Sem qualquer novo código, este é um oscilador que reproduz uma onda dente de serra de 441 Hz para sempre. Isso é ótimo, mas não é muito versátil. Para dar a SawtoothOscillator1 um pouco mais de versatilidade, incluí também um método de SetFrequency. O argumento para isso é uma freqüência que usa a classe para chamar o SetFrequencyRatio. O valor passado para SetFrequencyRatio pode variar de valores float de XAUDIO2_MIN_FREQ_RATIO (ou 1/1,024.0) até um valor máximo especificado anteriormente como um argumento para CreateSourceVoice. Eu usei o XAUDIO2_MAX_FREQ_RATIO (ou 1,024.0) para esse argumento. O alcance da audição humana — cerca de 20 Hz a 20.000 Hz — está bem dentro dos limites definidos por essas duas constantes aplicadas a frequência base de 441.

Buffers e retornos de chamada

Devo confessar que eu era inicialmente um pouco cético em relação ao método de SetFrequencyRatio. Digitalmente aumentando e diminuindo a freqüência de uma onda não não uma tarefa trivial. Senti-me obrigado a comparar os resultados com uma forma de onda gerada algoritmicamente. Este é o impulso por trás do projeto OscillatorCompare, que está entre o código para download desta coluna.

O projeto de OscillatorCompare inclui a classe de SawtoothOscillator1 que já descrevi bem como uma classe de SawtoothOscillator2. Esta segunda classe tem um método de SetFrequency que controla como a classe gera dinamicamente as amostras que definem a forma de onda. Esta forma de onda é continuamente construída em um buffer e enviada em tempo real para o objeto de IXAudio2SourceVoice em resposta a retornos de chamada.

Uma classe pode receber retornos de chamada de IXAudio2SourceVoice, implementar a interface IXAudio2VoiceCallback. Uma instância da classe que implementa essa interface, em seguida, é passada como um argumento para o método de CreateSourceVoice. A classe SawtoothOscillator2 implementa essa interface em si e sua própria instância passa para CreateSourceVoice, indicando também que ele não vai estar fazendo uso de SetFrequencyRatio:

pXAudio2->CreateSourceVoice(&pSourceVoice, &waveFormat,         XAUDIO2_VOICE_NOPITCH, 1.0f,         this);

Uma classe que implementa IXAudio2VoiceCallback pode usar o método OnBufferStart para ser notificado quando é hora de apresentar um novo buffer de dados de forma de onda. Geralmente quando usando OnBufferStart para manter os dados de forma de onda atualizado, você vai querer manter um par de amortecedores e alternativo-los. Esta é provavelmente a melhor solução, se você estiver obtendo dados de áudio de outra fonte, como um arquivo de áudio. O objetivo é não deixar o processador de áudio tornam-se "fome". Manter um buffer antes do tratamento ajuda a prevenir a fome, mas não garante isso.

Mas eu gravitaram em direção a outro método definido pelo IXAudio2VoiceCallback — OnVoiceProcessingPassStart. A menos que você está trabalhando com amortecedores muito pequenos, geralmente OnVoiceProcessingPassStart é chamado mais frequentemente do que OnBufferStart e indica quando um bloco de dados de áudio está prestes a ser processado e quantos bytes são necessários. Na documentação do XAudio2, esse método de retorno de chamada é promovido como aquele com a menor latência, que muitas vezes é altamente desejável para instrumentos de música eletrônica interativa. Você não quer um atraso entre o pressionamento de uma tecla e ouvindo a nota!

O arquivo de cabeçalho SawtoothOscillator2 define duas constantes:

static const int BUFFER_LENGTH = 1024; static const int WAVEFORM_LENGTH = 8192;

O primeiro constante é o comprimento do buffer usado para enviar dados de forma de onda. Aqui funciona como um buffer circular. Chamadas para o método de OnVoiceProcessingPassStart solicitar um número específico de bytes. O método responde colocando os bytes no buffer (a partir de onde parou da última vez) e chamando o SubmitSourceBuffer apenas para esse segmento atualizado do buffer. Você quer este buffer a ser suficientemente grande para que o seu código de programa não é substituir a parte da reserva ainda está sendo jogada no fundo.

Acontece que para uma voz com uma taxa de amostragem de 44.100 Hz, chamadas para OnVoiceProcessingPassStart sempre solicitação 882 bytes ou 441 amostras de 16-bit. Em outras palavras, denomina-se OnVoiceProcessingPassStart à taxa constante de 100 vezes por segundo, ou a cada 10 ms. Embora não documentado, esta duração de 10 ms pode ser tratada como um processamento de áudio XAudio2 "quantum", e é uma boa figura para manter em mente. Conseqüentemente, o código que você escreve para este método não te disperses. Evite chamadas API e chamadas de biblioteca de tempo de execução.

A segunda constante é o comprimento de um único ciclo da forma de onda desejada. Poderia ser o tamanho de uma matriz contendo as amostras de que forma de onda, mas em SawtoothOscillator2 é usado apenas para cálculos.

O método de SetFrequency em SawtoothOscillator2 usa essa constante para calcular um incremento angular que é proporcional a frequência da onda:

angleIncrement = (int)(65536.0                 * WAVEFORM_LENGTH                 * freq / 44100.0);

Embora angleIncrement seja um número inteiro, ele é tratado como se compreende palavras integrais e fracionárias. Este é o valor usado para determinar cada amostra sucessiva da forma de onda.

Por exemplo, suponha que o argumento SetFrequency é de 440 Hz. O angleIncrement é calculado como 5.356.535. Em hexadecimal, isto é 0x51BBF7, que é tratada como um inteiro de 0x51 (ou 81 decimal), com uma parte fracionária de 0xBBF7, equivalente a 0.734. Se o ciclo completo de uma forma de onda é de 8.192 bytes e você usar apenas a parte inteira e ir 81 bytes para cada amostra, a freqüência resultante é 436.05 Hz aproximadamente. (Que é de 44.100 vezes 81 dividido por 8.192). Se você ignorar bytes 82, a freqüência resultante é 441.43 Hz. Você quer algo entre essas duas frequências.

Eis porque uma parte fracionária também precisa entrar o cálculo. A coisa toda, provavelmente seria mais fácil em ponto flutuante e ponto flutuante pode até ser mais rápido em alguns processadores modernos, mas Figura 2 mostra uma abordagem mais de somente inteiro "tradicional". Observe que somente a seção atualizada do buffer circular é especificada com cada chamada para SubmitSourceBuffer.

Figura 2 OnVoiceProcessingPassStart em SawtoothOscillator2

void _stdcall SawtoothOscillator2::OnVoiceProcessingPassStart(UINT32 bytesRequired)
{
  if (bytesRequired == 0)
      return;
  int startIndex = index;
  int endIndex = startIndex + bytesRequired / 2;
  if (endIndex <= BUFFER_LENGTH)
  {
    FillAndSubmit(startIndex, endIndex - startIndex);
  }
  else
  {
    FillAndSubmit(startIndex, BUFFER_LENGTH - startIndex);
    FillAndSubmit(0, endIndex % BUFFER_LENGTH);
  }
  index = (index + bytesRequired / 2) % BUFFER_LENGTH;
}
void SawtoothOscillator2::FillAndSubmit(int startIndex, int count)
{
  for (int i = startIndex; i < startIndex + count; i++)
  {
    pWaveformBuffer[i] = (short)(angle / WAVEFORM_LENGTH - 32768);
    angle = (angle + angleIncrement) % (WAVEFORM_LENGTH * 65536);
  }
  XAUDIO2_BUFFER buffer = {0};
  buffer.AudioBytes = 2 * BUFFER_LENGTH;
  buffer.pAudioData = (byte *)pWaveformBuffer;
  buffer.Flags = 0;
  buffer.PlayBegin = startIndex;
  buffer.PlayLength = count;
  HRESULT hr = pSourceVoice->SubmitSourceBuffer(&buffer);
  if (FAILED(hr))
    throw ref new COMException(hr, "SubmitSourceBuffer");
}

SawtoothOscillator1 e SawtoothOscillator2 podem ser comparada lado a lado no programa OscillatorCompare. MainPage tem dois pares de controles deslizantes para mudar a freqüência e o volume de cada oscilador. O controle deslizante para a freqüência gera apenas os valores inteiros que variam de 24 a 132. Eu pedi esses valores de códigos utilizados no Musical instrumento Digital Interface (MIDI) padrão para representar os arremessos. O valor de 24 corresponde as C três oitavas abaixo do dó médio, que é chamado de 1 C (C em oitava 1) em notação científica de campo e tem uma freqüência de cerca de 32,7 Hz. O valor de 132 corresponde ao C 10, seis oitavas acima do dó médio e uma frequência de cerca de 16.744 Hz. Um conversor de dica de ferramenta nestes sliders exibe o valor atual em notação científica de campo e a freqüência equivalente.

Como eu experimentei com estes dois osciladores, eu não conseguia ouvir a diferença. Eu também instalei um osciloscópio de software em outro computador para examinar visualmente as formas de onda resultantes, e eu não podia ver qualquer diferença ou. Isto indica-me que o SetFrequency­relação método é implementado de forma inteligente, que naturalmente devemos esperar em um sistema tão sofisticados como o DirectX. Eu suspeito que interpolações são executadas nos dados de forma de onda reamostrada para mudar a freqüência. Se você está nervoso, você pode definir o BASE_FREQ muito baixo — por exemplo, para 20Hz — e a classe irá gerar uma forma de onda detalhada, que consiste de 2.205 amostras. Você também pode experimentar com um alto valor: Por exemplo, 8.820 Hz fará com que uma onda de apenas cinco amostras seja gerado! Para ter certeza, isso tem um som um pouco diferente, porque a forma de onda interpolada situa-se em algum lugar entre um dente de serra e uma onda triangular, mas a forma de onda resultante é ainda suave sem "jaggies".

Isso é não dar a entender que tudo funciona hunky dory. Com qualquer oscilador dente de serra, as oitavas superior par Obtém bastante caóticas. A amostragem da forma de onda tende a emitir altos tons de baixa frequência de uma espécie já ouvi antes e que pretendo investigar mais plenamente no futuro.

Mantenha o Volume!

O método de SetVolume definido pelo IXAudio2Voice e herdado por IXAudio2SourceVoice é documentado como um multiplicador de ponto flutuante que pode ser definido como valores que variam de -2 ^ 24 a 2 ^ 24, que é igual a 16.777.216.

Na vida real, no entanto, você provavelmente vai querer manter o volume em um objeto IXAudio2SourceVoice para um valor entre 0 e 1. O valor corresponde ao silêncio de 0 e 1 correspondem a nenhum ganho ou atenuação. Tenha em mente que qualquer que seja a fonte da forma de onda associada uma IXAudio2SourceVoice — se ele está sendo gerado algoritmicamente ou se origina em um arquivo de áudio — provavelmente tem amostras de 16-bit que possivelmente vêm perto os valores mínimos e máximos de -32.768 e 32.767. Se você tentar amplificar essas formas de onda com um nível de volume maior que 1, as amostras irão exceder a largura de um inteiro de 16 bits e serão cortadas os valores mínimo e máximo. Distorção e ruído resultará.

Isso se torna crítico quando você começar combinando várias instâncias de IXAudio2SourceVoice. As formas de onda dessas instâncias múltiplas são misturadas por ser somados. Se você permitir que cada uma dessas instâncias para ter um volume de 1, a soma das vozes muito bem poderia resultar em amostras que excedem o tamanho de 16-bit inteiros. Isso pode acontecer esporadicamente, resultando apenas distorção intermitente — ou cronicamente, resultando em uma verdadeira bagunça.

Ao usar várias instâncias de IXAudio2SourceVoice que geram formas de onda completa 16-bit de largura, uma medida de segurança é definir o volume de cada oscilador 1 dividido pelo número de vozes. Que garante que a soma nunca excede um valor de 16 bits. Um ajuste global de volume também pode ser feito através da voz de masterização. Você também pode querer olhar para a função XAudio2CreateVolumeMeter, que permite que você crie um objeto de processamento de áudio que pode ajudar o monitor de volume para fins de depuração.

Nosso primeiro instrumento Musical

É comum para instrumentos musicais em comprimidos para ter um teclado de piano-estilo, mas eu tenho sido intrigado recentemente um tipo de botão teclado encontrado em acordeões como o Russian bayan (que eu estou familiarizado com o trabalho do compositor russo Sofia Gubaidulina). Porque cada chave é um botão em vez de uma alavanca longa, muitas chaves mais podem ser embaladas dentro do limitado espaço da tela do tablet, como mostrado na Figura 3.


Figura 3 o programa de ChromaticButtonKeyboard

As duas linhas de fundo as chaves no topo duas linhas duplicadas e são disponibilizadas para facilitar o dedilhado dos acordes comuns e seqüências melódicas. Caso contrário, cada grupo de 12 teclas no top três linhas fornecem todas as notas da oitava, geralmente crescente da esquerda para a direita. A gama total aqui quatro oitavas, que é sobre duas vezes o que você iria ficar com um teclado de piano do mesmo tamanho.

Um bayan real tem uma oitava adicional, mas eu não poderia caber em sem fazer os botões muito pequenos. O código-fonte permite que você defina constantes para experimentar essa oitava extra, ou para eliminar uma outra oitava e fazer os botões ainda maior.

Porque não pode alegar que este programa soa como qualquer instrumento que existe no mundo real, eu simplesmente chamou ChromaticButton­teclado. As chaves são instâncias de um controle personalizado chamado chave que deriva de ContentControl, mas executa algum toque de processamento para manter uma propriedade IsPressed e gerar um evento de IsPressedChanged. A diferença entre o toque de manipulação desse controle e o toque de um botão comum (que também tem uma propriedade IsPressed) de manipulação é perceptível quando você varre o dedo através do teclado: Um botão padrão irá definir a propriedade IsPressed para true apenas se a imprensa de dedo ocorre na superfície do botão, enquanto esse controle chave personalizada considera a tecla ser pressionada, se um dedo varre do lado.

O programa cria seis instâncias de uma classe de SawtoothOscillator que é praticamente idêntica da classe de SawtoothOscillator1 do projeto anterior. Se o seu ecrã táctil oferece suporte a ele, você pode jogar seis notas simultâneas. Não há nenhum retorno de chamada e a freqüência do oscilador é controlada por chamadas para o método de SetFrequencyRatio.

Para controlar quais osciladores estão disponíveis e quais osciladores estão jogando, o arquivo de MainPage.xaml.h define dois objetos de coleção padrão como campos:

 

std::vector<SawtoothOscillator *> availableOscillators; std::map<int, SawtoothOscillator *> playingOscillators;

Originalmente, cada objeto de chave tinha sua propriedade Tag definida para o código de nota MIDI discutido anteriormente. Que é como o manipulador de IsPressedChanged determina qual tecla está sendo pressionada e frequência para calcular. Esse código de MIDI também foi usado como a chave do mapa para a coleção de playingOscillators. Funcionou muito bem até que eu jogava uma nota das duas linhas de fundo que duplicado uma nota já jogando, o que resultou em uma chave duplicada e uma exceção. Eu facilmente resolvido esse problema incorporando um valor para a propriedade Tag que indica a linha em que se encontra a chave: A marca agora é igual ao código de nota MIDI mais 1.000 vezes o número de linha.

Figura 4 mostra o manipulador de IsPressedChanged para as instâncias de chave. Quando uma tecla é pressionada, um oscilador é removido da coleção availableOscillators, dado uma freqüência e um volume diferente de zero e colocar na coleção playingOscillators. Quando uma tecla é liberada, o oscilador é dado um volume zero e voltou para a availableOscillators.

Figura 4 o manipulador de IsPressedChanged para as instâncias de chaves

void MainPage::OnKeyIsPressedChanged(Object^ sender, bool isPressed)
{
  Key^ key = dynamic_cast<Key^>(sender);
  int keyNum = (int)key->Tag;
  if (isPressed)
  {
    if (availableOscillators.size() > 0)
    {
      SawtoothOscillator* pOscillator = availableOscillators.back();
      availableOscillators.pop_back();
      double freq = 440 * pow(2, (keyNum % 1000 - 69) / 12.0);
      pOscillator->SetFrequency((float)freq);
      pOscillator->SetAmplitude(1.0f / NUM_OSCILLATORS);
      playingOscillators[keyNum] = pOscillator;
    }
  }
  else
  {
    SawtoothOscillator * pOscillator = playingOscillators[keyNum];
    if (pOscillator != nullptr)
    {
      pOscillator->SetAmplitude(0);
      availableOscillators.push_back(pOscillator);
      playingOscillators.erase(keyNum);
    }
  }
}

Que é aproximadamente tão simples como um instrumento de multi-voice pode ser, e é claro que ele é falho: Sons não devem ser desligados e ligado como um interruptor. O volume deve deslizar acima rapidamente, mas sem problemas quando inicia uma nota e cair para trás quando ele pára. Muitos instrumentos reais têm também uma mudança no volume e timbre conforme o andamento da nota. Ainda há muito espaço para melhorias.

Mas considerando a simplicidade do código, funciona surpreendentemente bem e é muito sensível. Se você compilar o programa para o processador do braço, você pode implantá-lo no braço-baseado Microsoft Surface e passeio embalando o untethered comprimido em um braço enquanto jogava nele com a outra mão, o que eu devo dizer que é um pouco de emoção.

Charles Petzold é um colaborador de longa data para MSDN Magazine e autor de "Programação Windows, 6ª edição" (o ' Reilly Media, 2012), um livro sobre como escrever aplicativos para Windows 8. Seu site é charlespetzold.com.

Agradecemos aos seguintes especialistas técnicos pela revisão deste artigo: Tom Mathews e Thomas Petchel