@acpfx/play-file 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/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # @acpfx/play-file
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d757640: Initial release: type-safe contracts, Rust orchestrator, manifest-driven event filtering
8
+
9
+ - Rust schema crate as canonical event type source of truth with codegen to TypeScript + Zod
10
+ - Node manifests (manifest.yaml) declaring consumes/emits contracts
11
+ - Orchestrator event filtering: nodes only receive declared events
12
+ - Rust orchestrator with ratatui TUI (--ui flag)
13
+ - node-sdk with structured logging helpers
14
+ - CI/CD with GitHub Actions and changesets
15
+ - Platform-specific npm packages for Rust binaries (esbuild-style distribution)
16
+
17
+ ### Patch Changes
18
+
19
+ - Updated dependencies [d757640]
20
+ - @acpfx/core@0.2.0
21
+ - @acpfx/node-sdk@0.2.0
package/manifest.yaml ADDED
@@ -0,0 +1,9 @@
1
+ name: play-file
2
+ description: Writes audio chunks to a WAV file
3
+ consumes:
4
+ - audio.chunk
5
+ - control.interrupt
6
+ - lifecycle.done
7
+ emits:
8
+ - lifecycle.ready
9
+ - lifecycle.done
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@acpfx/play-file",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "acpfx-play-file": "./dist/index.js"
7
+ },
8
+ "main": "./dist/index.js",
9
+ "dependencies": {
10
+ "@acpfx/core": "0.2.0",
11
+ "@acpfx/node-sdk": "0.2.0"
12
+ },
13
+ "scripts": {
14
+ "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --packages=external"
15
+ }
16
+ }
package/src/index.ts ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * play-file node — reads audio.chunk events from stdin and writes a WAV file.
3
+ * Handles control.interrupt by stopping and finalizing the WAV header.
4
+ *
5
+ * Settings (via ACPFX_SETTINGS):
6
+ * path: string — output WAV file path
7
+ */
8
+
9
+ import { createWriteStream, type WriteStream } from "node:fs";
10
+ import { open } from "node:fs/promises";
11
+ import { resolve } from "node:path";
12
+ import { emit, log, onEvent, handleManifestFlag } from "@acpfx/node-sdk";
13
+
14
+ handleManifestFlag();
15
+
16
+ type Settings = {
17
+ path: string;
18
+ };
19
+
20
+ const settings: Settings = JSON.parse(
21
+ process.env.ACPFX_SETTINGS || "{}",
22
+ );
23
+
24
+ if (!settings.path) {
25
+ log.error("settings.path is required");
26
+ process.exit(1);
27
+ }
28
+
29
+ const filePath = resolve(settings.path);
30
+ let stream: WriteStream | null = null;
31
+ let bytesWritten = 0;
32
+ let sampleRate = 16000;
33
+ let channels = 1;
34
+ let started = false;
35
+
36
+ function createWavHeader(dataSize: number, sr: number, ch: number): Buffer {
37
+ const bitsPerSample = 16;
38
+ const byteRate = (sr * ch * bitsPerSample) / 8;
39
+ const blockAlign = (ch * bitsPerSample) / 8;
40
+ const header = Buffer.alloc(44);
41
+ let off = 0;
42
+
43
+ header.write("RIFF", off); off += 4;
44
+ header.writeUInt32LE(dataSize + 36, off); off += 4;
45
+ header.write("WAVE", off); off += 4;
46
+ header.write("fmt ", off); off += 4;
47
+ header.writeUInt32LE(16, off); off += 4;
48
+ header.writeUInt16LE(1, off); off += 2;
49
+ header.writeUInt16LE(ch, off); off += 2;
50
+ header.writeUInt32LE(sr, off); off += 4;
51
+ header.writeUInt32LE(byteRate, off); off += 4;
52
+ header.writeUInt16LE(blockAlign, off); off += 2;
53
+ header.writeUInt16LE(bitsPerSample, off); off += 2;
54
+ header.write("data", off); off += 4;
55
+ header.writeUInt32LE(dataSize, off);
56
+
57
+ return header;
58
+ }
59
+
60
+ function startWriting(): void {
61
+ if (started) return;
62
+ started = true;
63
+ stream = createWriteStream(filePath);
64
+ bytesWritten = 0;
65
+ // Write placeholder WAV header
66
+ stream.write(Buffer.alloc(44));
67
+ }
68
+
69
+ async function finalize(): Promise<void> {
70
+ if (!stream) return;
71
+
72
+ await new Promise<void>((resolve, reject) => {
73
+ stream!.end(() => resolve());
74
+ stream!.on("error", reject);
75
+ });
76
+
77
+ // Update WAV header with correct sizes
78
+ const header = createWavHeader(bytesWritten, sampleRate, channels);
79
+ const fd = await open(filePath, "r+");
80
+ await fd.write(header, 0, header.length, 0);
81
+ await fd.close();
82
+
83
+ stream = null;
84
+ }
85
+
86
+ // Emit lifecycle.ready
87
+ emit({ type: "lifecycle.ready", component: "play-file" });
88
+
89
+ const rl = onEvent((event) => {
90
+ if (event.type === "audio.chunk") {
91
+ startWriting();
92
+ // Capture format from first chunk
93
+ if (bytesWritten === 0) {
94
+ sampleRate = (event.sampleRate as number) ?? 16000;
95
+ channels = (event.channels as number) ?? 1;
96
+ }
97
+ const pcm = Buffer.from(event.data as string, "base64");
98
+ if (stream?.writable) {
99
+ stream.write(pcm);
100
+ bytesWritten += pcm.length;
101
+ }
102
+ } else if (event.type === "control.interrupt") {
103
+ finalize().then(() => {
104
+ emit({ type: "lifecycle.done", component: "play-file" });
105
+ process.exit(0);
106
+ });
107
+ } else if (event.type === "lifecycle.done") {
108
+ finalize().then(() => {
109
+ emit({ type: "lifecycle.done", component: "play-file" });
110
+ process.exit(0);
111
+ });
112
+ }
113
+ });
114
+
115
+ rl.on("close", () => {
116
+ // stdin EOF — finalize and exit
117
+ finalize().then(() => {
118
+ emit({ type: "lifecycle.done", component: "play-file" });
119
+ process.exit(0);
120
+ });
121
+ });
122
+
123
+ process.on("SIGTERM", () => {
124
+ finalize().then(() => process.exit(0));
125
+ });