@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 +25 -0
- package/NOTICE +9 -0
- package/README.md +173 -2
- package/THIRD_PARTY_LICENSES.md +640 -0
- package/dist/engine.d.ts +138 -0
- package/dist/engine.js +275 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +83 -0
- package/dist/state.d.ts +9 -0
- package/dist/state.js +24 -0
- package/dist/workerd.d.ts +8 -0
- package/dist/workerd.js +27 -0
- package/package.json +54 -4
- package/rust/Cargo.lock +137 -0
- package/rust/Cargo.toml +27 -0
- package/rust/src/lib.rs +496 -0
- package/wasm/resampler.wasm +0 -0
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand-written wrapper over the rubato resampler's WebAssembly exports.
|
|
3
|
+
*
|
|
4
|
+
* The module has no imports and a tiny C ABI (see `rust/src/lib.rs`), so
|
|
5
|
+
* there is no generated glue: we instantiate it, keep the exports, and copy
|
|
6
|
+
* PCM in and out of linear memory. Everything here is synchronous; only
|
|
7
|
+
* loading the module (`initResampler`) is async.
|
|
8
|
+
*/
|
|
9
|
+
/** Quality presets, same names as libsoxr's (QQ fastest .. VHQ best). */
|
|
10
|
+
export type Quality = "QQ" | "LQ" | "MQ" | "HQ" | "VHQ";
|
|
11
|
+
/** All presets, lowest quality first. */
|
|
12
|
+
export declare const QUALITIES: readonly Quality[];
|
|
13
|
+
export interface ResampleOptions {
|
|
14
|
+
/** Quality preset. Defaults to "VHQ" (as upstream's soxr resamplers). */
|
|
15
|
+
quality?: Quality;
|
|
16
|
+
/** Interleaved channel count, 1..8. Defaults to 1. */
|
|
17
|
+
channels?: number;
|
|
18
|
+
}
|
|
19
|
+
/** What a quality preset maps to in rubato (see the README for the table). */
|
|
20
|
+
export interface QualityInfo {
|
|
21
|
+
quality: Quality;
|
|
22
|
+
sincLen: number;
|
|
23
|
+
oversampling: number;
|
|
24
|
+
}
|
|
25
|
+
interface Exports {
|
|
26
|
+
memory: WebAssembly.Memory;
|
|
27
|
+
rs_stream_new(inRate: number, outRate: number, channels: number, quality: number): number;
|
|
28
|
+
rs_stream_free(h: number): void;
|
|
29
|
+
rs_stream_input(h: number, n: number): number;
|
|
30
|
+
rs_stream_process(h: number, n: number): number;
|
|
31
|
+
rs_stream_flush(h: number): number;
|
|
32
|
+
rs_stream_output(h: number): number;
|
|
33
|
+
rs_stream_input_f32(h: number, n: number): number;
|
|
34
|
+
rs_stream_process_f32(h: number, n: number): number;
|
|
35
|
+
rs_stream_flush_f32(h: number): number;
|
|
36
|
+
rs_stream_output_f32(h: number): number;
|
|
37
|
+
rs_stream_reset(h: number): void;
|
|
38
|
+
rs_stream_delay(h: number): number;
|
|
39
|
+
rs_stream_cutoff(h: number): number;
|
|
40
|
+
rs_quality_sinc_len(q: number): number;
|
|
41
|
+
rs_quality_oversampling(q: number): number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A stateful streaming resampler (one rubato instance in WASM memory).
|
|
45
|
+
*
|
|
46
|
+
* Accepts chunks of any size and keeps the filter history between them, so
|
|
47
|
+
* there are no clicks at chunk boundaries. Output is aligned with the input
|
|
48
|
+
* (the filter delay is trimmed), and `flush()` emits the tail so the stream's
|
|
49
|
+
* total output is round(totalInputFrames * outRate / inRate) frames.
|
|
50
|
+
*
|
|
51
|
+
* Call `destroy()` when done; a FinalizationRegistry frees forgotten streams
|
|
52
|
+
* eventually, but WASM memory is not reclaimed promptly by GC.
|
|
53
|
+
*/
|
|
54
|
+
export declare class RubatoStream {
|
|
55
|
+
#private;
|
|
56
|
+
readonly inRate: number;
|
|
57
|
+
readonly outRate: number;
|
|
58
|
+
readonly channels: number;
|
|
59
|
+
readonly quality: Quality;
|
|
60
|
+
/** @internal Use `RubatoEngine.createStream`. */
|
|
61
|
+
constructor(engine: RubatoEngine, x: Exports, inRate: number, outRate: number, opts: ResampleOptions);
|
|
62
|
+
/** Filter delay in output frames (already compensated for in the output). */
|
|
63
|
+
get delayFrames(): number;
|
|
64
|
+
/** Relative anti-aliasing cutoff (1.0 = input Nyquist; scaled when downsampling). */
|
|
65
|
+
get cutoff(): number;
|
|
66
|
+
/** Resample interleaved int16 samples. Returns a fresh array. */
|
|
67
|
+
processSamples(samples: Int16Array): Int16Array;
|
|
68
|
+
/** Resample interleaved float samples in [-1, 1] (unclipped). Returns a fresh array. */
|
|
69
|
+
processFloat32(samples: Float32Array): Float32Array;
|
|
70
|
+
/** Resample 16-bit little-endian PCM bytes. */
|
|
71
|
+
process(audio: Uint8Array): Uint8Array;
|
|
72
|
+
/** Emit the audio held in the filter as int16 samples, then reset. */
|
|
73
|
+
flushSamples(): Int16Array;
|
|
74
|
+
/** Emit the audio held in the filter as float samples, then reset. */
|
|
75
|
+
flushFloat32(): Float32Array;
|
|
76
|
+
/** Emit the audio held in the filter as PCM bytes, then reset. */
|
|
77
|
+
flush(): Uint8Array;
|
|
78
|
+
/** Discard the audio held in the filter and start a new stream. */
|
|
79
|
+
reset(): void;
|
|
80
|
+
/** Free the WASM-side state. The stream is unusable afterwards. */
|
|
81
|
+
destroy(): void;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The resampler engine: one instantiated WASM module. Cheap to share; all
|
|
85
|
+
* methods are synchronous.
|
|
86
|
+
*/
|
|
87
|
+
export declare class RubatoEngine {
|
|
88
|
+
#private;
|
|
89
|
+
constructor(instance: WebAssembly.Instance);
|
|
90
|
+
/** @internal */
|
|
91
|
+
_register(stream: RubatoStream, h: number, token: object): void;
|
|
92
|
+
/** @internal */
|
|
93
|
+
static _unregister(token: object): void;
|
|
94
|
+
/** Create a streaming resampler. */
|
|
95
|
+
createStream(inRate: number, outRate: number, opts?: ResampleOptions): RubatoStream;
|
|
96
|
+
/** One-shot resampling of interleaved int16 samples (whole clip, tail included). */
|
|
97
|
+
resampleSamples(samples: Int16Array, inRate: number, outRate: number, opts?: ResampleOptions): Int16Array;
|
|
98
|
+
/** One-shot resampling of interleaved float samples (whole clip, tail included). */
|
|
99
|
+
resampleFloat32(samples: Float32Array, inRate: number, outRate: number, opts?: ResampleOptions): Float32Array;
|
|
100
|
+
/**
|
|
101
|
+
* One-shot resampling of 16-bit little-endian PCM bytes. Returns the input
|
|
102
|
+
* unchanged when the rates are equal, like upstream.
|
|
103
|
+
*/
|
|
104
|
+
resample(audio: Uint8Array, inRate: number, outRate: number, opts?: ResampleOptions): Uint8Array;
|
|
105
|
+
/** The rubato parameters behind a quality preset. */
|
|
106
|
+
qualityInfo(quality: Quality): QualityInfo;
|
|
107
|
+
}
|
|
108
|
+
/** Instantiate the engine from a compiled module (synchronous). */
|
|
109
|
+
export declare function engineFromModule(module: WebAssembly.Module): RubatoEngine;
|
|
110
|
+
/**
|
|
111
|
+
* Instantiate the engine from a module or raw bytes. Compiling from bytes is
|
|
112
|
+
* not allowed on Cloudflare Workers; use the `workerd` entry there.
|
|
113
|
+
*/
|
|
114
|
+
export declare function engineFromSource(source: WebAssembly.Module | BufferSource): Promise<RubatoEngine>;
|
|
115
|
+
/** Mirrors python-soxr's `soxr.ResampleStream` (int16 or float32, by first use). */
|
|
116
|
+
export interface SoxrCompatibleStream {
|
|
117
|
+
resampleChunk<T extends Int16Array | Float32Array>(chunk: T, last?: boolean): T;
|
|
118
|
+
clear(): void;
|
|
119
|
+
destroy(): void;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The shape core's `SOXRAudioResampler` / `SOXRStreamAudioResampler` delegate
|
|
123
|
+
* to: `resample` stands in for `soxr.resample()` and `createStream` for
|
|
124
|
+
* `soxr.ResampleStream(...)`.
|
|
125
|
+
*/
|
|
126
|
+
export interface SoxrCompatibleEngine {
|
|
127
|
+
resample<T extends Int16Array | Float32Array>(audio: T, inRate: number, outRate: number, quality: Quality): T;
|
|
128
|
+
createStream(options: {
|
|
129
|
+
inRate: number;
|
|
130
|
+
outRate: number;
|
|
131
|
+
numChannels: number;
|
|
132
|
+
quality: Quality;
|
|
133
|
+
}): SoxrCompatibleStream;
|
|
134
|
+
}
|
|
135
|
+
/** Wrap an engine in the soxr-shaped API core expects. */
|
|
136
|
+
export declare function createSoxrCompatibleEngine(engine: RubatoEngine): SoxrCompatibleEngine;
|
|
137
|
+
export {};
|
|
138
|
+
//# sourceMappingURL=engine.d.ts.map
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Copyright (c) 2026, pipedog contributors
|
|
2
|
+
// SPDX-License-Identifier: BSD-2-Clause
|
|
3
|
+
/** All presets, lowest quality first. */
|
|
4
|
+
export const QUALITIES = ["QQ", "LQ", "MQ", "HQ", "VHQ"];
|
|
5
|
+
const EMPTY_BYTES = new Uint8Array(0);
|
|
6
|
+
function qualityIndex(quality) {
|
|
7
|
+
const i = QUALITIES.indexOf(quality);
|
|
8
|
+
if (i < 0)
|
|
9
|
+
throw new Error(`unknown resampler quality: ${String(quality)}`);
|
|
10
|
+
return i;
|
|
11
|
+
}
|
|
12
|
+
/** View 16-bit PCM bytes as samples (copying when the offset is odd). */
|
|
13
|
+
function bytesToInt16(audio) {
|
|
14
|
+
const length = audio.length >> 1;
|
|
15
|
+
if (audio.byteOffset % 2 !== 0)
|
|
16
|
+
return new Int16Array(audio.slice(0, length * 2).buffer);
|
|
17
|
+
return new Int16Array(audio.buffer, audio.byteOffset, length);
|
|
18
|
+
}
|
|
19
|
+
function int16ToBytes(samples) {
|
|
20
|
+
return new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A stateful streaming resampler (one rubato instance in WASM memory).
|
|
24
|
+
*
|
|
25
|
+
* Accepts chunks of any size and keeps the filter history between them, so
|
|
26
|
+
* there are no clicks at chunk boundaries. Output is aligned with the input
|
|
27
|
+
* (the filter delay is trimmed), and `flush()` emits the tail so the stream's
|
|
28
|
+
* total output is round(totalInputFrames * outRate / inRate) frames.
|
|
29
|
+
*
|
|
30
|
+
* Call `destroy()` when done; a FinalizationRegistry frees forgotten streams
|
|
31
|
+
* eventually, but WASM memory is not reclaimed promptly by GC.
|
|
32
|
+
*/
|
|
33
|
+
export class RubatoStream {
|
|
34
|
+
inRate;
|
|
35
|
+
outRate;
|
|
36
|
+
channels;
|
|
37
|
+
quality;
|
|
38
|
+
#x;
|
|
39
|
+
#h;
|
|
40
|
+
#token = {};
|
|
41
|
+
/** @internal Use `RubatoEngine.createStream`. */
|
|
42
|
+
constructor(engine, x, inRate, outRate, opts) {
|
|
43
|
+
this.inRate = inRate;
|
|
44
|
+
this.outRate = outRate;
|
|
45
|
+
this.channels = opts.channels ?? 1;
|
|
46
|
+
this.quality = opts.quality ?? "VHQ";
|
|
47
|
+
if (!Number.isInteger(inRate) || !Number.isInteger(outRate) || inRate <= 0 || outRate <= 0) {
|
|
48
|
+
throw new Error(`invalid sample rates: ${inRate}->${outRate}`);
|
|
49
|
+
}
|
|
50
|
+
if (!Number.isInteger(this.channels) || this.channels < 1 || this.channels > 8) {
|
|
51
|
+
throw new Error(`invalid channel count: ${this.channels}`);
|
|
52
|
+
}
|
|
53
|
+
this.#x = x;
|
|
54
|
+
this.#h = x.rs_stream_new(inRate, outRate, this.channels, qualityIndex(this.quality));
|
|
55
|
+
if (this.#h === 0)
|
|
56
|
+
throw new Error(`could not create resampler ${inRate}->${outRate}`);
|
|
57
|
+
engine._register(this, this.#h, this.#token);
|
|
58
|
+
}
|
|
59
|
+
#handle() {
|
|
60
|
+
if (this.#h === 0)
|
|
61
|
+
throw new Error("RubatoStream has been destroyed");
|
|
62
|
+
return this.#h;
|
|
63
|
+
}
|
|
64
|
+
/** Filter delay in output frames (already compensated for in the output). */
|
|
65
|
+
get delayFrames() {
|
|
66
|
+
return this.#x.rs_stream_delay(this.#handle());
|
|
67
|
+
}
|
|
68
|
+
/** Relative anti-aliasing cutoff (1.0 = input Nyquist; scaled when downsampling). */
|
|
69
|
+
get cutoff() {
|
|
70
|
+
return this.#x.rs_stream_cutoff(this.#handle());
|
|
71
|
+
}
|
|
72
|
+
/** Resample interleaved int16 samples. Returns a fresh array. */
|
|
73
|
+
processSamples(samples) {
|
|
74
|
+
const h = this.#handle();
|
|
75
|
+
const x = this.#x;
|
|
76
|
+
if (samples.length > 0) {
|
|
77
|
+
const ptr = x.rs_stream_input(h, samples.length);
|
|
78
|
+
new Int16Array(x.memory.buffer, ptr, samples.length).set(samples);
|
|
79
|
+
}
|
|
80
|
+
return this.#readI16(h, x.rs_stream_process(h, samples.length));
|
|
81
|
+
}
|
|
82
|
+
/** Resample interleaved float samples in [-1, 1] (unclipped). Returns a fresh array. */
|
|
83
|
+
processFloat32(samples) {
|
|
84
|
+
const h = this.#handle();
|
|
85
|
+
const x = this.#x;
|
|
86
|
+
if (samples.length > 0) {
|
|
87
|
+
const ptr = x.rs_stream_input_f32(h, samples.length);
|
|
88
|
+
new Float32Array(x.memory.buffer, ptr, samples.length).set(samples);
|
|
89
|
+
}
|
|
90
|
+
return this.#readF32(h, x.rs_stream_process_f32(h, samples.length));
|
|
91
|
+
}
|
|
92
|
+
/** Resample 16-bit little-endian PCM bytes. */
|
|
93
|
+
process(audio) {
|
|
94
|
+
const out = this.processSamples(bytesToInt16(audio));
|
|
95
|
+
return out.length === 0 ? EMPTY_BYTES : int16ToBytes(out);
|
|
96
|
+
}
|
|
97
|
+
/** Emit the audio held in the filter as int16 samples, then reset. */
|
|
98
|
+
flushSamples() {
|
|
99
|
+
const h = this.#handle();
|
|
100
|
+
return this.#readI16(h, this.#x.rs_stream_flush(h));
|
|
101
|
+
}
|
|
102
|
+
/** Emit the audio held in the filter as float samples, then reset. */
|
|
103
|
+
flushFloat32() {
|
|
104
|
+
const h = this.#handle();
|
|
105
|
+
return this.#readF32(h, this.#x.rs_stream_flush_f32(h));
|
|
106
|
+
}
|
|
107
|
+
/** Emit the audio held in the filter as PCM bytes, then reset. */
|
|
108
|
+
flush() {
|
|
109
|
+
const out = this.flushSamples();
|
|
110
|
+
return out.length === 0 ? EMPTY_BYTES : int16ToBytes(out);
|
|
111
|
+
}
|
|
112
|
+
/** Discard the audio held in the filter and start a new stream. */
|
|
113
|
+
reset() {
|
|
114
|
+
this.#x.rs_stream_reset(this.#handle());
|
|
115
|
+
}
|
|
116
|
+
/** Free the WASM-side state. The stream is unusable afterwards. */
|
|
117
|
+
destroy() {
|
|
118
|
+
if (this.#h === 0)
|
|
119
|
+
return;
|
|
120
|
+
RubatoEngine._unregister(this.#token);
|
|
121
|
+
this.#x.rs_stream_free(this.#h);
|
|
122
|
+
this.#h = 0;
|
|
123
|
+
}
|
|
124
|
+
#readI16(h, n) {
|
|
125
|
+
if (n < 0)
|
|
126
|
+
throw new Error("resampler failed");
|
|
127
|
+
if (n === 0)
|
|
128
|
+
return new Int16Array(0);
|
|
129
|
+
// Copy out: the view would be invalidated by the next call or a memory grow.
|
|
130
|
+
return new Int16Array(this.#x.memory.buffer, this.#x.rs_stream_output(h), n).slice();
|
|
131
|
+
}
|
|
132
|
+
#readF32(h, n) {
|
|
133
|
+
if (n < 0)
|
|
134
|
+
throw new Error("resampler failed");
|
|
135
|
+
if (n === 0)
|
|
136
|
+
return new Float32Array(0);
|
|
137
|
+
return new Float32Array(this.#x.memory.buffer, this.#x.rs_stream_output_f32(h), n).slice();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The resampler engine: one instantiated WASM module. Cheap to share; all
|
|
142
|
+
* methods are synchronous.
|
|
143
|
+
*/
|
|
144
|
+
export class RubatoEngine {
|
|
145
|
+
#x;
|
|
146
|
+
static #registry = typeof FinalizationRegistry === "function"
|
|
147
|
+
? new FinalizationRegistry(({ x, h }) => x.rs_stream_free(h))
|
|
148
|
+
: null;
|
|
149
|
+
constructor(instance) {
|
|
150
|
+
this.#x = instance.exports;
|
|
151
|
+
if (typeof this.#x.rs_stream_new !== "function") {
|
|
152
|
+
throw new Error("not the @pipedog/resampler-wasm module");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** @internal */
|
|
156
|
+
_register(stream, h, token) {
|
|
157
|
+
RubatoEngine.#registry?.register(stream, { x: this.#x, h }, token);
|
|
158
|
+
}
|
|
159
|
+
/** @internal */
|
|
160
|
+
static _unregister(token) {
|
|
161
|
+
RubatoEngine.#registry?.unregister(token);
|
|
162
|
+
}
|
|
163
|
+
/** Create a streaming resampler. */
|
|
164
|
+
createStream(inRate, outRate, opts = {}) {
|
|
165
|
+
return new RubatoStream(this, this.#x, inRate, outRate, opts);
|
|
166
|
+
}
|
|
167
|
+
/** One-shot resampling of interleaved int16 samples (whole clip, tail included). */
|
|
168
|
+
resampleSamples(samples, inRate, outRate, opts = {}) {
|
|
169
|
+
if (inRate === outRate)
|
|
170
|
+
return samples;
|
|
171
|
+
const stream = this.createStream(inRate, outRate, opts);
|
|
172
|
+
try {
|
|
173
|
+
const head = stream.processSamples(samples);
|
|
174
|
+
const tail = stream.flushSamples();
|
|
175
|
+
if (tail.length === 0)
|
|
176
|
+
return head;
|
|
177
|
+
const out = new Int16Array(head.length + tail.length);
|
|
178
|
+
out.set(head);
|
|
179
|
+
out.set(tail, head.length);
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
stream.destroy();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** One-shot resampling of interleaved float samples (whole clip, tail included). */
|
|
187
|
+
resampleFloat32(samples, inRate, outRate, opts = {}) {
|
|
188
|
+
if (inRate === outRate)
|
|
189
|
+
return samples;
|
|
190
|
+
const stream = this.createStream(inRate, outRate, opts);
|
|
191
|
+
try {
|
|
192
|
+
const head = stream.processFloat32(samples);
|
|
193
|
+
const tail = stream.flushFloat32();
|
|
194
|
+
if (tail.length === 0)
|
|
195
|
+
return head;
|
|
196
|
+
const out = new Float32Array(head.length + tail.length);
|
|
197
|
+
out.set(head);
|
|
198
|
+
out.set(tail, head.length);
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
stream.destroy();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* One-shot resampling of 16-bit little-endian PCM bytes. Returns the input
|
|
207
|
+
* unchanged when the rates are equal, like upstream.
|
|
208
|
+
*/
|
|
209
|
+
resample(audio, inRate, outRate, opts = {}) {
|
|
210
|
+
if (inRate === outRate)
|
|
211
|
+
return audio;
|
|
212
|
+
return int16ToBytes(this.resampleSamples(bytesToInt16(audio), inRate, outRate, opts));
|
|
213
|
+
}
|
|
214
|
+
/** The rubato parameters behind a quality preset. */
|
|
215
|
+
qualityInfo(quality) {
|
|
216
|
+
const q = qualityIndex(quality);
|
|
217
|
+
return {
|
|
218
|
+
quality,
|
|
219
|
+
sincLen: this.#x.rs_quality_sinc_len(q),
|
|
220
|
+
oversampling: this.#x.rs_quality_oversampling(q),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Instantiate the engine from a compiled module (synchronous). */
|
|
225
|
+
export function engineFromModule(module) {
|
|
226
|
+
return new RubatoEngine(new WebAssembly.Instance(module, {}));
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Instantiate the engine from a module or raw bytes. Compiling from bytes is
|
|
230
|
+
* not allowed on Cloudflare Workers; use the `workerd` entry there.
|
|
231
|
+
*/
|
|
232
|
+
export async function engineFromSource(source) {
|
|
233
|
+
if (source instanceof WebAssembly.Module)
|
|
234
|
+
return engineFromModule(source);
|
|
235
|
+
const { instance } = await WebAssembly.instantiate(source, {});
|
|
236
|
+
return new RubatoEngine(instance);
|
|
237
|
+
}
|
|
238
|
+
/** Wrap an engine in the soxr-shaped API core expects. */
|
|
239
|
+
export function createSoxrCompatibleEngine(engine) {
|
|
240
|
+
return {
|
|
241
|
+
resample(audio, inRate, outRate, quality) {
|
|
242
|
+
if (audio instanceof Float32Array)
|
|
243
|
+
return engine.resampleFloat32(audio, inRate, outRate, { quality });
|
|
244
|
+
return engine.resampleSamples(audio, inRate, outRate, { quality });
|
|
245
|
+
},
|
|
246
|
+
createStream({ inRate, outRate, numChannels, quality }) {
|
|
247
|
+
const stream = engine.createStream(inRate, outRate, { quality, channels: numChannels });
|
|
248
|
+
return {
|
|
249
|
+
resampleChunk(chunk, last = false) {
|
|
250
|
+
if (chunk instanceof Float32Array) {
|
|
251
|
+
const head = stream.processFloat32(chunk);
|
|
252
|
+
if (!last)
|
|
253
|
+
return head;
|
|
254
|
+
const tail = stream.flushFloat32();
|
|
255
|
+
const out = new Float32Array(head.length + tail.length);
|
|
256
|
+
out.set(head);
|
|
257
|
+
out.set(tail, head.length);
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
const head = stream.processSamples(chunk);
|
|
261
|
+
if (!last)
|
|
262
|
+
return head;
|
|
263
|
+
const tail = stream.flushSamples();
|
|
264
|
+
const out = new Int16Array(head.length + tail.length);
|
|
265
|
+
out.set(head);
|
|
266
|
+
out.set(tail, head.length);
|
|
267
|
+
return out;
|
|
268
|
+
},
|
|
269
|
+
clear: () => stream.reset(),
|
|
270
|
+
destroy: () => stream.destroy(),
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
//# sourceMappingURL=engine.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default entry (Bun, Node >= 22, Deno, browsers via a bundler).
|
|
3
|
+
*
|
|
4
|
+
* `await initResampler()` once, then everything is synchronous:
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { initResampler } from "@pipedog/resampler-wasm";
|
|
9
|
+
* const engine = await initResampler();
|
|
10
|
+
* const out = engine.resample(pcm16, 8000, 16000, { quality: "VHQ" });
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* On Cloudflare Workers the package resolves to `workerd.ts` instead (the
|
|
14
|
+
* "workerd" export condition), which imports the .wasm as a module because
|
|
15
|
+
* workerd cannot compile WASM from bytes.
|
|
16
|
+
*/
|
|
17
|
+
import { type RubatoEngine } from "./engine.ts";
|
|
18
|
+
export * from "./engine.ts";
|
|
19
|
+
export type ResamplerSource = WebAssembly.Module | BufferSource | URL | Response;
|
|
20
|
+
/**
|
|
21
|
+
* Load and instantiate the WASM resampler (idempotent). With no argument the
|
|
22
|
+
* bundled `wasm/resampler.wasm` is loaded next to this file. Passing a source
|
|
23
|
+
* replaces the shared engine.
|
|
24
|
+
*/
|
|
25
|
+
export declare function initResampler(source?: ResamplerSource): Promise<RubatoEngine>;
|
|
26
|
+
/** The engine set up by `initResampler()`. Throws if it has not been awaited yet. */
|
|
27
|
+
export declare function getResampler(): RubatoEngine;
|
|
28
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Copyright (c) 2026, pipedog contributors
|
|
2
|
+
// SPDX-License-Identifier: BSD-2-Clause
|
|
3
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
4
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
5
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
6
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
return path;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Default entry (Bun, Node >= 22, Deno, browsers via a bundler).
|
|
13
|
+
*
|
|
14
|
+
* `await initResampler()` once, then everything is synchronous:
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { initResampler } from "@pipedog/resampler-wasm";
|
|
19
|
+
* const engine = await initResampler();
|
|
20
|
+
* const out = engine.resample(pcm16, 8000, 16000, { quality: "VHQ" });
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* On Cloudflare Workers the package resolves to `workerd.ts` instead (the
|
|
24
|
+
* "workerd" export condition), which imports the .wasm as a module because
|
|
25
|
+
* workerd cannot compile WASM from bytes.
|
|
26
|
+
*/
|
|
27
|
+
import { engineFromSource } from "./engine.js";
|
|
28
|
+
import { currentEngine, initOnce, setEngine } from "./state.js";
|
|
29
|
+
export * from "./engine.js";
|
|
30
|
+
// Resolved lazily: in a Worker bundle `import.meta.url` is not a valid base, so
|
|
31
|
+
// building the URL at module load would crash any import of this entry there.
|
|
32
|
+
function defaultWasmUrl() {
|
|
33
|
+
return new URL("../wasm/resampler.wasm", import.meta.url);
|
|
34
|
+
}
|
|
35
|
+
async function readUrl(url) {
|
|
36
|
+
if (url.protocol === "file:") {
|
|
37
|
+
// Bun and Deno fetch file: URLs; Node's fetch does not, so fall back to fs.
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(url);
|
|
40
|
+
if (res.ok)
|
|
41
|
+
return await res.arrayBuffer();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// fall through to node:fs
|
|
45
|
+
}
|
|
46
|
+
// A variable specifier keeps bundlers (and type checkers without @types/node) out of it.
|
|
47
|
+
const specifier = "node:fs/promises";
|
|
48
|
+
const fs = (await import(__rewriteRelativeImportExtension(specifier)));
|
|
49
|
+
return (await fs.readFile(url));
|
|
50
|
+
}
|
|
51
|
+
const res = await fetch(url);
|
|
52
|
+
if (!res.ok)
|
|
53
|
+
throw new Error(`failed to fetch ${url.href}: ${res.status}`);
|
|
54
|
+
return await res.arrayBuffer();
|
|
55
|
+
}
|
|
56
|
+
async function load(source) {
|
|
57
|
+
if (source === undefined)
|
|
58
|
+
return engineFromSource(await readUrl(defaultWasmUrl()));
|
|
59
|
+
if (source instanceof URL)
|
|
60
|
+
return engineFromSource(await readUrl(source));
|
|
61
|
+
if (typeof Response !== "undefined" && source instanceof Response) {
|
|
62
|
+
return engineFromSource(await source.arrayBuffer());
|
|
63
|
+
}
|
|
64
|
+
return engineFromSource(source);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Load and instantiate the WASM resampler (idempotent). With no argument the
|
|
68
|
+
* bundled `wasm/resampler.wasm` is loaded next to this file. Passing a source
|
|
69
|
+
* replaces the shared engine.
|
|
70
|
+
*/
|
|
71
|
+
export async function initResampler(source) {
|
|
72
|
+
if (source !== undefined)
|
|
73
|
+
return setEngine(await load(source));
|
|
74
|
+
return initOnce(() => load(undefined));
|
|
75
|
+
}
|
|
76
|
+
/** The engine set up by `initResampler()`. Throws if it has not been awaited yet. */
|
|
77
|
+
export function getResampler() {
|
|
78
|
+
const engine = currentEngine();
|
|
79
|
+
if (!engine)
|
|
80
|
+
throw new Error("call `await initResampler()` before using the resampler");
|
|
81
|
+
return engine;
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=index.js.map
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The process-wide engine shared by both entry points. */
|
|
2
|
+
import type { RubatoEngine } from "./engine.ts";
|
|
3
|
+
/** @internal */
|
|
4
|
+
export declare function setEngine(e: RubatoEngine): RubatoEngine;
|
|
5
|
+
/** @internal */
|
|
6
|
+
export declare function currentEngine(): RubatoEngine | null;
|
|
7
|
+
/** @internal Run `load` once; concurrent callers share the promise. */
|
|
8
|
+
export declare function initOnce(load: () => Promise<RubatoEngine>): Promise<RubatoEngine>;
|
|
9
|
+
//# sourceMappingURL=state.d.ts.map
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Copyright (c) 2026, pipedog contributors
|
|
2
|
+
// SPDX-License-Identifier: BSD-2-Clause
|
|
3
|
+
let engine = null;
|
|
4
|
+
let pending = null;
|
|
5
|
+
/** @internal */
|
|
6
|
+
export function setEngine(e) {
|
|
7
|
+
engine = e;
|
|
8
|
+
return e;
|
|
9
|
+
}
|
|
10
|
+
/** @internal */
|
|
11
|
+
export function currentEngine() {
|
|
12
|
+
return engine;
|
|
13
|
+
}
|
|
14
|
+
/** @internal Run `load` once; concurrent callers share the promise. */
|
|
15
|
+
export function initOnce(load) {
|
|
16
|
+
if (engine)
|
|
17
|
+
return Promise.resolve(engine);
|
|
18
|
+
pending ??= load().then(setEngine, (e) => {
|
|
19
|
+
pending = null;
|
|
20
|
+
throw e;
|
|
21
|
+
});
|
|
22
|
+
return pending;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=state.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type RubatoEngine } from "./engine.ts";
|
|
2
|
+
export * from "./engine.ts";
|
|
3
|
+
export type ResamplerSource = WebAssembly.Module | BufferSource | URL;
|
|
4
|
+
/** Instantiate the resampler (idempotent). Only a `WebAssembly.Module` source works on workerd. */
|
|
5
|
+
export declare function initResampler(source?: ResamplerSource): Promise<RubatoEngine>;
|
|
6
|
+
/** The shared engine, instantiated from the bundled module on first use. */
|
|
7
|
+
export declare function getResampler(): RubatoEngine;
|
|
8
|
+
//# sourceMappingURL=workerd.d.ts.map
|
package/dist/workerd.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Copyright (c) 2026, pipedog contributors
|
|
2
|
+
// SPDX-License-Identifier: BSD-2-Clause
|
|
3
|
+
/**
|
|
4
|
+
* Cloudflare Workers entry (the "workerd" export condition).
|
|
5
|
+
*
|
|
6
|
+
* workerd forbids compiling WebAssembly from bytes at runtime, so the module
|
|
7
|
+
* is imported as a pre-compiled `WebAssembly.Module` (wrangler's default rule
|
|
8
|
+
* for `.wasm`) and instantiated synchronously. `getResampler()` works without
|
|
9
|
+
* calling `initResampler()` first.
|
|
10
|
+
*/
|
|
11
|
+
import wasm from "../wasm/resampler.wasm";
|
|
12
|
+
import { engineFromModule, engineFromSource } from "./engine.js";
|
|
13
|
+
import { currentEngine, setEngine } from "./state.js";
|
|
14
|
+
export * from "./engine.js";
|
|
15
|
+
/** Instantiate the resampler (idempotent). Only a `WebAssembly.Module` source works on workerd. */
|
|
16
|
+
export async function initResampler(source) {
|
|
17
|
+
if (source === undefined)
|
|
18
|
+
return getResampler();
|
|
19
|
+
if (source instanceof URL)
|
|
20
|
+
throw new Error("initResampler(URL) is not supported on workerd");
|
|
21
|
+
return setEngine(await engineFromSource(source));
|
|
22
|
+
}
|
|
23
|
+
/** The shared engine, instantiated from the bundled module on first use. */
|
|
24
|
+
export function getResampler() {
|
|
25
|
+
return currentEngine() ?? setEngine(engineFromModule(wasm));
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=workerd.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipedog/resampler-wasm",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "High-quality PCM resampling (rubato sinc, compiled to WebAssembly) for pipedog: one-shot and streaming, soxr-style quality presets, runs on Bun, Node, Deno and Cloudflare Workers.",
|
|
5
|
+
"license": "BSD-2-Clause",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/polvallverdu/pipedog.git",
|
|
9
|
+
"directory": "packages/resampler-wasm"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/polvallverdu/pipedog/tree/main/packages/resampler-wasm#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/polvallverdu/pipedog/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"workerd": "./dist/workerd.js",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./engine": {
|
|
24
|
+
"types": "./dist/engine.d.ts",
|
|
25
|
+
"default": "./dist/engine.js"
|
|
26
|
+
},
|
|
27
|
+
"./resampler.wasm": "./wasm/resampler.wasm"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"wasm",
|
|
32
|
+
"rust/Cargo.toml",
|
|
33
|
+
"rust/Cargo.lock",
|
|
34
|
+
"rust/src",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"NOTICE",
|
|
37
|
+
"THIRD_PARTY_LICENSES.md"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build:wasm": "bun scripts/build-wasm.ts",
|
|
41
|
+
"test": "bun test",
|
|
42
|
+
"smoke:node": "node test/smoke/node.ts",
|
|
43
|
+
"smoke:deno": "deno run -A test/smoke/deno.ts",
|
|
44
|
+
"smoke:workerd": "bun test/smoke/workerd/run.ts"
|
|
45
|
+
},
|
|
46
|
+
"pipecat": {
|
|
47
|
+
"runtimes": [
|
|
48
|
+
"bun",
|
|
49
|
+
"node",
|
|
50
|
+
"deno",
|
|
51
|
+
"workerd"
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
"main": "./dist/index.js",
|
|
55
|
+
"types": "./dist/index.d.ts"
|
|
56
|
+
}
|