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
132 lines
3.6 KiB
TypeScript
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()
|
|
}
|
|
}
|
|
}
|
|
}
|