The mute toggle, the falling-petals toggle and the chosen companion lived in localStorage, which is a property of the machine. Now that two people can sign in to one Petal, sharing a laptop would have meant sharing a mascot and one person's silence muting the other. Each key is namespaced by user id. The awkward part is timing: sounds.ts and petals.ts read their value the moment they are imported, long before /api/me can have answered. Rather than block startup on the network for a mute flag, a read before the answer arrives sees the old un-namespaced key -- on a single-writer browser, exactly the right value -- and setPrefsScope then adopts it into that account's namespace and tells every reader to look again. Adoption moves rather than copies, so the first account inherits what was set before accounts existed and the second starts from Petal's defaults. The personal spelling dictionary moves further than that: onto the server. It is built from her own writing, so it should not be readable by whoever sits down at the same browser next -- but merely namespacing it would have split the list she already has between her laptop and her tablet, which is worse than where we started. A table keyed (user_id, lang, word) follows her instead. The lang is the dictionary's, not hers: an English exception must not silence a pt-PT flag once the second pair ships. Adding a word takes effect in the editor immediately and persists in the background, so the underline goes away the instant she asks. A browser still holding the old list hands it over on first load, and only lets go once the server has taken it. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
201 lines
6.9 KiB
TypeScript
201 lines
6.9 KiB
TypeScript
// Petal's cute sound palette. These are hand-picked mp3 effects (warm bubble
|
|
// pops, a wooden "tok", rolling baoding balls, a Chinese fanfare for milestones,
|
|
// and a playful "haiya" for errors) living as assets in ./assets/sounds. Vite
|
|
// fingerprints and bundles them into dist, so they ride along inside the single
|
|
// Go binary; here we just fetch + decode them once and play them through a Web
|
|
// Audio bus with a soft master volume. The whole thing is opt-out-able and the
|
|
// mute choice persists in localStorage so it survives reloads.
|
|
|
|
import pop1Url from '../assets/sounds/pop1.mp3'
|
|
import pop2Url from '../assets/sounds/pop2.mp3'
|
|
import tokUrl from '../assets/sounds/tok.mp3'
|
|
import blockUrl from '../assets/sounds/block.mp3'
|
|
import baodingUrl from '../assets/sounds/baoding.mp3'
|
|
import milestoneUrl from '../assets/sounds/milestone.mp3'
|
|
import errorUrl from '../assets/sounds/error.mp3'
|
|
import { onPrefsScopeChange, readPref, writePref } from '../lib/prefs'
|
|
|
|
// The mute choice belongs to the account, not the browser — one person's
|
|
// silence must not mute the next writer to sign in here.
|
|
const STORAGE_KEY = 'petal.sound'
|
|
|
|
// Master volume — deliberately gentle. These are background delights, not alerts.
|
|
const MASTER_GAIN = 0.5
|
|
|
|
export type SoundName =
|
|
| 'pop1' // a soft bubble pop
|
|
| 'pop2' // a second bubble pop, slightly different
|
|
| 'tok' // a light wooden "tok"
|
|
| 'block' // a quick Chinese wood-block tap
|
|
| '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> = {
|
|
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<SoundName, AudioBuffer>()
|
|
let enabled = readEnabled()
|
|
const listeners = new Set<(on: boolean) => void>()
|
|
|
|
function readEnabled(): boolean {
|
|
return readPref(STORAGE_KEY) !== 'off'
|
|
}
|
|
|
|
// This module reads its value at import time, before /api/me has answered.
|
|
// Re-read once the account is known, in case this writer's choice differs from
|
|
// whatever the browser was holding.
|
|
onPrefsScopeChange(() => {
|
|
const next = readEnabled()
|
|
if (next === enabled) return
|
|
enabled = next
|
|
listeners.forEach((fn) => fn(next))
|
|
if (next) void ensureContext()
|
|
})
|
|
|
|
export function isSoundEnabled(): boolean {
|
|
return enabled
|
|
}
|
|
|
|
export function setSoundEnabled(on: boolean): void {
|
|
enabled = on
|
|
writePref(STORAGE_KEY, on ? 'on' : 'off')
|
|
listeners.forEach((fn) => fn(on))
|
|
// Touching the context on enable doubles as a user-gesture unlock + warm-up.
|
|
if (on) void ensureContext()
|
|
}
|
|
|
|
export function onSoundEnabledChange(fn: (on: boolean) => void): () => void {
|
|
listeners.add(fn)
|
|
return () => listeners.delete(fn)
|
|
}
|
|
|
|
// Lazily create the AudioContext (browsers require a user gesture before audio
|
|
// can play; the first play() after a click will succeed, earlier ones resume).
|
|
async function ensureContext(): Promise<AudioContext | null> {
|
|
if (typeof window === 'undefined') return null
|
|
const AC: typeof AudioContext | undefined =
|
|
window.AudioContext ??
|
|
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
|
if (!AC) return null
|
|
if (!ctx) {
|
|
ctx = new AC()
|
|
master = ctx.createGain()
|
|
master.gain.value = MASTER_GAIN
|
|
// A gentle soft-clip so stacked sounds never crackle.
|
|
const shaper = ctx.createWaveShaper()
|
|
shaper.curve = softClipCurve()
|
|
master.connect(shaper)
|
|
shaper.connect(ctx.destination)
|
|
}
|
|
if (ctx.state === 'suspended') {
|
|
try {
|
|
await ctx.resume()
|
|
} catch {
|
|
/* will retry on the next gesture */
|
|
}
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
function softClipCurve(): Float32Array<ArrayBuffer> {
|
|
const n = 1024
|
|
const curve = new Float32Array(new ArrayBuffer(n * 4))
|
|
for (let i = 0; i < n; i++) {
|
|
const x = (i / (n - 1)) * 2 - 1
|
|
curve[i] = Math.tanh(x * 1.2)
|
|
}
|
|
return curve
|
|
}
|
|
|
|
// Fetch + decode one sound, caching the decoded buffer for instant replay.
|
|
async function load(name: SoundName, audio: AudioContext): Promise<AudioBuffer | null> {
|
|
const cached = buffers.get(name)
|
|
if (cached) return cached
|
|
try {
|
|
const res = await fetch(SOURCES[name])
|
|
const bytes = await res.arrayBuffer()
|
|
const buf = await audio.decodeAudioData(bytes)
|
|
buffers.set(name, buf)
|
|
return buf
|
|
} catch {
|
|
return null // asset missing / decode failed — sound just won't play
|
|
}
|
|
}
|
|
|
|
// Warm the cache so the first real play is instant.
|
|
async function prime(): Promise<void> {
|
|
const audio = await ensureContext()
|
|
if (!audio) return
|
|
await Promise.all((Object.keys(SOURCES) as SoundName[]).map((n) => load(n, audio)))
|
|
}
|
|
|
|
// Fire a sound. No-op when muted or audio is unavailable. Never throws — a
|
|
// delight that fails should fail silently.
|
|
export function playSound(name: SoundName): void {
|
|
if (!enabled) return
|
|
void (async () => {
|
|
const audio = await ensureContext()
|
|
if (!audio || !master || audio.state !== 'running') return
|
|
const buf = await load(name, audio)
|
|
if (!buf) return
|
|
try {
|
|
const src = audio.createBufferSource()
|
|
src.buffer = buf
|
|
src.connect(master)
|
|
src.start()
|
|
} catch {
|
|
/* audio glitch — ignore */
|
|
}
|
|
})()
|
|
}
|
|
|
|
// 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])
|
|
}
|
|
|
|
// 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
|
|
// dropped by the browser's autoplay policy. Self-removing.
|
|
if (typeof window !== 'undefined') {
|
|
const unlock = () => {
|
|
void prime()
|
|
window.removeEventListener('pointerdown', unlock)
|
|
window.removeEventListener('keydown', unlock)
|
|
}
|
|
window.addEventListener('pointerdown', unlock)
|
|
window.addEventListener('keydown', unlock)
|
|
}
|