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
1318from __future__ import annotations
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
3757def _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+
49112def _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
0 commit comments