dictawhisper 0.0.13 → 0.0.14

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.
Files changed (60) hide show
  1. package/README.md +10 -2
  2. package/bin/dictawhisper.mjs +14 -0
  3. package/config.example.json +41 -0
  4. package/dist/ui/_app/immutable/assets/0.C6M3gHo0.css +1 -0
  5. package/dist/ui/_app/immutable/assets/2.C96vgj_4.css +1 -0
  6. package/dist/ui/_app/immutable/chunks/BSs9MmST.js +3 -0
  7. package/dist/ui/_app/immutable/chunks/Bjy-W4x2.js +81 -0
  8. package/dist/ui/_app/immutable/chunks/BslYtab-.js +1 -0
  9. package/dist/ui/_app/immutable/chunks/xihTtKlq.js +1 -0
  10. package/dist/ui/_app/immutable/entry/app.BJlRju4c.js +2 -0
  11. package/dist/ui/_app/immutable/entry/start.C5XBbXTT.js +1 -0
  12. package/dist/ui/_app/immutable/nodes/0.D23pkrHr.js +1 -0
  13. package/dist/ui/_app/immutable/nodes/1.C5T-U4uV.js +1 -0
  14. package/dist/ui/_app/immutable/nodes/2.59uAq_ko.js +5 -0
  15. package/dist/ui/_app/version.json +1 -0
  16. package/dist/ui/favicon.png +0 -0
  17. package/dist/ui/favicon.svg +3 -0
  18. package/dist/ui/index.html +38 -0
  19. package/dist/ui/logo.png +0 -0
  20. package/package.json +12 -3
  21. package/scripts/transcribe_faster_whisper.py +428 -0
  22. package/src/apiRoutes.ts +464 -0
  23. package/src/classes/Queue.ts +77 -0
  24. package/src/classes/Watcher.ts +117 -0
  25. package/src/cli.ts +132 -0
  26. package/src/config.ts +238 -0
  27. package/src/dev.ts +63 -0
  28. package/src/doctor.ts +12 -0
  29. package/src/lib/alignLib.ts +154 -0
  30. package/src/lib/audioLib.ts +202 -0
  31. package/src/lib/audioProbeLib.ts +112 -0
  32. package/src/lib/cleanupProvenanceLib.ts +80 -0
  33. package/src/lib/downloadLib.ts +8 -0
  34. package/src/lib/fileSettleLib.ts +213 -0
  35. package/src/lib/fsLib.ts +37 -0
  36. package/src/lib/healthLib.ts +377 -0
  37. package/src/lib/inboxUrl.ts +123 -0
  38. package/src/lib/journalEmbedLib.ts +127 -0
  39. package/src/lib/journalIndexLib.ts +923 -0
  40. package/src/lib/journalQueryLib.ts +269 -0
  41. package/src/lib/journalService.ts +287 -0
  42. package/src/lib/jsonLib.ts +71 -0
  43. package/src/lib/localberthGet.ts +14 -0
  44. package/src/lib/ollanetLib.ts +117 -0
  45. package/src/lib/ollanetReadyLib.ts +7 -0
  46. package/src/lib/organizationLib.ts +182 -0
  47. package/src/lib/pathAllowLib.ts +60 -0
  48. package/src/lib/queueLib.ts +30 -0
  49. package/src/lib/speechCleanupLib.ts +14 -0
  50. package/src/lib/tagConsolidateLib.ts +431 -0
  51. package/src/lib/tailscaleLib.ts +78 -0
  52. package/src/lib/transcriptionLib.ts +484 -0
  53. package/src/lib/whisperLib.ts +409 -0
  54. package/src/mcp.ts +151 -0
  55. package/src/prompts/cleanTranscription.ts +62 -0
  56. package/src/prompts/consolidateTags.ts +38 -0
  57. package/src/prompts/whisperInitial.ts +11 -0
  58. package/src/server.ts +220 -0
  59. package/src/socketEvents.ts +72 -0
  60. package/src/types/transcription.ts +56 -0
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env python3
2
+ """Transcribe audio with faster-whisper; write OpenAI-Whisper-compatible JSON.
3
+
4
+ One-shot:
5
+ python transcribe_faster_whisper.py --audio FILE --output FILE.json ...
6
+
7
+ Persistent worker (model loaded once; stdin/stdout JSON lines):
8
+ python transcribe_faster_whisper.py --worker --model large-v3 --device cuda ...
9
+ stdin: {"id":"1","cmd":"transcribe","audio":"...","output":"...","initial_prompt":"..."}
10
+ {"id":"2","cmd":"shutdown"}
11
+ stdout: {"type":"ready","model":"...","device":"..."}
12
+ {"id":"1","type":"progress","pct":12.3,"start":0,"end":4.2,"text":"..."}
13
+ {"id":"1","type":"done","ok":true,"elapsed":18.4,"chars":123,"segments":10}
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import sys
22
+ import time
23
+ from pathlib import Path
24
+ from typing import Any, Callable
25
+
26
+ from faster_whisper import WhisperModel
27
+
28
+ MODEL_ALIASES = {
29
+ "large": "large-v3",
30
+ "large-v3": "large-v3",
31
+ "turbo": "large-v3-turbo",
32
+ "large-v3-turbo": "large-v3-turbo",
33
+ }
34
+
35
+ # Light journal context only — do not ask the model to rewrite/polish speech.
36
+ # Downstream LLM cleanup already removes fillers while preserving content.
37
+ DEFAULT_INITIAL_PROMPT = (
38
+ "This is a personal audio journal spoken in English. "
39
+ "Transcribe the speech accurately with natural punctuation and capitalization. "
40
+ "Keep filler words, false starts, and repetitions as spoken. "
41
+ "Do not paraphrase. Prefer unusual names and terms over common words that sound similar."
42
+ )
43
+
44
+ KEEP_KEYS = (
45
+ "cleanedTranscription",
46
+ "tags",
47
+ "thinking",
48
+ "meta",
49
+ "elapsed",
50
+ "cleanupError",
51
+ "cleanupAttempts",
52
+ )
53
+
54
+
55
+ def _reconfigure_stdio() -> None:
56
+ """Windows cp1252 cannot print Whisper tokens like ⁄ or ş; do not fail the job for a log line."""
57
+ for stream in (sys.stdout, sys.stderr):
58
+ reconfigure = getattr(stream, "reconfigure", None)
59
+ if not reconfigure:
60
+ continue
61
+ try:
62
+ reconfigure(encoding="utf-8", errors="replace")
63
+ except Exception:
64
+ pass
65
+
66
+
67
+ _reconfigure_stdio()
68
+
69
+
70
+ def resolve_model(name: str) -> str:
71
+ key = name.strip().lower()
72
+ if key not in MODEL_ALIASES:
73
+ supported = ", ".join(sorted(MODEL_ALIASES))
74
+ raise SystemExit(f"Unsupported model '{name}'. Use one of: {supported}")
75
+ return MODEL_ALIASES[key]
76
+
77
+
78
+ def _write_line(msg: str, stream) -> None:
79
+ try:
80
+ print(msg, file=stream, flush=True)
81
+ except UnicodeEncodeError:
82
+ encoding = getattr(stream, "encoding", None) or "utf-8"
83
+ payload = (msg + "\n").encode(encoding, errors="replace")
84
+ buf = getattr(stream, "buffer", None)
85
+ if buf is not None:
86
+ buf.write(payload)
87
+ buf.flush()
88
+ return
89
+ print(payload.decode(encoding, errors="replace"), file=stream, flush=True)
90
+
91
+
92
+ def log(msg: str) -> None:
93
+ """Print immediately — piped stdout is block-buffered unless flushed."""
94
+ _write_line(msg, sys.stdout)
95
+
96
+
97
+ def log_err(msg: str) -> None:
98
+ _write_line(msg, sys.stderr)
99
+
100
+
101
+ def emit(payload: dict[str, Any]) -> None:
102
+ _write_line(json.dumps(payload, ensure_ascii=False), sys.stdout)
103
+
104
+
105
+ def load_existing(output_path: Path) -> dict[str, Any]:
106
+ if not output_path.is_file():
107
+ return {}
108
+ try:
109
+ loaded = json.loads(output_path.read_text(encoding="utf-8"))
110
+ return loaded if isinstance(loaded, dict) else {}
111
+ except Exception:
112
+ return {}
113
+
114
+
115
+ def write_sidecar(
116
+ output_path: Path,
117
+ *,
118
+ texts: list[str],
119
+ segments: list[dict[str, Any]],
120
+ language: str,
121
+ model_name: str,
122
+ device: str,
123
+ compute_type: str,
124
+ duration: float | None,
125
+ vad_filter: bool,
126
+ ) -> dict[str, Any]:
127
+ existing = load_existing(output_path)
128
+ payload = {key: existing[key] for key in KEEP_KEYS if key in existing}
129
+ payload.update(
130
+ {
131
+ "text": "".join(texts).strip(),
132
+ "segments": segments,
133
+ "language": language,
134
+ "whisper": {
135
+ "engine": "faster-whisper",
136
+ "model": model_name,
137
+ "device": device,
138
+ "compute_type": compute_type,
139
+ "duration": duration,
140
+ "vad_filter": vad_filter,
141
+ "word_timestamps": True,
142
+ },
143
+ }
144
+ )
145
+ output_path.parent.mkdir(parents=True, exist_ok=True)
146
+ output_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
147
+ return payload
148
+
149
+
150
+ def transcribe_to_sidecar(
151
+ model: WhisperModel,
152
+ audio_path: Path,
153
+ output_path: Path,
154
+ *,
155
+ language: str,
156
+ initial_prompt: str | None,
157
+ vad_filter: bool,
158
+ model_name: str,
159
+ device: str,
160
+ compute_type: str,
161
+ log_fn: Callable[[str], None],
162
+ progress_fn: Callable[[dict[str, Any]], None] | None = None,
163
+ ) -> dict[str, Any]:
164
+ if not audio_path.is_file():
165
+ raise FileNotFoundError(f"audio not found: {audio_path}")
166
+
167
+ log_fn(f"[transcribe] audio={audio_path}")
168
+ log_fn("[transcribe] starting transcription...")
169
+
170
+ segments_iter, info = model.transcribe(
171
+ str(audio_path),
172
+ language=language,
173
+ task="transcribe",
174
+ beam_size=5,
175
+ vad_filter=vad_filter,
176
+ initial_prompt=initial_prompt or None,
177
+ condition_on_previous_text=False,
178
+ compression_ratio_threshold=2.2,
179
+ word_timestamps=True,
180
+ log_progress=False,
181
+ )
182
+
183
+ duration = float(info.duration or 0.0)
184
+ if duration > 0:
185
+ log_fn(f"[transcribe] audio duration={duration:.1f}s language={info.language}")
186
+ else:
187
+ log_fn(f"[transcribe] language={info.language}")
188
+
189
+ segments: list[dict[str, Any]] = []
190
+ texts: list[str] = []
191
+ for i, segment in enumerate(segments_iter):
192
+ text = segment.text
193
+ texts.append(text)
194
+ words = []
195
+ for word in segment.words or []:
196
+ if word.start is None or word.end is None:
197
+ continue
198
+ words.append(
199
+ {
200
+ "word": word.word,
201
+ "start": float(word.start),
202
+ "end": float(word.end),
203
+ "probability": float(word.probability) if word.probability is not None else None,
204
+ }
205
+ )
206
+ segments.append(
207
+ {
208
+ "id": i,
209
+ "seek": 0,
210
+ "start": segment.start,
211
+ "end": segment.end,
212
+ "text": text,
213
+ "words": words,
214
+ "tokens": [],
215
+ "temperature": segment.temperature,
216
+ "avg_logprob": segment.avg_logprob,
217
+ "compression_ratio": segment.compression_ratio,
218
+ "no_speech_prob": segment.no_speech_prob,
219
+ }
220
+ )
221
+ if duration > 0:
222
+ pct = min(100.0, (segment.end / duration) * 100.0)
223
+ log_fn(
224
+ f"[transcribe] {pct:5.1f}% "
225
+ f"({segment.start:7.1f}-{segment.end:7.1f}s / {duration:.1f}s) "
226
+ f"{text.strip()}"
227
+ )
228
+ if progress_fn:
229
+ progress_fn(
230
+ {
231
+ "pct": round(pct, 1),
232
+ "start": float(segment.start),
233
+ "end": float(segment.end),
234
+ "text": text.strip(),
235
+ }
236
+ )
237
+ else:
238
+ log_fn(f"[{segment.start:7.2f} -> {segment.end:7.2f}] {text.strip()}")
239
+ if progress_fn:
240
+ progress_fn(
241
+ {
242
+ "pct": None,
243
+ "start": float(segment.start),
244
+ "end": float(segment.end),
245
+ "text": text.strip(),
246
+ }
247
+ )
248
+
249
+ if duration > 0:
250
+ log_fn(f"[transcribe] 100.0% done ({duration:.1f}s)")
251
+
252
+ payload = write_sidecar(
253
+ output_path,
254
+ texts=texts,
255
+ segments=segments,
256
+ language=info.language,
257
+ model_name=model_name,
258
+ device=device,
259
+ compute_type=compute_type,
260
+ duration=info.duration,
261
+ vad_filter=vad_filter,
262
+ )
263
+ log_fn(f"[transcribe] wrote {output_path} ({len(payload['text'])} chars, {len(segments)} segments)")
264
+ return {"chars": len(payload["text"]), "segments": len(segments), "duration": duration}
265
+
266
+
267
+ def build_parser() -> argparse.ArgumentParser:
268
+ parser = argparse.ArgumentParser(description=__doc__)
269
+ parser.add_argument("--worker", action="store_true", help="Long-lived stdin/stdout JSON worker")
270
+ parser.add_argument("--audio", help="Path to audio file (one-shot)")
271
+ parser.add_argument("--output", help="Path to output .json file (one-shot)")
272
+ parser.add_argument(
273
+ "--model",
274
+ default="large-v3",
275
+ help="Model alias: large-v3|large|turbo|large-v3-turbo (default: large-v3)",
276
+ )
277
+ parser.add_argument("--device", default="cuda", choices=("cuda", "cpu"))
278
+ parser.add_argument(
279
+ "--compute-type",
280
+ default="float16",
281
+ help="CTranslate2 compute type (default: float16). Use int8_float16 if VRAM is tight.",
282
+ )
283
+ parser.add_argument("--language", default="en")
284
+ parser.add_argument(
285
+ "--initial-prompt",
286
+ default=DEFAULT_INITIAL_PROMPT,
287
+ help="Optional first-window prompt. Pass empty string to disable.",
288
+ )
289
+ parser.add_argument(
290
+ "--vad-filter",
291
+ action=argparse.BooleanOptionalAction,
292
+ default=True,
293
+ help="Silero VAD to skip silence (default: on)",
294
+ )
295
+ return parser
296
+
297
+
298
+ def run_oneshot(args: argparse.Namespace) -> int:
299
+ if not args.audio or not args.output:
300
+ print("[transcribe] --audio and --output are required in one-shot mode", file=sys.stderr, flush=True)
301
+ return 1
302
+
303
+ audio_path = Path(args.audio)
304
+ output_path = Path(args.output)
305
+ model_name = resolve_model(args.model)
306
+ log(f"[transcribe] model={model_name} device={args.device} compute_type={args.compute_type}")
307
+ log("[transcribe] loading model...")
308
+ model = WhisperModel(model_name, device=args.device, compute_type=args.compute_type)
309
+ log("[transcribe] model loaded; starting transcription...")
310
+ transcribe_to_sidecar(
311
+ model,
312
+ audio_path,
313
+ output_path,
314
+ language=args.language,
315
+ initial_prompt=args.initial_prompt,
316
+ vad_filter=args.vad_filter,
317
+ model_name=model_name,
318
+ device=args.device,
319
+ compute_type=args.compute_type,
320
+ log_fn=log,
321
+ )
322
+ # CTranslate2/CUDA often abort() during teardown on Windows (exit 0xC0000409)
323
+ # after a successful run. Skip destructors so Node sees a clean 0.
324
+ os._exit(0)
325
+
326
+
327
+ def run_worker(args: argparse.Namespace) -> None:
328
+ model_name = resolve_model(args.model)
329
+ log_err(f"[transcribe] worker model={model_name} device={args.device} compute_type={args.compute_type}")
330
+ log_err("[transcribe] loading model...")
331
+ model = WhisperModel(model_name, device=args.device, compute_type=args.compute_type)
332
+ log_err("[transcribe] model loaded")
333
+ emit({"type": "ready", "model": model_name, "device": args.device, "compute_type": args.compute_type})
334
+
335
+ for raw in sys.stdin:
336
+ line = raw.strip()
337
+ if not line:
338
+ continue
339
+ try:
340
+ job = json.loads(line)
341
+ except json.JSONDecodeError as error:
342
+ emit({"type": "error", "error": f"invalid json: {error}"})
343
+ continue
344
+
345
+ job_id = str(job.get("id") or "")
346
+ cmd = str(job.get("cmd") or "transcribe")
347
+ if cmd == "shutdown":
348
+ log_err("[transcribe] worker shutdown")
349
+ # Same Windows CUDA teardown: skip destructors on the way out.
350
+ os._exit(0)
351
+
352
+ if cmd != "transcribe":
353
+ emit({"id": job_id, "type": "done", "ok": False, "error": f"unknown cmd {cmd}"})
354
+ continue
355
+
356
+ job_model = resolve_model(str(job.get("model") or args.model))
357
+ job_device = str(job.get("device") or args.device)
358
+ job_compute = str(job.get("compute_type") or args.compute_type)
359
+ if job_model != model_name or job_device != args.device or job_compute != args.compute_type:
360
+ emit(
361
+ {
362
+ "id": job_id,
363
+ "type": "done",
364
+ "ok": False,
365
+ "error": (
366
+ f"worker mismatch (loaded {model_name}/{args.device}/{args.compute_type}, "
367
+ f"job {job_model}/{job_device}/{job_compute})"
368
+ ),
369
+ }
370
+ )
371
+ continue
372
+
373
+ audio = job.get("audio")
374
+ output = job.get("output")
375
+ if not audio or not output:
376
+ emit({"id": job_id, "type": "done", "ok": False, "error": "audio and output are required"})
377
+ continue
378
+
379
+ started = time.time()
380
+ try:
381
+ result = transcribe_to_sidecar(
382
+ model,
383
+ Path(str(audio)),
384
+ Path(str(output)),
385
+ language=str(job.get("language") or args.language),
386
+ initial_prompt=job.get("initial_prompt", args.initial_prompt),
387
+ vad_filter=bool(job.get("vad_filter", args.vad_filter)),
388
+ model_name=model_name,
389
+ device=args.device,
390
+ compute_type=args.compute_type,
391
+ log_fn=log_err,
392
+ progress_fn=lambda payload, _id=job_id: emit({"id": _id, "type": "progress", **payload}),
393
+ )
394
+ emit(
395
+ {
396
+ "id": job_id,
397
+ "type": "done",
398
+ "ok": True,
399
+ "elapsed": round(time.time() - started, 2),
400
+ "chars": result["chars"],
401
+ "segments": result["segments"],
402
+ }
403
+ )
404
+ except Exception as error:
405
+ emit(
406
+ {
407
+ "id": job_id,
408
+ "type": "done",
409
+ "ok": False,
410
+ "elapsed": round(time.time() - started, 2),
411
+ "error": str(error),
412
+ }
413
+ )
414
+
415
+ log_err("[transcribe] worker stdin closed")
416
+ os._exit(0)
417
+
418
+
419
+ def main() -> int:
420
+ args = build_parser().parse_args()
421
+ if args.worker:
422
+ run_worker(args)
423
+ return 0
424
+ return run_oneshot(args)
425
+
426
+
427
+ if __name__ == "__main__":
428
+ raise SystemExit(main())