< Volver 

Rod and Reef: Subtítulos

2026-09-08

Un pipeline en Python que transcribe las voces de Rod and Reef con OpenAI Whisper, identifica qué línea del guion es cada toma improvisada, genera los subtítulos palabra por palabra como módulos de Luau y los sube a Roblox. En el juego, un reproductor guiado por reloj los muestra sin desfasarse de la voz.

PythonLuauRobloxAudioUI
Ir a vista rápida

Subtítulos que esperan a la voz.

Advertencia

Rod and Reef todavía no ha sido lanzado, y no se pueden mostrar videos ni GIFs de este sistema hasta que salga.

Resumen. Rod and Reef tiene voz grabada en tres formatos: 74 frases cortas del protagonista (los barks ambientales), diálogos largos de cinemáticas y los monólogos de un nivel secreto, la Dream Maze. Todos llevan subtítulos, y en los barks cada palabra aparece cuando el actor la dice. Sincronizar a mano las 888 palabras de los barks, más las de todo lo demás, no era viable, y el actor además improvisaba, así que el guion tampoco servía como fuente del texto. Este artículo describe el pipeline que hice para generarlos. Una fase transcribe cada audio con faster-whisper large-v3 en la GPU y guarda los tiempos de cada palabra. Otra identifica qué línea del guion es cada toma con un emparejamiento difuso restringido por categoría. Una tercera escribe un módulo de Luau por audio con la duración y la pausa de cada palabra, ancladas al instante en que se dice. La última sube los audios a Roblox. En el juego, el reproductor revela las palabras siguiendo un reloj y no esperas encadenadas, así que no acumula desfase.

1. El problema

Los barks son frases de uno a siete segundos que el protagonista dice mientras juegas (están descritos en otro artículo de este portafolio). Un subtítulo que aparece entero y de golpe se lee antes de que el actor termine la primera palabra, y uno que se escribe a velocidad constante se adelanta o se atrasa según el ritmo de la toma. Lo que yo quería era que cada palabra empezara a aparecer justo cuando se dice, con las pausas del actor donde el actor hizo la pausa.

Eso requiere un tiempo de inicio por palabra. Para los barks son 888 palabras, y las cinemáticas y la Dream Maze suman bastantes más. Además el guion no era una fuente confiable: el actor improvisó, cambió palabras, alargó algunas frases muchísimo, y el documento ni siquiera era consistente consigo mismo. Las tres primeras secciones numeraban sus líneas localmente (de 1 a 9, de 1 a 8 y de 1 a 9), y de la cuarta en adelante la numeración era global, de 33 a 100, con un hueco muerto entre 27 y 32. Así que el guion solo podía servir para identificar qué línea era cada audio, y el texto del subtítulo tenía que salir de lo que se dijo de verdad.

2. La forma del pipeline

El pipeline tiene cinco fases, cada una escribe sus resultados en una carpeta de salida y se puede volver a correr sola:

  1. Leer el guion y convertirlo en un banco de líneas por categoría.
  2. Transcribir cada audio con tiempos por palabra.
  3. Emparejar cada transcripción con una línea del guion.
  4. Generar un módulo de Luau por bark.
  5. Subir los audios a Roblox y regenerar los módulos con sus identificadores.

Solo la fase 1 usa la GPU, y es la cara. Su salida cruda, un registro con cada palabra, su inicio, su fin y su probabilidad, se guarda entera y nunca se borra. Cambiar el umbral del emparejamiento o la fórmula de los tiempos no obliga a volver a transcribir, porque las fases 2 y 3 corren encima de ese registro. La transcripción es incremental por defecto, salvo que se pida lo contrario, y cuando una toma tiene una regrabación (3 (v2).wav) se queda con la versión más nueva.

3. Transcribir

La transcripción usa faster-whisper, una reimplementación de Whisper [1] sobre CTranslate2, con el modelo large-v3 en CUDA y precisión de 16 bits, búsqueda en haz de ancho 5 y tiempos por palabra. Dos parámetros se apartan de los valores por defecto y los dos tienen una razón concreta.

El condicionamiento con el texto anterior está apagado. Cada bark es una frase suelta y sin contexto, y arrastrar el texto del clip anterior es la receta clásica para que Whisper alucine repeticiones en clips cortos. El filtro de detección de voz (VAD) también está apagado, porque son tomas de estudio ya recortadas, y el VAD se come las interjecciones cortas, como un “YEAHHH!!” del bote, tratándolas como ruido.

En Windows hubo un problema que parecía de hardware y no lo era. CTranslate2 no trae las bibliotecas de CUDA dentro de su paquete: vienen de los paquetes nvidia-*-cu12, que las dejan en site-packages, y Windows no busca DLLs ahí. La primera llamada fallaba con “cublas64_12.dll is not found”, que se lee como una GPU rota. Un módulo pequeño registra esas carpetas en la ruta de búsqueda de DLLs antes de importar faster-whisper, y con eso corre en una RTX 5060 Ti.

4. Qué línea es este audio

4.1 Espacio de búsqueda

Los audios estaban organizados en carpetas por categoría (estar quieto, caminar, bote, pesca, chistes, reacciones por NPC, momentos especiales y tramo final), y cada carpeta se mapea a la sección del guion que le corresponde. Hay 14 de estos ámbitos. Restringir la búsqueda a las líneas del ámbito, unas pocas en vez de las 94 del documento, es lo que hace viable el emparejamiento difuso: con tan pocas candidatas, una improvisación todavía se parece más a su línea que a cualquier otra.

4.2 Un puntaje para dos tipos de improvisación

El actor improvisaba de dos maneras, y cada una rompe un puntaje distinto. A veces reordenaba o cambiaba palabras sueltas. Otras veces se extendía muchísimo sobre una línea corta: “Let’s see…” en el guion se convirtió en siete segundos sobre cíclopes, sirenas y un dios del mar enojado.

Los dos puntajes salen de RapidFuzz y se basan en la distancia de edición con inserciones y borrados (Indel), una variante de la de Levenshtein [2]. Para dos cadenas a y b, la similitud base es

ratio(a,b)=100(1−dIndel(a,b)|a|+|b|)

token_sort_ratio ordena las palabras de las dos cadenas antes de compararlas, así que absorbe el reordenamiento y sigue castigando las palabras que faltan, que es lo que separa dos líneas parecidas de la misma sección. partial_ratio compara la cadena corta contra la mejor ventana del mismo largo dentro de la larga, así que reconoce una línea del guion como un tramo dentro de una improvisación larga, donde token_sort_ratio se hunde por la diferencia de longitud. Ninguno cubre al otro, así que el puntaje es el máximo:

s(t,g)=max⁡(token_sort(t,g), partial(t,g))

sobre la transcripción t y la línea del guion g, las dos normalizadas: sin acotaciones entre paréntesis, sin puntuación y en minúsculas.

partial_ratio tiende a inflar los puntajes de las líneas cortas. Eso lo contiene una regla de margen: una asignación automática exige un puntaje de al menos 80 y una ventaja de al menos 8 puntos sobre la segunda candidata. Si el puntaje inflado favorece a la ganadora, también favorece a las rivales, la ventaja se achica y el caso pasa a revisión manual.

4.3 Uno a uno

Dos audios no pueden quedarse con la misma línea. Dentro de cada ámbito se calculan todos los pares (audio, línea) con su puntaje, se ordenan de mayor a menor y se recorren: el primer par con los dos lados libres se casa. Es un emparejamiento goloso. No siempre es el óptimo, que se encontraría con el método húngaro [3], pero con puntajes tan separados como estos da lo mismo, y tiene una propiedad útil: el audio que sobra no le roba la línea a otro. La carpeta del bote tenía 10 audios para 9 líneas, y el décimo cayó solo a revisión.

Una transcripción vacía es la firma de una línea sin palabras. Whisper no sacó palabras porque no las había, y eso es una señal mucho más limpia que la duración. El tarareo del bote se empareja así con la única línea no verbal de su ámbito, antes de que el emparejamiento difuso lo arrastre a cualquier lado, y su módulo sale sin texto: el clip suena completo y no se dibuja ningún subtítulo.

5. Tiempos por palabra

5.1 El modelo

Un bark es una sesión de un solo nodo, la frase completa, partida en subnodos de una palabra. Cada palabra i tiene una duración di, el tiempo que la máquina de escribir tarda en revelarla, y una pausa δi, la quietud antes de la siguiente. Si Ti es el instante en que empieza a aparecer la palabra i,

Ti+1=Ti+di+δi

Whisper da, para cada palabra, el instante en que empieza a decirse, si, y el instante en que termina, ei. El texto de cada subnodo es la palabra transcrita y nunca la del guion.

La duración es cosmética y sale del largo de la palabra, no del audio, para que ninguna palabra aparezca de golpe ni se arrastre:

dˆi=clamp(0.03|wi|, 0.08, 0.35)

con |wi| el número de caracteres.

5.2 La primera fórmula y su deriva

La primera versión usaba el hueco crudo entre palabras, δi=si+1−ei. Parece lo natural y no funciona. Con esa fórmula,

Ti+1−Ti=dˆi+(si+1−ei)mientras quesi+1−si=(ei−si)+(si+1−ei)

así que cada palabra mete un error de dˆi−(ei−si), la diferencia entre la velocidad de escritura y lo que la palabra dura hablada. Whisper encadena casi todas las palabras de fin a inicio, con huecos de 0.00 exactos, y nada absorbe esa diferencia. Sumada sobre la frase, el texto terminaba unos 1.7 segundos antes que el actor. Escalar los huecos tampoco sirve, porque la mayoría mide cero, cero escalado sigue siendo cero, y todo el tiempo sobrante terminaría apilado en las dos o tres pausas reales de la frase.

5.3 Anclar al inicio

La versión que quedó mide de inicio a inicio. Con gi=si+1−si,

di=max⁡(0.05, min⁡(dˆi, gi)),δi=max⁡(0, gi−di),δn=0

Cuando gi≥0.05, la suma di+δi es exactamente gi, así que Ti+1−Ti=si+1−si, y sumando,

Ti−T1=∑j=1i−1(sj+1−sj)=si−s1

Cada palabra empieza a aparecer justo cuando se dice, medido desde la primera. Sigue siendo el hueco real del audio, y lo único que cambia es contra qué par de marcas se mide.

El tope di≤gi es lo que hace que eso se cumpla. Sin él, una palabra que se dijo más rápido de lo que su largo sugiere tendría dˆi>gi, la pausa daría negativa, se recortaría a cero y el resto de la frase quedaría atrasada en esa diferencia, una deuda que solo crece. El piso de 0.05 s existe para que una palabra nunca aparezca en un solo cuadro, y es el único lugar donde queda deuda: si gi<0.05, la frase se atrasa 0.05−gi. El desfase total queda acotado por

ε≤∑i:gi<0.05(0.05−gi)≤0.05⋅#{i:gi<0.05}

que con el habla normal es cero o casi cero.

Así queda un módulo generado:

local BarkComedy01: Subtitles.SubtitleSession = {
	[1] = {
		Audio = "rbxassetid://112127624070584",
		Nodes = {
			{ Text = "If", Duration = 0.08, Delay = 0.2 },
			{ Text = "this", Duration = 0.12, Delay = 0.04 },
			{ Text = "fish", Duration = 0.12, Delay = 0.12 },
			-- ...
			{ Text = "lot.", Duration = 0.12, Delay = 1.18 },
			{ Text = "Repeatedly.", Duration = 0.33, Delay = 0 },
		},
	},
}

5.4 Revisar solo lo que hay que oír

Junto con los módulos sale un reporte de divergencias entre lo transcrito y el guion, calculado con el SequenceMatcher de difflib, que implementa el algoritmo de Ratcliff y Obershelp [4]. Desde el código, una improvisación del actor y un error de Whisper se ven igual. Lo que el reporte hace es marcar los tramos donde la probabilidad mínima de las palabras baja de 0.6 como sospechosos de ser un error de transcripción, y así la lista de cosas que hay que escuchar con audífonos se reduce a unas pocas.

6. Subir a Roblox

La fase 4 sube los audios por la API de Open Cloud, a nombre del grupo dueño del juego y no de una cuenta personal. Antes de subir nada valida cada archivo contra los límites de Roblox (20 MB, 7 minutos, 48 kHz y uno, dos, tres o seis canales) leyendo el audio con PyAV, que ya venía como dependencia de faster-whisper. La documentación de Roblox se contradice sobre la cuota mensual de subidas, así que el script avisa contra la cifra más baja, que es la única que puede sorprender a mitad de una tanda. La clave de la API se lee de una variable de entorno y nunca se imprime ni se escribe en disco.

El registro de subidas se guarda después de cada archivo, así que una corrida interrumpida se reanuda sin volver a pagar lo que ya subió. Y el identificador del asset no se parcha como texto dentro del módulo de Luau. Se guarda en el registro, y la fase 3 vuelve a generar el módulo leyéndolo. El generador es una función pura del registro de transcripción, de las asignaciones y del registro de subidas, así que correrlo dos veces da exactamente lo mismo, y no hay ningún reemplazo de texto frágil del que dependa el resultado.

7. El reproductor

7.1 Una sola escritura del texto

El SubtitleController escribe la frase completa en la etiqueta una sola vez, antes de mostrar nada, y lo único que mueve después es MaxVisibleGraphemes. Hacer crecer el texto palabra por palabra obliga al motor a volver a diagramar la etiqueta en cada palabra, y con el texto centrado la línea se corre debajo de sí misma todo el tiempo que se está escribiendo. Antes de empezar se precargan todos los audios de la sesión, para que la reproducción nunca compita con un sonido que todavía está cargando.

7.2 Un reloj y no esperas

La primera versión esperaba con task.wait entre grafema y grafema. Un wait solo se reanuda en el siguiente latido del motor, así que pedir 0.029 s cuesta más bien 0.037 s, un impuesto de un 25% que cada grafema de cada palabra pagaba y acumulaba. Por eso las líneas terminaban visiblemente después de que la voz se callaba.

El reproductor actual calcula de antemano, para cada palabra, el grafema en el que termina, Gi, y el instante en que tiene que estar completa:

Ei=∑j≤ikdj+∑j<iδj

donde k es un factor global de velocidad de escritura que permite ajustarla a ojo sin regenerar ningún módulo. Solo escala las duraciones y nunca las pausas, porque las pausas son silencios medidos de la actuación, y estirarlas sacaría la línea de sincronía con la voz. En cada cuadro lee el tiempo transcurrido τ y pone la etiqueta donde el plan dice que debería estar:

visibles(τ)=Gi−1+⌊ni⋅τ−(Ei−kdi)kdi⌋

para la palabra i en curso, con ni sus grafemas, incluido el espacio que la precede. Las palabras cuyo Ei ya pasó se ponen al día en una sola escritura. Un reloj muestreado no puede derivar: el error es de un cuadro como mucho, haga lo que haga el framerate, y después de un tirón el texto se pone al día en vez de quedarse atrás para siempre.

Cuando la última palabra termina de escribirse, el reproductor espera lo que queda del audio en vez de cortarlo, porque el ritmo lo marcan las palabras y no el clip, y el actor a veces sigue hablando un instante más. Después sostiene la línea completa 1.25 s y la desvanece en 0.5 s, el texto y su contorno juntos. Ese tiempo final se exporta como constante, porque quien encadena algo que tiene que empezar cierto tiempo después de un subtítulo necesita restarlo. Sin eso, una pausa de 2.5 s antes de la siguiente voz de la Dream Maze terminó siendo bastante más larga.

7.3 Varias líneas a la vez

Varias sesiones pueden sonar al mismo tiempo, y cada una ocupa una fila: la fila 0 es la etiqueta diseñada en Studio y las demás son clones 34 píxeles más abajo. Cada sesión toma la fila libre más baja, así que la pila no deja huecos cuando una sesión larga y una corta se solapan. La configuración de subtítulos del jugador esconde la línea y no la actuación: la sesión sigue ocupando su fila, cargando su audio y respetando sus tiempos, y solo el lienzo no se ve. Si apagar los subtítulos cancelara las sesiones, también callaría la voz, que es el trabajo de otra opción.

8. Tres tipos de audio, tres formatos

Los barks son el caso de arriba: un audio es una línea y una sesión de un nodo, y hay guion contra el cual emparejar.

Las cinemáticas son diálogos de 45 a 100 segundos entre el protagonista y un NPC, en un solo archivo continuo, y no tienen un guion escrito, así que la transcripción es la fuente de verdad y se pasa directo a generar. El formato tampoco es una sesión. Es una tabla plana de la forma [segundo] = "línea", donde el segundo es el inicio absoluto de la primera palabra de cada segmento de Whisper, medido desde el cero del clip. El reproductor de cinemáticas del juego lee la posición actual del sonido en cada cuadro y muestra la última línea que ya tocaba. El audio es el reloj, así que no hay nada que sincronizar, y un clip que llega tarde o un cliente que se congela un segundo se corrigen solos en el cuadro siguiente. El pipeline genera la primera versión de estas tablas, y después se revisan a mano.

La Dream Maze queda en medio. Cada piso tiene un monólogo de 18 a 45 segundos, como una cinemática, pero lo reproduce el SubtitleController como una sesión, como un bark. Eso obliga a convertir los tiempos absolutos del clip en la cadena de duraciones y pausas relativas que entiende una sesión, que no tiene reloj propio. Una sesión arranca cuando la llaman y no en el segundo cero del audio, así que el primer nodo es uno mudo, sin texto, que dura exactamente el silencio inicial s1. La pausa de cada nodo es la que deja empezar al siguiente en su propio segundo:

δnodo=ssiguiente−súltima−dúltima

Una línea en pantalla tiene como mucho 10 palabras, y los cortes se buscan de forma recursiva en la pausa más larga del tramo, para no partir una frase por la mitad. Hay un detalle que costó una línea de una sola palabra en el primer piso. Dentro de una frase dicha de corrido, Whisper encadena las palabras con huecos de cero exactos, así que todos los cortes empatan, y quedarse con el primero corta en la posición más temprana posible. El desempate es por equilibrio: entre huecos iguales gana el que deja dos mitades más parecidas, minimizando |i−n/2|. Los segmentos de Whisper separados por menos de 0.6 s se juntan antes de partir, porque en estas tomas los cortes falsos del segmentador miden 0.00 s y las pausas reales pasan de 1.5 s. Y un par de palabras que Whisper inventó se quitan identificándolas por su raíz y su tiempo de inicio, porque el texto solo no basta cuando la misma palabra aparece bien en otra parte de la toma.

9. Limitaciones

En los barks, la primera palabra empieza a aparecer cuando arranca el audio y no en s1, así que el texto va adelantado a la voz por el silencio inicial de la toma. Con clips de estudio recortados esa diferencia es chica, y la Dream Maze, donde sí importa, la resuelve con el nodo mudo. El emparejamiento goloso no es óptimo en general, y funciona porque los puntajes de estas tomas están bien separados. En las cinemáticas, la calidad de los cortes de línea depende por completo de dónde decide cortar Whisper, que a veces parte una frase a la mitad o pega dos turnos de diálogo, y eso hay que cazarlo leyendo el módulo generado. Y las palabras de baja confianza siguen necesitando que alguien las escuche.

10. Lo que me dejó

Guardar la salida cruda del paso caro fue lo que hizo posible iterar sobre todo lo demás, porque la fórmula de los tiempos cambió varias veces y nunca hubo que volver a la GPU. La otra lección fue la de la deriva. Las dos versiones que fallaron (huecos crudos y esperas encadenadas) tenían el mismo defecto, que era sumar errores chicos sin nada que los corrigiera. Las que funcionan anclan cada valor a una referencia absoluta: el inicio de cada palabra en el generador, el reloj en el reproductor y la posición del sonido en las cinemáticas.

Referencias

  1. A. Radford, J. W. Kim, T. Xu, G. Brockman, C. McLeavey e I. Sutskever. “Robust Speech Recognition via Large-Scale Weak Supervision”. Proceedings of the 40th International Conference on Machine Learning, 2023.
  2. V. I. Levenshtein. “Binary Codes Capable of Correcting Deletions, Insertions, and Reversals”. Soviet Physics Doklady, 10(8), 1966.
  3. H. W. Kuhn. “The Hungarian Method for the Assignment Problem”. Naval Research Logistics Quarterly, 2, 1955.
  4. J. W. Ratcliff y D. E. Metzener. “Pattern Matching: The Gestalt Approach”. Dr. Dobb’s Journal, julio de 1988.

Este texto de Mastedore. Todos los derechos reservados.