diff --git a/music_theory/README.md b/music_theory/README.md index 54a1785..22e5ec6 100644 --- a/music_theory/README.md +++ b/music_theory/README.md @@ -70,6 +70,26 @@ and the signal is bit-crushed, hissed and low-passed. `degrade: 0` is byte- identical to the original, so "the ending is the opening, destroyed" is a one-flag transform on the same seed. +## Render sound effects + +`sfx` renders spellcode's spell and move sounds -- sfxr-style parameter +sets built from this package's own primitives, no AI audio and no sfxr +port -- as 16-bit mono WAVs plus a `MANIFEST.json` (every parameter, the +seed, the package version and each file's SHA-256): + +``` +python -m music_theory sfx --out sfx/ # all 22, 44.1 kHz +python -m music_theory sfx --out sfx/ --sample-rate 22050 # match the music beds +python -m music_theory sfx --out sfx/ --only fire_hit --only dodge +``` + +Presets: `_cast`, `_hit` and `_impact` for +arcane, fire, water, lightning, earth and wind, plus `dodge`, +`parry_normal`, `parry_perfect` and `fizzle`. Each is seeded from its +name, so a re-render is byte-identical. A sound is a few `sfx.Layer`s +(transient, body, tail, sub) mixed and normalised; define a new one with +`sfx_layers.make(name, *layers)` and render it with `sfx.render`. + ## Modules | module | article | contents | @@ -83,7 +103,13 @@ one-flag transform on the same seed. | `sequencer` | 10 | beat grid, `NoteEvent`, bar-exact render | | `compose` | 11 | `Recipe` → bass, arpeggio, melody | | `degrade` | — | the ruin transform | -| `analysis` | — | FFT peak and spectral centroid, so claims are testable | +| `analysis` | — | FFT peak, spectral centroid, band energy, octave-band slope | +| `noise` | — | seeded white and pink noise, Poisson grain gates | +| `sweep` | — | pitch curves (glide, vibrato, arpeggio, jumps, retrigger), phase-accumulating oscillator, ring mod | +| `envelopes` | — | exponential decay, attack-hold-punch-decay `Shape` | +| `biquad` | — | resonant low/high/band-pass with a moving cutoff | +| `sfx`, `sfx_layers` | — | sfxr-style layered parameter set and its renderer | +| `sfx_presets*`, `sfx_render` | — | spellcode's 22 presets, WAVs + manifest | | `wav`, `manifest` | — | 16-bit mono PCM out, SHA-256 provenance | ## Develop diff --git a/music_theory/music_theory/analysis.py b/music_theory/music_theory/analysis.py index b1ebee5..e9a9716 100644 --- a/music_theory/music_theory/analysis.py +++ b/music_theory/music_theory/analysis.py @@ -3,11 +3,15 @@ Not in the article, but every demo here makes a claim ("this is 440 Hz", "this is darker") and a claim about audio that only a listener can verify -is one the test suite cannot hold. These two measurements let it. +is one the test suite cannot hold. These measurements let it: the peak and +centroid for pitch and brightness, band energy for "the filter removed +that", and the octave-band slope for "this noise is white / pink". """ from __future__ import annotations +from itertools import pairwise + import numpy as np from music_theory.synth import SAMPLE_RATE, Signal @@ -39,3 +43,48 @@ def spectral_centroid(signal: Signal, sample_rate: int = SAMPLE_RATE) -> float: def peak(signal: Signal) -> float: """Loudest absolute sample value.""" return float(np.max(np.abs(signal))) if len(signal) else 0.0 + + +## An octave-band slope needs at least this many bands to fit a line. +_MIN_BANDS = 2 + + +def _power(signal: Signal, sample_rate: int) -> tuple[Signal, Signal]: + """Frequency bins and the power in each.""" + power = np.abs(np.fft.rfft(signal)) ** 2 + return np.fft.rfftfreq(len(signal), d=1.0 / sample_rate), power + + +def band_energy( + signal: Signal, low_hz: float, high_hz: float, sample_rate: int = SAMPLE_RATE +) -> float: + """Spectral power in ``[low_hz, high_hz)``: "is there energy below the cutoff".""" + if not signal.size: + return 0.0 + bins, power = _power(signal, sample_rate) + return float(np.sum(power[(bins >= low_hz) & (bins < high_hz)])) + + +def spectral_slope( + signal: Signal, + sample_rate: int = SAMPLE_RATE, + low_hz: float = 100.0, + high_hz: float = 8000.0, +) -> float: + """Slope of log power density against log frequency, over octave bands. + + White noise is flat (0), pink falls 3 dB per octave (-1), brown -2. + Octave bands, not raw bins: a single bin of noise is itself noise, but + the mean over an octave is steady enough to fit a line through. + """ + edges = low_hz * 2.0 ** np.arange(int(np.log2(high_hz / low_hz)) + 1) + if len(edges) <= _MIN_BANDS: + msg = f"{low_hz}..{high_hz} Hz spans fewer than {_MIN_BANDS} octave bands" + raise ValueError(msg) + bins, power = _power(signal, sample_rate) + centres = np.sqrt(edges[:-1] * edges[1:]) + density = np.array( + [np.mean(power[(bins >= lo) & (bins < hi)]) for lo, hi in pairwise(edges)] + ) + slope = np.polyfit(np.log2(centres), np.log2(density), 1)[0] + return float(slope) diff --git a/music_theory/music_theory/biquad.py b/music_theory/music_theory/biquad.py new file mode 100644 index 0000000..f6df25b --- /dev/null +++ b/music_theory/music_theory/biquad.py @@ -0,0 +1,114 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Biquad: low-, high- and band-pass filters with resonance, cutoff may move. + +``synth.lowpass`` is one pole: a gentle 6 dB/octave darkening with no +resonance and no other shapes. Sound effects need more -- a high-pass to +thin a crack, a resonant band-pass whose centre sweeps to make a whoosh. +These are the two-pole filters from Robert Bristow-Johnson's "Audio EQ +Cookbook", 12 dB/octave, with ``q`` as the resonance (0.707 = no peak). + +The cutoff can be a per-sample curve. Coefficients are recomputed once per +``BLOCK`` samples, not per sample: a sweep is far slower than 1.5 ms, and +the per-sample loop is the part that costs. +""" + +from __future__ import annotations + +import math +from typing import Literal + +import numpy as np + +from music_theory.synth import SAMPLE_RATE, Signal + +type FilterKind = Literal["lowpass", "highpass", "bandpass"] + +FILTER_KINDS: tuple[FilterKind, ...] = ("lowpass", "highpass", "bandpass") + +## Butterworth: the steepest response with no resonant bump. +FLAT_Q = 1.0 / math.sqrt(2.0) + +## Samples per coefficient update (~1.5 ms at 44.1 kHz). +BLOCK = 64 + +## Cutoffs are kept inside this audible, numerically safe range. +_MIN_HZ = 10.0 +_MAX_FRACTION_OF_RATE = 0.49 + +type Coefficients = tuple[Signal, Signal, Signal, Signal, Signal] + + +def coefficients( + kind: FilterKind, cutoff: Signal, q: float, sample_rate: int = SAMPLE_RATE +) -> Coefficients: + """Normalised (b0, b1, b2, a1, a2) for every cutoff, vectorised.""" + hz = np.clip(cutoff, _MIN_HZ, _MAX_FRACTION_OF_RATE * sample_rate) + w0 = 2.0 * math.pi * hz / sample_rate + cos_w0 = np.cos(w0) + alpha = np.sin(w0) / (2.0 * q) + a0 = 1.0 + alpha + if kind == "lowpass": + b0 = (1.0 - cos_w0) / 2.0 + b1 = 1.0 - cos_w0 + b2 = b0 + elif kind == "highpass": + b0 = (1.0 + cos_w0) / 2.0 + b1 = -(1.0 + cos_w0) + b2 = b0 + else: + b0 = alpha + b1 = np.zeros_like(alpha) + b2 = -alpha + return (b0 / a0, b1 / a0, b2 / a0, -2.0 * cos_w0 / a0, (1.0 - alpha) / a0) + + +def _cutoff_curve(cutoff: float | Signal, frames: int) -> Signal: + curve = np.broadcast_to(np.asarray(cutoff, dtype=np.float64), (frames,)) + return np.asarray(curve, dtype=np.float64) + + +def _validate(kind: FilterKind, q: float, cutoff: float | Signal, frames: int) -> None: + if kind not in FILTER_KINDS: + msg = f"unknown filter {kind!r}; choose one of {FILTER_KINDS}" + raise ValueError(msg) + if q <= 0.0: + msg = f"q must be positive, got {q}" + raise ValueError(msg) + if np.ndim(cutoff) and np.size(cutoff) != frames: + msg = f"need one cutoff per sample: {np.size(cutoff)} for {frames}" + raise ValueError(msg) + + +def _run(samples: list[float], blocks: list[tuple[float, ...]]) -> list[float]: + """The per-sample loop, in plain floats (numpy scalars are ~10x slower). + + Transposed direct form II: two state values, and it tolerates the + coefficients changing between blocks without blowing up. + """ + out = [0.0] * len(samples) + z1 = z2 = 0.0 + for start, (c0, c1, c2, d1, d2) in zip( + range(0, len(samples), BLOCK), blocks, strict=True + ): + for index in range(start, min(start + BLOCK, len(samples))): + x = samples[index] + y = c0 * x + z1 + z1 = c1 * x - d1 * y + z2 + z2 = c2 * x - d2 * y + out[index] = y + return out + + +def biquad( + signal: Signal, + kind: FilterKind, + cutoff: float | Signal, + q: float = FLAT_Q, + sample_rate: int = SAMPLE_RATE, +) -> Signal: + """Filter ``signal``; ``cutoff`` is Hz, constant or one value per sample.""" + _validate(kind, q, cutoff, len(signal)) + curve = _cutoff_curve(cutoff, len(signal))[::BLOCK] + columns = (c.tolist() for c in coefficients(kind, curve, q, sample_rate)) + blocks = list(zip(*columns, strict=True)) + return np.asarray(_run(signal.tolist(), blocks), dtype=np.float64) diff --git a/music_theory/music_theory/cli.py b/music_theory/music_theory/cli.py index 1f39afc..7174c9e 100644 --- a/music_theory/music_theory/cli.py +++ b/music_theory/music_theory/cli.py @@ -2,7 +2,8 @@ """Command line: hear a section of the article, or render a recipe to a file. Subcommands: ``list``, ``explain ``, ``demo --out DIR`` -and ``render --recipe recipe.json --out bed.wav [--manifest m.json]``. +``render --recipe recipe.json --out bed.wav [--manifest m.json]`` and +``sfx --out DIR [--sample-rate HZ] [--only NAME ...]``. """ from __future__ import annotations @@ -13,7 +14,7 @@ from pathlib import Path import sys from typing import TYPE_CHECKING -from music_theory import compose, demos, manifest, wav +from music_theory import compose, demos, manifest, sfx, sfx_presets, sfx_render, wav if TYPE_CHECKING: from music_theory.demo_support import Demo @@ -40,6 +41,18 @@ def build_parser() -> argparse.ArgumentParser: render.add_argument( "--degrade", type=float, help="override the recipe's degrade amount" ) + effects = sub.add_parser("sfx", help="render the SFX presets to WAVs + manifest") + effects.add_argument("--out", type=Path, required=True, help="directory to fill") + effects.add_argument( + "--sample-rate", type=int, choices=(22050, 44100), default=sfx.SFX_SAMPLE_RATE + ) + effects.add_argument( + "--only", + action="append", + choices=sfx_presets.NAMES, + metavar="NAME", + help="render just this preset (repeatable); default: all", + ) return parser @@ -89,6 +102,17 @@ def cmd_render( return 0 +def cmd_sfx(out_dir: Path, sample_rate: int, names: list[str] | None) -> int: + """Render SFX presets into ``out_dir`` and print how to hear each.""" + entries = sfx_render.render_all(out_dir, sample_rate, names) + for name in sfx_presets.NAMES if names is None else names: + record = entries[f"{name}.wav"] + path = out_dir / f"{name}.wav" + _emit(f"{path.name}: {record['seconds']:.2f} s {wav.play_command(path)}") + _emit(f"manifest: {out_dir / sfx_render.MANIFEST_NAME}") + return 0 + + def _print_demo(demo: Demo) -> None: _emit(f"[{demo.section}] {demo.name}") for fact in demo.facts: @@ -104,4 +128,6 @@ def main(argv: list[str] | None = None) -> int: return cmd_explain(args.name) if args.command == "demo": return cmd_demo(args.name, args.out) + if args.command == "sfx": + return cmd_sfx(args.out, args.sample_rate, args.only) return cmd_render(args.recipe, args.out, args.manifest, args.degrade) diff --git a/music_theory/music_theory/envelopes.py b/music_theory/music_theory/envelopes.py new file mode 100644 index 0000000..039d628 --- /dev/null +++ b/music_theory/music_theory/envelopes.py @@ -0,0 +1,67 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Envelopes for sound effects: struck, punched, and dying exponentially. + +``synth.adsr`` is a sustained note's envelope -- linear segments around a +held level. A hit or a zap is never held: it rises, may kick harder for a +moment (sfxr's "punch"), and then dies away the way a real resonance does, +by a constant ratio per unit of time. That last part is what reads as +"natural"; a linear fade sounds like a volume knob being turned down. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from music_theory import synth +from music_theory.synth import SAMPLE_RATE, Signal + + +@dataclass(frozen=True) +class Shape: + """Attack, hold and decay in seconds; punch boosts the start of the hold. + + ``curve`` is how many time constants the decay spans: 5 ends ~43 dB + down, 10 sounds clipped short. The decay also fades linearly to zero + across its length so the very last sample is silent -- no click. + """ + + attack: float = 0.005 + hold: float = 0.05 + punch: float = 0.0 + decay: float = 0.2 + curve: float = 5.0 + + @property + def seconds(self) -> float: + """Total length of the envelope.""" + return self.attack + self.hold + self.decay + + +def exponential( + frames: int, time_constant: float, sample_rate: int = SAMPLE_RATE +) -> Signal: + """exp(-t / tau): falls by the same ratio every ``time_constant`` seconds.""" + if time_constant <= 0.0: + msg = f"time constant must be positive, got {time_constant}" + raise ValueError(msg) + times = np.arange(max(frames, 0), dtype=np.float64) / sample_rate + return np.exp(-times / time_constant) + + +def shape(envelope: Shape, sample_rate: int = SAMPLE_RATE) -> Signal: + """Gain curve of a ``Shape``: ADSR's attack, then punch, then exp decay.""" + head_seconds = envelope.attack + envelope.hold + # The attack-and-hold head IS an ADSR with full sustain and no release, + # so it reuses that rather than growing a second linear-ramp routine. + head = synth.adsr( + synth.Envelope(envelope.attack, 0.0, 1.0, 0.0), head_seconds, sample_rate + ) + attack = min(int(envelope.attack * sample_rate), len(head)) + hold = len(head) - attack + head[attack:] *= 1.0 + envelope.punch * (1.0 - np.arange(hold) / max(hold, 1)) + frames = int(envelope.decay * sample_rate) + position = np.arange(frames, dtype=np.float64) / max(frames, 1) + tail = np.exp(-envelope.curve * position) * (1.0 - position) + return np.concatenate((head, tail)) diff --git a/music_theory/music_theory/manifest.py b/music_theory/music_theory/manifest.py index 2aa2db8..1fa0602 100644 --- a/music_theory/music_theory/manifest.py +++ b/music_theory/music_theory/manifest.py @@ -10,20 +10,30 @@ it reproduced the shipped bytes -- or show exactly what changed. from __future__ import annotations import json -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Protocol from music_theory import __version__, wav if TYPE_CHECKING: from pathlib import Path - from music_theory.compose import Recipe from music_theory.synth import Signal Entry = dict[str, object] -def entry(recipe: Recipe, path: Path, signal: Signal) -> Entry: +class Recordable(Protocol): + """Anything rendered from plain data: a ``compose.Recipe``, an ``sfx.Sfx``.""" + + @property + def sample_rate(self) -> int: + """Rate the file was rendered at.""" + + def to_dict(self) -> dict[str, object]: + """The full recipe as JSON-ready data.""" + + +def entry(recipe: Recordable, path: Path, signal: Signal) -> Entry: """Provenance record for one rendered file.""" return { "file": path.name, diff --git a/music_theory/music_theory/noise.py b/music_theory/music_theory/noise.py new file mode 100644 index 0000000..c0c16bb --- /dev/null +++ b/music_theory/music_theory/noise.py @@ -0,0 +1,66 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Noise: the sound of mass -- explosions, splashes, wind, gravel. + +Not in the article, which is about pitched notes. A sound effect is mostly +unpitched, and noise is its raw material: white is every frequency at +once, pink tilts that by 3 dB per octave so it sounds even rather than +hissy, and grains chop either into sparse clicks (crackle, rattle). + +Every source takes a ``numpy.random.Generator`` so a preset rendered with +the same seed is the same bytes, every time. +""" + +from __future__ import annotations + +import numpy as np + +from music_theory.synth import SAMPLE_RATE, Signal + + +def white(frames: int, rng: np.random.Generator) -> Signal: + """Uniform noise in [-1, 1): equal power at every frequency.""" + return rng.uniform(-1.0, 1.0, max(frames, 0)) + + +def pink(frames: int, rng: np.random.Generator) -> Signal: + """1/f noise, peak 1: white noise with each bin scaled by 1/sqrt(f). + + Power falls as 1/f, i.e. 3 dB per octave, so every octave holds the + same energy -- the reason it sounds like rain or surf rather than hiss. + Shaping in the frequency domain gives the exact slope at any length, + where a running filter only approximates it. + """ + source = white(frames, rng) + if frames <= 1: + return source + spectrum = np.fft.rfft(source) + scale = np.zeros(len(spectrum)) + scale[1:] = 1.0 / np.sqrt(np.arange(1, len(spectrum))) + shaped = np.fft.irfft(spectrum * scale, n=frames) + return np.asarray(shaped / np.max(np.abs(shaped)), dtype=np.float64) + + +def grains( + frames: int, + rate_hz: float, + grain_seconds: float, + rng: np.random.Generator, + sample_rate: int = SAMPLE_RATE, +) -> Signal: + """A 0..1 gate of short Hann bumps at Poisson-random times. + + Multiply a noise layer by it and it becomes crackle (fire, ~20/s) or a + gravel rattle (earth, ~100/s): a few-millisecond grain is heard as a + click, and the random spacing is what keeps it from sounding like a + buzz at the grain rate. + """ + gate = np.zeros(max(frames, 0), dtype=np.float64) + if not gate.size or rate_hz <= 0.0: + return gate + count = int(rng.poisson(rate_hz * frames / sample_rate)) + width = max(int(grain_seconds * sample_rate), 1) + bump = np.hanning(width + 2)[1:-1] + for start in np.sort(rng.integers(0, frames, count)): + end = min(int(start) + width, frames) + gate[start:end] = np.maximum(gate[start:end], bump[: end - start]) + return gate diff --git a/music_theory/music_theory/sfx.py b/music_theory/music_theory/sfx.py new file mode 100644 index 0000000..45ef3f9 --- /dev/null +++ b/music_theory/music_theory/sfx.py @@ -0,0 +1,155 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""SFX: an sfxr-style parameter set, rendered by this package's primitives. + +Not in the article. sfxr describes a sound effect as ~20 numbers: a wave, +a pitch slide with vibrato/arpeggio/retrigger, an attack-sustain-punch- +decay envelope, filters and a little distortion. That set is here as +frozen dataclasses (grouped, so each group stays readable), and a sound is +a few LAYERS of it mixed together -- a transient that sells the hit, a +body that carries the element, a tail, sometimes a sub -- because one sfxr +voice cannot be a crackle and a rumble at once. + +Rendering is deterministic: layer ``i`` of a sound draws its noise from +``default_rng([seed, i])``, so the same ``Sfx`` is the same bytes forever +and a manifest's SHA-256 can prove it. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +import json +from typing import Literal + +import numpy as np + +from music_theory import biquad, degrade, envelopes, noise, sweep, synth + +type Wave = Literal["sine", "triangle", "square", "saw", "noise", "pink"] +type FilterChoice = Literal["none", "lowpass", "highpass", "bandpass"] + +WAVES: tuple[Wave, ...] = ("sine", "triangle", "square", "saw", "noise", "pink") + +## CD rate: lightning cracks and high-passed hiss live above the 11 kHz a +## 22050 Hz file can hold. Pass 22050 to match the music beds instead. +SFX_SAMPLE_RATE = 44100 + + +@dataclass(frozen=True) +class Tone: + """The raw source: a wave, its square duty, and its pitch curve. + + The pitch is ignored by ``noise`` and ``pink``, which have none. + """ + + wave: Wave = "square" + duty: float = 0.5 + pitch: sweep.Pitch = field(default_factory=sweep.Pitch) + + +@dataclass(frozen=True) +class Filter: + """A biquad whose cutoff glides through ``hz`` over the whole layer.""" + + kind: FilterChoice = "none" + hz: tuple[float, ...] = (1000.0,) + resonance: float = biquad.FLAT_Q + + +@dataclass(frozen=True) +class Grit: + """Ring modulation, bit-crushing and grain gating; 0 switches each off.""" + + ring_hz: float = 0.0 + crush_bits: float = 0.0 + grain_rate: float = 0.0 + grain_seconds: float = 0.002 + + +@dataclass(frozen=True) +class Layer: + """One sfxr voice placed ``delay`` seconds into the sound at ``gain``.""" + + tone: Tone + shape: envelopes.Shape + filtering: Filter = field(default_factory=Filter) + grit: Grit = field(default_factory=Grit) + gain: float = 1.0 + delay: float = 0.0 + + +@dataclass(frozen=True) +class Sfx: + """A named sound: layers mixed, then normalised to ``peak``.""" + + name: str + layers: tuple[Layer, ...] + seed: int = 0 + sample_rate: int = SFX_SAMPLE_RATE + peak: float = synth.DEFAULT_PEAK + + def to_dict(self) -> dict[str, object]: + """Every parameter as plain JSON data, for the manifest. + + Round-tripped through JSON so tuples come back as the lists a + manifest read from disk holds, and the two compare equal. + """ + data: dict[str, object] = json.loads(json.dumps(asdict(self))) + return data + + +def _source( + tone: Tone, frames: int, rng: np.random.Generator, sample_rate: int +) -> synth.Signal: + if tone.wave == "noise": + return noise.white(frames, rng) + if tone.wave == "pink": + return noise.pink(frames, rng) + if tone.wave not in WAVES: + msg = f"unknown wave {tone.wave!r}; choose one of {WAVES}" + raise ValueError(msg) + hz = sweep.frequency_curve(tone.pitch, frames, rng, sample_rate) + signal = sweep.phase_oscillator(hz, tone.wave, sample_rate, tone.duty) + if tone.wave == "square": + # A pulse narrower than half a cycle sits off-centre (mean 2*duty-1). + # That DC is inaudible but eats headroom and clicks at the ends. + signal = signal - (2.0 * tone.duty - 1.0) + return signal + + +def _filtered(signal: synth.Signal, spec: Filter, sample_rate: int) -> synth.Signal: + if spec.kind == "none": + return signal + times = np.arange(len(signal), dtype=np.float64) / sample_rate + span = len(signal) / sample_rate + cutoff = sweep.glide(spec.hz, times, span) + return biquad.biquad(signal, spec.kind, cutoff, spec.resonance, sample_rate) + + +def render_layer( + layer: Layer, rng: np.random.Generator, sample_rate: int = SFX_SAMPLE_RATE +) -> synth.Signal: + """One layer, from its own start (the delay is applied by ``render``).""" + gain = envelopes.shape(layer.shape, sample_rate) + frames = len(gain) + signal = _source(layer.tone, frames, rng, sample_rate) + grit = layer.grit + if grit.grain_rate: + signal = signal * noise.grains( + frames, grit.grain_rate, grit.grain_seconds, rng, sample_rate + ) + signal = sweep.ring_modulate(signal, grit.ring_hz, sample_rate) + signal = _filtered(signal, layer.filtering, sample_rate) + if grit.crush_bits: + signal = degrade.bitcrush(signal, grit.crush_bits) + return layer.gain * signal * gain + + +def render(sound: Sfx) -> synth.Signal: + """Mix every layer at its delay and normalise the result to ``peak``.""" + voices = [] + for index, layer in enumerate(sound.layers): + rng = np.random.default_rng([sound.seed, index]) + lead = np.zeros(int(layer.delay * sound.sample_rate), dtype=np.float64) + body = render_layer(layer, rng, sound.sample_rate) + voices.append(np.concatenate((lead, body))) + return synth.normalize(synth.mix(*voices), sound.peak) diff --git a/music_theory/music_theory/sfx_layers.py b/music_theory/music_theory/sfx_layers.py new file mode 100644 index 0000000..88ef70a --- /dev/null +++ b/music_theory/music_theory/sfx_layers.py @@ -0,0 +1,77 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""SFX layers: the recurring building blocks of a spell sound, named. + +A hit is a TRANSIENT (5-20 ms of noise that sells the contact), a BODY +(the element's identity), a TAIL (filtered noise dying away) and, for +heavy hits, a SUB (a low sine falling in pitch). These helpers build the +recurring layers so a preset reads as its recipe rather than as twenty +constructor arguments. +""" + +from __future__ import annotations + +import zlib + +from music_theory import synth +from music_theory.envelopes import Shape +from music_theory.sfx import Filter, Grit, Layer, Sfx, Tone +from music_theory.sweep import Pitch + + +def make(name: str, *layers: Layer, peak: float = synth.DEFAULT_PEAK) -> Sfx: + """A named sound seeded from its name (crc32 is stable across runs).""" + return Sfx(name, layers, seed=zlib.crc32(name.encode()), peak=peak) + + +def transient( + seconds: float = 0.01, high_hz: float = 2500.0, gain: float = 0.8 +) -> Layer: + """A click of high-passed white noise: the moment of contact.""" + return Layer( + Tone("noise"), + Shape(attack=0.0, hold=0.002, decay=seconds, curve=6.0), + Filter("highpass", (high_hz,)), + gain=gain, + ) + + +def sub(start_hz: float, end_hz: float, seconds: float, gain: float = 0.9) -> Layer: + """A sine dropping in pitch: weight you feel more than hear.""" + return Layer( + Tone("sine", pitch=Pitch(hz=(start_hz, end_hz))), + Shape(attack=0.003, hold=0.0, decay=seconds, curve=3.0), + gain=gain, + ) + + +def whoosh( + hz: tuple[float, ...], shape: Shape, q: float = 2.0, gain: float = 1.0 +) -> Layer: + """Noise through a band-pass whose centre glides through ``hz``.""" + return Layer(Tone("noise"), shape, Filter("bandpass", hz, q), gain=gain) + + +def rumble(cutoff_hz: float, shape: Shape, gain: float = 0.7) -> Layer: + """Low-passed white noise: a tail, thunder, the thud under a hit. + + White rather than pink: pink's 1/f puts most of its energy below + 20 Hz, which no speaker plays and every limiter still counts. + """ + return Layer(Tone("noise"), shape, Filter("lowpass", (cutoff_hz,)), gain=gain) + + +def crackle( + rate_hz: float, shape: Shape, filtering: Filter, gain: float = 0.6 +) -> Layer: + """Sparse 2 ms noise grains at ``rate_hz``: fire crackle, gravel rattle.""" + return Layer(Tone("noise"), shape, filtering, Grit(grain_rate=rate_hz), gain=gain) + + +def chime(hz: float, seconds: float, gain: float = 0.6, delay: float = 0.0) -> Layer: + """A struck sine with an exponential ring-out.""" + return Layer( + Tone("sine", pitch=Pitch(hz=(hz,))), + Shape(attack=0.002, hold=0.0, decay=seconds, curve=4.0), + gain=gain, + delay=delay, + ) diff --git a/music_theory/music_theory/sfx_presets.py b/music_theory/music_theory/sfx_presets.py new file mode 100644 index 0000000..659f311 --- /dev/null +++ b/music_theory/music_theory/sfx_presets.py @@ -0,0 +1,107 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""SFX presets: every sound spellcode needs, by name. + +Six elements (spellcode's v1 set: arcane, fire, water, lightning, earth, +wind) times three beats (cast, hit, impact), plus the moves: dodge, a +normal and a perfect parry, and the fizzle of a spell that failed. + +The moves follow sfxr's rule of thumb -- a down-slide is failure or +impact, an up-slide is reward: the perfect parry rings higher and longer +than the normal one and climbs a fifth, the fizzle slides down into a +crushed sputter. +""" + +from __future__ import annotations + +from dataclasses import replace + +from music_theory.envelopes import Shape +from music_theory.sfx import Filter, Grit, Layer, Sfx, Tone +from music_theory.sfx_layers import chime, crackle, make, transient, whoosh +from music_theory.sfx_presets_a import ARCANE, FIRE, WATER +from music_theory.sfx_presets_b import EARTH, LIGHTNING, WIND +from music_theory.sweep import Pitch + +ELEMENTS: tuple[str, ...] = ("arcane", "fire", "water", "lightning", "earth", "wind") + +MOVE_PEAK = 0.6 + +## Inharmonic partials of a struck bar, the "chime" in R1's ice recipe. +_BAR_PARTIALS = (1.0, 2.76, 5.40) + +_PERFECT_HZ = 1600.0 + +MOVES: tuple[Sfx, ...] = ( + make( + "dodge", + replace( + whoosh((500.0, 2500.0, 700.0), Shape(attack=0.04, hold=0.04, decay=0.1)), + tone=Tone("pink"), + ), + peak=MOVE_PEAK, + ), + make( + "parry_normal", + transient(0.01, 3000.0, 0.7), + Layer( + Tone("triangle", pitch=Pitch(hz=(900.0,))), + Shape(attack=0.001, hold=0.01, punch=0.5, decay=0.22, curve=6.0), + grit=Grit(ring_hz=370.0), + ), + Layer( + Tone("square", duty=0.3, pitch=Pitch(hz=(1400.0,))), + Shape(attack=0.001, hold=0.0, decay=0.12), + grit=Grit(ring_hz=530.0), + gain=0.3, + ), + peak=MOVE_PEAK, + ), + make( + "parry_perfect", + transient(0.01, 4000.0, 0.6), + *( + chime(_PERFECT_HZ * ratio, 0.5, 0.6 / (index + 1)) + for index, ratio in enumerate(_BAR_PARTIALS) + ), + chime(_PERFECT_HZ * 1.5, 0.45, 0.4, delay=0.06), + Layer( + Tone("square", duty=0.25, pitch=Pitch(hz=(2000.0, 4000.0))), + Shape(attack=0.01, hold=0.04, decay=0.08), + Filter("lowpass", (6000.0,)), + gain=0.15, + ), + peak=0.8, + ), + make( + "fizzle", + Layer( + Tone("saw", pitch=Pitch(hz=(500.0, 90.0))), + Shape(attack=0.01, hold=0.1, decay=0.25), + Filter("lowpass", (2500.0, 400.0)), + Grit(crush_bits=4.0), + ), + crackle( + 30.0, + Shape(attack=0.0, hold=0.15, decay=0.15), + Filter("highpass", (1000.0,)), + 0.4, + ), + peak=MOVE_PEAK, + ), +) + +PRESETS: dict[str, Sfx] = { + sound.name: sound + for group in (ARCANE, FIRE, WATER, LIGHTNING, EARTH, WIND, MOVES) + for sound in group +} + +NAMES: tuple[str, ...] = tuple(PRESETS) + + +def preset(name: str) -> Sfx: + """The preset called ``name``.""" + if name not in PRESETS: + msg = f"unknown preset {name!r}; choose one of {NAMES}" + raise KeyError(msg) + return PRESETS[name] diff --git a/music_theory/music_theory/sfx_presets_a.py b/music_theory/music_theory/sfx_presets_a.py new file mode 100644 index 0000000..0ae7227 --- /dev/null +++ b/music_theory/music_theory/sfx_presets_a.py @@ -0,0 +1,150 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Spell presets, part one: arcane, fire and water (cast, hit, impact). + +Recipes follow spellcode's research note R1 section 6 where it gives one; +arcane has none there, so its recipe is this module's own: a "plain magic" +voice of triangle and pulse waves with ring-modulated shimmer, so it stays +recognisably tonal next to the noise-heavy elements. Every cast slides UP +(charge, R1 section 5), every hit slides down or bursts, and every impact +outlasts its hit and adds a sub or rumble for weight. +""" + +from __future__ import annotations + +from music_theory.envelopes import Shape +from music_theory.sfx import Filter, Grit, Layer, Sfx, Tone +from music_theory.sfx_layers import crackle, make, rumble, sub, transient, whoosh +from music_theory.sweep import Pitch + +CAST_PEAK = 0.6 +HIT_PEAK = 0.7 +IMPACT_PEAK = 0.9 + +ARCANE: tuple[Sfx, ...] = ( + make( + "arcane_cast", + Layer( + Tone("triangle", pitch=Pitch(hz=(330.0, 990.0), vibrato_semitones=0.3)), + Shape(attack=0.03, hold=0.15, decay=0.12), + ), + Layer( + Tone("square", duty=0.25, pitch=Pitch(hz=(660.0, 1980.0))), + Shape(attack=0.05, hold=0.12, decay=0.1), + Filter("lowpass", (1500.0, 5000.0)), + gain=0.3, + ), + peak=CAST_PEAK, + ), + make( + "arcane_hit", + transient(0.01, 3000.0, 0.6), + Layer( + Tone("square", duty=0.25, pitch=Pitch(hz=(1200.0, 300.0))), + Shape(attack=0.002, hold=0.01, punch=0.3, decay=0.12), + Filter("lowpass", (4000.0,)), + Grit(ring_hz=220.0), + ), + peak=HIT_PEAK, + ), + make( + "arcane_impact", + transient(0.012, 2000.0), + Layer( + Tone("square", pitch=Pitch(hz=(800.0, 120.0))), + Shape(attack=0.002, hold=0.03, punch=0.4, decay=0.3), + Filter("lowpass", (3000.0, 600.0), resonance=3.0), + Grit(ring_hz=90.0), + ), + sub(110.0, 45.0, 0.3), + rumble(800.0, Shape(attack=0.01, hold=0.05, decay=0.4), 0.4), + peak=IMPACT_PEAK, + ), +) + +FIRE: tuple[Sfx, ...] = ( + make( + "fire_cast", + whoosh((400.0, 1500.0), Shape(attack=0.12, hold=0.03, decay=0.12)), + crackle( + 25.0, + Shape(attack=0.05, hold=0.15, decay=0.07), + Filter("highpass", (2000.0,)), + 0.5, + ), + Layer( + Tone("saw", pitch=Pitch(hz=(110.0, 220.0))), + Shape(attack=0.1, hold=0.05, decay=0.12), + Filter("lowpass", (600.0,)), + gain=0.3, + ), + peak=CAST_PEAK, + ), + make( + "fire_hit", + transient(0.008, 2000.0), + rumble(2000.0, Shape(attack=0.002, hold=0.03, punch=0.5, decay=0.15), 1.0), + crackle( + 20.0, + Shape(attack=0.0, hold=0.05, decay=0.1), + Filter("highpass", (1500.0,)), + 0.4, + ), + peak=HIT_PEAK, + ), + make( + "fire_impact", + rumble(2000.0, Shape(attack=0.002, hold=0.05, punch=0.5, decay=0.25), 1.0), + crackle( + 20.0, + Shape(attack=0.01, hold=0.15, decay=0.25), + Filter("highpass", (1500.0,)), + ), + rumble(400.0, Shape(attack=0.01, hold=0.1, decay=0.3)), + sub(70.0, 40.0, 0.25), + peak=IMPACT_PEAK, + ), +) + +## Bloops: a sine flicking 300 -> 1200 Hz in 30 ms, retriggered, each one +## at a random pitch offset so a run of them sounds like water, not a beep. +_BLOOP = Tone( + "sine", + pitch=Pitch( + hz=(300.0, 1200.0), + glide=0.03, + repeat=0.05, + jump_semitones=4.0, + jump_every=0.05, + ), +) + +WATER: tuple[Sfx, ...] = ( + make( + "water_cast", + Layer(_BLOOP, Shape(attack=0.01, hold=0.2, decay=0.1), gain=0.7), + whoosh((800.0, 3000.0), Shape(attack=0.1, hold=0.1, decay=0.1), 1.5, 0.5), + peak=CAST_PEAK, + ), + make( + "water_hit", + whoosh( + (3000.0, 800.0), + Shape(attack=0.003, hold=0.02, punch=0.3, decay=0.23), + 1.0, + ), + Layer(_BLOOP, Shape(attack=0.0, hold=0.1, decay=0.05), gain=0.5), + peak=HIT_PEAK, + ), + make( + "water_impact", + transient(0.01, 1500.0), + whoosh( + (3000.0, 600.0), + Shape(attack=0.003, hold=0.05, punch=0.4, decay=0.35), + 0.9, + ), + Layer(_BLOOP, Shape(attack=0.0, hold=0.3, decay=0.1), gain=0.5, delay=0.05), + sub(80.0, 40.0, 0.25, 0.7), + peak=IMPACT_PEAK, + ), +) diff --git a/music_theory/music_theory/sfx_presets_b.py b/music_theory/music_theory/sfx_presets_b.py new file mode 100644 index 0000000..b4507d3 --- /dev/null +++ b/music_theory/music_theory/sfx_presets_b.py @@ -0,0 +1,141 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Spell presets, part two: lightning, earth and wind (cast, hit, impact). + +From R1 section 6. Lightning is a full-band crack, a square wave jumping +to a random pitch every 12-15 ms and a bit-crushed 110 Hz saw buzz, with +a low thunder tail on impact. Earth is a falling sub, low-passed noise and +a rattle of gravel grains. Wind is a resonant band-pass whose centre +sweeps up then down -- flow, not impact, so it gets no transient. +""" + +from __future__ import annotations + +from music_theory.envelopes import Shape +from music_theory.sfx import Filter, Grit, Layer, Sfx, Tone +from music_theory.sfx_layers import crackle, make, rumble, sub, whoosh +from music_theory.sfx_presets_a import CAST_PEAK, HIT_PEAK, IMPACT_PEAK +from music_theory.sweep import Pitch + +## The crack: 8 ms of unfiltered white noise. +_CRACK = Layer(Tone("noise"), Shape(attack=0.0, hold=0.002, decay=0.008)) + +_BUZZ = Tone("saw", pitch=Pitch(hz=(110.0,))) + + +def _arc(start_hz: float, end_hz: float, shape: Shape) -> Layer: + """A square wave jumping +-7 semitones every 12 ms along a glide.""" + pitch = Pitch(hz=(start_hz, end_hz), jump_semitones=7.0, jump_every=0.012) + return Layer( + Tone("square", pitch=pitch), shape, Filter("highpass", (500.0,)), gain=0.6 + ) + + +def _gravel(rate_hz: float, shape: Shape) -> Layer: + """Stone on stone: 2 ms grains, band-passed low enough to stay heavy.""" + return crackle(rate_hz, shape, Filter("bandpass", (1500.0,), 1.5), 0.35) + + +LIGHTNING: tuple[Sfx, ...] = ( + make( + "lightning_cast", + _arc(200.0, 900.0, Shape(attack=0.05, hold=0.15, decay=0.1)), + Layer( + Tone("saw", pitch=Pitch(hz=(110.0, 165.0))), + Shape(attack=0.05, hold=0.15, decay=0.1), + grit=Grit(crush_bits=5.0), + gain=0.4, + ), + crackle( + 60.0, + Shape(attack=0.1, hold=0.1, decay=0.1), + Filter("highpass", (4000.0,)), + 0.4, + ), + peak=CAST_PEAK, + ), + make( + "lightning_hit", + _CRACK, + _arc(1200.0, 600.0, Shape(attack=0.001, hold=0.04, punch=0.3, decay=0.08)), + Layer( + _BUZZ, + Shape(attack=0.002, hold=0.05, decay=0.06), + grit=Grit(crush_bits=4.0), + gain=0.5, + ), + peak=HIT_PEAK, + ), + make( + "lightning_impact", + _CRACK, + _arc(1500.0, 500.0, Shape(attack=0.001, hold=0.06, punch=0.4, decay=0.12)), + Layer( + _BUZZ, + Shape(attack=0.002, hold=0.08, decay=0.1), + grit=Grit(crush_bits=4.0), + gain=0.5, + ), + rumble(200.0, Shape(attack=0.02, hold=0.1, decay=0.7), 0.8), + peak=IMPACT_PEAK, + ), +) + +EARTH: tuple[Sfx, ...] = ( + make( + "earth_cast", + Layer( + Tone("triangle", pitch=Pitch(hz=(55.0, 140.0))), + Shape(attack=0.08, hold=0.15, decay=0.1), + gain=0.9, + ), + _gravel(60.0, Shape(attack=0.1, hold=0.15, decay=0.08)), + rumble(300.0, Shape(attack=0.1, hold=0.1, decay=0.1), 0.3), + peak=CAST_PEAK, + ), + make( + "earth_hit", + sub(60.0, 35.0, 0.2, 1.0), + rumble(300.0, Shape(attack=0.002, hold=0.03, punch=0.3, decay=0.17)), + _gravel(100.0, Shape(attack=0.0, hold=0.2, decay=0.1)), + peak=HIT_PEAK, + ), + make( + "earth_impact", + # A thump, not a click: the contact transient low-passed, not high. + rumble(1500.0, Shape(attack=0.0, hold=0.002, decay=0.012), 0.6), + sub(60.0, 30.0, 0.35, 1.0), + rumble(300.0, Shape(attack=0.002, hold=0.05, punch=0.5, decay=0.35)), + _gravel(110.0, Shape(attack=0.02, hold=0.25, decay=0.1)), + peak=IMPACT_PEAK, + ), +) + +WIND: tuple[Sfx, ...] = ( + make( + "wind_cast", + whoosh((250.0, 1800.0), Shape(attack=0.15, hold=0.1, decay=0.1), 5.0), + whoosh((500.0, 2500.0), Shape(attack=0.1, hold=0.1, decay=0.15), 2.0, 0.4), + peak=CAST_PEAK, + ), + make( + "wind_hit", + whoosh((300.0, 1500.0, 400.0), Shape(attack=0.08, hold=0.1, decay=0.12), 4.0), + whoosh( + (200.0, 900.0, 300.0), Shape(attack=0.08, hold=0.1, decay=0.12), 1.5, 0.4 + ), + peak=HIT_PEAK, + ), + make( + "wind_impact", + whoosh((200.0, 1200.0, 300.0), Shape(attack=0.1, hold=0.3, decay=0.25), 3.0), + rumble(250.0, Shape(attack=0.05, hold=0.2, decay=0.3), 0.6), + Layer( + Tone("noise"), + Shape(attack=0.05, hold=0.1, decay=0.15), + Filter("bandpass", (600.0, 3000.0, 800.0), resonance=6.0), + gain=0.3, + delay=0.1, + ), + peak=IMPACT_PEAK, + ), +) diff --git a/music_theory/music_theory/sfx_render.py b/music_theory/music_theory/sfx_render.py new file mode 100644 index 0000000..95337d1 --- /dev/null +++ b/music_theory/music_theory/sfx_render.py @@ -0,0 +1,37 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Render SFX presets to a directory of 16-bit mono WAVs plus a manifest. + +One ``.wav`` per preset and a ``MANIFEST.json`` holding each file's +full parameter set, seed, package version and SHA-256 -- so a game can +commit the WAVs and anyone can prove a re-render reproduced them. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import TYPE_CHECKING + +from music_theory import manifest, sfx, sfx_presets, wav + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path + +MANIFEST_NAME = "MANIFEST.json" + + +def render_all( + out_dir: Path, + sample_rate: int = sfx.SFX_SAMPLE_RATE, + names: Sequence[str] | None = None, +) -> dict[str, manifest.Entry]: + """Render ``names`` (default: every preset) and merge them into the manifest.""" + manifest_path = out_dir / MANIFEST_NAME + entries = manifest.read(manifest_path) if manifest_path.exists() else {} + for name in sfx_presets.NAMES if names is None else names: + sound = replace(sfx_presets.preset(name), sample_rate=sample_rate) + signal = sfx.render(sound) + path = wav.write(out_dir / f"{name}.wav", signal, sample_rate) + entries[path.name] = manifest.entry(sound, path, signal) + manifest.write(manifest_path, entries) + return entries diff --git a/music_theory/music_theory/sweep.py b/music_theory/music_theory/sweep.py new file mode 100644 index 0000000..937efdc --- /dev/null +++ b/music_theory/music_theory/sweep.py @@ -0,0 +1,119 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Sweep: pitch that moves, drawn by an oscillator that accumulates phase. + +Not in the article, where every note holds one pitch. A sound effect's +identity is mostly its pitch MOTION: a down-slide reads as an attack or an +impact, an up-slide as a charge or a reward. ``synth.oscillator`` computes +phase as ``t * hz``, which only works for a constant ``hz`` -- feed it a +changing one and the waveform jumps. Here phase is the running sum of +``hz / sample_rate``, so any frequency curve stays continuous. + +The curve is sfxr's pitch section as data: breakpoints with an exponential +glide between them, vibrato, a one-step arpeggio, random pitch jumps, and a +retrigger that restarts the slide and arpeggio every ``repeat`` seconds. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import math + +import numpy as np + +from music_theory.synth import SAMPLE_RATE, Signal, Waveform, waveshape + +_SEMITONES_PER_OCTAVE = 12.0 + + +@dataclass(frozen=True) +class Pitch: + """A frequency curve. Times in seconds; 0 switches a feature off. + + ``hz`` are breakpoints spread evenly over ``glide`` seconds (the whole + sound when 0) with an exponential glide between them, so one octave up + takes the same time anywhere in the range. + """ + + hz: tuple[float, ...] = (440.0,) + glide: float = 0.0 + vibrato_semitones: float = 0.0 + vibrato_hz: float = 6.0 + arp_ratio: float = 1.0 + arp_at: float = 0.0 + jump_semitones: float = 0.0 + jump_every: float = 0.015 + repeat: float = 0.0 + + +def semitones(ratio_semitones: Signal | float) -> Signal: + """Frequency ratio of a number of semitones (12 per octave).""" + return np.asarray( + 2.0 ** (np.asarray(ratio_semitones) / _SEMITONES_PER_OCTAVE), dtype=np.float64 + ) + + +def glide(points: tuple[float, ...], times: Signal, span: float) -> Signal: + """Exponential interpolation through ``points`` over ``span`` seconds. + + Interpolates in log-frequency so pitch moves at an even musical rate; + holds the last point after ``span`` (and everywhere, if ``span`` is 0). + """ + if not points or min(points) <= 0.0: + msg = f"glide needs positive frequencies, got {points!r}" + raise ValueError(msg) + logs = np.log(np.asarray(points, dtype=np.float64)) + if len(points) == 1 or span <= 0.0: + return np.full(len(times), float(points[-1])) + anchors = np.linspace(0.0, span, len(points)) + return np.asarray(np.exp(np.interp(times, anchors, logs)), dtype=np.float64) + + +def _jumps(pitch: Pitch, times: Signal, rng: np.random.Generator) -> Signal: + """Hold a random pitch offset for ``jump_every`` seconds, then jump.""" + steps = (times / pitch.jump_every).astype(np.int64) + offsets = rng.uniform( + -pitch.jump_semitones, pitch.jump_semitones, int(steps[-1]) + 1 + ) + return semitones(offsets[steps]) + + +def frequency_curve( + pitch: Pitch, frames: int, rng: np.random.Generator, sample_rate: int = SAMPLE_RATE +) -> Signal: + """Hz at every sample of a sound ``frames`` long.""" + times = np.arange(max(frames, 0), dtype=np.float64) / sample_rate + if not times.size: + return times + local = times % pitch.repeat if pitch.repeat > 0.0 else times + span = pitch.glide if pitch.glide > 0.0 else times[-1] + curve = glide(pitch.hz, local, span) + if pitch.arp_at > 0.0: + curve = np.where(local >= pitch.arp_at, curve * pitch.arp_ratio, curve) + if pitch.vibrato_semitones: + wobble = np.sin(2.0 * math.pi * pitch.vibrato_hz * times) + curve = curve * semitones(pitch.vibrato_semitones * wobble) + if pitch.jump_semitones: + curve = curve * _jumps(pitch, times, rng) + return curve + + +def phase_oscillator( + hz: Signal, waveform: Waveform, sample_rate: int = SAMPLE_RATE, duty: float = 0.5 +) -> Signal: + """A waveform following a per-sample frequency curve, starting at phase 0.""" + if not hz.size: + return np.zeros(0, dtype=np.float64) + phase = np.concatenate(([0.0], np.cumsum(hz[:-1] / sample_rate))) % 1.0 + return waveshape(phase, waveform, duty) + + +def ring_modulate(signal: Signal, hz: float, sample_rate: int = SAMPLE_RATE) -> Signal: + """Multiply by a sine: each partial f becomes f - hz and f + hz. + + The result is inharmonic, which is why it reads as metal, bells and + "magic" rather than as a note. + """ + if not hz: + return signal.copy() + times = np.arange(len(signal), dtype=np.float64) / sample_rate + return signal * np.sin(2.0 * math.pi * hz * times) diff --git a/music_theory/music_theory/synth.py b/music_theory/music_theory/synth.py index 642f464..a4dfe94 100644 --- a/music_theory/music_theory/synth.py +++ b/music_theory/music_theory/synth.py @@ -66,10 +66,19 @@ def oscillator( sample_rate: int = SAMPLE_RATE, ) -> Signal: """One waveform at one frequency, amplitude 1, no envelope.""" + phase = (time_axis(seconds, sample_rate) * hz) % 1.0 + return waveshape(phase, waveform) + + +def waveshape(phase: Signal, waveform: Waveform, duty: float = _HALF_CYCLE) -> Signal: + """One cycle's shape read at ``phase`` (0..1); ``duty`` is the square's high part. + + Split from ``oscillator`` so a sweep, whose phase is accumulated rather + than ``t * hz``, draws exactly the same four timbres. + """ if waveform not in WAVEFORMS: msg = f"unknown waveform {waveform!r}; choose one of {WAVEFORMS}" raise ValueError(msg) - phase = (time_axis(seconds, sample_rate) * hz) % 1.0 if waveform == "sine": return np.sin(2.0 * math.pi * phase) saw = 2.0 * phase - 1.0 @@ -77,7 +86,7 @@ def oscillator( return saw if waveform == "triangle": return 2.0 * np.abs(saw) - 1.0 - return np.where(phase < _HALF_CYCLE, 1.0, -1.0).astype(np.float64) + return np.where(phase < duty, 1.0, -1.0).astype(np.float64) def additive( diff --git a/music_theory/music_theory/tests/test_analysis.py b/music_theory/music_theory/tests/test_analysis.py index 5955f50..eace821 100644 --- a/music_theory/music_theory/tests/test_analysis.py +++ b/music_theory/music_theory/tests/test_analysis.py @@ -30,3 +30,28 @@ def test_spectral_centroid_of_silence_and_empty() -> None: def test_peak() -> None: assert analysis.peak(np.array([0.1, -0.7, 0.3])) == pytest.approx(0.7) assert analysis.peak(np.zeros(0)) == 0.0 + + +def test_band_energy_finds_a_sine_in_its_band() -> None: + signal = synth.oscillator(440.0, 0.5) + inside = analysis.band_energy(signal, 400.0, 500.0) + assert inside == pytest.approx(analysis.band_energy(signal, 0.0, 11025.0)) + assert analysis.band_energy(signal, 1000.0, 2000.0) < inside * 1e-6 + assert analysis.band_energy(np.zeros(0), 0.0, 100.0) == 0.0 + + +def test_spectral_slope_of_white_noise_is_flat() -> None: + rng = np.random.default_rng(1) + white = rng.uniform(-1.0, 1.0, 44100) + assert analysis.spectral_slope(white, 44100) == pytest.approx(0.0, abs=0.15) + + +def test_spectral_slope_of_a_falling_spectrum_is_negative() -> None: + rng = np.random.default_rng(1) + dark = synth.lowpass(rng.uniform(-1.0, 1.0, 22050), 200.0) + assert analysis.spectral_slope(dark) < -1.0 + + +def test_spectral_slope_needs_two_bands() -> None: + with pytest.raises(ValueError, match="octave"): + analysis.spectral_slope(np.ones(100), 22050, 1000.0, 1500.0) diff --git a/music_theory/music_theory/tests/test_biquad.py b/music_theory/music_theory/tests/test_biquad.py new file mode 100644 index 0000000..6d750f7 --- /dev/null +++ b/music_theory/music_theory/tests/test_biquad.py @@ -0,0 +1,108 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +from typing import cast + +import numpy as np +import pytest + +from music_theory import analysis, biquad + +RATE = 44100 +CUTOFF = 2000.0 + + +@pytest.fixture(scope="module") +def white() -> np.ndarray: + return np.random.default_rng(11).uniform(-1.0, 1.0, RATE // 2) + + +def _db(filtered: float, original: float) -> float: + return float(10.0 * np.log10(filtered / original)) + + +def test_highpass_removes_energy_below_the_cutoff(white: np.ndarray) -> None: + out = biquad.biquad(white, "highpass", CUTOFF, sample_rate=RATE) + # 12 dB/octave: two octaves below the cutoff is at least 20 dB down. + below = (0.0, CUTOFF / 4) + drop = _db( + analysis.band_energy(out, *below, RATE), + analysis.band_energy(white, *below, RATE), + ) + assert drop < -20.0 + above = (4 * CUTOFF, 8 * CUTOFF) + kept = _db( + analysis.band_energy(out, *above, RATE), + analysis.band_energy(white, *above, RATE), + ) + assert kept > -1.0 + + +def test_lowpass_removes_energy_above_the_cutoff(white: np.ndarray) -> None: + out = biquad.biquad(white, "lowpass", CUTOFF, sample_rate=RATE) + above = (2 * CUTOFF, 4 * CUTOFF) + drop = _db( + analysis.band_energy(out, *above, RATE), + analysis.band_energy(white, *above, RATE), + ) + assert drop < -10.0 + below = (0.0, CUTOFF / 4) + kept = _db( + analysis.band_energy(out, *below, RATE), + analysis.band_energy(white, *below, RATE), + ) + assert kept > -1.0 + + +def test_bandpass_keeps_the_centre_and_cuts_both_sides(white: np.ndarray) -> None: + out = biquad.biquad(white, "bandpass", 1000.0, q=8.0, sample_rate=RATE) + + def change(low: float, high: float) -> float: + return _db( + analysis.band_energy(out, low, high, RATE), + analysis.band_energy(white, low, high, RATE), + ) + + assert change(950.0, 1050.0) > -3.0 + assert change(125.0, 250.0) < -15.0 + assert change(4000.0, 8000.0) < -15.0 + + +def test_resonance_peaks_at_the_cutoff(white: np.ndarray) -> None: + flat = biquad.biquad(white, "lowpass", CUTOFF, q=0.707, sample_rate=RATE) + sharp = biquad.biquad(white, "lowpass", CUTOFF, q=8.0, sample_rate=RATE) + band = (CUTOFF * 0.9, CUTOFF * 1.1) + gain = _db( + analysis.band_energy(sharp, *band, RATE), + analysis.band_energy(flat, *band, RATE), + ) + assert gain > 10.0 + + +def test_a_swept_cutoff_brightens_over_time(white: np.ndarray) -> None: + cutoff = np.geomspace(200.0, 10000.0, len(white)) + out = biquad.biquad(white, "lowpass", cutoff, sample_rate=RATE) + half = len(out) // 2 + early = analysis.spectral_centroid(out[:half], RATE) + late = analysis.spectral_centroid(out[half:], RATE) + assert late > 3 * early + + +def test_cutoffs_beyond_nyquist_are_clamped_and_stay_finite(white: np.ndarray) -> None: + out = biquad.biquad(white, "lowpass", 1e6, sample_rate=RATE) + assert np.all(np.isfinite(out)) + assert float(np.max(np.abs(out))) < 4.0 + low = biquad.biquad(white, "highpass", 0.0, sample_rate=RATE) + assert np.all(np.isfinite(low)) + + +def test_empty_signal_passes_through() -> None: + assert biquad.biquad(np.zeros(0), "bandpass", 500.0).size == 0 + + +def test_rejects_bad_kind_q_and_cutoff_length() -> None: + signal = np.zeros(10) + with pytest.raises(ValueError, match="unknown filter"): + biquad.biquad(signal, cast("biquad.FilterKind", "notch"), 500.0) + with pytest.raises(ValueError, match="q must be positive"): + biquad.biquad(signal, "lowpass", 500.0, q=0.0) + with pytest.raises(ValueError, match="one cutoff per sample"): + biquad.biquad(signal, "lowpass", np.full(3, 500.0)) diff --git a/music_theory/music_theory/tests/test_cli.py b/music_theory/music_theory/tests/test_cli.py index 728022c..ff25d7b 100644 --- a/music_theory/music_theory/tests/test_cli.py +++ b/music_theory/music_theory/tests/test_cli.py @@ -96,3 +96,26 @@ def test_module_entry_point( runpy.run_module("music_theory", run_name="__main__") assert info.value.code == 0 assert "tone" in capsys.readouterr().out + + +def test_sfx_renders_presets_and_manifest( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + argv = ["sfx", "--out", str(tmp_path), "--sample-rate", "22050"] + assert cli.main([*argv, "--only", "dodge", "--only", "fizzle"]) == 0 + assert sorted(p.name for p in tmp_path.glob("*.wav")) == [ + "dodge.wav", + "fizzle.wav", + ] + assert (tmp_path / "MANIFEST.json").exists() + out = capsys.readouterr().out + assert "dodge.wav" in out + assert "aplay -q" in out + + +def test_sfx_rejects_unknown_preset( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + with pytest.raises(SystemExit): + cli.main(["sfx", "--out", str(tmp_path), "--only", "frost_hit"]) + assert "invalid choice" in capsys.readouterr().err diff --git a/music_theory/music_theory/tests/test_envelopes.py b/music_theory/music_theory/tests/test_envelopes.py new file mode 100644 index 0000000..120fc6c --- /dev/null +++ b/music_theory/music_theory/tests/test_envelopes.py @@ -0,0 +1,58 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +import numpy as np +import pytest + +from music_theory import envelopes + +RATE = 1000 + + +def test_exponential_has_a_constant_ratio_per_time_constant() -> None: + gain = envelopes.exponential(500, 0.1, RATE) + assert gain[0] == 1.0 + ratios = gain[100:400:100] / gain[0:300:100] + assert ratios.tolist() == pytest.approx([np.exp(-1.0)] * 3) + + +def test_exponential_rejects_a_non_positive_time_constant() -> None: + with pytest.raises(ValueError, match="time constant"): + envelopes.exponential(10, 0.0, RATE) + + +def test_shape_length_is_attack_hold_decay() -> None: + env = envelopes.Shape(attack=0.01, hold=0.02, decay=0.07) + assert env.seconds == pytest.approx(0.1) + assert len(envelopes.shape(env, RATE)) == 100 + + +def test_shape_rises_holds_and_dies_without_a_click() -> None: + gain = envelopes.shape(envelopes.Shape(attack=0.01, hold=0.02, decay=0.1), RATE) + assert gain[0] == 0.0 + assert gain[10:30].tolist() == pytest.approx([1.0] * 20) + tail = gain[30:] + assert np.all(np.diff(tail) <= 0.0) + assert tail[-1] < 0.01 + assert float(np.min(gain)) >= 0.0 + + +def test_punch_boosts_the_start_of_the_hold() -> None: + gain = envelopes.shape( + envelopes.Shape(attack=0.0, hold=0.05, punch=0.5, decay=0.05), RATE + ) + assert gain[0] == pytest.approx(1.5) + assert gain[49] == pytest.approx(1.0, abs=0.02) + assert float(np.max(gain)) == pytest.approx(1.5) + + +def test_steeper_curve_decays_faster() -> None: + soft = envelopes.shape(envelopes.Shape(attack=0.0, hold=0.0, decay=0.1), RATE) + hard = envelopes.shape( + envelopes.Shape(attack=0.0, hold=0.0, decay=0.1, curve=10.0), RATE + ) + assert hard[50] < soft[50] + assert hard[0] == soft[0] == 1.0 + + +def test_shape_of_nothing_is_empty() -> None: + zero = envelopes.Shape(attack=0.0, hold=0.0, decay=0.0) + assert envelopes.shape(zero, RATE).size == 0 diff --git a/music_theory/music_theory/tests/test_noise.py b/music_theory/music_theory/tests/test_noise.py new file mode 100644 index 0000000..d00a224 --- /dev/null +++ b/music_theory/music_theory/tests/test_noise.py @@ -0,0 +1,65 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +import numpy as np +import pytest + +from music_theory import analysis, noise + +RATE = 44100 + + +def _rng(seed: int = 7) -> np.random.Generator: + return np.random.default_rng(seed) + + +def test_white_is_flat_and_bounded() -> None: + signal = noise.white(RATE, _rng()) + assert len(signal) == RATE + assert float(np.max(np.abs(signal))) <= 1.0 + assert analysis.spectral_slope(signal, RATE) == pytest.approx(0.0, abs=0.3) + + +def test_pink_falls_three_db_per_octave() -> None: + signal = noise.pink(RATE, _rng()) + assert len(signal) == RATE + assert float(np.max(np.abs(signal))) == pytest.approx(1.0) + assert analysis.spectral_slope(signal, RATE) == pytest.approx(-1.0, abs=0.3) + + +def test_pink_is_darker_than_white() -> None: + white = analysis.spectral_centroid(noise.white(RATE, _rng()), RATE) + pink = analysis.spectral_centroid(noise.pink(RATE, _rng()), RATE) + # Amplitude ~ f^-1/2 puts the centroid at a third of Nyquist, white at half. + assert pink < white * 0.75 + + +def test_noise_is_seeded() -> None: + assert np.array_equal(noise.white(500, _rng(3)), noise.white(500, _rng(3))) + assert np.array_equal(noise.pink(500, _rng(3)), noise.pink(500, _rng(3))) + assert not np.array_equal(noise.white(500, _rng(3)), noise.white(500, _rng(4))) + + +def test_empty_noise() -> None: + assert noise.white(0, _rng()).size == 0 + assert noise.pink(0, _rng()).size == 0 + assert noise.pink(1, _rng()).size == 1 + + +def test_grains_are_sparse_bumps_in_unit_range() -> None: + gate = noise.grains(RATE, 20.0, 0.002, _rng(), RATE) + assert len(gate) == RATE + assert float(np.min(gate)) == 0.0 + assert float(np.max(gate)) <= 1.0 + onsets = int(np.sum((gate[1:] > 0.0) & (gate[:-1] == 0.0))) + assert 8 <= onsets <= 32 + assert float(np.mean(gate > 0.0)) < 0.1 + + +def test_grains_density_follows_the_rate() -> None: + sparse = noise.grains(RATE, 10.0, 0.002, _rng(), RATE) + dense = noise.grains(RATE, 200.0, 0.002, _rng(), RATE) + assert float(np.mean(dense > 0.0)) > 5 * float(np.mean(sparse > 0.0)) + + +def test_grains_without_rate_or_frames_are_silent() -> None: + assert not np.any(noise.grains(100, 0.0, 0.002, _rng(), RATE)) + assert noise.grains(0, 20.0, 0.002, _rng(), RATE).size == 0 diff --git a/music_theory/music_theory/tests/test_sfx.py b/music_theory/music_theory/tests/test_sfx.py new file mode 100644 index 0000000..282508e --- /dev/null +++ b/music_theory/music_theory/tests/test_sfx.py @@ -0,0 +1,132 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +from dataclasses import replace +import json +from typing import cast + +import numpy as np +import pytest + +from music_theory import analysis, envelopes, sfx, sweep + +RATE = sfx.SFX_SAMPLE_RATE +FLAT = envelopes.Shape(attack=0.0, hold=0.3, decay=0.0) + + +def _layer(wave: sfx.Wave = "noise") -> sfx.Layer: + return sfx.Layer(tone=sfx.Tone(wave=wave), shape=FLAT) + + +def _one(layer: sfx.Layer, seed: int = 1, rate: int = RATE) -> np.ndarray: + return sfx.render(sfx.Sfx("t", (layer,), seed=seed, sample_rate=rate)) + + +def test_default_rate_is_cd_quality() -> None: + assert RATE == 44100 + + +def test_render_is_normalised_to_the_peak_and_sized_by_the_layers() -> None: + late = replace(_layer(), delay=0.1) + signal = sfx.render(sfx.Sfx("t", (_layer(), late), peak=0.5)) + assert len(signal) == int(0.1 * RATE) + int(0.3 * RATE) + assert analysis.peak(signal) == pytest.approx(0.5) + + +def test_delay_leaves_silence_before_the_layer() -> None: + signal = _one(replace(_layer(), delay=0.05)) + assert not np.any(signal[: int(0.05 * RATE)]) + assert np.any(signal[int(0.05 * RATE) :]) + + +def test_pink_layer_is_darker_than_white() -> None: + white = analysis.spectral_centroid(_one(_layer("noise")), RATE) + pink = analysis.spectral_centroid(_one(_layer("pink")), RATE) + # Amplitude ~ f^-1/2 puts the centroid at a third of Nyquist, white at half. + assert pink < white * 0.75 + + +def test_tonal_layer_follows_its_pitch_curve() -> None: + tone = sfx.Tone(wave="sine", pitch=sweep.Pitch(hz=(300.0, 2400.0))) + signal = _one(replace(_layer(), tone=tone)) + quarter = len(signal) // 4 + early = analysis.dominant_frequency(signal[:quarter], RATE) + late = analysis.dominant_frequency(signal[-quarter:], RATE) + assert late > 2 * early + + +def test_highpass_layer_drops_the_lows() -> None: + high = sfx.Filter(kind="highpass", hz=(3000.0,)) + raw = _one(_layer()) + cut = _one(replace(_layer(), filtering=high)) + low_raw = analysis.band_energy(raw, 0.0, 1000.0, RATE) + low_cut = analysis.band_energy(cut, 0.0, 1000.0, RATE) + assert low_cut < low_raw / 100 + + +def test_bandpass_breakpoints_sweep_up_then_down() -> None: + whoosh = sfx.Filter(kind="bandpass", hz=(300.0, 3000.0, 400.0), resonance=4.0) + signal = _one(replace(_layer(), filtering=whoosh)) + third = len(signal) // 3 + start, middle, end = ( + analysis.spectral_centroid(signal[i * third : (i + 1) * third], RATE) + for i in range(3) + ) + assert middle > start + assert middle > end + + +def test_bitcrush_quantises_the_layer() -> None: + crushed = _one(replace(_layer("saw"), grit=sfx.Grit(crush_bits=3.0))) + assert len(np.unique(crushed)) <= 2**4 + 1 + + +def test_ring_modulation_moves_a_tone() -> None: + tone = sfx.Tone(wave="sine", pitch=sweep.Pitch(hz=(1000.0,))) + plain = _one(replace(_layer(), tone=tone)) + ringed = _one(replace(_layer(), tone=tone, grit=sfx.Grit(ring_hz=300.0))) + assert analysis.dominant_frequency(plain, RATE) == pytest.approx(1000.0, abs=5.0) + assert analysis.dominant_frequency(ringed, RATE) != pytest.approx(1000.0, abs=50.0) + + +def test_grains_make_a_crackle_of_mostly_silence() -> None: + crackle = _one( + replace(_layer(), grit=sfx.Grit(grain_rate=20.0, grain_seconds=0.002)) + ) + assert float(np.mean(crackle == 0.0)) > 0.8 + + +def test_same_seed_same_samples_new_seed_new_noise() -> None: + assert np.array_equal(_one(_layer(), seed=4), _one(_layer(), seed=4)) + assert not np.array_equal(_one(_layer(), seed=4), _one(_layer(), seed=5)) + + +def test_layers_get_independent_noise() -> None: + signal = sfx.render( + sfx.Sfx("t", (replace(_layer(), gain=1.0), replace(_layer(), gain=-1.0))) + ) + assert np.any(signal) + + +def test_sample_rate_option_scales_the_frame_count() -> None: + assert len(_one(_layer(), rate=22050)) * 2 == len(_one(_layer(), rate=44100)) + + +def test_to_dict_is_json_and_names_every_field() -> None: + data = sfx.Sfx("zap", (_layer(),), seed=9).to_dict() + text = json.dumps(data) + assert '"name": "zap"' in text + assert data["seed"] == 9 + assert data["sample_rate"] == RATE + layers = cast("list[dict[str, object]]", data["layers"]) + assert set(layers[0]) == {"tone", "shape", "filtering", "grit", "gain", "delay"} + + +def test_empty_sound_and_bad_wave() -> None: + assert sfx.render(sfx.Sfx("none", ())).size == 0 + with pytest.raises(ValueError, match="unknown wave"): + _one(_layer(cast("sfx.Wave", "organ"))) + + +def test_narrow_pulses_carry_no_dc() -> None: + pulse = sfx.Tone(wave="square", duty=0.25, pitch=sweep.Pitch(hz=(441.0,))) + signal = _one(replace(_layer(), tone=pulse)) + assert abs(float(np.mean(signal))) < 0.01 diff --git a/music_theory/music_theory/tests/test_sfx_presets.py b/music_theory/music_theory/tests/test_sfx_presets.py new file mode 100644 index 0000000..ddcb505 --- /dev/null +++ b/music_theory/music_theory/tests/test_sfx_presets.py @@ -0,0 +1,110 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +import hashlib + +import numpy as np +import pytest + +from music_theory import analysis, sfx, sfx_presets, synth + +RATE = sfx.SFX_SAMPLE_RATE +ELEMENTS = ("arcane", "fire", "water", "lightning", "earth", "wind") +MOVES = ("dodge", "parry_perfect", "parry_normal", "fizzle") + + +@pytest.fixture(scope="module") +def rendered() -> dict[str, np.ndarray]: + return {name: sfx.render(p) for name, p in sfx_presets.PRESETS.items()} + + +def _halves(signal: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + half = len(signal) // 2 + return signal[:half], signal[half:] + + +def test_the_preset_list_is_exactly_spellcodes_set() -> None: + expected = {f"{e}_{beat}" for e in ELEMENTS for beat in ("cast", "hit", "impact")} + expected |= set(MOVES) + assert set(sfx_presets.NAMES) == expected + assert len(sfx_presets.NAMES) == 22 + assert sfx_presets.ELEMENTS == ELEMENTS + + +def test_preset_lookup() -> None: + assert sfx_presets.preset("fire_hit").name == "fire_hit" + with pytest.raises(KeyError, match="unknown preset 'frost_hit'"): + sfx_presets.preset("frost_hit") + + +def test_every_preset_is_audible_finite_and_short( + rendered: dict[str, np.ndarray], +) -> None: + for name, signal in rendered.items(): + assert np.all(np.isfinite(signal)), name + assert analysis.peak(signal) == pytest.approx(sfx_presets.preset(name).peak), ( + name + ) + assert 0.08 <= len(signal) / RATE <= 1.5, name + + +def test_presets_differ_from_each_other(rendered: dict[str, np.ndarray]) -> None: + digests = { + hashlib.sha256(synth.to_int16(s).tobytes()).hexdigest() + for s in rendered.values() + } + assert len(digests) == len(rendered) + seeds = {p.seed for p in sfx_presets.PRESETS.values()} + assert len(seeds) == len(rendered) + + +def test_impacts_outlast_hits(rendered: dict[str, np.ndarray]) -> None: + for element in ELEMENTS: + assert len(rendered[f"{element}_impact"]) > len(rendered[f"{element}_hit"]) + + +def test_earth_is_darker_than_lightning(rendered: dict[str, np.ndarray]) -> None: + for beat in ("hit", "impact"): + earth = analysis.spectral_centroid(rendered[f"earth_{beat}"], RATE) + lightning = analysis.spectral_centroid(rendered[f"lightning_{beat}"], RATE) + assert earth < lightning / 2, beat + + +@pytest.mark.parametrize("name", ["fire_cast", "wind_cast", "water_cast"]) +def test_noise_casts_brighten_as_they_charge( + rendered: dict[str, np.ndarray], name: str +) -> None: + early, late = _halves(rendered[name]) + assert analysis.spectral_centroid(late, RATE) > analysis.spectral_centroid( + early, RATE + ) + + +@pytest.mark.parametrize("name", ["arcane_cast", "lightning_cast", "earth_cast"]) +def test_tonal_casts_rise_in_pitch(rendered: dict[str, np.ndarray], name: str) -> None: + early, late = _halves(rendered[name]) + assert analysis.dominant_frequency(late, RATE) > analysis.dominant_frequency( + early, RATE + ) + + +def test_fizzle_falls_and_perfect_parry_rings_higher( + rendered: dict[str, np.ndarray], +) -> None: + early, late = _halves(rendered["fizzle"]) + assert analysis.dominant_frequency(late, RATE) < analysis.dominant_frequency( + early, RATE + ) + perfect = analysis.dominant_frequency(rendered["parry_perfect"], RATE) + normal = analysis.dominant_frequency(rendered["parry_normal"], RATE) + assert perfect > normal + assert len(rendered["parry_perfect"]) > len(rendered["parry_normal"]) + + +def test_wind_hit_whooshes_up_then_down(rendered: dict[str, np.ndarray]) -> None: + signal = rendered["wind_hit"] + third = len(signal) // 3 + start, middle, end = ( + analysis.spectral_centroid(signal[i * third : (i + 1) * third], RATE) + for i in range(3) + ) + assert middle > start + assert middle > end diff --git a/music_theory/music_theory/tests/test_sfx_render.py b/music_theory/music_theory/tests/test_sfx_render.py new file mode 100644 index 0000000..13c12a6 --- /dev/null +++ b/music_theory/music_theory/tests/test_sfx_render.py @@ -0,0 +1,58 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +from dataclasses import replace +from typing import TYPE_CHECKING +import wave + +from music_theory import manifest, sfx, sfx_presets, sfx_render, wav + +if TYPE_CHECKING: + from pathlib import Path + + +def _rate(path: Path) -> int: + with wave.Wave_read(str(path)) as handle: + assert handle.getnchannels() == 1 + assert handle.getsampwidth() == 2 + return handle.getframerate() + + +def test_render_all_writes_every_preset_and_a_manifest(tmp_path: Path) -> None: + entries = sfx_render.render_all(tmp_path) + wavs = sorted(p.name for p in tmp_path.glob("*.wav")) + assert wavs == sorted(f"{name}.wav" for name in sfx_presets.NAMES) + stored = manifest.read(tmp_path / sfx_render.MANIFEST_NAME) + assert stored == entries + for name in wavs: + assert stored[name]["sha256"] == wav.sha256(tmp_path / name) + assert stored[name]["sample_rate"] == 44100 + assert _rate(tmp_path / name) == 44100 + + +def test_render_all_is_byte_identical_twice(tmp_path: Path) -> None: + first = sfx_render.render_all(tmp_path / "a") + second = sfx_render.render_all(tmp_path / "b") + assert {k: v["sha256"] for k, v in first.items()} == { + k: v["sha256"] for k, v in second.items() + } + for name in first: + assert (tmp_path / "a" / name).read_bytes() == ( + tmp_path / "b" / name + ).read_bytes() + + +def test_a_new_seed_changes_a_noise_preset(tmp_path: Path) -> None: + preset = sfx_presets.preset("fire_impact") + one = wav.write(tmp_path / "one.wav", sfx.render(preset), preset.sample_rate) + other = replace(preset, seed=preset.seed + 1) + two = wav.write(tmp_path / "two.wav", sfx.render(other), other.sample_rate) + assert wav.sha256(one) != wav.sha256(two) + + +def test_subset_at_22050_merges_into_the_manifest(tmp_path: Path) -> None: + sfx_render.render_all(tmp_path, 22050, ["dodge"]) + entries = sfx_render.render_all(tmp_path, 22050, ["fizzle"]) + assert set(entries) == {"dodge.wav", "fizzle.wav"} + assert _rate(tmp_path / "fizzle.wav") == 22050 + recipe = entries["fizzle.wav"]["recipe"] + assert isinstance(recipe, dict) + assert recipe["sample_rate"] == 22050 diff --git a/music_theory/music_theory/tests/test_sweep.py b/music_theory/music_theory/tests/test_sweep.py new file mode 100644 index 0000000..0d39eb1 --- /dev/null +++ b/music_theory/music_theory/tests/test_sweep.py @@ -0,0 +1,131 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +import numpy as np +import pytest + +from music_theory import analysis, sweep + +RATE = 44100 + + +def _rng(seed: int = 5) -> np.random.Generator: + return np.random.default_rng(seed) + + +def _curve(pitch: sweep.Pitch, frames: int = RATE, seed: int = 5) -> np.ndarray: + return sweep.frequency_curve(pitch, frames, _rng(seed), RATE) + + +def test_glide_is_exponential_between_breakpoints() -> None: + times = np.array([0.0, 0.5, 1.0, 2.0]) + curve = sweep.glide((100.0, 400.0), times, 1.0) + assert curve.tolist() == pytest.approx([100.0, 200.0, 400.0, 400.0]) + + +def test_glide_with_three_points_goes_up_then_down() -> None: + times = np.array([0.0, 0.5, 1.0]) + assert sweep.glide((300.0, 1500.0, 400.0), times, 1.0).tolist() == pytest.approx( + [300.0, 1500.0, 400.0] + ) + + +def test_glide_single_point_and_zero_span_are_constant() -> None: + times = np.array([0.0, 0.3]) + assert sweep.glide((220.0,), times, 1.0).tolist() == [220.0, 220.0] + assert sweep.glide((220.0, 880.0), times, 0.0).tolist() == [880.0, 880.0] + + +def test_glide_rejects_empty_and_non_positive_points() -> None: + with pytest.raises(ValueError, match="positive"): + sweep.glide((), np.zeros(2), 1.0) + with pytest.raises(ValueError, match="positive"): + sweep.glide((100.0, 0.0), np.zeros(2), 1.0) + + +def test_sweep_up_moves_the_fft_peak_up() -> None: + freq = _curve(sweep.Pitch(hz=(300.0, 2400.0))) + signal = sweep.phase_oscillator(freq, "sine", RATE) + quarter = RATE // 4 + early = analysis.dominant_frequency(signal[:quarter], RATE) + late = analysis.dominant_frequency(signal[-quarter:], RATE) + assert early < 700.0 + assert late > 1200.0 + + +def test_phase_oscillator_has_no_jumps_on_a_sweep() -> None: + freq = _curve(sweep.Pitch(hz=(100.0, 4000.0))) + signal = sweep.phase_oscillator(freq, "sine", RATE) + step = 2.0 * np.pi * 4000.0 / RATE + assert float(np.max(np.abs(np.diff(signal)))) <= step * 1.01 + assert signal[0] == 0.0 + + +def test_phase_oscillator_constant_pitch_and_duty() -> None: + freq = np.full(RATE // 2, 440.0) + tone = sweep.phase_oscillator(freq, "square", RATE) + assert analysis.dominant_frequency(tone, RATE) == pytest.approx(440.0, abs=2.0) + narrow = sweep.phase_oscillator(freq, "square", RATE, duty=0.125) + assert float(np.mean(narrow)) == pytest.approx(-0.75, abs=0.02) + assert sweep.phase_oscillator(np.zeros(0), "sine", RATE).size == 0 + + +def test_glide_seconds_limits_the_slide() -> None: + freq = _curve(sweep.Pitch(hz=(200.0, 800.0), glide=0.1)) + assert freq[int(0.05 * RATE)] == pytest.approx(400.0, rel=1e-3) + assert freq[int(0.5 * RATE)] == pytest.approx(800.0) + + +def test_retrigger_restarts_the_slide() -> None: + pitch = sweep.Pitch(hz=(300.0, 1200.0), glide=0.03, repeat=0.05) + freq = _curve(pitch) + period = int(0.05 * RATE) + assert freq[0] == pytest.approx(300.0) + assert freq[period + 1] < freq[period - 1] + assert freq[2 * period + 10] == pytest.approx(freq[10], rel=1e-2) + + +def test_arpeggio_steps_the_pitch() -> None: + freq = _curve(sweep.Pitch(hz=(400.0,), arp_ratio=1.5, arp_at=0.1)) + assert freq[int(0.05 * RATE)] == pytest.approx(400.0) + assert freq[int(0.2 * RATE)] == pytest.approx(600.0) + + +def test_arpeggio_restarts_with_the_retrigger() -> None: + pitch = sweep.Pitch(hz=(400.0,), arp_ratio=2.0, arp_at=0.02, repeat=0.04) + freq = _curve(pitch) + assert freq[int(0.03 * RATE)] == pytest.approx(800.0) + assert freq[int(0.045 * RATE)] == pytest.approx(400.0) + + +def test_vibrato_wobbles_within_its_depth() -> None: + freq = _curve(sweep.Pitch(hz=(440.0,), vibrato_semitones=1.0, vibrato_hz=6.0)) + assert float(np.max(freq)) == pytest.approx(440.0 * 2 ** (1 / 12), rel=1e-3) + assert float(np.min(freq)) == pytest.approx(440.0 * 2 ** (-1 / 12), rel=1e-3) + + +def test_random_jumps_hold_then_step_and_are_seeded() -> None: + pitch = sweep.Pitch(hz=(500.0,), jump_semitones=6.0, jump_every=0.01) + freq = _curve(pitch) + hold = int(0.01 * RATE) + assert np.all(freq[:hold] == freq[0]) + assert len(np.unique(freq)) > 50 + ratio = freq / 500.0 + assert float(np.max(ratio)) <= 2 ** (6 / 12) + 1e-9 + assert float(np.min(ratio)) >= 2 ** (-6 / 12) - 1e-9 + assert np.array_equal(freq, _curve(pitch)) + assert not np.array_equal(freq, _curve(pitch, seed=6)) + + +def test_frequency_curve_of_no_frames() -> None: + assert _curve(sweep.Pitch(), frames=0).size == 0 + + +def test_ring_modulation_moves_energy_to_sum_and_difference() -> None: + carrier = sweep.phase_oscillator(np.full(RATE, 1000.0), "sine", RATE) + ringed = sweep.ring_modulate(carrier, 300.0, RATE) + + def near(hz: float) -> float: + return analysis.band_energy(ringed, hz - 20.0, hz + 20.0, RATE) + + assert near(700.0) > 100 * near(1000.0) + assert near(1300.0) > 100 * near(1000.0) + assert np.array_equal(sweep.ring_modulate(carrier, 0.0, RATE), carrier) diff --git a/music_theory/music_theory/tests/test_synth.py b/music_theory/music_theory/tests/test_synth.py index ffa02be..205b7a4 100644 --- a/music_theory/music_theory/tests/test_synth.py +++ b/music_theory/music_theory/tests/test_synth.py @@ -78,3 +78,12 @@ def test_to_int16_clips() -> None: out = synth.to_int16(np.array([2.0, -2.0, 0.0])) assert list(out) == [32767, -32767, 0] assert out.dtype == np.int16 + + +def test_waveshape_duty_sets_the_square_balance() -> None: + phase = np.linspace(0.0, 1.0, 1000, endpoint=False) + narrow = synth.waveshape(phase, "square", duty=0.25) + assert float(np.mean(narrow)) == pytest.approx(-0.5) + assert np.array_equal(synth.waveshape(phase, "saw"), 2.0 * phase - 1.0) + with pytest.raises(ValueError, match="unknown waveform"): + synth.waveshape(phase, cast("synth.Waveform", "noise"))