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
166 lines
4.8 KiB
Python
166 lines
4.8 KiB
Python
#!/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))
|