mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
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:
parent
eef2d46f32
commit
26c55675dd
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
114
music_theory/music_theory/biquad.py
Normal file
114
music_theory/music_theory/biquad.py
Normal 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)
|
||||
@ -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)
|
||||
|
||||
67
music_theory/music_theory/envelopes.py
Normal file
67
music_theory/music_theory/envelopes.py
Normal 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))
|
||||
@ -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,
|
||||
|
||||
66
music_theory/music_theory/noise.py
Normal file
66
music_theory/music_theory/noise.py
Normal 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
|
||||
155
music_theory/music_theory/sfx.py
Normal file
155
music_theory/music_theory/sfx.py
Normal 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)
|
||||
77
music_theory/music_theory/sfx_layers.py
Normal file
77
music_theory/music_theory/sfx_layers.py
Normal 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,
|
||||
)
|
||||
107
music_theory/music_theory/sfx_presets.py
Normal file
107
music_theory/music_theory/sfx_presets.py
Normal 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]
|
||||
150
music_theory/music_theory/sfx_presets_a.py
Normal file
150
music_theory/music_theory/sfx_presets_a.py
Normal 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,
|
||||
),
|
||||
)
|
||||
141
music_theory/music_theory/sfx_presets_b.py
Normal file
141
music_theory/music_theory/sfx_presets_b.py
Normal 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,
|
||||
),
|
||||
)
|
||||
37
music_theory/music_theory/sfx_render.py
Normal file
37
music_theory/music_theory/sfx_render.py
Normal 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
|
||||
119
music_theory/music_theory/sweep.py
Normal file
119
music_theory/music_theory/sweep.py
Normal 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)
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
108
music_theory/music_theory/tests/test_biquad.py
Normal file
108
music_theory/music_theory/tests/test_biquad.py
Normal 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))
|
||||
@ -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
|
||||
|
||||
58
music_theory/music_theory/tests/test_envelopes.py
Normal file
58
music_theory/music_theory/tests/test_envelopes.py
Normal 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
|
||||
65
music_theory/music_theory/tests/test_noise.py
Normal file
65
music_theory/music_theory/tests/test_noise.py
Normal 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
|
||||
132
music_theory/music_theory/tests/test_sfx.py
Normal file
132
music_theory/music_theory/tests/test_sfx.py
Normal 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
|
||||
110
music_theory/music_theory/tests/test_sfx_presets.py
Normal file
110
music_theory/music_theory/tests/test_sfx_presets.py
Normal 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
|
||||
58
music_theory/music_theory/tests/test_sfx_render.py
Normal file
58
music_theory/music_theory/tests/test_sfx_render.py
Normal 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
|
||||
131
music_theory/music_theory/tests/test_sweep.py
Normal file
131
music_theory/music_theory/tests/test_sweep.py
Normal 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)
|
||||
@ -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"))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user