@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 CHANGED
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/recorder",
3
- "version": "2.1.1",
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": "2.1.1"
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 = interleaveInt16(userPcm, resampled);
360
+ const stereo = interleaveStereoPcm16(userPcm, resampled);
356
361
  this.conversationAudioBytes = stereo.byteLength;
357
- await writeFile(this.conversationAudioPath, buildWav(stereo, 2, userRate));
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
- }
@@ -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
+ }