Emulador de GB – 16: Audio (Parte 2)

En el post anterior expliqué como emulé la funcionalidad básica para reproducir sonido en el emulador.

El oído es muy sensible al sonido y notamos mucho cualquier inconsistencia. Cualquier silencio o salto durante la reproducción lo podemos escuchar aunque dura solo unos milisegundos, por eso es muy importante la sincronización.

Hasta ahora no me preocupaba tanto el tema porque si el juego se ve mas rápido o mas lento igual podemos acostumbrarnos, pero con el sonido es RE importante el tema.

Además es importante la sincronización porque si se generan los sonidos a diferente frecuencia estamos cambiando los tonos. Todos conocemos el efecto de acelerar un video y el audio, que hace que las voces se vuelvan mas agudas. Es posible corregir esto en ciertos contextos, pero para el emulador realmente no tiene sentido estirar o comprimir el sonido, hay que reproducirlo a la velocidad original.

Estado actual

Con lo que expliqué en el post anterior pueden ocurrir dos problemas:

  • El emulador anda lento lento y no puede generar un segundo de audio en un segundo real.
  • O anda rápido y genera un segundo de audio antes de reproducir completo el segundo anterior.

Si el problema es el primero, antes de sincronizar cosas hay que optimizar. Si el emulador no tiene la velocidad suficiente no hay forma de sincronizarlo con ninguna técnica. Una vez que se arregle el problema 1 seguramente se pase a tener el 2do, y ahí recién se puede aplicar lo que voy a contar.

En mi caso yo estaba en el caso 1 compilando en modo debug y en el caso 2 compilando en modo release, pero hice algunas optimizaciones para estar en el caso 2 en los dos modos en mi PC. En otro post voy a hablar sobre optimizaciones del emulador y como medir la velocidad a la que funciona.

Otro dato importante del post anterior es que para arreglar el problema del delay de 1 segundo por reproducir el audio cada 44100 muestras generadas hice que haya un delay de 1 solo frame, reproduciendo el audio cada 735 muestras, equivalente a 1/60 segundos de muestras. Acá subí 4 videos mostrando como suenan los 4 problemas.

60 cuadros por segundo

En gaming en general es importante y deseable que los juegos funcionen a una cantidad de cuadros por segundo constantes. El motivo no es el mismo que con el emulador, porque el juego no debería funcionar mas rápido o mas lento sólo porque se dibujan mas o menos cuadros por segundo.

Mas allá de que nuestro cerebro no es tan sensible al video como al audio, también notamos bastante cuando el video tiene un frame rate variable, y eso es una de las cosas que se intenta evitar en gaming.

Cómo conozco las técnicas que se usan en gaming mis primeros intentos para arreglar el emulador fueron por este lado y NO funcionaron, pero quiero contarlo igual para que sepan cuales son y no lo intenten en sus casas.

VSync

Cuando hablé de la GPU hablé de esto, y es algo que es tan cierto hoy como en cualquier generación de consolas. Las pantallas no dibujan toda la imagen al mismo tiempo, dibujan pixel por pixel de izquierda a derecha, línea por línea de arriba hacia abajo, y cuando terminan con toda la pantalla generan una señal en el hardware.

La pantalla del GameBoy funciona a 60Hz, y los monitores actuales también, entonces una opción es sincronizar el emulador usando la señal de VSync.

SFML tiene una opción para que una ventana use VSync, así que durante un tiempo la usé y parecía funcionar, pero en algún momento dejó de funcionar y no se realmente por qué. Mas allá de eso, cuando funcionó la sincronización el sonido se escuchaba raro, así que algo mas pasaba.

Sleep

Cuando una aplicación sincroniza algo con VSync, lo que suele hacer es realizar cierta cantidad de trabajo (que tarda menos de 1/60 segundos en completarse) y después espera sin hacer nada hasta recibir la señal del monitor.

Se puede simular algo parecido pero en lugar de esperar hasta recibir la señal, se espera a que pase el tiempo restante del frame.

O sea:

  • Al empezar un frame se guarda el tiempo actual
  • Al terminar el trabajo de un frame se toma el tiempo actual
  • Se restan los dos tiempo para obtener el tiempo que se estuvo trabajando
  • Se le resta ese valor a 1/60 segundos, para obtener el tiempo que falta para que termine un frame
  • Se frena (duerme) el proceso durante ese tiempo

Si esto se hace bien, cada frame tarda 1/60 segundos y la sincronización es equivalente al VSync.

En C++11 en la biblioteca standard hay 2 headers con funciones útiles para esto: thread y chrono. En partícular, thread tiene la función sleep_for que frena el proceso actual durante cierto tiempo, y chrono tiene la funcion now para saber el tiempo actual y el tipo de dato duration para representar intervalos de tiempo.

El uso de chrono::duration es medio raro y creo que no vale la pena explicarlo, pero tiene la precisión que se necesita para estas cosas.

Mi implementación de esto fue algo así:

using frame_duration = std::chrono::duration<float, std::ratio<1,1>>;
...

int main() {
  ...
  frame_duration frameTime(1/60.0f);
  auto frameStartTime = chrono::system_clock::now();

  while (gameWindow.IsOpen()) {
    ...
    bool frameFinished = gpu.Step(lastOpCycles * 4);
    if (audio->Step(lastOpCycles)) {
      // lo mismo del post anterior
    }

    if (frameFinished) {
      // actualizo las ventanas
      // proceso eventos con SFML

      auto currentTime = chrono::system_clock::now();
      auto diff = currentTime - frameStartTime;
      if (diff < frameTime) {
        std::this_thread::sleep_for(frameTime - diff);
        // la línea que sigue se ejecuta después
        // de "frameTime - diff" segundos
        frameStartTime = chrono::system_clock::now();
      }
    }
  }
}

Ya no tengo esta versión del código así que lo hice medio de memoria y puede tener errores.

Ese código es bastante común, pero de todas formas no me estaba funcionando del todo bien, y cuando funcionaba me generaba un sonido con mucho ruido, igual que cuando pude usar VSync, así que de nuevo era claro que había otro problema además de la sincronización.

Algo raro que notaba es que si reproducía un segundo de audio, el segundo sonaba bien. Pero si reproducía 60 sonidos de 1/60 segundos tenía mucho ruido. Estuve muchas horas tratando de saber que pasaba, haciendo capturas de audio, comparando archivos .ogg, probando cosas medio aleatorias como reproducir de a 2 frames en lugar de 1. Pensé que era un problema de en donde estaba reproduciendo los sonidos así que moví la llamada a Play a varios lugares, pero nada funcionaba bien.

Solución

Después de muchas pruebas en algún descansó me decidí por probar algo que venía evitando porque no lo entendía del todo y pensaba que tenía que modificar mucho el emulador si lo hacía, pero ya no me quedaban ideas.

Hasta ahora mi solución era emular ciclos de la CPU uno después de otro, y parar cada vez que tenía información suficiente para reproducir un frame de audio. O sea, la velocidad de emulación de la CPU determina la velocidad de reproducción del audio y como expliqué esto es dificil de sincronizar.

Pero la sincronización de los componentes puede darse en la otra dirección. Puedo poner a reproducir el audio, que va a estar sincronizado con el hardware de la PC, y cada vez que el audio necesita muestras emulo los ciclos necesarios de CPU.

Esto no es muy intuitivo y hay que saber que es posible reproducir audio sin tener la información completa, algo que normalmente no se enseña porque no tiene tanta utilidad. Yo lo había leído en uno de los blogs que mencioné en el post anterior, pero realmente lo empecé a entender con el video de OneLoneCoder.

El flujo del programa cambia y ya no es tan fácil de seguir, así que voy a hacer unos diagramas.

Solución actual que no funciona bien:

Solución nueva:

La ventaja de esta solución es que de ese diagrama hay dos cosas que pasan automáticamente: la pregunta de si hay o no suficientes muestras de audio, y la reproducción.

El hardware de sonido reproduce las muestras de a grupos, cuando no tiene muestras (porque recién empieza o porque ya reprodujo las que tenía) ejecuta una función para obtener nuevas. En esta función hay que avanzar el emulador lo mas rápido posible hasta tener un grupo de muestras y como resultado de la función simplemente actualizamos unos valores internos con las muestras nuevas. Cuando esa función termina el hardware de audio reproduce las muestras hasta terminarlas, y durante este tiempo el emulador no avanza.

De esa forma se logra la sincronización, y es mucho mas precisa que usar sleep_for.

SFML

Después de entender como funciona esta solución en la teoría, hay que ver como llevarla a la práctica, y no estaba seguro de como hacer algo así con SFML.

Acá es cuando me di cuenta de otro problema que explicaba por qué, incluso cuando logré sincronizar bien las cosas con sleep_for, el audio se escuchaba con mucho ruido.

Yo estaba usando las clases Sound y SoundBuffer, pero mirando la documentación no había nada para hacer lo que necesitaba con esta nueva solución.

SFML tiene otra clase para reproducir audio, Music. Esta clase es ideal para reproducir audios largos porque a diferencia de Sound que necesita un buffer con todas las muestras, Music puede obtenerlas en el momento, streameando. Esto se usa para reproducir archivos de música grandes (decenas o cientos de MB) sin cargar todo el archivo completo en memoria. La clase Music puede cargar parte de un archivo en un buffer interno y lo actualiza con mas muestras constantemente.

El problema con usar Sound para el emulador es que aunque estoy emulando y reproduciendo sonidos cortos (milisegundos), el resultado que necesito no es un sonido corto. No se pueden sincronizar reproducciones de Sound para tener un sonido constante, no iba a poder arreglarlo nunca así. Por eso si reproducía de a un segundo se escuchaba bien pero 60 sonidos de 1/60 con las mismas muestras se escuchaban mal.

El sonido del emulador es mas parecido en la práctica a lo que hace Music, es una sola “canción” que empieza cuando abro el emulador y termina cuando lo cierro, el flujo de muestras es continuo aunque el juego esté en silencio.

Lo malo de Music es que, igual que Sound, está pensaba para reproducir sonido desde archivos, pero buscando un poco mas encontré finalmente la solución: SoundStream.

SoundStream es una clase abstracta pensada para crear clases derivadas que tomen las muestras de cualquier lado. En mi caso tengo que hacer mi propia clase derivada de SoundStream que genere las muestras emulando ciclos de la CPU. La clase Music es una especialización de SoundStream hecha para funcionar con archivos.

Implementación

Para hacer todo esto primero tuve que modificar main para que todo lo que relacionado con emular la consola (instrucciones de CPU, update de GPU, Audio, etc) se ejecute dentro de una única función. Actualmente tengo un montón de variables globales y una función que los usa y le paso la función a mi implementación de SoundStream, pero mi plan es encapsular todo esto en un objeto que represente la consola completa.

Mas allá de los detalles de mi clase derivada de SoundStream, la función realmente importante quedó así:

bool CustomAudioStream::onGetData(Chunk& data) {
  // 735 muestras de audio son 1 frame
  while (sampleIndex < 735)
    mainLoopFunction();

  data.samples = samples;
  data.sampleCount = 735;
  sampleIndex = 0;
  return true;
}

SFML se encarga de llamar a esa función cuando ya no tiene muestras y de reproducir el sonido según los samples de “data”.

main.cpp quedó así:

void MainLoop() {
  // ejecutar instrucción de CPU
  // actualizar GPU y setear frameFinished
  // actualizar timer, dma, etc
  // actualizar audio y el buffer de muestras
}

int main() {
  ...

  CustomAudioStream audioStream(MainLoop, sampleIndex, samples);
  // esto inicia el thread de audio
  audioStream.play();

  while (gameWindow.IsOpen()) {
    // acá antes se hacía lo que ahora está en MainLoop

    if (frameFinished) {
      // actualizo las ventanas

      sf::Event event;
      while (gameWindow.PollEvent(event)) {
        // proceso eventos de SFML
      }
    }
  }

  audioStream.stop();
  return 0;
}

Con estos cambios se arreglaron los problemas!

El sonido todavía se escucha raro, pero ya no es por problemas de sincronización ni de reproducción, es porque me falta emular mucha funcionalidad y mas canales.

Extra

Al hacer todo esto el emulador funciona mucho mejor, pero está bueno seguir teniendo la opción de emular lo mas rápido posible al menos por 2 motivos:

  • Si la PC es buena, se tiene automáticamente una opción de fastforward.
  • Para optimizar el emulador hay que saber a cuandos FPS funciona, pero sincronizar con el audio lo limita a 60.

Hacer esto es relativamente trivial, es cuestión de llamar a MainLoop dentro del while de main o dentro de CustomAudioStream de acuerdo a un booleano. Cuando se aprieta una tecla se pausa el audio y se empieza a llamar a MainLoop en main.cpp, cuando se suelta la tecla se vuelva a reproducir el audio y se deja de llamar a MainLoop en main.cpp.

int main() {
  ...

  bool syncWithAudio = true;

  CustomSoundStream audioStream;
  if (syncWithAudio)
    audioStream.play();

  while (gameWindow->IsOpen()) {
    if (!syncWithAudio)
      MainLoop();

    if (frameFinished) {
      // actualizar ventanas

      sf::Event event;
      while (gameWindow.PollEvent(event)) {
        // proceso eventos de SFML
        
        if (event.type == sf::Event::KeyPressed
            && event.key.code == sf::Keyboard::F) {
          syncWithAudio = false;
          audioStream.stop();
        }
        if (event.type == sf::Event::KeyReleased
            && event.key.code == sf::Keyboard::F) {
          syncWithAudio = true;
          audioStream.play();
        }
      }
    }
  }

  if (syncWithAudio)
    audioStream.stop();
}

Conclusión

Este post es mas corto de lo normal (bien!) pero me pareció importante dedicarle un tiempo para hablar en detalle de esto. El resultado de sincronizar la emulación a la reproducción de audio es muy bueno y requiere mucho menos trabajo del que pensaba. Todavía faltan emular muchas cosas para que se escuche como el audio original, pero tener el juego funcionando a la velocidad normal es re importante.

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 <span>%d</span> blogueros les gusta esto: