Núm. 07Kwami
La forma d’una veu
El cos per defecte de Kwami és una esfera que un soroll 3D infla i enfonsa. Així és com la veu de l’agent es converteix en aquest moviment, des dels bins d’una transformada de Fourier fins a cada vèrtex, a cada fotograma.
Alex Colls Outumuro
Cofundador i CTO · · 7 min de lectura
Kwami és un company en 3D amb qui parles. La seva biblioteca porta cinc cossos, i el que ve per defecte és una esfera la superfície de la qual es mou quan parla. Res d’aquest moviment no està animat amb fotogrames clau. A cada fotograma, el navegador llegeix la veu de l’agent, la divideix en bandes de freqüència i mou cada vèrtex de l’esfera a partir d’aquests nombres. Així ho fa Kwami, del so a la superfície.
D’una veu a 1.024 nombres
La veu de l’agent arriba com una pista d’àudio de LiveKit. La biblioteca la reprodueix en un element d’àudio i passa la mateixa pista a un analitzador de Web Audio. L’analitzador pren les últimes 2.048 mostres, hi aplica una finestra de Blackman, calcula una transformada ràpida de Fourier i es queda amb 1.024 bins. Un bin és la intensitat d’una franja estreta de l’espectre, i la seva amplada és la freqüència de mostreig dividida per 2.048. En un context d’àudio a 48 kHz, una freqüència habitual, cada bin fa uns 23 Hz d’amplada, i entre tots arriben fins a 24 kHz, la meitat de la freqüència de mostreig. Cada intensitat es llegeix en decibels, retallada entre −90 i −10, i es converteix en un byte de 0 a 255.
Els bins es reparteixen en quatre bandes segons la seva posició: el primer 10 %, fins al 40 %, fins al 70 %, i la resta. El tall és lineal en freqüència, mentre que l’oïda sent per octaves, així que a 48 kHz la banda baixa va de 0 a 2,4 kHz i conté tant el to d’una veu que parla com el seu primer formant. Cada banda és la mitjana dels seus bytes, dividida per 255. No és el RMS de la forma d’ona: els bytes ja són decibels, així que una banda és una mitjana de nivells en decibels. El nivell global pondera les quatre bandes amb 0,4, 0,35, 0,2 i 0,05, de greu a agut.
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
}Suau, però no massa
Un espectre en brut parpelleja. L’analitzador ja suavitza cada bin: cada lectura nova és un 35 % de l’anterior i un 65 % de la mesura fresca. Després, les bandes baixa, mitjana i alta, i el nivell global, passen cadascun per la seva pròpia mitjana mòbil exponencial, un cop per fotograma:
s ← s + α · (x − s)Aquí x és el valor nou de la banda i s el que el cos en recorda. És el filtre passabaix més simple que hi ha: una lectura de fa n fotogrames encara pesa α·(1 − α)ⁿ, així que el passat s’esvaeix de manera geomètrica i un sol nombre el guarda sencer. Un ajust de velocitat de resposta posa α entre 0,12 i 0,47: a 60 fotogrames per segon, el passat es redueix a la meitat en uns 90 mil·lisegons a l’extrem lent i en 18 al ràpid. Suavitzar i prou llimaria l’inici de cada síl·laba, així que es torna a barrejar una part de la banda en brut, del 15 % al 65 % segons un ajust de reforç de transitoris. El cos només reacciona quan aquest nivell barrejat supera tres quartes parts de l’ajust de sensibilitat. Per sota, Kwami respira.
Més fort, més punxes
El cos és una esfera unitat de 180 per 180 segments per defecte, una mica menys de 33.000 vèrtexs. Cap vertex shader no els mou. La forma es calcula a la CPU, es torna a escriure i rep normals noves a cada fotograma, amb entre tres i set mostres de soroll per vèrtex. Cada vèrtex guarda la direcció que tenia la primera vegada que es va animar la malla, i cada fotograma torna a partir d’aquestes direccions. Una versió anterior rellegia els vèrtexs de la malla, i uns segons de deriva bastaven per deixar al cos un con incrustat.
Al llarg d’aquesta direcció, el radi surt d’un soroll simplex, el soroll de gradient de Ken Perlin sobre una graella de tetràedres. Cada mostra barreja els gradients de quatre cantonades en lloc de les vuit d’un cub, i torna un valor suau just dins de −1 a 1. El mostregem en 3D a la direcció del vèrtex, no en 2D sobre el mapa UV de l’esfera, així que el desplaçament no té costura i res no es pinça als pols. El camp es construeix un cop per pàgina a partir d’una taula remenada, i no canvia mai. El temps se suma a les coordenades, de manera que l’esfera el travessa lliscant. En repòs, dues capes lentes d’aquest soroll modelen la superfície. Quan la veu sona prou fort, una empenta feta de les bandes baixa, mitjana i alta, amb un sostre de 3, augmenta alhora l’alçada i la freqüència d’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 fort no vol dir només punxes més altes. Vol dir més punxes. Multiplicar les coordenades per audioFreqBoost fa que l’esfera mostregi un tros més ampli del camp, així que hi caben més bonys a la mateixa superfície. A partir d’una empenta de 0,2, una quarta capa, al doble d’aquesta freqüència, hi entra amb un fos per al detall fi. La suma es limita entre 0,55 i 1,45 vegades el radi en repòs, i després se suavitza vèrtex a vèrtex amb el mateix filtre, amb α de 0,3 a 0,7 mentre la veu és activa i 0,25 si no. Un filtre que només barreja valors dins d’aquest interval no en pot sortir mai. Finalment, l’amplitud de cada vèrtex pondera les tres amplituds d’eix amb els |x|, |y| i |z| de la seva direcció. En un vector unitari, |x| + |y| + |z| va d’1 a √3, així que amb amplituds iguals les diagonals es mouen fins a un 73 % més que els punts sobre els eixos.
Més fort no vol dir només punxes més altes. Vol dir més punxes.
Escoltar, pensar, respirar
L’agent de veu publica el seu estat, i el cos barreja tres camps segons aquest estat. Parlar, igual que el repòs, fa servir el que acabem de veure. Escoltar gira com un mitjó el relleu de repòs amb un 70 % de la seva força, així que els bonys es tornen clots, i conserva un 40 % de les punxes d’àudio. Pensar, que dura com a màxim deu segons per defecte, remena tres capes més ràpides, dues de les quals van derivant a mesura que avança. El conjunt batega i s’apaga com 1 − p², on p és la part d’aquest temps ja passada. Les barreges avancen de manera lineal, escalades pel temps real entre fotogrames, així que per defecte un canvi triga un terç de segon, tant a 60 Hz com a 120. En silenci, el cos respira: la seva escala puja i baixa un cop cada 3,5 segons, al voltant de mig per cent amb l’ajust per defecte.
El color és cosa del fragment shader. La pell per defecte, una de 22, pondera tres colors segons l’angle θ al voltant de l’eix z del cos, cadascun amb 0,5 + 0,5·cos(θ − 2πk/3), per a k = 0, 1, 2. Tres cosinus separats 120° sempre sumen zero, així que els pesos sempre sumen 1,5, i dividir-los per la seva suma només els reescala. La veu mou cada vèrtex al llarg de la seva pròpia direcció, cosa que deixa θ igual: els colors van amb la superfície en lloc de lliscar-hi per sobre. Un reflex de Phong al damunt dona al cos la seva brillantor.
Alex

