Skip to content

Instantly share code, notes, and snippets.

@arturitu
Created February 16, 2026 19:33
Show Gist options
  • Select an option

  • Save arturitu/b9fac1c294405700b429aa50b7190c06 to your computer and use it in GitHub Desktop.

Select an option

Save arturitu/b9fac1c294405700b429aa50b7190c06 to your computer and use it in GitHub Desktop.
PhonemeAnalyzer
import { PhonemeKey } from '../types';
/**
* PhonemeAnalyzer redesigned for the Hanna-Barbera animation system (Preston Blair).
* Classifies audio input into 6 mouth shapes (A-F) based on formants and amplitude.
*/
export class PhonemeAnalyzer {
private analyser: AnalyserNode;
private dataArray: Uint8Array;
private binFrequency: number;
private lastPhoneme: PhonemeKey = 'A';
private phonemeHoldFrames: number = 0;
private readonly MIN_HOLD_FRAMES = 1;
constructor(audioContext: AudioContext, source: AudioNode) {
this.analyser = audioContext.createAnalyser();
this.analyser.fftSize = 512;
this.analyser.smoothingTimeConstant = 0.35;
source.connect(this.analyser);
this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
this.binFrequency = audioContext.sampleRate / this.analyser.fftSize;
}
private hzToBin(hz: number): number {
return Math.round(hz / this.binFrequency);
}
private getAverageFrequencyRangeHz(startHz: number, endHz: number): number {
const startBin = this.hzToBin(startHz);
const endBin = this.hzToBin(endHz);
let sum = 0;
const start = Math.max(0, startBin);
const end = Math.min(this.dataArray.length, endBin);
const count = end - start;
if (count <= 0) return 0;
for (let i = start; i < end; i++) sum += this.dataArray[i];
return sum / count / 255;
}
public getPhoneme(): PhonemeKey {
const detected = this.detectHannaBarberaShape();
if (detected === this.lastPhoneme) {
this.phonemeHoldFrames = 0;
return detected;
}
this.phonemeHoldFrames++;
if (detected === 'D' || detected === 'A' || this.phonemeHoldFrames >= this.MIN_HOLD_FRAMES) {
this.lastPhoneme = detected;
this.phonemeHoldFrames = 0;
}
return this.lastPhoneme;
}
private detectHannaBarberaShape(): PhonemeKey {
this.analyser.getByteFrequencyData(this.dataArray);
const energyLow = this.getAverageFrequencyRangeHz(80, 400);
const energyMid = this.getAverageFrequencyRangeHz(400, 1500);
const energyHigh = this.getAverageFrequencyRangeHz(2000, 6000);
const totalVolume = (energyLow + energyMid + energyHigh) / 3;
// If there is silence, we return 'A' (Rests/Closed)
if (totalVolume < 0.05) return "A";
if (totalVolume < 0.12 && energyLow > energyHigh) return "A";
if (totalVolume > 0.4 && energyMid > energyHigh * 1.5) return "D";
if (energyHigh > energyMid * 0.8 || (energyHigh > 0.2 && energyLow < 0.3)) return "B";
if (energyLow > energyMid * 2.5 && energyHigh < 0.1) return "F";
if (energyLow > energyHigh * 2 && energyMid > energyHigh) return "E";
return "C";
}
public dispose() {
try { this.analyser.disconnect(); } catch (e) {}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment