@acpfx/recorder 0.2.2 → 0.2.4
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 +15 -0
- package/README.md +33 -0
- package/{src/index.ts → dist/index.js} +202 -147
- package/dist/manifest.json +1 -0
- package/package.json +6 -2
- package/CHANGELOG.md +0 -38
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-2026 acpfx contributors
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
10
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
11
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
12
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
13
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
14
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
15
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @acpfx/recorder
|
|
2
|
+
|
|
3
|
+
Records all pipeline events to JSONL and audio tracks to WAV files. Observes the full event stream for debugging and analysis.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
This package is a pipeline node for [@acpfx/cli](../orchestrator/README.md). See the CLI package for installation and usage.
|
|
8
|
+
|
|
9
|
+
## Manifest
|
|
10
|
+
|
|
11
|
+
- **Consumes:** all event types
|
|
12
|
+
- **Emits:** `lifecycle.ready`, `lifecycle.done`
|
|
13
|
+
|
|
14
|
+
## Settings
|
|
15
|
+
|
|
16
|
+
| Name | Type | Default | Description |
|
|
17
|
+
|------|------|---------|-------------|
|
|
18
|
+
| `outputDir` | string | `./recordings` | Directory to write recordings to |
|
|
19
|
+
|
|
20
|
+
## Pipeline Example
|
|
21
|
+
|
|
22
|
+
```yaml
|
|
23
|
+
nodes:
|
|
24
|
+
recorder:
|
|
25
|
+
use: "@acpfx/recorder"
|
|
26
|
+
settings: { outputDir: ./recordings }
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Wire the recorder as an output of any node whose events you want to capture.
|
|
30
|
+
|
|
31
|
+
## License
|
|
32
|
+
|
|
33
|
+
ISC
|
|
@@ -1,116 +1,217 @@
|
|
|
1
|
-
|
|
2
|
-
* recorder node — captures all events to events.jsonl, writes audio tracks
|
|
3
|
-
* to WAV files, generates conversation.wav and timeline.html.
|
|
4
|
-
*
|
|
5
|
-
* Settings (via ACPFX_SETTINGS):
|
|
6
|
-
* outputDir?: string — output directory (default: ./recordings/<run-id>)
|
|
7
|
-
*/
|
|
1
|
+
#!/usr/bin/env node
|
|
8
2
|
|
|
3
|
+
// src/index.ts
|
|
9
4
|
import {
|
|
10
5
|
mkdirSync,
|
|
11
6
|
createWriteStream,
|
|
12
7
|
writeFileSync,
|
|
13
|
-
readFileSync
|
|
14
|
-
type WriteStream,
|
|
8
|
+
readFileSync as readFileSync2
|
|
15
9
|
} from "node:fs";
|
|
16
10
|
import { open } from "node:fs/promises";
|
|
17
|
-
import { join, resolve } from "node:path";
|
|
11
|
+
import { join as join2, resolve } from "node:path";
|
|
18
12
|
import { randomUUID } from "node:crypto";
|
|
19
|
-
import { emit, log, onEvent, handleManifestFlag } from "@acpfx/node-sdk";
|
|
20
13
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
type Settings = {
|
|
24
|
-
outputDir?: string;
|
|
25
|
-
};
|
|
14
|
+
// ../node-sdk/src/index.ts
|
|
15
|
+
import { createInterface } from "node:readline";
|
|
26
16
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const OUTPUT_DIR = resolve(settings.outputDir ?? "./recordings", RUN_ID);
|
|
17
|
+
// ../core/src/config.ts
|
|
18
|
+
import { parse as parseYaml } from "yaml";
|
|
30
19
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
20
|
+
// ../core/src/manifest.ts
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { join, dirname } from "node:path";
|
|
23
|
+
import { z as z2 } from "zod";
|
|
34
24
|
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
25
|
+
// ../core/src/acpfx-flags.ts
|
|
26
|
+
import { z } from "zod";
|
|
27
|
+
var SetupCheckResponseSchema = z.object({
|
|
28
|
+
needed: z.boolean(),
|
|
29
|
+
description: z.string().optional()
|
|
30
|
+
});
|
|
31
|
+
var SetupProgressSchema = z.discriminatedUnion("type", [
|
|
32
|
+
z.object({
|
|
33
|
+
type: z.literal("progress"),
|
|
34
|
+
message: z.string(),
|
|
35
|
+
pct: z.number().optional()
|
|
36
|
+
}),
|
|
37
|
+
z.object({ type: z.literal("complete"), message: z.string() }),
|
|
38
|
+
z.object({ type: z.literal("error"), message: z.string() })
|
|
39
|
+
]);
|
|
40
|
+
var UnsupportedFlagResponseSchema = z.object({
|
|
41
|
+
unsupported: z.boolean(),
|
|
42
|
+
flag: z.string()
|
|
43
|
+
});
|
|
39
44
|
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
// ../core/src/manifest.ts
|
|
46
|
+
var ArgumentTypeSchema = z2.enum(["string", "number", "boolean"]);
|
|
47
|
+
var ManifestArgumentSchema = z2.object({
|
|
48
|
+
type: ArgumentTypeSchema,
|
|
49
|
+
default: z2.unknown().optional(),
|
|
50
|
+
description: z2.string().optional(),
|
|
51
|
+
required: z2.boolean().optional(),
|
|
52
|
+
enum: z2.array(z2.unknown()).optional()
|
|
53
|
+
});
|
|
54
|
+
var ManifestEnvFieldSchema = z2.object({
|
|
55
|
+
required: z2.boolean().optional(),
|
|
56
|
+
description: z2.string().optional()
|
|
57
|
+
});
|
|
58
|
+
var NodeManifestSchema = z2.object({
|
|
59
|
+
name: z2.string(),
|
|
60
|
+
description: z2.string().optional(),
|
|
61
|
+
consumes: z2.array(z2.string()),
|
|
62
|
+
emits: z2.array(z2.string()),
|
|
63
|
+
arguments: z2.record(z2.string(), ManifestArgumentSchema).optional(),
|
|
64
|
+
additional_arguments: z2.boolean().optional(),
|
|
65
|
+
env: z2.record(z2.string(), ManifestEnvFieldSchema).optional()
|
|
66
|
+
});
|
|
67
|
+
function handleAcpfxFlags(manifestPath) {
|
|
68
|
+
const acpfxFlag = process.argv.find((a) => a.startsWith("--acpfx-"));
|
|
69
|
+
const legacyManifest = process.argv.includes("--manifest");
|
|
70
|
+
if (!acpfxFlag && !legacyManifest) return;
|
|
71
|
+
const flag = acpfxFlag ?? "--acpfx-manifest";
|
|
72
|
+
switch (flag) {
|
|
73
|
+
case "--acpfx-manifest":
|
|
74
|
+
printManifest(manifestPath);
|
|
75
|
+
break;
|
|
76
|
+
case "--acpfx-setup-check":
|
|
77
|
+
process.stdout.write(JSON.stringify({ needed: false }) + "\n");
|
|
78
|
+
process.exit(0);
|
|
79
|
+
break;
|
|
80
|
+
default:
|
|
81
|
+
process.stdout.write(
|
|
82
|
+
JSON.stringify({ unsupported: true, flag }) + "\n"
|
|
83
|
+
);
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function handleManifestFlag(manifestPath) {
|
|
88
|
+
handleAcpfxFlags(manifestPath);
|
|
89
|
+
}
|
|
90
|
+
function printManifest(manifestPath) {
|
|
91
|
+
if (!manifestPath) {
|
|
92
|
+
const script = process.argv[1];
|
|
93
|
+
const scriptDir = dirname(script);
|
|
94
|
+
const scriptBase = script.replace(/\.[^.]+$/, "");
|
|
95
|
+
const colocated = `${scriptBase}.manifest.json`;
|
|
96
|
+
try {
|
|
97
|
+
readFileSync(colocated);
|
|
98
|
+
manifestPath = colocated;
|
|
99
|
+
} catch {
|
|
100
|
+
manifestPath = join(scriptDir, "manifest.json");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const content = readFileSync(manifestPath, "utf8");
|
|
105
|
+
process.stdout.write(content.trim() + "\n");
|
|
106
|
+
process.exit(0);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
process.stderr.write(`Failed to read manifest: ${err}
|
|
109
|
+
`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
47
113
|
|
|
114
|
+
// ../node-sdk/src/index.ts
|
|
115
|
+
var NODE_NAME = process.env.ACPFX_NODE_NAME ?? "unknown";
|
|
116
|
+
function emit(event) {
|
|
117
|
+
process.stdout.write(JSON.stringify(event) + "\n");
|
|
118
|
+
}
|
|
119
|
+
function log(level, message) {
|
|
120
|
+
emit({ type: "log", level, component: NODE_NAME, message });
|
|
121
|
+
}
|
|
122
|
+
log.info = (message) => log("info", message);
|
|
123
|
+
log.warn = (message) => log("warn", message);
|
|
124
|
+
log.error = (message) => log("error", message);
|
|
125
|
+
log.debug = (message) => log("debug", message);
|
|
126
|
+
function onEvent(handler) {
|
|
127
|
+
const rl2 = createInterface({ input: process.stdin });
|
|
128
|
+
rl2.on("line", (line) => {
|
|
129
|
+
if (!line.trim()) return;
|
|
130
|
+
try {
|
|
131
|
+
const event = JSON.parse(line);
|
|
132
|
+
handler(event);
|
|
133
|
+
} catch {
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
return rl2;
|
|
137
|
+
}
|
|
48
138
|
|
|
49
|
-
|
|
139
|
+
// src/index.ts
|
|
140
|
+
handleManifestFlag();
|
|
141
|
+
var settings = JSON.parse(process.env.ACPFX_SETTINGS || "{}");
|
|
142
|
+
var RUN_ID = randomUUID().slice(0, 8);
|
|
143
|
+
var OUTPUT_DIR = resolve(settings.outputDir ?? "./recordings", RUN_ID);
|
|
144
|
+
var SAMPLE_RATE = 16e3;
|
|
145
|
+
var CHANNELS = 1;
|
|
146
|
+
var BYTES_PER_SAMPLE = 2;
|
|
147
|
+
var eventsStream;
|
|
148
|
+
var startTime = Date.now();
|
|
149
|
+
var allEvents = [];
|
|
150
|
+
var tracks = /* @__PURE__ */ new Map();
|
|
151
|
+
function createWavHeader(dataSize, sr, ch) {
|
|
50
152
|
const bitsPerSample = 16;
|
|
51
|
-
const byteRate =
|
|
52
|
-
const blockAlign =
|
|
153
|
+
const byteRate = sr * ch * bitsPerSample / 8;
|
|
154
|
+
const blockAlign = ch * bitsPerSample / 8;
|
|
53
155
|
const header = Buffer.alloc(44);
|
|
54
156
|
let off = 0;
|
|
55
|
-
header.write("RIFF", off);
|
|
56
|
-
|
|
57
|
-
header.
|
|
58
|
-
|
|
59
|
-
header.
|
|
60
|
-
|
|
61
|
-
header.
|
|
62
|
-
|
|
63
|
-
header.writeUInt32LE(
|
|
64
|
-
|
|
65
|
-
header.writeUInt16LE(
|
|
66
|
-
|
|
157
|
+
header.write("RIFF", off);
|
|
158
|
+
off += 4;
|
|
159
|
+
header.writeUInt32LE(dataSize + 36, off);
|
|
160
|
+
off += 4;
|
|
161
|
+
header.write("WAVE", off);
|
|
162
|
+
off += 4;
|
|
163
|
+
header.write("fmt ", off);
|
|
164
|
+
off += 4;
|
|
165
|
+
header.writeUInt32LE(16, off);
|
|
166
|
+
off += 4;
|
|
167
|
+
header.writeUInt16LE(1, off);
|
|
168
|
+
off += 2;
|
|
169
|
+
header.writeUInt16LE(ch, off);
|
|
170
|
+
off += 2;
|
|
171
|
+
header.writeUInt32LE(sr, off);
|
|
172
|
+
off += 4;
|
|
173
|
+
header.writeUInt32LE(byteRate, off);
|
|
174
|
+
off += 4;
|
|
175
|
+
header.writeUInt16LE(blockAlign, off);
|
|
176
|
+
off += 2;
|
|
177
|
+
header.writeUInt16LE(bitsPerSample, off);
|
|
178
|
+
off += 2;
|
|
179
|
+
header.write("data", off);
|
|
180
|
+
off += 4;
|
|
67
181
|
header.writeUInt32LE(dataSize, off);
|
|
68
182
|
return header;
|
|
69
183
|
}
|
|
70
|
-
|
|
71
|
-
function getOrCreateTrack(trackId: string): TrackWriter {
|
|
184
|
+
function getOrCreateTrack(trackId) {
|
|
72
185
|
let tw = tracks.get(trackId);
|
|
73
186
|
if (tw) return tw;
|
|
74
|
-
|
|
75
187
|
const filename = `${trackId}.wav`;
|
|
76
|
-
const path =
|
|
188
|
+
const path = join2(OUTPUT_DIR, filename);
|
|
77
189
|
const stream = createWriteStream(path);
|
|
78
|
-
// Write placeholder header
|
|
79
190
|
stream.write(Buffer.alloc(44));
|
|
80
191
|
tw = { stream, path, bytesWritten: 0 };
|
|
81
192
|
tracks.set(trackId, tw);
|
|
82
193
|
return tw;
|
|
83
194
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
await new Promise<void>((res, rej) => {
|
|
195
|
+
async function finalizeTrack(tw) {
|
|
196
|
+
await new Promise((res, rej) => {
|
|
87
197
|
tw.stream.end(() => res());
|
|
88
198
|
tw.stream.on("error", rej);
|
|
89
199
|
});
|
|
90
|
-
|
|
91
200
|
const header = createWavHeader(tw.bytesWritten, SAMPLE_RATE, CHANNELS);
|
|
92
201
|
const fd = await open(tw.path, "r+");
|
|
93
202
|
await fd.write(header, 0, header.length, 0);
|
|
94
203
|
await fd.close();
|
|
95
204
|
}
|
|
96
|
-
|
|
97
|
-
function generateConversationWav(): void {
|
|
98
|
-
// Merge input (mic) and output (tts) tracks into a single timeline WAV.
|
|
99
|
-
// We place them sequentially: input audio, then a gap, then output audio.
|
|
100
|
-
// Timeline positions come from event timestamps.
|
|
101
|
-
|
|
205
|
+
function generateConversationWav() {
|
|
102
206
|
const micTrack = tracks.get("mic");
|
|
103
207
|
const ttsTrack = tracks.get("tts");
|
|
104
208
|
if (!micTrack && !ttsTrack) return;
|
|
105
|
-
|
|
106
|
-
// Find the first and last audio chunk timestamps for each track
|
|
107
209
|
let micStartMs = Infinity, micEndMs = 0;
|
|
108
210
|
let ttsStartMs = Infinity, ttsEndMs = 0;
|
|
109
|
-
|
|
110
211
|
for (const ev of allEvents) {
|
|
111
212
|
if (ev.type === "audio.chunk") {
|
|
112
|
-
const ts =
|
|
113
|
-
const dur =
|
|
213
|
+
const ts = ev.ts ?? 0;
|
|
214
|
+
const dur = ev.durationMs ?? 0;
|
|
114
215
|
if (ev.trackId === "mic" || ev._from === "mic") {
|
|
115
216
|
micStartMs = Math.min(micStartMs, ts);
|
|
116
217
|
micEndMs = Math.max(micEndMs, ts + dur);
|
|
@@ -121,35 +222,25 @@ function generateConversationWav(): void {
|
|
|
121
222
|
}
|
|
122
223
|
}
|
|
123
224
|
}
|
|
124
|
-
|
|
125
|
-
// Calculate total duration and offsets relative to the earliest timestamp
|
|
126
225
|
const globalStart = Math.min(
|
|
127
226
|
micStartMs === Infinity ? Infinity : micStartMs,
|
|
128
|
-
ttsStartMs === Infinity ? Infinity : ttsStartMs
|
|
227
|
+
ttsStartMs === Infinity ? Infinity : ttsStartMs
|
|
129
228
|
);
|
|
130
229
|
if (globalStart === Infinity) return;
|
|
131
|
-
|
|
132
230
|
const globalEnd = Math.max(micEndMs, ttsEndMs);
|
|
133
231
|
const totalDurationMs = globalEnd - globalStart;
|
|
134
|
-
const totalSamples = Math.ceil(
|
|
232
|
+
const totalSamples = Math.ceil(totalDurationMs / 1e3 * SAMPLE_RATE);
|
|
135
233
|
const totalBytes = totalSamples * CHANNELS * BYTES_PER_SAMPLE;
|
|
136
|
-
|
|
137
|
-
// Create a silent buffer for the full duration
|
|
138
234
|
const pcm = Buffer.alloc(totalBytes);
|
|
139
|
-
|
|
140
|
-
// Write mic audio at correct timeline position
|
|
141
235
|
for (const ev of allEvents) {
|
|
142
236
|
if (ev.type !== "audio.chunk") continue;
|
|
143
|
-
const ts =
|
|
144
|
-
const trackId =
|
|
237
|
+
const ts = ev.ts ?? 0;
|
|
238
|
+
const trackId = ev.trackId ?? ev._from ?? "";
|
|
145
239
|
if (trackId !== "mic" && trackId !== "tts") continue;
|
|
146
|
-
|
|
147
240
|
const offsetMs = ts - globalStart;
|
|
148
|
-
const offsetSamples = Math.floor(
|
|
241
|
+
const offsetSamples = Math.floor(offsetMs / 1e3 * SAMPLE_RATE);
|
|
149
242
|
const offsetBytes = offsetSamples * CHANNELS * BYTES_PER_SAMPLE;
|
|
150
|
-
const data = Buffer.from(
|
|
151
|
-
|
|
152
|
-
// Mix: add samples (clamped to int16 range)
|
|
243
|
+
const data = Buffer.from(ev.data ?? "", "base64");
|
|
153
244
|
for (let i = 0; i < data.length && offsetBytes + i + 1 < pcm.length; i += 2) {
|
|
154
245
|
const existing = pcm.readInt16LE(offsetBytes + i);
|
|
155
246
|
const incoming = data.readInt16LE(i);
|
|
@@ -157,48 +248,32 @@ function generateConversationWav(): void {
|
|
|
157
248
|
pcm.writeInt16LE(mixed, offsetBytes + i);
|
|
158
249
|
}
|
|
159
250
|
}
|
|
160
|
-
|
|
161
|
-
const convPath = join(OUTPUT_DIR, "conversation.wav");
|
|
251
|
+
const convPath = join2(OUTPUT_DIR, "conversation.wav");
|
|
162
252
|
const header = createWavHeader(pcm.length, SAMPLE_RATE, CHANNELS);
|
|
163
253
|
writeFileSync(convPath, Buffer.concat([header, pcm]));
|
|
164
254
|
log.info(`Wrote conversation.wav (${totalDurationMs}ms)`);
|
|
165
255
|
}
|
|
166
|
-
|
|
167
|
-
function generateTimelineHtml(): void {
|
|
168
|
-
// Read WAV files as base64 for embedding
|
|
256
|
+
function generateTimelineHtml() {
|
|
169
257
|
let inputWavB64 = "";
|
|
170
258
|
let outputWavB64 = "";
|
|
171
|
-
const micPath =
|
|
172
|
-
const ttsPath =
|
|
173
|
-
try {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
);
|
|
190
|
-
})
|
|
191
|
-
.map((ev) => ({
|
|
192
|
-
time: ((ev.ts as number) - startTime) / 1000,
|
|
193
|
-
type: ev.type,
|
|
194
|
-
text:
|
|
195
|
-
(ev as any).text ??
|
|
196
|
-
(ev as any).delta ??
|
|
197
|
-
(ev as any).pendingText ??
|
|
198
|
-
(ev as any).reason ??
|
|
199
|
-
"",
|
|
200
|
-
}));
|
|
201
|
-
|
|
259
|
+
const micPath = join2(OUTPUT_DIR, "mic.wav");
|
|
260
|
+
const ttsPath = join2(OUTPUT_DIR, "tts.wav");
|
|
261
|
+
try {
|
|
262
|
+
inputWavB64 = readFileSync2(micPath).toString("base64");
|
|
263
|
+
} catch {
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
outputWavB64 = readFileSync2(ttsPath).toString("base64");
|
|
267
|
+
} catch {
|
|
268
|
+
}
|
|
269
|
+
const markers = allEvents.filter((ev) => {
|
|
270
|
+
const t = ev.type;
|
|
271
|
+
return t === "speech.partial" || t === "speech.delta" || t === "speech.final" || t === "speech.pause" || t === "agent.submit" || t === "agent.delta" || t === "agent.complete" || t === "control.interrupt";
|
|
272
|
+
}).map((ev) => ({
|
|
273
|
+
time: (ev.ts - startTime) / 1e3,
|
|
274
|
+
type: ev.type,
|
|
275
|
+
text: ev.text ?? ev.delta ?? ev.pendingText ?? ev.reason ?? ""
|
|
276
|
+
}));
|
|
202
277
|
const html = `<!DOCTYPE html>
|
|
203
278
|
<html lang="en">
|
|
204
279
|
<head>
|
|
@@ -291,71 +366,51 @@ function playPause() {
|
|
|
291
366
|
</script>
|
|
292
367
|
</body>
|
|
293
368
|
</html>`;
|
|
294
|
-
|
|
295
|
-
const htmlPath = join(OUTPUT_DIR, "timeline.html");
|
|
369
|
+
const htmlPath = join2(OUTPUT_DIR, "timeline.html");
|
|
296
370
|
writeFileSync(htmlPath, html);
|
|
297
371
|
log.info(`Wrote timeline.html`);
|
|
298
372
|
}
|
|
299
|
-
|
|
300
|
-
async function finalize(): Promise<void> {
|
|
301
|
-
// Close events stream
|
|
373
|
+
async function finalize() {
|
|
302
374
|
if (eventsStream) {
|
|
303
|
-
await new Promise
|
|
375
|
+
await new Promise((res) => eventsStream.end(() => res()));
|
|
304
376
|
}
|
|
305
|
-
|
|
306
|
-
// Finalize all audio tracks
|
|
307
377
|
for (const tw of tracks.values()) {
|
|
308
378
|
await finalizeTrack(tw);
|
|
309
379
|
}
|
|
310
|
-
|
|
311
|
-
// Generate conversation.wav
|
|
312
380
|
try {
|
|
313
381
|
generateConversationWav();
|
|
314
382
|
} catch (err) {
|
|
315
383
|
log.error(`Error generating conversation.wav: ${err}`);
|
|
316
384
|
}
|
|
317
|
-
|
|
318
|
-
// Generate timeline.html
|
|
319
385
|
try {
|
|
320
386
|
generateTimelineHtml();
|
|
321
387
|
} catch (err) {
|
|
322
388
|
log.error(`Error generating timeline.html: ${err}`);
|
|
323
389
|
}
|
|
324
|
-
|
|
325
390
|
log.info(`Recording saved to ${OUTPUT_DIR}`);
|
|
326
391
|
}
|
|
327
|
-
|
|
328
|
-
// --- Main ---
|
|
329
|
-
|
|
330
392
|
mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
331
|
-
eventsStream = createWriteStream(
|
|
393
|
+
eventsStream = createWriteStream(join2(OUTPUT_DIR, "events.jsonl"));
|
|
332
394
|
startTime = Date.now();
|
|
333
|
-
|
|
334
395
|
emit({ type: "lifecycle.ready", component: "recorder" });
|
|
335
396
|
log.info(`Recording to ${OUTPUT_DIR}`);
|
|
336
|
-
|
|
337
|
-
const rl = onEvent((event) => {
|
|
338
|
-
// Record every event to events.jsonl
|
|
397
|
+
var rl = onEvent((event) => {
|
|
339
398
|
allEvents.push(event);
|
|
340
399
|
eventsStream.write(JSON.stringify(event) + "\n");
|
|
341
|
-
|
|
342
|
-
// Capture audio tracks
|
|
343
400
|
if (event.type === "audio.chunk") {
|
|
344
|
-
const trackId =
|
|
401
|
+
const trackId = event.trackId ?? event._from ?? "unknown";
|
|
345
402
|
const tw = getOrCreateTrack(trackId);
|
|
346
|
-
const pcm = Buffer.from(
|
|
403
|
+
const pcm = Buffer.from(event.data ?? "", "base64");
|
|
347
404
|
tw.stream.write(pcm);
|
|
348
405
|
tw.bytesWritten += pcm.length;
|
|
349
406
|
}
|
|
350
407
|
});
|
|
351
|
-
|
|
352
408
|
rl.on("close", () => {
|
|
353
409
|
finalize().then(() => {
|
|
354
410
|
emit({ type: "lifecycle.done", component: "recorder" });
|
|
355
411
|
process.exit(0);
|
|
356
412
|
});
|
|
357
413
|
});
|
|
358
|
-
|
|
359
414
|
process.on("SIGTERM", () => {
|
|
360
415
|
finalize().then(() => process.exit(0));
|
|
361
416
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"name":"recorder","description":"Records all events to JSONL and audio tracks to WAV files","consumes":["audio.chunk","audio.level","speech.partial","speech.delta","speech.final","speech.pause","agent.submit","agent.delta","agent.complete","agent.thinking","agent.tool_start","agent.tool_done","control.interrupt","control.state","control.error","lifecycle.ready","lifecycle.done","log","player.status"],"emits":["lifecycle.ready","lifecycle.done"],"arguments":{"outputDir":{"type":"string","description":"Directory to write recordings to (default: ./recordings)"}}}
|
package/package.json
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acpfx/recorder",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"acpfx-recorder": "./dist/index.js"
|
|
7
7
|
},
|
|
8
8
|
"main": "./dist/index.js",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"manifest.yaml"
|
|
12
|
+
],
|
|
9
13
|
"dependencies": {
|
|
10
14
|
"@acpfx/core": "0.4.0",
|
|
11
15
|
"@acpfx/node-sdk": "0.3.0"
|
|
12
16
|
},
|
|
13
17
|
"scripts": {
|
|
14
|
-
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --packages=external"
|
|
18
|
+
"build": "esbuild src/index.ts --bundle --banner:js=\"#!/usr/bin/env node\" --platform=node --format=esm --outfile=dist/index.js --packages=external && node ../../scripts/copy-manifest.js"
|
|
15
19
|
}
|
|
16
20
|
}
|
package/CHANGELOG.md
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
# @acpfx/recorder
|
|
2
|
-
|
|
3
|
-
## 0.2.2
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
- Updated dependencies [0e6838e]
|
|
8
|
-
- @acpfx/core@0.4.0
|
|
9
|
-
- @acpfx/node-sdk@0.3.0
|
|
10
|
-
|
|
11
|
-
## 0.2.1
|
|
12
|
-
|
|
13
|
-
### Patch Changes
|
|
14
|
-
|
|
15
|
-
- Updated dependencies [79c6694]
|
|
16
|
-
- Updated dependencies [a0320a1]
|
|
17
|
-
- @acpfx/core@0.3.0
|
|
18
|
-
- @acpfx/node-sdk@0.2.1
|
|
19
|
-
|
|
20
|
-
## 0.2.0
|
|
21
|
-
|
|
22
|
-
### Minor Changes
|
|
23
|
-
|
|
24
|
-
- d757640: Initial release: type-safe contracts, Rust orchestrator, manifest-driven event filtering
|
|
25
|
-
|
|
26
|
-
- Rust schema crate as canonical event type source of truth with codegen to TypeScript + Zod
|
|
27
|
-
- Node manifests (manifest.yaml) declaring consumes/emits contracts
|
|
28
|
-
- Orchestrator event filtering: nodes only receive declared events
|
|
29
|
-
- Rust orchestrator with ratatui TUI (--ui flag)
|
|
30
|
-
- node-sdk with structured logging helpers
|
|
31
|
-
- CI/CD with GitHub Actions and changesets
|
|
32
|
-
- Platform-specific npm packages for Rust binaries (esbuild-style distribution)
|
|
33
|
-
|
|
34
|
-
### Patch Changes
|
|
35
|
-
|
|
36
|
-
- Updated dependencies [d757640]
|
|
37
|
-
- @acpfx/core@0.2.0
|
|
38
|
-
- @acpfx/node-sdk@0.2.0
|