Skip to content

Commit 72e2b51

Browse files
committed
Timbre as texture: multi-timescale modulation, spectral homogeneity, medium residue
Replace per-stream filter/drive/reverb with a TextureProfile that describes each element faithfully: - rock (terrain): homogeneous + gentle slow envelope + viscous lava residue tail - fire (atmosphere): broadband + fast chaotic flicker under a slow drift - agents (entity): kept clean — conscious beings are described by feeling, not texture (their character is the sparse unison motif) Improves scene-tracking (lava corr 0.43->0.63, high band 0.75) and is the same parameterization a per-feature scalogram would later extract from video.
1 parent 2c04d7a commit 72e2b51

4 files changed

Lines changed: 199 additions & 74 deletions

File tree

DESIGN.md

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,34 @@ things downstream:
4040
- **A recurring motif** — the lead is a short theme that is transposed/retrograded
4141
per segment, so the piece feels composed rather than random.
4242

43-
## Timbre: a voice per stream
44-
45-
Each stream has its own **TimbreKit** (in the genre preset, swappable), and the
46-
timbre is also modulated by the scene over time:
47-
48-
- `drive` (saturation) follows **tension**
49-
- a time-varying low-pass opens with **brightness**
50-
- per-kit `reverb` (space) and `tremolo` (movement)
51-
52-
Cutoffs are chosen so the streams stay spectrally separated (the mid stream sits
53-
below the high band), which keeps both the mix and the QC gate legible.
43+
## Timbre: describing each element's texture
44+
45+
Timbre is not a coating on a note — it is the **sonification of the real
46+
element**. Each stream has a **TextureProfile** (in the genre preset, swappable)
47+
that describes its element as faithfully as possible:
48+
49+
- **spectral homogeneity** (`bandwidth`): homogeneous/tonal ↔ broadband/noisy
50+
- **multi-timescale amplitude modulation**: a `slow` drift + a `fast` flicker,
51+
where `chaos` makes the flicker noise-driven rather than a clean LFO
52+
- **medium / residue**: a viscous, dark decay tail — the substance the element
53+
sits in
54+
- saturation (`drive`, follows **tension**) and a low-pass that opens with
55+
**brightness**
56+
57+
The three archetypes fall straight out of this:
58+
59+
| Stream | Element | Profile |
60+
|---|---|---|
61+
| terrain | rock | homogeneous, narrow; gentle slow in-and-out; a **viscous lava residue** tail |
62+
| atmosphere | fire | broadband; a **fast chaotic flicker** under a **slow drift** (the flame's wandering centre of mass) |
63+
| entity | agents | kept clean — conscious beings are described by *feeling*, carried by the sparse unison motif, not by physical texture |
64+
65+
A conscious agent can't be captured by texture, so the entity stream is
66+
deliberately untextured; its character comes from the composition (sparse,
67+
collective-unison motif). Cutoffs keep the streams spectrally separated (mid sits
68+
below the high band) so the mix and QC gate stay legible. Note these profiles are
69+
the *same object* a per-feature scalogram would measure — the bridge to driving
70+
timbre from real video texture later.
5471

5572
## Architecture
5673

src/music_making/genre.py

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,30 @@
99

1010
from dataclasses import dataclass, field
1111

12-
from .timbre import TimbreKit
12+
from .timbre import TextureProfile
1313

1414

15-
def _smooth_funk_timbres() -> dict[str, TimbreKit]:
15+
def _smooth_funk_timbres() -> dict[str, TextureProfile]:
1616
return {
17-
# warm, rounded low end
18-
"terrain": TimbreKit("warm-low", cutoff_base=2200, brightness_depth=0.4,
19-
drive_base=0.18, drive_depth=0.25, reverb=0.06),
20-
# present, slightly gritty mid — the 'voice' of the agents. Cutoff kept
21-
# below the high band so the mid stream stays out of atmosphere's territory.
22-
"entity_activity": TimbreKit("present-mid", cutoff_base=3500, brightness_depth=0.4,
23-
drive_base=0.10, drive_depth=0.35, reverb=0.16),
24-
# airy, shimmering, spacious top
25-
"atmosphere": TimbreKit("airy-high", cutoff_base=11000, brightness_depth=0.9,
26-
drive_base=0.04, drive_depth=0.10, reverb=0.22,
27-
tremolo_rate=5.5, tremolo_depth=0.16),
17+
# ROCK: homogeneous at macro + micro scale -> narrow/tonal, a gentle slow
18+
# in-and-out, no fast flicker; emerging from lava -> a viscous residue tail.
19+
"terrain": TextureProfile(
20+
"rock", cutoff_base=2000, brightness_depth=0.3, bandwidth=0.05,
21+
drive_base=0.18, drive_depth=0.20,
22+
slow_rate=0.3, slow_depth=0.30, fast_depth=0.0,
23+
residue=0.35, residue_decay=0.5, reverb=0.05),
24+
# AGENTS: conscious -> described by feeling, kept clean (the sparse unison
25+
# motif in the composition carries their character, not DSP texture).
26+
"entity_activity": TextureProfile(
27+
"agents", cutoff_base=3500, brightness_depth=0.4, bandwidth=0.05,
28+
drive_base=0.10, drive_depth=0.30, reverb=0.16),
29+
# FIRE: broadband + a fast, chaotic flicker (flames) modulated by a slow
30+
# drift (the centre of mass wandering over long periods).
31+
"atmosphere": TextureProfile(
32+
"fire", cutoff_base=11000, brightness_depth=0.9, bandwidth=0.55,
33+
drive_base=0.04, drive_depth=0.10,
34+
slow_rate=0.2, slow_depth=0.35, fast_rate=11.0, fast_depth=0.6, chaos=0.85,
35+
reverb=0.22),
2836
}
2937

3038

@@ -39,7 +47,7 @@ class GenrePreset:
3947
lead_program: int
4048
swing: float # 0..1 swing pushed onto off-beats
4149
falsetto: bool # shift sung vocals up an octave
42-
timbres: dict[str, TimbreKit] = field(default_factory=_smooth_funk_timbres)
50+
timbres: dict[str, TextureProfile] = field(default_factory=_smooth_funk_timbres)
4351

4452

4553
SMOOTH_FUNK = GenrePreset(

src/music_making/timbre.py

Lines changed: 107 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1-
"""Per-stream timbre.
1+
"""Per-stream timbre as a sonification of physical texture.
22
3-
Each frequency stream (terrain/low, entity/mid, atmosphere/high) has its own
4-
spectral character, described by a configurable ``TimbreKit`` and modulated by
5-
the scene over time:
3+
The principle (see DESIGN.md): a stream's timbre should describe the *real
4+
element* as faithfully as possible — materials and processes by their **texture**
5+
(spectral homogeneity + multi-timescale modulation + the medium they sit in),
6+
conscious agents by **feeling** (kept clean here; their character is carried by
7+
the sparse unison motif in the composition).
68
7-
drive <- tension (saturation / grit)
8-
low-pass <- brightness (the filter opens as the scene brightens)
9-
reverb -- space (per-kit)
10-
tremolo -- movement (per-kit)
9+
A ``TextureProfile`` captures:
10+
* spectral placement / homogeneity (narrow tonal <-> broadband noisy)
11+
* multi-timescale amplitude modulation (slow drift + fast/chaotic flicker)
12+
* a medium/residue tail (the substance the element sits in)
13+
* saturation + space
14+
15+
Scene-modulated: drive follows `tension`, the low-pass opens with `brightness`.
1116
"""
1217

1318
from __future__ import annotations
@@ -22,20 +27,34 @@
2227

2328

2429
@dataclass(frozen=True)
25-
class TimbreKit:
30+
class TextureProfile:
2631
name: str
27-
cutoff_base: float # Hz, low-pass baseline
28-
brightness_depth: float # how much `brightness` opens the filter
29-
drive_base: float # static saturation
30-
drive_depth: float # `tension` adds saturation
31-
reverb: float # wet mix 0..1
32-
tremolo_rate: float = 0.0
33-
tremolo_depth: float = 0.0
34-
program: int | None = None # optional GM program override for this stream
32+
# spectral placement / homogeneity
33+
cutoff_base: float # Hz low-pass baseline
34+
brightness_depth: float # how much `brightness` opens the filter
35+
bandwidth: float = 0.0 # 0 = homogeneous/tonal, 1 = broadband/noisy
36+
# saturation
37+
drive_base: float = 0.0
38+
drive_depth: float = 0.0 # `tension` adds saturation
39+
# multi-timescale amplitude modulation
40+
slow_rate: float = 0.0 # Hz, slow drift (e.g. fire's centre-of-mass wander)
41+
slow_depth: float = 0.0
42+
fast_rate: float = 0.0 # Hz, fast flicker (e.g. flames)
43+
fast_depth: float = 0.0
44+
chaos: float = 0.0 # 0 = periodic LFO, 1 = noise-driven chaotic flicker
45+
# medium / residue (the substance the element emerges from)
46+
residue: float = 0.0 # amount of viscous decay tail
47+
residue_decay: float = 0.5 # seconds
48+
# space
49+
reverb: float = 0.0
50+
program: int | None = None # optional GM program override
51+
52+
53+
# Backwards-compatible alias (the concept used to be called a "kit").
54+
TimbreKit = TextureProfile
3555

3656

3757
def _layer_env(sb: Storyboard, layer: str, n: int, control: int = 1024) -> np.ndarray:
38-
"""A control-rate scene-layer envelope upsampled to ``n`` samples."""
3958
cn = max(2, min(control, n))
4059
cvals = np.array([sb.layer_at(layer, i / (cn - 1)) for i in range(cn)], dtype=np.float32)
4160
return np.interp(np.linspace(0, cn - 1, n), np.arange(cn), cvals).astype(np.float32)
@@ -46,6 +65,50 @@ def _saturate(x: np.ndarray, drive_env: np.ndarray) -> np.ndarray:
4665
return (np.tanh(x * k) / np.tanh(np.maximum(k, 1e-6))).astype(np.float32)
4766

4867

68+
def _sine_lfo(n: int, rate: float, sr: int) -> np.ndarray:
69+
t = np.arange(n) / sr
70+
return (0.5 * (1.0 + np.sin(2 * np.pi * rate * t))).astype(np.float32)
71+
72+
73+
def _noise_lfo(n: int, rate: float, sr: int, seed: int) -> np.ndarray:
74+
"""Chaotic flicker: white noise band-limited to ~`rate`, normalized 0..1."""
75+
rng = np.random.default_rng(seed)
76+
w = rng.standard_normal(n).astype(np.float32)
77+
cutoff = min(0.99, max(rate, 0.5) / (sr / 2))
78+
sos = signal.butter(2, cutoff, btype="low", output="sos")
79+
f = signal.sosfilt(sos, w)
80+
f -= f.min()
81+
return (f / (f.max() or 1.0)).astype(np.float32)
82+
83+
84+
def _modulate(x: np.ndarray, sr: int, p: TextureProfile) -> np.ndarray:
85+
env = np.ones(len(x), dtype=np.float32)
86+
if p.slow_depth > 0 and p.slow_rate > 0:
87+
env *= 1.0 - p.slow_depth * (1.0 - _sine_lfo(len(x), p.slow_rate, sr))
88+
if p.fast_depth > 0 and p.fast_rate > 0:
89+
per = _sine_lfo(len(x), p.fast_rate, sr)
90+
if p.chaos > 0:
91+
fast = p.chaos * _noise_lfo(len(x), p.fast_rate, sr, seed=1234) + (1 - p.chaos) * per
92+
else:
93+
fast = per
94+
env *= 1.0 - p.fast_depth * (1.0 - fast)
95+
return (x * env).astype(np.float32)
96+
97+
98+
def _broadband(x: np.ndarray, sr: int, p: TextureProfile) -> np.ndarray:
99+
"""Heterogeneity: add high-frequency crackle that follows the signal's
100+
amplitude (e.g. the broadband texture of fire)."""
101+
if p.bandwidth <= 0:
102+
return x
103+
amp_sos = signal.butter(2, 20 / (sr / 2), btype="low", output="sos")
104+
amp = signal.sosfilt(amp_sos, np.abs(x)).astype(np.float32)
105+
rng = np.random.default_rng(777)
106+
noise = rng.standard_normal(len(x)).astype(np.float32)
107+
hp = signal.butter(2, 2000 / (sr / 2), btype="high", output="sos")
108+
noise = signal.sosfilt(hp, noise).astype(np.float32)
109+
return (x + p.bandwidth * 0.5 * noise * amp).astype(np.float32)
110+
111+
49112
def _lp_timevarying(x: np.ndarray, sb: Storyboard, cutoff_base: float, depth: float,
50113
block: int = 8192) -> np.ndarray:
51114
n = len(x)
@@ -57,8 +120,7 @@ def _lp_timevarying(x: np.ndarray, sb: Storyboard, cutoff_base: float, depth: fl
57120
pos = 0
58121
while pos < n:
59122
end = min(n, pos + block)
60-
t_norm = ((pos + end) / 2.0) / n
61-
br = sb.layer_at("brightness", t_norm)
123+
br = sb.layer_at("brightness", ((pos + end) / 2.0) / n)
62124
cutoff = cutoff_base * (0.4 + 1.3 * depth * br) + cutoff_base * 0.2
63125
cutoff = max(200.0, min(nyq * 0.95, cutoff))
64126
sos = signal.butter(2, cutoff / nyq, btype="low", output="sos")
@@ -69,6 +131,21 @@ def _lp_timevarying(x: np.ndarray, sb: Storyboard, cutoff_base: float, depth: fl
69131
return out
70132

71133

134+
def _residue(x: np.ndarray, sr: int, p: TextureProfile) -> np.ndarray:
135+
"""Viscous medium tail: a dark, smeared decay after each hit — e.g. the lava
136+
the rock emerges from, persisting for ~`residue_decay` seconds."""
137+
if p.residue <= 0:
138+
return x
139+
length = max(1, int(p.residue_decay * sr))
140+
t = np.arange(length) / sr
141+
ir = np.exp(-t / (p.residue_decay / 3.0)).astype(np.float32)
142+
tail = signal.fftconvolve(x, ir)[: len(x)].astype(np.float32)
143+
dark = signal.butter(2, 600 / (sr / 2), btype="low", output="sos")
144+
tail = signal.sosfilt(dark, tail).astype(np.float32)
145+
tail *= (np.max(np.abs(x)) + 1e-9) / (np.max(np.abs(tail)) + 1e-9) # match level
146+
return ((1.0 - p.residue * 0.5) * x + p.residue * tail).astype(np.float32)
147+
148+
72149
_IR: np.ndarray | None = None
73150

74151

@@ -90,32 +167,26 @@ def _reverb(x: np.ndarray, wet: float) -> np.ndarray:
90167
return ((1.0 - wet) * x + wet * 0.6 * tail).astype(np.float32)
91168

92169

93-
def _tremolo(x: np.ndarray, rate: float, depth: float) -> np.ndarray:
94-
if rate <= 0 or depth <= 0:
95-
return x
96-
t = np.arange(len(x)) / audio.SR
97-
lfo = 1.0 - depth * 0.5 * (1.0 + np.sin(2 * np.pi * rate * t))
98-
return (x * lfo.astype(np.float32)).astype(np.float32)
99-
100-
101-
def apply(samples: np.ndarray, sb: Storyboard, kit: TimbreKit) -> np.ndarray:
170+
def apply(samples: np.ndarray, sb: Storyboard, p: TextureProfile) -> np.ndarray:
102171
x = samples.astype(np.float32)
103172
if len(x) == 0:
104173
return x
105-
drive_env = kit.drive_base + kit.drive_depth * _layer_env(sb, "tension", len(x))
174+
drive_env = p.drive_base + p.drive_depth * _layer_env(sb, "tension", len(x))
106175
x = _saturate(x, drive_env)
107-
x = _lp_timevarying(x, sb, kit.cutoff_base, kit.brightness_depth)
108-
x = _tremolo(x, kit.tremolo_rate, kit.tremolo_depth)
109-
x = _reverb(x, kit.reverb)
176+
x = _modulate(x, audio.SR, p) # slow drift + fast/chaotic flicker
177+
x = _broadband(x, audio.SR, p) # heterogeneity / crackle
178+
x = _lp_timevarying(x, sb, p.cutoff_base, p.brightness_depth)
179+
x = _residue(x, audio.SR, p) # viscous medium tail
180+
x = _reverb(x, p.reverb)
110181
peak = float(np.max(np.abs(x))) or 1.0
111182
if peak > 1.0:
112183
x = x / peak * 0.99
113184
return x
114185

115186

116-
def render_stem(midi_path: str, wav_path: str, sb: Storyboard, kit: TimbreKit,
187+
def render_stem(midi_path: str, wav_path: str, sb: Storyboard, profile: TextureProfile,
117188
soundfont: str | None = None) -> str:
118-
"""Render a MIDI stem and stamp the stream's timbre onto it."""
189+
"""Render a MIDI stem and stamp the stream's texture onto it."""
119190
audio.render_midi(midi_path, wav_path, soundfont=soundfont)
120-
audio.save_wav(wav_path, apply(audio.load_wav(wav_path), sb, kit))
191+
audio.save_wav(wav_path, apply(audio.load_wav(wav_path), sb, profile))
121192
return wav_path

tests/test_timbre.py

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import numpy as np
22

3-
from music_making import audio, timbre
4-
from music_making import from_text
5-
from music_making.timbre import TimbreKit
3+
from music_making import audio, from_text, timbre
4+
from music_making.timbre import TextureProfile
65

76

87
def _high_energy(x: np.ndarray) -> float:
@@ -11,21 +10,51 @@ def _high_energy(x: np.ndarray) -> float:
1110
return float((spec[freqs > 4500] ** 2).sum())
1211

1312

14-
def test_lowpass_kit_darkens_signal():
13+
def _shorttime_rms_std(x: np.ndarray, win: int = 2048) -> float:
14+
n = (len(x) // win) * win
15+
frames = x[:n].reshape(-1, win)
16+
return float(np.std(np.sqrt((frames ** 2).mean(axis=1))))
17+
18+
19+
def test_lowpass_profile_darkens_signal():
1520
sb = from_text("a calm night", seed=0, duration_sec=4.0)
16-
rng = np.random.default_rng(0)
17-
x = (rng.standard_normal(int(4 * audio.SR)) * 0.2).astype(np.float32)
18-
dark = TimbreKit("dark", cutoff_base=800, brightness_depth=0.0,
19-
drive_base=0.0, drive_depth=0.0, reverb=0.0)
20-
y = timbre.apply(x, sb, dark)
21-
assert _high_energy(y) < _high_energy(x) * 0.5
21+
x = (np.random.default_rng(0).standard_normal(int(4 * audio.SR)) * 0.2).astype(np.float32)
22+
dark = TextureProfile("dark", cutoff_base=800, brightness_depth=0.0)
23+
assert _high_energy(timbre.apply(x, sb, dark)) < _high_energy(x) * 0.5
2224

2325

2426
def test_apply_preserves_length_and_bounds():
2527
sb = from_text("bright fire and heat", seed=0, duration_sec=3.0)
2628
x = (np.random.default_rng(1).standard_normal(int(3 * audio.SR)) * 0.3).astype(np.float32)
27-
kit = TimbreKit("k", cutoff_base=6000, brightness_depth=0.5, drive_base=0.2,
28-
drive_depth=0.3, reverb=0.2, tremolo_rate=5.0, tremolo_depth=0.2)
29-
y = timbre.apply(x, sb, kit)
29+
fire = TextureProfile("fire", cutoff_base=9000, brightness_depth=0.7, bandwidth=0.5,
30+
drive_base=0.1, drive_depth=0.3, slow_rate=0.2, slow_depth=0.3,
31+
fast_rate=11.0, fast_depth=0.6, chaos=0.85, reverb=0.2)
32+
y = timbre.apply(x, sb, fire)
3033
assert len(y) == len(x)
3134
assert np.max(np.abs(y)) <= 1.0
35+
36+
37+
def test_residue_smears_a_burst_forward():
38+
sb = from_text("solid rock", seed=0, duration_sec=2.0)
39+
x = np.zeros(int(1.0 * audio.SR), dtype=np.float32)
40+
burst = int(0.05 * audio.SR)
41+
x[:burst] = np.random.default_rng(2).standard_normal(burst).astype(np.float32) * 0.5
42+
late = slice(int(0.2 * audio.SR), int(0.7 * audio.SR))
43+
44+
plain = TextureProfile("plain", cutoff_base=12000, brightness_depth=0.0)
45+
viscous = TextureProfile("viscous", cutoff_base=12000, brightness_depth=0.0,
46+
residue=0.6, residue_decay=0.5)
47+
e_plain = float(np.sum(timbre.apply(x, sb, plain)[late] ** 2))
48+
e_visc = float(np.sum(timbre.apply(x, sb, viscous)[late] ** 2))
49+
assert e_visc > e_plain * 2
50+
51+
52+
def test_fast_flicker_adds_amplitude_movement():
53+
sb = from_text("bright fire", seed=0, duration_sec=2.0)
54+
t = np.arange(int(2.0 * audio.SR)) / audio.SR
55+
tone = (0.3 * np.sin(2 * np.pi * 220 * t)).astype(np.float32)
56+
still = TextureProfile("still", cutoff_base=12000, brightness_depth=0.0)
57+
flicker = TextureProfile("flicker", cutoff_base=12000, brightness_depth=0.0,
58+
fast_rate=11.0, fast_depth=0.8, chaos=0.0)
59+
assert _shorttime_rms_std(timbre.apply(tone, sb, flicker)) > \
60+
_shorttime_rms_std(timbre.apply(tone, sb, still))

0 commit comments

Comments
 (0)