Files
petal/scripts/gen_sounds.py
prosolis 2487e73551 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
2026-06-26 07:34:23 -07:00

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))