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:
@@ -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}
|
||||||
|
|||||||
BIN
web/src/assets/sounds/baoding.mp3
Normal file
BIN
web/src/assets/sounds/baoding.mp3
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/block.mp3
Normal file
BIN
web/src/assets/sounds/block.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
web/src/assets/sounds/error.mp3
Normal file
BIN
web/src/assets/sounds/error.mp3
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/milestone.mp3
Normal file
BIN
web/src/assets/sounds/milestone.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
web/src/assets/sounds/pop1.mp3
Normal file
BIN
web/src/assets/sounds/pop1.mp3
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/pop2.mp3
Normal file
BIN
web/src/assets/sounds/pop2.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
web/src/assets/sounds/tok.mp3
Normal file
BIN
web/src/assets/sounds/tok.mp3
Normal file
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -48,8 +48,16 @@ export const WELCOME_BACK: Line = {
|
|||||||
en: 'Welcome back ✨ let’s keep going!',
|
en: 'Welcome back ✨ let’s 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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user