@maheidem/pi-audio-transcribe 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/LICENSE +32 -0
- package/README.md +132 -0
- package/config.ts +174 -0
- package/index.ts +322 -0
- package/lib/json-store.ts +92 -0
- package/omlx.ts +295 -0
- package/package.json +69 -0
- package/paths.ts +128 -0
- package/pipeline.ts +252 -0
- package/settings.ts +130 -0
- package/sidecar.ts +123 -0
- package/ui/audio-panel.ts +104 -0
- package/ui/settings-panel.ts +393 -0
- package/validate.ts +388 -0
package/pipeline.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcription pipeline — deliberately free of any pi import.
|
|
3
|
+
*
|
|
4
|
+
* Keeping pi out of this module means the whole flow (pre-flight, ASR,
|
|
5
|
+
* cross-check, validation, sidecar) is unit-testable without a running pi,
|
|
6
|
+
* which is what makes the fixture-server tests possible. index.ts is the
|
|
7
|
+
* thin adapter that binds this to pi's tool/command/input surfaces.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { basename, relative } from "node:path";
|
|
11
|
+
import { readdir, stat } from "node:fs/promises";
|
|
12
|
+
import type { Dirent } from "node:fs";
|
|
13
|
+
|
|
14
|
+
import { resolveConfig, type TranscribeConfig } from "./config.ts";
|
|
15
|
+
import {
|
|
16
|
+
isAudioPath,
|
|
17
|
+
listSttModels,
|
|
18
|
+
mimeFor,
|
|
19
|
+
pickSttModel,
|
|
20
|
+
transcribe,
|
|
21
|
+
normalizeLanguage,
|
|
22
|
+
type AsrResponse,
|
|
23
|
+
type SttModel,
|
|
24
|
+
} from "./omlx.ts";
|
|
25
|
+
import { expandPath } from "./paths.ts";
|
|
26
|
+
import { exists, probeAudio, sha256File, sidecarPathFor, writeJsonAtomic, SIDECAR_SCHEMA, type SidecarDocument } from "./sidecar.ts";
|
|
27
|
+
import { countWords, extractMeasurements, similarity, validateSpeech, type CrossCheckResult, type ValidationReport } from "./validate.ts";
|
|
28
|
+
|
|
29
|
+
export type Exec = (cmd: string, args: string[], opts?: { signal?: AbortSignal }) => Promise<{ stdout: string; code: number; stderr?: string }>;
|
|
30
|
+
|
|
31
|
+
/** Everything the pipeline needs from its host; pi supplies this via index.ts. */
|
|
32
|
+
export interface PipelineCtx {
|
|
33
|
+
cwd: string;
|
|
34
|
+
exec: Exec;
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
onProgress?: (msg: string) => void;
|
|
37
|
+
/** Wrap the write so it serialises against pi's built-in edit/write. */
|
|
38
|
+
withQueue?: <T>(path: string, fn: () => Promise<T>) => Promise<T>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface FileResult {
|
|
42
|
+
input: string;
|
|
43
|
+
audioPath: string;
|
|
44
|
+
sidecarPath: string | null;
|
|
45
|
+
skipped: boolean;
|
|
46
|
+
skipReason?: string;
|
|
47
|
+
error?: string;
|
|
48
|
+
verdict?: ValidationReport["verdict"];
|
|
49
|
+
confidence?: number;
|
|
50
|
+
language?: string | null;
|
|
51
|
+
durationSeconds?: number | null;
|
|
52
|
+
words?: number;
|
|
53
|
+
text?: string;
|
|
54
|
+
recommendations?: string[];
|
|
55
|
+
crossCheckSimilarity?: number | null;
|
|
56
|
+
measurements?: Array<{ raw: string; value: number; unit: string }>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Setup {
|
|
60
|
+
cfg: TranscribeConfig;
|
|
61
|
+
model: string | null;
|
|
62
|
+
modelInfo: SttModel | null;
|
|
63
|
+
note: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Resolve the STT model: a pinned config id wins, else auto-discover. */
|
|
67
|
+
export async function resolveSttModel(cfg: TranscribeConfig, signal?: AbortSignal): Promise<Setup> {
|
|
68
|
+
if (cfg.model) return { cfg, model: cfg.model, modelInfo: null, note: `pinned via config: ${cfg.model}` };
|
|
69
|
+
const models = await listSttModels(cfg.baseUrl, cfg.apiKey, 15_000, signal);
|
|
70
|
+
const picked = pickSttModel(models);
|
|
71
|
+
if (!picked) return { cfg, model: null, modelInfo: null, note: "no audio_stt model exposed by the server" };
|
|
72
|
+
const est = picked.estimatedSize ? (picked.estimatedSize / 1e9).toFixed(1) : "?";
|
|
73
|
+
return {
|
|
74
|
+
cfg,
|
|
75
|
+
model: picked.id,
|
|
76
|
+
modelInfo: picked,
|
|
77
|
+
note: picked.loaded ? `${picked.id} (already resident)` : `${picked.id} (cold load, ~${est} GB)`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Transcribe one file and persist its sidecar. Per-file failures return a result; they do not throw. */
|
|
82
|
+
export async function transcribeOne(audioPath: string, setup: Setup, ctx: PipelineCtx): Promise<FileResult> {
|
|
83
|
+
const cfg = setup.cfg;
|
|
84
|
+
const abs = expandPath(audioPath, ctx.cwd);
|
|
85
|
+
const sidecarPath = sidecarPathFor(abs);
|
|
86
|
+
const base: FileResult = { input: audioPath, audioPath: abs, sidecarPath: null, skipped: false };
|
|
87
|
+
const progress = (m: string) => ctx.onProgress?.(m);
|
|
88
|
+
|
|
89
|
+
// Pre-flight. The server answers HTTP 500 for bad input, so every cheap
|
|
90
|
+
// rejection happens here where the message can be actionable.
|
|
91
|
+
const st = await stat(abs).catch(() => null);
|
|
92
|
+
if (!st) return { ...base, skipped: true, skipReason: "file not found" };
|
|
93
|
+
if (st.isDirectory()) return { ...base, skipped: true, skipReason: "is a directory" };
|
|
94
|
+
if (!isAudioPath(abs)) return { ...base, skipped: true, skipReason: `unrecognized audio extension ".${(basename(abs).split(".").pop() ?? "")}"` };
|
|
95
|
+
if (!st.size) return { ...base, skipped: true, skipReason: "file is empty (0 bytes)" };
|
|
96
|
+
if (cfg.maxBytes > 0 && st.size > cfg.maxBytes) return { ...base, skipped: true, skipReason: `${st.size} bytes exceeds maxBytes ${cfg.maxBytes}` };
|
|
97
|
+
if (!setup.model) return { ...base, skipped: true, skipReason: "no STT model available on the server" };
|
|
98
|
+
if (ctx.signal?.aborted) return { ...base, skipped: true, skipReason: "cancelled" };
|
|
99
|
+
if (!cfg.force && (await exists(sidecarPath))) return { ...base, skipped: true, skipReason: "sidecar exists (pass force to overwrite)", sidecarPath };
|
|
100
|
+
|
|
101
|
+
const requestedLanguage = normalizeLanguage(cfg.language);
|
|
102
|
+
progress(`transcribing ${basename(abs)} via ${setup.model}`);
|
|
103
|
+
|
|
104
|
+
// ffprobe = ground truth for duration; unavailable is tolerated.
|
|
105
|
+
const probe = await probeAudio(abs, ctx.exec, ctx.signal);
|
|
106
|
+
|
|
107
|
+
let primary: AsrResponse;
|
|
108
|
+
try {
|
|
109
|
+
primary = await transcribe({ baseUrl: cfg.baseUrl, model: setup.model, filePath: abs, apiKey: cfg.apiKey, language: requestedLanguage, timeoutMs: cfg.timeoutMs, signal: ctx.signal });
|
|
110
|
+
} catch (err) {
|
|
111
|
+
return { ...base, error: err instanceof Error ? err.message : String(err) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Cross-check pass: re-run with the language the server itself reported.
|
|
115
|
+
// On real speech this reproduces the primary byte-for-byte, so any
|
|
116
|
+
// divergence is a genuine instability signal.
|
|
117
|
+
let cross: CrossCheckResult = { performed: false, skippedReason: "disabled" };
|
|
118
|
+
const autoLang = normalizeLanguage(primary.language);
|
|
119
|
+
if (cfg.crossCheck && primary.text.trim() && !ctx.signal?.aborted) {
|
|
120
|
+
const hint = requestedLanguage ?? autoLang;
|
|
121
|
+
progress(`cross-check pass (${hint ?? "auto"})`);
|
|
122
|
+
try {
|
|
123
|
+
const second = await transcribe({ baseUrl: cfg.baseUrl, model: setup.model, filePath: abs, apiKey: cfg.apiKey, language: hint, timeoutMs: cfg.timeoutMs, signal: ctx.signal });
|
|
124
|
+
const sim = similarity(primary.text, second.text);
|
|
125
|
+
cross = { performed: true, languageUsed: hint, similarity: sim, textChanged: sim < 1, altText: sim < 1 ? second.text : undefined, latencySeconds: second.duration };
|
|
126
|
+
} catch (err) {
|
|
127
|
+
cross = { performed: false, skippedReason: err instanceof Error ? err.message : String(err) };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const report = validateSpeech({ response: primary, requestedLanguage, probe, crossCheck: cross });
|
|
132
|
+
const measurements = extractMeasurements(primary.text);
|
|
133
|
+
|
|
134
|
+
const doc: SidecarDocument = {
|
|
135
|
+
schema: SIDECAR_SCHEMA,
|
|
136
|
+
createdAt: new Date().toISOString(),
|
|
137
|
+
audio: {
|
|
138
|
+
path: abs,
|
|
139
|
+
name: basename(abs),
|
|
140
|
+
bytes: st.size,
|
|
141
|
+
sha256: await sha256File(abs),
|
|
142
|
+
mime: mimeFor(abs),
|
|
143
|
+
durationSeconds: probe.durationSeconds ?? null,
|
|
144
|
+
sampleRateHz: probe.sampleRateHz ?? null,
|
|
145
|
+
channels: probe.channels ?? null,
|
|
146
|
+
codec: probe.codec ?? null,
|
|
147
|
+
},
|
|
148
|
+
transcript: {
|
|
149
|
+
text: primary.text,
|
|
150
|
+
words: countWords(primary.text),
|
|
151
|
+
characters: primary.text.length,
|
|
152
|
+
language: report.language.normalized,
|
|
153
|
+
},
|
|
154
|
+
validation: {
|
|
155
|
+
verdict: report.verdict,
|
|
156
|
+
confidence: report.confidence,
|
|
157
|
+
checks: report.checks,
|
|
158
|
+
recommendations: report.recommendations,
|
|
159
|
+
language: report.language,
|
|
160
|
+
timings: report.timings,
|
|
161
|
+
crossCheck: report.crossCheck,
|
|
162
|
+
...(measurements.length ? { measurements } : {}),
|
|
163
|
+
},
|
|
164
|
+
provider: {
|
|
165
|
+
name: "oMLX",
|
|
166
|
+
baseUrl: cfg.baseUrl,
|
|
167
|
+
model: setup.model,
|
|
168
|
+
engine: setup.modelInfo?.engineType ?? null,
|
|
169
|
+
languageHintSent: requestedLanguage,
|
|
170
|
+
crossChecked: cross.performed,
|
|
171
|
+
note: "response.duration is inference latency, not audio length; audio duration comes from ffprobe/segments[].end",
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const write = async () => writeJsonAtomic(sidecarPath, doc);
|
|
176
|
+
try {
|
|
177
|
+
if (ctx.withQueue) await ctx.withQueue(sidecarPath, write);
|
|
178
|
+
else await write();
|
|
179
|
+
} catch (err) {
|
|
180
|
+
return { ...base, error: `transcribed but failed to write sidecar: ${err instanceof Error ? err.message : String(err)}`, text: primary.text, verdict: report.verdict, confidence: report.confidence };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
input: audioPath,
|
|
185
|
+
audioPath: abs,
|
|
186
|
+
sidecarPath,
|
|
187
|
+
skipped: false,
|
|
188
|
+
verdict: report.verdict,
|
|
189
|
+
confidence: report.confidence,
|
|
190
|
+
language: report.language.normalized,
|
|
191
|
+
durationSeconds: probe.durationSeconds ?? report.timings.audioSeconds,
|
|
192
|
+
words: doc.transcript.words,
|
|
193
|
+
text: primary.text,
|
|
194
|
+
recommendations: report.recommendations,
|
|
195
|
+
crossCheckSimilarity: cross.similarity ?? null,
|
|
196
|
+
measurements,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Expand files/directories into a flat, de-duplicated audio path list. */
|
|
201
|
+
export async function expandInputs(inputs: string[], cwd: string, recursive: boolean): Promise<string[]> {
|
|
202
|
+
const out: string[] = [];
|
|
203
|
+
for (const raw of inputs) {
|
|
204
|
+
const abs = expandPath(raw, cwd);
|
|
205
|
+
const st = await stat(abs).catch(() => null);
|
|
206
|
+
if (!st) continue;
|
|
207
|
+
const found = st.isDirectory() ? await walk(abs, recursive) : isAudioPath(abs) ? [abs] : [];
|
|
208
|
+
for (const f of found) if (!out.includes(f)) out.push(f);
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function walk(dir: string, recursive: boolean): Promise<string[]> {
|
|
214
|
+
const found: string[] = [];
|
|
215
|
+
let entries: Dirent[];
|
|
216
|
+
try {
|
|
217
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
218
|
+
} catch {
|
|
219
|
+
return found;
|
|
220
|
+
}
|
|
221
|
+
for (const e of entries) {
|
|
222
|
+
const full = `${dir.replace(/\/+$/, "")}/${e.name}`;
|
|
223
|
+
if (e.isDirectory()) {
|
|
224
|
+
if (recursive && ![".git", "node_modules", ".Trash"].includes(e.name)) found.push(...(await walk(full, recursive)));
|
|
225
|
+
} else if (isAudioPath(full)) found.push(full);
|
|
226
|
+
}
|
|
227
|
+
return found;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Human-readable multi-file summary. */
|
|
231
|
+
export function formatSummary(results: FileResult[], setup: Setup, cwd: string): string {
|
|
232
|
+
const done = results.filter((r) => !r.skipped && !r.error);
|
|
233
|
+
const skipped = results.filter((r) => r.skipped);
|
|
234
|
+
const failed = results.filter((r) => r.error);
|
|
235
|
+
const rel = (p: string): string => {
|
|
236
|
+
if (!p) return "";
|
|
237
|
+
const r = relative(cwd, p);
|
|
238
|
+
return r && !r.startsWith("..") ? r : p;
|
|
239
|
+
};
|
|
240
|
+
const lines = [`oMLX STT · model ${setup.model ?? "unavailable"} · ${done.length} transcribed, ${skipped.length} skipped, ${failed.length} failed`];
|
|
241
|
+
for (const r of done) {
|
|
242
|
+
const dur = r.durationSeconds ? `${r.durationSeconds.toFixed(1)}s` : "?";
|
|
243
|
+
const sim = r.crossCheckSimilarity != null ? ` · xcheck ${(r.crossCheckSimilarity * 100).toFixed(0)}%` : "";
|
|
244
|
+
lines.push(` ${String(r.verdict ?? "?").toUpperCase()} (${((r.confidence ?? 0) * 100).toFixed(0)}%) ${r.language ?? "?"} ${dur}${sim} — ${rel(r.audioPath)}`);
|
|
245
|
+
lines.push(` → ${rel(r.sidecarPath ?? "")}`);
|
|
246
|
+
}
|
|
247
|
+
for (const r of failed) lines.push(` FAILED ${rel(r.audioPath)}: ${r.error}`);
|
|
248
|
+
for (const r of skipped) lines.push(` skipped ${rel(r.audioPath)}: ${r.skipReason}`);
|
|
249
|
+
const recs = [...new Set(done.flatMap((r) => r.recommendations ?? []))];
|
|
250
|
+
if (recs.length) lines.push(` advice: ${recs.join(" | ")}`);
|
|
251
|
+
return lines.join("\n");
|
|
252
|
+
}
|
package/settings.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONFIG_PATH,
|
|
3
|
+
environmentOverrideKeys,
|
|
4
|
+
loadStoredConfig,
|
|
5
|
+
normalizeBaseUrl,
|
|
6
|
+
resetConfig,
|
|
7
|
+
resolveConfig,
|
|
8
|
+
saveConfig,
|
|
9
|
+
type TranscribeConfig,
|
|
10
|
+
} from "./config.ts";
|
|
11
|
+
|
|
12
|
+
export type AudioSettingKey =
|
|
13
|
+
| "autoDetect"
|
|
14
|
+
| "crossCheck"
|
|
15
|
+
| "force"
|
|
16
|
+
| "language"
|
|
17
|
+
| "model"
|
|
18
|
+
| "baseUrl"
|
|
19
|
+
| "timeoutMs"
|
|
20
|
+
| "maxBytes";
|
|
21
|
+
|
|
22
|
+
function booleanValue(raw: string): boolean | null {
|
|
23
|
+
const normalized = raw.trim().toLowerCase();
|
|
24
|
+
if (["true", "on", "yes", "1"].includes(normalized)) return true;
|
|
25
|
+
if (["false", "off", "no", "0"].includes(normalized)) return false;
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatBytes(bytes: number): string {
|
|
30
|
+
if (bytes === 0) return "unlimited";
|
|
31
|
+
if (bytes >= 1024 ** 3) return `${Number((bytes / 1024 ** 3).toFixed(1))} GB`;
|
|
32
|
+
if (bytes >= 1024 ** 2) return `${Number((bytes / 1024 ** 2).toFixed(1))} MB`;
|
|
33
|
+
if (bytes >= 1024) return `${Number((bytes / 1024).toFixed(1))} KB`;
|
|
34
|
+
return `${bytes} B`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseBytes(raw: string): number | null {
|
|
38
|
+
const normalized = raw.trim().toLowerCase();
|
|
39
|
+
if (["0", "none", "unlimited", "off"].includes(normalized)) return 0;
|
|
40
|
+
const match = /^(\d+(?:\.\d+)?)\s*(b|kb|kib|mb|mib|gb|gib)?$/.exec(normalized);
|
|
41
|
+
if (!match) return null;
|
|
42
|
+
const amount = Number(match[1]);
|
|
43
|
+
const unit = match[2] ?? "b";
|
|
44
|
+
const power = unit.startsWith("k") ? 1 : unit.startsWith("m") ? 2 : unit.startsWith("g") ? 3 : 0;
|
|
45
|
+
const bytes = Math.round(amount * 1024 ** power);
|
|
46
|
+
return Number.isSafeInteger(bytes) ? bytes : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function formatTimeout(milliseconds: number): string {
|
|
50
|
+
if (milliseconds % 60_000 === 0) return `${milliseconds / 60_000}m`;
|
|
51
|
+
if (milliseconds % 1_000 === 0) return `${milliseconds / 1_000}s`;
|
|
52
|
+
return `${milliseconds}ms`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseTimeout(raw: string): number | null {
|
|
56
|
+
const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m)?\s*$/i.exec(raw);
|
|
57
|
+
if (!match) return null;
|
|
58
|
+
const amount = Number(match[1]);
|
|
59
|
+
const unit = (match[2] ?? "s").toLowerCase();
|
|
60
|
+
const milliseconds = Math.round(amount * (unit === "m" ? 60_000 : unit === "s" ? 1_000 : 1));
|
|
61
|
+
return Number.isSafeInteger(milliseconds) ? milliseconds : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Canonical persistent actions shared by the panel and future script syntax. */
|
|
65
|
+
export class AudioSettingsController {
|
|
66
|
+
private storedValue: TranscribeConfig;
|
|
67
|
+
|
|
68
|
+
constructor() {
|
|
69
|
+
this.storedValue = loadStoredConfig();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
stored(): TranscribeConfig {
|
|
73
|
+
return { ...this.storedValue };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
effective(): TranscribeConfig {
|
|
77
|
+
return resolveConfig();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
overrides(): string[] {
|
|
81
|
+
return environmentOverrideKeys();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
apply(key: AudioSettingKey, raw: string): string | null {
|
|
85
|
+
const next = { ...this.storedValue };
|
|
86
|
+
if (key === "autoDetect" || key === "crossCheck" || key === "force") {
|
|
87
|
+
const value = booleanValue(raw);
|
|
88
|
+
if (value === null) return `${key} accepts on or off`;
|
|
89
|
+
next[key] = value;
|
|
90
|
+
} else if (key === "language") {
|
|
91
|
+
const value = raw.trim();
|
|
92
|
+
if (!value || value.toLowerCase() === "auto") next.language = null;
|
|
93
|
+
else if (!/^[A-Za-z]{2,16}(?:-[A-Za-z0-9]{2,16})*$/.test(value)) return "Language must be auto or an ISO-style tag such as pt or en-US";
|
|
94
|
+
else next.language = value;
|
|
95
|
+
} else if (key === "model") {
|
|
96
|
+
const value = raw.trim();
|
|
97
|
+
if (value.length > 200) return "Model id must be 200 characters or fewer";
|
|
98
|
+
next.model = !value || value.toLowerCase() === "auto" ? null : value;
|
|
99
|
+
} else if (key === "baseUrl") {
|
|
100
|
+
const value = normalizeBaseUrl(raw);
|
|
101
|
+
try {
|
|
102
|
+
const parsed = new URL(value);
|
|
103
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) throw new Error("invalid");
|
|
104
|
+
} catch {
|
|
105
|
+
return "Server must be an HTTP(S) host or URL";
|
|
106
|
+
}
|
|
107
|
+
next.baseUrl = value;
|
|
108
|
+
} else if (key === "timeoutMs") {
|
|
109
|
+
const value = parseTimeout(raw);
|
|
110
|
+
if (value === null || value < 1_000 || value > 30 * 60_000) return "Timeout must be between 1s and 30m";
|
|
111
|
+
next.timeoutMs = value;
|
|
112
|
+
} else if (key === "maxBytes") {
|
|
113
|
+
const value = parseBytes(raw);
|
|
114
|
+
if (value === null || value > 100 * 1024 ** 3) return "Max file size must be unlimited or at most 100 GB";
|
|
115
|
+
next.maxBytes = value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this.storedValue = saveConfig(next);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
reset(): TranscribeConfig {
|
|
123
|
+
this.storedValue = resetConfig();
|
|
124
|
+
return this.stored();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
configPath(): string {
|
|
128
|
+
return CONFIG_PATH;
|
|
129
|
+
}
|
|
130
|
+
}
|
package/sidecar.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidecar persistence + audio probing.
|
|
3
|
+
*
|
|
4
|
+
* The sidecar is a JSON document written next to the audio file:
|
|
5
|
+
* Medidas da gaveta.m4a -> Medidas da gaveta.transcript.json
|
|
6
|
+
*
|
|
7
|
+
* Writes are atomic (temp file + rename) so a cancelled or crashed run can
|
|
8
|
+
* never leave a half-written transcript that looks authoritative.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { open, rename, stat, unlink } from "node:fs/promises";
|
|
13
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
14
|
+
import type { ProbeInfo } from "./validate.ts";
|
|
15
|
+
|
|
16
|
+
export const SIDECAR_SCHEMA = "pi-audio-transcript/v1";
|
|
17
|
+
|
|
18
|
+
export interface SidecarDocument {
|
|
19
|
+
schema: typeof SIDECAR_SCHEMA;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
audio: {
|
|
22
|
+
path: string;
|
|
23
|
+
name: string;
|
|
24
|
+
bytes: number;
|
|
25
|
+
sha256: string;
|
|
26
|
+
mime: string;
|
|
27
|
+
durationSeconds: number | null;
|
|
28
|
+
sampleRateHz: number | null;
|
|
29
|
+
channels: number | null;
|
|
30
|
+
codec: string | null;
|
|
31
|
+
};
|
|
32
|
+
transcript: {
|
|
33
|
+
text: string;
|
|
34
|
+
words: number;
|
|
35
|
+
characters: number;
|
|
36
|
+
language: string | null;
|
|
37
|
+
};
|
|
38
|
+
validation: Record<string, unknown>;
|
|
39
|
+
provider: Record<string, unknown>;
|
|
40
|
+
raw?: Record<string, unknown>;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `foo.m4a` -> `foo.transcript.json` (dotfiles and extension-less names handled). */
|
|
45
|
+
export function sidecarPathFor(audioPath: string): string {
|
|
46
|
+
const ext = extname(audioPath);
|
|
47
|
+
if (!ext || audioPath.startsWith(".") || ext.length > 6) return `${audioPath}.transcript.json`;
|
|
48
|
+
return `${audioPath.slice(0, audioPath.length - ext.length)}.transcript.json`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function sha256File(path: string): Promise<string> {
|
|
52
|
+
const hash = createHash("sha256");
|
|
53
|
+
const fh = await open(path, "r");
|
|
54
|
+
try {
|
|
55
|
+
const buf = Buffer.alloc(1024 * 1024);
|
|
56
|
+
for (;;) {
|
|
57
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, null);
|
|
58
|
+
if (!bytesRead) break;
|
|
59
|
+
hash.update(buf.subarray(0, bytesRead));
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
await fh.close();
|
|
63
|
+
}
|
|
64
|
+
return hash.digest("hex");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Atomic write: same-directory temp file, fsync-ish, then rename over target. */
|
|
68
|
+
export async function writeJsonAtomic(path: string, doc: unknown): Promise<void> {
|
|
69
|
+
const tmp = join(dirname(path), `.${basename(path)}.${process.pid}.${Date.now()}.tmp`);
|
|
70
|
+
const fh = await open(tmp, "w");
|
|
71
|
+
try {
|
|
72
|
+
await fh.writeFile(`${JSON.stringify(doc, null, 2)}\n`, "utf8");
|
|
73
|
+
await fh.sync?.();
|
|
74
|
+
} catch (err) {
|
|
75
|
+
await fh.close().catch(() => {});
|
|
76
|
+
await unlink(tmp).catch(() => {});
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
await fh.close();
|
|
80
|
+
await rename(tmp, path);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function exists(path: string): Promise<boolean> {
|
|
84
|
+
return stat(path).then(() => true, () => false);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read real duration / sample rate / codec with ffprobe.
|
|
89
|
+
* Returns available:false (never throws) when ffprobe is missing, so the
|
|
90
|
+
* extension still works on machines without ffmpeg.
|
|
91
|
+
*/
|
|
92
|
+
export async function probeAudio(
|
|
93
|
+
path: string,
|
|
94
|
+
exec: (cmd: string, args: string[], opts?: { signal?: AbortSignal }) => Promise<{ stdout: string; code: number; stderr?: string }>,
|
|
95
|
+
signal?: AbortSignal,
|
|
96
|
+
): Promise<ProbeInfo> {
|
|
97
|
+
const args = ["-v", "error", "-show_entries", "format=duration,size:stream=codec_name,sample_rate,channels", "-of", "json", path];
|
|
98
|
+
let res: { stdout: string; code: number; stderr?: string };
|
|
99
|
+
try {
|
|
100
|
+
res = await exec("ffprobe", args, { signal });
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return { available: false, error: `ffprobe could not run: ${err instanceof Error ? err.message : String(err)}` };
|
|
103
|
+
}
|
|
104
|
+
if (res.code !== 0 || !res.stdout?.trim()) {
|
|
105
|
+
return { available: false, error: `ffprobe exit ${res.code}${res.stderr?.trim() ? `: ${res.stderr.trim().slice(0, 160)}` : ""}` };
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const j = JSON.parse(res.stdout) as {
|
|
109
|
+
format?: { duration?: string; size?: string };
|
|
110
|
+
streams?: Array<{ codec_type?: string; codec_name?: string; sample_rate?: string; channels?: number }>;
|
|
111
|
+
};
|
|
112
|
+
const audio = (j.streams ?? []).find((s) => s.codec_type === "audio") ?? (j.streams ?? [])[0];
|
|
113
|
+
return {
|
|
114
|
+
available: true,
|
|
115
|
+
durationSeconds: j.format?.duration != null ? Number(j.format.duration) : null,
|
|
116
|
+
sampleRateHz: audio?.sample_rate != null ? Number(audio.sample_rate) : null,
|
|
117
|
+
channels: audio?.channels ?? null,
|
|
118
|
+
codec: audio?.codec_name ?? null,
|
|
119
|
+
};
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return { available: false, error: `ffprobe output was unparseable: ${err instanceof Error ? err.message : String(err)}` };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { sep } from "node:path";
|
|
3
|
+
import type { TranscribeConfig } from "../config.ts";
|
|
4
|
+
import { AudioSettingsController, formatBytes, formatTimeout } from "../settings.ts";
|
|
5
|
+
import type { PanelSnapshot, PanelValueStyle } from "./settings-panel.ts";
|
|
6
|
+
|
|
7
|
+
function compactHomePath(path: string): string {
|
|
8
|
+
const home = homedir().replace(/[\\/]+$/, "");
|
|
9
|
+
return path.startsWith(`${home}${sep}`) ? `~${path.slice(home.length)}` : path;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function compactOverrides(overrides: string[]): string {
|
|
13
|
+
return overrides.length <= 3
|
|
14
|
+
? overrides.join(", ")
|
|
15
|
+
: `${overrides.slice(0, 3).join(", ")} +${overrides.length - 3}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function apiKeySource(
|
|
19
|
+
stored: TranscribeConfig,
|
|
20
|
+
effective: TranscribeConfig,
|
|
21
|
+
overrides: string[],
|
|
22
|
+
): string {
|
|
23
|
+
if (!effective.apiKey) return "not configured";
|
|
24
|
+
if (overrides.includes("API key")) return stored.apiKey ? "env (file also set)" : "environment";
|
|
25
|
+
return "config file";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function displayValue(
|
|
29
|
+
stored: string,
|
|
30
|
+
effective: string,
|
|
31
|
+
style: PanelValueStyle = "text",
|
|
32
|
+
): { value: string; valueStyle: PanelValueStyle } {
|
|
33
|
+
return stored === effective
|
|
34
|
+
? { value: effective, valueStyle: style }
|
|
35
|
+
: { value: `${effective} (env; file: ${stored})`, valueStyle: "warning" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildAudioPanelSnapshot(controller: AudioSettingsController): PanelSnapshot {
|
|
39
|
+
const stored = controller.stored();
|
|
40
|
+
const effective = controller.effective();
|
|
41
|
+
const overrides = controller.overrides();
|
|
42
|
+
const language = displayValue(stored.language ?? "auto", effective.language ?? "auto", effective.language ? "text" : "muted");
|
|
43
|
+
const model = displayValue(stored.model ?? "auto", effective.model ?? "auto", effective.model ? "text" : "muted");
|
|
44
|
+
const server = displayValue(stored.baseUrl, effective.baseUrl, "accent");
|
|
45
|
+
const timeout = displayValue(formatTimeout(stored.timeoutMs), formatTimeout(effective.timeoutMs));
|
|
46
|
+
const maxSize = displayValue(formatBytes(stored.maxBytes), formatBytes(effective.maxBytes));
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
title: "Audio Transcribe",
|
|
50
|
+
summaryLines: [
|
|
51
|
+
`oMLX speech-to-text · model: ${effective.model ?? "auto"}`,
|
|
52
|
+
],
|
|
53
|
+
sections: [
|
|
54
|
+
{
|
|
55
|
+
title: "Behavior",
|
|
56
|
+
rows: [
|
|
57
|
+
{ key: "autoDetect", label: "Input path hook", value: effective.autoDetect ? "enabled" : "disabled", rawValue: String(stored.autoDetect), valueStyle: effective.autoDetect ? "success" : "muted", kind: "toggle" },
|
|
58
|
+
{ key: "crossCheck", label: "Validation pass", value: effective.crossCheck ? "enabled" : "disabled", rawValue: String(stored.crossCheck), valueStyle: effective.crossCheck ? "success" : "warning", kind: "toggle" },
|
|
59
|
+
{ key: "force", label: "Overwrite sidecars", value: effective.force ? "enabled" : "disabled", rawValue: String(stored.force), valueStyle: effective.force ? "warning" : "muted", kind: "toggle" },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
title: "Recognition",
|
|
64
|
+
rows: [
|
|
65
|
+
{ key: "language", label: "Language hint", ...language, rawValue: stored.language ?? "auto", inputHint: "ISO tag such as pt, or auto · Esc cancels", kind: "input" },
|
|
66
|
+
{ key: "model", label: "Model", ...model, rawValue: stored.model ?? "auto", inputHint: "Model id, or auto · Esc cancels", kind: "input" },
|
|
67
|
+
{ key: "baseUrl", label: "oMLX server", ...server, rawValue: stored.baseUrl, inputHint: "HTTP(S) host or URL · /v1 is added automatically", kind: "input" },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
title: "Limits",
|
|
72
|
+
rows: [
|
|
73
|
+
{ key: "timeoutMs", label: "Request timeout", ...timeout, rawValue: formatTimeout(stored.timeoutMs), inputHint: "1s to 30m", kind: "input" },
|
|
74
|
+
{ key: "maxBytes", label: "Max file size", ...maxSize, rawValue: formatBytes(stored.maxBytes), inputHint: "Bytes, KB, MB, GB, or unlimited", kind: "input" },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
title: "Actions",
|
|
79
|
+
rows: [
|
|
80
|
+
{ key: "transcribe", label: "Transcribe a path", value: "open…", kind: "action" },
|
|
81
|
+
{ key: "status", label: "Server diagnostics", value: "run…", kind: "action" },
|
|
82
|
+
{ key: "reset", label: "Reset settings", value: "confirm…", valueStyle: "warning", kind: "action" },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
detailLines: [
|
|
87
|
+
...(overrides.length
|
|
88
|
+
? [`Env overrides: ${compactOverrides(overrides)} · file settings still save`]
|
|
89
|
+
: ["Atomic config · corrupt files are preserved for recovery"]),
|
|
90
|
+
`Config: ${compactHomePath(controller.configPath())} · API key: ${apiKeySource(stored, effective, overrides)}`,
|
|
91
|
+
],
|
|
92
|
+
idleMessage: "Changes save immediately",
|
|
93
|
+
shortcuts: [
|
|
94
|
+
{ key: "t", label: "transcribe", action: "transcribe" },
|
|
95
|
+
{ key: "h", label: "health", action: "status" },
|
|
96
|
+
{ key: "r", label: "reset", action: "reset" },
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Compare only user-visible config fields; API key remains intentionally hidden. */
|
|
102
|
+
export function panelConfigSummary(config: TranscribeConfig): string {
|
|
103
|
+
return `${config.autoDetect ? "hook on" : "hook off"}, ${config.crossCheck ? "cross-check on" : "cross-check off"}, ${config.language ?? "auto language"}`;
|
|
104
|
+
}
|