feat(monitor/frontend): audio chime infrastructure (load/unlock/play, phase 6 wires the trigger)

Per spec §10.5 — three pure functions sitting on top of the existing
useAudioStore (so React surfaces can subscribe to .unlocked if a
"click anywhere to enable audio" toast is needed later):

  loadChime(file)              fetch / decode arraybuffer into AudioBuffer
  unlockOnFirstGesture()       attach one-shot pointerdown listener that
                               resumes a suspended AudioContext, sets
                               unlocked=true. Idempotent across re-mounts
                               via module-level listenerAttached guard
                               (StrictMode double-mount safe).
  playChime() → boolean        returns false if the audio context isn't
                               ready / buffer not loaded / not unlocked,
                               so callers can fall back to a toast.

Phase 6 calls playChime() on alert match; Phase 5 only wires the
unlockOnFirstGesture() call into the dashboard lifecycle (Task 24)
so the audio is ready when Phase 6 lands.
This commit is contained in:
CarterPerez-dev 2026-05-03 09:41:22 -04:00
parent 43df53f845
commit 21f54390df
1 changed files with 47 additions and 0 deletions

View File

@ -0,0 +1,47 @@
// ©AngelaMos | 2026
// audio.ts
import { useAudioStore } from '@/stores/audio'
let listenerAttached = false
function ensureCtx(): AudioContext {
let ctx = useAudioStore.getState().ctx
if (!ctx) {
ctx = new AudioContext()
useAudioStore.getState().setCtx(ctx)
}
return ctx
}
export async function loadChime(file: File): Promise<void> {
const ctx = ensureCtx()
const ab = await file.arrayBuffer()
const buffer = await ctx.decodeAudioData(ab)
useAudioStore.getState().setBuffer(buffer)
}
export function unlockOnFirstGesture(): void {
if (useAudioStore.getState().unlocked) return
if (listenerAttached) return
listenerAttached = true
const handler = async (): Promise<void> => {
listenerAttached = false
const ctx = ensureCtx()
if (ctx.state === 'suspended') await ctx.resume()
useAudioStore.getState().setUnlocked(true)
}
document.addEventListener('pointerdown', handler, { once: true })
}
export function playChime(): boolean {
const { ctx, buffer, unlocked } = useAudioStore.getState()
if (!ctx || !buffer || !unlocked) return false
const src = ctx.createBufferSource()
src.buffer = buffer
src.connect(ctx.destination)
src.start(0)
return true
}