Skip to main content

qualia_core_db/audio/
tf_surface_edit.rs

1//! P7.6 — Audio edits as geometric surface operations on the time-frequency
2//! surface.
3//!
4//! Each edit is a geometric transformation of the TfSurface raster:
5//!
6//! - **Gain**: scale magnitude in a rectangular region (affine scale in z).
7//! - **Cut/paste**: copy a rectangular patch of the surface to another
8//!   location (translation in the time-frequency plane).
9//! - **Time-stretch**: resample the surface along the time axis (affine
10//!   scale in u).
11//! - **Pitch-shift**: resample along the frequency axis (affine scale in v).
12//! - **Crossfade**: blend two surfaces with a weight ramp (linear blend
13//!   of two height fields).
14//! - **Spectral gate**: zero out bins below a threshold (clipping plane
15//!   in z).
16//!
17//! All operations write to caller-supplied buffers. Deterministic.
18
19use super::tf_surface::TfSurface;
20
21// ───────────────────────────────────────────────────────────────────────────
22//  Errors
23// ───────────────────────────────────────────────────────────────────────────
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum SurfaceEditError {
27    BufferTooSmall { needed: usize, have: usize },
28    InvalidRegion,
29}
30
31impl core::fmt::Display for SurfaceEditError {
32    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
33        match self {
34            Self::BufferTooSmall { needed, have } => {
35                write!(
36                    f,
37                    "surface edit: buffer too small, need {needed}, have {have}"
38                )
39            }
40            Self::InvalidRegion => write!(f, "surface edit: invalid region"),
41        }
42    }
43}
44
45impl std::error::Error for SurfaceEditError {}
46
47// ───────────────────────────────────────────────────────────────────────────
48//  Region
49// ───────────────────────────────────────────────────────────────────────────
50
51/// A rectangular region in the time-frequency plane.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct Region {
54    pub frame_start: usize,
55    pub frame_end: usize,
56    pub bin_start: usize,
57    pub bin_end: usize,
58}
59
60impl Region {
61    #[inline]
62    pub fn new(frame_start: usize, frame_end: usize, bin_start: usize, bin_end: usize) -> Self {
63        Self {
64            frame_start,
65            frame_end,
66            bin_start,
67            bin_end,
68        }
69    }
70
71    #[inline]
72    pub fn full(frame_count: usize, bin_count: usize) -> Self {
73        Self::new(0, frame_count, 0, bin_count)
74    }
75
76    #[inline]
77    pub fn frame_span(&self) -> usize {
78        self.frame_end.saturating_sub(self.frame_start)
79    }
80
81    #[inline]
82    pub fn bin_span(&self) -> usize {
83        self.bin_end.saturating_sub(self.bin_start)
84    }
85
86    #[inline]
87    pub fn is_valid(&self) -> bool {
88        self.frame_end > self.frame_start && self.bin_end > self.bin_start
89    }
90}
91
92// ───────────────────────────────────────────────────────────────────────────
93//  Edits
94// ───────────────────────────────────────────────────────────────────────────
95
96/// Apply a gain (scalar multiplication) to a region of the surface.
97/// `out` must be at least `frame_count * bin_count`.
98pub fn apply_gain(
99    surface: &TfSurface,
100    region: &Region,
101    gain: f32,
102    out: &mut [f32],
103) -> Result<usize, SurfaceEditError> {
104    let needed = surface.frame_count * surface.bin_count;
105    if out.len() < needed {
106        return Err(SurfaceEditError::BufferTooSmall {
107            needed,
108            have: out.len(),
109        });
110    }
111
112    // Copy the full surface, then apply gain to the region.
113    out[..needed].copy_from_slice(surface.raster);
114
115    let f_end = region.frame_end.min(surface.frame_count);
116    let b_end = region.bin_end.min(surface.bin_count);
117    for f in region.frame_start..f_end {
118        for b in region.bin_start..b_end {
119            out[f * surface.bin_count + b] *= gain;
120        }
121    }
122
123    Ok(needed)
124}
125
126/// Spectral gate: zero out all bins below `threshold` in the region.
127pub fn spectral_gate(
128    surface: &TfSurface,
129    region: &Region,
130    threshold: f32,
131    out: &mut [f32],
132) -> Result<usize, SurfaceEditError> {
133    let needed = surface.frame_count * surface.bin_count;
134    if out.len() < needed {
135        return Err(SurfaceEditError::BufferTooSmall {
136            needed,
137            have: out.len(),
138        });
139    }
140
141    out[..needed].copy_from_slice(surface.raster);
142
143    let f_end = region.frame_end.min(surface.frame_count);
144    let b_end = region.bin_end.min(surface.bin_count);
145    for f in region.frame_start..f_end {
146        for b in region.bin_start..b_end {
147            let idx = f * surface.bin_count + b;
148            if out[idx] < threshold {
149                out[idx] = 0.0;
150            }
151        }
152    }
153
154    Ok(needed)
155}
156
157/// Copy a rectangular patch from `src` to a destination offset in `out`.
158/// `out` must be at least `frame_count * bin_count`.
159pub fn copy_patch(
160    surface: &TfSurface,
161    src_region: &Region,
162    dst_frame: usize,
163    dst_bin: usize,
164    out: &mut [f32],
165) -> Result<usize, SurfaceEditError> {
166    let needed = surface.frame_count * surface.bin_count;
167    if out.len() < needed {
168        return Err(SurfaceEditError::BufferTooSmall {
169            needed,
170            have: out.len(),
171        });
172    }
173
174    out[..needed].copy_from_slice(surface.raster);
175
176    let f_span = src_region.frame_span();
177    let b_span = src_region.bin_span();
178
179    for df in 0..f_span {
180        let src_f = src_region.frame_start + df;
181        let dst_f = dst_frame + df;
182        if src_f >= surface.frame_count || dst_f >= surface.frame_count {
183            break;
184        }
185        for db in 0..b_span {
186            let src_b = src_region.bin_start + db;
187            let dst_b = dst_bin + db;
188            if src_b >= surface.bin_count || dst_b >= surface.bin_count {
189                break;
190            }
191            out[dst_f * surface.bin_count + dst_b] =
192                surface.raster[src_f * surface.bin_count + src_b];
193        }
194    }
195
196    Ok(needed)
197}
198
199/// Time-stretch by resampling along the time axis.
200/// `factor > 1.0` stretches, `factor < 1.0` compresses.
201/// `out` must be at least `new_frame_count * bin_count` where
202/// `new_frame_count = round(frame_count * factor)`.
203pub fn time_stretch(
204    surface: &TfSurface,
205    factor: f32,
206    out: &mut [f32],
207) -> Result<(usize, usize), SurfaceEditError> {
208    let new_frames = (surface.frame_count as f32 * factor).round() as usize;
209    let new_frames = new_frames.max(1);
210    let needed = new_frames * surface.bin_count;
211    if out.len() < needed {
212        return Err(SurfaceEditError::BufferTooSmall {
213            needed,
214            have: out.len(),
215        });
216    }
217
218    for f in 0..new_frames {
219        let src_f = f as f32 / factor;
220        for b in 0..surface.bin_count {
221            out[f * surface.bin_count + b] = surface.sample_bilinear(src_f, b as f32);
222        }
223    }
224
225    Ok((new_frames, surface.bin_count))
226}
227
228/// Pitch-shift by resampling along the frequency axis.
229/// `factor > 1.0` shifts up, `factor < 1.0` shifts down.
230/// `out` must be at least `frame_count * bin_count`.
231pub fn pitch_shift(
232    surface: &TfSurface,
233    factor: f32,
234    out: &mut [f32],
235) -> Result<usize, SurfaceEditError> {
236    let needed = surface.frame_count * surface.bin_count;
237    if out.len() < needed {
238        return Err(SurfaceEditError::BufferTooSmall {
239            needed,
240            have: out.len(),
241        });
242    }
243
244    for f in 0..surface.frame_count {
245        for b in 0..surface.bin_count {
246            let src_b = b as f32 / factor;
247            out[f * surface.bin_count + b] = surface.sample_bilinear(f as f32, src_b);
248        }
249    }
250
251    Ok(needed)
252}
253
254/// Crossfade: blend two surfaces with weight `t` (0 = surface_a, 1 = surface_b).
255/// Both surfaces must have the same dimensions.
256pub fn crossfade(
257    surface_a: &TfSurface,
258    surface_b: &TfSurface,
259    t: f32,
260    out: &mut [f32],
261) -> Result<usize, SurfaceEditError> {
262    if surface_a.frame_count != surface_b.frame_count || surface_a.bin_count != surface_b.bin_count
263    {
264        return Err(SurfaceEditError::InvalidRegion);
265    }
266
267    let needed = surface_a.frame_count * surface_a.bin_count;
268    if out.len() < needed {
269        return Err(SurfaceEditError::BufferTooSmall {
270            needed,
271            have: out.len(),
272        });
273    }
274
275    for i in 0..needed {
276        out[i] = surface_a.raster[i] * (1.0 - t) + surface_b.raster[i] * t;
277    }
278
279    Ok(needed)
280}
281
282/// Fade in: ramp gain from 0 to 1 over the first `fade_frames` frames.
283pub fn fade_in(
284    surface: &TfSurface,
285    fade_frames: usize,
286    out: &mut [f32],
287) -> Result<usize, SurfaceEditError> {
288    let needed = surface.frame_count * surface.bin_count;
289    if out.len() < needed {
290        return Err(SurfaceEditError::BufferTooSmall {
291            needed,
292            have: out.len(),
293        });
294    }
295
296    out[..needed].copy_from_slice(surface.raster);
297
298    let fade = fade_frames.min(surface.frame_count);
299    for f in 0..fade {
300        let gain = f as f32 / fade as f32;
301        for b in 0..surface.bin_count {
302            out[f * surface.bin_count + b] *= gain;
303        }
304    }
305
306    Ok(needed)
307}
308
309/// Fade out: ramp gain from 1 to 0 over the last `fade_frames` frames.
310pub fn fade_out(
311    surface: &TfSurface,
312    fade_frames: usize,
313    out: &mut [f32],
314) -> Result<usize, SurfaceEditError> {
315    let needed = surface.frame_count * surface.bin_count;
316    if out.len() < needed {
317        return Err(SurfaceEditError::BufferTooSmall {
318            needed,
319            have: out.len(),
320        });
321    }
322
323    out[..needed].copy_from_slice(surface.raster);
324
325    let fade = fade_frames.min(surface.frame_count);
326    let start = surface.frame_count - fade;
327    for f in 0..fade {
328        let gain = 1.0 - (f + 1) as f32 / fade as f32;
329        for b in 0..surface.bin_count {
330            out[(start + f) * surface.bin_count + b] *= gain;
331        }
332    }
333
334    Ok(needed)
335}
336
337// ───────────────────────────────────────────────────────────────────────────
338//  Tests
339// ───────────────────────────────────────────────────────────────────────────
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::audio::audio_spectral_sheet::SPECTRAL_PREVIEW_BINS;
345
346    fn make_surface() -> (Vec<f32>, usize, usize) {
347        let frames = 4;
348        let bins = SPECTRAL_PREVIEW_BINS;
349        let mut raster = vec![0.5f32; frames * bins];
350        // Set some distinct values for testing.
351        raster[1 * bins + 16] = 1.0;
352        raster[2 * bins + 32] = 0.8;
353        (raster, frames, bins)
354    }
355
356    #[test]
357    fn gain_scales_region() {
358        let (raster, frames, bins) = make_surface();
359        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
360        let region = Region::new(1, 3, 10, 20);
361        let mut out = vec![0.0f32; frames * bins];
362        apply_gain(&s, &region, 2.0, &mut out).unwrap();
363        // Inside region: doubled.
364        assert!(
365            (out[1 * bins + 16] - 2.0).abs() < 1e-6,
366            "peak should be doubled"
367        );
368        // Outside region: unchanged.
369        assert!(
370            (out[0 * bins + 0] - 0.5).abs() < 1e-6,
371            "outside should be unchanged"
372        );
373    }
374
375    #[test]
376    fn spectral_gate_zeros_below_threshold() {
377        let (raster, frames, bins) = make_surface();
378        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
379        let region = Region::full(frames, bins);
380        let mut out = vec![0.0f32; frames * bins];
381        spectral_gate(&s, &region, 0.6, &mut out).unwrap();
382        // Values below 0.6 should be zeroed.
383        assert_eq!(out[0 * bins + 0], 0.0, "0.5 should be gated");
384        // Values >= 0.6 should remain.
385        assert!((out[1 * bins + 16] - 1.0).abs() < 1e-6, "1.0 should remain");
386        assert!((out[2 * bins + 32] - 0.8).abs() < 1e-6, "0.8 should remain");
387    }
388
389    #[test]
390    fn copy_patch_translates_region() {
391        let (raster, frames, bins) = make_surface();
392        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
393        let src = Region::new(1, 2, 16, 17); // single cell at (1,16) = 1.0
394        let mut out = vec![0.0f32; frames * bins];
395        copy_patch(&s, &src, 3, 40, &mut out).unwrap();
396        // The value 1.0 should now appear at (3, 40).
397        assert!(
398            (out[3 * bins + 40] - 1.0).abs() < 1e-6,
399            "patch should be copied"
400        );
401    }
402
403    #[test]
404    fn time_stretch_doubles_frames() {
405        let (raster, frames, bins) = make_surface();
406        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
407        let mut out = vec![0.0f32; frames * 2 * bins];
408        let (new_frames, _) = time_stretch(&s, 2.0, &mut out).unwrap();
409        assert_eq!(new_frames, 8, "should double frame count");
410    }
411
412    #[test]
413    fn time_stretch_preserves_energy_pattern() {
414        let (raster, frames, bins) = make_surface();
415        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
416        let mut out = vec![0.0f32; frames * 2 * bins];
417        let (new_frames, _) = time_stretch(&s, 2.0, &mut out).unwrap();
418        // The peak at frame 1 should appear around frame 2 in the stretched version.
419        let peak_frame = (0..new_frames)
420            .map(|f| (f, out[f * bins + 16]))
421            .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
422            .unwrap()
423            .0;
424        assert!(
425            peak_frame >= 1 && peak_frame <= 3,
426            "peak should be near frame 2"
427        );
428    }
429
430    #[test]
431    fn pitch_shift_preserves_frame_count() {
432        let (raster, frames, bins) = make_surface();
433        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
434        let mut out = vec![0.0f32; frames * bins];
435        pitch_shift(&s, 2.0, &mut out).unwrap();
436        // Frame count unchanged.
437        // Check that the output has non-zero values.
438        assert!(
439            out.iter().any(|&v| v > 0.0),
440            "pitch shift should preserve energy"
441        );
442    }
443
444    #[test]
445    fn crossfade_blends_surfaces() {
446        let (raster_a, frames, bins) = make_surface();
447        let raster_b = vec![1.0f32; frames * bins];
448        let sa = TfSurface::new(&raster_a, frames, bins, 44100, 512);
449        let sb = TfSurface::new(&raster_b, frames, bins, 44100, 512);
450        let mut out = vec![0.0f32; frames * bins];
451        crossfade(&sa, &sb, 0.5, &mut out).unwrap();
452        // At t=0.5, value should be average.
453        let expected = (raster_a[0] + raster_b[0]) * 0.5;
454        assert!((out[0] - expected).abs() < 1e-6, "crossfade should blend");
455    }
456
457    #[test]
458    fn crossfade_at_t0_returns_a() {
459        let (raster_a, frames, bins) = make_surface();
460        let raster_b = vec![1.0f32; frames * bins];
461        let sa = TfSurface::new(&raster_a, frames, bins, 44100, 512);
462        let sb = TfSurface::new(&raster_b, frames, bins, 44100, 512);
463        let mut out = vec![0.0f32; frames * bins];
464        crossfade(&sa, &sb, 0.0, &mut out).unwrap();
465        for i in 0..frames * bins {
466            assert!(
467                (out[i] - raster_a[i]).abs() < 1e-6,
468                "t=0 should return surface A"
469            );
470        }
471    }
472
473    #[test]
474    fn crossfade_at_t1_returns_b() {
475        let (raster_a, frames, bins) = make_surface();
476        let raster_b = vec![1.0f32; frames * bins];
477        let sa = TfSurface::new(&raster_a, frames, bins, 44100, 512);
478        let sb = TfSurface::new(&raster_b, frames, bins, 44100, 512);
479        let mut out = vec![0.0f32; frames * bins];
480        crossfade(&sa, &sb, 1.0, &mut out).unwrap();
481        for i in 0..frames * bins {
482            assert!(
483                (out[i] - raster_b[i]).abs() < 1e-6,
484                "t=1 should return surface B"
485            );
486        }
487    }
488
489    #[test]
490    fn fade_in_ramps_from_zero() {
491        let (raster, frames, bins) = make_surface();
492        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
493        let mut out = vec![0.0f32; frames * bins];
494        fade_in(&s, 2, &mut out).unwrap();
495        // Frame 0 should be zeroed (gain = 0/2 = 0).
496        assert_eq!(out[0], 0.0, "frame 0 should be zeroed by fade-in");
497        // Frame 1 should be half (gain = 1/2 = 0.5).
498        assert!(
499            (out[1 * bins] - 0.25).abs() < 1e-6,
500            "frame 1 should be halved"
501        );
502        // Frame 2+ should be unchanged.
503        assert!(
504            (out[2 * bins] - 0.5).abs() < 1e-6,
505            "frame 2 should be unchanged"
506        );
507    }
508
509    #[test]
510    fn fade_out_ramps_to_zero() {
511        let (raster, frames, bins) = make_surface();
512        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
513        let mut out = vec![0.0f32; frames * bins];
514        fade_out(&s, 2, &mut out).unwrap();
515        // Last frame should be zeroed.
516        assert_eq!(out[(frames - 1) * bins], 0.0, "last frame should be zeroed");
517        // Second-to-last should be halved.
518        assert!(
519            (out[(frames - 2) * bins] - 0.25).abs() < 1e-6,
520            "second-to-last should be halved"
521        );
522    }
523
524    #[test]
525    fn all_edits_deterministic() {
526        let (raster, frames, bins) = make_surface();
527        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
528        let region = Region::new(0, 2, 0, 32);
529
530        let mut out1 = vec![0.0f32; frames * bins];
531        let mut out2 = vec![0.0f32; frames * bins];
532        apply_gain(&s, &region, 1.5, &mut out1).unwrap();
533        apply_gain(&s, &region, 1.5, &mut out2).unwrap();
534        assert_eq!(out1, out2, "gain must be deterministic");
535
536        let mut out3 = vec![0.0f32; frames * bins];
537        let mut out4 = vec![0.0f32; frames * bins];
538        spectral_gate(&s, &region, 0.3, &mut out3).unwrap();
539        spectral_gate(&s, &region, 0.3, &mut out4).unwrap();
540        assert_eq!(out3, out4, "gate must be deterministic");
541    }
542
543    #[test]
544    fn buffer_too_small_errors() {
545        let (raster, frames, bins) = make_surface();
546        let s = TfSurface::new(&raster, frames, bins, 44100, 512);
547        let mut out = vec![0.0f32; 10]; // too small
548        let region = Region::full(frames, bins);
549        let err = apply_gain(&s, &region, 2.0, &mut out).unwrap_err();
550        assert_eq!(
551            err,
552            SurfaceEditError::BufferTooSmall {
553                needed: frames * bins,
554                have: 10,
555            }
556        );
557    }
558}