Skip to main content

sim_lib_sound_render/
vocoder.rs

1use std::f64::consts::{PI, TAU};
2
3use sim_lib_numbers_signal::{
4    Direction, SignalBuffer, SignalError, SignalView, SpectrumPacking, TransformKind,
5    TransformPlan, transform, unwrap_phase,
6};
7use sim_lib_sound_audio_lift::{AudioTransformError, StftPlan, stft};
8use thiserror::Error;
9
10/// Relationship imposed between neighboring short-time Fourier bins.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum PhaseLockPolicy {
13    /// Propagate every bin phase independently.
14    Independent,
15    /// Lock non-peak bins to the nearest local spectral peak.
16    Identity {
17        /// Maximum distance in bins at which a local peak may own a bin.
18        radius_bins: usize,
19    },
20}
21
22/// Attack-handling policy for the phase vocoder.
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub enum TransientPolicy {
25    /// Propagate every frame continuously, allowing the classic vocoder smear.
26    Smear,
27    /// Reset synthesis phases when normalized positive spectral flux reaches a threshold.
28    Reset {
29        /// Finite nonnegative onset threshold.
30        spectral_flux_threshold: f64,
31    },
32}
33
34/// Policy used to choose an unwrapped phase residual.
35#[derive(Clone, Copy, Debug, PartialEq)]
36pub enum PhaseUnwrapPolicy {
37    /// Select the residual nearest the expected bin-center advance.
38    ExpectedAdvance {
39        /// Discontinuity passed to the canonical numbers-signal unwrap owner.
40        discontinuity_radians: f64,
41    },
42}
43
44/// Policy used to turn analysis-frame phase into synthesis frequency.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum InstantaneousFrequencyPolicy {
47    /// Use each FFT bin center and discard within-bin phase deviation.
48    BinCenter,
49    /// Add the unwrapped phase derivative to each bin-center frequency.
50    PhaseDerivative,
51}
52
53/// Complete bounded policy for offline monophonic phase vocoding.
54#[derive(Clone, Debug, PartialEq)]
55pub struct VocoderPolicy {
56    /// Source and destination sample rate in hertz.
57    pub sample_rate_hz: u32,
58    /// Output duration divided by input duration.
59    pub stretch_ratio: f64,
60    /// Output pitch divided by input pitch, independent of duration.
61    pub pitch_ratio: f64,
62    /// Existing phase-preserving STFT plan to compose.
63    pub stft: StftPlan,
64    /// Neighbor-bin phase relationship.
65    pub phase_lock: PhaseLockPolicy,
66    /// Transient phase-reset behavior.
67    pub transient: TransientPolicy,
68    /// Phase-residual unwrapping convention.
69    pub phase_unwrap: PhaseUnwrapPolicy,
70    /// Instantaneous-frequency estimator.
71    pub instantaneous_frequency: InstantaneousFrequencyPolicy,
72    /// Hard output-sample bound checked before synthesis allocation.
73    pub max_output_samples: usize,
74}
75
76impl Default for VocoderPolicy {
77    fn default() -> Self {
78        Self {
79            sample_rate_hz: 48_000,
80            stretch_ratio: 1.0,
81            pitch_ratio: 1.0,
82            stft: StftPlan::default(),
83            phase_lock: PhaseLockPolicy::Identity { radius_bins: 3 },
84            transient: TransientPolicy::Reset {
85                spectral_flux_threshold: 0.35,
86            },
87            phase_unwrap: PhaseUnwrapPolicy::ExpectedAdvance {
88                discontinuity_radians: PI,
89            },
90            instantaneous_frequency: InstantaneousFrequencyPolicy::PhaseDerivative,
91            max_output_samples: 16_777_216,
92        }
93    }
94}
95
96/// Visible PCM and policy evidence returned by [`phase_vocode`].
97#[derive(Clone, Debug, PartialEq)]
98pub struct PhaseVocoderReport {
99    /// Offline monophonic float PCM; values are not silently clipped.
100    pub samples: Vec<f32>,
101    /// Input sample count.
102    pub input_samples: usize,
103    /// Exact requested output sample count.
104    pub output_samples: usize,
105    /// Number of STFT frames composed by the transform.
106    pub analysis_frames: usize,
107    /// Floating synthesis hop before per-frame coordinate rounding.
108    pub synthesis_hop: f64,
109    /// Frames whose phases were reset by transient policy.
110    pub transient_resets: usize,
111    /// Absolute peak of the unbounded float result.
112    pub peak: f32,
113    /// Samples outside `[-1, 1]`; no limiter or clipper is hidden here.
114    pub clipped_samples: usize,
115    /// Exact retained policy.
116    pub policy: VocoderPolicy,
117}
118
119/// Invalid phase-vocoder policy or delegated STFT/signal failure.
120#[derive(Clone, Debug, Error, PartialEq)]
121pub enum VocoderError {
122    /// A named policy field violated its finite definition.
123    #[error("invalid vocoder {field}: {reason}")]
124    InvalidPolicy {
125        /// Rejected field.
126        field: &'static str,
127        /// Stable diagnostic.
128        reason: &'static str,
129    },
130    /// Requested output exceeded its explicit sample bound.
131    #[error("vocoder output needs {required} samples, exceeding {maximum}")]
132    OutputLimit {
133        /// Requested samples.
134        required: usize,
135        /// Policy ceiling.
136        maximum: usize,
137    },
138    /// Output coordinate or allocation arithmetic overflowed.
139    #[error("vocoder output coordinate arithmetic overflowed")]
140    SizeOverflow,
141    /// Existing audio-domain STFT rejected the request.
142    #[error(transparent)]
143    Stft(#[from] AudioTransformError),
144    /// Existing generic transform or phase owner rejected the request.
145    #[error(transparent)]
146    Signal(#[from] SignalError),
147}
148
149/// Applies a bounded offline phase vocoder by composing the existing
150/// phase-preserving STFT and generic numbers-signal FFT/unwrap owners.
151pub fn phase_vocode(
152    input: &[f32],
153    policy: VocoderPolicy,
154) -> Result<PhaseVocoderReport, VocoderError> {
155    validate_policy(&policy)?;
156    let output_samples = scaled_len(input.len(), policy.stretch_ratio)?;
157    if output_samples > policy.max_output_samples {
158        return Err(VocoderError::OutputLimit {
159            required: output_samples,
160            maximum: policy.max_output_samples,
161        });
162    }
163    if input.is_empty() {
164        return Ok(empty_report(policy));
165    }
166
167    let analysis = stft(input, policy.sample_rate_hz, &policy.stft)?;
168    let bins = policy.stft.frame / 2 + 1;
169    let synthesis_hop = policy.stft.hop as f64 * policy.stretch_ratio;
170    let synthesis_window = policy.stft.synthesis_window.generate(policy.stft.frame)?;
171    let analysis_window = policy.stft.analysis_window.generate(policy.stft.frame)?;
172    let synthesis_len =
173        synthesis_storage_len(analysis.frames.len(), synthesis_hop, policy.stft.frame)?;
174    let mut output = vec![0.0f64; synthesis_len];
175    let mut gain = vec![0.0f64; synthesis_len];
176    let mut previous_phase = vec![0.0f64; bins];
177    let mut synthesis_phase = vec![0.0f64; bins];
178    let mut previous_magnitude = vec![0.0f64; bins];
179    let mut transient_resets = 0usize;
180
181    for (frame_index, frame) in analysis.frames.iter().enumerate() {
182        let magnitudes = frame
183            .bins
184            .iter()
185            .map(|(real, imaginary)| real.hypot(*imaginary))
186            .collect::<Vec<_>>();
187        let phases = frame
188            .bins
189            .iter()
190            .map(|(real, imaginary)| imaginary.atan2(*real))
191            .collect::<Vec<_>>();
192        let reset =
193            frame_index > 0 && transient_reset(&magnitudes, &previous_magnitude, policy.transient);
194        transient_resets += usize::from(reset);
195
196        for bin in 0..bins {
197            if frame_index == 0 || reset {
198                synthesis_phase[bin] = phases[bin];
199            } else {
200                let expected = TAU * policy.stft.hop as f64 * bin as f64 / policy.stft.frame as f64;
201                let residual = unwrap_residual(
202                    phases[bin] - previous_phase[bin] - expected,
203                    policy.phase_unwrap,
204                )?;
205                let radians_per_sample = match policy.instantaneous_frequency {
206                    InstantaneousFrequencyPolicy::BinCenter => {
207                        TAU * bin as f64 / policy.stft.frame as f64
208                    }
209                    InstantaneousFrequencyPolicy::PhaseDerivative => {
210                        (expected + residual) / policy.stft.hop as f64
211                    }
212                };
213                synthesis_phase[bin] += radians_per_sample * synthesis_hop;
214            }
215        }
216        apply_phase_lock(
217            &magnitudes,
218            &phases,
219            &mut synthesis_phase,
220            policy.phase_lock,
221        );
222        let shifted = shift_bins(&magnitudes, &synthesis_phase, policy.pitch_ratio, bins);
223        overlap_add(
224            &shifted,
225            frame_index,
226            synthesis_hop,
227            &policy,
228            &analysis_window.samples,
229            &synthesis_window.samples,
230            &mut output,
231            &mut gain,
232        )?;
233        previous_phase.copy_from_slice(&phases);
234        previous_magnitude.copy_from_slice(&magnitudes);
235    }
236
237    let trim_start = scaled_len(analysis.left_padding, policy.stretch_ratio)?;
238    let samples = trim_output(&output, &gain, trim_start, output_samples);
239    let peak = samples.iter().copied().map(f32::abs).fold(0.0, f32::max);
240    let clipped_samples = samples.iter().filter(|sample| sample.abs() > 1.0).count();
241    Ok(PhaseVocoderReport {
242        samples,
243        input_samples: input.len(),
244        output_samples,
245        analysis_frames: analysis.frames.len(),
246        synthesis_hop,
247        transient_resets,
248        peak,
249        clipped_samples,
250        policy,
251    })
252}
253
254fn validate_policy(policy: &VocoderPolicy) -> Result<(), VocoderError> {
255    if policy.sample_rate_hz == 0 {
256        return Err(invalid("sample rate", "must be positive"));
257    }
258    if !policy.stretch_ratio.is_finite() || policy.stretch_ratio <= 0.0 {
259        return Err(invalid("stretch ratio", "must be finite and positive"));
260    }
261    if !policy.pitch_ratio.is_finite() || policy.pitch_ratio <= 0.0 {
262        return Err(invalid("pitch ratio", "must be finite and positive"));
263    }
264    if policy.max_output_samples == 0 {
265        return Err(invalid("output bound", "must be positive"));
266    }
267    if let PhaseLockPolicy::Identity { radius_bins } = policy.phase_lock
268        && radius_bins == 0
269    {
270        return Err(invalid("phase lock radius", "must be positive"));
271    }
272    if let TransientPolicy::Reset {
273        spectral_flux_threshold,
274    } = policy.transient
275        && (!spectral_flux_threshold.is_finite() || spectral_flux_threshold < 0.0)
276    {
277        return Err(invalid(
278            "transient threshold",
279            "must be finite and nonnegative",
280        ));
281    }
282    let PhaseUnwrapPolicy::ExpectedAdvance {
283        discontinuity_radians,
284    } = policy.phase_unwrap;
285    if !discontinuity_radians.is_finite() || !(PI..=TAU).contains(&discontinuity_radians) {
286        return Err(invalid(
287            "phase unwrap discontinuity",
288            "must lie between pi and one turn",
289        ));
290    }
291    Ok(())
292}
293
294fn scaled_len(len: usize, ratio: f64) -> Result<usize, VocoderError> {
295    let scaled = len as f64 * ratio;
296    if !scaled.is_finite() || scaled > usize::MAX as f64 {
297        return Err(VocoderError::SizeOverflow);
298    }
299    Ok(scaled.round() as usize)
300}
301
302fn synthesis_storage_len(frames: usize, hop: f64, frame_len: usize) -> Result<usize, VocoderError> {
303    let last = frames.saturating_sub(1) as f64 * hop;
304    if !last.is_finite() || last > usize::MAX as f64 {
305        return Err(VocoderError::SizeOverflow);
306    }
307    (last.round() as usize)
308        .checked_add(frame_len)
309        .ok_or(VocoderError::SizeOverflow)
310}
311
312fn transient_reset(current: &[f64], previous: &[f64], policy: TransientPolicy) -> bool {
313    let TransientPolicy::Reset {
314        spectral_flux_threshold,
315    } = policy
316    else {
317        return false;
318    };
319    let positive_flux = current
320        .iter()
321        .zip(previous)
322        .map(|(current, previous)| (current - previous).max(0.0))
323        .sum::<f64>();
324    let energy = current.iter().sum::<f64>().max(f64::EPSILON);
325    positive_flux / energy >= spectral_flux_threshold
326}
327
328fn unwrap_residual(residual: f64, policy: PhaseUnwrapPolicy) -> Result<f64, SignalError> {
329    let PhaseUnwrapPolicy::ExpectedAdvance {
330        discontinuity_radians,
331    } = policy;
332    let unwrapped = unwrap_phase(&[0.0, residual], discontinuity_radians)?;
333    Ok(unwrapped[1])
334}
335
336fn apply_phase_lock(
337    magnitudes: &[f64],
338    analysis_phase: &[f64],
339    synthesis_phase: &mut [f64],
340    policy: PhaseLockPolicy,
341) {
342    let PhaseLockPolicy::Identity { radius_bins } = policy else {
343        return;
344    };
345    let peaks = (0..magnitudes.len())
346        .filter(|index| {
347            let left = index
348                .checked_sub(1)
349                .map(|left| magnitudes[left])
350                .unwrap_or(f64::NEG_INFINITY);
351            let right = magnitudes
352                .get(index + 1)
353                .copied()
354                .unwrap_or(f64::NEG_INFINITY);
355            magnitudes[*index] >= left && magnitudes[*index] >= right
356        })
357        .collect::<Vec<_>>();
358    let independent = synthesis_phase.to_vec();
359    for bin in 0..synthesis_phase.len() {
360        let owner = peaks
361            .iter()
362            .copied()
363            .filter(|peak| peak.abs_diff(bin) <= radius_bins)
364            .min_by_key(|peak| peak.abs_diff(bin));
365        if let Some(peak) = owner {
366            synthesis_phase[bin] = independent[peak] + analysis_phase[bin] - analysis_phase[peak];
367        }
368    }
369}
370
371fn shift_bins(
372    magnitudes: &[f64],
373    phases: &[f64],
374    pitch_ratio: f64,
375    output_bins: usize,
376) -> Vec<(f64, f64)> {
377    let mut shifted = vec![(0.0, 0.0); output_bins];
378    for source in 0..magnitudes.len() {
379        let target = source as f64 * pitch_ratio;
380        let lower = target.floor() as usize;
381        let fraction = target - target.floor();
382        add_polar(
383            &mut shifted,
384            lower,
385            magnitudes[source] * (1.0 - fraction),
386            phases[source] * pitch_ratio,
387        );
388        add_polar(
389            &mut shifted,
390            lower.saturating_add(1),
391            magnitudes[source] * fraction,
392            phases[source] * pitch_ratio,
393        );
394    }
395    if let Some(dc) = shifted.first_mut() {
396        dc.1 = 0.0;
397    }
398    if let Some(nyquist) = shifted.last_mut() {
399        nyquist.1 = 0.0;
400    }
401    shifted
402}
403
404fn add_polar(target: &mut [(f64, f64)], bin: usize, magnitude: f64, phase: f64) {
405    if let Some((real, imaginary)) = target.get_mut(bin) {
406        *real += magnitude * phase.cos();
407        *imaginary += magnitude * phase.sin();
408    }
409}
410
411#[allow(clippy::too_many_arguments)]
412fn overlap_add(
413    bins: &[(f64, f64)],
414    frame_index: usize,
415    synthesis_hop: f64,
416    policy: &VocoderPolicy,
417    analysis_window: &[f64],
418    synthesis_window: &[f64],
419    output: &mut [f64],
420    gain: &mut [f64],
421) -> Result<(), VocoderError> {
422    let mut inverse = TransformPlan::new(TransformKind::RealFft, policy.stft.frame);
423    inverse.direction = Direction::Inverse;
424    inverse.normalization = policy.stft.normalization;
425    inverse.sign = policy.stft.phase;
426    inverse.packing = SpectrumPacking::HermitianHalf;
427    let SignalBuffer::Real(frame) = transform(&inverse, SignalView::Complex(bins))? else {
428        unreachable!("inverse real FFT returns real samples")
429    };
430    let start = (frame_index as f64 * synthesis_hop).round() as usize;
431    for offset in 0..policy.stft.frame {
432        let at = start
433            .checked_add(offset)
434            .ok_or(VocoderError::SizeOverflow)?;
435        output[at] += frame.as_slice()[offset] * synthesis_window[offset];
436        gain[at] += analysis_window[offset] * synthesis_window[offset];
437    }
438    Ok(())
439}
440
441fn trim_output(output: &[f64], gain: &[f64], start: usize, len: usize) -> Vec<f32> {
442    (0..len)
443        .map(|offset| {
444            let at = start.saturating_add(offset);
445            let divisor = gain.get(at).copied().unwrap_or(0.0);
446            if divisor.abs() <= 1e-12 {
447                0.0
448            } else {
449                (output.get(at).copied().unwrap_or(0.0) / divisor) as f32
450            }
451        })
452        .collect()
453}
454
455fn empty_report(policy: VocoderPolicy) -> PhaseVocoderReport {
456    PhaseVocoderReport {
457        samples: Vec::new(),
458        input_samples: 0,
459        output_samples: 0,
460        analysis_frames: 0,
461        synthesis_hop: policy.stft.hop as f64 * policy.stretch_ratio,
462        transient_resets: 0,
463        peak: 0.0,
464        clipped_samples: 0,
465        policy,
466    }
467}
468
469fn invalid(field: &'static str, reason: &'static str) -> VocoderError {
470    VocoderError::InvalidPolicy { field, reason }
471}