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

@@ -475,6 +475,7 @@ export default function App() {
<PetalCompanion
wordCount={wordCount}
saveStatus={status}
llmDown={llmDown}
editTick={editTick}
acceptTick={acceptTick}
text={docText}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

View File

@@ -7,6 +7,7 @@ import { COMPANIONS, DEFAULT_COMPANION } from './companions'
interface Props {
wordCount: number
saveStatus: SaveStatus
llmDown: boolean
editTick: number
acceptTick: number
text: string
@@ -27,10 +28,11 @@ const STORAGE_KEY = 'petal.companion'
// useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and
// break reminders. Clicking the mascot opens a picker to switch companions
// (the choice persists in localStorage).
export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick, text }: Props) {
export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Props) {
const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({
wordCount,
saveStatus,
llmDown,
editTick,
acceptTick,
text,

View File

@@ -48,8 +48,16 @@ export const WELCOME_BACK: Line = {
en: 'Welcome back ✨ lets keep going!',
}
// Word-count milestones worth a little cheer.
export const MILESTONES = [50, 100, 250, 500, 1000, 2000]
// Gentle "haiya, something went wrong" lines — paired with the error sound when
// the LLM is unreachable or a save fails. Never alarming, always reassuring.
export const ERRORS: Line[] = [
{ zh: '哎呀~出了点小问题,你的字都还在哦。', en: 'Oops — a little hiccup, but your words are safe.' },
{ zh: '哎呀,我这边卡了一下,马上就好。', en: 'Haiya, I got stuck for a sec — back in a moment.' },
{ zh: '别担心,等一下再试试看 🍵', en: "Don't worry — let's try again in a bit. 🍵" },
]
// Word-count milestones worth a little cheer — every 100 words, on up.
export const MILESTONES = Array.from({ length: 100 }, (_, i) => (i + 1) * 100)
export function milestoneLine(n: number): Line {
return {

View File

@@ -3,6 +3,7 @@ import type { SaveStatus } from '../../hooks/useAutoSave'
import {
BREAKS,
ENCOURAGEMENTS,
ERRORS,
GREETING,
MILESTONES,
TIPS,
@@ -12,13 +13,13 @@ import {
type Line,
} from './tips'
import { analyzeProse } from './prose'
import { playSound } from '../../audio/sounds'
import { playPop, playSound, type SoundName } from '../../audio/sounds'
// The kitten's expression. Maps to a Lottie animation when assets are present,
// otherwise to an emoji placeholder (see PetalCompanion).
export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate'
export type BubbleTone = 'cheer' | 'tip' | 'break'
export type BubbleTone = 'cheer' | 'tip' | 'break' | 'error'
export interface Bubble extends Line {
tone: BubbleTone
}
@@ -26,6 +27,9 @@ export interface Bubble extends Line {
interface Signals {
wordCount: number
saveStatus: SaveStatus
// True while the writing-assist LLM is unreachable (timeout / down). A
// false→true flip earns a gentle "haiya".
llmDown: boolean
// Monotonic counters bumped by the app on each editor change / accepted
// suggestion — lets the companion react without a full event bus.
editTick: number
@@ -62,7 +66,7 @@ function readBubbleMs(b: Bubble): number {
// useCompanion is the behavior engine: it watches writing signals and decides
// when the kitten speaks, what mood it shows, and how to pace itself so the
// companion feels alive without interrupting. UI-agnostic — returns state only.
export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text }: Signals) {
export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Signals) {
const [mood, setMood] = useState<Mood>('idle')
const [bubble, setBubble] = useState<Bubble | null>(null)
@@ -88,7 +92,8 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text
// Show a bubble + talking mood, then settle back. `proactive` messages respect
// the spacing floor; user-triggered ones (cheers) always go through.
const say = useCallback((b: Bubble, opts?: { proactive?: boolean; celebrate?: boolean }) => {
const say = useCallback(
(b: Bubble, opts?: { proactive?: boolean; celebrate?: boolean; sound?: SoundName }) => {
const t = now()
if (opts?.proactive) {
if (bubble) return
@@ -96,11 +101,11 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text
lastProactive.current = t
}
sleeping.current = false
// A soft sound to match the bubble's mood — celebrations cheer, gentle
// cheers sparkle, break nudges chime, and tips give a little bubble pop.
playSound(
opts?.celebrate ? 'cheer' : b.tone === 'cheer' ? 'shimmer' : b.tone === 'break' ? 'chime' : 'pop',
)
// A sound to match the bubble: callers can name a specific one (the milestone
// fanfare, the "haiya" on errors); everything else gets a rotating pop so the
// same blip never repeats back to back.
if (opts?.sound) playSound(opts.sound)
else playPop()
setBubble(b)
setMood(opts?.celebrate ? 'celebrate' : 'talking')
clearTimeout(bubbleTimer.current)
@@ -196,11 +201,39 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text
const n = MILESTONES[nextMilestone.current]
nextMilestone.current += 1
// Skip silently if we're just loading a long doc (no edits yet).
if (!firstEdit.current) say({ ...milestoneLine(n), tone: 'cheer' }, { celebrate: true })
if (!firstEdit.current)
say({ ...milestoneLine(n), tone: 'cheer' }, { celebrate: true, sound: 'milestone' })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wordCount])
// Trouble — a save failed or the writing-assist LLM went unreachable. Play the
// playful "haiya" and reassure her, but never spam: at most one per gap, and
// only on the *transition* into trouble (not while it lingers).
const lastError = useRef(0)
const ERROR_GAP = 20_000
const wasLlmDown = useRef(false)
const haiya = useCallback(() => {
const t = now()
if (t - lastError.current < ERROR_GAP) {
playSound('error') // still acknowledge it, just without a fresh bubble
return
}
lastError.current = t
say({ ...pick(ERRORS), tone: 'error' }, { sound: 'error' })
}, [say])
useEffect(() => {
if (saveStatus === 'error') haiya()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [saveStatus])
useEffect(() => {
if (llmDown && !wasLlmDown.current) haiya()
wasLlmDown.current = llmDown
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [llmDown])
// Occasional gentle cheer on a successful save (kept rare so it isn't noise).
useEffect(() => {
if (saveStatus === 'saved' && Math.random() < 0.18) {

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { isSoundEnabled, onSoundEnabledChange, playSound, setSoundEnabled } from '../../audio/sounds'
import { isSoundEnabled, onSoundEnabledChange, playPop, setSoundEnabled } from '../../audio/sounds'
// A tiny mute toggle for Petal's cute sounds, tucked at the right of the status
// bar. Bilingual tooltip (she reads Mandarin first), and a soft confirming pop
@@ -14,7 +14,7 @@ export function SoundToggle() {
const next = !on
setSoundEnabled(next)
setOn(next)
if (next) playSound('pop') // a little hello when re-enabled
if (next) playPop() // a little hello when re-enabled
}
return (