music_theory: sfx module -- noise, sweeps, biquads and 22 spellcode presets

Code-generated sound effects for spellcode (no AI audio, no sfxr port):
seeded white/pink noise and grain gates (noise), a phase-accumulating
oscillator over glide/vibrato/arpeggio/jump/retrigger pitch curves plus
ring mod (sweep), exponential and punch envelopes (envelopes), resonant
RBJ low/high/band-pass with a moving cutoff (biquad), and an sfxr-style
layered parameter set rendered at 44.1 kHz by default (sfx).

Presets: cast/hit/impact for arcane, fire, water, lightning, earth and
wind, plus dodge, parry_normal, parry_perfect and fizzle, following R1's
per-element recipes (arcane's is new). `python -m music_theory sfx --out
DIR` writes 16-bit mono WAVs and a SHA-256 manifest; manifest.entry now
takes any Recordable (Recipe or Sfx). analysis gains band_energy and
spectral_slope, which the tests use as the oracle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016o4CqWFZwpfiDj8csAPgLa
This commit is contained in:
Krzysztof Rudnicki 2026-10-19 11:27:49 +02:00
parent eef2d46f32
commit 26c55675dd
25 changed files with 1881 additions and 9 deletions

View File

@ -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: `<element>_cast`, `<element>_hit` and `<element>_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

View File

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

View File

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

View File

@ -2,7 +2,8 @@
"""Command line: hear a section of the article, or render a recipe to a file.
Subcommands: ``list``, ``explain <demo|all>``, ``demo <demo|all> --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)

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

@ -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]

View File

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

View File

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

View File

@ -0,0 +1,37 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Render SFX presets to a directory of 16-bit mono WAVs plus a manifest.
One ``<name>.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

View File

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

View File

@ -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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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