Editor de Voces#
El Editor de Voces hornea actuación de voz local (text-to-speech) para las líneas habladas
del juego, y genera las señales de forma de boca (lip-sync) que mueven la animación de un
personaje mientras habla. Lee/escribe un manifiesto de voz por proyecto
(voice/voice_manifest.json) que declara una voz por personaje y un conjunto de líneas de
voz (qué línea, hablada por qué personaje, con qué overrides de parámetros por línea). El
manifiesto no guarda el texto hablado: el id de una línea de voz es su lid, así que las
palabras son un registro de strings.json y el manifiesto sólo lo apunta; la
generación en sí corre a través de un modelo local (VoxCPM2) mediante un pipeline de Python
offline (tools/voicegen/), invocado desde el editor a través de endpoints de job del dev
server. Nada de esto se envía en el runtime del juego. Solo los WAV resultantes y los archivos
JSON de lip-sync sí.
Llegas a él desde el Hub (con el dev server corriendo). La columna izquierda VOZ tiene la configuración de voz del personaje seleccionado; la columna derecha LÍNEAS DE VOZ lista las líneas de ese personaje, ya sea como una lista compacta paginada o, al expandir una, una única línea enfocada con su texto, tomas, y controles de lip-sync. El texto se muestra en sólo lectura (ver más abajo).
Qué se voicea#
El editor no inventa un guión aparte: importa el texto hablado del propio juego vía 📥 Importar del juego, así que el texto de una línea de voz ES la línea que el juego realmente muestra, sin doble autoría:
strings.json(la tabla de strings de content-i18n,core/strings.js) es la fuente primaria, pero solo sus líneas habladas, no cada fila de la tabla:strings.jsontambién carga nombres de hotspot e ítem, descripciones de sala, texto de GUI, etiquetas de verbo, mensajes de sistema del motor y texto de logros, y ninguno de esos es algo que un personaje diría en voz alta.parseNameLid(), el mismo parser de direcciones que la tabla de strings usa en todos lados, clasifica cadalidsegún de qué es overlay; solo su tipo'line'(unSAY/NPCSAY) se vuelve candidata, con clave por el propio lid como id de línea. Es la misma tabla en la que escriben el Editor de Diálogos y el Editor de Cinemáticas cuando autoras un tokenSAY:#lid/NPCSAY:#lid; mira sus docs para cómo se acuña y resuelve un lid. Como el id de línea es el lid, la ruta del WAV horneado (.../<char>/<lid>/<lang>/baked.wav) se puede derivar del lid solo;voicePathForLid()decore/strings.jses el único constructor puro que llaman tanto el editor como el runtime.- Los árboles de diálogo también aportan la línea de NPC de cada nodo, a nombre del
personaje que la dice: aquel para el que Talk to abre ese diálogo (o, si ninguno está atado
a él, el personaje con el id del diálogo). El id sale de la
dirección de la línea, no de su texto (
dialogVoiceLid(dialogId, nodeId), con formadlg_<dialogo>_<nodo>), así que reescribir el nodo después no huerfaniza el clip horneado. Y es el mismo lid con el que la tabla de strings guarda las traducciones de ese nodo (el literal del archivo de diálogo sigue siendo la fuente del locale por defecto): una sola dirección keyea el texto en pantalla, sus traducciones y su clip de voz. Las líneas de opción del jugador no se importan (el hablante sería el personaje jugable que esté activo, algo que el importador no puede resolver). - Los tokens
SAY:/NPCSAY:literales heredados que todavía viven directamente en un archivo de sala sin migrar se toman como respaldo, deduplicados por personaje+texto y con un id slugificado.
El modal de import muestra cada línea candidata con su personaje hablante y texto, atenúa las que ya tienes (emparejadas por lid o por personaje+texto), y te deja seleccionar-todo/ninguno antes de agregarlas. Las líneas agregadas son filas normales que puedes borrar, o agregar una a una con + Línea, que abre un selector con las mismas líneas que encuentra Importar (primero las del personaje, atenuadas las que ya tienes) y muestra el texto de la elegida de inmediato. Una línea que dice otro personaje se guarda bajo ese personaje, no bajo el que tienes abierto, y el editor cambia a él para que la veas llegar; si quien la dice no tiene ficha de voz aquí (un narrador, por ejemplo), se queda en la tuya. Importar es una comodidad, no el único camino de entrada.
El id de la línea se elige, no se escribe#
El id de una línea es su lid, así que la línea enfocada lo muestra como etiqueta con Cambiar…
en vez de un campo de texto. Cambiar… apunta la línea a otra línea del juego con el mismo
selector, siempre que la línea todavía no tenga audio. En cuanto tiene una toma en cualquier
idioma, el id queda bloqueado (🔒): sus tomas viven en una carpeta con el nombre de ese id, y
renombrarlo dejaría el audio atrás y el juego mudo en esa línea. Para darle voz a otra línea,
agrégala con + Línea. Una línea cuyo id no está en la tabla de strings (una line_2 vieja,
por ejemplo) no tiene texto, y la tarjeta lo dice.
Aquí el texto es de sólo lectura#
Una línea enfocada muestra sus palabras en un bloque de sólo lectura por idioma (el idioma que
aún no tiene texto lo dice); no las autora, y el ? junto a Texto dice dónde se escriben.
Una línea que acabas de importar muestra su texto de inmediato, antes de guardar. Como el id de una línea de voz es su lid, el texto es un registro del strings.json
del proyecto, y se edita donde se autora el contenido: en el juego (el token SAY: mismo) y en el
Editor de Traducción para los demás idiomas.
Antes era un campo editable que escribía en el manifiesto, o sea que las mismas palabras vivían en
dos archivos sin nada que las mantuviera en sincro, y los dos alimentan consumidores distintos:
el pipeline tools/voicegen/ hornea el WAV desde uno mientras el motor renderiza el subtítulo
desde el otro. Una edición de cualquier lado y el jugador escucha una línea y lee otra. Una línea
que todavía carga su propio texto es heredada o sin lidificar; el cuadro lo aclara, y el pipeline
la sigue leyendo por una rama de back-compat.
Configuración de voz por personaje#
Cada personaje del juego (de characters.json) aparece automáticamente como una tarjeta de voz;
también puedes agregar voces extra (+ Voz extra) para NPCs que no son personajes jugables
del juego, con un id libremente editable. Cada voz tiene:
- Fuente: una de tres:
- Diseño (prompt): un prompt de texto que describe la voz deseada, sin necesidad de audio; VoxCPM2 sintetiza un hablante a partir de la descripción.
- Humano (clonar): clonada desde un WAV de referencia.
- Grabada (tal cual): la voz del personaje son grabaciones: la tuya, la de un actor o cualquier audio (ver Voces grabadas, tal cual). Al elegirla, bajo el selector aparecen los dos pasos a seguir. No hay nada que generar, así que no lleva prompt, referencia, parámetros de voz ni preview; no se le ofrece 🔥 Hornear todas las líneas (👄 Hornear lip-sync (todas) sí), y sus líneas nunca quedan desactualizadas.
- Para Humano (clonar), la Referencia WAV se elige de una lista con las referencias que ya guardaste para ese personaje, con un reproductor para escuchar la elegida. 🎙 Grabar (por el micrófono del navegador, con preview de escuchar-antes-de-guardar) o 📁 Importar agrega una referencia nueva y la deja elegida: no hay nada que escribir. Más un Estilo opcional que ajusta emoción/ritmo al sintetizar sin cambiar el timbre clonado.
- Parámetros de voz compartidos: knobs rotativos para
cfg_valueeinference_timesteps, más una Semilla (vacía = aleatoria en cada horneado, o fíjala para reproducibilidad con el 🎲 de reroll). El conjunto de knobs en sí no está hardcodeado en el editor: tanto él comogenerate.pyleen el mismotools/voicegen/param_spec.json, así que agregar un knob es un cambio de spec, no una reescritura del editor. - Una caja de Preview de voz (con gate de venv) sintetiza una frase ad-hoc con la configuración actual del personaje y la reproduce al instante; audita un ajuste antes de comprometerlo a un horneado real.
- Las explicaciones largas (qué significa cada Fuente, el Estilo, el Preview de voz) van detrás del ? junto a su etiqueta, para que la columna quede corta.
Los overrides por línea (en la vista de línea enfocada) dejan que una línea específica sobrescriba cualquiera de esos mismos knobs; un knob atenuado significa que está heredando el default del personaje, y doble-clic en un knob de override de línea borra el override en vez de solo resetear su valor.
Generación: el pipeline TTS local#
Hornear invoca tools/voicegen/generate.py, que debe correr bajo un venv de Python dedicado
(importa torch/voxcpm, nunca cargado por core/, shell/, ni el propio
dev_server.py). El editor nunca ejecuta directamente una ruta de venv; hace POST a
/api/voice/generate, que arranca un job en background, y el editor sondea
/api/voice/generate-status una vez por segundo hasta que reporta terminado. Dos puntos de
entrada:
- ⟳ Regenerar (por línea/idioma enfocado): fuerza una toma nueva.
- 🔥 Hornear todas las líneas: hornea en lote cada línea del personaje seleccionado, saltando cualquier línea cuya toma existente ya coincida con el hash actual de texto + parámetros + referencia (así que re-correrlo no cuesta nada una vez que todo está al día).
La primera vez que se intenta un horneado, window.ensureModelConsent('voxcpm2', …)
(tools/_ai-consent.js) lo condiciona: el modelo es una descarga de Hugging Face de varios GB,
así que se avisa al usuario una vez antes de bajarlo; llamadas siguientes chequean
localStorage y una consulta al caché server-side /api/model-status, y solo vuelven a
preguntar si ninguno dice que sí. El id/label/tamaño del modelo vienen del manifiesto compartido
tools/ai-models.json, no de strings hardcodeados; el gate de MusicGen del Editor de Audio usa
el mismo mecanismo idéntico.
Las tomas no son destructivas#
Cada (re)generación agrega una toma numerada (take_01.wav, take_02.wav, …) en vez de
sobrescribir; un takes.json por línea registra los parámetros/texto/hash de cada toma y cuál
está horneada (canónica: el archivo que el lip-sync y el runtime realmente reproducen). La
vista de línea enfocada lista cada toma con un botón de play y un control tipo radio de usar como
toma final, así que puedes auditar alternativas y elegir una favorita sin perder las demás. Una toma
también puede pasar por 🎚 FX, que abre el pedalboard completo del Editor de Audio en
un modal sobre esa toma puntual y postea de vuelta una toma nueva (nunca muta la original)
cuando guardas ahí.
Badges de estado#
Cada línea/idioma muestra uno de: sin generar (todavía sin tomas), tomas sin elegir (hay tomas,
ninguna canónica), horneado (el hash de la toma canónica coincide con el texto/parámetros actuales, al
día), o desactualizado (existe una toma canónica pero su hash ya no coincide; el manifiesto cambió
desde que se generó esa toma). El badge se calcula con el MISMO hash que usa generate.py para
decidir si hace falta hornear, así que el estado del editor y la decisión de "nada que hornear"
del CLI nunca pueden discrepar.
Voces grabadas, tal cual#
No necesitas el modelo para ponerle voz a una línea: el clip puede ser tu voz, la de un actor o cualquier audio. En la barra Tomas de una línea enfocada, 🎙 Grabar captura el micrófono y 📁 Importar toma un archivo de audio; en los dos casos el clip pasa a ser una toma de esa línea e idioma, convertido al mismo formato que escribe el generador, y queda como la toma canónica de inmediato. La toma muestra el badge grabación, tiene su propio reproductor y su radio como cualquier otra, y 🎚 FX y el lip-sync funcionan sobre ella. Estos botones están con o sin el venv de voicegen: sin clonar, sin prompt de diseño, sin descargas.
Estos botones viven en cada línea, así que no hay nada que grabar hasta que el personaje tiene líneas. Si a un personaje le pones voz sólo así, pon su Fuente en Grabada (tal cual) y sigue los dos pasos que muestra: trae las líneas con 📥 Importar del juego (o una a una con + Línea) y abre cada una para grabar o importar su toma. Sus líneas conservan 🎙 Grabar y 📁 Importar y pierden ⟳ Regenerar y los overrides de voz por línea, porque no hay modelo de por medio.
Horneado de señales de lip-sync#
👄 Hornear lip-sync (por línea, o 👄 Hornear lip-sync (todas) para todo el personaje) es una acción
separada de la generación de voz, ofrecida independientemente del venv de VoxCPM2: todo lo
que necesita es un baked.wav ya horneado y el modelo de voz de Rhubarb Lip Sync. Ese
modelo (unos 70 MB) no viene en el instalador: el primer horneado te pregunta si lo descarga, te
muestra el porcentaje mientras lo hace, y a partir de ahí queda cacheado y cada horneado
posterior corre sin conexión. Si declinas, simplemente no se hornea: nada se descarga a tus
espaldas. Y si prefieres poner tu propia copia de Rhubarb, apunta Hub Config a ella y esa ruta
manda. Corre síncronamente a través de /api/voice/lipsync (Rhubarb es
suficientemente rápido como para que la respuesta HTTP SEA el resultado, sin sondeo), escribiendo
lipsync.json al lado del WAV: { dur, rec, shapes, cues:[{t, shape}, …] }, una señal por cada
forma de boca Preston-Blair (de A a H, X = reposo), con tiempos en segundos contra baked.wav.
Las líneas en inglés usan el reconocedor asistido-por-texto pocketSphinx de Rhubarb
(alimentado con el propio texto de la línea como pista de diálogo para más precisión); cualquier
otro idioma cae al reconocedor fonético, agnóstico de idioma. La vista de línea enfocada
renderiza las señales horneadas como una tira de línea de tiempo de solo lectura y
proporcional (un segmento coloreado por señal, ancho ∝ duración) para que puedas chequear un
horneado a ojo sin abrir el runtime.
En runtime, shell/voice.js es quien realmente consume esto: cada vez que empieza a reproducirse
una línea SAY/NPCSAY voiceada, busca de forma perezosa el lipsync.json de esa línea y apunta
un único objetivo global de lip-sync (VOICE.lipsyncActive) al actor que habla. En cada cuadro,
visemeFrame(shape, frameCount) mapea la forma de la señal activa a un valor de apertura (0
cerrado/reposo, hasta 1 bien abierto) y elige el cuadro correspondiente de los que tenga la
animación _talk del actor: funcionan tanto 2 cuadros (cerrado/abierto) como hasta 9 (uno por
forma). Sin señales, voces mudeadas, o clip ya terminado, todo cae de vuelta a la animación
genérica de habla, así que una línea sin lip-sync igual anima, solo que sin precisión de forma de
boca.
Desactualización (stale) y regeneración#
Ambos horneados usan un hash de texto + parámetros efectivos + source/design/ref + mtime del
archivo de referencia. Edita el texto de una línea (o el prompt de diseño, style prompt,
parámetros, o clip de referencia de un personaje) y su badge pasa de horneado a desactualizado, pero
el baked.wav y el lipsync.json existentes quedan intactos y siguen reproduciéndose en el
juego exactamente igual que antes. Nada fuerza un re-horneado; quedar desactualizada es una advertencia,
no un bloqueo. Una toma grabada o importada nunca queda desactualizada: no tiene parámetros de generación de
los que alejarse. Regenerar la voz (⟳ Regenerar / 🔥 Hornear todas las líneas) produce una toma nueva contra el hash
nuevo y, si la marcas como toma final, un baked.wav nuevo, pero el lip-sync no se re-hornea
automáticamente. Vuelve a correr 👄 Hornear lip-sync para esa línea después, o la línea de tiempo
de señales queda cableada a como sonaba el baked.wav anterior.
Flujo de trabajo#
- 📥 Importar del juego para traer líneas candidatas desde
strings.json(lids de SAY/NPCSAY) y árboles de diálogo; selecciona las que quieres y agrégalas. - Elige un personaje en la toolbar, configura su Fuente (un prompt de Diseño, una referencia Humano + estilo opcional, o Grabada para tus tomas o las de un actor), y ajusta los Parámetros de voz compartidos.
- Usa Preview de voz para auditar la configuración con una frase descartable antes de gastar un horneado real.
- 🔥 Hornear todas las líneas (o ⟳ Regenerar una línea enfocada puntual); espera el gate de consentimiento la primera corrida, después el sondeo del job.
- Revisa las Tomas por línea, escucha, y elige cuál usar como toma final si la primera no es la mejor.
- 👄 Hornear lip-sync (por línea o para todo el personaje) una vez que la toma de voz que quieres sea la canónica.
- Guardar el manifiesto. Todo lo anterior (regenerar, elegir la toma final, hornear el lip-sync) lee el manifiesto desde disco, así que una edición sin guardar bloquea esas acciones hasta que guardes primero. Si alguien más (o una corrida por CLI) escribió el manifiesto desde que lo cargaste, Guardar muestra un aviso de conflicto (conservar tus cambios locales o sobrescribir los del otro, nunca se resuelve solo) y un servidor inalcanzable da un error en vez de quedarse colgado.
La voz y el lip-sync desactualizados pueden desalinearse en silencio del texto. Editar el texto de una línea SAY después de haberla voiceado no invalida el audio ni sus señales de boca; solo cambia un badge. Si te saltas la regeneración, el personaje va a seguir diciendo la línea vieja (correctamente lip-sincronizada consigo misma) mientras el subtítulo en pantalla muestra el texto nuevo, editado. Y si regeneras la voz pero te olvidas de re-hornear el lip-sync, obtienes un clip nuevo que suena correcto pero mueve la boca con señales cronometradas contra la grabación anterior: normalmente sigue siendo mirable, pero ya no coincide con precisión. Trata los badges desactualizado como una lista de pendientes, no como un detalle visual.