Files
qtalker---/frontend/src/lib/audio.ts
Indiana 6edbbbbc2a feat: complete Quantumancy web app — full frontend + docs
Frontend (React 18 + TS + Vite):
- Landing: glitching hero, live /api/stats veil ticker, mode cards, featured spirits
- Séance: three.js shader ghost (hue/form per entity, mood + audio-reactive),
  Ouija planchette board spelling utterances, transcript with TTS replay,
  entity dossier, direct contact streaming, passive/active listening
- Modes: Wire Ghost telemetry panel, EVP mic anomaly detection, WebUSB
  RTL-SDR sweep + waterfall (hardware pass pending), Ouija/Direct Contact
- Codex: public registry + entity dossiers, rarity tiers, i18n EN/ES complete
- State: VeilSocket (reconnect/backoff), seance reducer, auth context
- 72 vitest tests green; served by FastAPI at :7777

Docs: README + as-built plans 3-7
2026-07-20 21:11:49 +00:00

132 lines
3.6 KiB
TypeScript

// Spirit voice playback: a single FIFO queue so overlapping audio frames
// never talk over each other. The currently-playing element is routed
// through an AnalyserNode so the ghost scene can pulse with the voice.
export type PlayingInfo = {
utteranceId: string
url: string
}
export type SpiritAudioCallbacks = {
onStart?: (info: PlayingInfo) => void
onEnd?: (info: PlayingInfo) => void
}
export class SpiritAudioPlayer {
private queue: PlayingInfo[] = []
private current: PlayingInfo | null = null
private el: HTMLAudioElement | null = null
private ctx: AudioContext | null = null
private analyser: AnalyserNode | null = null
private source: MediaElementAudioSourceNode | null = null
private cb: SpiritAudioCallbacks = {}
private ampBuf: Uint8Array<ArrayBuffer> = new Uint8Array(0)
setCallbacks(cb: SpiritAudioCallbacks): void {
this.cb = cb
}
get isPlaying(): boolean {
return this.current !== null
}
get currentId(): string | null {
return this.current?.utteranceId ?? null
}
get queuedCount(): number {
return this.queue.length
}
/** Enqueue audio for an utterance id. */
enqueue(utteranceId: string, url: string): void {
this.queue.push({ utteranceId, url })
void this.pump()
}
/** Instantaneous playback amplitude 0..1 (0 when silent/not playing). */
getAmplitude(): number {
if (!this.analyser || !this.current) return 0
if (this.ampBuf.length !== this.analyser.fftSize) {
this.ampBuf = new Uint8Array(this.analyser.fftSize)
}
this.analyser.getByteTimeDomainData(this.ampBuf)
let sum = 0
for (let i = 0; i < this.ampBuf.length; i++) {
const v = (this.ampBuf[i] - 128) / 128
sum += v * v
}
return Math.min(1, Math.sqrt(sum / this.ampBuf.length) * 3)
}
clear(): void {
this.queue = []
this.el?.pause()
this.el = null
this.current = null
}
private ensureGraph(el: HTMLAudioElement): void {
if (this.source && this.el === el) return
if (!this.ctx) {
this.ctx = new AudioContext()
this.analyser = this.ctx.createAnalyser()
this.analyser.fftSize = 512
this.analyser.smoothingTimeConstant = 0.7
this.analyser.connect(this.ctx.destination)
}
if (this.source) {
this.source.disconnect()
this.source = null
}
this.source = this.ctx.createMediaElementSource(el)
this.source.connect(this.analyser!)
}
private async pump(): Promise<void> {
if (this.current || this.queue.length === 0) return
const next = this.queue.shift()!
this.current = next
const el = new Audio(next.url)
this.el = el
el.crossOrigin = 'anonymous'
el.onended = () => {
const done = this.current
this.current = null
this.el = null
if (done) this.cb.onEnd?.(done)
void this.pump()
}
el.onerror = () => {
const done = this.current
this.current = null
this.el = null
if (done) this.cb.onEnd?.(done)
void this.pump()
}
try {
this.ensureGraph(el)
// AudioContext may be suspended until a user gesture; resume best-effort.
if (this.ctx && this.ctx.state === 'suspended') {
await this.ctx.resume().catch(() => undefined)
}
this.cb.onStart?.(next)
await el.play()
} catch {
// Autoplay blocked or unroutable: play without the analyser as fallback.
try {
await el.play()
this.cb.onStart?.(next)
} catch {
const done = this.current
this.current = null
this.el = null
if (done) this.cb.onEnd?.(done)
void this.pump()
}
}
}
}