@acpfx/mic-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 +21 -0
- package/manifest.yaml +9 -0
- package/package.json +16 -0
- package/src/index.ts +189 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @acpfx/mic-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
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@acpfx/mic-file",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"acpfx-mic-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,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mic-file node — reads a WAV file and emits audio.chunk events with real-time pacing.
|
|
3
|
+
* Also emits audio.level events with computed RMS energy.
|
|
4
|
+
*
|
|
5
|
+
* Settings (via ACPFX_SETTINGS):
|
|
6
|
+
* path: string — path to WAV file
|
|
7
|
+
* realtime?: boolean — pace at real-time rate (default: true)
|
|
8
|
+
* chunkMs?: number — chunk duration in ms (default: 100)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { resolve } from "node:path";
|
|
13
|
+
import { emit, log, onEvent, handleManifestFlag } from "@acpfx/node-sdk";
|
|
14
|
+
|
|
15
|
+
handleManifestFlag();
|
|
16
|
+
|
|
17
|
+
type Settings = {
|
|
18
|
+
path: string;
|
|
19
|
+
realtime?: boolean;
|
|
20
|
+
chunkMs?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const settings: Settings = JSON.parse(
|
|
24
|
+
process.env.ACPFX_SETTINGS || "{}",
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
if (!settings.path) {
|
|
28
|
+
log.error("settings.path is required");
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const CHUNK_MS = settings.chunkMs ?? 100;
|
|
33
|
+
const REALTIME = settings.realtime ?? true;
|
|
34
|
+
const BYTES_PER_SAMPLE = 2; // 16-bit PCM
|
|
35
|
+
const TRACK_ID = "mic";
|
|
36
|
+
|
|
37
|
+
let interrupted = false;
|
|
38
|
+
|
|
39
|
+
// Handle control.interrupt from stdin
|
|
40
|
+
const rl = onEvent((event) => {
|
|
41
|
+
if (event.type === "control.interrupt") {
|
|
42
|
+
interrupted = true;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
rl.on("close", () => {
|
|
47
|
+
process.exit(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
process.on("SIGTERM", () => {
|
|
51
|
+
interrupted = true;
|
|
52
|
+
process.exit(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// --- WAV parsing ---
|
|
56
|
+
|
|
57
|
+
type WavInfo = {
|
|
58
|
+
sampleRate: number;
|
|
59
|
+
channels: number;
|
|
60
|
+
bitsPerSample: number;
|
|
61
|
+
dataOffset: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function parseWavHeader(data: Buffer): WavInfo {
|
|
65
|
+
let offset = 12;
|
|
66
|
+
let sampleRate = 16000;
|
|
67
|
+
let channels = 1;
|
|
68
|
+
let bitsPerSample = 16;
|
|
69
|
+
let dataOffset = 44;
|
|
70
|
+
|
|
71
|
+
while (offset + 8 <= data.length) {
|
|
72
|
+
const chunkId = data.toString("ascii", offset, offset + 4);
|
|
73
|
+
const chunkSize = data.readUInt32LE(offset + 4);
|
|
74
|
+
|
|
75
|
+
if (chunkId === "fmt ") {
|
|
76
|
+
channels = data.readUInt16LE(offset + 10);
|
|
77
|
+
sampleRate = data.readUInt32LE(offset + 12);
|
|
78
|
+
bitsPerSample = data.readUInt16LE(offset + 22);
|
|
79
|
+
} else if (chunkId === "data") {
|
|
80
|
+
dataOffset = offset + 8;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
offset += 8 + chunkSize;
|
|
85
|
+
if (chunkSize % 2 !== 0) offset += 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { sampleRate, channels, bitsPerSample, dataOffset };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- Audio level computation ---
|
|
92
|
+
|
|
93
|
+
function computeLevel(pcm: Buffer): { rms: number; peak: number; dbfs: number } {
|
|
94
|
+
const samples = pcm.length / BYTES_PER_SAMPLE;
|
|
95
|
+
if (samples === 0) return { rms: 0, peak: 0, dbfs: -Infinity };
|
|
96
|
+
|
|
97
|
+
let sumSq = 0;
|
|
98
|
+
let peak = 0;
|
|
99
|
+
for (let i = 0; i < pcm.length; i += BYTES_PER_SAMPLE) {
|
|
100
|
+
const sample = pcm.readInt16LE(i);
|
|
101
|
+
sumSq += sample * sample;
|
|
102
|
+
const abs = Math.abs(sample);
|
|
103
|
+
if (abs > peak) peak = abs;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const rms = Math.sqrt(sumSq / samples);
|
|
107
|
+
const dbfs = rms > 0 ? 20 * Math.log10(rms / 32768) : -Infinity;
|
|
108
|
+
|
|
109
|
+
return { rms: Math.round(rms), peak, dbfs: Math.round(dbfs * 10) / 10 };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sleep(ms: number): Promise<void> {
|
|
113
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Main ---
|
|
117
|
+
|
|
118
|
+
async function main() {
|
|
119
|
+
const filePath = resolve(settings.path);
|
|
120
|
+
const fileData = readFileSync(filePath);
|
|
121
|
+
|
|
122
|
+
let sampleRate = 16000;
|
|
123
|
+
let channels = 1;
|
|
124
|
+
let pcmData: Buffer;
|
|
125
|
+
|
|
126
|
+
// Parse WAV header if present
|
|
127
|
+
if (
|
|
128
|
+
fileData.length > 44 &&
|
|
129
|
+
fileData.toString("ascii", 0, 4) === "RIFF" &&
|
|
130
|
+
fileData.toString("ascii", 8, 12) === "WAVE"
|
|
131
|
+
) {
|
|
132
|
+
const wavInfo = parseWavHeader(fileData);
|
|
133
|
+
sampleRate = wavInfo.sampleRate;
|
|
134
|
+
channels = wavInfo.channels;
|
|
135
|
+
pcmData = fileData.subarray(wavInfo.dataOffset);
|
|
136
|
+
} else {
|
|
137
|
+
pcmData = fileData;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const bytesPerFrame = channels * BYTES_PER_SAMPLE;
|
|
141
|
+
const chunkSize = Math.floor((sampleRate * channels * BYTES_PER_SAMPLE * CHUNK_MS) / 1000);
|
|
142
|
+
|
|
143
|
+
// Emit lifecycle.ready
|
|
144
|
+
emit({ type: "lifecycle.ready", component: "mic-file" });
|
|
145
|
+
|
|
146
|
+
let offset = 0;
|
|
147
|
+
while (offset < pcmData.length && !interrupted) {
|
|
148
|
+
const end = Math.min(offset + chunkSize, pcmData.length);
|
|
149
|
+
const chunk = pcmData.subarray(offset, end);
|
|
150
|
+
const durationMs = Math.round((chunk.length / (sampleRate * bytesPerFrame)) * 1000);
|
|
151
|
+
|
|
152
|
+
// Emit audio.chunk
|
|
153
|
+
emit({
|
|
154
|
+
type: "audio.chunk",
|
|
155
|
+
trackId: TRACK_ID,
|
|
156
|
+
format: "pcm_s16le",
|
|
157
|
+
sampleRate,
|
|
158
|
+
channels,
|
|
159
|
+
data: Buffer.from(chunk).toString("base64"),
|
|
160
|
+
durationMs,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Emit audio.level
|
|
164
|
+
const level = computeLevel(chunk);
|
|
165
|
+
emit({
|
|
166
|
+
type: "audio.level",
|
|
167
|
+
trackId: TRACK_ID,
|
|
168
|
+
rms: level.rms,
|
|
169
|
+
peak: level.peak,
|
|
170
|
+
dbfs: level.dbfs,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
offset = end;
|
|
174
|
+
|
|
175
|
+
// Pace at real-time rate
|
|
176
|
+
if (REALTIME && offset < pcmData.length) {
|
|
177
|
+
await sleep(durationMs);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Emit lifecycle.done
|
|
182
|
+
emit({ type: "lifecycle.done", component: "mic-file" });
|
|
183
|
+
process.exit(0);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
main().catch((err) => {
|
|
187
|
+
log.error(`Fatal: ${err.message}`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
});
|