@makefinks/daemon 0.1.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/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/cli.js +22 -0
- package/package.json +79 -0
- package/src/ai/agent-turn-runner.ts +130 -0
- package/src/ai/daemon-ai.ts +403 -0
- package/src/ai/exa-client.ts +21 -0
- package/src/ai/exa-fetch-cache.ts +104 -0
- package/src/ai/model-config.ts +99 -0
- package/src/ai/sanitize-messages.ts +83 -0
- package/src/ai/system-prompt.ts +363 -0
- package/src/ai/tools/fetch-urls.ts +187 -0
- package/src/ai/tools/grounding-manager.ts +94 -0
- package/src/ai/tools/index.ts +52 -0
- package/src/ai/tools/read-file.ts +100 -0
- package/src/ai/tools/render-url.ts +275 -0
- package/src/ai/tools/run-bash.ts +224 -0
- package/src/ai/tools/subagents.ts +195 -0
- package/src/ai/tools/todo-manager.ts +150 -0
- package/src/ai/tools/web-search.ts +91 -0
- package/src/app/App.tsx +711 -0
- package/src/app/components/AppOverlays.tsx +131 -0
- package/src/app/components/AvatarLayer.tsx +51 -0
- package/src/app/components/ConversationPane.tsx +476 -0
- package/src/avatar/DaemonAvatarRenderable.ts +343 -0
- package/src/avatar/daemon-avatar-rig.ts +1165 -0
- package/src/avatar-preview.ts +186 -0
- package/src/cli.ts +26 -0
- package/src/components/ApiKeyInput.tsx +99 -0
- package/src/components/ApiKeyStep.tsx +95 -0
- package/src/components/ApprovalPicker.tsx +109 -0
- package/src/components/ContentBlockView.tsx +141 -0
- package/src/components/DaemonText.tsx +34 -0
- package/src/components/DeviceMenu.tsx +166 -0
- package/src/components/GroundingBadge.tsx +21 -0
- package/src/components/GroundingMenu.tsx +310 -0
- package/src/components/HotkeysPane.tsx +115 -0
- package/src/components/InlineStatusIndicator.tsx +106 -0
- package/src/components/ModelMenu.tsx +411 -0
- package/src/components/OnboardingOverlay.tsx +446 -0
- package/src/components/ProviderMenu.tsx +177 -0
- package/src/components/SessionMenu.tsx +297 -0
- package/src/components/SettingsMenu.tsx +291 -0
- package/src/components/StatusBar.tsx +126 -0
- package/src/components/TokenUsageDisplay.tsx +92 -0
- package/src/components/ToolCallView.tsx +113 -0
- package/src/components/TypingInputBar.tsx +131 -0
- package/src/components/tool-layouts/components.tsx +120 -0
- package/src/components/tool-layouts/defaults.ts +9 -0
- package/src/components/tool-layouts/index.ts +22 -0
- package/src/components/tool-layouts/layouts/bash.ts +110 -0
- package/src/components/tool-layouts/layouts/grounding.tsx +98 -0
- package/src/components/tool-layouts/layouts/index.ts +8 -0
- package/src/components/tool-layouts/layouts/read-file.ts +59 -0
- package/src/components/tool-layouts/layouts/subagent.tsx +118 -0
- package/src/components/tool-layouts/layouts/system-info.ts +8 -0
- package/src/components/tool-layouts/layouts/todo.tsx +139 -0
- package/src/components/tool-layouts/layouts/url-tools.ts +220 -0
- package/src/components/tool-layouts/layouts/web-search.ts +110 -0
- package/src/components/tool-layouts/registry.ts +17 -0
- package/src/components/tool-layouts/types.ts +94 -0
- package/src/hooks/daemon-event-handlers.ts +944 -0
- package/src/hooks/keyboard-handlers.ts +399 -0
- package/src/hooks/menu-navigation.ts +147 -0
- package/src/hooks/use-app-audio-devices-loader.ts +71 -0
- package/src/hooks/use-app-callbacks.ts +202 -0
- package/src/hooks/use-app-context-builder.ts +159 -0
- package/src/hooks/use-app-display-state.ts +162 -0
- package/src/hooks/use-app-menus.ts +51 -0
- package/src/hooks/use-app-model-pricing-loader.ts +45 -0
- package/src/hooks/use-app-model.ts +123 -0
- package/src/hooks/use-app-openrouter-models-loader.ts +44 -0
- package/src/hooks/use-app-openrouter-provider-loader.ts +35 -0
- package/src/hooks/use-app-preferences-bootstrap.ts +212 -0
- package/src/hooks/use-app-sessions.ts +105 -0
- package/src/hooks/use-app-settings.ts +62 -0
- package/src/hooks/use-conversation-manager.ts +163 -0
- package/src/hooks/use-copy-on-select.ts +50 -0
- package/src/hooks/use-daemon-events.ts +396 -0
- package/src/hooks/use-daemon-keyboard.ts +397 -0
- package/src/hooks/use-grounding.ts +46 -0
- package/src/hooks/use-input-history.ts +92 -0
- package/src/hooks/use-menu-keyboard.ts +93 -0
- package/src/hooks/use-playwright-notification.ts +23 -0
- package/src/hooks/use-reasoning-animation.ts +97 -0
- package/src/hooks/use-response-timer.ts +55 -0
- package/src/hooks/use-tool-approval.tsx +202 -0
- package/src/hooks/use-typing-mode.ts +137 -0
- package/src/hooks/use-voice-dependencies-notification.ts +37 -0
- package/src/index.tsx +48 -0
- package/src/scripts/setup-browsers.ts +42 -0
- package/src/state/app-context.tsx +160 -0
- package/src/state/daemon-events.ts +67 -0
- package/src/state/daemon-state.ts +493 -0
- package/src/state/migrations/001-init.ts +33 -0
- package/src/state/migrations/index.ts +8 -0
- package/src/state/model-history-store.ts +45 -0
- package/src/state/runtime-context.ts +21 -0
- package/src/state/session-store.ts +359 -0
- package/src/types/index.ts +405 -0
- package/src/types/theme.ts +52 -0
- package/src/ui/constants.ts +157 -0
- package/src/utils/clipboard.ts +89 -0
- package/src/utils/debug-logger.ts +69 -0
- package/src/utils/formatters.ts +242 -0
- package/src/utils/js-rendering.ts +77 -0
- package/src/utils/markdown-tables.ts +234 -0
- package/src/utils/model-metadata.ts +191 -0
- package/src/utils/openrouter-endpoints.ts +212 -0
- package/src/utils/openrouter-models.ts +205 -0
- package/src/utils/openrouter-pricing.ts +59 -0
- package/src/utils/openrouter-reported-cost.ts +16 -0
- package/src/utils/paste.ts +33 -0
- package/src/utils/preferences.ts +289 -0
- package/src/utils/text-fragment.ts +39 -0
- package/src/utils/tool-output-preview.ts +250 -0
- package/src/utils/voice-dependencies.ts +107 -0
- package/src/utils/workspace-manager.ts +85 -0
- package/src/voice/audio-recorder.ts +579 -0
- package/src/voice/mic-level.ts +35 -0
- package/src/voice/tts/openai-tts-stream.ts +222 -0
- package/src/voice/tts/speech-controller.ts +64 -0
- package/src/voice/tts/tts-player.ts +257 -0
- package/src/voice/voice-input-controller.ts +96 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import { type ChildProcess, spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import type { AudioDevice } from "../types";
|
|
4
|
+
|
|
5
|
+
let soxAvailableCache: boolean | null = null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Get the appropriate sox audio driver for the current platform.
|
|
9
|
+
* Falls back to pulseaudio for unknown platforms.
|
|
10
|
+
*/
|
|
11
|
+
function getPlatformAudioDriver(): string {
|
|
12
|
+
switch (process.platform) {
|
|
13
|
+
case "darwin":
|
|
14
|
+
return "coreaudio";
|
|
15
|
+
case "linux":
|
|
16
|
+
// pulseaudio is most common on modern Linux (Ubuntu, Fedora, etc.)
|
|
17
|
+
// Falls back gracefully - sox will error if driver unavailable
|
|
18
|
+
return "pulseaudio";
|
|
19
|
+
case "win32":
|
|
20
|
+
return "waveaudio";
|
|
21
|
+
default:
|
|
22
|
+
return "pulseaudio";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get sox install instructions for the current platform.
|
|
28
|
+
*/
|
|
29
|
+
export function getSoxInstallHint(): string {
|
|
30
|
+
switch (process.platform) {
|
|
31
|
+
case "darwin":
|
|
32
|
+
return "Run: brew install sox";
|
|
33
|
+
case "linux":
|
|
34
|
+
return "Install sox via your package manager (e.g., apt install sox libsox-fmt-pulse)";
|
|
35
|
+
case "win32":
|
|
36
|
+
return "Install sox from https://sox.sourceforge.net/";
|
|
37
|
+
default:
|
|
38
|
+
return "Install sox using your package manager.";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isSoxAvailable(): boolean {
|
|
43
|
+
if (soxAvailableCache !== null) {
|
|
44
|
+
return soxAvailableCache;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const result = spawnSync("sox", ["--version"], {
|
|
48
|
+
stdio: "ignore",
|
|
49
|
+
timeout: 2000,
|
|
50
|
+
});
|
|
51
|
+
soxAvailableCache = result.status === 0;
|
|
52
|
+
return soxAvailableCache;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class SoxNotAvailableError extends Error {
|
|
56
|
+
constructor() {
|
|
57
|
+
super(`sox is not installed. Voice recording requires sox. ${getSoxInstallHint()}`);
|
|
58
|
+
this.name = "SoxNotAvailableError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type AudioRecorderEvents = {
|
|
63
|
+
data: (chunk: Buffer) => void;
|
|
64
|
+
error: (error: Error) => void;
|
|
65
|
+
stop: () => void;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type AudioRecorderOptions = {
|
|
69
|
+
/** Sample rate in Hz (default: 16000) */
|
|
70
|
+
sampleRate?: number;
|
|
71
|
+
/** Input device name for coreaudio (optional - uses default if not specified) */
|
|
72
|
+
deviceName?: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
interface DefaultInputDeviceCache {
|
|
76
|
+
name: string | undefined;
|
|
77
|
+
timestampMs: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let defaultInputDeviceCache: DefaultInputDeviceCache | null = null;
|
|
81
|
+
|
|
82
|
+
function parseDeviceFromEnv(): string | undefined {
|
|
83
|
+
return process.env.DAEMON_AUDIO_DEVICE ?? process.env.AUDIO_DEVICE ?? undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseSoxBufferBytesFromEnv(): number | undefined {
|
|
87
|
+
const raw = process.env.DAEMON_AUDIO_BUFFER_BYTES ?? process.env.DAEMON_SOX_BUFFER_BYTES ?? undefined;
|
|
88
|
+
if (!raw) return undefined;
|
|
89
|
+
const n = Number(raw);
|
|
90
|
+
if (!Number.isFinite(n)) return undefined;
|
|
91
|
+
const clamped = Math.max(256, Math.min(16384, Math.floor(n)));
|
|
92
|
+
return clamped;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseDefaultInputDeviceFromSystemProfiler(output: string): string | undefined {
|
|
96
|
+
let currentDeviceName: string | undefined;
|
|
97
|
+
|
|
98
|
+
for (const line of output.split("\n")) {
|
|
99
|
+
// In `system_profiler SPAudioDataType`, device sections look like:
|
|
100
|
+
// " MacBook Air-Mikrofon:" (8 spaces, name, colon)
|
|
101
|
+
const headerMatch = line.match(/^\s{8}(.+):\s*$/);
|
|
102
|
+
if (headerMatch && headerMatch[1] && headerMatch[1] !== "Devices") {
|
|
103
|
+
currentDeviceName = headerMatch[1];
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (currentDeviceName && line.includes("Default Input Device: Yes")) {
|
|
108
|
+
return currentDeviceName;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Best-effort: read the macOS system default input device name.
|
|
117
|
+
* Used for UI selection highlighting when no explicit device was chosen.
|
|
118
|
+
*/
|
|
119
|
+
export async function getSystemDefaultInputDeviceName(): Promise<string | undefined> {
|
|
120
|
+
if (process.platform !== "darwin") return undefined;
|
|
121
|
+
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
if (defaultInputDeviceCache && now - defaultInputDeviceCache.timestampMs < 10_000) {
|
|
124
|
+
return defaultInputDeviceCache.name;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const output = await new Promise<string>((resolve, reject) => {
|
|
129
|
+
const proc = spawn("system_profiler", ["SPAudioDataType"], {
|
|
130
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
let stdout = "";
|
|
134
|
+
proc.stdout?.on("data", (data: Buffer) => {
|
|
135
|
+
stdout += data.toString();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
proc.once("error", (error) => reject(error));
|
|
139
|
+
proc.once("close", () => resolve(stdout));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const name = parseDefaultInputDeviceFromSystemProfiler(output);
|
|
143
|
+
defaultInputDeviceCache = { name, timestampMs: now };
|
|
144
|
+
return name;
|
|
145
|
+
} catch (error: unknown) {
|
|
146
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
147
|
+
defaultInputDeviceCache = { name: undefined, timestampMs: now };
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Create a valid WAV header for raw PCM data.
|
|
154
|
+
* @param dataLength Length of the raw PCM data in bytes
|
|
155
|
+
* @param sampleRate Sample rate in Hz
|
|
156
|
+
* @param numChannels Number of audio channels
|
|
157
|
+
* @param bitsPerSample Bits per sample (typically 16)
|
|
158
|
+
*/
|
|
159
|
+
function createWavHeader(
|
|
160
|
+
dataLength: number,
|
|
161
|
+
sampleRate: number,
|
|
162
|
+
numChannels: number = 1,
|
|
163
|
+
bitsPerSample: number = 16
|
|
164
|
+
): Buffer {
|
|
165
|
+
const header = Buffer.alloc(44);
|
|
166
|
+
const byteRate = sampleRate * numChannels * (bitsPerSample / 8);
|
|
167
|
+
const blockAlign = numChannels * (bitsPerSample / 8);
|
|
168
|
+
|
|
169
|
+
// RIFF chunk descriptor
|
|
170
|
+
header.write("RIFF", 0);
|
|
171
|
+
header.writeUInt32LE(36 + dataLength, 4); // File size - 8
|
|
172
|
+
header.write("WAVE", 8);
|
|
173
|
+
|
|
174
|
+
// fmt sub-chunk
|
|
175
|
+
header.write("fmt ", 12);
|
|
176
|
+
header.writeUInt32LE(16, 16); // Subchunk1Size (16 for PCM)
|
|
177
|
+
header.writeUInt16LE(1, 20); // AudioFormat (1 = PCM)
|
|
178
|
+
header.writeUInt16LE(numChannels, 22);
|
|
179
|
+
header.writeUInt32LE(sampleRate, 24);
|
|
180
|
+
header.writeUInt32LE(byteRate, 28);
|
|
181
|
+
header.writeUInt16LE(blockAlign, 32);
|
|
182
|
+
header.writeUInt16LE(bitsPerSample, 34);
|
|
183
|
+
|
|
184
|
+
// data sub-chunk
|
|
185
|
+
header.write("data", 36);
|
|
186
|
+
header.writeUInt32LE(dataLength, 40);
|
|
187
|
+
|
|
188
|
+
return header;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Audio recorder using sox for cross-platform microphone capture.
|
|
193
|
+
* Records mono audio suitable for transcription APIs.
|
|
194
|
+
*
|
|
195
|
+
* Uses sox with coreaudio on macOS for direct device capture.
|
|
196
|
+
*/
|
|
197
|
+
export class AudioRecorder extends EventEmitter {
|
|
198
|
+
private process: ChildProcess | null = null;
|
|
199
|
+
private chunks: Buffer[] = [];
|
|
200
|
+
private _isRecording = false;
|
|
201
|
+
private options: Required<Omit<AudioRecorderOptions, "deviceName">> &
|
|
202
|
+
Pick<AudioRecorderOptions, "deviceName">;
|
|
203
|
+
private startTime: number = 0;
|
|
204
|
+
|
|
205
|
+
constructor(options: AudioRecorderOptions = {}) {
|
|
206
|
+
super();
|
|
207
|
+
this.options = {
|
|
208
|
+
sampleRate: options.sampleRate ?? 16000,
|
|
209
|
+
deviceName: options.deviceName,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
get isRecording(): boolean {
|
|
214
|
+
return this._isRecording;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Start recording audio from the microphone.
|
|
219
|
+
* Uses sox with platform-appropriate driver (coreaudio/pulseaudio/waveaudio).
|
|
220
|
+
* Outputs raw PCM s16le which we wrap with a WAV header on stop().
|
|
221
|
+
*/
|
|
222
|
+
start(): void {
|
|
223
|
+
if (this._isRecording) return;
|
|
224
|
+
|
|
225
|
+
if (!isSoxAvailable()) {
|
|
226
|
+
this.emit("error", new SoxNotAvailableError());
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
this.chunks = [];
|
|
231
|
+
this._isRecording = true;
|
|
232
|
+
this.startTime = Date.now();
|
|
233
|
+
|
|
234
|
+
const deviceName = this.options.deviceName ?? parseDeviceFromEnv();
|
|
235
|
+
const soxBufferBytes = parseSoxBufferBytesFromEnv() ?? 2048;
|
|
236
|
+
const audioDriver = getPlatformAudioDriver();
|
|
237
|
+
|
|
238
|
+
const args: string[] = [
|
|
239
|
+
"--buffer",
|
|
240
|
+
String(soxBufferBytes),
|
|
241
|
+
"--input-buffer",
|
|
242
|
+
String(soxBufferBytes),
|
|
243
|
+
"-q",
|
|
244
|
+
"-t",
|
|
245
|
+
audioDriver,
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
if (deviceName) {
|
|
249
|
+
args.push(deviceName);
|
|
250
|
+
} else {
|
|
251
|
+
args.push("default");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Output format: raw signed 16-bit little-endian mono
|
|
255
|
+
args.push(
|
|
256
|
+
"-t",
|
|
257
|
+
"raw",
|
|
258
|
+
"-e",
|
|
259
|
+
"signed-integer",
|
|
260
|
+
"-b",
|
|
261
|
+
"16",
|
|
262
|
+
"-c",
|
|
263
|
+
"1",
|
|
264
|
+
"-r",
|
|
265
|
+
String(this.options.sampleRate),
|
|
266
|
+
"-"
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
this.process = spawn("sox", args, {
|
|
270
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
this.process.stdout?.on("data", (chunk: Buffer) => {
|
|
274
|
+
this.chunks.push(chunk);
|
|
275
|
+
this.emit("data", chunk);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
this.process.stderr?.on("data", (data: Buffer) => {
|
|
279
|
+
const msg = data.toString();
|
|
280
|
+
// Only emit as error if it's a real failure
|
|
281
|
+
if (
|
|
282
|
+
msg.includes("FAIL") ||
|
|
283
|
+
msg.includes("error") ||
|
|
284
|
+
msg.includes("Error") ||
|
|
285
|
+
msg.includes("Invalid") ||
|
|
286
|
+
msg.includes("Could not") ||
|
|
287
|
+
msg.includes("can't")
|
|
288
|
+
) {
|
|
289
|
+
this.emit("error", new Error(msg.trim()));
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
this.process.on("error", (err) => {
|
|
294
|
+
this._isRecording = false;
|
|
295
|
+
this.emit(
|
|
296
|
+
"error",
|
|
297
|
+
new Error(
|
|
298
|
+
`Failed to start recording: ${err.message}. ` +
|
|
299
|
+
`${getSoxInstallHint()} ` +
|
|
300
|
+
`(Tip: set DAEMON_AUDIO_DEVICE to a specific input device name)`
|
|
301
|
+
)
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
this.process.on("close", (code) => {
|
|
306
|
+
this._isRecording = false;
|
|
307
|
+
this.emit("stop");
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Stop recording and return the captured audio as a WAV Buffer.
|
|
313
|
+
* Adds a proper WAV header to the raw PCM data.
|
|
314
|
+
* Note: This returns immediately. For full buffer flush, use stopAsync().
|
|
315
|
+
*/
|
|
316
|
+
stop(): Buffer {
|
|
317
|
+
const proc = this.process;
|
|
318
|
+
if (proc) {
|
|
319
|
+
// Send SIGINT to sox for graceful exit
|
|
320
|
+
proc.kill("SIGINT");
|
|
321
|
+
this.process = null;
|
|
322
|
+
}
|
|
323
|
+
this._isRecording = false;
|
|
324
|
+
|
|
325
|
+
// Combine all raw PCM chunks
|
|
326
|
+
const pcmData = Buffer.concat(this.chunks);
|
|
327
|
+
|
|
328
|
+
// We trust sox delivered the sample rate we asked for
|
|
329
|
+
const actualSampleRate = this.options.sampleRate;
|
|
330
|
+
const wavHeader = createWavHeader(pcmData.length, actualSampleRate);
|
|
331
|
+
|
|
332
|
+
// Combine header + PCM data
|
|
333
|
+
const wavBuffer = Buffer.concat([wavHeader, pcmData]);
|
|
334
|
+
|
|
335
|
+
return wavBuffer;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Stop recording and wait for sox to fully flush its buffers.
|
|
340
|
+
* Returns the captured audio as a WAV Buffer.
|
|
341
|
+
*/
|
|
342
|
+
async stopAsync(): Promise<Buffer> {
|
|
343
|
+
const proc = this.process;
|
|
344
|
+
if (!proc) {
|
|
345
|
+
return this.stop();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return new Promise((resolve) => {
|
|
349
|
+
// Resolve only after stdout has ended and the process has closed,
|
|
350
|
+
// to avoid truncating buffered audio.
|
|
351
|
+
let stdoutEnded = proc.stdout ? false : true;
|
|
352
|
+
let procClosed = false;
|
|
353
|
+
|
|
354
|
+
const finalize = () => {
|
|
355
|
+
if (!stdoutEnded || !procClosed) return;
|
|
356
|
+
this._isRecording = false;
|
|
357
|
+
|
|
358
|
+
const pcmData = Buffer.concat(this.chunks);
|
|
359
|
+
const actualSampleRate = this.options.sampleRate;
|
|
360
|
+
const wavHeader = createWavHeader(pcmData.length, actualSampleRate);
|
|
361
|
+
const wavBuffer = Buffer.concat([wavHeader, pcmData]);
|
|
362
|
+
|
|
363
|
+
resolve(wavBuffer);
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
proc.stdout?.once("end", () => {
|
|
367
|
+
stdoutEnded = true;
|
|
368
|
+
finalize();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Wait for process to close (stdio closed)
|
|
372
|
+
proc.once("close", () => {
|
|
373
|
+
procClosed = true;
|
|
374
|
+
finalize();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Send SIGINT to sox for graceful exit
|
|
378
|
+
proc.kill("SIGINT");
|
|
379
|
+
this.process = null;
|
|
380
|
+
|
|
381
|
+
// Timeout safety - kill if sox doesn't exit
|
|
382
|
+
setTimeout(() => {
|
|
383
|
+
if (proc.exitCode !== null) return;
|
|
384
|
+
proc.kill("SIGTERM");
|
|
385
|
+
}, 3000);
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Get the current recording as a Buffer without stopping.
|
|
391
|
+
*/
|
|
392
|
+
getBuffer(): Buffer {
|
|
393
|
+
return Buffer.concat(this.chunks);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Get the current recording duration in seconds (approximate).
|
|
398
|
+
*/
|
|
399
|
+
getDuration(): number {
|
|
400
|
+
// Use elapsed time for more accurate duration during recording
|
|
401
|
+
if (this._isRecording) {
|
|
402
|
+
return (Date.now() - this.startTime) / 1000;
|
|
403
|
+
}
|
|
404
|
+
// After stopping, calculate from raw PCM buffer size
|
|
405
|
+
// Raw PCM: 16-bit mono samples at sampleRate (2 bytes per sample)
|
|
406
|
+
const bytes = this.chunks.reduce((acc, chunk) => acc + chunk.length, 0);
|
|
407
|
+
const samples = bytes / 2; // 16-bit = 2 bytes per sample
|
|
408
|
+
return samples / this.options.sampleRate;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Clean up resources.
|
|
413
|
+
*/
|
|
414
|
+
destroy(): void {
|
|
415
|
+
if (this.process) {
|
|
416
|
+
this.process.kill("SIGKILL");
|
|
417
|
+
this.process = null;
|
|
418
|
+
}
|
|
419
|
+
this._isRecording = false;
|
|
420
|
+
this.chunks = [];
|
|
421
|
+
this.removeAllListeners();
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Singleton instance for convenience
|
|
426
|
+
let recorder: AudioRecorder | null = null;
|
|
427
|
+
let recorderConfig: AudioRecorderOptions | null = null;
|
|
428
|
+
|
|
429
|
+
export function getRecorder(options: AudioRecorderOptions = {}): AudioRecorder {
|
|
430
|
+
if (!recorder) {
|
|
431
|
+
recorder = new AudioRecorder(options);
|
|
432
|
+
recorderConfig = { ...options };
|
|
433
|
+
return recorder;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (options.sampleRate !== undefined || options.deviceName !== undefined) {
|
|
437
|
+
const current = recorderConfig ?? {};
|
|
438
|
+
const desiredSampleRate = options.sampleRate ?? current.sampleRate;
|
|
439
|
+
const desiredDeviceName = options.deviceName ?? current.deviceName;
|
|
440
|
+
|
|
441
|
+
if (desiredSampleRate !== current.sampleRate || desiredDeviceName !== current.deviceName) {
|
|
442
|
+
recorder.destroy();
|
|
443
|
+
recorder = new AudioRecorder({
|
|
444
|
+
sampleRate: desiredSampleRate,
|
|
445
|
+
deviceName: desiredDeviceName,
|
|
446
|
+
});
|
|
447
|
+
recorderConfig = {
|
|
448
|
+
sampleRate: desiredSampleRate,
|
|
449
|
+
deviceName: desiredDeviceName,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return recorder;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function destroyRecorder(): void {
|
|
457
|
+
if (recorder) {
|
|
458
|
+
recorder.destroy();
|
|
459
|
+
recorder = null;
|
|
460
|
+
recorderConfig = null;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Set the audio device for recording by name.
|
|
466
|
+
* This will recreate the recorder with the new device.
|
|
467
|
+
*/
|
|
468
|
+
export function setAudioDevice(deviceName: string): void {
|
|
469
|
+
const current = recorderConfig ?? {};
|
|
470
|
+
if (recorder) {
|
|
471
|
+
recorder.destroy();
|
|
472
|
+
}
|
|
473
|
+
recorder = new AudioRecorder({
|
|
474
|
+
sampleRate: current.sampleRate ?? 16000,
|
|
475
|
+
deviceName,
|
|
476
|
+
});
|
|
477
|
+
recorderConfig = {
|
|
478
|
+
sampleRate: current.sampleRate ?? 16000,
|
|
479
|
+
deviceName,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Get the currently selected device name.
|
|
485
|
+
*/
|
|
486
|
+
export function getCurrentDeviceName(): string | undefined {
|
|
487
|
+
return recorderConfig?.deviceName;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* List available audio input devices using sox.
|
|
492
|
+
* macOS: Uses coreaudio verbose output parsing.
|
|
493
|
+
* Linux: Uses pactl for PulseAudio sources.
|
|
494
|
+
* Other: Returns default device only.
|
|
495
|
+
*/
|
|
496
|
+
export async function listAudioDevices(): Promise<AudioDevice[]> {
|
|
497
|
+
if (process.platform === "darwin") {
|
|
498
|
+
return listAudioDevicesMacOS();
|
|
499
|
+
}
|
|
500
|
+
if (process.platform === "linux") {
|
|
501
|
+
return listAudioDevicesLinux();
|
|
502
|
+
}
|
|
503
|
+
return [{ name: "default" }];
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function listAudioDevicesMacOS(): Promise<AudioDevice[]> {
|
|
507
|
+
return new Promise((resolve) => {
|
|
508
|
+
const proc = spawn("sox", ["-V6", "-n", "-t", "coreaudio", "nonexistent_device_to_force_list"], {
|
|
509
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
let output = "";
|
|
513
|
+
proc.stderr?.on("data", (data: Buffer) => {
|
|
514
|
+
output += data.toString();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
proc.on("close", () => {
|
|
518
|
+
const devices: AudioDevice[] = [];
|
|
519
|
+
const lines = output.split("\n");
|
|
520
|
+
const seen = new Set<string>();
|
|
521
|
+
|
|
522
|
+
for (const line of lines) {
|
|
523
|
+
const match = line.match(/Found Audio Device "(.+)"/);
|
|
524
|
+
if (match && match[1]) {
|
|
525
|
+
const name = match[1];
|
|
526
|
+
if (!seen.has(name)) {
|
|
527
|
+
seen.add(name);
|
|
528
|
+
devices.push({ name });
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (devices.length === 0) {
|
|
534
|
+
devices.push({ name: "default" });
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
resolve(devices);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
proc.on("error", () => {
|
|
541
|
+
resolve([{ name: "default" }]);
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function listAudioDevicesLinux(): Promise<AudioDevice[]> {
|
|
547
|
+
return new Promise((resolve) => {
|
|
548
|
+
const proc = spawn("pactl", ["list", "sources", "short"], {
|
|
549
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
let output = "";
|
|
553
|
+
proc.stdout?.on("data", (data: Buffer) => {
|
|
554
|
+
output += data.toString();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
proc.on("close", () => {
|
|
558
|
+
const devices: AudioDevice[] = [];
|
|
559
|
+
const lines = output.split("\n");
|
|
560
|
+
|
|
561
|
+
for (const line of lines) {
|
|
562
|
+
const parts = line.split("\t");
|
|
563
|
+
if (parts[1]) {
|
|
564
|
+
devices.push({ name: parts[1] });
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (devices.length === 0) {
|
|
569
|
+
devices.push({ name: "default" });
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
resolve(devices);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
proc.on("error", () => {
|
|
576
|
+
resolve([{ name: "default" }]);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface MicLevelOptions {
|
|
2
|
+
noiseFloor?: number;
|
|
3
|
+
gain?: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Compute a normalized mic level (0..1) from a PCM16 LE audio chunk.
|
|
8
|
+
* Returns `null` if the chunk is too small to analyze.
|
|
9
|
+
*/
|
|
10
|
+
export function computeMicLevelFromPcm16Chunk(chunk: Buffer, options: MicLevelOptions = {}): number | null {
|
|
11
|
+
if (chunk.length < 2) return null;
|
|
12
|
+
|
|
13
|
+
const sampleCount = Math.floor(chunk.length / 2);
|
|
14
|
+
if (sampleCount <= 0) return null;
|
|
15
|
+
|
|
16
|
+
let sumSquares = 0;
|
|
17
|
+
|
|
18
|
+
const view = new Int16Array(chunk.buffer, chunk.byteOffset, sampleCount);
|
|
19
|
+
for (let i = 0; i < view.length; i++) {
|
|
20
|
+
const v = view[i] ?? 0;
|
|
21
|
+
const f = v / 32768;
|
|
22
|
+
sumSquares += f * f;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const rms = Math.sqrt(sumSquares / view.length);
|
|
26
|
+
const noiseFloor = options.noiseFloor ?? 0.005;
|
|
27
|
+
const gain = options.gain ?? 25;
|
|
28
|
+
const rawLevel = Math.max(0, (rms - noiseFloor) * gain);
|
|
29
|
+
return Math.min(1, rawLevel);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function smoothMicLevel(prev: number, next: number): number {
|
|
33
|
+
const alpha = next > prev ? 0.9 : 0.6;
|
|
34
|
+
return prev + (next - prev) * alpha;
|
|
35
|
+
}
|