Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
offline_stretch.hpp
Go to the documentation of this file.
1#pragma once
27
30
31#include <algorithm>
32#include <cmath>
33#include <string>
34#include <vector>
35
36namespace pulp::signal {
37
48
55
67
76 bool relocate_transients = false;
77 double time_ratio = 1.0;
78 double pitch_semitones = 0.0;
80 double formant_semitones = 0.0;
81 bool repitch_linked = false;
83 // STN noise-morphing OFF by default. Measured (Python-vs-C++ A/B across
84 // bass/mix/vocal/drum at 0.75–3×): routing the residual through NoiseMorpher
85 // DULLS the output ~400 spectral-centroid points on every material — the muddy
86 // drum + the "wind"/modulation the validated Python prototype never had (it has
87 // no STN). Off restores the Python brightness (drum 2521→2953 ≈ source 2990).
88 // Opt back in per-render for genuinely noise-dominated textures where natural
89 // residual stretch matters more than HF brightness. (The morpher's HF loss is
90 // a separate bug to fix before re-enabling by default.)
91 bool route_noise_stn = false;
93 int quality = 2;
94
95 // Range MUST be sized up-front: the underlying
96 // RealtimePitchTimeProcessor clamps to [1/max, max] and allocates from these
97 // bounds at prepare(). The sampler's tempo ratios (host_bpm/loop_bpm)
98 // routinely exceed 0.5–2×, so the defaults are wider than the realtime
99 // engine's. A process() ratio beyond the PREPARED bounds is REJECTED, never
100 // silently clamped — so a mis-sized render is a loud error, not a wrong result.
101 double max_time_ratio = 4.0;
102 double max_pitch_semitones = 24.0;
103
104 // Material-adaptive STFT geometry (ear-validated). The phase core
105 // is fixed at FFT 4096/512; that window is too SMALL to resolve closely-
106 // spaced low partials (bass stretches wobble) and too LARGE for percussive
107 // time resolution (drum attacks soften). Setting these picks a window suited
108 // to the input — large+high-overlap for bass, small for transients. 0 = keep
109 // the engine default. Use OfflineStretch::recommend_window() to choose them
110 // from the actual audio, then pass here at prepare().
111 int fft_size = 0;
112 int analysis_hop = 0;
113
114 // Transient-detector sensitivity (0 = engine default 1.0). Higher keeps EVERY
115 // percussion hit sharp; the default detector softens some hits ("transients
116 // move around"). recommend_window pairs a higher value with the percussive
117 // window; a caller can override.
119};
120
126inline long offline_stretch_output_frames(long in_frames, double time_ratio) noexcept {
127 if (in_frames <= 0 || !(time_ratio > 0.0)) return 0;
128 const double exact = static_cast<double>(in_frames) * time_ratio;
129 const long out = static_cast<long>(std::lround(exact));
130 return out < 0 ? 0 : out;
131}
132
136template <typename SampleType = float>
138public:
145 void prepare(double sample_rate, int channels,
146 const OfflineStretchOptions& sizing = {}) {
147 sample_rate_ = sample_rate;
148 channels_ = channels < 1 ? 1 : channels;
149 max_time_ratio_ = sizing.max_time_ratio >= 1.0 ? sizing.max_time_ratio : 1.0;
150 max_pitch_semitones_ = sizing.max_pitch_semitones >= 0.0
151 ? sizing.max_pitch_semitones : 0.0;
152 prepared_ = (sample_rate > 0.0) && (channels >= 1);
153 if (prepared_) {
154 // Size the tempo-only engine FROM the prepared bound — not the
155 // engine's default 2.0x — so host_bpm/loop_bpm ratios up to
156 // max_time_ratio_ render without silent clamping.
160 cfg.channels = channels_;
161 cfg.max_block = kBlock;
162 // Size for the R*P product so the single-pass independent path
163 // (stretch by R*P, then resample by P) stays within engine bounds.
164 const double max_pitch_ratio =
165 std::exp2((max_pitch_semitones_ > 0.0 ? max_pitch_semitones_ : 0.0) / 12.0);
166 cfg.max_time_ratio = static_cast<float>(max_time_ratio_ * max_pitch_ratio);
167 cfg.max_pitch_semitones = 0.0f; // pitch is fixed in time_stretch mode
168 cfg.transient_preservation = true;
169 cfg.transient_sensitivity = sizing.transient_sensitivity; // keep every hit sharp
170 cfg.noise_morphing = sizing.route_noise_stn; // STN noise path
171 // Material-adaptive window/overlap (ear-validated: fixes bass wobble
172 // and keeps drum transients natural; the realtime default is one size
173 // too small for bass and too large for percussion).
174 cfg.fft_size = sizing.fft_size;
175 cfg.analysis_hop = sizing.analysis_hop;
176 fft_size_ = sizing.fft_size; // remembered for accessor/tests
177 engine_.prepare(sample_rate, cfg);
178 latency_anchor_ = calibrate_anchor();
179
180 // Separate engine in realtime_pitch mode for the pitch path (formant
181 // preserve/shift/independent live here, in the SpectralEnvelopeShifter).
185 pcfg.channels = channels_;
186 pcfg.max_block = kBlock;
188 static_cast<float>(max_pitch_semitones_ > 0.0 ? max_pitch_semitones_ : 1.0);
191 pcfg.transient_preservation = true;
192 pcfg.noise_morphing = sizing.route_noise_stn;
193 pitch_engine_.prepare(sample_rate, pcfg);
194 }
195 }
196
197 int channels() const noexcept { return channels_; }
198 double sample_rate() const noexcept { return sample_rate_; }
199 double max_time_ratio() const noexcept { return max_time_ratio_; }
200 double max_pitch_semitones() const noexcept { return max_pitch_semitones_; }
202 int fft_size() const noexcept { return fft_size_; }
203
216 struct Window { int fft_size; int analysis_hop; };
217 static Window recommend_window(const SampleType* const* in, long frames,
218 int channels, double sample_rate) {
219 if (in == nullptr || frames <= 0 || channels < 1 || !(sample_rate > 0.0))
220 return {0, 0};
221 // Mono mix.
222 std::vector<double> x(static_cast<size_t>(frames), 0.0);
223 for (int c = 0; c < channels; ++c) {
224 const SampleType* s = in[c];
225 if (!s) continue;
226 for (long i = 0; i < frames; ++i) x[static_cast<size_t>(i)] += s[i];
227 }
228 const double inv = 1.0 / channels;
229 for (auto& v : x) v *= inv;
230
231 // Crest of a 512-sample energy envelope (256 hop) = transient density.
232 const long W = 512, H = 256;
233 double e_sum = 0.0, e_max = 0.0; long e_cnt = 0;
234 for (long i = 0; i + W <= frames; i += H) {
235 double acc = 0.0;
236 for (long j = 0; j < W; ++j) { const double v = x[static_cast<size_t>(i + j)]; acc += v * v; }
237 const double rms = std::sqrt(acc / static_cast<double>(W) + 1e-12);
238 e_sum += rms; e_max = std::max(e_max, rms); ++e_cnt;
239 }
240 const double crest = e_cnt > 0 ? e_max / (e_sum / static_cast<double>(e_cnt) + 1e-12) : 0.0;
241
242 // Low-band fraction: RBJ 2nd-order lowpass @200 Hz, lowpassed energy /
243 // total energy. Avoids an FFT dependency in the header.
244 const double f0 = 200.0, q = 0.7071;
245 const double w0 = 2.0 * 3.14159265358979323846 * f0 / sample_rate;
246 const double cw = std::cos(w0), sw = std::sin(w0), alpha = sw / (2.0 * q);
247 const double b0 = (1.0 - cw) * 0.5, b1 = 1.0 - cw, b2 = (1.0 - cw) * 0.5;
248 const double a0 = 1.0 + alpha, a1 = -2.0 * cw, a2 = 1.0 - alpha;
249 const double nb0 = b0 / a0, nb1 = b1 / a0, nb2 = b2 / a0, na1 = a1 / a0, na2 = a2 / a0;
250 double z1 = 0.0, z2 = 0.0, lo_e = 0.0, tot_e = 0.0;
251 for (long i = 0; i < frames; ++i) {
252 const double in_s = x[static_cast<size_t>(i)];
253 const double out_s = nb0 * in_s + z1;
254 z1 = nb1 * in_s - na1 * out_s + z2;
255 z2 = nb2 * in_s - na2 * out_s;
256 lo_e += out_s * out_s; tot_e += in_s * in_s;
257 }
258 const double lo_band = tot_e > 0.0 ? lo_e / tot_e : 0.0;
259
260 // Percussive: 1024/128 — matches the validated Python "drum_pl" reference
261 // the user picked. (The earlier "wobble" that motivated a 2048 window was
262 // the STN noise path, not the window; with STN off, 1024 gives the sharp,
263 // bright drum attacks of the Python prototype.)
264 if (crest > 3.0) return {1024, 128}; // percussive — time resolution, match Python ref
265 if (lo_band > 0.30) return {8192, 512}; // bass/low — resolve low partials
266 return {0, 0}; // default 4096/512
267 }
268
275 bool process(const SampleType* const* in, long in_frames,
276 SampleType* const* out, long out_frames,
277 const OfflineStretchOptions& opts,
278 std::string* err = nullptr) {
279 if (!prepared_) return fail(err, "OfflineStretch::process called before prepare()");
280 if (in_frames < 0) return fail(err, "in_frames must be >= 0");
281 if (out_frames < 0) return fail(err, "out_frames must be >= 0");
282 if (in == nullptr && in_frames > 0) return fail(err, "null input");
283 if (out == nullptr && out_frames > 0) return fail(err, "null output");
284 if (!(opts.time_ratio > 0.0)) return fail(err, "time_ratio must be > 0");
285
286 // Range is fixed at prepare() — reject, never silently clamp.
287 if (opts.time_ratio > max_time_ratio_ || opts.time_ratio < 1.0 / max_time_ratio_)
288 return fail(err, "time_ratio outside the prepared range [1/max, max]; "
289 "widen OfflineStretchOptions::max_time_ratio at prepare()");
290 if (std::abs(opts.pitch_semitones) > max_pitch_semitones_)
291 return fail(err, "pitch_semitones outside the prepared range; widen "
292 "OfflineStretchOptions::max_pitch_semitones at prepare()");
293
294 const long expected = offline_stretch_output_frames(in_frames, opts.time_ratio);
295 if (out_frames != expected)
296 return fail(err, "out_frames must equal round(in_frames * time_ratio)");
297
298 // Character-mode dispatch. `varispeed` is pitch+time-linked (tape): a pure
299 // resample + a speed-scaled head EQ, no phase-vocoder artifacts. The
300 // `phase_vocoder` and `granular` characters are reserved modes that fall
301 // through to the clean spectral path for now; the tempo-only spectral path
302 // can still opt into verbatim transient relocation.
304 return varispeed_render(in, in_frames, out, out_frames, opts.time_ratio);
305
306 // Linked / vinyl mode: pure high-quality resample. Output sample i reads
307 // input position i/ratio at a constant rate, so pitch tracks tempo
308 // exactly (factor 1/ratio) and the output is exactly `expected` frames by
309 // construction. sinc6 (6-point Blackman-Harris windowed sinc) is
310 // mastering-grade and is an exact identity at ratio == 1.
311 if (opts.repitch_linked) {
312 for (int c = 0; c < channels_; ++c)
313 for (long i = 0; i < out_frames; ++i)
314 out[c][i] = sample_sinc6(in[c], in_frames,
315 static_cast<double>(i) / opts.time_ratio);
316 return true;
317 }
318
319 // Route pitch==0 through the tempo-only spectral path; ratio==1 is the
320 // exact identity fast path.
321 const bool pitch_off = std::abs(opts.pitch_semitones) < 1e-12;
322 if (pitch_off && opts.time_ratio == 1.0) {
323 for (int c = 0; c < channels_; ++c)
324 for (long i = 0; i < out_frames; ++i) out[c][i] = in[c][i];
325 return true;
326 }
327 if (pitch_off) {
328 if (opts.quality <= 0) { // draft: fast OLA preview
329 ola_tempo(in, in_frames, out, out_frames, opts.time_ratio);
330 return true;
331 }
332 if (!tempo_stretch(in, in_frames, out, out_frames, opts.time_ratio)) return false;
333 // Graft verbatim transients onto the PV output to restore attack punch.
335 opts.relocate_transients) && opts.time_ratio != 1.0)
336 relocate_transients(in, in_frames, out, out_frames, opts.time_ratio);
337 restore_onset_head(in, in_frames, out, out_frames); // attack at sample 0 (soft-start)
338 match_spectral_rms(in, in_frames, out, out_frames); // restore the PV energy loss
339 return true;
340 }
341
342 // Pitch-only (duration preserved): realtime_pitch engine + formant mode.
343 if (opts.time_ratio == 1.0) {
344 if (!pitch_shift(in, in_frames, out, out_frames,
346 return false;
347 match_spectral_rms(in, in_frames, out, out_frames);
348 return true;
349 }
350
351 // Independent R+S (time_ratio != 1 AND pitch != 0).
352 const int ch = channels_;
353 const double P = std::exp2(opts.pitch_semitones / 12.0);
355 // Single spectral pass: stretch by R*P with pitch preserved, then
356 // resample reading at step P — which shifts pitch by
357 // +S, drags formants along (follow), and lands at duration R. One
358 // phase-vocoder pass + one interpolation, not two cascaded PV passes.
359 const double eff = opts.time_ratio * P;
360 const long inter_len = offline_stretch_output_frames(in_frames, eff);
361 std::vector<std::vector<SampleType>> inter(static_cast<size_t>(ch),
362 std::vector<SampleType>(static_cast<size_t>(inter_len)));
363 std::vector<SampleType*> ip(static_cast<size_t>(ch));
365 if (!tempo_stretch(in, in_frames, ip.data(), inter_len, eff)) return fail(err, "stretch failed");
366 for (int c = 0; c < ch; ++c)
367 for (long j = 0; j < out_frames; ++j)
368 out[c][j] = sample_sinc6(inter[static_cast<size_t>(c)].data(), inter_len,
369 static_cast<double>(j) * P);
370 match_spectral_rms(in, in_frames, out, out_frames);
371 return true;
372 }
373
374 // Formant preserve / independent: cascade pitch (formant-correct, via the
375 // SpectralEnvelopeShifter) then tempo. Two phase-vocoder passes — the
376 // honest cost of keeping formants fixed while both R and S change with
377 // the current per-mode engine API (a true single-pass R+P needs the
378 // engine-internal combination).
379 std::vector<std::vector<SampleType>> inter(static_cast<size_t>(ch),
380 std::vector<SampleType>(static_cast<size_t>(in_frames)));
381 std::vector<SampleType*> pp(static_cast<size_t>(ch));
383 if (!pitch_shift(in, in_frames, pp.data(), in_frames,
385 return fail(err, "pitch stage failed");
386 std::vector<const SampleType*> cp(static_cast<size_t>(ch));
388 if (!tempo_stretch(cp.data(), in_frames, out, out_frames, opts.time_ratio)) return false;
389 match_spectral_rms(in, in_frames, out, out_frames);
390 return true;
391 }
392
393private:
394 // Condition the spectral output: (1) make up the phase-vocoder energy loss and
395 // (2) hard-guard the peak so the engine never emits a sample past full scale.
396 //
397 // (1) The WOLA normalization is unity for COHERENT overlap-add (a pure tone
398 // reconstructs at full level — proven by the spectral-engine tests), but a
399 // time-stretch overlaps phase-propagated frames of BROADBAND material partially
400 // INcoherently, so the reconstructed level sits ~3-4 dB below the source. Match
401 // the output's interior RMS back to the input with a single make-up gain
402 // (make-up only — never attenuates the body), pre-capped so the make-up alone
403 // can't push the current peak past full scale.
404 //
405 // (2) A final hard peak-limit catches ANY residual overshoot in the buffer —
406 // notably the verbatim-relocation graft, which re-injects an original attack
407 // additively and can sum a transient above full scale on its own. Guarantees
408 // |out| <= kCeiling for every downstream consumer (the engine output is clean
409 // even without a host limiter). Near-identity on coherent/tonal material; the
410 // ratio==1 identity path never reaches here.
411 SampleType peak_abs(const SampleType* const* b, long n) const noexcept {
412 SampleType peak = SampleType{0};
413 for (int c = 0; c < channels_; ++c)
414 for (long i = 0; i < n; ++i) peak = std::max(peak, std::fabs(b[c][i]));
415 return peak;
416 }
417 void match_spectral_rms(const SampleType* const* in, long in_frames,
418 SampleType* const* out, long out_frames) const noexcept {
419 // Guard the make-up window past the onset-head graft as well as the edges.
420 // restore_onset_head (tempo path) replaces up to ~fft/2 leading samples; for
421 // SHORT outputs that graft reaches past n/8 into the interior window and skews
422 // the gain. Floor the edge guard at the head span so the make-up is measured
423 // over the steady body only. Harmless on the no-graft paths (a larger edge
424 // guard). fft_size_<=0 means the engine default (4096).
425 const long fft = engine_.fft_size() > 0 ? engine_.fft_size() : 4096;
426 const long head_guard = std::max<long>(
427 static_cast<long>(std::llround(kHeadMs * sample_rate_)), fft / 2);
428 auto interior_rms = [this, head_guard](const SampleType* const* b, long n) {
429 const long lo = std::min<long>(std::max<long>(n / 8, head_guard), 4096), hi = n - lo;
430 if (hi <= lo) return 0.0;
431 double s = 0.0; long cnt = 0;
432 for (int c = 0; c < channels_; ++c)
433 for (long i = lo; i < hi; ++i) { const double v = b[c][i]; s += v * v; ++cnt; }
434 return cnt > 0 ? std::sqrt(s / static_cast<double>(cnt)) : 0.0;
435 };
436 // (1) RMS make-up. The soft-clip below bounds peaks, so the make-up is the
437 // full RMS target (no whole-buffer peak cap, which would attenuate the body
438 // and undo the make-up whenever a single transient overshoots).
439 const double ri = interior_rms(in, in_frames);
440 const double ro = interior_rms(out, out_frames);
441 if (ri > 1e-9 && ro > 1e-9) {
442 const double g = ri / ro;
443 if (g > 1.0 + 1e-4) {
444 const SampleType gf = static_cast<SampleType>(g);
445 for (int c = 0; c < channels_; ++c)
446 for (long i = 0; i < out_frames; ++i) out[c][i] *= gf;
447 }
448 }
449 // (2) Soft-clip every sample: transparent below the knee, smoothly bounded to
450 // +/-kCeiling above it. Tames the make-up peaks AND the verbatim-graft
451 // overshoot (an attack re-injected additively can exceed full scale) WITHOUT
452 // scaling the whole buffer down — so the RMS make-up survives and only the
453 // few overshooting transient samples are rounded.
454 if (peak_abs(out, out_frames) > kKnee) {
455 for (int c = 0; c < channels_; ++c)
456 for (long i = 0; i < out_frames; ++i) out[c][i] = soft_clip(out[c][i]);
457 }
458 }
459
460 // Transparent below +/-kKnee, smooth tanh shoulder to a hard +/-kCeiling ceiling.
461 static constexpr SampleType kKnee = static_cast<SampleType>(0.9);
462 static constexpr SampleType kCeiling = static_cast<SampleType>(0.999);
463 static SampleType soft_clip(SampleType x) noexcept {
464 const SampleType a = std::fabs(x);
465 if (a <= kKnee) return x;
466 const SampleType s = x < SampleType{0} ? SampleType{-1} : SampleType{1};
467 return s * (kKnee + (kCeiling - kKnee) * std::tanh((a - kKnee) / (kCeiling - kKnee)));
468 }
469
470 // 6-point Blackman-Harris windowed-sinc read of `x` at fractional position
471 // `pos`; out-of-range taps read as silence (edge zero-pad). Exact identity
472 // when pos is integral.
473 static SampleType sample_sinc6(const SampleType* x, long n, double pos) {
474 const long i0 = static_cast<long>(std::floor(pos));
475 const SampleType frac = static_cast<SampleType>(pos - static_cast<double>(i0));
476 auto at = [&](long k) -> SampleType { return (k >= 0 && k < n) ? x[k] : SampleType{0}; };
477 return Interpolator::sinc6(frac, at(i0 - 2), at(i0 - 1), at(i0),
478 at(i0 + 1), at(i0 + 2), at(i0 + 3));
479 }
480
481 // Varispeed (tape) render: pitch+time LINKED. Output sample i reads input
482 // position i/ratio at a constant rate (a pure mastering-grade resample — no
483 // phase-vocoder artifacts), then a speed-scaled head EQ colors it like a tape
484 // machine at that speed. Slowing down (ratio>1) loses highs and gains low-mid
485 // warmth; speeding up (ratio<1) brightens and thins. Exact identity at ratio 1.
486 bool varispeed_render(const SampleType* const* in, long in_frames,
487 SampleType* const* out, long out_frames, double ratio) {
488 for (int c = 0; c < channels_; ++c)
489 for (long i = 0; i < out_frames; ++i)
490 out[c][i] = sample_sinc6(in[c], in_frames,
491 static_cast<double>(i) / ratio);
492 apply_head_eq(out, out_frames, ratio);
493 // Mandatory short end-fade (tape doesn't hard-cut). A bare resample of a
494 // source that ends mid-ring leaves an audible tick; a ~10 ms raised-cosine
495 // tail (+ 2 ms head) removes it and suits the tape character. Skipped at
496 // ratio 1 to keep varispeed an exact identity there.
497 if (ratio != 1.0) {
498 const double sr = sample_rate_ > 0 ? sample_rate_ : 48000.0;
499 const long fo = std::min<long>(static_cast<long>(0.010 * sr), out_frames / 4);
500 const long fi = std::min<long>(static_cast<long>(0.002 * sr), out_frames / 4);
501 constexpr double kPi = 3.14159265358979323846;
502 for (int c = 0; c < channels_; ++c) {
503 for (long i = 0; i < fi; ++i)
504 out[c][i] *= static_cast<SampleType>(0.5 - 0.5 * std::cos(kPi * i / fi));
505 for (long i = 0; i < fo; ++i)
506 out[c][out_frames-1-i] *= static_cast<SampleType>(0.5 - 0.5 * std::cos(kPi * i / fo));
507 }
508 }
509 return true;
510 }
511
512 // Speed-scaled tape head EQ: a low-mid head bump (warmth) + a head-gap HF
513 // shelf (dulling), both scaled by log2(ratio) so the color tracks the speed.
514 // At ratio 1 every gain is 0 dB → exact bypass (varispeed stays a clean
515 // identity at unity). Two cascaded RBJ biquads per channel, double state.
516 void apply_head_eq(SampleType* const* out, long n, double ratio) {
517 if (ratio == 1.0 || n <= 0) return; // unity = transparent
518 const double oct = std::log2(ratio); // +1 per octave slower
519 const double sr = sample_rate_ > 0 ? sample_rate_ : 48000.0;
520 // Low-mid head bump (peaking) and head-gap HF loss (high shelf), clamped.
521 const double low_db = std::clamp(2.0 * oct, -3.0, 5.0); // warmth when slow
522 const double hi_db = std::clamp(-3.5 * oct, -10.0, 6.0); // dull when slow
523 Biquad bump = Biquad::peaking(140.0, 0.8, low_db, sr);
524 Biquad shelf = Biquad::high_shelf(4000.0, hi_db, sr);
525 for (int c = 0; c < channels_; ++c) {
526 bump.reset(); shelf.reset();
527 for (long i = 0; i < n; ++i) {
528 double s = static_cast<double>(out[c][i]);
529 s = bump.process(s);
530 s = shelf.process(s);
531 out[c][i] = static_cast<SampleType>(s);
532 }
533 }
534 }
535
536 // Minimal RBJ biquad (double precision) for the head EQ. Direct-form I.
537 struct Biquad {
538 double b0 = 1, b1 = 0, b2 = 0, a1 = 0, a2 = 0;
539 double x1 = 0, x2 = 0, y1 = 0, y2 = 0;
540 void reset() { x1 = x2 = y1 = y2 = 0; }
541 double process(double x) {
542 const double y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
543 x2 = x1; x1 = x; y2 = y1; y1 = y; return y;
544 }
545 static Biquad from(double b0, double b1, double b2, double a0, double a1, double a2) {
546 Biquad q; q.b0 = b0 / a0; q.b1 = b1 / a0; q.b2 = b2 / a0;
547 q.a1 = a1 / a0; q.a2 = a2 / a0; return q;
548 }
549 static Biquad peaking(double f0, double q, double gain_db, double sr) {
550 const double A = std::pow(10.0, gain_db / 40.0);
551 const double w0 = 2.0 * 3.14159265358979323846 * f0 / sr;
552 const double cw = std::cos(w0), sw = std::sin(w0), alpha = sw / (2.0 * q);
553 return from(1 + alpha * A, -2 * cw, 1 - alpha * A,
554 1 + alpha / A, -2 * cw, 1 - alpha / A);
555 }
556 static Biquad high_shelf(double f0, double gain_db, double sr) {
557 const double A = std::pow(10.0, gain_db / 40.0);
558 const double w0 = 2.0 * 3.14159265358979323846 * f0 / sr;
559 const double cw = std::cos(w0), sw = std::sin(w0);
560 const double alpha = sw / 2.0 * std::sqrt((A + 1 / A) * (1 / 0.9 - 1) + 2);
561 const double tsa = 2.0 * std::sqrt(A) * alpha;
562 return from(A * ((A + 1) + (A - 1) * cw + tsa),
563 -2 * A * ((A - 1) + (A + 1) * cw),
564 A * ((A + 1) + (A - 1) * cw - tsa),
565 (A + 1) - (A - 1) * cw + tsa,
566 2 * ((A - 1) - (A + 1) * cw),
567 (A + 1) - (A - 1) * cw - tsa);
568 }
569 };
570
571 // Measure the engine's input-domain time-stretch anchor L: an impulse at P
572 // synthesizes its peak at (P - L)*r + L, so L = (P*r - peak)/(r - 1). Done
573 // once at prepare() so alignment is correct regardless of quality geometry
574 // (fft/hop), instead of hardcoding a magic latency constant.
575 double calibrate_anchor() {
576 const int ch = channels_;
577 const int blk = kBlock;
578 const int fft = engine_.fft_size();
579 const float r = 2.0f;
580 const long P = static_cast<long>(fft) * 2;
581 const long N = P + static_cast<long>(fft) * 6;
582 engine_.reset();
583 engine_.set_time_ratio(r);
584
585 std::vector<std::vector<SampleType>> in(static_cast<size_t>(ch),
586 std::vector<SampleType>(static_cast<size_t>(N), SampleType{0}));
587 std::vector<std::vector<SampleType>> sil(static_cast<size_t>(ch),
588 std::vector<SampleType>(static_cast<size_t>(blk), SampleType{0}));
589 std::vector<std::vector<SampleType>> ob(static_cast<size_t>(ch),
590 std::vector<SampleType>(static_cast<size_t>(blk)));
591 for (int c = 0; c < ch; ++c) in[static_cast<size_t>(c)][static_cast<size_t>(P)] = SampleType{1};
592 std::vector<const SampleType*> ip(static_cast<size_t>(ch));
593 std::vector<const SampleType*> sp(static_cast<size_t>(ch));
594 std::vector<SampleType*> op(static_cast<size_t>(ch));
595 for (int c = 0; c < ch; ++c) { sp[c] = sil[c].data(); op[c] = ob[c].data(); }
596
597 std::vector<SampleType> acc; // channel 0 only
598 auto drain = [&]() {
599 while (engine_.available_stretched() > 0) {
600 const int t = std::min(engine_.available_stretched(), blk);
601 engine_.read_stretched(op.data(), t);
602 for (int i = 0; i < t; ++i) acc.push_back(ob[0][static_cast<size_t>(i)]);
603 }
604 };
605 for (long i = 0; i < N; i += blk) {
606 for (int c = 0; c < ch; ++c) ip[c] = in[static_cast<size_t>(c)].data() + i;
607 engine_.feed(ip.data(), static_cast<int>(std::min<long>(blk, N - i)));
608 drain();
609 }
610 for (int k = 0; k < 8; ++k) { engine_.feed(sp.data(), blk); drain(); }
611
612 long pk = 0;
613 SampleType mx = SampleType{0};
614 for (long i = 0; i < static_cast<long>(acc.size()); ++i)
615 if (std::fabs(acc[static_cast<size_t>(i)]) > mx) { mx = std::fabs(acc[static_cast<size_t>(i)]); pk = i; }
616 engine_.reset();
617 if (mx <= SampleType{0}) return 0.0; // degenerate; no anchor shift
618 return (static_cast<double>(P) * r - static_cast<double>(pk)) / (static_cast<double>(r) - 1.0);
619 }
620
621 // Fast draft (quality 0): plain Hann-windowed overlap-add tempo change.
622 // Phase-incoherent (some smear) but quick and inoffensive — the sampler
623 // shows this instantly on a tempo change and swaps in the full-quality
624 // render when ready. 50%-overlap Hann is COLA (interior sums to 1), so no
625 // renormalization; exact out_frames by construction; pitch preserved.
626 void ola_tempo(const SampleType* const* in, long in_frames,
627 SampleType* const* out, long out_frames, double ratio) {
628 const int ch = channels_;
629 constexpr int W = 1024;
630 constexpr int Hs = W / 2;
631 constexpr double kTwoPi = 6.28318530717958647692;
632 const double Ha = static_cast<double>(Hs) / ratio;
633 for (int c = 0; c < ch; ++c)
634 std::fill(out[c], out[c] + out_frames, SampleType{0});
635 for (long k = 0;; ++k) {
636 const long sp = k * Hs;
637 if (sp >= out_frames) break;
638 const long ap = static_cast<long>(std::llround(static_cast<double>(k) * Ha));
639 for (int j = 0; j < W; ++j) {
640 const long so = sp + j;
641 if (so >= out_frames) break;
642 const long ii = ap + j;
643 if (ii < 0 || ii >= in_frames) continue;
644 const SampleType wnd = static_cast<SampleType>(
645 0.5 * (1.0 - std::cos(kTwoPi * static_cast<double>(j)
646 / static_cast<double>(W))));
647 for (int c = 0; c < ch; ++c) out[c][so] += in[c][ii] * wnd;
648 }
649 }
650 }
651
652 // Whole-file tempo-only stretch via the time_stretch engine. Front-pads with
653 // silence so the real content sees full WOLA overlap, drains the stretched
654 // stream, trims the stretched pad (content alignment), then copies EXACTLY
655 // out_frames from the aligned start (a continuous stream => clean truncation,
656 // exact length, no boundary click). Offline: allocates scratch per call. The
657 // residual sub-hop coverage error vs a true distributed-hop scheduler is a
658 // documented limitation of the realtime-core route.
659 bool tempo_stretch(const SampleType* const* in, long in_frames,
660 SampleType* const* out, long out_frames, double ratio) {
661 const int ch = channels_;
662 const int blk = kBlock;
663 const int fft = engine_.fft_size();
664 engine_.reset();
665 engine_.set_time_ratio(static_cast<float>(ratio));
666
667 const long pad = static_cast<long>(std::ceil(static_cast<double>(fft) / ratio)) + fft;
668 // Output index j must read real-input position j/ratio. The engine maps
669 // absolute input X to stretched position (X - L)*ratio + L, where L is
670 // the calibrated anchor (calibrate_anchor()); trimming this many leading
671 // samples aligns real-input 0 to output 0 at every ratio.
672 const long lead = static_cast<long>(std::llround(
673 (static_cast<double>(pad) - latency_anchor_) * ratio + latency_anchor_));
674
675 std::vector<std::vector<SampleType>> accum(static_cast<size_t>(ch));
676 std::vector<std::vector<SampleType>> outblk(static_cast<size_t>(ch),
677 std::vector<SampleType>(static_cast<size_t>(blk)));
678 std::vector<std::vector<SampleType>> silblk(static_cast<size_t>(ch),
679 std::vector<SampleType>(static_cast<size_t>(blk), SampleType{0}));
680 std::vector<const SampleType*> inp(static_cast<size_t>(ch));
681 std::vector<const SampleType*> silp(static_cast<size_t>(ch));
682 std::vector<SampleType*> outp(static_cast<size_t>(ch));
683 for (int c = 0; c < ch; ++c) { silp[c] = silblk[c].data(); outp[c] = outblk[c].data(); }
684
685 auto drain = [&]() {
686 while (engine_.available_stretched() > 0) {
687 const int take = std::min(engine_.available_stretched(), blk);
688 engine_.read_stretched(outp.data(), take);
689 for (int c = 0; c < ch; ++c)
690 accum[static_cast<size_t>(c)].insert(
691 accum[static_cast<size_t>(c)].end(),
692 outblk[static_cast<size_t>(c)].begin(),
693 outblk[static_cast<size_t>(c)].begin() + take);
694 }
695 };
696 auto feed = [&](const SampleType* const* src, int n) { engine_.feed(src, n); drain(); };
697
698 for (long p = 0; p < pad; p += blk)
699 feed(silp.data(), static_cast<int>(std::min<long>(blk, pad - p)));
700 for (long i = 0; i < in_frames; i += blk) {
701 for (int c = 0; c < ch; ++c) inp[c] = in[c] + i;
702 feed(inp.data(), static_cast<int>(std::min<long>(blk, in_frames - i)));
703 }
704 const long need = lead + out_frames + fft;
705 const long cap = 2 * (in_frames + pad) + 64L * blk;
706 for (long g = 0; static_cast<long>(accum[0].size()) < need && g < cap; g += blk)
707 feed(silp.data(), blk);
708
709 for (int c = 0; c < ch; ++c)
710 for (long i = 0; i < out_frames; ++i) {
711 const long idx = lead + i;
712 out[c][i] = (idx >= 0 && idx < static_cast<long>(accum[static_cast<size_t>(c)].size()))
713 ? accum[static_cast<size_t>(c)][static_cast<size_t>(idx)]
714 : SampleType{0};
715 }
716 return true;
717 }
718
719 // Self-contained spectral-flux onset detector over the channel-summed signal.
720 // Signal-layer safe (core/signal must NOT depend on core/audio's OnsetDetector).
721 // Returns input sample indices of detected transient attacks, ascending.
722 std::vector<long> detect_onsets(const SampleType* const* in, long n) const {
723 std::vector<long> onsets;
724 if (n < kOnsetWin) return onsets;
725 const long hop = kOnsetHop;
726 auto win_energy = [&](long s) {
727 double e = 0.0;
728 const long end = std::min<long>(s + kOnsetWin, n);
729 for (long i = s; i < end; ++i)
730 for (int c = 0; c < channels_; ++c) {
731 const SampleType v = in[c][i];
732 e += static_cast<double>(v) * static_cast<double>(v);
733 }
734 return e;
735 };
736 std::vector<double> flux;
737 flux.reserve(static_cast<size_t>(n / hop + 1));
738 double prev = win_energy(0);
739 for (long s = hop; s + kOnsetWin <= n; s += hop) {
740 const double e = win_energy(s);
741 flux.push_back(std::max(0.0, e - prev)); // half-wave-rectified energy rise
742 prev = e;
743 }
744 if (flux.empty()) return onsets;
745 double mean = 0.0; for (double f : flux) mean += f; mean /= static_cast<double>(flux.size());
746 const double thresh = mean * kOnsetThreshMult;
747 long last = -kOnsetMinGap;
748 for (size_t i = 0; i < flux.size(); ++i) {
749 const bool peak = (i == 0 || flux[i] >= flux[i - 1]) &&
750 (i + 1 >= flux.size() || flux[i] >= flux[i + 1]);
751 const long sample = static_cast<long>(i + 1) * hop; // s started at hop
752 if (flux[i] > thresh && peak && sample - last > kOnsetMinGap) {
753 onsets.push_back(sample);
754 last = sample;
755 }
756 }
757 return onsets;
758 }
759
760 // Channel-summed |x| at a single sample (transient-peak locator helper).
761 double abs_sum(const SampleType* const* x, long i, long n) const {
762 if (i < 0 || i >= n) return 0.0;
763 double a = 0.0;
765 return a;
766 }
767
768 // Verbatim transient relocation (StretchTransientMode::verbatim_relocate):
769 // graft each ORIGINAL transient attack back onto the phase-vocoder output to
770 // restore the sharp peak the PV smears (the "compressed / less dynamic"
771 // artifact). The output transient does NOT sit at oi*ratio — tempo_stretch's
772 // lead/anchor trim leaves a ratio-dependent group-delay offset — so we locate
773 // the PV's actual smeared peak by searching |out| in a window around the
774 // nominal position, then PEAK-ALIGN the original attack onto it. Equal-power
775 // edge crossfades keep the seam click-free; only the short attack is replaced,
776 // so the stretched sustain (and exact out_frames length) is intact.
777 // RBJ 2nd-order high-pass (one channel) into dst — same biquad family as the
778 // recommend_window low-band probe, complementary coefficients.
779 static void rbj_highpass(const SampleType* src, SampleType* dst, long n, double sr, double f0) {
780 const double q = 0.7071;
781 const double w0 = 2.0 * 3.14159265358979323846 * f0 / sr;
782 const double cw = std::cos(w0), sw = std::sin(w0), alpha = sw / (2.0 * q);
783 const double b0 = (1.0 + cw) * 0.5, b1 = -(1.0 + cw), b2 = (1.0 + cw) * 0.5;
784 const double a0 = 1.0 + alpha, a1 = -2.0 * cw, a2 = 1.0 - alpha;
785 const double nb0 = b0 / a0, nb1 = b1 / a0, nb2 = b2 / a0, na1 = a1 / a0, na2 = a2 / a0;
786 double z1 = 0.0, z2 = 0.0;
787 for (long i = 0; i < n; ++i) {
788 const double in_s = src[static_cast<size_t>(i)];
789 const double out_s = nb0 * in_s + z1;
790 z1 = nb1 * in_s - na1 * out_s + z2;
791 z2 = nb2 * in_s - na2 * out_s;
792 dst[static_cast<size_t>(i)] = static_cast<SampleType>(out_s);
793 }
794 }
795
796 // Restore the attack at the very start of the render. The phase vocoder
797 // reconstructs a hard onset at sample 0 from an EMPTY analysis history — the
798 // Hann analysis window is ~0 at its edge, so the first frames weight the onset
799 // near-zero and the attack comes up as a RAMP over ~fft/2 samples (~10 ms at the
800 // percussive window). The length-lock trim aligns input[0] to output[0] but does
801 // not remove the ramp, so the head is too quiet. relocate_transients can't fix
802 // it: detect_onsets needs a flux RISE and a sample-0 attack is already inside its
803 // first window (no rise), so the boundary onset is structurally missed.
804 // Graft the input's leading attack over the head with an equal-power crossfade
805 // back to the PV body (input[0] is the true first sample, so no peak search is
806 // needed — unlike the interior graft). No-op when the input head is silent (a
807 // real fade-in) or the PV did not actually lose the attack. Runs BEFORE
808 // match_spectral_rms (whose interior-RMS window excludes the head, and whose
809 // soft-clip still bounds any grafted peak).
810 void restore_onset_head(const SampleType* const* in, long in_frames,
811 SampleType* const* out, long out_frames) const noexcept {
812 // The PV ramp the graft has to cover is ~fft/2 samples, which is only ~10 ms
813 // at the percussive window (1024) but grows with the window — ~42 ms at the
814 // 4096 default, ~85 ms at 8192 for sustained/tonal material. A fixed 10 ms
815 // head hands the crossfade back to a PV that is still ramping for the larger
816 // windows, leaving an envelope dip between the head and the PV body. Scale
817 // the head to the actual ramp (floor it at ~10 ms) so the crossfade always
818 // reaches a fully-recovered PV. fft_size_<=0 means the engine default (4096).
819 // head_span is hoisted to long first to avoid an initializer-list narrowing
820 // conversion from std::llround's long long (MSVC release build).
821 const long fft = engine_.fft_size() > 0 ? engine_.fft_size() : 4096;
822 const long ramp = std::max<long>(1, fft / 2);
823 const long head_span = std::max<long>(static_cast<long>(std::llround(kHeadMs * sample_rate_)), ramp);
824 const long head = std::min<long>({out_frames, in_frames, head_span});
825 if (head < 2) return;
826 SampleType in_peak = SampleType{0};
827 SampleType out_peak = SampleType{0};
828 for (int c = 0; c < channels_; ++c)
829 for (long i = 0; i < head; ++i) {
830 in_peak = std::max(in_peak, std::fabs(in[c][static_cast<size_t>(i)]));
831 out_peak = std::max(out_peak, std::fabs(out[c][static_cast<size_t>(i)]));
832 }
833 if (in_peak <= kHeadEps) return; // input head silent (fade-in) -> preserve
834 if (in_peak <= out_peak * kHeadRatio) return; // PV didn't lose the attack -> no-op
835 constexpr double kHalfPi = 1.5707963267948966;
836 for (long i = 0; i < head; ++i) {
837 const double cw = std::cos(kHalfPi * static_cast<double>(i) / static_cast<double>(head));
838 const SampleType w = static_cast<SampleType>(cw * cw);
839 for (int c = 0; c < channels_; ++c)
840 out[c][static_cast<size_t>(i)] =
841 w * in[c][static_cast<size_t>(i)]
842 + (SampleType{1} - w) * out[c][static_cast<size_t>(i)];
843 }
844 }
845
846 void relocate_transients(const SampleType* const* in, long in_frames,
847 SampleType* const* out, long out_frames, double ratio) const {
848 if (in_frames <= 0 || out_frames <= 0) return;
849 const long W = kReloAttack; // grafted half-window each side of the peak
850 const long xf = std::min<long>(kReloXfade, W);
851 // Graft only the HIGH band of each attack. The phase vocoder smears the
852 // high-frequency transient (the click/crack) but reconstructs sustained LOW
853 // frequencies cleanly and CONTINUOUSLY. Grafting the full-band original
854 // re-injects low-frequency energy whose phase cannot match the PV body
855 // across the short (~1 ms) seam — and a deep kick's fundamental period
856 // (~15 ms) is longer than the whole graft window, so the seam can't bridge
857 // it. That low-frequency discontinuity is the "blown-out deep hit" at
858 // stretch. High-passing both sides and swapping only the high band leaves
859 // the kick body entirely to the continuous PV low end (no seam there) while
860 // still restoring the attack's high-frequency punch.
861 std::vector<std::vector<SampleType>> hp_in(static_cast<size_t>(channels_));
862 std::vector<std::vector<SampleType>> hp_out(static_cast<size_t>(channels_));
863 for (int c = 0; c < channels_; ++c) {
864 hp_in[static_cast<size_t>(c)].resize(static_cast<size_t>(in_frames));
865 hp_out[static_cast<size_t>(c)].resize(static_cast<size_t>(out_frames));
866 rbj_highpass(in[c], hp_in[static_cast<size_t>(c)].data(), in_frames, sample_rate_, kReloHpHz);
867 rbj_highpass(out[c], hp_out[static_cast<size_t>(c)].data(), out_frames, sample_rate_, kReloHpHz);
868 }
869 // Search +/- a fraction of the STRETCHED onset spacing for the output
870 // transient peak: wide enough to cover the PV's ratio-dependent latency
871 // offset, but never so wide it locks onto a neighboring transient (which
872 // would happen with a fixed window at high compression, ratio << 1).
873 const long search = std::clamp<long>(
874 std::llround(static_cast<double>(kOnsetMinGap) * ratio * 0.4), 128L, kReloSearch);
875 for (long oi : detect_onsets(in, in_frames)) {
876 // Input attack peak near the onset. The energy-window detector returns
877 // the window START, which LEADS the peak by ~kOnsetWin, so search
878 // FORWARD from oi (plus a small look-back for jitter).
879 long ip = oi; double ipk = -1.0;
880 for (long j = std::max(0L, oi - kReloBack); j < std::min(in_frames, oi + kOnsetWin + W); ++j) {
881 const double a = abs_sum(in, j, in_frames);
882 if (a > ipk) { ipk = a; ip = j; }
883 }
884 if (ipk <= 0.0) continue;
885 // Output transient peak: search |out| around the nominal stretched pos.
886 const long op0 = std::llround(static_cast<double>(oi) * ratio);
887 long op = op0; double opk = -1.0;
888 for (long p = std::max(0L, op0 - search); p <= std::min(out_frames - 1, op0 + search); ++p) {
889 const double a = abs_sum(out, p, out_frames);
890 if (a > opk) { opk = a; op = p; }
891 }
892 // Graft the original attack, peak-aligned (in[ip] -> out[op]), with a
893 // linear crossfade ramping back to the PV output at both edges.
894 for (long k = -W; k <= W; ++k) {
895 const long si = ip + k, di = op + k;
896 if (si < 0 || si >= in_frames || di < 0 || di >= out_frames) continue;
897 const long ak = std::llabs(k);
898 SampleType w = SampleType{1}; // weight for the ORIGINAL high band
899 if (ak > W - xf)
900 w = static_cast<SampleType>(W - ak) / static_cast<SampleType>(xf);
901 // Swap the high band only: out += w*(hp_in - hp_out) makes the center
902 // (w=1) carry the PV low band + the original high band, with the seam
903 // crossfade acting only on the high band (short periods -> clean).
904 for (int c = 0; c < channels_; ++c)
905 out[c][di] += w * (hp_in[static_cast<size_t>(c)][static_cast<size_t>(si)] -
906 hp_out[static_cast<size_t>(c)][static_cast<size_t>(di)]);
907 }
908 }
909 }
910
911 // Duration-preserving pitch shift via the realtime_pitch engine. Pitch and
912 // formant targets are set BEFORE reset() so the control smoothers latch
913 // immediately (no 30 ms pitch ramp at the start). process() is equal-length
914 // with a fixed leading latency, so feed input + latency silence and trim the
915 // leading latency to recover exactly out_frames (== in_frames) aligned samples.
916 bool pitch_shift(const SampleType* const* in, long in_frames,
917 SampleType* const* out, long out_frames,
918 double semitones, OfflineFormantMode fmode, double fsemis) {
919 const int ch = channels_;
920 const int blk = kBlock;
921 pitch_engine_.set_pitch_semitones(static_cast<float>(semitones));
922 switch (fmode) {
924 pitch_engine_.set_formant_mode(FormantMode::follow);
925 pitch_engine_.set_formant_semitones(0.0f); break;
927 pitch_engine_.set_formant_mode(FormantMode::preserve);
928 pitch_engine_.set_formant_semitones(0.0f); break;
930 pitch_engine_.set_formant_mode(FormantMode::preserve);
931 pitch_engine_.set_formant_semitones(static_cast<float>(fsemis)); break;
932 }
933 pitch_engine_.reset(); // latches the pitch/formant smoothers to the targets above
934
935 const long lat = pitch_engine_.latency_samples();
936 std::vector<std::vector<SampleType>> accum(static_cast<size_t>(ch));
937 std::vector<std::vector<SampleType>> ob(static_cast<size_t>(ch),
938 std::vector<SampleType>(static_cast<size_t>(blk)));
939 std::vector<std::vector<SampleType>> sil(static_cast<size_t>(ch),
940 std::vector<SampleType>(static_cast<size_t>(blk), SampleType{0}));
941 std::vector<const SampleType*> ip(static_cast<size_t>(ch));
942 std::vector<const SampleType*> sp(static_cast<size_t>(ch));
943 std::vector<SampleType*> op(static_cast<size_t>(ch));
944 for (int c = 0; c < ch; ++c) { sp[c] = sil[c].data(); op[c] = ob[c].data(); }
945
946 auto pump = [&](const SampleType* const* src, int nn) {
947 pitch_engine_.process(src, op.data(), nn);
948 for (int c = 0; c < ch; ++c)
949 accum[static_cast<size_t>(c)].insert(
950 accum[static_cast<size_t>(c)].end(),
951 ob[static_cast<size_t>(c)].begin(),
952 ob[static_cast<size_t>(c)].begin() + nn);
953 };
954 for (long i = 0; i < in_frames; i += blk) {
955 for (int c = 0; c < ch; ++c) ip[c] = in[c] + i;
956 pump(ip.data(), static_cast<int>(std::min<long>(blk, in_frames - i)));
957 }
958 for (long f = 0; f < lat; f += blk)
959 pump(sp.data(), static_cast<int>(std::min<long>(blk, lat - f)));
960
961 for (int c = 0; c < ch; ++c)
962 for (long i = 0; i < out_frames; ++i) {
963 const long idx = lat + i;
964 out[c][i] = (idx >= 0 && idx < static_cast<long>(accum[static_cast<size_t>(c)].size()))
965 ? accum[static_cast<size_t>(c)][static_cast<size_t>(idx)]
966 : SampleType{0};
967 }
968 return true;
969 }
970
971 static bool fail(std::string* err, const char* msg) {
972 if (err) *err = msg;
973 return false;
974 }
975
976 static constexpr int kBlock = 4096;
977 // Verbatim transient relocation tuning (sample counts target ~44.1-48 kHz;
978 // attack/crossfade are short enough to be SR-robust without rescaling).
979 static constexpr long kOnsetWin = 512; // energy window
980 static constexpr long kOnsetHop = 128; // onset analysis hop
981 static constexpr double kOnsetThreshMult = 4.0; // flux > mean*this = onset
982 static constexpr long kOnsetMinGap = 1920; // >= ~40 ms between onsets
983 static constexpr long kReloAttack = 256; // ~5 ms grafted half-window
984 static constexpr long kReloXfade = 64; // ~1.3 ms linear edge crossfade
985 static constexpr long kReloBack = 768; // input-peak search look-back (onset lags the peak)
986 static constexpr long kReloSearch = 1536; // +/- ~32 ms output-peak search
987 static constexpr double kHeadMs = 0.010; // onset-head graft span (~10 ms)
988 static constexpr SampleType kHeadEps = static_cast<SampleType>(1e-3); // input head silent -> no soft-start
989 static constexpr SampleType kHeadRatio = static_cast<SampleType>(1.1); // PV must have lost the attack to fire
990 static constexpr double kReloHpHz = 300.0; // graft only the high band above this (low end stays on the clean PV)
991 RealtimePitchTimeProcessorT<SampleType> engine_;
992 RealtimePitchTimeProcessorT<SampleType> pitch_engine_;
993 double latency_anchor_ = 0.0;
994 double sample_rate_ = 0.0;
995 int channels_ = 1;
996 double max_time_ratio_ = 4.0;
997 double max_pitch_semitones_ = 24.0;
998 int fft_size_ = 0;
999 bool prepared_ = false;
1000};
1001
1004
1005} // namespace pulp::signal
void reset()
Definition biquad.hpp:377
float process(float input)
Definition biquad.hpp:211
Offline stretcher.
Definition offline_stretch.hpp:137
int fft_size() const noexcept
The STFT window actually in use (0 until prepare() with an override).
Definition offline_stretch.hpp:202
double max_pitch_semitones() const noexcept
Definition offline_stretch.hpp:200
int channels() const noexcept
Definition offline_stretch.hpp:197
static Window recommend_window(const SampleType *const *in, long frames, int channels, double sample_rate)
Definition offline_stretch.hpp:217
void prepare(double sample_rate, int channels, const OfflineStretchOptions &sizing={})
Size internal state for channels at sample_rate.
Definition offline_stretch.hpp:145
double sample_rate() const noexcept
Definition offline_stretch.hpp:198
double max_time_ratio() const noexcept
Definition offline_stretch.hpp:199
bool process(const SampleType *const *in, long in_frames, SampleType *const *out, long out_frames, const OfflineStretchOptions &opts, std::string *err=nullptr)
Render the whole input into the caller-allocated output.
Definition offline_stretch.hpp:275
High-quality sample interpolation for delay lines and resampling.
@ end
Definition editor_host.hpp:354
@ x1
Definition forge_distortion_catalog.hpp:48
MidiEvent at(MidiEvent event, std::int32_t sample_offset) noexcept
Definition utility_contract.hpp:140
@ f
Definition pitch.hpp:25
@ g
Definition pitch.hpp:27
@ alpha
Straight alpha: src * a + dst * (1 - a).
Definition gpu_pass.hpp:268
constexpr double kPi
Definition primitives.hpp:38
constexpr double kTwoPi
Definition lfo_sampling.hpp:77
@ size
Definition config.hpp:216
Definition additive_bank.hpp:218
@ realtime_pitch
Definition realtime_pitch_time_geometry.hpp:26
@ time_stretch
Definition realtime_pitch_time_geometry.hpp:26
BiquadT< float > Biquad
Definition biquad.hpp:738
OfflineStretchT< double > OfflineStretch64
Definition offline_stretch.hpp:1003
@ peak
Sample peak. Literal, fastest-responding.
Definition feedforward_compressor.hpp:119
@ rms
Running mean-square. Reads program loudness.
Definition feedforward_compressor.hpp:120
@ feed
Definition finite_stretch_builder.hpp:40
@ drain
Definition finite_stretch_builder.hpp:40
@ c
Definition spectrum_trace.hpp:35
StretchCharacter
Character mode — the musically-distinct "engine per job" selector.
Definition offline_stretch.hpp:66
@ clean
Definition offline_stretch.hpp:66
@ granular
Definition offline_stretch.hpp:66
@ phase_vocoder
Definition offline_stretch.hpp:66
@ varispeed
Definition offline_stretch.hpp:66
@ clean
Lagrange-interpolated line. Linear, transparent, the default.
Definition flanger.hpp:154
@ quality
Definition realtime_pitch_time_geometry.hpp:25
@ ratio
base_frequency_hz * ratio.
Definition fm_operator_engine.hpp:20
OfflineStretchT< float > OfflineStretch
Definition offline_stretch.hpp:1002
@ a
Definition formant_filter_bank.hpp:20
@ i
Definition formant_filter_bank.hpp:20
@ e
Definition formant_filter_bank.hpp:20
@ exact
Definition fast_math.hpp:630
long offline_stretch_output_frames(long in_frames, double time_ratio) noexcept
The exact, sample-accurate output length for an input of in_frames frames at the given time ratio: ro...
Definition offline_stretch.hpp:126
StretchTransientMode
Transient strategy.
Definition offline_stretch.hpp:54
@ verbatim_relocate
Definition offline_stretch.hpp:54
@ phase_reset
Definition offline_stretch.hpp:54
OfflineFormantMode
Formant behavior for the spectral modes.
Definition offline_stretch.hpp:47
@ preserve_original
Definition offline_stretch.hpp:47
@ shift_independently
Definition offline_stretch.hpp:47
@ follow_pitch
Definition offline_stretch.hpp:47
@ follow
Definition realtime_pitch_time_geometry.hpp:27
@ preserve
Definition realtime_pitch_time_geometry.hpp:27
@ s
Definition input_events.hpp:41
@ j
Definition input_events.hpp:40
@ y
Definition input_events.hpp:42
@ r
Definition input_events.hpp:41
@ q
Definition input_events.hpp:41
@ n
Definition input_events.hpp:40
@ p
Definition input_events.hpp:40
@ k
Definition input_events.hpp:40
@ x
Definition input_events.hpp:41
@ v
Definition input_events.hpp:41
@ t
Definition input_events.hpp:41
@ w
Definition input_events.hpp:41
Realtime pitch shifting and time-scale modification for coherent channel groups, built on SpectralFra...
static SampleType sinc6(SampleType frac, SampleType ym2, SampleType ym1, SampleType y0, SampleType y1, SampleType y2, SampleType y3)
Windowed-sinc interpolation (6-point).
Definition interpolator.hpp:87
Whole-input render options.
Definition offline_stretch.hpp:69
int fft_size
0 = default (4096); else power-of-two ≥ 256
Definition offline_stretch.hpp:111
double max_time_ratio
supported stretch span is [1/max, max]
Definition offline_stretch.hpp:101
bool route_noise_stn
route noise/residual through NoiseMorpher
Definition offline_stretch.hpp:91
double max_pitch_semitones
Definition offline_stretch.hpp:102
double pitch_semitones
fractional allowed
Definition offline_stretch.hpp:78
StretchTransientMode transient_mode
Definition offline_stretch.hpp:92
StretchCharacter character
Character mode (see StretchCharacter).
Definition offline_stretch.hpp:72
float transient_sensitivity
Definition offline_stretch.hpp:118
bool relocate_transients
Graft verbatim transients onto the stretched output for punch (restores the attack peak the phase voc...
Definition offline_stretch.hpp:76
bool repitch_linked
true => pure resample (vinyl); pitch follows time_ratio, spectral path skipped
Definition offline_stretch.hpp:81
OfflineFormantMode formant_mode
Definition offline_stretch.hpp:79
double time_ratio
output duration / input duration
Definition offline_stretch.hpp:77
int analysis_hop
0 = default (512); must divide fft_size
Definition offline_stretch.hpp:112
int quality
<=0 draft preview; >=1 full spectral render
Definition offline_stretch.hpp:93
double formant_semitones
used only when formant_mode==shift_independently
Definition offline_stretch.hpp:80
Material-adaptive window/overlap recommendation.
Definition offline_stretch.hpp:216
int fft_size
Definition offline_stretch.hpp:216
int analysis_hop
Definition offline_stretch.hpp:216
Definition realtime_pitch_time_geometry.hpp:61
bool transient_preservation
Definition realtime_pitch_time_geometry.hpp:71
FormantMode formant_mode
Definition realtime_pitch_time_geometry.hpp:69
float max_pitch_semitones
Definition realtime_pitch_time_geometry.hpp:66
int max_block
Definition realtime_pitch_time_geometry.hpp:65
int fft_size
Definition realtime_pitch_time_geometry.hpp:75
int channels
Definition realtime_pitch_time_geometry.hpp:64
float max_time_ratio
Definition realtime_pitch_time_geometry.hpp:67
int analysis_hop
Definition realtime_pitch_time_geometry.hpp:76
int true_envelope_iterations
Definition realtime_pitch_time_geometry.hpp:70
float transient_sensitivity
Definition realtime_pitch_time_geometry.hpp:72
PitchTimeQuality quality
Definition realtime_pitch_time_geometry.hpp:63
PitchTimeMode mode
Definition realtime_pitch_time_geometry.hpp:62
bool noise_morphing
Definition realtime_pitch_time_geometry.hpp:73