Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ FFT-based extreme time-stretching library (Paulstretch algorithm). Single transl
### Public classes

- **`StreamingStretcher`** — Block-based push/pull primitive for realtime use (AudioWorklet / Web Worker). The host calls `next_input_size()` to learn how many input frames `step()` wants next, gathers exactly that many (zero-padding if the source ran out), calls `step()` to produce `bufsize()` output frames, then advances its input cursor by an additional `skip_after_step()` frames. The first `step()` call expects `max_input_chunk()` (= 3 × bufsize) frames for the initial fill; subsequent requests alternate between 0 and `bufsize()` depending on stretch factor and onset detection. `step_without_onset_feedback()` + `apply_onset()` exists for hosts that need to coordinate onsets across channels.
- **`OfflineRenderer`** — Convenience wrapper around `StreamingStretcher` for whole-buffer rendering (`render_mono`, `render_stereo`). Stereo rendering runs two independent `StreamingStretcher`s but synchronizes onset detection (`max` of both channels) so the channels stay aligned.
- **`OfflineRenderer`** — Convenience wrapper around `StreamingStretcher` for whole-buffer rendering (`render_mono`, `render_stereo`). Stereo rendering runs two independent `StreamingStretcher`s but synchronizes onset detection (`max` of both channels) so the channels stay aligned. `render_mono_chunked` / `render_stereo_chunked` run the same DSP loop but deliver the output one `bufsize()`-frame chunk at a time via a `ChunkSink` callback, so peak memory stays bounded for very long outputs (the buffered path holds the whole result — and on WASM a second JS-side copy of it — in linear memory at once, which can exceed the heap and abort). The buffered and chunked paths share a common `stream_channel` / `stream_stereo` core in `src/paulstretch.cpp`.
- **`BinauralBeatsProcessor`** — Independent post-processor that mixes the stretched signal toward mono and adds a sub-audio LFO-style beat between L/R (`set_options`, optional frequency envelope, `process(left, right, nframes, position_pct)`).

### Key data flow
Expand Down
9 changes: 9 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ if (PAULSTRETCH_BUILD_TESTS AND NOT EMSCRIPTEN)
COMMAND paulstretch_streaming_test
${CMAKE_CURRENT_SOURCE_DIR}/tests/plenty_of_unknown.wav
)

add_executable(paulstretch_chunked_test tests/chunked_test.cpp)
target_link_libraries(paulstretch_chunked_test PRIVATE paulstretch_core)
add_test(NAME paulstretch_chunked_test COMMAND paulstretch_chunked_test)
endif()

if (PAULSTRETCH_BUILD_EXAMPLES AND NOT EMSCRIPTEN)
Expand All @@ -125,6 +129,11 @@ if (EMSCRIPTEN AND PAULSTRETCH_BUILD_WASM)
-O3
--bind
-sALLOW_MEMORY_GROWTH=1
# Raise the growth ceiling to the wasm32 maximum (4 GiB). Emscripten
# defaults to 2 GiB, which an hour-plus offline render can exceed and
# abort. For unbounded output lengths, prefer renderMonoChunked /
# renderStereoChunked, which keep linear memory bounded regardless.
-sMAXIMUM_MEMORY=4294967296
# Default ENVIRONMENT (web,webview,worker,node) — works in browsers,
# bundlers (Vite/Webpack), Node, and Web Workers.
-sEXPORT_ES6=1
Expand Down
85 changes: 15 additions & 70 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@ auto [left, right] = renderer.render_stereo(left_in, right_in);

Stereo rendering runs two independent stretchers but synchronizes onset detection across channels so they stay phase-aligned.

#### Very long outputs (chunked rendering)

`render_mono` returns the whole result in one `std::vector`. For an extreme stretch — a few seconds blown up several hundred times into an hour-plus of audio — that buffer can be enormous, and on the WebAssembly build it has to live in linear memory twice over (the C++ vector plus the returned `Float32Array`), which can exceed the WASM heap and abort. `render_mono_chunked` / `render_stereo_chunked` run the identical algorithm but hand each `bufsize()`-frame chunk to a sink as it is produced, so peak memory stays bounded regardless of output length:

```cpp
renderer.render_mono_chunked(input, [&](const float *data, int frames) {
// Consume the chunk — append to a buffer, write to disk, feed an encoder.
});

renderer.render_stereo_chunked(left_in, right_in,
[&](const float *left, const float *right, int frames) { /* ... */ });
```

### Streaming (realtime) rendering

`StreamingStretcher` is a block-based push/pull primitive for realtime hosts (audio callback, AudioWorklet, Web Worker). The host gathers exactly the number of input frames the stretcher asks for, calls `step()` to produce one output chunk, then advances its input cursor by the additional skip distance:
Expand Down Expand Up @@ -159,77 +172,9 @@ int width = paulstretch::fft_simd_size(); // 4

## Node.js / WASM usage

```js
import createPaulstretchModule from "@olilarkin/paulstretch-wasm";

const Module = await createPaulstretchModule();
const renderer = new Module.OfflineRenderer(8.0, 4096, 48000, Module.Window.Hann, 0.0);

const output = renderer.renderMono(input);
const { left, right } = renderer.renderStereo(leftChannel, rightChannel);

renderer.delete(); // embind objects are not GC'd
```

### Streaming

```js
const s = new Module.StreamingStretcher(8.0, 4096, 48000, Module.Window.Hann, 0.0);

while (rendering) {
const want = s.nextInputSize();
const input = gatherFrames(want); // Float32Array, zero-pad if needed
const { output, onset } = s.step(input, positionPct);
writeFrames(output);
inputCursor += want + s.skipAfterStep();
}
s.delete();
```

For multichannel hosts that need synchronized onsets across channels, use `stepWithoutOnsetFeedback()` on every channel, take the max onset, then call `applyOnset()` on every channel before the next iteration.

### Stretch envelope

Pass parallel arrays of positions (0–1) and multiplier values:

```js
renderer.setStretchEnvelope(
new Float32Array([0, 0.5, 1.0]),
new Float32Array([1.0, 4.0, 1.0]),
);
const output = renderer.renderMono(input);
renderer.clearStretchEnvelope();
```

### Spectral processing

`setProcessOptions` accepts a plain JS object with camelCase keys (e.g. `pitchShiftEnabled`, `pitchShiftCents`, `filterEnabled`, `filterLowHz`); unspecified fields keep their defaults:
The Emscripten build is published as [`@olilarkin/paulstretch-wasm`](https://github.com/olilarkin/libpaulstretch/pkgs/npm/paulstretch-wasm). See [`npm/README.md`](npm/README.md) for installation, the full JS API (offline, streaming, envelope, spectral processing, binaural beats), and bundler notes. Type definitions live in [`npm/index.d.ts`](npm/index.d.ts).

```js
renderer.setProcessOptions({
pitchShiftEnabled: true,
pitchShiftCents: 700,
filterEnabled: true,
filterLowHz: 200,
filterHighHz: 4000,
});
```

See `npm/index.d.ts` for the full `ProcessOptions` shape.

### Binaural beats

```js
const bb = new Module.BinauralBeatsProcessor(48000);
bb.setOptions({
enabled: true,
stereoMode: Module.BinauralStereoMode.LeftRight,
mono: 0.5,
beatFrequencyHz: 8,
});
const { left, right } = bb.process(leftIn, rightIn, positionPct);
bb.delete();
```
The C++ and JS APIs are 1:1 — JS methods use camelCase versions of the C++ names, and `setProcessOptions` accepts a plain object instead of a `ProcessOptions` struct.

## Notes

Expand Down
17 changes: 17 additions & 0 deletions include/paulstretch/paulstretch.h
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#pragma once

#include <cstddef>
#include <functional>
#include <memory>
#include <string>
#include <vector>
Expand Down Expand Up @@ -190,6 +191,22 @@ class OfflineRenderer {
StereoBuffer render_stereo(const std::vector<float> &left,
const std::vector<float> &right) const;

// Chunked offline rendering. Identical algorithm to render_mono/render_stereo,
// but instead of materialising the whole output in one buffer the result is
// delivered to `sink` one chunk at a time (each chunk is `bufsize()` frames).
// This keeps peak memory bounded regardless of stretch factor / output length
// — essential for the WASM build, whose linear memory is capped well below the
// size an hour-plus render would otherwise need. The pointers passed to `sink`
// are only valid for the duration of the call; copy out anything you keep.
using ChunkSink = std::function<void(const float *data, int frames)>;
using StereoChunkSink =
std::function<void(const float *left, const float *right, int frames)>;
void render_mono_chunked(const std::vector<float> &input,
const ChunkSink &sink) const;
void render_stereo_chunked(const std::vector<float> &left,
const std::vector<float> &right,
const StereoChunkSink &sink) const;

std::size_t estimate_output_frames(std::size_t input_frames) const;

private:
Expand Down
23 changes: 23 additions & 0 deletions npm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,29 @@ renderer.delete();
const { left, right } = renderer.renderStereo(leftIn, rightIn);
```

### Very long outputs (chunked rendering)

`renderMono` returns the whole result in one `Float32Array`, which has to live in
WebAssembly memory twice over (the internal buffer plus the returned copy). A large
stretch — e.g. a few seconds stretched several hundred times into an hour-plus of
audio — can exceed the WASM heap and abort. For those cases use `renderMonoChunked`
(or `renderStereoChunked`): same algorithm, but the output is delivered one chunk at
a time so peak WASM memory stays bounded regardless of length. Accumulate the chunks
on the JS heap, or stream them straight to disk / an encoder.

```js
const chunks = [];
const totalFrames = renderer.renderMonoChunked(input, (chunk) => {
// `chunk` is a fresh Float32Array you may keep (~fftSize frames).
chunks.push(chunk);
});

// Stereo: callback receives (left, right) per chunk.
// const totalFrames = renderer.renderStereoChunked(leftIn, rightIn, (l, r) => { ... });

renderer.delete();
```

### Time-varying stretch (breakpoint envelope)

Positions are normalized `0..1` over the input. Values multiply the `stretch` you passed to the constructor.
Expand Down
17 changes: 17 additions & 0 deletions npm/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,23 @@ export interface OfflineRenderer {
renderMono(input: Float32Array): Float32Array;
renderStereo(left: Float32Array, right: Float32Array): StereoBuffer;

/**
* Chunked offline render — use this instead of `renderMono` for very long
* outputs (large stretch factors). The whole result of `renderMono` must live
* in WASM linear memory twice over (the C++ buffer plus the returned copy),
* so an hour-plus render can exceed the heap cap and abort. `renderMonoChunked`
* instead invokes `onChunk` with each ~`bufsize` slice; copy/accumulate it on
* the JS heap (or stream it to disk / an encoder) as it arrives. Peak WASM
* memory stays bounded regardless of output length. The Float32Array passed to
* `onChunk` is a fresh JS-heap copy you may keep. Returns the total frame count.
*/
renderMonoChunked(input: Float32Array, onChunk: (chunk: Float32Array) => void): number;
renderStereoChunked(
left: Float32Array,
right: Float32Array,
onChunk: (left: Float32Array, right: Float32Array) => void,
): number;

/**
* Set a time-varying stretch multiplier. Positions are normalized 0..1
* over the input duration; values are multipliers on the constructor's
Expand Down
Loading
Loading