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
This commit is contained in:
131
frontend/src/lib/audio.ts
Normal file
131
frontend/src/lib/audio.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user