@pipedog/resampler-wasm 0.0.0-stage → 0.1.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,3 +1,174 @@
1
- # Temporary Holding Version
1
+ # @pipedog/resampler-wasm
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ High-quality PCM sample-rate conversion for pipedog, compiled to WebAssembly.
4
+ It is the engine behind core's `SOXRAudioResampler` and `SOXRStreamAudioResampler`.
5
+
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 **39,790 bytes** (27.6 KB gzipped).
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. |
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 are libsoxr's. Each one maps to rubato `SincInterpolationParameters`, and the
58
+ filter length increases from one preset to the next.
59
+
60
+ | preset | `sinc_len` | window | `oversampling_factor` | interpolation | `f_cutoff` (auto) | delay (input frames) |
61
+ |---|---|---|---|---|---|---|
62
+ | `QQ` | 16 | Hann2 | 32 | Linear | 0.668 | 8 |
63
+ | `LQ` | 32 | Blackman2 | 64 | Linear | 0.704 | 16 |
64
+ | `MQ` | 64 | Blackman2 | 128 | Quadratic | 0.852 | 32 |
65
+ | `HQ` | 128 | BlackmanHarris2 | 256 | Cubic | 0.895 | 64 |
66
+ | `VHQ` | 256 | BlackmanHarris2 | 256 | Cubic | 0.947 | 128 |
67
+
68
+ - `f_cutoff` is left at `None`, so rubato's `calculate_cutoff` chooses it: the highest cutoff
69
+ that keeps aliasing below the window's sidelobe level for that filter length. The table shows
70
+ the cutoff relative to the lower of the two Nyquist frequencies. When downsampling, rubato
71
+ scales it by the ratio. For example, VHQ from 48 kHz to 16 kHz gives 0.316 of the input
72
+ Nyquist, which is 0.947 of the output Nyquist.
73
+ - Delay is the filter's look-ahead, equal to `sinc_len / 2` input frames (8 ms for VHQ at 16 kHz).
74
+ It is compensated: the output is time-aligned with the input to within about 0.01 sample
75
+ (see "Alignment"). In streaming mode it still shows up as latency, because the last
76
+ `sinc_len / 2` input frames only come out on the next chunk or on `flush()`.
77
+ - Memory: each stream holds a sinc table of `sinc_len × oversampling` floats, which is 256 KB
78
+ for VHQ, 128 KB for HQ and about 2 KB for QQ. WASM memory grows and never shrinks.
79
+ - Speed (Bun, Apple Silicon, mono): converting 10 s of 16 kHz audio to 48 kHz takes 100 ms at
80
+ VHQ, 56 ms at HQ and 12 ms at QQ. Converting 10 s from 44.1 kHz to 16 kHz takes 39 ms at VHQ.
81
+ A `simd128` build was measured and gave no speed-up, so the artifact is plain scalar wasm.
82
+
83
+ ## Streaming semantics
84
+
85
+ - Each `process()` call is split into pieces of at most 1024 frames.
86
+ `Resizable::set_chunk_size` is set to each piece's length, which keeps the filter history.
87
+ Nothing is held back beyond the filter delay. A sample count that is not a multiple of the
88
+ channel count leaves the remainder buffered for the next call.
89
+ - Output is aligned: the start-up delay is trimmed from the front of every stream. `flush()`
90
+ pads with silence until the stream has emitted exactly `round(totalInputFrames × out / in)`
91
+ frames, then resets. This matches upstream's
92
+ `test_soxr_stream_audio_resampler_flush_returns_the_held_audio`: 640 samples at 16 kHz give
93
+ fewer than 320 samples, and adding the flush gives exactly 320.
94
+ - `reset()` discards the filter history, any pending samples and the counters.
95
+ - **Alignment.** rubato's first output frame sits at input position `1/ratio − sinc_len/2`, so
96
+ the raw delay is `sinc_len·ratio/2 − 1` output frames. For many ratios that is a fraction
97
+ (48 kHz → 16 kHz, for example). To make it a whole number, the stream is primed with
98
+ `k` zero frames, where `k < 2q` and `q` is the denominator of the reduced ratio `p/q`. The
99
+ whole-number delay is then trimmed. All of this is computed in `alignment()` in
100
+ `rust/src/lib.rs`.
101
+ - Output samples differ from libsoxr's because the filters are different. Lengths follow the
102
+ rule above.
103
+
104
+ ## What core needs to call
105
+
106
+ Core must stay dependency-free, so it should declare this structural interface itself and
107
+ accept an injected engine. `createSoxrCompatibleEngine(engine)` returns exactly this shape:
108
+
109
+ ```ts
110
+ type SoxrQuality = "QQ" | "LQ" | "MQ" | "HQ" | "VHQ";
111
+ interface SoxrCompatibleEngine {
112
+ // stands in for soxr.resample(audio, in_rate, out_rate, quality=...)
113
+ resample<T extends Int16Array | Float32Array>(audio: T, inRate: number, outRate: number, quality: SoxrQuality): T;
114
+ // stands in for soxr.ResampleStream(in_rate=, out_rate=, num_channels=, quality=, dtype="int16")
115
+ createStream(o: { inRate: number; outRate: number; numChannels: number; quality: SoxrQuality }): {
116
+ resampleChunk<T extends Int16Array | Float32Array>(chunk: T, last?: boolean): T; // last=true appends the tail and resets
117
+ clear(): void; // soxr ResampleStream.clear()
118
+ destroy(): void; // free WASM state (optional; a FinalizationRegistry is the backstop)
119
+ };
120
+ }
121
+ ```
122
+
123
+ Mapping onto `packages/core/src/audio/resamplers/`:
124
+
125
+ | core stub | delegate to |
126
+ |---|---|
127
+ | `soxrResample(audio, inRate, outRate, quality)` (`soxr_resampler.ts`; also used with `Float32Array` by `local_smart_turn_v3.ts`) | `engine.resample(audio, inRate, outRate, quality)` |
128
+ | `new SoxrResampleStream({ inRate, outRate, numChannels, quality })` (`soxr_stream_resampler.ts`) | `engine.createStream({ inRate, outRate, numChannels, quality })` |
129
+ | `SoxrResampleStream.resampleChunk(chunk, last)` | `stream.resampleChunk(chunk, last)` |
130
+ | `SoxrResampleStream.clear()` | `stream.clear()` |
131
+
132
+ Suggested wiring: core adds a setter, for example `setSoxrEngine(e: SoxrCompatibleEngine)`
133
+ next to the stubs, and the stubs throw a clear "call setSoxrEngine()" error while it is unset.
134
+ The application then does:
135
+
136
+ ```ts
137
+ import { createSoxrCompatibleEngine, initResampler } from "@pipedog/resampler-wasm";
138
+ import { setSoxrEngine } from "@pipedog/core/audio/resamplers/soxr_resampler";
139
+
140
+ setSoxrEngine(createSoxrCompatibleEngine(await initResampler()));
141
+ ```
142
+
143
+ On Workers, `getResampler()` is synchronous, so the setter can also be called with
144
+ `setSoxrEngine(createSoxrCompatibleEngine(getResampler()))`. The smoke test checks this inside
145
+ a request handler. It has not been checked at module top level.
146
+
147
+ Equal rates never reach the engine, because core returns early just as upstream does. The
148
+ engine's `resample` also returns its input unchanged for equal rates.
149
+
150
+ ## C ABI (for reference)
151
+
152
+ | export | |
153
+ |---|---|
154
+ | `rs_alloc(len) -> ptr`, `rs_free(ptr, len)` | raw linear-memory blocks |
155
+ | `rs_stream_new(in_rate, out_rate, channels, quality) -> handle` | quality: 0=QQ … 4=VHQ; 0 on bad params |
156
+ | `rs_stream_input(h, n) -> *i16` / `rs_stream_input_f32(h, n) -> *f32` | staging buffer for `n` samples |
157
+ | `rs_stream_process(h, n) -> count` / `rs_stream_process_f32` | output at `rs_stream_output(h)` / `rs_stream_output_f32(h)`; −1 on error |
158
+ | `rs_stream_flush(h) -> count` / `rs_stream_flush_f32` | drain tail and reset |
159
+ | `rs_stream_reset(h)`, `rs_stream_free(h)` | |
160
+ | `rs_stream_delay(h)`, `rs_stream_cutoff(h)`, `rs_quality_sinc_len(q)`, `rs_quality_oversampling(q)` | introspection |
161
+
162
+ The JS side copies its input into the staging buffer and copies the output out. It recreates
163
+ typed-array views after every call, because a `memory.grow` detaches the old `ArrayBuffer`.
164
+
165
+ ## Building and testing
166
+
167
+ ```sh
168
+ rustup target add wasm32-unknown-unknown
169
+ bun run build:wasm # cargo + wasm-opt -Oz (wasm-opt from PATH, else `npx -p binaryen`); --no-opt to skip
170
+ bun test # bun:test suite (test/resampler.test.ts)
171
+ bun run smoke:node # node test/smoke/node.ts (TypeScript sources run directly; Node >= 22.6)
172
+ bun run smoke:deno # deno run -A test/smoke/deno.ts
173
+ bun run smoke:workerd # bundles test/smoke/workerd/worker.ts with the "workerd" condition, serves it with `npx workerd`
174
+ ```