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

@@ -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) {