@pipedog/resampler-wasm 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,25 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2024–2026, Daily
4
+ Copyright (c) 2026, pipedog contributors
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/NOTICE ADDED
@@ -0,0 +1,9 @@
1
+ pipedog is an unofficial TypeScript port of Pipecat (https://github.com/pipecat-ai/pipecat),
2
+ a framework for voice and multimodal conversational AI created by Daily.
3
+
4
+ It is not affiliated with or endorsed by Daily. Pipecat is distributed under
5
+ the BSD 2-Clause License, Copyright (c) 2024–2026, Daily; that copyright notice
6
+ and license are retained in LICENSE, and every ported source file carries a
7
+ header naming the upstream file it was derived from.
8
+
9
+ The upstream commit each file was ported from is recorded in porting/manifest.json.
package/README.md CHANGED
@@ -1,5 +1,186 @@
1
1
  # @pipedog/resampler-wasm
2
2
 
3
- Placeholder version. Install a released version instead: `npm install @pipedog/resampler-wasm@latest`.
3
+ High-quality PCM sample-rate conversion for pipedog, compiled to WebAssembly.
4
+ It is the engine behind core's `SOXRAudioResampler` and `SOXRStreamAudioResampler`.
4
5
 
5
- Part of [pipedog](https://github.com/polvallverdu/pipedog).
6
+ - [rubato](https://github.com/HEnquist/rubato) 5.0.1 (MIT OR Apache-2.0) windowed-sinc resampler, written in Rust,
7
+ built for `wasm32-unknown-unknown`. We use it instead of libsoxr, which is LGPL and needs emscripten.
8
+ - No wasm-bindgen and no generated JS glue. The module has **zero imports** and a small C ABI
9
+ (`rust/src/lib.rs`), and `src/engine.ts` wraps the `WebAssembly.Instance` exports by hand.
10
+ - `wasm/resampler.wasm` is a committed build artifact of **38,893 bytes** (18.2 KB with `gzip -9`).
11
+ It is built with cargo `opt-level = "z"`, LTO, `panic = "abort"`, then `wasm-opt -Oz`.
12
+ - int16 little-endian PCM in and out, plus float32. Any channel count from 1 to 8, interleaved.
13
+ - One-shot and streaming. Streams take chunks of any size, keep the audio continuous across
14
+ chunks, and support `flush()` and `reset()`.
15
+ - Verified on Bun, Node 24, Deno 2 and workerd (Cloudflare Workers).
16
+
17
+ ## Usage
18
+
19
+ ```ts
20
+ import { initResampler } from "@pipedog/resampler-wasm";
21
+
22
+ const engine = await initResampler(); // once; everything after this is synchronous
23
+
24
+ // One-shot (a whole clip, tail included). Returns the input unchanged if the rates are equal.
25
+ const out: Uint8Array = engine.resample(pcm16, 8000, 16000, { quality: "VHQ" });
26
+
27
+ // Streaming
28
+ const stream = engine.createStream(48000, 16000, { quality: "HQ", channels: 1 });
29
+ const a = stream.process(chunk1); // any chunk size
30
+ const b = stream.process(chunk2);
31
+ const tail = stream.flush(); // emit the filter tail, then start a new stream
32
+ stream.reset(); // or: drop the held audio
33
+ stream.destroy(); // free the WASM-side state
34
+ ```
35
+
36
+ There are also `resampleSamples(Int16Array)`, `resampleFloat32(Float32Array)`, and
37
+ `processSamples`/`processFloat32`/`flushSamples`/`flushFloat32` on streams.
38
+
39
+ ## Loading on each runtime
40
+
41
+ `package.json` `"exports"["."]` uses export conditions:
42
+
43
+ | condition | entry | how the module gets loaded |
44
+ |---|---|---|
45
+ | `workerd` | `src/workerd.ts` | `import wasm from "../wasm/resampler.wasm"` gives a compiled `WebAssembly.Module` (wrangler's default `.wasm` rule), which is instantiated synchronously. workerd refuses to compile WASM from bytes ("Wasm code generation disallowed by embedder", confirmed by the smoke test). `getResampler()` works without calling `initResampler()` first. |
46
+ | `default` | `src/index.ts` | `fetch(new URL("../wasm/resampler.wasm", import.meta.url))`. Bun and Deno can fetch `file:` URLs. Node 24 cannot ("not implemented... yet"), so the entry falls back to a dynamic `import("node:fs/promises")`. The specifier is held in a variable so bundlers and type checkers ignore it. If the file cannot be read at all (a bundle that did not copy the `.wasm` asset), the same bytes are taken from `src/wasm_bytes.ts` (base64, generated by `build:wasm`). |
47
+
48
+ `initResampler(source?)` accepts a `WebAssembly.Module`, a `BufferSource`, a `URL` or a `Response`
49
+ (the last two only on the default entry). With no argument it loads the bundled file. It is
50
+ idempotent, and concurrent callers share a single load. `getResampler()` returns the shared
51
+ engine. On the default entry it throws if `initResampler()` has not been awaited yet.
52
+ `./engine` exports `engineFromModule`/`engineFromSource` for anyone who wants to manage
53
+ instances themselves.
54
+
55
+ ## Quality presets
56
+
57
+ The preset names and targets come from libsoxr's quality table (`soxr_quality_spec()` in
58
+ libsoxr 0.1.3). In both libsoxr and this package, the stopband starts at the Nyquist frequency
59
+ of the lower of the two rates (N below), whichever way the conversion goes:
60
+
61
+ | preset | libsoxr: passband end, rejection | here: passband (±0.01 dB) | ±0.1 dB | −3 dB | stopband from 1.0 N | images (upsampling) |
62
+ |---|---|---|---|---|---|---|
63
+ | `QQ` | cubic interpolation, no filter | 0.665 N | 0.695 N | 0.785 N | ≥ 75 dB | ≤ −72 dB |
64
+ | `LQ` | 0.676 N, 96 dB (16-bit) | 0.74 N | 0.765 N | 0.82 N | ≥ 112 dB | ≤ −117 dB |
65
+ | `MQ` | 0.915 N, 96 dB (16-bit) | 0.91 N | 0.92 N | 0.94 N | ≥ 111 dB | ≤ −121 dB |
66
+ | `HQ` | 0.913 N, 120 dB (20-bit) | 0.905 N | 0.915 N | 0.935 N | ≥ 138 dB | ≤ −149 dB |
67
+ | `VHQ` | 0.911 N, 169 dB (28-bit) | 0.91 N | 0.915 N | 0.935 N | ≥ 157 dB (measurement floor) | ≤ −149 dB |
68
+
69
+ How these were measured: float tones in, float out. Passband edges and stopband come from
70
+ 48 kHz → 8 kHz and 48 kHz → 16 kHz, which give the same values. The stopband figure is the
71
+ worst tone between 1.0 N and the input Nyquist frequency. The image figure is the worst residual
72
+ after the input tone is removed, for 8k → 48k, 16k → 48k and 16k → 24k.
73
+
74
+ Which libsoxr numbers are matched:
75
+
76
+ - **Passband edges.** MQ, HQ and VHQ are flat to libsoxr's ~0.91 N, so they differ mainly in
77
+ stopband attenuation, as in libsoxr. LQ is at least as wide as libsoxr's 0.676 N.
78
+ - **Rejection.** It meets or beats libsoxr's 96 dB (LQ, MQ) and 120 dB (HQ). VHQ measures
79
+ ≥ 157 dB, which is the floor of a float32-input measurement. libsoxr's 169 dB cannot be
80
+ observed with float32 or int16 I/O.
81
+ - **QQ.** libsoxr's QQ is plain cubic interpolation with no anti-aliasing filter. Here QQ is a
82
+ short filter that still rejects aliases by ≥ 75 dB.
83
+
84
+ Parameters (`rust/src/lib.rs`, `preset()`). Lengths and table densities are counted at the lower
85
+ rate:
86
+
87
+ | preset | length (lower-rate samples) | window | table points / sample | interpolation | cutoff (−6 dB) |
88
+ |---|---|---|---|---|---|
89
+ | `QQ` | 32 | Blackman | 32 | Linear | 0.82 N |
90
+ | `LQ` | 64 | BlackmanHarris | 64 | Cubic | 0.84 N |
91
+ | `MQ` | 160 | BlackmanHarris | 128 | Cubic | 0.95 N |
92
+ | `HQ` | 240 | BlackmanHarris2 | 128 | Cubic | 0.945 N |
93
+ | `VHQ` | 272 | BlackmanHarris2 | 256 | Cubic | 0.945 N |
94
+
95
+ - rubato measures `sinc_len` in input samples and only scales the cutoff when downsampling. So
96
+ the stream stretches the filter by the downsampling factor: VHQ 48k → 16k uses
97
+ `sinc_len = 816`. It also divides the table density by the same factor, because the
98
+ stretched sinc varies more slowly. Everything runs in f64; with f32 the arithmetic noise
99
+ floor sits near −125 dB, above VHQ's stopband.
100
+ - Delay is half the filter length, 136 lower-rate samples (8.5 ms) for VHQ. It is
101
+ compensated: the output is time-aligned with the input to within about 0.01 sample (see
102
+ "Alignment"). In streaming mode it still shows up as latency, because the last half-filter
103
+ of input only comes out on the next chunk or on `flush()`.
104
+ - Table density also sets the alignment: rubato's sinc table is offset by one table step, so
105
+ the output leads the input by `1 / table points` lower-rate samples (0.004 for VHQ, 0.008 for
106
+ HQ/MQ). That is why the densities are higher than the stopband alone would need.
107
+ - Memory: each stream holds a sinc table of about `length × table points` f64 values: 544 KB
108
+ for VHQ, 240 KB for HQ, 160 KB for MQ, 32 KB for LQ and 8 KB for QQ. WASM memory grows and
109
+ never shrinks.
110
+ - Speed (Bun, Apple Silicon, mono, 10 s of audio; runs vary by about ±10 ms). 16k → 48k takes
111
+ about 25 / 50 / 70 / 115 / 115 ms (QQ..VHQ), and 44.1k → 16k takes about 15 / 35 / 70 / 105 /
112
+ 115 ms. A 20 ms chunk at VHQ costs about 0.23 ms (16k → 48k) or 0.21 ms (48k → 16k). A `simd128` build was measured and gave no
113
+ speed-up, so the artifact is plain scalar wasm.
114
+
115
+ ## Streaming semantics
116
+
117
+ - Each `process()` call is split into pieces of at most 1024 frames.
118
+ `Resizable::set_chunk_size` is set to each piece's length, which keeps the filter history.
119
+ Nothing is held back beyond the filter delay. A sample count that is not a multiple of the
120
+ channel count leaves the remainder buffered for the next call.
121
+ - Output is aligned: the start-up delay is trimmed from the front of every stream. `flush()`
122
+ pads with silence until the stream has emitted exactly `round(totalInputFrames × out / in)`
123
+ frames, then resets. This matches upstream's
124
+ `test_soxr_stream_audio_resampler_flush_returns_the_held_audio`: 640 samples at 16 kHz give
125
+ fewer than 320 samples, and adding the flush gives exactly 320.
126
+ - `reset()` discards the filter history, any pending samples and the counters.
127
+ - **Alignment.** rubato's first output frame sits at input position `1/ratio − sinc_len/2`, so
128
+ the raw delay is `sinc_len·ratio/2 − 1` output frames. For many ratios that is a fraction
129
+ (48 kHz → 16 kHz, for example). To make it a whole number, the stream is primed with
130
+ `k` zero frames, where `k < 2q` and `q` is the denominator of the reduced ratio `p/q`. The
131
+ whole-number delay is then trimmed. All of this is computed in `alignment()` in
132
+ `rust/src/lib.rs`.
133
+ - Output samples differ from libsoxr's because the filters are different. Lengths follow the
134
+ rule above.
135
+
136
+ ## How core uses it
137
+
138
+ `@pipedog/core` depends on this package and wires it in itself
139
+ (`packages/core/src/audio/resamplers/engine.ts`): `SOXRAudioResampler` and
140
+ `SOXRStreamAudioResampler` load it lazily on first use (`initResampler()`, then
141
+ `createSoxrCompatibleEngine()`), fall back to a pure-TypeScript windowed-sinc engine
142
+ with one warning if it cannot load, and accept any engine of the same shape through
143
+ `setResamplerEngine()`. `createSoxrCompatibleEngine(engine)` returns that shape:
144
+
145
+ ```ts
146
+ type SoxrQuality = "QQ" | "LQ" | "MQ" | "HQ" | "VHQ";
147
+ interface SoxrCompatibleEngine {
148
+ // stands in for soxr.resample(audio, in_rate, out_rate, quality=...)
149
+ resample<T extends Int16Array | Float32Array>(audio: T, inRate: number, outRate: number, quality: SoxrQuality): T;
150
+ // stands in for soxr.ResampleStream(in_rate=, out_rate=, num_channels=, quality=, dtype="int16")
151
+ createStream(o: { inRate: number; outRate: number; numChannels: number; quality: SoxrQuality }): {
152
+ resampleChunk<T extends Int16Array | Float32Array>(chunk: T, last?: boolean): T; // last=true appends the tail and resets
153
+ clear(): void; // soxr ResampleStream.clear()
154
+ destroy(): void; // free WASM state (optional; a FinalizationRegistry is the backstop)
155
+ };
156
+ }
157
+ ```
158
+
159
+ Equal rates never reach the engine, because core returns early just as upstream does. The
160
+ engine's `resample` also returns its input unchanged for equal rates.
161
+
162
+ ## C ABI (for reference)
163
+
164
+ | export | |
165
+ |---|---|
166
+ | `rs_alloc(len) -> ptr`, `rs_free(ptr, len)` | raw linear-memory blocks |
167
+ | `rs_stream_new(in_rate, out_rate, channels, quality) -> handle` | quality: 0=QQ … 4=VHQ; 0 on bad params |
168
+ | `rs_stream_input(h, n) -> *i16` / `rs_stream_input_f32(h, n) -> *f32` | staging buffer for `n` samples |
169
+ | `rs_stream_process(h, n) -> count` / `rs_stream_process_f32` | output at `rs_stream_output(h)` / `rs_stream_output_f32(h)`; −1 on error |
170
+ | `rs_stream_flush(h) -> count` / `rs_stream_flush_f32` | drain tail and reset |
171
+ | `rs_stream_reset(h)`, `rs_stream_free(h)` | |
172
+ | `rs_stream_delay(h)`, `rs_stream_cutoff(h)`, `rs_quality_sinc_len(q)`, `rs_quality_oversampling(q)` | introspection |
173
+
174
+ The JS side copies its input into the staging buffer and copies the output out. It recreates
175
+ typed-array views after every call, because a `memory.grow` detaches the old `ArrayBuffer`.
176
+
177
+ ## Building and testing
178
+
179
+ ```sh
180
+ rustup target add wasm32-unknown-unknown
181
+ bun run build:wasm # cargo + wasm-opt -Oz (wasm-opt from PATH, else `npx -p binaryen`); --no-opt to skip
182
+ bun test # bun:test suite (test/resampler.test.ts)
183
+ bun run smoke:node # node test/smoke/node.ts (TypeScript sources run directly; Node >= 22.6)
184
+ bun run smoke:deno # deno run -A test/smoke/deno.ts
185
+ bun run smoke:workerd # bundles test/smoke/workerd/worker.ts with the "workerd" condition, serves it with `npx workerd`
186
+ ```