1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
//! # Spectral Composition Mode
//!
//! Instead of mapping ODE state variables to audio parameters, directly
//! synthesise audio whose *spectrum IS* the attractor's power spectrum.
//!
//! ## Method
//!
//! 1. Collect a window of attractor trajectory samples (x, y, z over T steps).
//! 2. Compute the FFT of each state variable's time series.
//! 3. Extract the dominant frequency bins and their amplitudes.
//! 4. Synthesise audio by summing sinusoids at those frequencies with
//! amplitudes proportional to the FFT magnitudes.
//!
//! The result: audio whose spectrum IS the attractor's power spectrum.
//! Chaotic attractors produce rich, broadband spectra; limit cycles produce
//! sparse harmonic spectra.
//!
//! ## Usage
//!
//! ```rust
//! use math_sonify::spectral_composition::{SpectralComposer, SpectralConfig};
//!
//! let cfg = SpectralConfig::default();
//! let mut composer = SpectralComposer::new(cfg);
//! composer.ingest(&[1.0, 0.5, -0.3, -0.8, 0.2]);
//! let _ = composer.synthesise(44100, 512);
//! ```
/// Configuration for spectral composition.
#[derive(Debug, Clone)]
pub struct SpectralConfig {
/// Number of trajectory samples to accumulate before FFT.
pub window_size: usize,
/// Number of dominant spectral bins to use for synthesis.
pub num_partials: usize,
/// Base frequency (Hz) for mapping FFT bin 1.
pub base_frequency: f64,
/// Maximum frequency (Hz) allowed for any partial.
pub max_frequency: f64,
/// Amplitude scale factor.
pub amplitude_scale: f64,
/// Smoothing: blend fraction of new spectrum into running average (0..1).
pub spectral_smoothing: f64,
}
impl Default for SpectralConfig {
fn default() -> Self {
Self {
window_size: 256,
num_partials: 16,
base_frequency: 55.0,
max_frequency: 8000.0,
amplitude_scale: 0.8,
spectral_smoothing: 0.1,
}
}
}
/// A single spectral partial: a sinusoidal component.
#[derive(Debug, Clone)]
pub struct Partial {
/// Frequency in Hz.
pub frequency: f64,
/// Amplitude (0..1).
pub amplitude: f64,
/// Current phase (radians, for stateful synthesis).
pub phase: f64,
}
impl Partial {
/// Advance this partial by one audio sample and return the sample value.
pub fn tick(&mut self, sample_rate: f64) -> f64 {
let sample = self.amplitude * self.phase.sin();
self.phase += 2.0 * std::f64::consts::PI * self.frequency / sample_rate;
// Wrap phase to avoid float drift
if self.phase > std::f64::consts::TAU {
self.phase -= std::f64::consts::TAU;
}
sample
}
}
/// Power spectrum bin.
#[derive(Debug, Clone)]
pub struct SpectrumBin {
/// Bin index.
pub bin: usize,
/// Frequency (Hz).
pub frequency: f64,
/// Magnitude (linear).
pub magnitude: f64,
}
/// Spectral composition engine.
pub struct SpectralComposer {
pub config: SpectralConfig,
/// Circular buffer of incoming trajectory samples.
buffer: Vec<f64>,
/// Write index into buffer.
write_idx: usize,
/// Whether the buffer has been filled at least once.
buffer_full: bool,
/// Currently active partials (synthesised from most recent FFT).
pub partials: Vec<Partial>,
/// Smoothed power spectrum (bins).
smoothed_spectrum: Vec<f64>,
}
impl SpectralComposer {
pub fn new(config: SpectralConfig) -> Self {
let sz = config.window_size;
Self {
buffer: vec![0.0; sz],
write_idx: 0,
buffer_full: false,
partials: Vec::new(),
smoothed_spectrum: vec![0.0; sz / 2],
config,
}
}
/// Ingest a batch of attractor samples (one state variable, e.g., x).
pub fn ingest(&mut self, samples: &[f64]) {
let sz = self.config.window_size;
for &s in samples {
self.buffer[self.write_idx] = s;
self.write_idx = (self.write_idx + 1) % sz;
if self.write_idx == 0 {
self.buffer_full = true;
}
}
if self.buffer_full || self.write_idx >= sz / 2 {
self.update_spectrum();
}
}
/// Update the smoothed power spectrum from the current buffer.
fn update_spectrum(&mut self) {
let sz = self.config.window_size;
// Apply Hann window then compute DFT magnitude (no external crate needed)
let windowed: Vec<f64> = (0..sz)
.map(|i| {
let w = 0.5 * (1.0 - (2.0 * std::f64::consts::PI * i as f64 / (sz - 1) as f64).cos());
// Read from circular buffer with correct offset
let buf_idx = (self.write_idx + i) % sz;
self.buffer[buf_idx] * w
})
.collect();
let n_bins = sz / 2;
let alpha = self.config.spectral_smoothing;
for k in 0..n_bins {
// DFT magnitude at bin k (O(N²) but window_size is small)
let mut re = 0.0_f64;
let mut im = 0.0_f64;
for n in 0..sz {
let angle = -2.0 * std::f64::consts::PI * k as f64 * n as f64 / sz as f64;
re += windowed[n] * angle.cos();
im += windowed[n] * angle.sin();
}
let mag = (re * re + im * im).sqrt() / sz as f64;
// Exponential smoothing
self.smoothed_spectrum[k] = (1.0 - alpha) * self.smoothed_spectrum[k] + alpha * mag;
}
}
/// Extract the top-N spectral bins from the smoothed spectrum.
pub fn dominant_bins(&self, sample_rate: f64) -> Vec<SpectrumBin> {
let sz = self.config.window_size;
let n_bins = sz / 2;
let mut bins: Vec<SpectrumBin> = (1..n_bins) // skip DC
.map(|k| {
let freq = k as f64 * sample_rate / sz as f64;
SpectrumBin {
bin: k,
frequency: freq,
magnitude: self.smoothed_spectrum[k],
}
})
.filter(|b| b.frequency <= self.config.max_frequency)
.collect();
bins.sort_by(|a, b| b.magnitude.partial_cmp(&a.magnitude).unwrap());
bins.truncate(self.config.num_partials);
bins
}
/// Refresh the partial set from the current spectrum.
pub fn refresh_partials(&mut self, sample_rate: f64) {
let bins = self.dominant_bins(sample_rate);
let max_mag = bins
.iter()
.map(|b| b.magnitude)
.fold(0.0_f64, f64::max)
.max(1e-12);
// Keep existing phases to avoid clicks; match by bin index
let old_partials: std::collections::HashMap<usize, f64> = self
.partials
.iter()
.map(|p| {
// Map frequency back to approximate bin index
let bin = (p.frequency * self.config.window_size as f64 / sample_rate) as usize;
(bin, p.phase)
})
.collect();
self.partials = bins
.iter()
.map(|b| {
let amplitude =
(b.magnitude / max_mag * self.config.amplitude_scale).clamp(0.0, 1.0);
let phase = old_partials.get(&b.bin).copied().unwrap_or(0.0);
Partial {
frequency: b.frequency,
amplitude,
phase,
}
})
.collect();
}
/// Synthesise `num_samples` audio samples by summing active partials.
pub fn synthesise(&mut self, sample_rate: u32, num_samples: usize) -> Vec<f32> {
let sr = sample_rate as f64;
self.refresh_partials(sr);
(0..num_samples)
.map(|_| {
let sample: f64 = self.partials.iter_mut().map(|p| p.tick(sr)).sum();
// Soft clip
let clipped = sample.tanh();
clipped as f32
})
.collect()
}
/// Render an ASCII bar chart of the current power spectrum.
pub fn spectrum_bars(&self, width: usize) -> String {
let n_bins = self.smoothed_spectrum.len().min(width);
let max = self.smoothed_spectrum[1..n_bins]
.iter()
.cloned()
.fold(0.0_f64, f64::max)
.max(1e-12);
let bar_chars = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let bars: String = (1..n_bins)
.map(|k| {
let norm = self.smoothed_spectrum[k] / max;
let idx = (norm * (bar_chars.len() - 1) as f64) as usize;
bar_chars[idx.min(bar_chars.len() - 1)]
})
.collect();
format!("[{}]", bars)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sine_wave(freq: f64, sr: f64, n: usize) -> Vec<f64> {
(0..n)
.map(|i| (2.0 * std::f64::consts::PI * freq * i as f64 / sr).sin())
.collect()
}
#[test]
fn ingest_and_synthesise() {
let cfg = SpectralConfig {
window_size: 64,
num_partials: 4,
..Default::default()
};
let mut composer = SpectralComposer::new(cfg);
let signal = sine_wave(440.0, 44100.0, 128);
composer.ingest(&signal);
let audio = composer.synthesise(44100, 256);
assert_eq!(audio.len(), 256);
assert!(audio.iter().all(|s| s.is_finite()));
}
#[test]
fn dominant_bins_at_440() {
let cfg = SpectralConfig {
window_size: 256,
num_partials: 8,
..Default::default()
};
let mut composer = SpectralComposer::new(cfg);
let signal = sine_wave(440.0, 44100.0, 512);
composer.ingest(&signal);
let bins = composer.dominant_bins(44100.0);
assert!(!bins.is_empty());
// Top bin should be near 440 Hz
let top_freq = bins[0].frequency;
// With window_size=256 and sr=44100, bin resolution ≈ 172 Hz; bin near 440 is bin 2 or 3
assert!(top_freq > 100.0 && top_freq < 1000.0,
"top bin freq should be in range: {top_freq}");
}
#[test]
fn synthesised_audio_soft_clipped() {
let mut composer = SpectralComposer::new(SpectralConfig::default());
// Ingest many ones to produce a large magnitude
composer.ingest(&vec![1.0; 512]);
let audio = composer.synthesise(44100, 128);
for s in &audio {
assert!(s.abs() <= 1.0 + 1e-6, "sample out of tanh range: {s}");
}
}
#[test]
fn spectrum_bars_width() {
let composer = SpectralComposer::new(SpectralConfig { window_size: 64, ..Default::default() });
let bars = composer.spectrum_bars(30);
// Should return a string containing at least some characters
assert!(bars.len() > 2);
}
}