N.º 07Kwami
La forma de una voz
El cuerpo por defecto de Kwami es una esfera que un ruido 3D hincha y hunde. Así es como la voz del agente se convierte en ese movimiento, desde los bins de una transformada de Fourier hasta cada vértice, en cada fotograma.
Alex Colls Outumuro
Cofundador y CTO · · 7 min de lectura
Kwami es un compañero en 3D con el que hablas. Su biblioteca trae cinco cuerpos, y el que viene por defecto es una esfera cuya superficie se mueve cuando habla. Nada de ese movimiento está animado con fotogramas clave. En cada fotograma, el navegador lee la voz del agente, la divide en bandas de frecuencia y mueve cada vértice de la esfera a partir de esos números. Así lo hace Kwami, del sonido a la superficie.
De una voz a 1.024 números
La voz del agente llega como una pista de audio de LiveKit. La biblioteca la reproduce en un elemento de audio y pasa la misma pista a un analizador de Web Audio. El analizador toma las últimas 2.048 muestras, aplica una ventana de Blackman, calcula una transformada rápida de Fourier y se queda con 1.024 bins. Un bin es la intensidad de una franja estrecha del espectro, y su anchura es la frecuencia de muestreo dividida entre 2.048. En un contexto de audio a 48 kHz, una frecuencia habitual, cada bin mide unos 23 Hz, y entre todos llegan hasta 24 kHz, la mitad de la frecuencia de muestreo. Cada intensidad se lee en decibelios, recortada entre −90 y −10, y se convierte en un byte de 0 a 255.
Los bins se reparten en cuatro bandas según su posición: el primer 10 %, hasta el 40 %, hasta el 70 %, y el resto. El corte es lineal en frecuencia, mientras que el oído oye por octavas, así que a 48 kHz la banda baja va de 0 a 2,4 kHz y contiene tanto el tono de una voz que habla como su primer formante. Cada banda es la media de sus bytes, dividida entre 255. No es el RMS de la forma de onda: los bytes ya son decibelios, así que una banda es una media de niveles en decibelios. El nivel global pondera las cuatro bandas con 0,4, 0,35, 0,2 y 0,05, de grave a agudo.
const lowEnd = Math.floor(dataLength * 0.1)
const midEnd = Math.floor(dataLength * 0.4)
const highEnd = Math.floor(dataLength * 0.7)
let lowSum = 0, midSum = 0, highSum = 0, ultraSum = 0
for (let i = 0; i < dataLength; i++) {
const value = (frequencyData[i] ?? 0) / 255
if (i < lowEnd) lowSum += value
else if (i < midEnd) midSum += value
else if (i < highEnd) highSum += value
else ultraSum += value
}Suave, pero no demasiado
Un espectro en bruto parpadea. El analizador ya suaviza cada bin: cada lectura nueva es un 35 % de la anterior y un 65 % de la medida recién tomada. Después, las bandas baja, media y alta, y el nivel global, pasan cada uno por su propia media móvil exponencial, una vez por fotograma:
s ← s + α · (x − s)Aquí x es el valor nuevo de la banda y s lo que el cuerpo recuerda de ella. Es el filtro paso bajo más simple que hay: una lectura de hace n fotogramas aún pesa α·(1 − α)ⁿ, así que el pasado se desvanece de forma geométrica y un solo número lo guarda entero. Un ajuste de velocidad de respuesta pone α entre 0,12 y 0,47: a 60 fotogramas por segundo, el pasado se reduce a la mitad en unos 90 milisegundos en el extremo lento y en 18 en el rápido. Suavizar sin más limaría el inicio de cada sílaba, así que se vuelve a mezclar una parte de la banda en bruto, del 15 % al 65 % según un ajuste de refuerzo de transitorios. El cuerpo solo reacciona cuando ese nivel mezclado supera tres cuartas partes del ajuste de sensibilidad. Por debajo, Kwami respira.
Más fuerte, más picos
El cuerpo es una esfera unitaria de 180 por 180 segmentos por defecto, algo menos de 33.000 vértices. Ningún vertex shader los mueve. La forma se calcula en la CPU, se vuelve a escribir y recibe normales nuevas en cada fotograma, con entre tres y siete muestras de ruido por vértice. Cada vértice guarda la dirección que tenía la primera vez que se animó la malla, y cada fotograma vuelve a partir de esas direcciones. Una versión anterior releía los vértices de la malla, y unos segundos de deriva bastaban para dejarle al cuerpo un cono incrustado.
A lo largo de esa dirección, el radio sale de un ruido simplex, el ruido de gradiente de Ken Perlin sobre una rejilla de tetraedros. Cada muestra mezcla los gradientes de cuatro esquinas en lugar de las ocho de un cubo, y devuelve un valor suave justo dentro de −1 a 1. Lo muestreamos en 3D en la dirección del vértice, no en 2D sobre el mapa UV de la esfera, así que el desplazamiento no tiene costura y nada se pellizca en los polos. El campo se construye una vez por página a partir de una tabla barajada, y nunca cambia. El tiempo se suma a las coordenadas, de modo que la esfera se desliza a través de él. En reposo, dos capas lentas de ese ruido moldean la superficie. Cuando la voz suena lo bastante fuerte, un empuje hecho de las bandas baja, media y alta, con un tope de 3, aumenta a la vez la altura y la frecuencia de una tercera capa:
const audioFreqBoost = 1 + audioPush * spikeDensity
…
const spikeNoise = noise3D(
direction.x * baseFreqX * audioFreqBoost + tX * 1.3,
direction.y * baseFreqY * audioFreqBoost + tY * 1.3,
direction.z * baseFreqZ * audioFreqBoost + tZ * 1.3,
)
…
const audioAmp = audioPush * 0.25 * amplitudeMultiplier
…
const speakingDisplacement = idleAmp * idleNoise
+ audioAmp * spikeNoise
+ audioDetail * detailNoiseMás fuerte no solo significa picos más altos. Significa más picos. Multiplicar las coordenadas por audioFreqBoost hace que la esfera muestree un trozo más amplio del campo, así que caben más de sus bultos en la misma superficie. A partir de un empuje de 0,2, una cuarta capa, al doble de esa frecuencia, entra con un fundido para el detalle fino. La suma se limita entre 0,55 y 1,45 veces el radio en reposo, y luego se suaviza vértice a vértice con el mismo filtro, con α de 0,3 a 0,7 mientras la voz está activa y 0,25 si no. Un filtro que solo mezcla valores dentro de ese intervalo nunca puede salir de él. Por último, la amplitud de cada vértice pondera las tres amplitudes de eje con los |x|, |y| y |z| de su dirección. En un vector unitario, |x| + |y| + |z| va de 1 a √3, así que con amplitudes iguales las diagonales se mueven hasta un 73 % más que los puntos sobre los ejes.
Más fuerte no solo significa picos más altos. Significa más picos.
Escuchar, pensar, respirar
El agente de voz publica su estado, y el cuerpo mezcla tres campos según él. Hablar, igual que el reposo, usa el que acabamos de ver. Escuchar le da la vuelta al relieve de reposo con un 70 % de su fuerza, así que los bultos se vuelven huecos, y conserva un 40 % de los picos de audio. Pensar, que dura como mucho diez segundos por defecto, agita tres capas más rápidas, dos de las cuales derivan a medida que avanza. El conjunto late y se apaga como 1 − p², donde p es la parte de ese tiempo ya transcurrida. Las mezclas avanzan de forma lineal, escaladas por el tiempo real entre fotogramas, así que por defecto un cambio tarda un tercio de segundo, tanto a 60 Hz como a 120. En silencio, el cuerpo respira: su escala sube y baja una vez cada 3,5 segundos, alrededor de medio por ciento con el ajuste por defecto.
El color es cosa del fragment shader. La piel por defecto, una de 22, pondera tres colores según el ángulo θ alrededor del eje z del cuerpo, cada uno con 0,5 + 0,5·cos(θ − 2πk/3), para k = 0, 1, 2. Tres cosenos separados 120° siempre suman cero, así que los pesos siempre suman 1,5, y dividirlos entre su suma solo los reescala. La voz mueve cada vértice a lo largo de su propia dirección, lo que deja θ igual: los colores van con la superficie en lugar de deslizarse sobre ella. Un brillo de Phong encima le da al cuerpo su lustre.
Alex

