Fixed-cycle splice time-stretch — the S-series sampler algorithm whose ARTEFACTS ARE THE PRODUCT. More...
#include <pulp/signal/crossfade.hpp>#include <pulp/signal/denormal.hpp>#include <pulp/signal/units.hpp>#include <algorithm>#include <cmath>#include <cstddef>#include <cstdint>#include <vector>Go to the source code of this file.
Classes | |
| struct | pulp::signal::CyclicStretchRegime |
| One of the two documented sonic regimes. More... | |
| class | pulp::signal::CyclicStretchT< SampleType > |
| The fixed-cycle splice stretcher. More... | |
Namespaces | |
| namespace | pulp |
| namespace | pulp::signal |
Typedefs | |
| using | pulp::signal::CyclicStretch = CyclicStretchT<float> |
| using | pulp::signal::CyclicStretch64 = CyclicStretchT<double> |
Functions | |
| long long | pulp::signal::round_half_even (double x) noexcept |
| Round half to even, in integer logic. | |
Variables | |
| constexpr CyclicStretchRegime | pulp::signal::kCyclicStretchShortFrame {200.0, 1, 45.0, 1.0} |
| "Clean-ish short frame" — one cycle per grain, a generous equal-power splice, cycle_hz meant to be tuned to the material's fundamental. | |
| constexpr CyclicStretchRegime | pulp::signal::kCyclicStretchLongFrame {200.0, 8, 8.0, 0.0} |
| "Glitchy long frame" — tens of milliseconds per grain, a short linear splice, cycle_hz left wherever it happens to be. | |
Fixed-cycle splice time-stretch — the S-series sampler algorithm whose ARTEFACTS ARE THE PRODUCT.
This is the metallic frame-rate flutter and the doubled/stuttered consonants that define the jungle and lo-fi vocal sound. It is deliberately NOT a transparent stretch, and the two things it does not do are the whole design: there is no correlation search and there is no transient detection. Everything audible about it follows from those two absences.
No academic paper, patent, or service manual documents Akai's actual timestretch algorithm. What is documented is the FAMILY it belongs to — overlap-add time-scale modification, whose literature is cited at the bottom of this block — and the user-facing control surface the hardware exposed: a "cycle" value the operator tuned by ear, a stretch percentage, and acknowledged metallic artefacts on non-ideal cycles. The operator's manuals are citable for THAT and nothing more.
So: the topology and the OLA math are grounded in published theory; the splice scheduler, every frame and crossfade constant, and the √2 gain bound are original design choices derived from the documented control surface and the algorithm family, not from a product implementation. Where this file says "faithful", it means faithful to a documented control surface and a documented artefact description — never to an algorithm anyone published.
SOLA with the similarity search deleted and the analysis grid frozen to a user-set cycle.
| SOLA / WSOLA | this | |
|---|---|---|
| analysis position | search ±tolerance for best correlation | rigid snap to the L grid |
| transients | preserved, because the search aligns onsets | repeated or dropped on the grid |
| splice artefact | minimised | kept — it is the product |
| duration change | continuous hop retiming | whole-cycle repeat / omit via the snap |
Every row is the same one decision playing out, which is why the reconstruction is defensible without any internals: delete the search, and the rest is forced.
Because in_pos is quantised to whole cycles while out_pos advances by a fixed hop, consecutive grains sometimes land on the SAME cell — a repeated cycle, lengthening — and sometimes SKIP one — an omitted cycle, shortening. Averaged over many grains the input advances S/r per grain, so duration scales by r; locally it is quantised to whole cycles. That single snap is the entire "cyclic" idea, and it needs no accumulator and no randomness.
Why the sidebands land at fs/S. For steady input every grain carries near-identical content; what recurs deterministically is the splice window, with period S in the output. So the output is approximately the input times a periodic envelope of period S, whose Fourier series has components at k·fs/S — and multiplying a tone f₀ by it produces sidebands at f₀ ± k·fs/S exactly. Standard OLA-modulation reasoning; the novelty is only that this design KEEPS the modulation instead of minimising it.
Round-half-to-even is spelled out, not delegated to llround. The snap has to be bit-reproducible across platforms, and the two obvious library calls disagree at exactly the ties the schedule hits constantly: llround rounds half AWAY from zero, and nearbyint gives half-to-even only while the FP rounding mode is untouched. round_half_even below does it in integer logic so neither can bite. A tie is not a rare event here — g·S/(r·L) lands on .5 on a regular lattice for the small rational ratios this module is used at.
The scheduler is clamped at BOTH ends, not just below. The source spec clamps only the falling-behind case and asserts in_pos can never exceed what has been captured "by construction". That is true for r ≥ 1 and false for r < 1: compression reads faster than input arrives, so in_pos races ahead of total_written and would read uninitialised ring. Both clamps are here, both grid-snapped so a clamp can never break the cyclic invariant, and both deterministic. See advance_read_cursor.
Grains are produced as late as possible, not as early as possible. The obvious loop keeps a hop of lookahead buffered so a block boundary cannot starve the output. It also silently forces the latency UP by that lookahead, and worse, it makes the upper clamp engage permanently even at r = 1 — the scheduler asks for samples one lookahead into the future on every grain. Producing exactly the grains that the current output sample depends on keeps the reported latency at N and keeps the clamp disengaged whenever the ratio does not actually require it.
A splice's two halves are matched to each other, not to the current parameters. Each grain fades in over exactly as many samples as its predecessor faded out. At settled lengths those are the same number and the rule is invisible; while a length control is moving it is the difference between a complementary splice and a level jump. See the gain note below.
Splice weights are evaluated per sample, not tabulated. A fade table would be faster in the overlap, but it would make every set_* an O(X) transcendental loop — up to 76 800 sin/cos at 192 kHz — and set_* is called per block by the catalog node. Per-sample evaluation costs about two transcendentals per output sample in the worst regime and keeps every setter O(1).
Oversampling policy: none, and the reason is that there is nothing to oversample. The signal path is multiply-by-weight and sum. No waveshaper, no saturator, no filter, no fractional-rate resampling — grains are read at integer offsets at 1×, which is also why this stretch does not shift pitch. Series law 4 is satisfied vacuously and the policy is stated rather than omitted. (The splice discontinuity does generate broadband energy; that is the artefact, not aliasing, and no amount of oversampling would or should remove it.)
Worst-case gain is √2 at settled lengths, and it is proven rather than measured. In a splice the output is a·w_out + b·w_in for source samples a, b bounded by A. Equal-gain has w_in + w_out = 1 identically, so it can never boost. Equal-power has cos(πθ/2) + sin(πθ/2), maximised at θ = ½ at 2·sin(π/4) = √2. For a blended shape the sum is (1−shape) + shape·(sin+cos) ≤ (1−shape) + shape·√2 ≤ √2, and the triangle inequality carries that to arbitrary a, b. Since X ≤ N/2 is clamped, never more than two grains overlap, so there is no third term. √2 is therefore a hard bound at SETTLED resolved lengths — not a measured maximum — and worst_case_gain() returns it. The suite asserts it against adversarial input rather than trusting the algebra alone, and finds it attained to nine digits (1.414213538) by a DC input. The boost is KEPT rather than normalised away: it is part of the character, and output_db exists to make room for it.
While the LENGTH controls are moving, the bound is 2, not √2. This is the one place the derivation has a seam and it is worth being exact about. cycle_hz, grain_periods and crossfade_pct change the grain geometry underneath grains that are already in flight, so the outgoing fade and the incoming fade can stop being complementary — the outgoing grain is still at unity while the incoming one is already up. Two mechanisms keep that bounded. Each grain fades IN over exactly as many samples as its predecessor faded OUT, so a splice stays complementary whenever the geometry allows it at all; and a grain may not start before the grain two back has ENDED, which is the two-grain invariant enforced directly instead of inferred from the hop. Neither guard binds at settled lengths (2X ≤ N makes the second one vacuous), so nothing about the steady-state sound changes. With both in place the ceiling is the trivial one — two grains, each weighted at most 1 — and a deliberately violent per-block sweep of every length control across its full range measures 1.975. Without them the same sweep reached 2.35, with four and five grains summing at once. stretch_ratio, crossfade_shape, mix and output_db change no length and are free to automate at audio rate.
Determinism (series law 2) is structural: there is no RNG anywhere. The schedule is a pure integer function of g while the clamps are disengaged, and of (g, total_written) when they engage — and total_written is just a count of samples fed in. Nothing to seed. Because production depends only on absolute counters, block size cannot change the result either.
Latency is N, and that is only the initial fill. The scheduler cannot emit grain 0 until its N source samples exist. There is no steady-state constant group delay for r ≠ 1 — a time-scaling process does not have one — and this block says so rather than pretending otherwise. The dry path is delayed by exactly N so mix is phase-honest.
No overlap. That engine is a whole-input, non-realtime, frequency-domain phase-vocoder chain (STFT/WOLA, peak phase locking, formant shifting, transient relocation, exact output-length locking) whose entire purpose is transparency. This is a realtime, streaming, time-domain, search-free splicer whose purpose is the opposite. Different domain, different execution model, different goal. They are not two implementations of one thing and neither should be expressed in terms of the other.
RT contract: prepare(sample_rate) sizes the capture ring for the maximum capture_ms and the grain accumulator for the maximum grain_periods · L; it is the only method that allocates. set_* recompute the resolved lengths (L, N, X, S) and clamp in place — no allocation, no reset. process and reset are allocation-free and lock-free; reset clears the ring, accumulator, pointers and grain counter. The class holds no global/static state and no RNG, so a zero-initialized instance is a valid, silent, deterministic one.
References. S. Roucos and A. M. Wilgus, "High Quality Time-Scale Modification for Speech", ICASSP-85, vol. 10, pp. 493–496, 1985 — SOLA, the overlap-add time-scaling family this degenerates from. E. Moulines and F. Charpentier, "Pitch-synchronous waveform processing techniques for text-to-speech synthesis using diphones", Speech Communication 9(5–6), pp. 453–467, 1990 — PSOLA, cited as the CONTRAST case: it tracks pitch to place its marks, and this deliberately does not. W. Verhelst and M. Roelands, "An overlap-add technique based on waveform similarity (WSOLA) for high quality time-scale modification of speech", ICASSP-93, vol. 2, pp. 554–557, 1993 — the similarity search this omits on purpose. Akai S950 / S1000 operator's manuals — cited for the CONTROL SURFACE ONLY (a user-tuned cycle, a stretch percentage, documented metallic artefacts); see the honest gap above. Equal-gain and equal-power crossfade weight laws are common practice and are composed from crossfade.hpp rather than re-derived.