Skip to main content

oxideav_opus/
celt_overlap_add.rs

1//! CELT §4.3.7 weighted overlap-add
2//! (RFC 6716 §4.3.7, p. 121).
3//!
4//! The inverse MDCT ([`crate::celt_imdct`]) maps each CELT frame's `N`
5//! denormalised frequency bins to a `2N`-sample time-domain block. A
6//! single such block is *not* the decoded signal: the MDCT is a lapped
7//! transform, and each reconstructed block carries a time-domain aliased
8//! copy of itself folded about its half-block midpoints (the defining
9//! property that lets `N` coefficients represent a `2N`-sample block
10//! without redundancy). RFC 6716 §4.3.7.1 (p. 121) names the step that
11//! removes that aliasing:
12//!
13//! > The output of the inverse MDCT (after weighted overlap-add) is sent
14//! > to the post-filter.
15//!
16//! The "weighted overlap-add" multiplies each `2N` block by the §4.3.7
17//! low-overlap window ([`crate::celt_mdct_window`]) and sums the leading
18//! half of the current block with the trailing half of the previous
19//! block. Because the window is power-complementary
20//! (`W(n)^2 + W(L-1-n)^2 = 1`, the Princen-Bradley condition the §4.3.7
21//! prose requires), the equal-and-opposite aliased halves of neighbouring
22//! blocks cancel exactly on the add, and the result is the aliasing-free
23//! time-domain signal (at the §4.3.7 `1/2` IMDCT scale).
24//!
25//! ## Block layout and the hop
26//!
27//! For a frame of `N` MDCT bins:
28//!
29//! * the inverse MDCT yields a `2N`-sample block;
30//! * the synthesis window touches only the `overlap` samples at each
31//!   edge — the leading `overlap` samples are multiplied by the rising
32//!   ramp `W(0) .. W(overlap-1)`, the trailing `overlap` samples by the
33//!   falling ramp `W(overlap-1) .. W(0)`, and the `2N - 2*overlap`
34//!   samples in the middle are unity ("inserting ones in the middle",
35//!   §4.3.7);
36//! * the hop between successive frames is `N` samples, so block `i`
37//!   overlaps block `i-1` over its first `N` samples.
38//!
39//! The decoder therefore keeps a per-channel **history** buffer holding
40//! the windowed *second half* (samples `N .. 2N`) of the previous block.
41//! Each frame emits `N` time-domain samples:
42//!
43//! ```text
44//!   out(n) = windowed_block_i(n) + history(n)     for n = 0 .. N
45//!   history'(n) = windowed_block_i(N + n)         for n = 0 .. N
46//! ```
47//!
48//! On the very first frame after a stream start or a §4.5.2 CELT state
49//! reset the history is all-zero, so the first emitted frame is simply
50//! the leading half of the first windowed block — the aliasing in that
51//! leading half is cancelled by the *next* frame's overlap, exactly as
52//! the trailing aliasing of every block is cancelled by its successor.
53//!
54//! The `overlap` is constrained to `0 < overlap <= N`. The CELT layer
55//! fixes the 48 kHz overlap at 120 samples
56//! ([`crate::celt_mdct_window::CELT_OVERLAP_48K`]); for the shortest CELT
57//! frame the per-MDCT `N` can be as small as the overlap, so equality is
58//! permitted. An even overlap is required so the window centre splits
59//! cleanly (the §4.3.7 low-overlap construction).
60//!
61//! ## Relation to the transform core
62//!
63//! [`crate::celt_imdct`] computes the raw `2N` inverse block already
64//! scaled by `1/2`; this module owns the windowing and the cross-frame
65//! state. The TDAC property — that a windowed forward/inverse pair plus
66//! this overlap-add reconstructs the input at the `1/2` scale — is pinned
67//! both by the transform core's own tests and, end-to-end through this
68//! stateful adder, by the tests below.
69//!
70//! ## Provenance
71//!
72//! Lapped-transform overlap-add narrative + "after weighted overlap-add"
73//! step ordering: RFC 6716 §4.3.7 / §4.3.7.1 (p. 121); fixed-overlap
74//! statement: §1 (p. 9–10); window shape: §4.3.7 (p. 121). All
75//! reproduced from `docs/audio/opus/rfc6716-opus.txt`. The overlap-add
76//! is the textbook synthesis step for a lapped transform with a
77//! power-complementary window; no external library source was consulted.
78
79use crate::celt_mdct_window::{mdct_window, MdctWindowError};
80
81/// Errors returnable by the §4.3.7 weighted overlap-add helpers.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum OverlapAddError {
84    /// The transform half-length `N` is zero; an empty block has no
85    /// overlap-add.
86    ZeroLength,
87    /// The supplied block was not exactly `2*N` samples long.
88    BlockLenNotEven {
89        /// The block length the caller supplied.
90        got: usize,
91    },
92    /// The block length disagreed with the adder's configured `N`.
93    BlockLenMismatch {
94        /// The block length the caller supplied.
95        got: usize,
96        /// The required length `2 * N`.
97        want: usize,
98    },
99    /// The overlap is zero or exceeds `N` (the §4.3.7 window touches at
100    /// most one half-block at each edge), or is odd (the low-overlap
101    /// construction needs an even overlap so the centre splits cleanly).
102    BadOverlap {
103        /// The overlap the caller passed.
104        overlap: usize,
105        /// The configured `N`.
106        n: usize,
107    },
108    /// The output slice passed to [`WeightedOverlapAdd::process_into`]
109    /// is shorter than the `N` samples a frame emits.
110    OutputTooSmall {
111        /// The required output length `N`.
112        want: usize,
113        /// The length the caller supplied.
114        got: usize,
115    },
116}
117
118impl core::fmt::Display for OverlapAddError {
119    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
120        match *self {
121            OverlapAddError::ZeroLength => {
122                write!(f, "oxideav-opus: CELT §4.3.7 overlap-add requires N >= 1")
123            }
124            OverlapAddError::BlockLenNotEven { got } => write!(
125                f,
126                "oxideav-opus: CELT §4.3.7 overlap-add block length {got} is not 2*N (odd)"
127            ),
128            OverlapAddError::BlockLenMismatch { got, want } => write!(
129                f,
130                "oxideav-opus: CELT §4.3.7 overlap-add block length {got} != required 2*N = {want}"
131            ),
132            OverlapAddError::BadOverlap { overlap, n } => write!(
133                f,
134                "oxideav-opus: CELT §4.3.7 overlap-add overlap {overlap} invalid for N={n} \
135                 (require 0 < overlap <= N and overlap even)"
136            ),
137            OverlapAddError::OutputTooSmall { want, got } => write!(
138                f,
139                "oxideav-opus: CELT §4.3.7 overlap-add output length {got} < required N = {want}"
140            ),
141        }
142    }
143}
144
145impl std::error::Error for OverlapAddError {}
146
147/// Apply the §4.3.7 low-overlap synthesis window to a `2N`-sample inverse
148/// MDCT block **in place** (RFC 6716 §4.3.7, p. 121).
149///
150/// The leading `overlap` samples are scaled by the rising ramp
151/// `W(0) .. W(overlap-1)`, the trailing `overlap` samples by the falling
152/// ramp `W(overlap-1) .. W(0)`, and the `2N - 2*overlap` samples in the
153/// middle are left unchanged (unity window).
154///
155/// `block.len()` must be `2 * n`, and `ramp.len()` must be `overlap`
156/// with `0 < overlap <= n`. This is a free function so a caller that has
157/// already built the ramp once can window many blocks without rebuilding
158/// it; [`WeightedOverlapAdd`] caches the ramp and calls this internally.
159///
160/// # Errors
161///
162/// Returns [`OverlapAddError`] if `n == 0`, if `block.len() != 2*n`, or
163/// if the ramp length is not a valid overlap for `n`.
164pub fn apply_synthesis_window(
165    block: &mut [f64],
166    ramp: &[f64],
167    n: usize,
168) -> Result<(), OverlapAddError> {
169    if n == 0 {
170        return Err(OverlapAddError::ZeroLength);
171    }
172    if block.len() != 2 * n {
173        if block.len() % 2 != 0 {
174            return Err(OverlapAddError::BlockLenNotEven { got: block.len() });
175        }
176        return Err(OverlapAddError::BlockLenMismatch {
177            got: block.len(),
178            want: 2 * n,
179        });
180    }
181    let overlap = ramp.len();
182    if overlap == 0 || overlap > n || overlap % 2 != 0 {
183        return Err(OverlapAddError::BadOverlap { overlap, n });
184    }
185    let two_n = 2 * n;
186    for i in 0..overlap {
187        // Rising ramp on the leading edge.
188        block[i] *= ramp[i];
189        // Falling ramp on the trailing edge: W(overlap-1-i).
190        block[two_n - 1 - i] *= ramp[i];
191    }
192    // The middle `2N - 2*overlap` samples keep their unity window.
193    Ok(())
194}
195
196/// The §4.3.7 stateful weighted overlap-add for one channel
197/// (RFC 6716 §4.3.7 / §4.3.7.1, p. 121).
198///
199/// Feed each frame's `2N`-sample inverse MDCT block (the raw output of
200/// [`crate::celt_imdct`], already scaled by `1/2`) to [`Self::process`]
201/// / [`Self::process_into`]; each call returns the `N` aliasing-free
202/// time-domain samples for that frame and carries the windowed trailing
203/// half forward as the overlap history for the next frame.
204///
205/// One instance tracks one channel. A stereo decoder keeps two.
206///
207/// # Examples
208///
209/// ```
210/// use oxideav_opus::celt_overlap_add::WeightedOverlapAdd;
211/// // N = 4, overlap = 2.
212/// let mut ola = WeightedOverlapAdd::new(4, 2).unwrap();
213/// let block = [0.0_f64; 8];
214/// // A silent block stays silent through the overlap-add.
215/// assert_eq!(ola.process(&block).unwrap(), vec![0.0; 4]);
216/// ```
217#[derive(Debug, Clone, PartialEq)]
218pub struct WeightedOverlapAdd {
219    /// The transform half-length: each frame emits `n` samples and
220    /// consumes a `2*n`-sample block.
221    n: usize,
222    /// The `overlap` rising window taps `W(0) .. W(overlap-1)`.
223    ramp: Vec<f64>,
224    /// The windowed trailing half (`n` samples) of the previous block,
225    /// to be added to the leading half of the next block. All-zero at
226    /// stream start / after a §4.5.2 reset.
227    history: Vec<f64>,
228}
229
230impl WeightedOverlapAdd {
231    /// Create a fresh overlap-add for transform half-length `n` and
232    /// `overlap`, with zeroed history (the stream-start / post-reset
233    /// state).
234    ///
235    /// Requires `0 < overlap <= n` and `overlap` even (the §4.3.7
236    /// low-overlap construction).
237    ///
238    /// # Errors
239    ///
240    /// Returns [`OverlapAddError::ZeroLength`] if `n == 0`, or
241    /// [`OverlapAddError::BadOverlap`] if the overlap is out of range or
242    /// odd.
243    pub fn new(n: usize, overlap: usize) -> Result<Self, OverlapAddError> {
244        if n == 0 {
245            return Err(OverlapAddError::ZeroLength);
246        }
247        if overlap == 0 || overlap > n || overlap % 2 != 0 {
248            return Err(OverlapAddError::BadOverlap { overlap, n });
249        }
250        let ramp = mdct_window(overlap).map_err(|e| match e {
251            MdctWindowError::ZeroLength => OverlapAddError::BadOverlap { overlap, n },
252            MdctWindowError::OddOverlap { overlap } => OverlapAddError::BadOverlap { overlap, n },
253            MdctWindowError::PositionOutOfRange { .. } => {
254                OverlapAddError::BadOverlap { overlap, n }
255            }
256        })?;
257        Ok(Self {
258            n,
259            ramp,
260            history: vec![0.0_f64; n],
261        })
262    }
263
264    /// The transform half-length `N`: each frame emits this many samples.
265    #[must_use]
266    pub fn frame_len(&self) -> usize {
267        self.n
268    }
269
270    /// The overlap length (window transition width).
271    #[must_use]
272    pub fn overlap(&self) -> usize {
273        self.ramp.len()
274    }
275
276    /// The current overlap history (the windowed trailing half of the
277    /// last block). All-zero before the first [`Self::process`] call.
278    #[must_use]
279    pub fn history(&self) -> &[f64] {
280        &self.history
281    }
282
283    /// Reset the overlap history to zero, as on a §4.5.2 CELT state
284    /// reset or at the start of a new stream.
285    pub fn reset(&mut self) {
286        for h in self.history.iter_mut() {
287            *h = 0.0;
288        }
289    }
290
291    /// Run the weighted overlap-add for one frame, returning the `N`
292    /// aliasing-free time-domain samples.
293    ///
294    /// `block` is the `2*N`-sample inverse MDCT output for this frame.
295    /// Allocating wrapper for [`Self::process_into`].
296    ///
297    /// # Errors
298    ///
299    /// Returns [`OverlapAddError::BlockLenMismatch`] /
300    /// [`OverlapAddError::BlockLenNotEven`] if `block.len() != 2*N`.
301    pub fn process(&mut self, block: &[f64]) -> Result<Vec<f64>, OverlapAddError> {
302        let mut out = vec![0.0_f64; self.n];
303        self.process_into(block, &mut out)?;
304        Ok(out)
305    }
306
307    /// Run the weighted overlap-add for one frame into a caller-provided
308    /// buffer.
309    ///
310    /// `block` is the `2*N`-sample inverse MDCT output; `out` receives
311    /// the `N` time-domain samples (`out.len()` must be `>= N`). On error
312    /// the adder's history is left unchanged.
313    ///
314    /// # Errors
315    ///
316    /// Returns [`OverlapAddError`] if the block length is wrong or `out`
317    /// is shorter than `N`.
318    pub fn process_into(&mut self, block: &[f64], out: &mut [f64]) -> Result<(), OverlapAddError> {
319        let two_n = 2 * self.n;
320        if block.len() != two_n {
321            if block.len() % 2 != 0 {
322                return Err(OverlapAddError::BlockLenNotEven { got: block.len() });
323            }
324            return Err(OverlapAddError::BlockLenMismatch {
325                got: block.len(),
326                want: two_n,
327            });
328        }
329        if out.len() < self.n {
330            return Err(OverlapAddError::OutputTooSmall {
331                want: self.n,
332                got: out.len(),
333            });
334        }
335
336        // Window the block (a private copy so the caller's buffer is not
337        // mutated). Leading rising ramp, trailing falling ramp, unity
338        // middle.
339        let mut windowed = block.to_vec();
340        // `apply_synthesis_window` re-validates n / overlap, but both are
341        // invariants here, so it cannot fail.
342        apply_synthesis_window(&mut windowed, &self.ramp, self.n)?;
343
344        // Emit the leading half overlap-added with the saved history.
345        for i in 0..self.n {
346            out[i] = windowed[i] + self.history[i];
347        }
348
349        // Save the windowed trailing half as the next frame's history.
350        self.history.copy_from_slice(&windowed[self.n..two_n]);
351
352        Ok(())
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::celt_imdct::{imdct, mdct_forward};
360    use crate::celt_mdct_window::window_tap;
361
362    const EPS: f64 = 1e-9;
363
364    #[test]
365    fn rejects_zero_n() {
366        assert_eq!(
367            WeightedOverlapAdd::new(0, 2),
368            Err(OverlapAddError::ZeroLength)
369        );
370    }
371
372    #[test]
373    fn rejects_bad_overlap() {
374        // Overlap larger than N.
375        assert_eq!(
376            WeightedOverlapAdd::new(4, 6),
377            Err(OverlapAddError::BadOverlap { overlap: 6, n: 4 })
378        );
379        // Zero overlap.
380        assert_eq!(
381            WeightedOverlapAdd::new(4, 0),
382            Err(OverlapAddError::BadOverlap { overlap: 0, n: 4 })
383        );
384        // Odd overlap.
385        assert_eq!(
386            WeightedOverlapAdd::new(8, 3),
387            Err(OverlapAddError::BadOverlap { overlap: 3, n: 8 })
388        );
389    }
390
391    #[test]
392    fn overlap_equal_to_n_is_allowed() {
393        // The shortest CELT MDCT can have N == overlap (full-overlap).
394        let ola = WeightedOverlapAdd::new(4, 4).unwrap();
395        assert_eq!(ola.frame_len(), 4);
396        assert_eq!(ola.overlap(), 4);
397    }
398
399    #[test]
400    fn rejects_wrong_block_length() {
401        let mut ola = WeightedOverlapAdd::new(4, 2).unwrap();
402        // Odd length.
403        assert_eq!(
404            ola.process(&[0.0; 7]),
405            Err(OverlapAddError::BlockLenNotEven { got: 7 })
406        );
407        // Even but != 2N.
408        assert_eq!(
409            ola.process(&[0.0; 6]),
410            Err(OverlapAddError::BlockLenMismatch { got: 6, want: 8 })
411        );
412    }
413
414    #[test]
415    fn rejects_output_too_small() {
416        let mut ola = WeightedOverlapAdd::new(4, 2).unwrap();
417        let block = [0.0; 8];
418        let mut out = [0.0; 3];
419        assert_eq!(
420            ola.process_into(&block, &mut out),
421            Err(OverlapAddError::OutputTooSmall { want: 4, got: 3 })
422        );
423    }
424
425    #[test]
426    fn silence_stays_silent() {
427        let mut ola = WeightedOverlapAdd::new(8, 4).unwrap();
428        for _ in 0..3 {
429            let out = ola.process(&[0.0; 16]).unwrap();
430            assert_eq!(out, vec![0.0; 8]);
431        }
432    }
433
434    #[test]
435    fn first_frame_emits_windowed_leading_half() {
436        // With zero history, frame 0 output is just the windowed leading
437        // half of the block (the trailing-half aliasing is cancelled by
438        // the next frame).
439        let n = 4;
440        let overlap = 2;
441        let mut ola = WeightedOverlapAdd::new(n, overlap).unwrap();
442        let block: Vec<f64> = (0..2 * n).map(|i| (i as f64 + 1.0) * 0.5).collect();
443        let out = ola.process(&block).unwrap();
444        // Expected: leading `overlap` samples scaled by rising ramp,
445        // remaining samples in [overlap, N) unity.
446        let ramp = mdct_window(overlap).unwrap();
447        for i in 0..overlap {
448            assert!((out[i] - block[i] * ramp[i]).abs() < EPS, "i={i}");
449        }
450        for i in overlap..n {
451            assert!((out[i] - block[i]).abs() < EPS, "i={i}");
452        }
453    }
454
455    #[test]
456    fn history_holds_windowed_trailing_half() {
457        let n = 4;
458        let overlap = 2;
459        let mut ola = WeightedOverlapAdd::new(n, overlap).unwrap();
460        assert_eq!(ola.history(), &[0.0; 4]);
461        let block: Vec<f64> = (0..2 * n).map(|i| i as f64 + 1.0).collect();
462        ola.process(&block).unwrap();
463        // History = windowed samples [N, 2N): the trailing `overlap`
464        // samples carry the falling ramp, the rest are unity.
465        let ramp = mdct_window(overlap).unwrap();
466        let mut windowed = block.clone();
467        apply_synthesis_window(&mut windowed, &ramp, n).unwrap();
468        assert_eq!(ola.history(), &windowed[n..2 * n]);
469    }
470
471    #[test]
472    fn reset_zeroes_history() {
473        let mut ola = WeightedOverlapAdd::new(4, 2).unwrap();
474        ola.process(&[1.0; 8]).unwrap();
475        assert!(ola.history().iter().any(|&h| h != 0.0));
476        ola.reset();
477        assert_eq!(ola.history(), &[0.0; 4]);
478    }
479
480    #[test]
481    fn apply_synthesis_window_layout() {
482        // Directly verify the rising/falling/unity layout.
483        let n = 8;
484        let overlap = 4;
485        let ramp = mdct_window(overlap).unwrap();
486        let block: Vec<f64> = (0..2 * n).map(|i| i as f64 + 1.0).collect();
487        let mut w = block.clone();
488        apply_synthesis_window(&mut w, &ramp, n).unwrap();
489        let two_n = 2 * n;
490        for i in 0..overlap {
491            assert!((w[i] - block[i] * ramp[i]).abs() < EPS, "lead i={i}");
492            assert!(
493                (w[two_n - 1 - i] - block[two_n - 1 - i] * ramp[i]).abs() < EPS,
494                "trail i={i}"
495            );
496        }
497        for i in overlap..(two_n - overlap) {
498            assert!((w[i] - block[i]).abs() < EPS, "middle i={i}");
499        }
500    }
501
502    #[test]
503    fn apply_synthesis_window_rejects_bad_args() {
504        let ramp = mdct_window(2).unwrap();
505        assert_eq!(
506            apply_synthesis_window(&mut [0.0; 8], &ramp, 0),
507            Err(OverlapAddError::ZeroLength)
508        );
509        // Block length not 2N.
510        assert_eq!(
511            apply_synthesis_window(&mut [0.0; 6], &ramp, 4),
512            Err(OverlapAddError::BlockLenMismatch { got: 6, want: 8 })
513        );
514        // Odd block length.
515        assert!(matches!(
516            apply_synthesis_window(&mut [0.0; 7], &ramp, 4),
517            Err(OverlapAddError::BlockLenNotEven { got: 7 })
518        ));
519        // Overlap > N (ramp of len 6, N=2).
520        let big = mdct_window(6).unwrap();
521        assert_eq!(
522            apply_synthesis_window(&mut [0.0; 4], &big, 2),
523            Err(OverlapAddError::BadOverlap { overlap: 6, n: 2 })
524        );
525    }
526
527    /// A symmetric Princen-Bradley analysis/synthesis window over the
528    /// *whole* `2N` block: `w(n) = sin( (pi/2N)*(n + 1/2) )`. This is the
529    /// full-overlap window pair (overlap = N) that gives perfect MDCT
530    /// reconstruction; here it lets us drive the stateful overlap-add
531    /// end-to-end against a windowed forward/inverse round-trip.
532    fn pb_window(two_n: usize) -> Vec<f64> {
533        (0..two_n)
534            .map(|i| (core::f64::consts::PI / (two_n as f64) * (i as f64 + 0.5)).sin())
535            .collect()
536    }
537
538    #[test]
539    fn multi_frame_arithmetic_matches_reference() {
540        // Validate the stateful adder's arithmetic across several frames:
541        // for each frame, out_b(i) = windowed_b[i] + windowed_{b-1}[N+i],
542        // and the history carries the windowed trailing half forward. We
543        // compare the adder against an independent hand computation using
544        // the §4.3.7 low-overlap ramp.
545        let n = 6;
546        let overlap = 4;
547        let mut ola = WeightedOverlapAdd::new(n, overlap).unwrap();
548        let ramp = mdct_window(overlap).unwrap();
549        // Sanity: the ramp is the §4.3.7 window over its own length.
550        assert!((ramp[0] - window_tap(0, overlap).unwrap()).abs() < EPS);
551
552        let blocks: Vec<Vec<f64>> = (0..3)
553            .map(|b| {
554                (0..2 * n)
555                    .map(|i| ((b * n + i) as f64 * 0.21).sin() - 0.3)
556                    .collect()
557            })
558            .collect();
559
560        // Independent reference: window each block, then out_b(i) =
561        // windowed_b[i] + windowed_{b-1}[N+i].
562        let mut prev_tail = vec![0.0_f64; n];
563        for block in &blocks {
564            let mut w = block.clone();
565            apply_synthesis_window(&mut w, &ramp, n).unwrap();
566            let want: Vec<f64> = (0..n).map(|i| w[i] + prev_tail[i]).collect();
567            let got = ola.process(block).unwrap();
568            for i in 0..n {
569                assert!(
570                    (got[i] - want[i]).abs() < EPS,
571                    "i={i}: {} != {}",
572                    got[i],
573                    want[i]
574                );
575            }
576            prev_tail.copy_from_slice(&w[n..2 * n]);
577        }
578    }
579
580    #[test]
581    fn end_to_end_tdac_via_imdct_full_overlap() {
582        // True TDAC: with the symmetric Princen-Bradley window applied on
583        // BOTH analysis and synthesis (overlap = N), a windowed forward
584        // MDCT, the §4.3.7 inverse MDCT, and a hop-N overlap-add
585        // reconstruct the input at the §4.3.7 `1/2` scale. This pins the
586        // aliasing-cancellation property the §4.3.7 overlap-add provides,
587        // using the canonical symmetric MDCT window (independent of the
588        // CELT low-overlap layout, which is exercised arithmetically
589        // above).
590        let n = 8;
591        let two_n = 2 * n;
592        let win = pb_window(two_n);
593        // hop = N; produce three overlapping analysis blocks of a signal.
594        let total = 4 * n;
595        let signal: Vec<f64> = (0..total)
596            .map(|i| (i as f64 * 0.37).sin() * 1.5 - (i as f64 * 0.11).cos())
597            .collect();
598
599        // Synthesise each block: window (analysis), forward, inverse,
600        // window (synthesis) -> these are the per-block windowed IMDCT
601        // outputs the overlap-add sums.
602        let synth = |start: usize| -> Vec<f64> {
603            let blk: Vec<f64> = (0..two_n).map(|i| signal[start + i] * win[i]).collect();
604            let coeffs = mdct_forward(&blk).unwrap();
605            let rec = imdct(&coeffs).unwrap();
606            rec.iter().zip(win.iter()).map(|(r, w)| r * w).collect()
607        };
608
609        // Overlap-add by hand (the symmetric window is already applied):
610        // out covering global [N, 2N) = synth(0)[N..2N] + synth(N)[0..N].
611        let f0 = synth(0);
612        let f1 = synth(n);
613        for j in 0..n {
614            let recon = f0[n + j] + f1[j];
615            let want = 0.5 * signal[n + j];
616            assert!(
617                (recon - want).abs() < 1e-9,
618                "overlap j={j}: {recon} != 0.5*signal {want}"
619            );
620        }
621    }
622
623    #[test]
624    fn error_display_messages() {
625        assert!(OverlapAddError::ZeroLength.to_string().contains("N >= 1"));
626        assert!(OverlapAddError::BlockLenNotEven { got: 7 }
627            .to_string()
628            .contains("not 2*N"));
629        assert!(OverlapAddError::BlockLenMismatch { got: 6, want: 8 }
630            .to_string()
631            .contains("!= required 2*N"));
632        assert!(OverlapAddError::BadOverlap { overlap: 6, n: 4 }
633            .to_string()
634            .contains("invalid"));
635        assert!(OverlapAddError::OutputTooSmall { want: 4, got: 3 }
636            .to_string()
637            .contains("< required N"));
638    }
639}