Spectral freeze / infinite hold for phase-vocoder frame groups. More...
#include <pulp/signal/checked_allocation.hpp>#include <pulp/signal/rt_work_counter.hpp>#include <pulp/signal/staged_transition.hpp>#include <algorithm>#include <array>#include <atomic>#include <cassert>#include <cmath>#include <complex>#include <cstddef>#include <cstdint>#include <cstring>#include <span>#include <utility>#include <vector>Go to the source code of this file.
Classes | |
| struct | pulp::signal::FreezeHoldSnapshotT< SampleType > |
| Value image of a FreezeHold's held state: everything needed to reproduce the held output bit-for-bit on a hold prepared with the same geometry. More... | |
| struct | pulp::signal::FreezeHoldReferenceTiming |
| Time-based FreezeHold timing that reproduces the frame-count defaults (8 capture frames, 6 crossfade frames, 0.015 rad/frame walk) at the reference geometry they were tuned for: a 512-sample analysis hop at 48 kHz (RealtimePitchTimeProcessor's quality mode, 4096/512). More... | |
| class | pulp::signal::FreezeHoldT< SampleType > |
| struct | pulp::signal::FreezeHoldT< SampleType >::Config |
| Two sizing modes. More... | |
Namespaces | |
| namespace | pulp |
| namespace | pulp::signal |
Typedefs | |
| using | pulp::signal::FreezeHoldSnapshot = FreezeHoldSnapshotT<float> |
| using | pulp::signal::FreezeHoldSnapshot64 = FreezeHoldSnapshotT<double> |
| using | pulp::signal::FreezeHold = FreezeHoldT<float> |
| using | pulp::signal::FreezeHold64 = FreezeHoldT<double> |
Enumerations | |
| enum class | pulp::signal::FreezeHoldSynthesis : std::uint8_t { pulp::signal::polar , pulp::signal::rotor } |
| How held frames are synthesised (see the class notes on per-callback cost). More... | |
| enum class | pulp::signal::FreezeHoldFrequency : std::uint8_t { pulp::signal::newest , pulp::signal::energy_weighted } |
| Where a held bin's instantaneous frequency comes from. More... | |
| enum class | pulp::signal::FreezeHoldAverage : std::uint8_t { pulp::signal::at_latch , pulp::signal::running } |
| When the capture window is averaged. More... | |
| enum class | pulp::signal::FreezeHoldPhaseLock : std::uint8_t { pulp::signal::none , pulp::signal::peak_lobe } |
| Phase structure of a hold as it starts (see the class notes on stationarity). More... | |
| enum class | pulp::signal::FreezeRestoreEngage : std::uint8_t { pulp::signal::crossfade , pulp::signal::immediate , pulp::signal::as_captured } |
| How a restored hold enters the output. More... | |
Spectral freeze / infinite hold for phase-vocoder frame groups.
Captures a rolling window of recent analysis frames (preallocated at prepare) and, when engaged, replaces live analysis frames with a synthetic steady-state hold: per-channel magnitudes averaged over the captured window, per-bin phases advancing at the instantaneous frequency estimated from the captured frames, plus a small bounded random walk that de-periodicizes the hold (spectral freezing per J.-F. Charles, "A Tutorial on Spectral Sound Processing Using Max/MSP and Jitter," CMJ 32(3), 2008; the random-walk de-looping follows the granular-hold contrast family, Roads, Microsound, 2001).
Designed to sit at the HEAD of a vocoder chain (before phase propagation and formant processing): held frames look like live steady-state input, so downstream pitch/formant control keeps working over frozen audio. Per-channel phase offsets are initialized from the latched frame and every channel advances by the same per-bin amount, preserving the spatial image. The per-bin advance is estimated from all channels together (magnitude-weighted), so an anti-phase bin — whose channel sum cancels — still gets its true frequency.
Engage policy (explicit no-mute rule): freezing latches only once the capture window is full; until then live frames pass through and the latch arms. The hold averages exactly the last capture_frames consecutive analysis frames before the latch. A release is committed: the old hold fades out even if a freeze is requested again during the fade, and the capture window restarts at the release, so the next hold contains only input analyzed after it. Engage/release crossfade over a fixed number of frames in the spectral domain; engage_progress() reports the fade position. Consumers that align their own (e.g. time-domain) crossfade can instead render the hold directly through the public pipeline: write_hold(), advance_hold(), rewind_hold_phases(), and the held-state accessors.
Deterministic (xorshift PRNG seeded at prepare); no allocation or locks after prepare(). All members except stage_restore() and restore_pending() belong to the thread that runs process_group(); state such as is_latched() must reach a UI through the owner's own publication, not by reading this object from another thread.
A held state can be captured with snapshot() into caller-prepared storage, serialized to a versioned, size-bounded byte image, and recalled with stage_restore() from a non-audio thread. The audio owner adopts a staged restore at the start of the next frame group.
Per-callback cost. The defaults reproduce the original hold bit for bit, and three of their costs land in single callbacks: every held frame evaluates one std::polar per channel per bin; a latch averages capture_frames × channels × bins magnitudes in the latching frame group (about 1.5 M adds for a 2 s hold at 8192 points, stereo); and the latch derives every bin's phase with an atan2. A host drops a buffer whose callback overruns, so these are opt-in Config fields that bound them:
Level and frequency over long holds. With frequency = newest (the default) every bin rotates at the increment of the NEWEST captured frame. Over a window that spans a change of material, a bin whose energy came from the older material is then rotated at whatever frequency the newest frame's leakage gave it, so the older sound is not heard and the hold plays well below the input. frequency = energy_weighted takes each bin's increment as the circular mean over the whole window, weighted by the per-frame product of consecutive magnitudes (the argument of sum_t sum_ch X_t conj(X_{t-1})), so a bin follows the content that dominates it.
Stationarity. With phase_lock = none a hold starts phase-coherent (the latched frame's phases) and each bin then advances at its own frequency, so the bins of one partial's main lobe drift apart and the hold decoheres over its first few hundred milliseconds: its level moves after it is already audible. phase_lock = peak_lobe keeps each peak's lobe rotating at the peak's frequency, so a partial stays coherent, and starts every other bin at a random phase, so noise-like content has its steady-state statistics from the first held frame. The inter-channel phase offsets are kept in both cases.
Expensive work is reported to pulp::signal::rt counters (trig for each polar/atan2, bins for per-bin accumulation), which is what a transition-cost gate reads.