@kuralle-syrinx/recorder 2.1.1 → 3.0.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/package.json +6 -2
- package/src/index.ts +7 -37
- package/src/wav.test.ts +42 -0
- package/src/wav.ts +52 -0
package/package.json
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/recorder",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"main": "./src/index.ts",
|
|
8
8
|
"types": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts",
|
|
11
|
+
"./wav": "./src/wav.ts"
|
|
12
|
+
},
|
|
9
13
|
"dependencies": {
|
|
10
|
-
"@kuralle-syrinx/core": "
|
|
14
|
+
"@kuralle-syrinx/core": "3.0.0"
|
|
11
15
|
},
|
|
12
16
|
"devDependencies": {
|
|
13
17
|
"@types/node": "^22.0.0",
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,11 @@ import {
|
|
|
14
14
|
type VoicePacket,
|
|
15
15
|
type VoicePlugin,
|
|
16
16
|
} from "@kuralle-syrinx/core";
|
|
17
|
+
import { interleaveStereoPcm16, pcm16ToWav } from "./wav.js";
|
|
18
|
+
|
|
19
|
+
// Re-export the runtime-agnostic builders so Workers hosts can `@kuralle-syrinx/recorder/wav`
|
|
20
|
+
// (no node:fs) without reaching through this Node-only entry point.
|
|
21
|
+
export { interleaveStereoPcm16, pcm16ToWav } from "./wav.js";
|
|
17
22
|
|
|
18
23
|
export interface VoiceSessionRecorderConfig {
|
|
19
24
|
readonly outputDir: string;
|
|
@@ -352,9 +357,9 @@ export class VoiceSessionRecorder implements VoicePlugin {
|
|
|
352
357
|
const resampled = assistantRate !== userRate
|
|
353
358
|
? resampleLinear(assistantPcm, assistantRate, userRate)
|
|
354
359
|
: assistantPcm;
|
|
355
|
-
const stereo =
|
|
360
|
+
const stereo = interleaveStereoPcm16(userPcm, resampled);
|
|
356
361
|
this.conversationAudioBytes = stereo.byteLength;
|
|
357
|
-
await writeFile(this.conversationAudioPath,
|
|
362
|
+
await writeFile(this.conversationAudioPath, pcm16ToWav(stereo, userRate, 2));
|
|
358
363
|
}
|
|
359
364
|
|
|
360
365
|
private async writeManifest(): Promise<void> {
|
|
@@ -791,38 +796,3 @@ function resampleLinear(src: Buffer, srcRate: number, dstRate: number): Buffer {
|
|
|
791
796
|
return dst;
|
|
792
797
|
}
|
|
793
798
|
|
|
794
|
-
function interleaveInt16(left: Buffer, right: Buffer): Buffer {
|
|
795
|
-
const leftSamples = left.byteLength >> 1;
|
|
796
|
-
const rightSamples = right.byteLength >> 1;
|
|
797
|
-
const maxSamples = Math.max(leftSamples, rightSamples);
|
|
798
|
-
if (maxSamples === 0) return Buffer.alloc(0);
|
|
799
|
-
const out = Buffer.alloc(maxSamples * 4);
|
|
800
|
-
for (let i = 0; i < maxSamples; i++) {
|
|
801
|
-
const l = i < leftSamples ? left.readInt16LE(i * 2) : 0;
|
|
802
|
-
const r = i < rightSamples ? right.readInt16LE(i * 2) : 0;
|
|
803
|
-
out.writeInt16LE(l, i * 4);
|
|
804
|
-
out.writeInt16LE(r, i * 4 + 2);
|
|
805
|
-
}
|
|
806
|
-
return out;
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
function buildWav(pcm: Buffer, channels: number, sampleRateHz: number): Buffer {
|
|
810
|
-
const bitsPerSample = 16;
|
|
811
|
-
const blockAlign = channels * (bitsPerSample >> 3);
|
|
812
|
-
const byteRate = sampleRateHz * blockAlign;
|
|
813
|
-
const header = Buffer.allocUnsafe(44);
|
|
814
|
-
header.write("RIFF", 0, "ascii");
|
|
815
|
-
header.writeUInt32LE(36 + pcm.byteLength, 4);
|
|
816
|
-
header.write("WAVE", 8, "ascii");
|
|
817
|
-
header.write("fmt ", 12, "ascii");
|
|
818
|
-
header.writeUInt32LE(16, 16);
|
|
819
|
-
header.writeUInt16LE(1, 20);
|
|
820
|
-
header.writeUInt16LE(channels, 22);
|
|
821
|
-
header.writeUInt32LE(sampleRateHz, 24);
|
|
822
|
-
header.writeUInt32LE(byteRate, 28);
|
|
823
|
-
header.writeUInt16LE(blockAlign, 32);
|
|
824
|
-
header.writeUInt16LE(bitsPerSample, 34);
|
|
825
|
-
header.write("data", 36, "ascii");
|
|
826
|
-
header.writeUInt32LE(pcm.byteLength, 40);
|
|
827
|
-
return Buffer.concat([header, pcm]);
|
|
828
|
-
}
|
package/src/wav.test.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { interleaveStereoPcm16, pcm16ToWav } from "./wav.js";
|
|
5
|
+
|
|
6
|
+
describe("pcm16ToWav", () => {
|
|
7
|
+
it("writes a canonical 44-byte RIFF/PCM header for the given rate and channels", () => {
|
|
8
|
+
const pcm = new Uint8Array([1, 2, 3, 4]); // 2 mono samples
|
|
9
|
+
const wav = pcm16ToWav(pcm, 16000, 1);
|
|
10
|
+
expect(wav.byteLength).toBe(44 + 4);
|
|
11
|
+
const view = new DataView(wav.buffer);
|
|
12
|
+
expect(String.fromCharCode(wav[0]!, wav[1]!, wav[2]!, wav[3]!)).toBe("RIFF");
|
|
13
|
+
expect(view.getUint32(4, true)).toBe(36 + 4); // chunk size
|
|
14
|
+
expect(String.fromCharCode(wav[8]!, wav[9]!, wav[10]!, wav[11]!)).toBe("WAVE");
|
|
15
|
+
expect(view.getUint16(20, true)).toBe(1); // PCM
|
|
16
|
+
expect(view.getUint16(22, true)).toBe(1); // channels
|
|
17
|
+
expect(view.getUint32(24, true)).toBe(16000); // sample rate
|
|
18
|
+
expect(view.getUint16(32, true)).toBe(2); // blockAlign = channels * 2
|
|
19
|
+
expect(view.getUint32(40, true)).toBe(4); // data size
|
|
20
|
+
expect([...wav.subarray(44)]).toEqual([1, 2, 3, 4]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("sets stereo blockAlign and byteRate", () => {
|
|
24
|
+
const view = new DataView(pcm16ToWav(new Uint8Array(8), 24000, 2).buffer);
|
|
25
|
+
expect(view.getUint16(32, true)).toBe(4); // 2ch * 2 bytes
|
|
26
|
+
expect(view.getUint32(28, true)).toBe(24000 * 4); // byteRate
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("interleaveStereoPcm16", () => {
|
|
31
|
+
it("interleaves L/R frames and pads the shorter stream with silence", () => {
|
|
32
|
+
const left = new Uint8Array([0x10, 0x00, 0x20, 0x00]); // samples 16, 32
|
|
33
|
+
const right = new Uint8Array([0x01, 0x00]); // sample 1, then padded
|
|
34
|
+
const out = interleaveStereoPcm16(left, right);
|
|
35
|
+
const view = new DataView(out.buffer);
|
|
36
|
+
expect(out.byteLength).toBe(2 * 4); // 2 frames × (2ch × 2 bytes)
|
|
37
|
+
expect(view.getInt16(0, true)).toBe(16); // L0
|
|
38
|
+
expect(view.getInt16(2, true)).toBe(1); // R0
|
|
39
|
+
expect(view.getInt16(4, true)).toBe(32); // L1
|
|
40
|
+
expect(view.getInt16(6, true)).toBe(0); // R1 padded
|
|
41
|
+
});
|
|
42
|
+
});
|
package/src/wav.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Pure, runtime-agnostic PCM16 → WAV and stereo-mix builders. No `node:fs`, no `Buffer` —
|
|
4
|
+
// just `Uint8Array` — so the same builders run on Node AND Cloudflare Workers (where a
|
|
5
|
+
// host writes the bytes to R2 instead of disk). The Node `VoiceSessionRecorder` and a
|
|
6
|
+
// Workers R2 recorder share these so the WAV/stereo logic can't drift between runtimes.
|
|
7
|
+
|
|
8
|
+
/** Wrap mono/stereo PCM16LE bytes in a 44-byte canonical WAV (RIFF/PCM) container. */
|
|
9
|
+
export function pcm16ToWav(pcm: Uint8Array, sampleRateHz: number, channels: number): Uint8Array {
|
|
10
|
+
const blockAlign = channels * 2; // 16-bit samples
|
|
11
|
+
const byteRate = sampleRateHz * blockAlign;
|
|
12
|
+
const out = new Uint8Array(44 + pcm.byteLength);
|
|
13
|
+
const view = new DataView(out.buffer);
|
|
14
|
+
writeAscii(view, 0, "RIFF");
|
|
15
|
+
view.setUint32(4, 36 + pcm.byteLength, true);
|
|
16
|
+
writeAscii(view, 8, "WAVE");
|
|
17
|
+
writeAscii(view, 12, "fmt ");
|
|
18
|
+
view.setUint32(16, 16, true);
|
|
19
|
+
view.setUint16(20, 1, true); // PCM
|
|
20
|
+
view.setUint16(22, channels, true);
|
|
21
|
+
view.setUint32(24, sampleRateHz, true);
|
|
22
|
+
view.setUint32(28, byteRate, true);
|
|
23
|
+
view.setUint16(32, blockAlign, true);
|
|
24
|
+
view.setUint16(34, 16, true);
|
|
25
|
+
writeAscii(view, 36, "data");
|
|
26
|
+
view.setUint32(40, pcm.byteLength, true);
|
|
27
|
+
out.set(pcm, 44);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Interleave two mono PCM16LE streams into stereo (left, right). The shorter stream is
|
|
33
|
+
* padded with silence to the longer one's frame count.
|
|
34
|
+
*/
|
|
35
|
+
export function interleaveStereoPcm16(left: Uint8Array, right: Uint8Array): Uint8Array {
|
|
36
|
+
const leftSamples = left.byteLength >> 1;
|
|
37
|
+
const rightSamples = right.byteLength >> 1;
|
|
38
|
+
const frames = Math.max(leftSamples, rightSamples);
|
|
39
|
+
const out = new Uint8Array(frames * 4);
|
|
40
|
+
const lv = new DataView(left.buffer, left.byteOffset, left.byteLength);
|
|
41
|
+
const rv = new DataView(right.buffer, right.byteOffset, right.byteLength);
|
|
42
|
+
const ov = new DataView(out.buffer);
|
|
43
|
+
for (let i = 0; i < frames; i += 1) {
|
|
44
|
+
ov.setInt16(i * 4, i < leftSamples ? lv.getInt16(i * 2, true) : 0, true);
|
|
45
|
+
ov.setInt16(i * 4 + 2, i < rightSamples ? rv.getInt16(i * 2, true) : 0, true);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function writeAscii(view: DataView, offset: number, text: string): void {
|
|
51
|
+
for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
|
|
52
|
+
}
|