@acpfx/recorder 0.2.0 → 0.2.3
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} +201 -148
- package/dist/manifest.json +1 -0
- package/manifest.yaml +4 -0
- package/package.json +8 -4
- package/CHANGELOG.md +0 -21
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,215 @@
|
|
|
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
|
-
*/
|
|
8
|
-
|
|
1
|
+
// src/index.ts
|
|
9
2
|
import {
|
|
10
3
|
mkdirSync,
|
|
11
4
|
createWriteStream,
|
|
12
5
|
writeFileSync,
|
|
13
|
-
readFileSync
|
|
14
|
-
type WriteStream,
|
|
6
|
+
readFileSync as readFileSync2
|
|
15
7
|
} from "node:fs";
|
|
16
8
|
import { open } from "node:fs/promises";
|
|
17
|
-
import { join, resolve } from "node:path";
|
|
9
|
+
import { join as join2, resolve } from "node:path";
|
|
18
10
|
import { randomUUID } from "node:crypto";
|
|
19
|
-
import { emit, log, onEvent, handleManifestFlag } from "@acpfx/node-sdk";
|
|
20
|
-
|
|
21
|
-
handleManifestFlag();
|
|
22
11
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
};
|
|
12
|
+
// ../node-sdk/src/index.ts
|
|
13
|
+
import { createInterface } from "node:readline";
|
|
26
14
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const OUTPUT_DIR = resolve(settings.outputDir ?? "./recordings", RUN_ID);
|
|
15
|
+
// ../core/src/config.ts
|
|
16
|
+
import { parse as parseYaml } from "yaml";
|
|
30
17
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
18
|
+
// ../core/src/manifest.ts
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { join, dirname } from "node:path";
|
|
21
|
+
import { z as z2 } from "zod";
|
|
34
22
|
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
23
|
+
// ../core/src/acpfx-flags.ts
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
var SetupCheckResponseSchema = z.object({
|
|
26
|
+
needed: z.boolean(),
|
|
27
|
+
description: z.string().optional()
|
|
28
|
+
});
|
|
29
|
+
var SetupProgressSchema = z.discriminatedUnion("type", [
|
|
30
|
+
z.object({
|
|
31
|
+
type: z.literal("progress"),
|
|
32
|
+
message: z.string(),
|
|
33
|
+
pct: z.number().optional()
|
|
34
|
+
}),
|
|
35
|
+
z.object({ type: z.literal("complete"), message: z.string() }),
|
|
36
|
+
z.object({ type: z.literal("error"), message: z.string() })
|
|
37
|
+
]);
|
|
38
|
+
var UnsupportedFlagResponseSchema = z.object({
|
|
39
|
+
unsupported: z.boolean(),
|
|
40
|
+
flag: z.string()
|
|
41
|
+
});
|
|
39
42
|
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
// ../core/src/manifest.ts
|
|
44
|
+
var ArgumentTypeSchema = z2.enum(["string", "number", "boolean"]);
|
|
45
|
+
var ManifestArgumentSchema = z2.object({
|
|
46
|
+
type: ArgumentTypeSchema,
|
|
47
|
+
default: z2.unknown().optional(),
|
|
48
|
+
description: z2.string().optional(),
|
|
49
|
+
required: z2.boolean().optional(),
|
|
50
|
+
enum: z2.array(z2.unknown()).optional()
|
|
51
|
+
});
|
|
52
|
+
var ManifestEnvFieldSchema = z2.object({
|
|
53
|
+
required: z2.boolean().optional(),
|
|
54
|
+
description: z2.string().optional()
|
|
55
|
+
});
|
|
56
|
+
var NodeManifestSchema = z2.object({
|
|
57
|
+
name: z2.string(),
|
|
58
|
+
description: z2.string().optional(),
|
|
59
|
+
consumes: z2.array(z2.string()),
|
|
60
|
+
emits: z2.array(z2.string()),
|
|
61
|
+
arguments: z2.record(z2.string(), ManifestArgumentSchema).optional(),
|
|
62
|
+
additional_arguments: z2.boolean().optional(),
|
|
63
|
+
env: z2.record(z2.string(), ManifestEnvFieldSchema).optional()
|
|
64
|
+
});
|
|
65
|
+
function handleAcpfxFlags(manifestPath) {
|
|
66
|
+
const acpfxFlag = process.argv.find((a) => a.startsWith("--acpfx-"));
|
|
67
|
+
const legacyManifest = process.argv.includes("--manifest");
|
|
68
|
+
if (!acpfxFlag && !legacyManifest) return;
|
|
69
|
+
const flag = acpfxFlag ?? "--acpfx-manifest";
|
|
70
|
+
switch (flag) {
|
|
71
|
+
case "--acpfx-manifest":
|
|
72
|
+
printManifest(manifestPath);
|
|
73
|
+
break;
|
|
74
|
+
case "--acpfx-setup-check":
|
|
75
|
+
process.stdout.write(JSON.stringify({ needed: false }) + "\n");
|
|
76
|
+
process.exit(0);
|
|
77
|
+
break;
|
|
78
|
+
default:
|
|
79
|
+
process.stdout.write(
|
|
80
|
+
JSON.stringify({ unsupported: true, flag }) + "\n"
|
|
81
|
+
);
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function handleManifestFlag(manifestPath) {
|
|
86
|
+
handleAcpfxFlags(manifestPath);
|
|
87
|
+
}
|
|
88
|
+
function printManifest(manifestPath) {
|
|
89
|
+
if (!manifestPath) {
|
|
90
|
+
const script = process.argv[1];
|
|
91
|
+
const scriptDir = dirname(script);
|
|
92
|
+
const scriptBase = script.replace(/\.[^.]+$/, "");
|
|
93
|
+
const colocated = `${scriptBase}.manifest.json`;
|
|
94
|
+
try {
|
|
95
|
+
readFileSync(colocated);
|
|
96
|
+
manifestPath = colocated;
|
|
97
|
+
} catch {
|
|
98
|
+
manifestPath = join(scriptDir, "manifest.json");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const content = readFileSync(manifestPath, "utf8");
|
|
103
|
+
process.stdout.write(content.trim() + "\n");
|
|
104
|
+
process.exit(0);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
process.stderr.write(`Failed to read manifest: ${err}
|
|
107
|
+
`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
47
111
|
|
|
112
|
+
// ../node-sdk/src/index.ts
|
|
113
|
+
var NODE_NAME = process.env.ACPFX_NODE_NAME ?? "unknown";
|
|
114
|
+
function emit(event) {
|
|
115
|
+
process.stdout.write(JSON.stringify(event) + "\n");
|
|
116
|
+
}
|
|
117
|
+
function log(level, message) {
|
|
118
|
+
emit({ type: "log", level, component: NODE_NAME, message });
|
|
119
|
+
}
|
|
120
|
+
log.info = (message) => log("info", message);
|
|
121
|
+
log.warn = (message) => log("warn", message);
|
|
122
|
+
log.error = (message) => log("error", message);
|
|
123
|
+
log.debug = (message) => log("debug", message);
|
|
124
|
+
function onEvent(handler) {
|
|
125
|
+
const rl2 = createInterface({ input: process.stdin });
|
|
126
|
+
rl2.on("line", (line) => {
|
|
127
|
+
if (!line.trim()) return;
|
|
128
|
+
try {
|
|
129
|
+
const event = JSON.parse(line);
|
|
130
|
+
handler(event);
|
|
131
|
+
} catch {
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
return rl2;
|
|
135
|
+
}
|
|
48
136
|
|
|
49
|
-
|
|
137
|
+
// src/index.ts
|
|
138
|
+
handleManifestFlag();
|
|
139
|
+
var settings = JSON.parse(process.env.ACPFX_SETTINGS || "{}");
|
|
140
|
+
var RUN_ID = randomUUID().slice(0, 8);
|
|
141
|
+
var OUTPUT_DIR = resolve(settings.outputDir ?? "./recordings", RUN_ID);
|
|
142
|
+
var SAMPLE_RATE = 16e3;
|
|
143
|
+
var CHANNELS = 1;
|
|
144
|
+
var BYTES_PER_SAMPLE = 2;
|
|
145
|
+
var eventsStream;
|
|
146
|
+
var startTime = Date.now();
|
|
147
|
+
var allEvents = [];
|
|
148
|
+
var tracks = /* @__PURE__ */ new Map();
|
|
149
|
+
function createWavHeader(dataSize, sr, ch) {
|
|
50
150
|
const bitsPerSample = 16;
|
|
51
|
-
const byteRate =
|
|
52
|
-
const blockAlign =
|
|
151
|
+
const byteRate = sr * ch * bitsPerSample / 8;
|
|
152
|
+
const blockAlign = ch * bitsPerSample / 8;
|
|
53
153
|
const header = Buffer.alloc(44);
|
|
54
154
|
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
|
-
|
|
155
|
+
header.write("RIFF", off);
|
|
156
|
+
off += 4;
|
|
157
|
+
header.writeUInt32LE(dataSize + 36, off);
|
|
158
|
+
off += 4;
|
|
159
|
+
header.write("WAVE", off);
|
|
160
|
+
off += 4;
|
|
161
|
+
header.write("fmt ", off);
|
|
162
|
+
off += 4;
|
|
163
|
+
header.writeUInt32LE(16, off);
|
|
164
|
+
off += 4;
|
|
165
|
+
header.writeUInt16LE(1, off);
|
|
166
|
+
off += 2;
|
|
167
|
+
header.writeUInt16LE(ch, off);
|
|
168
|
+
off += 2;
|
|
169
|
+
header.writeUInt32LE(sr, off);
|
|
170
|
+
off += 4;
|
|
171
|
+
header.writeUInt32LE(byteRate, off);
|
|
172
|
+
off += 4;
|
|
173
|
+
header.writeUInt16LE(blockAlign, off);
|
|
174
|
+
off += 2;
|
|
175
|
+
header.writeUInt16LE(bitsPerSample, off);
|
|
176
|
+
off += 2;
|
|
177
|
+
header.write("data", off);
|
|
178
|
+
off += 4;
|
|
67
179
|
header.writeUInt32LE(dataSize, off);
|
|
68
180
|
return header;
|
|
69
181
|
}
|
|
70
|
-
|
|
71
|
-
function getOrCreateTrack(trackId: string): TrackWriter {
|
|
182
|
+
function getOrCreateTrack(trackId) {
|
|
72
183
|
let tw = tracks.get(trackId);
|
|
73
184
|
if (tw) return tw;
|
|
74
|
-
|
|
75
185
|
const filename = `${trackId}.wav`;
|
|
76
|
-
const path =
|
|
186
|
+
const path = join2(OUTPUT_DIR, filename);
|
|
77
187
|
const stream = createWriteStream(path);
|
|
78
|
-
// Write placeholder header
|
|
79
188
|
stream.write(Buffer.alloc(44));
|
|
80
189
|
tw = { stream, path, bytesWritten: 0 };
|
|
81
190
|
tracks.set(trackId, tw);
|
|
82
191
|
return tw;
|
|
83
192
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
await new Promise<void>((res, rej) => {
|
|
193
|
+
async function finalizeTrack(tw) {
|
|
194
|
+
await new Promise((res, rej) => {
|
|
87
195
|
tw.stream.end(() => res());
|
|
88
196
|
tw.stream.on("error", rej);
|
|
89
197
|
});
|
|
90
|
-
|
|
91
198
|
const header = createWavHeader(tw.bytesWritten, SAMPLE_RATE, CHANNELS);
|
|
92
199
|
const fd = await open(tw.path, "r+");
|
|
93
200
|
await fd.write(header, 0, header.length, 0);
|
|
94
201
|
await fd.close();
|
|
95
202
|
}
|
|
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
|
-
|
|
203
|
+
function generateConversationWav() {
|
|
102
204
|
const micTrack = tracks.get("mic");
|
|
103
205
|
const ttsTrack = tracks.get("tts");
|
|
104
206
|
if (!micTrack && !ttsTrack) return;
|
|
105
|
-
|
|
106
|
-
// Find the first and last audio chunk timestamps for each track
|
|
107
207
|
let micStartMs = Infinity, micEndMs = 0;
|
|
108
208
|
let ttsStartMs = Infinity, ttsEndMs = 0;
|
|
109
|
-
|
|
110
209
|
for (const ev of allEvents) {
|
|
111
210
|
if (ev.type === "audio.chunk") {
|
|
112
|
-
const ts =
|
|
113
|
-
const dur =
|
|
211
|
+
const ts = ev.ts ?? 0;
|
|
212
|
+
const dur = ev.durationMs ?? 0;
|
|
114
213
|
if (ev.trackId === "mic" || ev._from === "mic") {
|
|
115
214
|
micStartMs = Math.min(micStartMs, ts);
|
|
116
215
|
micEndMs = Math.max(micEndMs, ts + dur);
|
|
@@ -121,35 +220,25 @@ function generateConversationWav(): void {
|
|
|
121
220
|
}
|
|
122
221
|
}
|
|
123
222
|
}
|
|
124
|
-
|
|
125
|
-
// Calculate total duration and offsets relative to the earliest timestamp
|
|
126
223
|
const globalStart = Math.min(
|
|
127
224
|
micStartMs === Infinity ? Infinity : micStartMs,
|
|
128
|
-
ttsStartMs === Infinity ? Infinity : ttsStartMs
|
|
225
|
+
ttsStartMs === Infinity ? Infinity : ttsStartMs
|
|
129
226
|
);
|
|
130
227
|
if (globalStart === Infinity) return;
|
|
131
|
-
|
|
132
228
|
const globalEnd = Math.max(micEndMs, ttsEndMs);
|
|
133
229
|
const totalDurationMs = globalEnd - globalStart;
|
|
134
|
-
const totalSamples = Math.ceil(
|
|
230
|
+
const totalSamples = Math.ceil(totalDurationMs / 1e3 * SAMPLE_RATE);
|
|
135
231
|
const totalBytes = totalSamples * CHANNELS * BYTES_PER_SAMPLE;
|
|
136
|
-
|
|
137
|
-
// Create a silent buffer for the full duration
|
|
138
232
|
const pcm = Buffer.alloc(totalBytes);
|
|
139
|
-
|
|
140
|
-
// Write mic audio at correct timeline position
|
|
141
233
|
for (const ev of allEvents) {
|
|
142
234
|
if (ev.type !== "audio.chunk") continue;
|
|
143
|
-
const ts =
|
|
144
|
-
const trackId =
|
|
235
|
+
const ts = ev.ts ?? 0;
|
|
236
|
+
const trackId = ev.trackId ?? ev._from ?? "";
|
|
145
237
|
if (trackId !== "mic" && trackId !== "tts") continue;
|
|
146
|
-
|
|
147
238
|
const offsetMs = ts - globalStart;
|
|
148
|
-
const offsetSamples = Math.floor(
|
|
239
|
+
const offsetSamples = Math.floor(offsetMs / 1e3 * SAMPLE_RATE);
|
|
149
240
|
const offsetBytes = offsetSamples * CHANNELS * BYTES_PER_SAMPLE;
|
|
150
|
-
const data = Buffer.from(
|
|
151
|
-
|
|
152
|
-
// Mix: add samples (clamped to int16 range)
|
|
241
|
+
const data = Buffer.from(ev.data ?? "", "base64");
|
|
153
242
|
for (let i = 0; i < data.length && offsetBytes + i + 1 < pcm.length; i += 2) {
|
|
154
243
|
const existing = pcm.readInt16LE(offsetBytes + i);
|
|
155
244
|
const incoming = data.readInt16LE(i);
|
|
@@ -157,48 +246,32 @@ function generateConversationWav(): void {
|
|
|
157
246
|
pcm.writeInt16LE(mixed, offsetBytes + i);
|
|
158
247
|
}
|
|
159
248
|
}
|
|
160
|
-
|
|
161
|
-
const convPath = join(OUTPUT_DIR, "conversation.wav");
|
|
249
|
+
const convPath = join2(OUTPUT_DIR, "conversation.wav");
|
|
162
250
|
const header = createWavHeader(pcm.length, SAMPLE_RATE, CHANNELS);
|
|
163
251
|
writeFileSync(convPath, Buffer.concat([header, pcm]));
|
|
164
252
|
log.info(`Wrote conversation.wav (${totalDurationMs}ms)`);
|
|
165
253
|
}
|
|
166
|
-
|
|
167
|
-
function generateTimelineHtml(): void {
|
|
168
|
-
// Read WAV files as base64 for embedding
|
|
254
|
+
function generateTimelineHtml() {
|
|
169
255
|
let inputWavB64 = "";
|
|
170
256
|
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
|
-
|
|
257
|
+
const micPath = join2(OUTPUT_DIR, "mic.wav");
|
|
258
|
+
const ttsPath = join2(OUTPUT_DIR, "tts.wav");
|
|
259
|
+
try {
|
|
260
|
+
inputWavB64 = readFileSync2(micPath).toString("base64");
|
|
261
|
+
} catch {
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
outputWavB64 = readFileSync2(ttsPath).toString("base64");
|
|
265
|
+
} catch {
|
|
266
|
+
}
|
|
267
|
+
const markers = allEvents.filter((ev) => {
|
|
268
|
+
const t = ev.type;
|
|
269
|
+
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";
|
|
270
|
+
}).map((ev) => ({
|
|
271
|
+
time: (ev.ts - startTime) / 1e3,
|
|
272
|
+
type: ev.type,
|
|
273
|
+
text: ev.text ?? ev.delta ?? ev.pendingText ?? ev.reason ?? ""
|
|
274
|
+
}));
|
|
202
275
|
const html = `<!DOCTYPE html>
|
|
203
276
|
<html lang="en">
|
|
204
277
|
<head>
|
|
@@ -291,71 +364,51 @@ function playPause() {
|
|
|
291
364
|
</script>
|
|
292
365
|
</body>
|
|
293
366
|
</html>`;
|
|
294
|
-
|
|
295
|
-
const htmlPath = join(OUTPUT_DIR, "timeline.html");
|
|
367
|
+
const htmlPath = join2(OUTPUT_DIR, "timeline.html");
|
|
296
368
|
writeFileSync(htmlPath, html);
|
|
297
369
|
log.info(`Wrote timeline.html`);
|
|
298
370
|
}
|
|
299
|
-
|
|
300
|
-
async function finalize(): Promise<void> {
|
|
301
|
-
// Close events stream
|
|
371
|
+
async function finalize() {
|
|
302
372
|
if (eventsStream) {
|
|
303
|
-
await new Promise
|
|
373
|
+
await new Promise((res) => eventsStream.end(() => res()));
|
|
304
374
|
}
|
|
305
|
-
|
|
306
|
-
// Finalize all audio tracks
|
|
307
375
|
for (const tw of tracks.values()) {
|
|
308
376
|
await finalizeTrack(tw);
|
|
309
377
|
}
|
|
310
|
-
|
|
311
|
-
// Generate conversation.wav
|
|
312
378
|
try {
|
|
313
379
|
generateConversationWav();
|
|
314
380
|
} catch (err) {
|
|
315
381
|
log.error(`Error generating conversation.wav: ${err}`);
|
|
316
382
|
}
|
|
317
|
-
|
|
318
|
-
// Generate timeline.html
|
|
319
383
|
try {
|
|
320
384
|
generateTimelineHtml();
|
|
321
385
|
} catch (err) {
|
|
322
386
|
log.error(`Error generating timeline.html: ${err}`);
|
|
323
387
|
}
|
|
324
|
-
|
|
325
388
|
log.info(`Recording saved to ${OUTPUT_DIR}`);
|
|
326
389
|
}
|
|
327
|
-
|
|
328
|
-
// --- Main ---
|
|
329
|
-
|
|
330
390
|
mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
331
|
-
eventsStream = createWriteStream(
|
|
391
|
+
eventsStream = createWriteStream(join2(OUTPUT_DIR, "events.jsonl"));
|
|
332
392
|
startTime = Date.now();
|
|
333
|
-
|
|
334
393
|
emit({ type: "lifecycle.ready", component: "recorder" });
|
|
335
394
|
log.info(`Recording to ${OUTPUT_DIR}`);
|
|
336
|
-
|
|
337
|
-
const rl = onEvent((event) => {
|
|
338
|
-
// Record every event to events.jsonl
|
|
395
|
+
var rl = onEvent((event) => {
|
|
339
396
|
allEvents.push(event);
|
|
340
397
|
eventsStream.write(JSON.stringify(event) + "\n");
|
|
341
|
-
|
|
342
|
-
// Capture audio tracks
|
|
343
398
|
if (event.type === "audio.chunk") {
|
|
344
|
-
const trackId =
|
|
399
|
+
const trackId = event.trackId ?? event._from ?? "unknown";
|
|
345
400
|
const tw = getOrCreateTrack(trackId);
|
|
346
|
-
const pcm = Buffer.from(
|
|
401
|
+
const pcm = Buffer.from(event.data ?? "", "base64");
|
|
347
402
|
tw.stream.write(pcm);
|
|
348
403
|
tw.bytesWritten += pcm.length;
|
|
349
404
|
}
|
|
350
405
|
});
|
|
351
|
-
|
|
352
406
|
rl.on("close", () => {
|
|
353
407
|
finalize().then(() => {
|
|
354
408
|
emit({ type: "lifecycle.done", component: "recorder" });
|
|
355
409
|
process.exit(0);
|
|
356
410
|
});
|
|
357
411
|
});
|
|
358
|
-
|
|
359
412
|
process.on("SIGTERM", () => {
|
|
360
413
|
finalize().then(() => process.exit(0));
|
|
361
414
|
});
|
|
@@ -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/manifest.yaml
CHANGED
package/package.json
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acpfx/recorder",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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
|
-
"@acpfx/core": "0.
|
|
11
|
-
"@acpfx/node-sdk": "0.
|
|
14
|
+
"@acpfx/core": "0.4.0",
|
|
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 --platform=node --format=esm --outfile=dist/index.js --packages=external && node ../../scripts/copy-manifest.js"
|
|
15
19
|
}
|
|
16
20
|
}
|
package/CHANGELOG.md
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# @acpfx/recorder
|
|
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
|