@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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** pi-extension-builder JsonStore v0.1.0 — canonical source and vendored primitive. */
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
|
|
12
|
+
export interface JsonStoreOptions<T extends object> {
|
|
13
|
+
path: string;
|
|
14
|
+
defaults: T;
|
|
15
|
+
normalize(value: unknown, defaults: T): T;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Small synchronous JSON repository for user preferences. */
|
|
19
|
+
export class JsonStore<T extends object> {
|
|
20
|
+
readonly path: string;
|
|
21
|
+
private readonly defaultsValue: T;
|
|
22
|
+
private readonly normalizeValue: JsonStoreOptions<T>["normalize"];
|
|
23
|
+
|
|
24
|
+
constructor(options: JsonStoreOptions<T>) {
|
|
25
|
+
this.path = options.path;
|
|
26
|
+
this.defaultsValue = this.clone(options.defaults);
|
|
27
|
+
this.normalizeValue = options.normalize;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
defaults(): T {
|
|
31
|
+
return this.clone(this.defaultsValue);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
load(): T {
|
|
35
|
+
if (!existsSync(this.path)) {
|
|
36
|
+
const value = this.defaults();
|
|
37
|
+
this.save(value);
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const raw = JSON.parse(readFileSync(this.path, "utf8")) as unknown;
|
|
43
|
+
const value = this.normalizeValue(raw, this.defaults());
|
|
44
|
+
if (JSON.stringify(raw) !== JSON.stringify(value)) this.save(value);
|
|
45
|
+
return this.clone(value);
|
|
46
|
+
} catch {
|
|
47
|
+
this.preserveCorruptFile();
|
|
48
|
+
const value = this.defaults();
|
|
49
|
+
this.save(value);
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
save(value: T): T {
|
|
55
|
+
const normalized = this.normalizeValue(value, this.defaults());
|
|
56
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
57
|
+
const temporary = `${this.path}.${process.pid}.${Date.now()}.tmp`;
|
|
58
|
+
try {
|
|
59
|
+
writeFileSync(temporary, `${JSON.stringify(normalized, null, 2)}\n`, {
|
|
60
|
+
encoding: "utf8",
|
|
61
|
+
mode: 0o600,
|
|
62
|
+
});
|
|
63
|
+
renameSync(temporary, this.path);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
try {
|
|
66
|
+
if (existsSync(temporary)) unlinkSync(temporary);
|
|
67
|
+
} catch {
|
|
68
|
+
// Best-effort cleanup; preserve the original error.
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
return this.clone(normalized);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
reset(): T {
|
|
76
|
+
return this.save(this.defaults());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private preserveCorruptFile(): void {
|
|
80
|
+
if (!existsSync(this.path)) return;
|
|
81
|
+
const stamp = new Date().toISOString().replace(/[^0-9TZ]/g, "");
|
|
82
|
+
try {
|
|
83
|
+
renameSync(this.path, `${this.path}.corrupt-${stamp}`);
|
|
84
|
+
} catch {
|
|
85
|
+
// If preservation fails, save() will atomically replace the bad file.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private clone(value: T): T {
|
|
90
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
91
|
+
}
|
|
92
|
+
}
|
package/omlx.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oMLX STT client.
|
|
3
|
+
*
|
|
4
|
+
* Verified against oMLX 0.6.4 / 192.168.31.152:8123:
|
|
5
|
+
* POST /v1/audio/transcriptions multipart(form-data: file, model [, language])
|
|
6
|
+
* -> 200 { text, language, duration, segments[] }
|
|
7
|
+
*
|
|
8
|
+
* Server behaviours that this module compensates for:
|
|
9
|
+
* - `duration` is INFERENCE LATENCY, not audio length. An 102.87 s clip
|
|
10
|
+
* reported duration=1.72. Real duration comes from segments[].end
|
|
11
|
+
* (reported 102.869, matching ffprobe 102.8677).
|
|
12
|
+
* - `language` case is inconsistent: "Portuguese" on auto-detect vs
|
|
13
|
+
* "portuguese" when the hint is sent. Always normalize.
|
|
14
|
+
* - `word_timestamps=true` and `response_format` are accepted but NOT
|
|
15
|
+
* implemented (docs say response_format is silently ignored; word
|
|
16
|
+
* timestamps verified unchanged on real audio). Do not rely on either.
|
|
17
|
+
* - Unsupported input returns HTTP 500 "unsupported file format" (not 4xx),
|
|
18
|
+
* so callers must pre-flight the file type.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface AsrSegment {
|
|
22
|
+
text: string;
|
|
23
|
+
language?: string | null;
|
|
24
|
+
start?: number;
|
|
25
|
+
end?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AsrResponse {
|
|
29
|
+
text: string;
|
|
30
|
+
language?: string | null;
|
|
31
|
+
/** INFERENCE LATENCY in seconds, not audio duration. */
|
|
32
|
+
duration?: number;
|
|
33
|
+
segments?: AsrSegment[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SttModel {
|
|
37
|
+
id: string;
|
|
38
|
+
loaded: boolean;
|
|
39
|
+
isLoading: boolean;
|
|
40
|
+
engineType?: string;
|
|
41
|
+
configModelType?: string;
|
|
42
|
+
estimatedSize?: number;
|
|
43
|
+
sourceType?: string;
|
|
44
|
+
realtimeStt?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ServerHealth {
|
|
48
|
+
ok: boolean;
|
|
49
|
+
version?: string;
|
|
50
|
+
loadedModels?: string[];
|
|
51
|
+
reachable: boolean;
|
|
52
|
+
error?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class OmnxError extends Error {
|
|
56
|
+
status?: number;
|
|
57
|
+
kind: "network" | "http" | "format" | "notfound" | "empty";
|
|
58
|
+
available?: string[];
|
|
59
|
+
constructor(message: string, kind: OmnxError["kind"], status?: number, available?: string[]) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = "OmnxError";
|
|
62
|
+
this.kind = kind;
|
|
63
|
+
this.status = status;
|
|
64
|
+
this.available = available;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Language names sometimes come back capitalized, sometimes lowercased. */
|
|
69
|
+
const LANG_NAMES: Record<string, string> = {
|
|
70
|
+
portuguese: "pt",
|
|
71
|
+
"pt-br": "pt",
|
|
72
|
+
"pt-pt": "pt",
|
|
73
|
+
brasil: "pt",
|
|
74
|
+
brazil: "pt",
|
|
75
|
+
english: "en",
|
|
76
|
+
spanish: "es",
|
|
77
|
+
french: "fr",
|
|
78
|
+
german: "de",
|
|
79
|
+
italian: "it",
|
|
80
|
+
dutch: "nl",
|
|
81
|
+
chinese: "zh",
|
|
82
|
+
japanese: "ja",
|
|
83
|
+
korean: "ko",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Normalize "Portuguese" / "portuguese" / "pt-BR" -> "pt". Returns null for None/unknown. */
|
|
87
|
+
export function normalizeLanguage(raw: unknown): string | null {
|
|
88
|
+
if (typeof raw !== "string") return null;
|
|
89
|
+
const v = raw.trim().toLowerCase();
|
|
90
|
+
if (!v || v === "none" || v === "null" || v === "unknown") return null;
|
|
91
|
+
if (LANG_NAMES[v]) return LANG_NAMES[v];
|
|
92
|
+
if (/^[a-z]{2}(-[a-z]{2})?$/i.test(v)) return v.split("-")[0];
|
|
93
|
+
return v;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function httpJson<T>(
|
|
97
|
+
url: string,
|
|
98
|
+
init: RequestInit,
|
|
99
|
+
timeoutMs: number,
|
|
100
|
+
signal?: AbortSignal,
|
|
101
|
+
): Promise<T> {
|
|
102
|
+
const ctrl = new AbortController();
|
|
103
|
+
const timer = setTimeout(() => ctrl.abort(new Error("timeout")), timeoutMs);
|
|
104
|
+
// Chain the caller's signal so pi's cancel (ctx.signal) aborts in-flight requests.
|
|
105
|
+
const onAbort = () => ctrl.abort(signal?.reason ?? new Error("aborted"));
|
|
106
|
+
if (signal) {
|
|
107
|
+
if (signal.aborted) {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
throw new OmnxError("Cancelled", "network");
|
|
110
|
+
}
|
|
111
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const res = await fetch(url, { ...init, signal: ctrl.signal });
|
|
115
|
+
const body = await res.text();
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
let message = body.slice(0, 400) || `HTTP ${res.status}`;
|
|
118
|
+
let available: string[] | undefined;
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(body) as { error?: { message?: string }; message?: string };
|
|
121
|
+
const raw = typeof parsed.error === "string" ? parsed.error : (parsed.error?.message ?? parsed.message ?? message);
|
|
122
|
+
message = raw || message;
|
|
123
|
+
const m = /Available:\s*(.+)$/is.exec(message);
|
|
124
|
+
if (m) available = m[1].split(",").map((s) => s.trim()).filter(Boolean);
|
|
125
|
+
} catch {
|
|
126
|
+
/* non-JSON error body; keep raw text */
|
|
127
|
+
}
|
|
128
|
+
throw new OmnxError(message, res.status === 404 ? "notfound" : "http", res.status, available);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(body) as T;
|
|
132
|
+
} catch {
|
|
133
|
+
throw new OmnxError("Response was not valid JSON", "http", res.status);
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err instanceof OmnxError) throw err;
|
|
137
|
+
const e = err as Error;
|
|
138
|
+
if (e.name === "AbortError" || /abort/i.test(e.message)) throw new OmnxError("Cancelled", "network");
|
|
139
|
+
throw new OmnxError(`Cannot reach ${new URL(url).host}: ${e.message}`, "network");
|
|
140
|
+
} finally {
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
signal?.removeEventListener("abort", onAbort);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function authHeaders(apiKey?: string): Record<string, string> {
|
|
147
|
+
return apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Real audio duration from segments; falls back to the (untrusted) duration field. */
|
|
151
|
+
export function audioDurationFromSegments(segments?: AsrSegment[]): number | null {
|
|
152
|
+
if (!Array.isArray(segments) || segments.length === 0) return null;
|
|
153
|
+
const ends = segments.map((s) => (typeof s?.end === "number" ? s.end : NaN)).filter((n) => Number.isFinite(n));
|
|
154
|
+
return ends.length ? Math.max(...ends) : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** List STT-capable models. Prefers engine_type==="audio_stt" from /v1/models/status. */
|
|
158
|
+
export async function listSttModels(baseUrl: string, apiKey?: string, timeoutMs = 15_000, signal?: AbortSignal): Promise<SttModel[]> {
|
|
159
|
+
const statusUrl = `${baseUrl.replace(/\/v1$/, "")}/v1/models/status`;
|
|
160
|
+
try {
|
|
161
|
+
const data = await httpJson<{ models?: Array<Record<string, unknown>> }>(statusUrl, { method: "GET", headers: authHeaders(apiKey) }, timeoutMs, signal);
|
|
162
|
+
const models = Array.isArray(data.models) ? data.models : [];
|
|
163
|
+
const stt = models
|
|
164
|
+
.filter((m) => {
|
|
165
|
+
const engine = String(m.engine_type ?? m.model_type ?? "").toLowerCase();
|
|
166
|
+
const cfg = String(m.config_model_type ?? "").toLowerCase();
|
|
167
|
+
return engine.includes("stt") || engine.includes("asr") || cfg.includes("asr") || cfg.includes("whisper");
|
|
168
|
+
})
|
|
169
|
+
.map((m) => ({
|
|
170
|
+
id: String(m.id),
|
|
171
|
+
loaded: Boolean(m.loaded),
|
|
172
|
+
isLoading: Boolean(m.is_loading),
|
|
173
|
+
engineType: String(m.engine_type ?? ""),
|
|
174
|
+
configModelType: String(m.config_model_type ?? ""),
|
|
175
|
+
estimatedSize: typeof m.estimated_size === "number" ? m.estimated_size : undefined,
|
|
176
|
+
sourceType: typeof m.source_type === "string" ? m.source_type : undefined,
|
|
177
|
+
realtimeStt: Boolean(m.realtime_stt),
|
|
178
|
+
}));
|
|
179
|
+
if (stt.length) return stt;
|
|
180
|
+
} catch {
|
|
181
|
+
/* fall through to /v1/models name sniffing */
|
|
182
|
+
}
|
|
183
|
+
// Fallback: /v1/models has no engine field, so sniff the id.
|
|
184
|
+
const data = await httpJson<{ data?: Array<{ id: string }> }>(`${baseUrl}/models`, { method: "GET", headers: authHeaders(apiKey) }, timeoutMs, signal);
|
|
185
|
+
return (data.data ?? [])
|
|
186
|
+
.filter((m) => /asr|whisper|stt|speech[-_]?rec/i.test(m.id))
|
|
187
|
+
.map((m) => ({ id: m.id, loaded: false, isLoading: false }));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Pick the best STT model: an already-loaded one wins (no cold start), then
|
|
192
|
+
* prefer a shorter canonical id over a duplicated `org--name` cache alias.
|
|
193
|
+
*/
|
|
194
|
+
export function pickSttModel(models: SttModel[]): SttModel | null {
|
|
195
|
+
if (!models.length) return null;
|
|
196
|
+
const score = (m: SttModel): number => {
|
|
197
|
+
let s = 0;
|
|
198
|
+
if (m.loaded) s += 100;
|
|
199
|
+
if (m.isLoading) s -= 50;
|
|
200
|
+
if (!m.id.includes("--")) s += 10; // canonical local id over "mlx-community--X" alias
|
|
201
|
+
if (/asr/i.test(m.id)) s += 5;
|
|
202
|
+
s -= m.id.length / 1000;
|
|
203
|
+
return s;
|
|
204
|
+
};
|
|
205
|
+
return [...models].sort((a, b) => score(b) - score(a))[0];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface TranscribeOptions {
|
|
209
|
+
baseUrl: string;
|
|
210
|
+
model: string;
|
|
211
|
+
filePath: string;
|
|
212
|
+
fileName?: string;
|
|
213
|
+
/** Omit for server auto-detect (the default). */
|
|
214
|
+
language?: string | null;
|
|
215
|
+
apiKey?: string;
|
|
216
|
+
timeoutMs?: number;
|
|
217
|
+
signal?: AbortSignal;
|
|
218
|
+
prompt?: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function transcribe(opts: TranscribeOptions): Promise<AsrResponse> {
|
|
222
|
+
const form = new FormData();
|
|
223
|
+
// A Blob from a byte buffer avoids relying on File constructor availability
|
|
224
|
+
// across Node versions, while still setting the multipart filename, which
|
|
225
|
+
// oMLX uses to sniff the container format.
|
|
226
|
+
const buf = await readFileBuffer(opts.filePath);
|
|
227
|
+
const blob = new Blob([new Uint8Array(buf)], { type: mimeFor(opts.fileName ?? opts.filePath) });
|
|
228
|
+
form.append("file", blob, opts.fileName ?? basename(opts.filePath));
|
|
229
|
+
form.append("model", opts.model);
|
|
230
|
+
if (opts.language) form.append("language", opts.language);
|
|
231
|
+
if (opts.prompt) form.append("prompt", opts.prompt);
|
|
232
|
+
|
|
233
|
+
const data = await httpJson<AsrResponse>(`${opts.baseUrl}/audio/transcriptions`, { method: "POST", headers: authHeaders(opts.apiKey), body: form }, opts.timeoutMs ?? 300_000, opts.signal);
|
|
234
|
+
if (typeof data?.text !== "string") throw new OmnxError("Transcription response missing `text`", "empty");
|
|
235
|
+
return data;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function serverHealth(baseUrl: string, apiKey?: string, timeoutMs = 8_000, signal?: AbortSignal): Promise<ServerHealth> {
|
|
239
|
+
try {
|
|
240
|
+
const data = await httpJson<{ status?: string; version?: string; loaded_models?: string[] }>(
|
|
241
|
+
`${baseUrl.replace(/\/v1$/, "")}/api/status`,
|
|
242
|
+
{ method: "GET", headers: authHeaders(apiKey) },
|
|
243
|
+
timeoutMs,
|
|
244
|
+
signal,
|
|
245
|
+
);
|
|
246
|
+
return { ok: data.status === "ok", version: data.version, loadedModels: data.loaded_models, reachable: true };
|
|
247
|
+
} catch (err) {
|
|
248
|
+
return { ok: false, reachable: false, error: err instanceof Error ? err.message : String(err) };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const EXT_MIME: Record<string, string> = {
|
|
253
|
+
// Verified accepted by oMLX 0.6.4 /v1/audio/transcriptions: wav, mp3,
|
|
254
|
+
// m4a, aac, opus. The remainder are advertised but untested here.
|
|
255
|
+
".3ga": "audio/3gpp",
|
|
256
|
+
".wav": "audio/wav",
|
|
257
|
+
".mp3": "audio/mpeg",
|
|
258
|
+
".m4a": "audio/mp4",
|
|
259
|
+
".mp4": "audio/mp4",
|
|
260
|
+
".aac": "audio/aac",
|
|
261
|
+
".opus": "audio/ogg",
|
|
262
|
+
".ogg": "audio/ogg",
|
|
263
|
+
".flac": "audio/flac",
|
|
264
|
+
".webm": "audio/webm",
|
|
265
|
+
".amr": "audio/amr",
|
|
266
|
+
".3gp": "audio/3gpp",
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export function mimeFor(path: string): string {
|
|
270
|
+
const dot = path.lastIndexOf(".");
|
|
271
|
+
return EXT_MIME[path.slice(dot).toLowerCase()] ?? "application/octet-stream";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export const AUDIO_EXTENSIONS: string[] = Object.keys(EXT_MIME);
|
|
275
|
+
|
|
276
|
+
/** Extensions confirmed working against the live server. */
|
|
277
|
+
export const VERIFIED_AUDIO_EXTENSIONS = [".wav", ".mp3", ".m4a", ".aac", ".opus"];
|
|
278
|
+
|
|
279
|
+
export function isAudioPath(path: string): boolean {
|
|
280
|
+
const dot = path.lastIndexOf(".");
|
|
281
|
+
if (dot < 0) return false;
|
|
282
|
+
return Object.keys(EXT_MIME).includes(path.slice(dot).toLowerCase());
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function basename(path: string): string {
|
|
286
|
+
const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
287
|
+
return i >= 0 ? path.slice(i + 1) : path;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function readFileBuffer(path: string): Promise<ArrayBuffer> {
|
|
291
|
+
const { readFile } = await import("node:fs/promises");
|
|
292
|
+
const buf = await readFile(path);
|
|
293
|
+
// Node Buffers are ArrayBuffers but typed nominally; slice gives a clean copy.
|
|
294
|
+
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
|
|
295
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@maheidem/pi-audio-transcribe",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Pi extension: transcribe audio with oMLX STT (Qwen3-ASR), validate the speech, and persist a JSON transcript sidecar beside each audio file.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"extension",
|
|
9
|
+
"speech-to-text",
|
|
10
|
+
"stt",
|
|
11
|
+
"asr",
|
|
12
|
+
"omlx",
|
|
13
|
+
"qwen3-asr",
|
|
14
|
+
"mlx",
|
|
15
|
+
"transcription"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
"config.ts",
|
|
19
|
+
"index.ts",
|
|
20
|
+
"omlx.ts",
|
|
21
|
+
"paths.ts",
|
|
22
|
+
"pipeline.ts",
|
|
23
|
+
"settings.ts",
|
|
24
|
+
"sidecar.ts",
|
|
25
|
+
"validate.ts",
|
|
26
|
+
"lib/json-store.ts",
|
|
27
|
+
"ui/audio-panel.ts",
|
|
28
|
+
"ui/settings-panel.ts",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"author": "maheidem",
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/Maheidem/pi-coder-management.git",
|
|
40
|
+
"directory": "custom-extensions/audio-transcribe"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/Maheidem/pi-coder-management/tree/main/custom-extensions/audio-transcribe#readme",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/Maheidem/pi-coder-management/issues"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "node tests/run.mjs",
|
|
48
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
49
|
+
"prepack": "npm run typecheck && npm test"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
53
|
+
"@earendil-works/pi-tui": "*",
|
|
54
|
+
"typebox": "*"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
58
|
+
"@earendil-works/pi-tui": "^0.84.4",
|
|
59
|
+
"@types/node": "^22.0.0",
|
|
60
|
+
"tsx": "^4.20.0",
|
|
61
|
+
"typebox": "^1.0.17",
|
|
62
|
+
"typescript": "^5.9.3"
|
|
63
|
+
},
|
|
64
|
+
"pi": {
|
|
65
|
+
"extensions": [
|
|
66
|
+
"./index.ts"
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
}
|
package/paths.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio path extraction from user input.
|
|
3
|
+
*
|
|
4
|
+
* Handles the forms that actually reach pi:
|
|
5
|
+
* - interactive paste, which inserts a path with backslash-escaped spaces
|
|
6
|
+
* /Users/me/Downloads/Medidas\ da\ gaveta.m4a
|
|
7
|
+
* - quoted paths
|
|
8
|
+
* "/Users/me/Downloads/Medidas da gaveta.m4a"
|
|
9
|
+
* - @-prefixed paths (models often echo the @ from `pi -p @file`)
|
|
10
|
+
* - plain paths without spaces
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { AUDIO_EXTENSIONS, isAudioPath } from "./omlx.ts";
|
|
14
|
+
|
|
15
|
+
/** Collapse a pasted path token into a real filesystem path. */
|
|
16
|
+
export function unescapePath(token: string): string {
|
|
17
|
+
let t = token.trim();
|
|
18
|
+
// Strip surrounding quotes.
|
|
19
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) t = t.slice(1, -1);
|
|
20
|
+
// Leading @ used by pi's file attachment syntax.
|
|
21
|
+
if (t.startsWith("@")) t = t.slice(1);
|
|
22
|
+
// Backslash-escaped spaces (shell/paste form) -> real spaces.
|
|
23
|
+
t = t.replace(/\\ /g, " ");
|
|
24
|
+
// Collapse doubled slashes introduced by sloppy concatenation, but keep UNC-ish prefixes.
|
|
25
|
+
t = t.replace(/([^:])\/\/+/g, "$1/");
|
|
26
|
+
return t.trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stripTrailingPunctuation(s: string): string {
|
|
30
|
+
return s.replace(/[),.;:!?'\]]+$/u, "").trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Find audio file paths referenced in free text, de-duplicated, in order.
|
|
35
|
+
* Deliberately conservative: a token must look like a path (contain a slash
|
|
36
|
+
* or start with ~ / . / a drive letter) AND carry an audio extension.
|
|
37
|
+
*/
|
|
38
|
+
export function extractAudioPaths(text: string): string[] {
|
|
39
|
+
if (!text) return [];
|
|
40
|
+
const found: string[] = [];
|
|
41
|
+
const push = (raw: string) => {
|
|
42
|
+
const p = unescapePath(stripTrailingPunctuation(raw));
|
|
43
|
+
if (!p) return;
|
|
44
|
+
if (!isAudioPath(p)) return;
|
|
45
|
+
if (!/[~/]/.test(p) && !/^\.{1,2}[\\/]/.test(p) && !/^[a-zA-Z]:[\\/]/.test(p)) return;
|
|
46
|
+
if (!found.includes(p)) found.push(p);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// 1. Quoted strings.
|
|
50
|
+
for (const m of text.matchAll(/"([^"]+)"|'([^']+)'/g)) push(m[1] ?? m[2] ?? "");
|
|
51
|
+
|
|
52
|
+
// 2. Backslash-escaped paths: greedy up to the audio extension.
|
|
53
|
+
const escapedRe = new RegExp(`(?:[^\\s"']|\\\\ )+\\.(?:${AUDIO_EXTENSIONS.map((e) => e.slice(1)).join("|")})\\b`, "gi");
|
|
54
|
+
for (const m of text.matchAll(escapedRe)) {
|
|
55
|
+
if (/\\\s/.test(m[0]) || m[0].includes("\\")) push(m[0]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 3. Plain whitespace-delimited tokens.
|
|
59
|
+
for (const tok of text.split(/\s+/)) {
|
|
60
|
+
if (!tok) continue;
|
|
61
|
+
if (tok.includes("\\ ")) continue; // handled by rule 2
|
|
62
|
+
push(tok);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return found;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Split a command argument string into tokens, honouring "quotes", 'quotes',
|
|
70
|
+
* and backslash-escaped spaces. Without this, `/transcribe "a b c.m4a"`
|
|
71
|
+
* shatters into three bogus paths — which matters because real recordings
|
|
72
|
+
* from phones are almost always named with spaces.
|
|
73
|
+
*/
|
|
74
|
+
export function tokenizeArgs(input: string): string[] {
|
|
75
|
+
const tokens: string[] = [];
|
|
76
|
+
let cur = "";
|
|
77
|
+
let quote: '"' | "'" | null = null;
|
|
78
|
+
let started = false;
|
|
79
|
+
for (let i = 0; i < input.length; i++) {
|
|
80
|
+
const ch = input[i]!;
|
|
81
|
+
if (quote) {
|
|
82
|
+
if (ch === quote) {
|
|
83
|
+
quote = null;
|
|
84
|
+
started = true;
|
|
85
|
+
}
|
|
86
|
+
// Inside single quotes everything is literal; inside double quotes
|
|
87
|
+
// a backslash still escapes the next char.
|
|
88
|
+
else if (ch === "\\" && quote === '"' && i + 1 < input.length) {
|
|
89
|
+
cur += input[++i];
|
|
90
|
+
}
|
|
91
|
+
// An escaped space typed inside quotes stays a space.
|
|
92
|
+
else cur += ch;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (ch === '"' || ch === "'") {
|
|
96
|
+
quote = ch;
|
|
97
|
+
started = true;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (ch === "\\" && i + 1 < input.length) {
|
|
101
|
+
cur += input[++i];
|
|
102
|
+
started = true;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (/\s/.test(ch)) {
|
|
106
|
+
if (started) {
|
|
107
|
+
tokens.push(cur);
|
|
108
|
+
cur = "";
|
|
109
|
+
started = false;
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
cur += ch;
|
|
114
|
+
started = true;
|
|
115
|
+
}
|
|
116
|
+
if (started) tokens.push(cur);
|
|
117
|
+
return tokens;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Resolve a possibly-relative path against cwd, expanding a leading `~`. */
|
|
121
|
+
export function expandPath(p: string, cwd: string): string {
|
|
122
|
+
let t = unescapePath(p);
|
|
123
|
+
if (t === "~") return process.env.HOME ?? t;
|
|
124
|
+
if (t.startsWith("~/")) return `${process.env.HOME ?? "~"}/${t.slice(2)}`;
|
|
125
|
+
if (t.startsWith("/")) return t;
|
|
126
|
+
if (/^[a-zA-Z]:[\\/]/.test(t)) return t;
|
|
127
|
+
return `${cwd.replace(/\/+$/, "")}/${t}`;
|
|
128
|
+
}
|