← Tous les articles

N° 07Kwami

La forme d’une voix

Le corps par défaut de Kwami est une sphère que du bruit 3D gonfle et creuse. Voici comment la voix de l’agent devient ce mouvement, des bins d’une transformée de Fourier jusqu’à chaque sommet, à chaque image.

Alex Colls Outumuro

Cofondateur et CTO · · 7 min de lecture

Estampe 07, Tresse. Des gouttes et des peignes sur un bain, tirée quand le site est construit.Pointez dessus pour faire tomber de l’encre.

Kwami est un compagnon en 3D à qui l’on parle. Sa bibliothèque propose cinq corps, et celui par défaut est une sphère dont la surface bouge quand il parle. Aucun de ces mouvements n’est animé par images clés. À chaque image, le navigateur lit la voix de l’agent, la découpe en bandes de fréquence et déplace chaque sommet de la sphère à partir de ces nombres. Voici comment Kwami s’y prend, du son à la surface.

D’une voix à 1 024 nombres

La voix de l’agent arrive sous forme de piste audio LiveKit. La bibliothèque la joue dans un élément audio et confie la même piste à un analyseur Web Audio. L’analyseur prend les 2 048 derniers échantillons, applique une fenêtre de Blackman, calcule une transformée de Fourier rapide et garde 1 024 bins. Un bin, c’est l’intensité d’une fine tranche du spectre, et sa largeur vaut la fréquence d’échantillonnage divisée par 2 048. Dans un contexte audio à 48 kHz, une fréquence courante, chaque bin fait environ 23 Hz de large, et ensemble ils montent jusqu’à 24 kHz, la moitié de la fréquence d’échantillonnage. Chaque intensité est lue en décibels, bornée entre −90 et −10, puis ramenée à un octet de 0 à 255.

Les bins sont répartis en quatre bandes selon leur position : les premiers 10 %, jusqu’à 40 %, jusqu’à 70 %, et le reste. Le découpage est linéaire en fréquence, alors que l’oreille entend par octaves : à 48 kHz, la bande basse va de 0 à 2,4 kHz et contient à la fois la hauteur d’une voix qui parle et son premier formant. Chaque bande est la moyenne de ses octets, divisée par 255. Ce n’est pas le RMS de la forme d’onde : les octets sont déjà des décibels, donc une bande est une moyenne de niveaux en décibels. Le niveau global pondère les quatre bandes par 0,4, 0,35, 0,2 et 0,05, du grave à l’aigu.

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
}

Lisse, mais pas trop

Un spectre brut scintille. L’analyseur lisse déjà chaque bin : chaque nouvelle valeur garde 35 % de la précédente et prend 65 % de la mesure fraîche. Puis les bandes basse, médium et aiguë, ainsi que le niveau global, passent chacun dans leur propre moyenne mobile exponentielle, une fois par image :

s ← s + α · (x − s)

Ici, x est la nouvelle valeur de la bande et s ce que le corps en retient. C’est le plus simple des filtres passe-bas : une lecture vieille de n images pèse encore α·(1 − α)ⁿ, donc le passé s’efface de façon géométrique et un seul nombre le contient tout entier. Un réglage de vitesse de réponse place α entre 0,12 et 0,47 : à 60 images par seconde, le passé diminue alors de moitié en 90 millisecondes environ au plus lent, et en 18 au plus rapide. Lisser seulement émousserait le début de chaque syllabe, alors une part de la bande brute est remélangée, de 15 % à 65 % selon un réglage de renfort des transitoires. Le corps ne réagit que lorsque ce niveau mélangé dépasse les trois quarts du réglage de sensibilité. En dessous, Kwami respire.

Plus fort, plus de pointes

Le corps est une sphère unité de 180 segments sur 180 par défaut, un peu moins de 33 000 sommets. Aucun vertex shader ne les déplace. La forme est calculée sur le CPU, réécrite et dotée de normales neuves à chaque image, avec trois à sept échantillons de bruit par sommet. Chaque sommet garde la direction qu’il avait la première fois que le maillage a été animé, et chaque image repart de ces directions. Une version antérieure relisait plutôt les sommets dans le maillage, et quelques secondes de dérive suffisaient à figer un cône dans le corps.

Le long de cette direction, le rayon vient d’un bruit simplex, le bruit de gradient de Ken Perlin sur une grille de tétraèdres. Chaque échantillon mélange les gradients de quatre coins au lieu des huit d’un cube, et rend une valeur lisse juste à l’intérieur de −1 à 1. On l’échantillonne en 3D à la direction du sommet, pas en 2D sur la carte UV de la sphère : le déplacement n’a donc pas de couture, et rien ne se pince aux pôles. Le champ est construit une fois par page à partir d’une table mélangée, et ne change jamais. Le temps s’ajoute aux coordonnées, si bien que la sphère glisse à travers lui. Au repos, deux couches lentes de ce bruit modèlent la surface. Quand la voix est assez forte, une poussée faite des bandes basse, médium et aiguë, plafonnée à 3, augmente à la fois la hauteur et la fréquence d’une troisième couche :

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 * detailNoise

Plus fort ne veut pas seulement dire des pointes plus hautes. Cela veut dire plus de pointes. Multiplier les coordonnées par audioFreqBoost fait échantillonner à la sphère une portion plus large du champ, donc davantage de ses bosses tiennent sur la même surface. Au-delà d’une poussée de 0,2, une quatrième couche, au double de cette fréquence, apparaît en fondu pour le détail fin. La somme est bornée entre 0,55 et 1,45 fois le rayon au repos, puis lissée sommet par sommet par le même filtre, avec α de 0,3 à 0,7 quand la voix est active et 0,25 sinon. Un filtre qui ne mélange que des valeurs comprises dans cet intervalle ne peut jamais en sortir. Enfin, l’amplitude de chaque sommet pondère les trois amplitudes d’axe par les |x|, |y| et |z| de sa direction. Sur un vecteur unitaire, |x| + |y| + |z| va de 1 à √3 : à amplitudes égales, les diagonales bougent jusqu’à 73 % de plus que les points sur les axes.

Plus fort ne veut pas seulement dire des pointes plus hautes. Cela veut dire plus de pointes.

Écouter, réfléchir, respirer

L’agent vocal publie son état, et le corps mélange trois champs en fonction de lui. Parler, comme le repos, utilise celui qu’on vient de voir. Écouter retourne le relief de repos comme un gant, à 70 % de sa force, si bien que les bosses deviennent des creux, et garde 40 % des pointes audio. Réfléchir, qui dure au plus dix secondes par défaut, agite trois couches plus rapides, dont deux dérivent à mesure que la réflexion avance. Le tout pulse, et s’estompe en 1 − p², où p est la part de ce temps déjà écoulée. Les mélanges progressent de façon linéaire, à l’échelle du temps réel entre deux images : par défaut, un changement prend un tiers de seconde, à 60 Hz comme à 120. Dans le silence, le corps respire : son échelle monte et descend une fois toutes les 3,5 secondes, d’environ un demi pour cent avec le réglage par défaut.

La couleur est l’affaire du fragment shader. La peau par défaut, l’une des 22, pondère trois couleurs selon l’angle θ autour de l’axe z du corps, chacune par 0,5 + 0,5·cos(θ − 2πk/3), pour k = 0, 1, 2. Trois cosinus décalés de 120° ont toujours une somme nulle : les poids font donc toujours 1,5 au total, et les diviser par leur somme ne fait que les remettre à l’échelle. La voix déplace chaque sommet le long de sa propre direction, ce qui laisse θ intact : les couleurs suivent la surface au lieu de glisser dessus. Un reflet de Phong par-dessus donne au corps son éclat.

Alex