diff --git a/scripts/gen_sounds.py b/scripts/gen_sounds.py new file mode 100644 index 0000000..8e55757 --- /dev/null +++ b/scripts/gen_sounds.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Generate Petal's cute UI sound palette as small WAV assets. + +These are warm, rounded little sounds — bubble pops, water drops, soft bells — +played when suggestions and companion bubbles appear. We synthesize them here +(rather than ship someone else's clips) so they match the app's gentle aesthetic +exactly and stay tiny. Output lands in web/src/assets/sounds/ where Vite bundles +and fingerprints them into dist (and so into the embedded Go binary). + +Run: python3 scripts/gen_sounds.py +""" +import math +import os +import struct +import wave + +SR = 44100 +OUT = os.path.join(os.path.dirname(__file__), "..", "web", "src", "assets", "sounds") + + +def env(n, attack, decay, total): + """Smooth attack + exponential decay envelope over `total` seconds.""" + a = max(1, int(attack * SR)) + out = [] + for i in range(n): + t = i / SR + if i < a: + amp = i / a + else: + amp = math.exp(-(t - attack) / decay) + out.append(amp) + return out + + +def sine(freq_at, n): + """Sine with a per-sample frequency function freq_at(t)->Hz (phase-accurate).""" + out = [] + phase = 0.0 + for i in range(n): + t = i / SR + f = freq_at(t) + phase += 2 * math.pi * f / SR + out.append(math.sin(phase)) + return out + + +def mix(*layers): + n = max(len(l) for l in layers) + out = [0.0] * n + for l in layers: + for i, v in enumerate(l): + out[i] += v + return out + + +def soft_clip(s): + return [math.tanh(v * 1.2) for v in s] + + +def normalize(s, peak=0.9): + m = max(1e-9, max(abs(v) for v in s)) + g = peak / m + return [v * g for v in s] + + +def write_wav(name, samples): + samples = soft_clip(samples) + samples = normalize(samples, 0.85) + # 4ms fade-out tail so nothing clicks at the end. + tail = int(0.004 * SR) + for i in range(tail): + samples[-1 - i] *= i / tail + os.makedirs(OUT, exist_ok=True) + path = os.path.join(OUT, name) + with wave.open(path, "w") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(SR) + frames = b"".join(struct.pack(">(new Set()) + useEffect(() => { + const fresh = suggestions.filter((s) => !chimedRef.current.has(s.id)) + fresh.forEach((s) => chimedRef.current.add(s.id)) + const justMade = fresh.filter( + (s) => Date.now() - new Date(s.created_at).getTime() < 12_000, + ) + if (justMade.length === 0) return + const types = [...new Set(justMade.map((s) => s.type))].slice(0, 3) + const timers = types.map((t, i) => + window.setTimeout(() => playSuggestionSound(t), i * 150), + ) + return () => timers.forEach((id) => window.clearTimeout(id)) + }, [suggestions]) + + // Forget chimed ids when switching documents so the set can't grow unbounded. + useEffect(() => { + chimedRef.current = new Set() + }, [currentDoc?.id]) + return (
+
= { + pop: popUrl, + droplet: dropletUrl, + chime: chimeUrl, + shimmer: shimmerUrl, + cheer: cheerUrl, +} + +let ctx: AudioContext | null = null +let master: GainNode | null = null +const buffers = new Map() +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 { + 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 { + 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 { + 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 { + 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 = { + 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) +} diff --git a/web/src/components/Companion/useCompanion.ts b/web/src/components/Companion/useCompanion.ts index a1b6f4b..e47a47f 100644 --- a/web/src/components/Companion/useCompanion.ts +++ b/web/src/components/Companion/useCompanion.ts @@ -12,6 +12,7 @@ import { type Line, } from './tips' import { analyzeProse } from './prose' +import { playSound } from '../../audio/sounds' // The kitten's expression. Maps to a Lottie animation when assets are present, // otherwise to an emoji placeholder (see PetalCompanion). @@ -95,6 +96,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', + ) setBubble(b) setMood(opts?.celebrate ? 'celebrate' : 'talking') clearTimeout(bubbleTimer.current) diff --git a/web/src/components/StatusBar/SoundToggle.tsx b/web/src/components/StatusBar/SoundToggle.tsx new file mode 100644 index 0000000..b3ea1f5 --- /dev/null +++ b/web/src/components/StatusBar/SoundToggle.tsx @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react' +import { isSoundEnabled, onSoundEnabledChange, playSound, 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 +// when sounds are turned back on so the choice is audible. +export function SoundToggle() { + const [on, setOn] = useState(isSoundEnabled) + + // Stay in sync if the setting is flipped elsewhere. + useEffect(() => onSoundEnabledChange(setOn), []) + + const toggle = () => { + const next = !on + setSoundEnabled(next) + setOn(next) + if (next) playSound('pop') // a little hello when re-enabled + } + + return ( + + ) +} diff --git a/web/src/components/StatusBar/StatusBar.tsx b/web/src/components/StatusBar/StatusBar.tsx index f4a1ca0..69cec87 100644 --- a/web/src/components/StatusBar/StatusBar.tsx +++ b/web/src/components/StatusBar/StatusBar.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react' import type { SaveStatus } from '../../hooks/useAutoSave' import { StatsPanel } from './StatsPanel' +import { SoundToggle } from './SoundToggle' interface Props { wordCount: number @@ -120,6 +121,9 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmD )} +
+ +
) } diff --git a/web/src/effects/PetalFall.tsx b/web/src/effects/PetalFall.tsx new file mode 100644 index 0000000..00e1214 --- /dev/null +++ b/web/src/effects/PetalFall.tsx @@ -0,0 +1,267 @@ +import { useEffect, useRef } from 'react' + +// PetalFall is the cozy ambient layer: a full-window WebGL canvas that drifts a +// handful of soft flower petals down the page. It sits behind everything, +// ignores pointer events, and stays sparse so it reads as "occasional petals on +// a breeze" rather than confetti. Honors prefers-reduced-motion by rendering +// nothing. No external libraries — a single tiny shader program animates every +// petal entirely on the GPU, so the CPU just spins a clock. + +// Each petal is a quad whose corners we expand in the vertex shader. We pack one +// quad as 6 vertices; per-vertex we send the corner offset (which corner) plus +// the petal's static randomness (lane, speed, size, sway, tint, spin). +const PETAL_COUNT = 26 + +// Soft Petal-palette tints (rose / blush / lavender), as RGB 0..1. +const TINTS: [number, number, number][] = [ + [0.91, 0.63, 0.75], // accent rose #E8A0BF + [1.0, 0.94, 0.96], // near-white blush + [0.96, 0.72, 0.63], // peach #F4B8A0 + [0.77, 0.71, 0.91], // lavender #C5B4E8 + [0.85, 0.55, 0.69], // deeper rose +] + +const VERT = ` +precision mediump float; +attribute vec2 a_corner; // quad corner in [-0.5,0.5] +attribute vec4 a_rand; // x: lane(0..1), y: speed, z: size, w: phase +attribute vec4 a_rand2; // x: swayAmp, y: swayFreq, z: spin, w: tintIdx +uniform float u_time; +uniform vec2 u_res; +varying vec2 v_uv; +varying float v_tint; +varying float v_alpha; + +void main() { + v_uv = a_corner + 0.5; + v_tint = a_rand2.w; + + float lane = a_rand.x; + float speed = a_rand.y; + float size = a_rand.z; + float phase = a_rand.w; + + // Vertical fall: loop 0..1 over time, offset per petal so they don't sync. + float fall = fract(u_time * speed + phase); + // Map to clip space: start above the top (1.15) fall to below bottom (-1.15). + float y = 1.15 - fall * 2.3; + + // Horizontal: a base lane plus a gentle sway as it descends. + float sway = a_rand2.x * sin(u_time * a_rand2.y + phase * 6.2831); + float x = (lane * 2.0 - 1.0) + sway; + + // Spin the quad over time. + float ang = u_time * a_rand2.z + phase * 6.2831; + float c = cos(ang), s = sin(ang); + vec2 corner = a_corner * size; + // Keep petals visually square despite aspect ratio. + corner.x *= u_res.y / u_res.x; + vec2 rot = vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c); + + // Fade in near the top and out near the bottom so they don't pop. + v_alpha = smoothstep(0.0, 0.12, fall) * (1.0 - smoothstep(0.86, 1.0, fall)); + + gl_Position = vec4(x + rot.x, y + rot.y, 0.0, 1.0); +} +` + +const FRAG = ` +precision mediump float; +varying vec2 v_uv; +varying float v_tint; +varying float v_alpha; +uniform vec3 u_tints[5]; + +// A soft petal/teardrop alpha mask centered in the quad. +float petalMask(vec2 uv) { + vec2 p = uv - vec2(0.5); + // Teardrop: pinch the top, round the bottom. + float r = length(vec2(p.x * 1.65, p.y * 1.15 + 0.12)); + float body = smoothstep(0.5, 0.16, r); + // A subtle crease down the middle for a petal feel. + float crease = 1.0 - 0.18 * exp(-pow(p.x * 7.0, 2.0)); + return body * crease; +} + +void main() { + float m = petalMask(v_uv); + if (m < 0.01) discard; + int idx = int(v_tint + 0.5); + vec3 col = u_tints[0]; + if (idx == 1) col = u_tints[1]; + else if (idx == 2) col = u_tints[2]; + else if (idx == 3) col = u_tints[3]; + else if (idx == 4) col = u_tints[4]; + gl_FragColor = vec4(col, m * v_alpha * 0.55); +} +` + +function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null { + const sh = gl.createShader(type) + if (!sh) return null + gl.shaderSource(sh, src) + gl.compileShader(sh) + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + console.error('petal shader compile failed', gl.getShaderInfoLog(sh)) + gl.deleteShader(sh) + return null + } + return sh +} + +// Deterministic-ish pseudo random (we can't use Math.random at module scope for +// SSR safety, but here in an effect it's fine; kept simple). +function rand(): number { + return Math.random() +} + +export function PetalFall() { + const canvasRef = useRef(null) + + useEffect(() => { + const reduce = + typeof window !== 'undefined' && + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches + if (reduce) return // respect users who don't want ambient motion + + const canvas = canvasRef.current + if (!canvas) return + const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false, antialias: true }) + if (!gl) return // no WebGL — silently skip the effect + + const vs = compile(gl, gl.VERTEX_SHADER, VERT) + const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG) + if (!vs || !fs) return + const prog = gl.createProgram()! + gl.attachShader(prog, vs) + gl.attachShader(prog, fs) + gl.linkProgram(prog) + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { + console.error('petal program link failed', gl.getProgramInfoLog(prog)) + return + } + gl.useProgram(prog) + + // Build interleaved vertex data: 6 verts per petal, each with corner(2), + // rand(4), rand2(4) = 10 floats. + const FLOATS_PER_VERT = 10 + const corners = [ + [-0.5, -0.5], + [0.5, -0.5], + [-0.5, 0.5], + [-0.5, 0.5], + [0.5, -0.5], + [0.5, 0.5], + ] + const data = new Float32Array(PETAL_COUNT * 6 * FLOATS_PER_VERT) + let o = 0 + for (let i = 0; i < PETAL_COUNT; i++) { + const lane = rand() + const speed = 0.018 + rand() * 0.03 // slow, gentle fall + const size = 0.05 + rand() * 0.06 + const phase = rand() + const swayAmp = 0.04 + rand() * 0.09 + const swayFreq = 0.4 + rand() * 0.7 + const spin = (rand() - 0.5) * 1.2 + const tint = Math.floor(rand() * TINTS.length) + for (let c = 0; c < 6; c++) { + data[o++] = corners[c][0] + data[o++] = corners[c][1] + data[o++] = lane + data[o++] = speed + data[o++] = size + data[o++] = phase + data[o++] = swayAmp + data[o++] = swayFreq + data[o++] = spin + data[o++] = tint + } + } + + const buf = gl.createBuffer() + gl.bindBuffer(gl.ARRAY_BUFFER, buf) + gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW) + + const stride = FLOATS_PER_VERT * 4 + const aCorner = gl.getAttribLocation(prog, 'a_corner') + const aRand = gl.getAttribLocation(prog, 'a_rand') + const aRand2 = gl.getAttribLocation(prog, 'a_rand2') + gl.enableVertexAttribArray(aCorner) + gl.vertexAttribPointer(aCorner, 2, gl.FLOAT, false, stride, 0) + gl.enableVertexAttribArray(aRand) + gl.vertexAttribPointer(aRand, 4, gl.FLOAT, false, stride, 2 * 4) + gl.enableVertexAttribArray(aRand2) + gl.vertexAttribPointer(aRand2, 4, gl.FLOAT, false, stride, 6 * 4) + + const uTime = gl.getUniformLocation(prog, 'u_time') + const uRes = gl.getUniformLocation(prog, 'u_res') + const uTints = gl.getUniformLocation(prog, 'u_tints') + gl.uniform3fv(uTints, TINTS.flat()) + + gl.enable(gl.BLEND) + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) + gl.disable(gl.DEPTH_TEST) + + // Non-null aliases so the render closures keep the narrowed types. + const view = canvas + const glc = gl + const dpr = Math.min(window.devicePixelRatio || 1, 2) + function resize() { + const w = Math.floor(window.innerWidth * dpr) + const h = Math.floor(window.innerHeight * dpr) + if (view.width !== w || view.height !== h) { + view.width = w + view.height = h + } + glc.viewport(0, 0, view.width, view.height) + glc.uniform2f(uRes, view.width, view.height) + } + resize() + window.addEventListener('resize', resize) + + let raf = 0 + let running = true + const start = performance.now() + function frame() { + if (!running) return + const t = (performance.now() - start) / 1000 + glc.clearColor(0, 0, 0, 0) + glc.clear(glc.COLOR_BUFFER_BIT) + glc.uniform1f(uTime, t) + glc.drawArrays(glc.TRIANGLES, 0, PETAL_COUNT * 6) + raf = requestAnimationFrame(frame) + } + // Pause when the tab is hidden to save the battery. + const onVisibility = () => { + if (document.hidden) { + running = false + cancelAnimationFrame(raf) + } else if (!running) { + running = true + raf = requestAnimationFrame(frame) + } + } + document.addEventListener('visibilitychange', onVisibility) + raf = requestAnimationFrame(frame) + + return () => { + running = false + cancelAnimationFrame(raf) + window.removeEventListener('resize', resize) + document.removeEventListener('visibilitychange', onVisibility) + gl.deleteBuffer(buf) + gl.deleteProgram(prog) + gl.deleteShader(vs) + gl.deleteShader(fs) + } + }, []) + + return ( + + ) +} diff --git a/web/src/index.css b/web/src/index.css index 4190c40..bf8e52f 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -382,7 +382,31 @@ button, a, input { only the title and the writing itself reach the page. This is Petal's PDF path — it uses the browser's own fonts, so CJK renders correctly with no server-side font embedding. */ +/* --- Ambient falling petals ------------------------------------------------- + The WebGL petal layer is a fixed, non-interactive overlay that drifts soft + blossoms over the page. Kept gentle (low opacity) so it never competes with + the writing. It hides itself in print and is skipped entirely for users who + prefer reduced motion (the component renders nothing in that case). */ +.petal-fall { + opacity: 0.85; +} + +/* Honor reduced-motion globally: still the looping ambient animations and drop + the petal layer. One-shot entrance/feedback animations are left intact. */ +@media (prefers-reduced-motion: reduce) { + .petal-fall { + display: none !important; + } + .petal-bob, + .petal-bob-slow, + .petal-zzz, + .petal-checkpoint-dot { + animation: none !important; + } +} + @media print { + .petal-fall, .petal-no-print, .petal-sidebar, .petal-suggestion-card, diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +///