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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user