DevSecOps

27 set, 2011

Gerando sons dinâmicos através do ActionScript

Publicidade

Para iniciar, gostaria de chamar sua atenção para os requisitos necessários para o acompanhamento desse artigo. Para não se perder pelo conteúdo, você deve conhecer o método de trabalho do Flash, ActionScript Orientado a Objetos e uso de ByteArray. Esse é um artigo de nível intermediário/avançado.

Nosso objetivo aqui é gerar sons dinâmicos através do ActionScript, sem samples (mp3) na Library ou externos.

O
conteúdo apresentado neste artigo é de um ex-aluno/amigo, André Anaya. O blog dele
(blog.andreanaya.com) possui o mesmo conteúdo abaixo, com exceção aos
arquivos fontes. O artigo é de ótimo nível e espero que todos gostem!

Passei as duas últimas semanas trabalhando num projeto envolvendo som.
Infelizmente não posso mostrá-lo ainda, mas meu objetivo era tocar um
monte de notas musicais em um intervalo especifico. A primeira coisa que
eu tentei fazer foi carregar um monte de samples e toca-los quando
precisasse, mas o desempenho ficou muito baixo com isso. Então minha
solução foi procurar no Google e no lab do Andre Michelle para entender como trabalhar com sons dinâmicos.

Bom,
ainda estou aprendendo, mas acho que muitas pessoas gostariam de
trabalhar com isso e não têm a menor idéia por onde começar, por isso
espero que esse post seja útil para essas pessoas.

A
primeira coisa que precisamos entender é que o som é uma onda, e essa
onde pode ter diferentes formatos dependendo do som que você está ouvindo.


Agora imagine que essa onda é formada por pontos
conectados. Cada ponto representa uma posição na onda em um determinado
instante.


*mexa seu mouse horizontalmente para mudar o formato da onde

Certo, agora vamos chamar esses pontos de samples, e dizer que eles podem mudar seu valor de -1 a 1.

Basicamente
é assim que o som funciona no Flash. Mas existem algumas coisas que
devemos saber sobre a qualidade. Um som com qualidade boa depende do
numero de samples tocados durante um segundo, e a precisão de cada
ponto. O numero de samples tocados num segundo é chamado de sample rate,
o Flash trabalha com um sample rate de 44100 samples por segundo.

Isso
significa que cada segundo contém 44100 pontos. Cada sample, ou ponto,
tem a precisão de 64 bits, sendo 32 bits para o canal esquerdo e 32 bits
pro canal direito.

Agora vamos trazer isso para o Flash. Lembre-se que estamos usando o Player 10.
Primeiro
nós criamos uma instancia de Sound, uma instancia de SoundChannel,
adicionamos um listener ao som e tocamos ele no sound channel.

package {
import flash.display.Sprite;
import flash.events.SampleDataEvent;
import flash.media.Sound;
import flash.media.SoundChannel;
public class Main extends Sprite {
private var sound:Sound;
private var channel:SoundChannel;
public function Main():void{
sound = new Sound();
sound.addEventListener(SampleDataEvent.SAMPLE_DATA, writeData);
channel = sound.play();
}
private function writeData(e:SampleDataEvent):void{
}
}
}

O
evento que sera ouvido é disparado antes do som ser executado e pede os
dados de audio que compõe aquele fragmento de som. Você pode prover
entre 2048 e 8192 samples. Quanto maior esse numero é, melhor é o
desempenho, porém a latência é maior. Se você provê menos de 2048 samples (por chamada do listener
de sampleData), a aplicação termina depois de tocar os samples
restantes.

Agora vamor preencher os dados de audio com samples randomicos. Cada sample com um valor entre -1 e 1.

private function writeData(e:SampleDataEvent):void{
for(var i:Number = 0; i<8192; i++){
//Left Channel
e.data.writeFloat(Math.random()*2-1);

//RightChannel
e.data.writeFloat(Math.random()*2-1);
}
}

Não
ouvimos nada além de um ruido. Para criar algum som decente precisamos
criar um formato de onda que faça algum sentido. Existem alguns formatos
básicos que podemos utilizar para criar algum som.

Vamos utilizar uma senoide para criar uma nota musical A4. Se a frequência dessa nota é 4400.00 Hz, isso significa que nossa onda completa 440 ciclos por segundo.

Essa é uma onda de 1Hz

E essa é uma onda de 2Hz

*Humanos só conseguem ouvir de 20 a 20,000 hertz

Se
nos sabemos que cada segundo contém 44100 samples e uma nota A4
completa 440 ciclos por segundo, cada sample será um peço de 440/44100
de um ciclo. Um ciclo são 360 graus, ou Math.PI*2 radianos pois o Flash
trabalha com radianos quando calcula senos. Convertendo isso para o Flash temos o seguinte:

private var phase:Number = 0;
private var step:Number = Math.PI*2 * 440/44100;

private function writeData(e:SampleDataEvent):void{
for(var i:Number = 0; i<8192; i++){
//Left Channel
e.data.writeFloat(Math.sin(phase));

//RightChannel
e.data.writeFloat(Math.sin(phase));

phase += step;
}
}

Agora conseguimos ouvir alguma coisa.
Veja o resultado aqui.

Se você quiser tocar notas diferentes você pode tentar essa fórmula onde a nota mais baixa é um A2.

phaseStep = 110.0*Math.pow( 2, octave + semiTone / 12 )/44100;

Vamos tentar criar um acorde maior A4.
Precisamos de 3 notas para isso, um A4, um C#4 e um E4.

private var A4:Number = 110.0*Math.pow( 2, 4 + 0 / 12 )/44100;
private var Csharp4:Number = 110.0*Math.pow( 2, 4 + 4 / 12 )/44100;
private var E4:Number = 110.0*Math.pow( 2, 4 + 7 / 12 )/44100;

E precisamos de uma fase para cada nota:

private var phaseA4:Number = 0;
private var phaseCsharp4:Number = 0;
private var phaseE4:Number = 0;

Depois adicionar os valores para compor o acorde

private function writeData(e:SampleDataEvent):void{
for(var i:Number = 0; i<8192; i++){
var amplitudeA4:Number = Math.sin(phaseA4);
var amplitudeCsharp4:Number = Math.sin(phaseCsharp4);
var amplitudeE4:Number = Math.sin(phaseE4);

phaseA4 += A4;
phaseCsharp4 += Csharp4;
phaseE4 += E4;

e.data.writeFloat(amplitudeA4+amplitudeCsharp4+amplitudeE4);
e.data.writeFloat(amplitudeA4+amplitudeCsharp4+amplitudeE4);
}
}

Quando
precisamos unir dois ou mais sons, precisamos apenas somar cada valor
para ter a amplitude final do sample, o problema é que quando escrevemos
um valor maior que 1 ou menor que -1. Dessa forma distorcemos o som.
Veja isso.

Nesse caso podemos resolver o problema dividindo o valor final por 3, uma vez que temos 3 notas

private function writeData(e:SampleDataEvent):void{
	for(var i:Number = 0; i<8192; i++){
		var amplitudeA4:Number = Math.sin(phaseA4);
		var amplitudeCsharp4:Number = Math.sin(phaseCsharp4);
		var amplitudeE4:Number = Math.sin(phaseE4);
 
		phaseA4 += A4;
		phaseCsharp4 += Csharp4;
		phaseE4 += E4;
 
		e.data.writeFloat((amplitudeA4+amplitudeCsharp4+amplitudeE4)/3);
		e.data.writeFloat((amplitudeA4+amplitudeCsharp4+amplitudeE4)/3);
	}
}

Agora ouvimos um som muito mais agradável.
Veja.
Baixe os arquivos aqui.

Nós
podemos fazer muitas coisas com isso, mas podemos fazer muito mais
usando samples pré-gravados e manipulando os samples para criar efeitos.

Agora, não vamos perder tempo criando o piano, então você pode baixar esse modelo.

Começando
desse ponto, nós precisamos criar uma instância de som, uma instância
de SoundChannel, adicionar o listeners e executar o som.

private var sound:Sound;
private var channel:SoundChannel;
 
public function Main():void{
	init();
 
	sound = new Sound();
	sound.addEventListener(SampleDataEvent.SAMPLE_DATA, onSampleData);
 
	channel = sound.play();
}
private function onSampleData(e:SampleDataEvent):void{
}

Agora nos vamos criar uma variável chamada BUFFER_SIZE que vai determinar o número de samples que serão tocados.

private var BUFFER_SIZE:uint = 3072;

Agora vamos criar um Vector que vai conter os dados de som.

private var buffer:Vector.<Vector.<Number>>;

Imagine
um Vector como se fosse um Array. A diferença é que ele tem o tipo de
dados que irá conter definido previamente. Nesse caso é um Vector
bidimensional que trata números. A razão de utilizar um Vector em vez de
um Array é que eles têm um processamento muito mais rápido e
precisamos disso para obter uma latência baixa.

Agora
iremos preencher esse Vector com valores que serão processados antes de
escreve a informação de audio, para isso ele precisa ter o mesmo
tamanho que o numero de samples que serão tocados.

private function createBuffer():void{
	buffer = new Vector.<Vector.<Number>>(2, true);
	buffer[0] = new Vector.<Number>(BUFFER_SIZE, true);
	buffer[1] = new Vector.<Number>(BUFFER_SIZE, true);
 
	var i:uint;
 
	for(i = 0 ; i<BUFFER_SIZE; i++){
		buffer[0][i] = 0.0;
		buffer[1][i] = 0.0;
	}
}

O Vector é bidimensional pois temos o canal esquerdo e o direito.

Agora vamos utilizar esse buffer para escrever os dados de áudio.

private function onSampleData(e:SampleDataEvent):void{
	var i:uint;
 
	for(i = 0; i<BUFFER_SIZE; i++){
		e.data.writeFloat(buffer[0][i]);
		e.data.writeFloat(buffer[1][i]);
	}
}

Até
agora não conseguimos ouvir nada. Precisamos criar uma classe para as
notas musicais. Essa classe vai conter os parâmetros da nota e vai
processar a informação de áudio dessa nota.

Primeiro precisamos criar a base dessa nota.

package {
	public class Note {
		private var _phaseStep:Number;
		private var _phase:Number;
 
		public function Note(semiTone:Number, octave:Number = 0):void{
			_phaseStep = 440.0*Math.pow( 2, octave + semiTone / 12 )/44100;
			_phase = 0;
		}
	}
}

Agora vamos criar o método que vai processar a informação de áudio.

public function process(buffer:Vector.<Vector.<Number>>):void{
	var l:Vector.<Number> = buffer[0];
	var r:Vector.<Number> = buffer[1];
 
	var i:uint;
	var t:Number = l.length;
 
	var amplitude:Number;
 
	for(i = 0; i<t; i++){
		amplitude = Math.sin(_phase);
		_phase += _phaseStep;
 
		l[i] += amplitude;
		r[i] += amplitude;
	}
}

O
que esse método faz, é receber o buffer, guardar cada canal em uma
variável e adicionar em cada sample o valor do seno baseado no phase da
nota.

Para testar isso, vamos criar uma nota na classe principal.

private var note:Note = new Note(0, 2); // This is a A2

O som está estranho, pois precisamos zerar o buffer antes de adicionar novos dados.

private function clearBuffer():void{
	var i:uint;
 
	for(i = 0 ; i<BUFFER_SIZE; i++){
		buffer[0][i] = 0.0;
		buffer[1][i] = 0.0;
	}
}

Agora precisamos chamar esse método antes de processar as notas:

private function onSampleData(e:SampleDataEvent):void{
clearBuffer();

var i:uint;

note.process(buffer);

for(i = 0; i<BUFFER_SIZE; i++){
e.data.writeFloat(buffer[0][i]);
e.data.writeFloat(buffer[1][i]);
}
}

OK,
agora conseguimos ouvir alguma coisa. Entretando essa é uma nota
musical então ela precisa soar e diminuir seu volume até terminar. Para
fazer isso podemos pensar na amplitude da onda como um número que é
multiplicado pelo volume da nota.

Se multiplicarmos a amplitude da
nota por um número que diminui a cada sample, conseguimos diminuir o
volume da nota para simular um piano.

De volta em nossa classe da nota, temos o seguinte:

package {
public class Note {
private var _phaseStep:Number;
private var _phase:Number;

private var _decay:uint = 30000;

public function Note(semiTone:Number, octave:Number = 0):void{
_phaseStep = 440.0*Math.pow( 2, octave + semiTone / 12 )/44100;
_phase = 0;
}
public function process(buffer:Vector.<Vector.<Number>>):void{
var l:Vector.<Number> = buffer[0];
var r:Vector.<Number> = buffer[1];

var i:uint;
var t:Number = l.length;

var amplitude:Number;

for(i = 0; i<t; i++){
amplitude = Math.sin(_phase)*_decay/30000;
_phase += _phaseStep;

l[i] += amplitude;
r[i] += amplitude;

if(--_decay<0){
_decay = 0;
}
}
}
}
}

O
valor inicial da variável _decay é o número de samples que serão tocados
até o fim da nota, nesse caso 30000. Isso significa que a cada sample esse valor vai diminuir até 0. E a razão de dividir essa variável pelo
seu valor inicial é para conseguir a porcentagem de volume para cada
sample.

Agora
vamos criar um método em nossa classe Main que vai criar uma nota para
cada tecla do piano. Primeiro vamos criar um Array para armazenar todas
as notas.

private var notes:Array = new Array();

Agora o método createNote

private function createNote(semiTone:Number):void{
	var note:Note = new Note(semiTone);
 
	notes.push(note);
}

Então, quando apertamos uma tecla, esse método é chamado passando o semitom (semiTune) equivalente para aquela nota.

private function init():void{
	stage.addEventListener(KeyboardEvent.KEY_DOWN, keyDown);
 
	C1.note = 0;
	Csharp1.note = 1;
	D1.note = 2;
	Dsharp1.note = 3;
	E1.note = 4;
	F1.note = 5;
	Fsharp1.note = 6;
	G1.note = 7;
	Gsharp1.note = 8;
	A1.note = 9;
	Asharp1.note = 10;
	B1.note = 11;
	C2.note = 12;
 
	C1.buttonMode = true;
	Csharp1.buttonMode = true;
	D1.buttonMode = true;
	Dsharp1.buttonMode = true;
	E1.buttonMode = true;
	F1.buttonMode = true;
	Fsharp1.buttonMode = true;
	G1.buttonMode = true;
	Gsharp1.buttonMode = true;
	A1.buttonMode = true;
	Asharp1.buttonMode = true;
	B1.buttonMode = true;
	C2.buttonMode = true;
 
	C1.addEventListener(MouseEvent.CLICK, onClick);
	Csharp1.addEventListener(MouseEvent.CLICK, onClick);
	D1.addEventListener(MouseEvent.CLICK, onClick);
	Dsharp1.addEventListener(MouseEvent.CLICK, onClick);
	E1.addEventListener(MouseEvent.CLICK, onClick);
	F1.addEventListener(MouseEvent.CLICK, onClick);
	Fsharp1.addEventListener(MouseEvent.CLICK, onClick);
	G1.addEventListener(MouseEvent.CLICK, onClick);
	Gsharp1.addEventListener(MouseEvent.CLICK, onClick);
	A1.addEventListener(MouseEvent.CLICK, onClick);
	Asharp1.addEventListener(MouseEvent.CLICK, onClick);
	B1.addEventListener(MouseEvent.CLICK, onClick);
	C2.addEventListener(MouseEvent.CLICK, onClick);
}
private function keyDown(e:KeyboardEvent):void{
	if(e.keyCode == 65){
		createNote(C1.note);
	} else if(e.keyCode == 87){
		createNote(Csharp1.note);
	} else if(e.keyCode == 83){
		createNote(D1.note);
	} else if(e.keyCode == 69){
		createNote(Dsharp1.note);
	} else if(e.keyCode == 68){
		createNote(E1.note);
	} else if(e.keyCode == 70){
		createNote(F1.note);
	} else if(e.keyCode == 84){
		createNote(Fsharp1.note);
	} else if(e.keyCode == 71){
		createNote(G1.note);
	} else if(e.keyCode == 89){
		createNote(Gsharp1.note);
	} else if(e.keyCode == 72){
		createNote(A1.note);
	} else if(e.keyCode == 85){
		createNote(Asharp1.note);
	} else if(e.keyCode == 74){
		createNote(B1.note);
	} else if(e.keyCode == 75){
		createNote(C2.note);
	}
}
private function onClick(e:MouseEvent):void{
	createNote(e.currentTarget.note);
}

Agora precisamos processar essas notas antes de escrever a informação de áudio:

private function onSampleData(e:SampleDataEvent):void{
	clearBuffer();
 
	var i:uint;
 
	processNotes();
 
	for(i = 0; i<BUFFER_SIZE; i++){
		e.data.writeFloat(buffer[0][i]);
		e.data.writeFloat(buffer[1][i]);
	}
}
private function processNotes():void{
	var i:uint;
	var t:uint = notes.length;
 
	for(i = 0; i<t; i++){
		notes[i].process(buffer);
	}
}

Parece
que conseguimos mas, se você tocar muitas notas, sua aplicação vai
começar a perder performance. Isso acontece porque toda vez que você
toca uma nota, ela continua sendo processada mesmo que seu volume seja
0.

Para corrigir isso, precisamos retornar um boleano dizendo se aquela
nota está muda ou não:

public function process(buffer:Vector.<Vector.<Number>>):Boolean{
	var l:Vector.<Number> = buffer[0];
	var r:Vector.<Number> = buffer[1];
 
	var i:uint;
	var t:Number = l.length;
 
	var amplitude:Number;
 
	for(i = 0; i<t; i++){
		amplitude = Math.sin(_phase)*_decay/30000;
		_phase += _phaseStep;
 
		l[i] += amplitude;
		r[i] += amplitude;
 
		if(--_decay == 0){
			return true;
		}
	}
	return false;
}

E com esse retorno nós podemos remover essa nota do array:

private function processNotes():void{
	var i:uint = notes.length;
 
	while(--i>-1){
		if(notes[i].process(buffer)){
			notes.splice(i, 1);
		}
	}
}

Aqui está o resultado final: 

Piano Source.

É um pouco confuso, mas se você tem alguma dúvida, sinta-se a vontade
para perguntar.

Até a próxima!