Ambient WebGL petals + cute notification sounds
Add a fixed, GPU-driven petal layer that drifts soft blossoms over the page (sparse, translucent, paused when hidden, skipped under prefers-reduced-motion). Add a synthesized cute sound palette — bubble pop, water droplet, soft bell, sparkle, cheer — generated as embedded WAV assets (scripts/gen_sounds.py) and played through a quiet Web Audio bus with soft-clipping and a persisted mute toggle in the status bar. Sounds fire per suggestion type when fresh advice arrives (staggered, old pending advice stays silent) and on companion bubbles by tone. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
174
web/src/audio/sounds.ts
Normal file
174
web/src/audio/sounds.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
// Petal's cute sound palette. The actual tones live as tiny WAV assets in
|
||||
// ./assets (generated by scripts/gen_sounds.py — warm bubble pops, water drops,
|
||||
// and soft bells tuned to match the app's gentle look). 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 popUrl from '../assets/sounds/pop.wav'
|
||||
import dropletUrl from '../assets/sounds/droplet.wav'
|
||||
import chimeUrl from '../assets/sounds/chime.wav'
|
||||
import shimmerUrl from '../assets/sounds/shimmer.wav'
|
||||
import cheerUrl from '../assets/sounds/cheer.wav'
|
||||
|
||||
const STORAGE_KEY = 'petal.sound'
|
||||
|
||||
// Master volume — deliberately gentle. These are background delights, not alerts.
|
||||
const MASTER_GAIN = 0.5
|
||||
|
||||
export type SoundName =
|
||||
| 'pop' // a soft bubble pop — the signature "something arrived"
|
||||
| 'droplet' // a single rounded water-drop ping
|
||||
| 'chime' // a gentle two-note bell
|
||||
| 'shimmer' // three quick ascending sparkles
|
||||
| 'cheer' // a happy little major arpeggio (accepts, milestones)
|
||||
|
||||
const SOURCES: Record<SoundName, string> = {
|
||||
pop: popUrl,
|
||||
droplet: dropletUrl,
|
||||
chime: chimeUrl,
|
||||
shimmer: shimmerUrl,
|
||||
cheer: cheerUrl,
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) !== 'off'
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function isSoundEnabled(): boolean {
|
||||
return enabled
|
||||
}
|
||||
|
||||
export function setSoundEnabled(on: boolean): void {
|
||||
enabled = on
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, on ? 'on' : 'off')
|
||||
} catch {
|
||||
/* private mode — choice just won't persist */
|
||||
}
|
||||
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 */
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
// 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<string, SoundName> = {
|
||||
grammar: 'pop',
|
||||
phrasing: 'droplet',
|
||||
idiom: 'chime',
|
||||
clarity: 'shimmer',
|
||||
voice: 'droplet',
|
||||
}
|
||||
|
||||
export function playSuggestionSound(type: string): void {
|
||||
playSound(SUGGESTION_SOUND[type] ?? 'pop')
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user