Replace the synthesized WAV blips with curated mp3 effects:
- error ("haiya") plays on LLM-down/timeout and save failures, debounced
and on-transition-only, with a reassuring bilingual bubble
- milestone (Chinese fanfare) now fires every 100 words
- all other notifications draw from a rotating pop pool that never repeats
the last sound, so batches of suggestions/tips stay lively
playPop() replaces the per-suggestion-type sound map; thread llmDown through
PetalCompanion -> useCompanion for the error cue. Old synth WAVs removed.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
195 lines
6.5 KiB
TypeScript
195 lines
6.5 KiB
TypeScript
// 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<SoundName, string> = {
|
|
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<SoundName, AudioBuffer>()
|
|
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<AudioContext | null> {
|
|
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<ArrayBuffer> {
|
|
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<AudioBuffer | null> {
|
|
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<void> {
|
|
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)
|
|
}
|