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 <PetalCompanion
wordCount={wordCount} wordCount={wordCount}
saveStatus={status} saveStatus={status}
llmDown={llmDown}
editTick={editTick} editTick={editTick}
acceptTick={acceptTick} acceptTick={acceptTick}
text={docText} 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 // Petal's cute sound palette. These are hand-picked mp3 effects (warm bubble
// ./assets (generated by scripts/gen_sounds.py — warm bubble pops, water drops, // pops, a wooden "tok", rolling baoding balls, a Chinese fanfare for milestones,
// and soft bells tuned to match the app's gentle look). Vite fingerprints and // and a playful "haiya" for errors) living as assets in ./assets/sounds. Vite
// bundles them into dist, so they ride along inside the single Go binary; here // fingerprints and bundles them into dist, so they ride along inside the single
// we just fetch + decode them once and play them through a Web Audio bus with a // Go binary; here we just fetch + decode them once and play them through a Web
// soft master volume. The whole thing is opt-out-able and the mute choice // Audio bus with a soft master volume. The whole thing is opt-out-able and the
// persists in localStorage so it survives reloads. // mute choice persists in localStorage so it survives reloads.
import popUrl from '../assets/sounds/pop.wav' import pop1Url from '../assets/sounds/pop1.mp3'
import dropletUrl from '../assets/sounds/droplet.wav' import pop2Url from '../assets/sounds/pop2.mp3'
import chimeUrl from '../assets/sounds/chime.wav' import tokUrl from '../assets/sounds/tok.mp3'
import shimmerUrl from '../assets/sounds/shimmer.wav' import blockUrl from '../assets/sounds/block.mp3'
import cheerUrl from '../assets/sounds/cheer.wav' 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' const STORAGE_KEY = 'petal.sound'
@@ -18,20 +20,32 @@ const STORAGE_KEY = 'petal.sound'
const MASTER_GAIN = 0.5 const MASTER_GAIN = 0.5
export type SoundName = export type SoundName =
| 'pop' // a soft bubble pop — the signature "something arrived" | 'pop1' // a soft bubble pop
| 'droplet' // a single rounded water-drop ping | 'pop2' // a second bubble pop, slightly different
| 'chime' // a gentle two-note bell | 'tok' // a light wooden "tok"
| 'shimmer' // three quick ascending sparkles | 'block' // a quick Chinese wood-block tap
| 'cheer' // a happy little major arpeggio (accepts, milestones) | '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> = { const SOURCES: Record<SoundName, string> = {
pop: popUrl, pop1: pop1Url,
droplet: dropletUrl, pop2: pop2Url,
chime: chimeUrl, tok: tokUrl,
shimmer: shimmerUrl, block: blockUrl,
cheer: cheerUrl, 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 ctx: AudioContext | null = null
let master: GainNode | null = null let master: GainNode | null = null
const buffers = new Map<SoundName, AudioBuffer>() 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 // Play the next notification pop from the pool, never repeating the last one, so
// little voice (grammar pops like a bubble, idioms chime, etc.). // consecutive blips always sound different. This is the everyday "something
const SUGGESTION_SOUND: Record<string, SoundName> = { // arrived" voice for suggestions, tips, and gentle cheers.
grammar: 'pop', export function playPop(): void {
phrasing: 'droplet', let i = lastPop
idiom: 'chime', // Tiny pool, so a short scan to pick anything but the last is plenty.
clarity: 'shimmer', for (let tries = 0; tries < POP_POOL.length && i === lastPop; tries++) {
voice: 'droplet', i = Math.floor(Math.random() * POP_POOL.length)
}
lastPop = i
playSound(POP_POOL[i])
} }
export function playSuggestionSound(type: string): void { // Each freshly-surfaced suggestion just gets a rotating pop — the variety comes
playSound(SUGGESTION_SOUND[type] ?? 'pop') // 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 // 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 { interface Props {
wordCount: number wordCount: number
saveStatus: SaveStatus saveStatus: SaveStatus
llmDown: boolean
editTick: number editTick: number
acceptTick: number acceptTick: number
text: string text: string
@@ -27,10 +28,11 @@ const STORAGE_KEY = 'petal.companion'
// useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and // useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and
// break reminders. Clicking the mascot opens a picker to switch companions // break reminders. Clicking the mascot opens a picker to switch companions
// (the choice persists in localStorage). // (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({ const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({
wordCount, wordCount,
saveStatus, saveStatus,
llmDown,
editTick, editTick,
acceptTick, acceptTick,
text, text,

View File

@@ -48,8 +48,16 @@ export const WELCOME_BACK: Line = {
en: 'Welcome back ✨ lets keep going!', en: 'Welcome back ✨ lets keep going!',
} }
// Word-count milestones worth a little cheer. // Gentle "haiya, something went wrong" lines — paired with the error sound when
export const MILESTONES = [50, 100, 250, 500, 1000, 2000] // 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 { export function milestoneLine(n: number): Line {
return { return {

View File

@@ -3,6 +3,7 @@ import type { SaveStatus } from '../../hooks/useAutoSave'
import { import {
BREAKS, BREAKS,
ENCOURAGEMENTS, ENCOURAGEMENTS,
ERRORS,
GREETING, GREETING,
MILESTONES, MILESTONES,
TIPS, TIPS,
@@ -12,13 +13,13 @@ import {
type Line, type Line,
} from './tips' } from './tips'
import { analyzeProse } from './prose' 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, // The kitten's expression. Maps to a Lottie animation when assets are present,
// otherwise to an emoji placeholder (see PetalCompanion). // otherwise to an emoji placeholder (see PetalCompanion).
export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate' 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 { export interface Bubble extends Line {
tone: BubbleTone tone: BubbleTone
} }
@@ -26,6 +27,9 @@ export interface Bubble extends Line {
interface Signals { interface Signals {
wordCount: number wordCount: number
saveStatus: SaveStatus 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 // Monotonic counters bumped by the app on each editor change / accepted
// suggestion — lets the companion react without a full event bus. // suggestion — lets the companion react without a full event bus.
editTick: number editTick: number
@@ -62,7 +66,7 @@ function readBubbleMs(b: Bubble): number {
// useCompanion is the behavior engine: it watches writing signals and decides // 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 // 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. // 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 [mood, setMood] = useState<Mood>('idle')
const [bubble, setBubble] = useState<Bubble | null>(null) 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 // Show a bubble + talking mood, then settle back. `proactive` messages respect
// the spacing floor; user-triggered ones (cheers) always go through. // 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() const t = now()
if (opts?.proactive) { if (opts?.proactive) {
if (bubble) return if (bubble) return
@@ -96,11 +101,11 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text
lastProactive.current = t lastProactive.current = t
} }
sleeping.current = false sleeping.current = false
// A soft sound to match the bubble's mood — celebrations cheer, gentle // A sound to match the bubble: callers can name a specific one (the milestone
// cheers sparkle, break nudges chime, and tips give a little bubble pop. // fanfare, the "haiya" on errors); everything else gets a rotating pop so the
playSound( // same blip never repeats back to back.
opts?.celebrate ? 'cheer' : b.tone === 'cheer' ? 'shimmer' : b.tone === 'break' ? 'chime' : 'pop', if (opts?.sound) playSound(opts.sound)
) else playPop()
setBubble(b) setBubble(b)
setMood(opts?.celebrate ? 'celebrate' : 'talking') setMood(opts?.celebrate ? 'celebrate' : 'talking')
clearTimeout(bubbleTimer.current) clearTimeout(bubbleTimer.current)
@@ -196,11 +201,39 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text
const n = MILESTONES[nextMilestone.current] const n = MILESTONES[nextMilestone.current]
nextMilestone.current += 1 nextMilestone.current += 1
// Skip silently if we're just loading a long doc (no edits yet). // 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [wordCount]) }, [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). // Occasional gentle cheer on a successful save (kept rare so it isn't noise).
useEffect(() => { useEffect(() => {
if (saveStatus === 'saved' && Math.random() < 0.18) { if (saveStatus === 'saved' && Math.random() < 0.18) {

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' 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 // 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 // bar. Bilingual tooltip (she reads Mandarin first), and a soft confirming pop
@@ -14,7 +14,7 @@ export function SoundToggle() {
const next = !on const next = !on
setSoundEnabled(next) setSoundEnabled(next)
setOn(next) setOn(next)
if (next) playSound('pop') // a little hello when re-enabled if (next) playPop() // a little hello when re-enabled
} }
return ( return (