Notification sounds: hand-picked mp3 palette + error/milestone cues

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
This commit is contained in:
prosolis
2026-06-26 09:05:05 -07:00
parent 045ec19b92
commit 58c326d0cf
18 changed files with 111 additions and 47 deletions

View File

@@ -1,16 +1,18 @@
// 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.
// 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 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'
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'
@@ -18,20 +20,32 @@ const STORAGE_KEY = 'petal.sound'
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)
| '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> = {
pop: popUrl,
droplet: dropletUrl,
chime: chimeUrl,
shimmer: shimmerUrl,
cheer: cheerUrl,
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>()
@@ -147,18 +161,24 @@ export function playSound(name: SoundName): void {
})()
}
// 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<string, SoundName> = {
grammar: 'pop',
phrasing: 'droplet',
idiom: 'chime',
clarity: 'shimmer',
voice: 'droplet',
// 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])
}
export function playSuggestionSound(type: string): void {
playSound(SUGGESTION_SOUND[type] ?? 'pop')
// 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