export type AudioCueKey = | 'session_started' | 'control_completed:start' | 'control_completed:control' | 'control_completed:finish' | 'punch_feedback:warning' | 'guidance:searching' | 'guidance:distant' | 'guidance:approaching' | 'guidance:ready' export interface AudioCueConfig { src: string volume: number loop: boolean loopGapMs: number backgroundMode: 'disabled' | 'guidance' } export interface GameAudioConfig { enabled: boolean masterVolume: number obeyMuteSwitch: boolean backgroundAudioEnabled: boolean distantDistanceMeters: number approachDistanceMeters: number readyDistanceMeters: number cues: Record } export interface PartialAudioCueConfig { src?: string volume?: number loop?: boolean loopGapMs?: number backgroundMode?: 'disabled' | 'guidance' } export interface GameAudioConfigOverrides { enabled?: boolean masterVolume?: number obeyMuteSwitch?: boolean backgroundAudioEnabled?: boolean distantDistanceMeters?: number approachDistanceMeters?: number readyDistanceMeters?: number cues?: Partial> } export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = { enabled: true, masterVolume: 1, obeyMuteSwitch: true, backgroundAudioEnabled: true, distantDistanceMeters: 80, approachDistanceMeters: 20, readyDistanceMeters: 5, cues: { session_started: { src: '/assets/sounds/session-start.wav', volume: 0.78, loop: false, loopGapMs: 0, backgroundMode: 'disabled', }, 'control_completed:start': { src: '/assets/sounds/start-complete.wav', volume: 0.84, loop: false, loopGapMs: 0, backgroundMode: 'disabled', }, 'control_completed:control': { src: '/assets/sounds/control-complete.wav', volume: 0.8, loop: false, loopGapMs: 0, backgroundMode: 'disabled', }, 'control_completed:finish': { src: '/assets/sounds/finish-complete.wav', volume: 0.92, loop: false, loopGapMs: 0, backgroundMode: 'disabled', }, 'punch_feedback:warning': { src: '/assets/sounds/warning.wav', volume: 0.72, loop: false, loopGapMs: 0, backgroundMode: 'disabled', }, 'guidance:searching': { src: '/assets/sounds/guidance-searching.wav', volume: 0.48, loop: true, loopGapMs: 1800, backgroundMode: 'guidance', }, 'guidance:distant': { src: '/assets/sounds/guidance-searching.wav', volume: 0.34, loop: true, loopGapMs: 4800, backgroundMode: 'guidance', }, 'guidance:approaching': { src: '/assets/sounds/guidance-approaching.wav', volume: 0.58, loop: true, loopGapMs: 950, backgroundMode: 'guidance', }, 'guidance:ready': { src: '/assets/sounds/guidance-ready.wav', volume: 0.68, loop: true, loopGapMs: 650, backgroundMode: 'guidance', }, }, } function clampVolume(value: number, fallback: number): number { return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : fallback } function clampDistance(value: number, fallback: number): number { return Number.isFinite(value) && value > 0 ? value : fallback } function clampGap(value: number, fallback: number): number { return Number.isFinite(value) && value >= 0 ? value : fallback } export function mergeGameAudioConfig(overrides?: GameAudioConfigOverrides | null): GameAudioConfig { const cues: GameAudioConfig['cues'] = { session_started: { ...DEFAULT_GAME_AUDIO_CONFIG.cues.session_started }, 'control_completed:start': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:start'] }, 'control_completed:control': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:control'] }, 'control_completed:finish': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:finish'] }, 'punch_feedback:warning': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['punch_feedback:warning'] }, 'guidance:searching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:searching'] }, 'guidance:distant': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:distant'] }, 'guidance:approaching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:approaching'] }, 'guidance:ready': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:ready'] }, } if (overrides && overrides.cues) { const keys = Object.keys(overrides.cues) as AudioCueKey[] for (const key of keys) { const cue = overrides.cues[key] if (!cue) { continue } if (typeof cue.src === 'string' && cue.src) { cues[key].src = cue.src } if (cue.volume !== undefined) { cues[key].volume = clampVolume(Number(cue.volume), cues[key].volume) } if (cue.loop !== undefined) { cues[key].loop = !!cue.loop } if (cue.loopGapMs !== undefined) { cues[key].loopGapMs = clampGap(Number(cue.loopGapMs), cues[key].loopGapMs) } if (cue.backgroundMode === 'disabled' || cue.backgroundMode === 'guidance') { cues[key].backgroundMode = cue.backgroundMode } } } return { enabled: overrides && overrides.enabled !== undefined ? !!overrides.enabled : DEFAULT_GAME_AUDIO_CONFIG.enabled, masterVolume: clampVolume(Number(overrides && overrides.masterVolume), DEFAULT_GAME_AUDIO_CONFIG.masterVolume), obeyMuteSwitch: overrides && overrides.obeyMuteSwitch !== undefined ? !!overrides.obeyMuteSwitch : DEFAULT_GAME_AUDIO_CONFIG.obeyMuteSwitch, backgroundAudioEnabled: overrides && overrides.backgroundAudioEnabled !== undefined ? !!overrides.backgroundAudioEnabled : true, distantDistanceMeters: clampDistance( Number(overrides && overrides.distantDistanceMeters), DEFAULT_GAME_AUDIO_CONFIG.distantDistanceMeters, ), approachDistanceMeters: clampDistance(Number(overrides && overrides.approachDistanceMeters), DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters), readyDistanceMeters: clampDistance( Number(overrides && overrides.readyDistanceMeters), DEFAULT_GAME_AUDIO_CONFIG.readyDistanceMeters, ), cues, } }