// Petal's cute sound palette. The actual tones live as tiny WAV assets in // ./assets (generated by scripts/gen_sounds.py — warm bubble pops, water drops, // and soft bells tuned to match the app's gentle look). Vite fingerprints and // bundles them into dist, so they ride along inside the single Go binary; here // we just fetch + decode them once and play them through a Web Audio bus with a // soft master volume. The whole thing is opt-out-able and the mute choice // persists in localStorage so it survives reloads. import popUrl from '../assets/sounds/pop.wav' import dropletUrl from '../assets/sounds/droplet.wav' import chimeUrl from '../assets/sounds/chime.wav' import shimmerUrl from '../assets/sounds/shimmer.wav' import cheerUrl from '../assets/sounds/cheer.wav' const STORAGE_KEY = 'petal.sound' // Master volume — deliberately gentle. These are background delights, not alerts. const MASTER_GAIN = 0.5 export type SoundName = | 'pop' // a soft bubble pop — the signature "something arrived" | 'droplet' // a single rounded water-drop ping | 'chime' // a gentle two-note bell | 'shimmer' // three quick ascending sparkles | 'cheer' // a happy little major arpeggio (accepts, milestones) const SOURCES: Record = { pop: popUrl, droplet: dropletUrl, chime: chimeUrl, shimmer: shimmerUrl, cheer: cheerUrl, } let ctx: AudioContext | null = null let master: GainNode | null = null const buffers = new Map() let enabled = readEnabled() const listeners = new Set<(on: boolean) => void>() function readEnabled(): boolean { try { return localStorage.getItem(STORAGE_KEY) !== 'off' } catch { return true } } export function isSoundEnabled(): boolean { return enabled } export function setSoundEnabled(on: boolean): void { enabled = on try { localStorage.setItem(STORAGE_KEY, on ? 'on' : 'off') } catch { /* private mode — choice just won't persist */ } listeners.forEach((fn) => fn(on)) // Touching the context on enable doubles as a user-gesture unlock + warm-up. if (on) void ensureContext() } export function onSoundEnabledChange(fn: (on: boolean) => void): () => void { listeners.add(fn) return () => listeners.delete(fn) } // Lazily create the AudioContext (browsers require a user gesture before audio // can play; the first play() after a click will succeed, earlier ones resume). async function ensureContext(): Promise { if (typeof window === 'undefined') return null const AC: typeof AudioContext | undefined = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext if (!AC) return null if (!ctx) { ctx = new AC() master = ctx.createGain() master.gain.value = MASTER_GAIN // A gentle soft-clip so stacked sounds never crackle. const shaper = ctx.createWaveShaper() shaper.curve = softClipCurve() master.connect(shaper) shaper.connect(ctx.destination) } if (ctx.state === 'suspended') { try { await ctx.resume() } catch { /* will retry on the next gesture */ } } return ctx } function softClipCurve(): Float32Array { const n = 1024 const curve = new Float32Array(new ArrayBuffer(n * 4)) for (let i = 0; i < n; i++) { const x = (i / (n - 1)) * 2 - 1 curve[i] = Math.tanh(x * 1.2) } return curve } // Fetch + decode one sound, caching the decoded buffer for instant replay. async function load(name: SoundName, audio: AudioContext): Promise { const cached = buffers.get(name) if (cached) return cached try { const res = await fetch(SOURCES[name]) const bytes = await res.arrayBuffer() const buf = await audio.decodeAudioData(bytes) buffers.set(name, buf) return buf } catch { return null // asset missing / decode failed — sound just won't play } } // Warm the cache so the first real play is instant. async function prime(): Promise { const audio = await ensureContext() if (!audio) return await Promise.all((Object.keys(SOURCES) as SoundName[]).map((n) => load(n, audio))) } // Fire a sound. No-op when muted or audio is unavailable. Never throws — a // delight that fails should fail silently. export function playSound(name: SoundName): void { if (!enabled) return void (async () => { const audio = await ensureContext() if (!audio || !master || audio.state !== 'running') return const buf = await load(name, audio) if (!buf) return try { const src = audio.createBufferSource() src.buffer = buf src.connect(master) src.start() } catch { /* audio glitch — ignore */ } })() } // Map an editor suggestion type to its sound, so each kind of help has its own // little voice (grammar pops like a bubble, idioms chime, etc.). const SUGGESTION_SOUND: Record = { grammar: 'pop', phrasing: 'droplet', idiom: 'chime', clarity: 'shimmer', voice: 'droplet', } export function playSuggestionSound(type: string): void { playSound(SUGGESTION_SOUND[type] ?? 'pop') } // Unlock + warm audio on the first user gesture so the very first sound isn't // dropped by the browser's autoplay policy. Self-removing. if (typeof window !== 'undefined') { const unlock = () => { void prime() window.removeEventListener('pointerdown', unlock) window.removeEventListener('keydown', unlock) } window.addEventListener('pointerdown', unlock) window.addEventListener('keydown', unlock) }