"""Synthesised placeholder SFX for the v1.0.0 cut. Pure stdlib — no numpy, no scipy, no third-party audio libs — so this script runs anywhere the game's .venv can run. Each sound is a short chiptune-style waveform written as a 16-bit mono PCM WAV into ``assets/audio/sfx/``. The audio.py loader looks up sounds by name (`audio.play("shoot")` → `assets/audio/sfx/shoot.wav`); the names below match every existing call site plus the ones wired in v0.9.0. The SFX are intentionally chiptune-thin — the meme-flavoured pass that the design notes call out is a post-v1.0 polish slot; these are the "every action has feedback" baseline. Re-run via ``./.venv/bin/python scripts/gen_sfx.py``. Idempotent: each call rewrites the WAVs to disk so a tuning tweak ships by running the script and committing the changed assets. """ import math import os import random import struct import wave SAMPLE_RATE = 44100 SFX_DIR = os.path.join("assets", "audio", "sfx") # Equal-temperament pitches in Hz. Just the notes I reach for in the # arpeggios below — adding more is one line of math. PITCH = { "A3": 220.0, "C4": 261.63, "D4": 293.66, "E4": 329.63, "F4": 349.23, "G4": 392.0, "A4": 440.0, "B4": 493.88, "C5": 523.25, "D5": 587.33, "E5": 659.25, "G5": 783.99, "A5": 880.0, "C6": 1046.5, "E6": 1318.51, "G6": 1567.98, } def _frames(seconds): return int(SAMPLE_RATE * seconds) def _envelope(n, attack=0.01, release=0.05, sustain=1.0): """Linear AR envelope; sustain is held between attack and release.""" a = max(1, int(SAMPLE_RATE * attack)) r = max(1, int(SAMPLE_RATE * release)) out = [0.0] * n for i in range(n): if i < a: out[i] = (i / a) * sustain elif i > n - r: out[i] = max(0.0, ((n - i) / r)) * sustain else: out[i] = sustain return out def _sine(freq, n, phase=0.0): w = 2 * math.pi * freq / SAMPLE_RATE return [math.sin(w * i + phase) for i in range(n)] def _square(freq, n, duty=0.5): period = SAMPLE_RATE / freq return [1.0 if (i % period) / period < duty else -1.0 for i in range(n)] def _triangle(freq, n): period = SAMPLE_RATE / freq out = [] for i in range(n): t = (i % period) / period out.append(4 * abs(t - 0.5) - 1) return out def _noise(n, rng): return [rng.uniform(-1.0, 1.0) for _ in range(n)] def _sweep(f0, f1, n, wave_fn=_square): """Linear pitch sweep from f0 to f1 over n samples.""" out = [0.0] * n phase = 0.0 for i in range(n): f = f0 + (f1 - f0) * (i / max(1, n - 1)) phase += 2 * math.pi * f / SAMPLE_RATE if wave_fn is _square: out[i] = 1.0 if math.sin(phase) > 0 else -1.0 elif wave_fn is _triangle: out[i] = (2 / math.pi) * math.asin(math.sin(phase)) else: out[i] = math.sin(phase) return out def _mix(*tracks): """Sum equal-length tracks, clip to [-1, 1].""" n = max(len(t) for t in tracks) out = [0.0] * n for t in tracks: for i, v in enumerate(t): out[i] += v peak = max((abs(v) for v in out), default=1.0) if peak > 1.0: out = [v / peak for v in out] return out def _concat(*chunks): out = [] for c in chunks: out.extend(c) return out def _apply(samples, env): n = min(len(samples), len(env)) return [samples[i] * env[i] for i in range(n)] def _write(name, samples, gain=0.6): """Write a 16-bit mono WAV at the project's sample rate.""" path = os.path.join(SFX_DIR, name + ".wav") with wave.open(path, "wb") as f: f.setnchannels(1) f.setsampwidth(2) f.setframerate(SAMPLE_RATE) peak = max((abs(v) for v in samples), default=1.0) norm = gain / max(0.01, peak) frames = b"".join( struct.pack("