Emulador de GB – 17: Audio (Parte 3)

La idea de este post es completar todos los canales, primero con el resto de features de los canales 1 y 2, y después agregar por completo los canales 3 y 4.

Canales 1 y 2

Duración

Lo primero y mas fácil de agregar es la duración del sonido. En la parte 1 comenté que el sonido empieza a sonar cuando se lo habilita escribiendo un 1 en el bit 7 de cierta posición de memoria, pero que no es posible deshabilitarlo escribiendo un 0 en el mismo lugar.

Una de las formas de deshabilitarlo es configurando una duración. Para esto se define, como en casi todo lo que comenté hasta ahora, un número que se usa en un cálculo que determina la duración. Los registros que se usan son 0xFF11 para el canal 1 y 0xFF16 para el 2, los mismos donde se define el Duty Cycle. Los bits 6 y 7 definen el Duty Cycle, los bits del 0 al 5 definen un valor “L” entre 0 y 63. Este es el cálculo que se hace:

duración = (64 – L) / 256 segundos

Es decir, el canal suena entre 1/256 ~= 0,0039 segundos y 64/256 ~= 0.25 segundos. Mientras mas bajo el número, mas largo el sonido.

Todos los valores posibles de L determinan una duración, pero es posible NO usar este parámetro y que el canal suene hasta frenar por otros motivos. En el mismo byte que se usa para habilitar un canal (0xFF14 y 0xFF19) el bit 6 indica si se tiene que usar o no el valor de duración.

Entonces, para reinterpretar estos valores hice esto:

channel1.Length = (64 - (ValorDe0xFF11 & 0x3F)) / 256.0f;
channel1.UseLength = ((ValorDe0xFF14 & 0x40) != 0);

Y al genera las muestras hice este cambio:

s16 Audio::GetChannel1Sample() {
  if (!channel1Enabled)
    return 0;

  if (channel1.UseLength && channel1.ElapsedTime > channel1.Length) {
    channel1.Enabled = false;
    return 0;
  }

  float temp = fmod(channel1.ElapsedTime, channel1.Period);
  temp /= channel1.Period;

  if (temp <= channel1.DutyCycle)
    return channel1.Volume;
  else
    return -channel1.Volume;
}

El código de GetChannel2Sample es igual.

sound_demo.gb

Antes de seguir viendo mas funcionalidad quiero comentar una forma mucho mas cómoda de testear estas cosas que jugar a un juego con música y comparando con BGB.

Hay mucho software hecho para GameBoy que se consigue en internet y funciona en emuladores como cualquier juego. En particular para sonido hay desde algunos que sirven para probar todas las configuraciones de cada canal hasta programas para componer música como “Little Sound DJ“, que hay músicos que lo usan hoy en dia para componer.

Por el momento voy a usar sound_demo.gb, aunque en algún momento espero que funcione los suficientemente bien como para emular Little Sound DJ y así escuchar canciones famosas en 8 bits.

Ese rom es un programa muy simple de usar, con Select se cambia de pantalla, cada pantalla tiene todos los parámetros configurables de 1 canal, mas una pantalla al final con unos controles globales. Con las flechas se mueve el cursor por las opciones y se cambian los valores, y con Start empieza a sonar el canal.

Por ejemplo, en esa captura del canal 1 se puede configurar el Duty Cycle en “Pat Duty”, la duración en “Sound Len”, el volumen en “Env Init”, la frecuencia en “Frequency”, y el bit para indicar si se usa o no la duración en “Cons Sel”.

Por supuesto también es posible (y muy útil) comparar los resultados usando BGB. Creo que a los 5 minutos de probar sonidos con sound_demo en lugar de juegos puntuales arreglé dos problemas en la emulación, así que no puedo dejar de recomendarlo.

Envelope Sweep

Retomando features, cuando hablé de volumen comenté que se podía hacer algo más que definir un volumen constante. En realidad el volumen como lo expliqué en la parte 1 es el volumen inicial, y se puede configurar que aumente o disminuya y que tan rápido lo hace.

En la documentación lo nombran de diferentes formas: “Envelope”, “Envelope Sweep” y “Volume Sweep”.

Estos nuevos parámetros se configuran en el mismo registro que el volumen incial (0xFF12 para el canal 1, 0xFF17 para el 2). 4 bits ya se usaban para definir el volumen de 0 a 15, el bit 3 se usa para la dirección (1 aumenta, 0 disminuye) y los bits del 0 al 2 definen un valor “n” entre 0 y 7 que determina cada cuánto tiempo cambia el volumen. Como con casi todo, ese valor “n” se usa en un cálculo:

período = n / 64 segundos

Esto quiere decir que cada n/64 segundos el volumen aumenta o disminuye en 1, de forma lineal. Si el volumen baja hasta llegar a 0 o sube hasta llegar a 15. Un ejemplo sería algo así:

El volumen inicial era 15 y baja 15 veces hasta 0 a intervalos regulares.

Así preparé los valores:

channel1.Volume = ValorDe0xFF12 >> 4;
channel1.VolumeDecrease = ((ValorDe0xFF12 & 0x08) == 0);
channel1.EnvelopeSweepCount = ValorDe0xFF12 & 0x03;
channel1.EnvelopePeriod = channel1.EnvelopeSweepCount / 64.0f;

Y así modifiqué Step de Audio:

bool Audio::Step(u8 cycles) {
  float delta = deltaTimePerEmulatedCycle * cycles;
    elapsedTime += delta;

    if (channel1.Enabled) {
      channel1.ElapsedTime += delta;

      if (channel1.EnvelopeSweepStep > 0.0f) {
        channel1.EnvelopeTime += delta;

        if (channel1.EnvelopeTime >= channel1.EnvelopePeriod) {
          if (channel1.VolumeDecrease && channel1.Volume > 0)
            channel1.Volume--;
          else if (!channel1.VolumeDecrease && channel1.Volume < 15)
            channel1.Volume++;

          channel1.EnvelopeTime -= channel1.EnvelopePeriod;
      }
    }
  }

  // Lo mismo para el canal 2

  if (elapsedTime >= sampleTime) {
    elapsedTime -= sampleTime;
    sample = GetSample();
    return true;
  }
  return false;
}

Es imporante tener en cuenta que la configuración de duración y envelope se pueden usar al mismo tiempo, pero que una puede apagar el canal antes que la otra. Si el volumen baja a 0 muy rápido el sonido no va a durar lo que se configuró en la duración, y si baja muy lento puede que se apague por duración antes de llegar a 0.

En sound_demo.gb se configura en Env Init, Env Mode y Env Nb Swp.

Canal 1

Frequency Sweep

Con lo anterior el canal 2 está emulado completamente! Pero el canal 1 tiene una funcionalidad más, parecida a la de Envelope del volumen, pero que se aplica a la frecuencia. Es un poco mas compleja de configurar, pero no tanto. Al igual que con el envelope la frecuencia se cambia cada cierto tiempo, pero el cambio no es simplemente sumar o restar 1.

Se configura todo en el byte de la posición 0xFF10. El bit 3 es la dirección, 0 para que la frecuencia aumente, 1 para que disminuya. Los bits del 4 al 6 definen un número “n” entre 0 y 7 que determina el período al que se cambia la frecuencia (como siempre, se usa en un cálculo). Los bits 0 a 2 definen un número “m” entre 0 y 7 determinan cuánto se cambia la frecuencia.

Primero el cálculo del período que es más fácil:

período = n / 128 segundos

Y el cambio de frecuencia se hace con esta fórmula:

frecuenciaX = frecuenciaX ± (frecuenciaX / 2^m)

O sea, se le suma o resta una diferencia que es el valor actual de frecuenciaX dividido por 2 “m” veces. Entonces, si “m” aumenta, el cambio de frecuencia es mas lento. Un ejemplo de cómo se vería esta onda es:

Se puede ver como los ciclos se hacen cada vez mas cortos

Al modificar frecuenciaX el valor tiene que quedar en el rango [0,2047], así que hay que chequear el nuevo valor antes de modificar la frecuencia final.

Si frecuenciaX es menor a 0, se pone en 0 y deja de modificarse, pero el canal sigue sonando. Si la frecuencia es mayor a 2047 el canal se deshabilita. Se puede verificar este comportamiento con sound_demo.gb configurando el canal para que ignore la duración y no tenga Envelope Sweep, pero configurando el Frequency Sweep.

Cuando la dirección “Swp Mode” es 0 (aumenta) el sonido se tiene que cortar en cierto momento, pero cuando es 1 el sonido queda sonando en un tono muy grave.

Si “Swp Time” o “Swp Shifts” valen 0, la frecuencia no cambia.

Con todo esto el código quedó así:

channel1.SweepTime = (NR10 >> 4) & 0x07;
channel1.SweepIncrease = (NR10 & 0x08) == 0;
channel1.SweepShifts = NR10 & 0x07;
channel1.SweepTimePeriod = channel1.SweepTime / 128.0f;
channel1.FrequencyXShadow = channel1.FrequencyX;
channel1.SweepEnabled = channel1.SweepTime > 0 && channel1.SweepShifts > 0;

Y en Audio::Step, si el canal 1 está habilitado además del Envelope Sweep hago:

if (channel1.SweepTimePeriod > 0.0f && channel1.SweepShifts > 0 && channel1.SweepEnabled) {
  channel1.SweepElapsedTime += delta;

  if (channel1.SweepElapsedTime >= channel1.SweepTimePeriod) {
    u16 temp = channel1.FrequencyXShadow >> channel1.SweepShifts;
    if (channel1.SweepIncrease) {
      channel1.FrequencyXShadow += temp;
      if (channel1.FrequencyXShadow > 2047) { // overflow
        channel1.FrequencyXShadow = 2047;
        channel1.Enabled = false;
      }
    } else {
      channel1.FrequencyXShadow -= temp;
      if (channel1.FrequencyXShadow > 2047) { // underflow
        channel1.FrequencyXShadow = 0;
        channel1.SweepEnabled = false;
      }
    }

    channel1.Frequency = 131072.0f / (2048 - channel1.FrequencyXShadow);
    channel1.Period = 1 / channel1.Frequency;

    channel1.SweepElapsedTime -= channel1.SweepTimePeriod;
  }
}

Lo de FrequencyXShadow es para no pisar el valor original de FrequencyX, porque si el sonido se vuelve a iniciar tiene que empezar desde la frecuencia original.

Con todo esto están los canales 1 y 2 completos! Muchas canciones de muchos juegos deberían sonar bastante bien. Hay varias situaciones que se pueden dar en el hardware original que producen un resultado no esperado y eso afecta a algunos juegos, pero por ahora no las voy a implementar. Están documentadas como Obscure Behaviors.

Canal 3 – Wave

Al 3er canal se lo conoce como “Wave”, porque en lugar de generar un pulso, se lo puedo configurar para usar cualquier forma de onda. “Cualquier” es una forma de decir, porque obviamente tiene ciertas limitaciones.

En cuanto a features se puede configurar, además de la forma de la onda, una duración, un volumen constante y una frecuencia constante. No tiene sweep de ningún tipo.

Duración

Conceptualmente es igual a los primeros dos canales, se configura un valor en cierta posición de memoria, el valor se usa en un cálculo que determina la duración final de la nota, y hay un bit de otro registro que sirve para usar o ignorar la duración configurada.

El registro para la duración es 0xFF1B y en este caso se usa el byte completo, así que el valor de “L” va de 0 a 255, y el cálculo es:

duración = (256 – L) / 256 segundos

El bit 6 de 0xFF1E determina si se usa o no la duración, y si se está usando el canal se deshabilita cuando pasa el tiempo.

Creo que no hace falta ver el código de esto, es igual al de los otros canales salvo por algunos valores.

Frecuencia

Se configura igual que los otros canales, escribiendo un valor entre 0 y 2047, dividido en 2 partes, los 8 bits mas bajos en la posición 0xFF1D y los 3 mas altos en los 3 bits mas bajos de 0xFF1E. La única diferencia es el cálculo que se hace para la frecuencia final:

frecuencia = 65536 / (2048 – frecuenciaX)

Es decir, entre 32Hz y 64KHz.

Volumen

En lugar de definir un valor de 0 a 15 como en los otros canales se define un valor de 0 a 3. Cada valor corresponde a cuanto se modifica el valor de la onda que se configuró:

0 -> Mute
1 -> No se modifica
2 -> Se divide a la mitad
3 -> Un 25% del valor original

Se usan sólo los bits 5 y 6 del registro 0xFF1C, el resto se ignora. Es decir:

channel3.Volume = ((NR32 & 0x60) >> 5);

Mas adelante voy a implementar GetChannel3Sample y voy a mostrar como usé el volumen.

Habilitar

Al igual que los primeros dos canales, el canal 3 empieza a sonar sólo si se escribe un 1 en el bit 7 del registro 0xFF1E, pero además hay otro bit de otro registro que determina si el canal suena o no, el bit 7 de 0xFF1A. Este otro bit SI se puede usar para frenar el canal escribiendo un 0.

Onda

Esta es la parte mas interesante del canal 3, se puede configurar cualquier forma de onda dentro de los límites del hardware.

La forma está definida por 32 valores que definen la amplitud de la onda en 32 puntos consecutivos. Estos 32 valores son valores de 4 bits y se definen en 16 bytes de memoria, desde 0xFF30 hasta 0xFF3F. Cada uno de estos bytes contiene 2 de los valores, y están todos en memoria en orden. Es decir, el byte de 0xFF30 contiene 2 valores, el primero en los 4 bits mas altos, el segundo en los 4 mas bajos; el byte de 0xFF31 contiene el 3er valor de la onda en los 4 bits mas altos, y el 4to en los 4 mas bajos, etc. Algo así:

Esta onda corresponde a los bytes: 0x01 0x23 0x45 0x67 0x89 0xAB 0xCD 0xEF 0xFE 0xDC 0xBA 0x98 0x76 0x54 0x32 0x10

Entonces lo que hay que hacer para generar las muestras del canal 3 es calcular según el tiempo que lleva sonando el canal que instante del ciclo corresponde, de ese valor obtener un byte, y de ese byte obtener el valor concreto. Después se lo divide (o no) por el volumen, y ese es el valor final.

s16 Audio::GetChannel3Sample() {
  if (!channel3.Enabled)
    return 0;

  if (channel3.Counter && channel3.ElapsedTime > channel3.Length)
    channel3.Enabled = false;

  if (channel3.Volume == 0)
    return 0;

  float temp = fmod(channel3.ElapsedTime, channel3.Period) / channel3.Period;
  temp *= 16;

  float byteNum;
  float reminder = modf(temp, &byteNum);

  u8 waveByte = WaveRAM[(int)byteNum];
  if (byteNum >= 4;
  else
    waveByte &= 0x0F;

  if (channel3.Volume > 1)
    waveByte >>= (channel3.Volume - 1);

  return (s16)waveByte - 8;
}

WaveRAM es un array de 16 “u8″s, que se actualiza en MMU::Write cuando se escribe en el rango [0xFF30,0xFF3F].

sound_debug.gb tiene una pantalla para probar el canal 3, pero hay dos problemas: por alguna razón no me funciona, pero además no tiene forma de ver o modificar la onda que se está usando, así que no es tán útil. No encontré ningún programa que muestres la onda configurada, en el próximo post voy a hablar de una tool que hice para debuggear sonido, pero mientras tanto una alternativa es capturar el audio de alguna parte en la que suene el canal 3 con un programa como Audacity y ver la onda ahí.

Canal 4 – Ruido

Este canal usa un generador pseudo aleatorio para producir una onda con valores que no sigan un patrón y así generar ruido. En realidad la onda sigue un patrón, porque el generador tiene límites y a partir de cierto punto la secuencia generada empieza a repetirse, pero la forma del ciclo da un resultado que se parece lo suficiente a ruido blanco.

Todavía no lo tengo funcionando del todo bien, pero quiero aprovechar este post para explicar lo que entendí hasta ahora.

Mas allá de la forma de la onda que es pseudo aleatoria, el canal tiene Envelope Sweep como los primeros 2 canales y una duración opcional como todos los demás. La configuración de la duración, el envelope y las formas de habilitar el canal son iguales a las del canal 1 y 2, así que no voy a repetirlo.

LFSR

Hay distintas formas de generar números pseudo aleatorios en una PC, una es usando un “Linear Feedback Shift Register” (o “LFSR”) con “XOR”, y el GameBoy usa esa técnica. No conozco mucho sobre esto ni la teoría que lo justifica, pero abajo dejo el link a Wikipedia para quien lo quiera investigar. Voy a dar una explicación mas práctica que es lo que mas me interesa para implementarlo en el emulador.

Partiendo del nombre, lo primero que hay que tener es un registro de cierto tamaño, que en este caso va a ser un “u16” (16 bits), aunque se usan solo 15.

Segundo, la parte de “shift” significa que para producir el output se hace un shift de los bits del registro. El que era el bit 0 antes del shift pasa a ser el output, y se agrega un bit nuevo en la posición 14. El valor de este bit es un XOR entre el bit que salió del regitro y el nuevo valor del bit 0 después del shift. Por esto de que el bit que sale se usa para el valor del bit nuevo es la parte de “Feedback”.

El GameBoy tiene una particularidad, se puede configurar el LFSR para que trabaje con un registro de 7 bits en lugar de 15, y en ese caso el resultado del XOR pisa el valor del bit 6 además del bit 14. El resultado de usar un registro mas chico o mas grande es que tan “ruido” es el ruido, mientras mas chico es el registro, mas se parece el ruido a una nota, porque la secuencia de valores se empieza a repetir mucho antes. Para usar 7 bits en lugar de 15 hay que escribir un 1 en el bit 3 del byte de 0xFF22.

Frecuencia

Este canal no tiene una onda, así que no se le puede definir una frecuencia, pero el hardware usa un clock interno para hacer el shift del LFSR y esa frecuencia si es configurable. El cálculo es medio raro porque se definen 2 valores diferentes que se usan en el mismo cálculo, un divisor “r” entre 0 y 7 y un shift “s” entre 0 y 15. El divisor son los primeros 3 bits de 0xFF22 y el shift son los últimos 4. El cálculo con esos valores es:

frecuencia = 524288 / (r * 2^(s+1)) Hz

Cuando r vale 0, en el cálculo se usa 0.5 en vez de 0.

Para los valores mas altos este cálculo da frecuencias muy bajas y no se escucha nada relamente. Ej, para r = 7 y s = 15, la cuenta da 2.28 Hz. Esto es algo que tengo que seguir investigando porque en BGB y Gambatte se escucha ruido para mas valores que en mi emulador, así que seguro algo me está faltando.

Implementación

La salida del LFSR es un bit, 0 o 1, y se está actualizando constantemente a la frecuencia configurada. Al tomar una muestra del canal 4 hay que tomar el último valor que generó el LFSR, si es 1 devolver el valor del volumen actual, si es 0 el valor del volumen pero negativo, así que GetChannel4Sample es muy simple:

s16 Audio::GetChannel4Sample() {
    if (!channel4.Enabled)
        return 0;

    if (channel4.Counter && channel4.ElapsedTime > channel4.Length)
        channel4.Enabled = false;

    return lfsrOutput == 0 ? channel4.Volume : -channel4.Volume;
}

“lfsrOutput” es el último valor que generó el LFSR, que es justamente la parte interseante de este canal. Primero, así traduzco los valores de los registros a una frecuencia y, mas importante, un período:

channel4.ShiftClockFrequency = (NR43 >> 4);
channel4.CounterWidth15 = ((NR43 & 0x08) == 0);
channel4.DividingRatio = NR43 & 0x07;
float r = channel4.DividingRatio == 0 ? 0.5f : channel4.DividingRatio;
channel4.Frequency = 524288.0f / (r * (1 << (channel4.ShiftClockFrequency + 1)));
channel4.Period = 1 / channel4.Frequency;

Eso quiere decir que cada “channel4.Period” segundos tengo que simular un paso del LFSR, así que al actualizar el componente de Audio ejecuto tantos pasos de LFSR como pueda en el tiemp que estoy simulando:

bool Audio::Step(float deltaTime) {
  ...

  if (channel4.Enabled) {
    channel4.ElapsedTime += deltaTime;

    UpdateLFSR(deltaTime);

    // Envelope Sweep del canal 4
  }

  if (elapsedTime >= sampleTime) {
    elapsedTime -= sampleTime;
    sample = GetSample();
  }
  return false;
}

Y así hice UpdateLFSR:

void Audio::UpdateLFSR(float deltaTime) {
  while (deltaTime > channel4.Period) {
    u8 firstBit = lfsr & 0x0001;
    lfsr >>= 1;
    bool newBit = firstBit ^ (lfsr & 0x0001);

    if (newBit)
      lfsr |= 0x4000;
    else
      lfsr &= 0xBFFF;

    if (!channel4.CounterWidth15) {
      if (newBit)
        lfsr |= 0x0040;
      else
        lfsr &= 0xFFBF;
    }

    lfsrOutput = firstBit ? 0 : 1;

    deltaTime -= channel4.Period;
  }
}

Un detalle que implementé pero no mencioné antes porque no se que tan importante es, es que el output del LFSR se invierte, así que en realidad si el bit 0 era un 0, el output es un 1.

El problema con esto es que el LFSR genera un 0 o un 1, y al leer una muestra el valor va a ser siempre 0 o 1, y a baja frecuencia termino teniendo “ondas” que son casi una línea recta, por eso deja de sonar cuando empiezo a subir los valores de los registros. Creo que la solución a esto es interpolar los valores, para de alguna forma devolver en una sola muestra información de mas de un bit, pero todavía no lo pude probar bien. Seguramente mire como lo resuelven otros emuladores en lugar de probar a ciegas, hay muy poca documentación sobre esto. Por ahora mi ruido se ve así:

Conclusión

Bueno, acá termina otro post largo, pero no termina el tema del sonido. Queda la configuración global de volumen y del stereo, pero también muchos detalles para implementar y casos especiales que hay que considerar, y además quiero contar sobre la tool que hice para debuggear las muestras generadas. Probablemente siga con Optimizaciónes y GameBoy Color antes de retomar con la (espero) última parte sobre audio.

Sobre los canales en si, son medio complejos de entender porque la información general está muy compactada, pero espero que se haya visto que la implementación en si no es tan compleja una vez que la arquitectura del emulador está preparada para emular sonido de forma correcta.

Links

Deja un comentario

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión /  Cambiar )

Google photo

Estás comentando usando tu cuenta de Google. Cerrar sesión /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión /  Cambiar )

Conectando a %s

Crea tu sitio web con WordPress.com
Empieza ahora
A %d blogueros les gusta esto: