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:
165
scripts/gen_sounds.py
Normal file
165
scripts/gen_sounds.py
Normal file
@@ -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("<h", int(max(-1, min(1, v)) * 32767)) for v in samples)
|
||||
w.writeframes(frames)
|
||||
print(f"wrote {name} ({len(samples)/SR*1000:.0f} ms, {len(frames)} bytes)")
|
||||
|
||||
|
||||
def apply_env(sig, e):
|
||||
return [s * a for s, a in zip(sig, e)]
|
||||
|
||||
|
||||
def bubble_pop():
|
||||
"""A cute bubble 'bloop' — pitch swoops up fast then a rounded body."""
|
||||
dur = 0.16
|
||||
n = int(dur * SR)
|
||||
# Rising swoop is the signature of a bubble.
|
||||
body = sine(lambda t: 360 + 720 * (1 - math.exp(-t * 38)), n)
|
||||
body = apply_env(body, env(n, 0.004, 0.05, dur))
|
||||
# A soft octave shimmer on top.
|
||||
shimmer = sine(lambda t: 1080 + 400 * (1 - math.exp(-t * 38)), n)
|
||||
shimmer = apply_env(shimmer, env(n, 0.003, 0.03, dur))
|
||||
shimmer = [v * 0.25 for v in shimmer]
|
||||
return mix(body, shimmer)
|
||||
|
||||
|
||||
def droplet():
|
||||
"""A clean little water-drop ping — high, downward, watery tail."""
|
||||
dur = 0.22
|
||||
n = int(dur * SR)
|
||||
main = sine(lambda t: 1500 * math.exp(-t * 3.2) + 760, n)
|
||||
main = apply_env(main, env(n, 0.003, 0.06, dur))
|
||||
# Tiny resonant echo for a wet feel.
|
||||
echo = sine(lambda t: 980, n)
|
||||
echo = apply_env(echo, env(n, 0.05, 0.07, dur))
|
||||
echo = [v * 0.2 for v in echo]
|
||||
return mix(main, droop_delay(echo, 0.04))
|
||||
|
||||
|
||||
def droop_delay(sig, delay_s):
|
||||
d = int(delay_s * SR)
|
||||
return [0.0] * d + sig
|
||||
|
||||
|
||||
def bell(freq, dur, partials=((1, 1.0), (2.01, 0.5), (2.78, 0.28), (4.1, 0.12))):
|
||||
"""A soft inharmonic bell tone (sum of slightly detuned partials)."""
|
||||
n = int(dur * SR)
|
||||
layers = []
|
||||
for ratio, amp in partials:
|
||||
s = sine(lambda t, f=freq * ratio: f, n)
|
||||
s = apply_env(s, env(n, 0.005, dur * 0.45, dur))
|
||||
layers.append([v * amp for v in s])
|
||||
return mix(*layers)
|
||||
|
||||
|
||||
def chime():
|
||||
"""Two gentle bell notes a soft fifth apart."""
|
||||
a = bell(784, 0.42) # G5
|
||||
b = bell(1175, 0.40) # D6
|
||||
b = droop_delay(b, 0.10)
|
||||
return mix(a, [v * 0.85 for v in b])
|
||||
|
||||
|
||||
def shimmer():
|
||||
"""Three quick ascending sparkles — glockenspiel-ish."""
|
||||
notes = [988, 1319, 1976] # B5, E6, B6
|
||||
layers = []
|
||||
for i, f in enumerate(notes):
|
||||
s = bell(f, 0.30, partials=((1, 1.0), (2.7, 0.3), (5.1, 0.1)))
|
||||
layers.append(droop_delay([v * 0.8 for v in s], 0.06 * i))
|
||||
return mix(*layers)
|
||||
|
||||
|
||||
def cheer():
|
||||
"""A happy little major arpeggio C5-E5-G5-C6 on bells."""
|
||||
notes = [523, 659, 784, 1047]
|
||||
layers = []
|
||||
for i, f in enumerate(notes):
|
||||
s = bell(f, 0.36)
|
||||
layers.append(droop_delay([v * 0.8 for v in s], 0.075 * i))
|
||||
return mix(*layers)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
write_wav("pop.wav", bubble_pop())
|
||||
write_wav("droplet.wav", droplet())
|
||||
write_wav("chime.wav", chime())
|
||||
write_wav("shimmer.wav", shimmer())
|
||||
write_wav("cheer.wav", cheer())
|
||||
print("done →", os.path.normpath(OUT))
|
||||
Reference in New Issue
Block a user