diff --git a/web/src/App.tsx b/web/src/App.tsx index 2e4f5a0..fb61874 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -475,6 +475,7 @@ export default function App() { = { - 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() @@ -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 = { - 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 diff --git a/web/src/components/Companion/PetalCompanion.tsx b/web/src/components/Companion/PetalCompanion.tsx index 0f4ac1f..d969b8f 100644 --- a/web/src/components/Companion/PetalCompanion.tsx +++ b/web/src/components/Companion/PetalCompanion.tsx @@ -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, diff --git a/web/src/components/Companion/tips.ts b/web/src/components/Companion/tips.ts index bb9bc26..cd3d20c 100644 --- a/web/src/components/Companion/tips.ts +++ b/web/src/components/Companion/tips.ts @@ -48,8 +48,16 @@ export const WELCOME_BACK: Line = { en: 'Welcome back ✨ let’s 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 { diff --git a/web/src/components/Companion/useCompanion.ts b/web/src/components/Companion/useCompanion.ts index e47a47f..ab070e4 100644 --- a/web/src/components/Companion/useCompanion.ts +++ b/web/src/components/Companion/useCompanion.ts @@ -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('idle') const [bubble, setBubble] = useState(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) { diff --git a/web/src/components/StatusBar/SoundToggle.tsx b/web/src/components/StatusBar/SoundToggle.tsx index b3ea1f5..f3a5d49 100644 --- a/web/src/components/StatusBar/SoundToggle.tsx +++ b/web/src/components/StatusBar/SoundToggle.tsx @@ -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 (