@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 +25 -0
- package/NOTICE +9 -0
- package/README.md +183 -2
- package/THIRD_PARTY_LICENSES.md +640 -0
- package/dist/engine.d.ts +140 -0
- package/dist/engine.js +278 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +111 -0
- package/dist/state.d.ts +9 -0
- package/dist/state.js +24 -0
- package/dist/wasm_bytes.d.ts +5 -0
- package/dist/wasm_bytes.js +10 -0
- package/dist/workerd.d.ts +8 -0
- package/dist/workerd.js +27 -0
- package/package.json +47 -3
- package/rust/Cargo.lock +137 -0
- package/rust/Cargo.toml +27 -0
- package/rust/src/lib.rs +533 -0
- package/wasm/resampler.wasm +0 -0
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
|
-
|
|
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
|
-
|
|
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
|
+
```
|