Files
2026-09-11 18:39:50 +03:00

177 lines
4.8 KiB
Python

#!/usr/bin/env python3
"""
degauss-sound "thwoonngg" every time.
degauss-sound # play it
degauss-sound -o out.wav # write a file instead
degauss-sound --seed 42 # reproducible, for tuning
degauss-sound --duration 1.5 # match your shader's DURATION
"""
import argparse
import array
import math
import os
import random
import subprocess
import sys
import tempfile
import wave
SR = 48000
TABLE_BITS = 12
TABLE_SIZE = 1 << TABLE_BITS
TABLE_MASK = TABLE_SIZE - 1
SINE = [math.sin(2.0 * math.pi * i / TABLE_SIZE) for i in range(TABLE_SIZE)]
def synth(duration, rng):
n = int(SR * duration)
out = array.array("d", bytes(8 * n))
# randomized tube personality
f0 = rng.uniform(46.0, 74.0)
glide = rng.uniform(3.0, 14.0)
h2 = rng.uniform(0.30, 0.60)
h3 = rng.uniform(0.12, 0.32)
h4 = rng.uniform(0.00, 0.09)
h5 = rng.uniform(0.03, 0.15)
warble_hz = rng.uniform(5.5, 10.5)
warble_depth = rng.uniform(0.28, 0.55)
wander_hz = rng.uniform(1.3, 3.4)
wander_depth = rng.uniform(0.4, 1.1)
decay = rng.uniform(2.1, 3.4)
rattle_amt = rng.uniform(0.025, 0.095)
rattle_decay = rng.uniform(6.0, 14.0)
thump_f = rng.uniform(33.0, 54.0)
thump_amt = rng.uniform(0.30, 0.70)
thump_decay = rng.uniform(11.0, 22.0)
second_kick = rng.random() < 0.35
kick_at = rng.uniform(0.22, 0.45)
kick_amt = rng.uniform(0.25, 0.55)
# phase accumulators
scale = TABLE_SIZE / SR
ph = 0.0
ph_warble = 0.0
ph_wander = 0.0
ph_thump = 0.0
dt = 1.0 / SR
inv_dur = 1.0 / duration
peak = 0.0
for i in range(n):
t = i * dt
k = t * inv_dur
# coil tone, gliding down
f = f0 - glide * k
ph += f * scale
p = int(ph)
tone = SINE[p & TABLE_MASK]
tone += h2 * SINE[(p << 1) & TABLE_MASK]
tone += h3 * SINE[(p * 3) & TABLE_MASK]
tone += h4 * SINE[(p << 2) & TABLE_MASK]
tone += h5 * SINE[(p * 5) & TABLE_MASK]
# warble, itself slowly wandering
ph_wander += wander_hz * scale
ph_warble += (
warble_hz + wander_depth * SINE[int(ph_wander) & TABLE_MASK]
) * scale
warble = 1.0 - warble_depth + warble_depth * SINE[int(ph_warble) & TABLE_MASK]
# envelope: near-instant attack, exponential decay
env = math.exp(-decay * t) * (1.0 - math.exp(-220.0 * t))
if second_kick:
d = t - kick_at
if d > 0.0:
env += (
kick_amt * math.exp(-decay * 1.6 * d) * (1.0 - math.exp(-160.0 * d))
)
# opening clunk
ph_thump += thump_f * scale
thump = (
thump_amt * SINE[int(ph_thump) & TABLE_MASK] * math.exp(-thump_decay * t)
)
# shadow-mask rattle
rattle = rattle_amt * (rng.random() * 2.0 - 1.0) * math.exp(-rattle_decay * t)
s = tone * warble * env + thump + rattle
out[i] = s
a = s if s >= 0.0 else -s
peak = max(peak, a)
# normalize, then fade the tail so it never clicks
gain = 0.85 / peak if peak > 0.0 else 0.0
fade = int(SR * 0.04)
pcm = array.array("h", bytes(2 * n))
for i in range(n):
s = out[i] * gain
if i >= n - fade:
s *= (n - i) / fade
v = int(s * 32767.0)
pcm[i] = 32767 if v > 32767 else (max(v, -32768))
return pcm
def write_wav(path, pcm):
with wave.open(path, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(SR)
w.writeframes(pcm.tobytes())
def play(path, volume):
for cmd in (
["pw-play", f"--volume={volume}", path],
["paplay", path],
["aplay", "-q", path],
):
try:
return subprocess.run(cmd, stderr=subprocess.DEVNULL).returncode # noqa: PLW1510
except FileNotFoundError:
continue
print("degauss-sound: no player found (pw-play/paplay/aplay)", file=sys.stderr)
return 1
def main():
ap = argparse.ArgumentParser()
ap.add_argument("-o", "--out", help="write a wav here instead of playing")
ap.add_argument(
"-d",
"--duration",
type=float,
default=1.5,
help="seconds; match your shader's DURATION (default 1.5)",
)
ap.add_argument("-s", "--seed", type=int, help="reproducible output")
ap.add_argument("-v", "--volume", default="0.5", help="pw-play volume")
args = ap.parse_args()
rng = random.Random(args.seed)
pcm = synth(args.duration, rng)
if args.out:
write_wav(args.out, pcm)
return 0
tmpdir = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir()
fd, path = tempfile.mkstemp(suffix=".wav", dir=tmpdir)
os.close(fd)
try:
write_wav(path, pcm)
return play(path, args.volume)
finally:
os.unlink(path)
if __name__ == "__main__":
sys.exit(main())