@hxnnxs/opencode-voice 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/CHANGELOG.md +17 -0
- package/CONTRIBUTING.md +22 -0
- package/LICENSE +21 -0
- package/PUBLISHING.md +36 -0
- package/README.es.md +174 -0
- package/README.md +174 -0
- package/README.ru.md +174 -0
- package/README.zh.md +174 -0
- package/SECURITY.md +19 -0
- package/assets/opencode-voice-dark.svg +27 -0
- package/assets/opencode-voice-light.svg +27 -0
- package/bin/opencode-voice.js +179 -0
- package/index.js +819 -0
- package/lib/download.js +129 -0
- package/lib/engine.js +406 -0
- package/lib/engines.js +337 -0
- package/lib/models.js +161 -0
- package/package.json +70 -0
package/lib/download.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { Readable, Transform } from "node:stream";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
5
|
+
import { getModelPath, getModelsDir, getModelVerificationPath } from "./models.js";
|
|
6
|
+
|
|
7
|
+
function sleep(ms) {
|
|
8
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function ensureDir(dir) {
|
|
12
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function sha256(file) {
|
|
16
|
+
const hash = createHash("sha256");
|
|
17
|
+
const input = fs.createReadStream(file);
|
|
18
|
+
for await (const chunk of input) hash.update(chunk);
|
|
19
|
+
return hash.digest("hex");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function verifyModel(model, file) {
|
|
23
|
+
if (!model.sha256) return true;
|
|
24
|
+
const actual = await sha256(file);
|
|
25
|
+
return actual === model.sha256;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function writeModelVerificationMarker(model, file, options = {}, settings = {}) {
|
|
29
|
+
if (!model.sha256) return;
|
|
30
|
+
const marker = getModelVerificationPath(model, options, settings);
|
|
31
|
+
await fs.promises.writeFile(marker, `${model.sha256}\n`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function fetchWithTimeout(url, init = {}, timeoutMs = 60000) {
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
37
|
+
try {
|
|
38
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error?.name === "AbortError") throw new Error(`Download timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
41
|
+
throw error;
|
|
42
|
+
} finally {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function downloadModelOnce(model, options = {}, settings = {}, hooks = {}, attempt = 1, attempts = 1) {
|
|
48
|
+
if (!model?.implemented || !model.url || !model.filename) {
|
|
49
|
+
throw new Error(`${model?.name || "Model"} is not supported by the current local engine yet`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const dir = getModelsDir(options, settings);
|
|
53
|
+
const file = getModelPath(model, options, settings);
|
|
54
|
+
const partial = `${file}.partial`;
|
|
55
|
+
await ensureDir(dir);
|
|
56
|
+
|
|
57
|
+
if (fs.existsSync(file)) {
|
|
58
|
+
hooks.onProgress?.({ state: "verifying", downloaded: fs.statSync(file).size, total: fs.statSync(file).size, percent: 100, attempt, attempts });
|
|
59
|
+
if (await verifyModel(model, file)) {
|
|
60
|
+
await writeModelVerificationMarker(model, file, options, settings);
|
|
61
|
+
return file;
|
|
62
|
+
}
|
|
63
|
+
await fs.promises.unlink(file);
|
|
64
|
+
await fs.promises.unlink(getModelVerificationPath(model, options, settings)).catch(() => {});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const partialSize = fs.existsSync(partial) ? fs.statSync(partial).size : 0;
|
|
68
|
+
const headers = partialSize > 0 ? { Range: `bytes=${partialSize}-` } : undefined;
|
|
69
|
+
const response = await fetchWithTimeout(model.url, { headers }, Number(options.downloadTimeoutMs || hooks.timeoutMs || 60000));
|
|
70
|
+
|
|
71
|
+
if (response.status === 416 && partialSize > 0) {
|
|
72
|
+
await fs.promises.unlink(partial).catch(() => {});
|
|
73
|
+
return downloadModelOnce(model, options, settings, hooks, attempt, attempts);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!response.ok && response.status !== 206) {
|
|
77
|
+
throw new Error(`Download failed: HTTP ${response.status}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const append = response.status === 206 && partialSize > 0;
|
|
81
|
+
const contentLength = Number(response.headers.get("content-length") || 0);
|
|
82
|
+
const total = contentLength > 0 ? contentLength + (append ? partialSize : 0) : model.sizeMB ? model.sizeMB * 1024 * 1024 : 0;
|
|
83
|
+
let downloaded = append ? partialSize : 0;
|
|
84
|
+
|
|
85
|
+
hooks.onProgress?.({ state: "downloading", downloaded, total, percent: total ? (downloaded / total) * 100 : 0, attempt, attempts });
|
|
86
|
+
|
|
87
|
+
const body = response.body?.getReader ? Readable.fromWeb(response.body) : response.body;
|
|
88
|
+
if (!body) throw new Error("Download failed: empty response body");
|
|
89
|
+
|
|
90
|
+
const progress = new Transform({
|
|
91
|
+
transform(chunk, _encoding, callback) {
|
|
92
|
+
downloaded += chunk.length ?? chunk.byteLength ?? 0;
|
|
93
|
+
hooks.onProgress?.({ state: "downloading", downloaded, total, percent: total ? (downloaded / total) * 100 : 0, attempt, attempts });
|
|
94
|
+
callback(null, chunk);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await pipeline(body, progress, fs.createWriteStream(partial, { flags: append ? "a" : "w" }));
|
|
99
|
+
|
|
100
|
+
hooks.onProgress?.({ state: "verifying", downloaded, total: total || downloaded, percent: 100, attempt, attempts });
|
|
101
|
+
|
|
102
|
+
if (!(await verifyModel(model, partial))) {
|
|
103
|
+
await fs.promises.unlink(partial).catch(() => {});
|
|
104
|
+
throw new Error(`SHA256 mismatch for ${model.name}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await fs.promises.rename(partial, file);
|
|
108
|
+
await writeModelVerificationMarker(model, file, options, settings);
|
|
109
|
+
hooks.onProgress?.({ state: "done", downloaded, total: total || downloaded, percent: 100, attempt, attempts });
|
|
110
|
+
return file;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function downloadModel(model, options = {}, settings = {}, hooks = {}) {
|
|
114
|
+
const attempts = Number(options.downloadRetries || hooks.retries || 3);
|
|
115
|
+
let lastError;
|
|
116
|
+
|
|
117
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
118
|
+
try {
|
|
119
|
+
return await downloadModelOnce(model, options, settings, hooks, attempt, attempts);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
lastError = error;
|
|
122
|
+
if (attempt >= attempts) break;
|
|
123
|
+
hooks.onRetry?.({ error, attempt, attempts, nextAttempt: attempt + 1 });
|
|
124
|
+
await sleep(Math.min(1000 * attempt, 3000));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
throw lastError;
|
|
129
|
+
}
|
package/lib/engine.js
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import { ensureDir } from "./download.js";
|
|
6
|
+
import { getAudioDir, getEnginesDir, getModelPath } from "./models.js";
|
|
7
|
+
|
|
8
|
+
const RECORDING_MIN_BYTES = 44;
|
|
9
|
+
|
|
10
|
+
function isExecutable(file) {
|
|
11
|
+
try {
|
|
12
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
13
|
+
return fs.statSync(file).isFile();
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function executableNames(command) {
|
|
20
|
+
const names = [command];
|
|
21
|
+
if (path.extname(command)) return names;
|
|
22
|
+
|
|
23
|
+
if (process.platform === "win32") {
|
|
24
|
+
const extensions = (process.env.PATHEXT || ".EXE;.CMD;.BAT")
|
|
25
|
+
.split(";")
|
|
26
|
+
.map((item) => item.trim().toLowerCase())
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
for (const extension of extensions) names.push(`${command}${extension}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return [...new Set(names)];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function platformKey(options = {}) {
|
|
35
|
+
return `${options.platform || process.platform}-${options.arch || process.arch}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getBundledEngineDir(command, options = {}) {
|
|
39
|
+
if (command !== "whisper-cli") return "";
|
|
40
|
+
return path.join(getEnginesDir(options, options), "whisper.cpp", platformKey(options));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function looksLikePath(value) {
|
|
44
|
+
return path.isAbsolute(value) || value.includes("/") || value.includes("\\");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function candidateCommands(command, options = {}) {
|
|
48
|
+
const candidates = [];
|
|
49
|
+
|
|
50
|
+
if (command === "whisper-cli") {
|
|
51
|
+
if (options.whisperCli) candidates.push(options.whisperCli);
|
|
52
|
+
if (process.env.OPENCODE_VOICE_WHISPER_CLI) candidates.push(process.env.OPENCODE_VOICE_WHISPER_CLI);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const bundledDir = getBundledEngineDir(command, options);
|
|
56
|
+
if (bundledDir) {
|
|
57
|
+
for (const name of executableNames(command)) candidates.push(path.join(bundledDir, name));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
candidates.push(...executableNames(command));
|
|
61
|
+
|
|
62
|
+
const fallbackDirs = [
|
|
63
|
+
path.join(os.homedir(), ".local", "bin"),
|
|
64
|
+
path.join(os.homedir(), ".opencode-voice", "bin"),
|
|
65
|
+
"/usr/local/bin",
|
|
66
|
+
"/opt/homebrew/bin",
|
|
67
|
+
"/usr/bin",
|
|
68
|
+
"/bin",
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
if (process.platform === "win32") {
|
|
72
|
+
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
|
73
|
+
fallbackDirs.unshift(path.join(localAppData, "opencode-voice", "bin"));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const dir of fallbackDirs) {
|
|
77
|
+
for (const name of executableNames(command)) candidates.push(path.join(dir, name));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return [...new Set(candidates.filter(Boolean))];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function resolveCommand(command, options = {}) {
|
|
84
|
+
for (const candidate of candidateCommands(command, options)) {
|
|
85
|
+
if (looksLikePath(candidate)) {
|
|
86
|
+
if (isExecutable(candidate)) return candidate;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const dir of (process.env.PATH || "").split(path.delimiter).filter(Boolean)) {
|
|
91
|
+
const file = path.join(dir, candidate);
|
|
92
|
+
if (isExecutable(file)) return file;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function commandExists(command, options = {}) {
|
|
100
|
+
return Boolean(resolveCommand(command, options));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function childEnv(options = {}) {
|
|
104
|
+
const localLib = path.join(os.homedir(), ".local", "lib");
|
|
105
|
+
const localBin = path.join(os.homedir(), ".local", "bin");
|
|
106
|
+
const bundledDir = getBundledEngineDir("whisper-cli", options);
|
|
107
|
+
const pathEntries = [bundledDir, localBin, localLib, process.env.PATH].filter(Boolean);
|
|
108
|
+
return {
|
|
109
|
+
...process.env,
|
|
110
|
+
PATH: pathEntries.join(path.delimiter),
|
|
111
|
+
LD_LIBRARY_PATH: [localLib, process.env.LD_LIBRARY_PATH].filter(Boolean).join(path.delimiter),
|
|
112
|
+
DYLD_LIBRARY_PATH: [localLib, process.env.DYLD_LIBRARY_PATH].filter(Boolean).join(path.delimiter),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function listMicrophones() {
|
|
117
|
+
if (process.platform === "linux" && commandExists("arecord")) {
|
|
118
|
+
const result = spawnSync("arecord", ["-L"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
119
|
+
const devices = result.stdout
|
|
120
|
+
.split(/\r?\n/)
|
|
121
|
+
.map((line) => line.trim())
|
|
122
|
+
.filter((line) => line && !line.startsWith("#") && !line.includes(" ") && line !== "null");
|
|
123
|
+
return ["default", ...devices.filter((item) => item !== "default")];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (process.platform === "darwin" && commandExists("ffmpeg")) {
|
|
127
|
+
const result = spawnSync("ffmpeg", ["-hide_banner", "-f", "avfoundation", "-list_devices", "true", "-i", ""], {
|
|
128
|
+
encoding: "utf8",
|
|
129
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
130
|
+
});
|
|
131
|
+
return result.stderr
|
|
132
|
+
.split(/\r?\n/)
|
|
133
|
+
.map((line) => line.match(/\[(\d+)\]\s+(.+)$/)?.[1])
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.map((id) => `:${id}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return ["default"];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildRecorders(file, settings = {}) {
|
|
142
|
+
const mic = settings.mic || "";
|
|
143
|
+
const recorders = [];
|
|
144
|
+
|
|
145
|
+
if (process.platform === "linux" && commandExists("arecord")) {
|
|
146
|
+
recorders.push({
|
|
147
|
+
label: mic ? `arecord (${mic})` : "arecord (default)",
|
|
148
|
+
command: "arecord",
|
|
149
|
+
args: ["-q", "-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "wav", ...(mic ? ["-D", mic] : []), file],
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (process.platform === "linux" && commandExists("ffmpeg")) {
|
|
154
|
+
if (!mic) {
|
|
155
|
+
recorders.push({
|
|
156
|
+
label: "ffmpeg pulse (default)",
|
|
157
|
+
command: "ffmpeg",
|
|
158
|
+
args: ["-hide_banner", "-loglevel", "error", "-y", "-f", "pulse", "-i", "default", "-ac", "1", "-ar", "16000", file],
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
recorders.push({
|
|
163
|
+
label: `ffmpeg alsa (${mic || "default"})`,
|
|
164
|
+
command: "ffmpeg",
|
|
165
|
+
args: ["-hide_banner", "-loglevel", "error", "-y", "-f", "alsa", "-i", mic || "default", "-ac", "1", "-ar", "16000", file],
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (process.platform === "darwin" && commandExists("ffmpeg")) {
|
|
170
|
+
recorders.push({
|
|
171
|
+
label: `ffmpeg avfoundation (${mic || ":0"})`,
|
|
172
|
+
command: "ffmpeg",
|
|
173
|
+
args: ["-hide_banner", "-loglevel", "error", "-y", "-f", "avfoundation", "-i", mic || ":0", "-ac", "1", "-ar", "16000", file],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (commandExists("sox")) {
|
|
178
|
+
recorders.push({
|
|
179
|
+
label: "sox default",
|
|
180
|
+
command: "sox",
|
|
181
|
+
args: ["-d", "-r", "16000", "-c", "1", "-b", "16", file],
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!recorders.length) throw new Error("No recorder found. Install ffmpeg, arecord, or sox.");
|
|
186
|
+
return recorders;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function waitForExit(proc, timeoutMs = 3000) {
|
|
190
|
+
return new Promise((resolve) => {
|
|
191
|
+
let settled = false;
|
|
192
|
+
const finish = () => {
|
|
193
|
+
if (settled) return;
|
|
194
|
+
settled = true;
|
|
195
|
+
resolve();
|
|
196
|
+
};
|
|
197
|
+
const timer = setTimeout(() => {
|
|
198
|
+
try {
|
|
199
|
+
proc.kill("SIGKILL");
|
|
200
|
+
} catch {}
|
|
201
|
+
finish();
|
|
202
|
+
}, timeoutMs);
|
|
203
|
+
|
|
204
|
+
proc.once("exit", () => {
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
finish();
|
|
207
|
+
});
|
|
208
|
+
proc.once("error", () => {
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
finish();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function cleanTranscription(text) {
|
|
216
|
+
return text
|
|
217
|
+
.replace(/\[[^\]]*\]/g, " ")
|
|
218
|
+
.replace(/\([^)]*\)/g, " ")
|
|
219
|
+
.replace(/\s+/g, " ")
|
|
220
|
+
.trim();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function lastLine(value) {
|
|
224
|
+
return value.trim().split("\n").filter(Boolean).pop() || "";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function startRecorder(recorder, file) {
|
|
228
|
+
return new Promise((resolve, reject) => {
|
|
229
|
+
let stderr = "";
|
|
230
|
+
let exited = false;
|
|
231
|
+
let exitCode = null;
|
|
232
|
+
|
|
233
|
+
const proc = spawn(recorder.command, recorder.args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
234
|
+
proc.stderr.on("data", (chunk) => {
|
|
235
|
+
stderr += chunk.toString();
|
|
236
|
+
});
|
|
237
|
+
proc.once("error", (error) => {
|
|
238
|
+
exited = true;
|
|
239
|
+
reject(new Error(`${recorder.label}: ${error.message}`));
|
|
240
|
+
});
|
|
241
|
+
proc.once("exit", (code) => {
|
|
242
|
+
exited = true;
|
|
243
|
+
exitCode = code;
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
setTimeout(() => {
|
|
247
|
+
if (exited) {
|
|
248
|
+
reject(new Error(`${recorder.label}: ${lastLine(stderr) || `exited with code ${exitCode}`}`));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!fs.existsSync(file)) {
|
|
253
|
+
try {
|
|
254
|
+
proc.kill("SIGKILL");
|
|
255
|
+
} catch {}
|
|
256
|
+
reject(new Error(`${recorder.label}: did not create an audio file`));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
resolve({ proc, file, recorder, stderr: () => stderr });
|
|
261
|
+
}, 650);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export class VoiceRuntime {
|
|
266
|
+
constructor(options = {}) {
|
|
267
|
+
this.options = options;
|
|
268
|
+
this.recording = null;
|
|
269
|
+
this.recordingError = "";
|
|
270
|
+
this.transcription = null;
|
|
271
|
+
this.pendingSubmit = false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
isRecording() {
|
|
275
|
+
return Boolean(this.recording);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
isTranscribing() {
|
|
279
|
+
return Boolean(this.transcription);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async start(settings = {}) {
|
|
283
|
+
if (this.recording) return this.recording.file;
|
|
284
|
+
|
|
285
|
+
const dir = getAudioDir(this.options, settings);
|
|
286
|
+
await ensureDir(dir);
|
|
287
|
+
|
|
288
|
+
const file = path.join(dir, `voice-${Date.now()}.wav`);
|
|
289
|
+
const recorders = buildRecorders(file, settings);
|
|
290
|
+
const errors = [];
|
|
291
|
+
this.recordingError = "";
|
|
292
|
+
|
|
293
|
+
for (const recorder of recorders) {
|
|
294
|
+
await fs.promises.unlink(file).catch(() => {});
|
|
295
|
+
try {
|
|
296
|
+
const active = await startRecorder(recorder, file);
|
|
297
|
+
active.proc.on("exit", () => {
|
|
298
|
+
if (this.recording?.proc === active.proc) this.recording = null;
|
|
299
|
+
});
|
|
300
|
+
this.recording = active;
|
|
301
|
+
return file;
|
|
302
|
+
} catch (error) {
|
|
303
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
throw new Error(`Could not start recorder. Tried: ${errors.join("; ")}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async stop() {
|
|
311
|
+
const active = this.recording;
|
|
312
|
+
if (!active) return null;
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
active.proc.kill("SIGINT");
|
|
316
|
+
} catch {}
|
|
317
|
+
|
|
318
|
+
await waitForExit(active.proc);
|
|
319
|
+
this.recording = null;
|
|
320
|
+
|
|
321
|
+
if (!fs.existsSync(active.file)) {
|
|
322
|
+
const lastError = lastLine(active.stderr?.() || this.recordingError);
|
|
323
|
+
throw new Error(lastError || `${active.recorder?.label || "Recorder"} did not create an audio file`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (fs.statSync(active.file).size <= RECORDING_MIN_BYTES) {
|
|
327
|
+
throw new Error("Recording is empty. Check your microphone input.");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return active.file;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async cancel() {
|
|
334
|
+
if (this.recording) {
|
|
335
|
+
try {
|
|
336
|
+
this.recording.proc.kill("SIGKILL");
|
|
337
|
+
} catch {}
|
|
338
|
+
this.recording = null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (this.transcription) {
|
|
342
|
+
try {
|
|
343
|
+
this.transcription.kill("SIGKILL");
|
|
344
|
+
} catch {}
|
|
345
|
+
this.transcription = null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async transcribe(audioFile, model, settings = {}) {
|
|
350
|
+
const commandOptions = { ...this.options, downloadDir: settings.downloadDir };
|
|
351
|
+
const whisperCli = resolveCommand("whisper-cli", commandOptions);
|
|
352
|
+
if (!whisperCli) {
|
|
353
|
+
throw new Error("whisper-cli not found. Install whisper.cpp or set OPENCODE_VOICE_WHISPER_CLI to the binary path.");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const modelFile = getModelPath(model, this.options, settings);
|
|
357
|
+
if (!fs.existsSync(modelFile)) {
|
|
358
|
+
throw new Error(`Model is not downloaded: ${model.name}`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const args = ["-m", modelFile, "-f", audioFile, "-np", "-nt"];
|
|
362
|
+
if (settings.language && settings.language !== "auto") args.push("-l", settings.language);
|
|
363
|
+
|
|
364
|
+
return new Promise((resolve, reject) => {
|
|
365
|
+
let stdout = "";
|
|
366
|
+
let stderr = "";
|
|
367
|
+
const proc = spawn(whisperCli, args, { env: childEnv(commandOptions), stdio: ["ignore", "pipe", "pipe"] });
|
|
368
|
+
this.transcription = proc;
|
|
369
|
+
|
|
370
|
+
const timer = setTimeout(() => {
|
|
371
|
+
try {
|
|
372
|
+
proc.kill("SIGKILL");
|
|
373
|
+
} catch {}
|
|
374
|
+
this.transcription = null;
|
|
375
|
+
reject(new Error("Transcription timed out"));
|
|
376
|
+
}, this.options.transcriptionTimeoutMs || 120000);
|
|
377
|
+
|
|
378
|
+
proc.stdout.on("data", (chunk) => {
|
|
379
|
+
stdout += chunk.toString();
|
|
380
|
+
});
|
|
381
|
+
proc.stderr.on("data", (chunk) => {
|
|
382
|
+
stderr += chunk.toString();
|
|
383
|
+
});
|
|
384
|
+
proc.on("error", (error) => {
|
|
385
|
+
clearTimeout(timer);
|
|
386
|
+
this.transcription = null;
|
|
387
|
+
reject(error);
|
|
388
|
+
});
|
|
389
|
+
proc.on("exit", (code) => {
|
|
390
|
+
clearTimeout(timer);
|
|
391
|
+
this.transcription = null;
|
|
392
|
+
if (code !== 0) {
|
|
393
|
+
reject(new Error(stderr.trim().split("\n").pop() || `whisper-cli exited with code ${code}`));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const text = cleanTranscription(stdout);
|
|
398
|
+
if (!text) {
|
|
399
|
+
reject(new Error("Transcription returned empty text"));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
resolve(text);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|