Files
Matewave/tools/sfx/generate_sfx.py
T
Claude 6ac5a582db Add app source for F-Droid release (v1.0.43)
Adds the full Android source tree with unit tests, fastlane store
metadata and the Gradle wrapper. The archive shipped without
gradle-wrapper.jar, so the official Gradle 9.7.1 wrapper jar is
included to make ./gradlew work from a fresh clone. .gitignore now
also excludes build output and keystores. README gains build
instructions and an updated structure tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015g6ECVSKcBG8CJPrqC3G5o
2026-10-06 18:23:20 +00:00

122 lines
3.0 KiB
Python

"""Synthesizes the board sound effects (CC0 1.0).
Usage:
python3 generate_sfx.py OUT_DIR
for s in move capture select; do
ffmpeg -y -fflags +bitexact -i OUT_DIR/sfx_$s.wav -map_metadata -1 \
-c:a libvorbis -q:a 5 ../../app/src/main/res/raw/sfx_$s.ogg
done
"""
import sys
import wave
from pathlib import Path
import numpy as np
SR = 44100
def modes(t, rng, specs):
out = np.zeros_like(t)
for freq, amp, tau in specs:
out += amp * np.sin(2 * np.pi * freq * t + rng.uniform(0, 2 * np.pi)) * np.exp(-t / tau)
return out
def band_noise(n, lo, hi, rng):
spectrum = np.fft.rfft(rng.standard_normal(n))
freqs = np.fft.rfftfreq(n, 1 / SR)
spectrum[(freqs < lo) | (freqs > hi)] = 0
x = np.fft.irfft(spectrum, n)
return x / np.max(np.abs(x))
def hit(dur, specs, noise_band, noise_tau, noise_amp, rng):
n = int(SR * dur)
t = np.arange(n) / SR
x = modes(t, rng, specs)
x += noise_amp * band_noise(n, *noise_band, rng) * np.exp(-t / noise_tau)
return x * np.minimum(1.0, t / 0.0004)
def board_knock(dur, rng, scale=1.0):
return hit(
dur,
[
(125 * scale, 0.45, 0.018),
(410 * scale, 1.00, 0.042),
(1130 * scale, 0.55, 0.024),
(2210 * scale, 0.30, 0.013),
(3050 * scale, 0.35, 0.007),
(4700 * scale, 0.15, 0.004),
],
(1200, 8000), 0.0025, 0.55, rng,
)
def piece_clack(dur, rng):
return hit(
dur,
[
(1650, 0.40, 0.016),
(2450, 1.00, 0.012),
(3900, 0.60, 0.008),
(6100, 0.30, 0.004),
],
(1800, 10000), 0.0018, 0.70, rng,
)
def finish(x, rms_db, fade=0.012):
n = int(SR * fade)
x = x.copy()
x[-n:] *= np.linspace(1, 0, n)
x *= 10 ** (rms_db / 20) / np.sqrt(np.mean(x ** 2))
peak = np.max(np.abs(x))
return x if peak <= 0.94 else x * 0.94 / peak
def move(rng):
return finish(board_knock(0.20, rng), -24.0)
def capture(rng):
total = int(SR * 0.30)
x = np.zeros(total)
clack = piece_clack(0.10, rng) * 0.85
x[: len(clack)] += clack
start = int(SR * 0.030)
knock = board_knock(0.30 - 0.030, rng, scale=0.94)
x[start:start + len(knock)] += knock
return finish(x, -25.0)
def select(rng):
x = hit(
0.10,
[(1850, 0.60, 0.007), (3300, 1.00, 0.005), (5200, 0.35, 0.003)],
(2500, 9000), 0.0012, 0.45, rng,
)
return finish(x, -27.0, fade=0.008)
def write_wav(path, x):
pcm = np.clip(np.round(x * 32767), -32768, 32767).astype("<i2")
with wave.open(str(path), "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(SR)
w.writeframes(pcm.tobytes())
def main():
out = Path(sys.argv[1])
out.mkdir(parents=True, exist_ok=True)
for seed, (name, make) in enumerate([("move", move), ("capture", capture), ("select", select)]):
write_wav(out / f"sfx_{name}.wav", make(np.random.default_rng(seed + 1)))
if __name__ == "__main__":
main()