// Petal's cute sound palette. These are hand-picked mp3 effects (warm bubble // pops, a wooden "tok", rolling baoding balls, a Chinese fanfare for milestones, // and a playful "haiya" for errors) living as assets in ./assets/sounds. 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 pop1Url from '../assets/sounds/pop1.mp3' import pop2Url from '../assets/sounds/pop2.mp3' import tokUrl from '../assets/sounds/tok.mp3' import blockUrl from '../assets/sounds/block.mp3' import baodingUrl from '../assets/sounds/baoding.mp3' import milestoneUrl from '../assets/sounds/milestone.mp3' import errorUrl from '../assets/sounds/error.mp3' const STORAGE_KEY = 'petal.sound' // Master volume — deliberately gentle. These are background delights, not alerts. const MASTER_GAIN = 0.5 export type SoundName = | 'pop1' // a soft bubble pop | 'pop2' // a second bubble pop, slightly different | 'tok' // a light wooden "tok" | 'block' // a quick Chinese wood-block tap | 'baoding' // rolling baoding-ball shimmer | 'milestone' // a happy Chinese fanfare (word-count milestones) | 'error' // a playful "haiya" — something went wrong const SOURCES: Record = { pop1: pop1Url, pop2: pop2Url, tok: tokUrl, block: blockUrl, baoding: baodingUrl, milestone: milestoneUrl, error: errorUrl, } // The pool of light "something happened" notification sounds. We rotate through // these (skipping the one just played) so the same blip never repeats back to // back — a batch of suggestions or a run of tips stays lively instead of // hammering one note. milestone/error are deliberately NOT in the pool: they're // reserved for their specific moments. const POP_POOL: SoundName[] = ['pop1', 'pop2', 'tok', 'block', 'baoding'] let lastPop = -1 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 */ } })() } // Play the next notification pop from the pool, never repeating the last one, so // consecutive blips always sound different. This is the everyday "something // arrived" voice for suggestions, tips, and gentle cheers. export function playPop(): void { let i = lastPop // Tiny pool, so a short scan to pick anything but the last is plenty. for (let tries = 0; tries < POP_POOL.length && i === lastPop; tries++) { i = Math.floor(Math.random() * POP_POOL.length) } lastPop = i playSound(POP_POOL[i]) } // Each freshly-surfaced suggestion just gets a rotating pop — the variety comes // from the pool, not from the suggestion type, so a batch of same-type fixes // still sounds lively. export function playSuggestionSound(_type: string): void { playPop() } // 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) }