// Sequentielle Audio-Wiedergabe für die TTS-Antworten + Pegel-Messung fürs Lippensync. // // Die einzelnen Satz-WAVs kommen nacheinander rein (satzweise Synthese → niedrige Latenz). // Wir spielen sie über EINEN AudioContext geordnet ab und hängen einen AnalyserNode dazwischen, // dessen Energie pro Frame in `level.current` (0..1) landet — der 3D-Avatar liest das im // useFrame und öffnet den Mund entsprechend. Kein Re-Render pro Frame (Mutable-Ref-Muster). export class AudioQueue { private ctx: AudioContext private analyser: AnalyserNode private queue: ArrayBuffer[] = [] private playing = false private raf = 0 private freq: Uint8Array /** Mutable, vom Avatar pro Frame gelesen. 0 = Mund zu, 1 = weit offen. */ readonly level = { current: 0 } onSpeaking?: (speaking: boolean) => void constructor() { const Ctor = window.AudioContext || (window as any).webkitAudioContext this.ctx = new Ctor() this.analyser = this.ctx.createAnalyser() this.analyser.fftSize = 256 this.analyser.smoothingTimeConstant = 0.6 this.analyser.connect(this.ctx.destination) this.freq = new Uint8Array(new ArrayBuffer(this.analyser.frequencyBinCount)) } async enqueue(buf: ArrayBuffer) { this.queue.push(buf) if (!this.playing) await this.playNext() } /** Laufende + wartende Wiedergabe verwerfen (z.B. wenn der Nutzer dazwischenredet). */ clear() { this.queue = [] } private async playNext(): Promise { const buf = this.queue.shift() if (!buf) { this.playing = false this.stopMeter() this.onSpeaking?.(false) return } this.playing = true this.onSpeaking?.(true) if (this.ctx.state === "suspended") { try { await this.ctx.resume() } catch { /* vom User-Gesture freigeschaltet */ } } let audioBuf: AudioBuffer try { audioBuf = await this.ctx.decodeAudioData(buf.slice(0)) } catch { return this.playNext() // kaputtes Segment überspringen } const src = this.ctx.createBufferSource() src.buffer = audioBuf src.connect(this.analyser) src.onended = () => { void this.playNext() } src.start() this.startMeter() } private startMeter() { cancelAnimationFrame(this.raf) const tick = () => { this.analyser.getByteFrequencyData(this.freq) // Sprachenergie liegt v.a. in den unteren/mittleren Bändern. const n = Math.min(this.freq.length, 48) let sum = 0 for (let i = 2; i < n; i++) sum += this.freq[i] const avg = sum / (n - 2) / 255 this.level.current = Math.min(1, avg * 1.9) this.raf = requestAnimationFrame(tick) } tick() } private stopMeter() { cancelAnimationFrame(this.raf) this.level.current = 0 } }