dictawhisper 0.1.14 → 0.1.16

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 (58) hide show
  1. package/README.md +51 -27
  2. package/config.example.json +3 -0
  3. package/dist/apiRoutes.js +9 -1
  4. package/dist/audioRescueRoutes.js +61 -0
  5. package/dist/cli.js +3 -3
  6. package/dist/config.js +3 -0
  7. package/dist/lib/activityLib.js +13 -3
  8. package/dist/lib/alignLib.js +8 -15
  9. package/dist/lib/asrModels.js +7 -0
  10. package/dist/lib/audioAssessmentLib.js +93 -0
  11. package/dist/lib/audioEnhancementLib.js +71 -0
  12. package/dist/lib/audioImprovementStorageLib.js +47 -0
  13. package/dist/lib/audioLib.js +13 -3
  14. package/dist/lib/audioRescueService.js +485 -0
  15. package/dist/lib/barkLib.js +48 -0
  16. package/dist/lib/cleanupProvenanceLib.js +3 -2
  17. package/dist/lib/entryBundleLib.js +12 -1
  18. package/dist/lib/fileSettleLib.js +1 -0
  19. package/dist/lib/healthLib.js +55 -33
  20. package/dist/lib/journalIndexLib.js +2 -8
  21. package/dist/lib/organizationLib.js +11 -4
  22. package/dist/lib/settingsLib.js +30 -4
  23. package/dist/lib/transcriptionBatchLib.js +338 -0
  24. package/dist/lib/transcriptionQualityLib.js +100 -0
  25. package/dist/lib/whisperLib.js +51 -11
  26. package/dist/prompts/cleanTranscription.js +16 -3
  27. package/dist/prompts/whisperInitial.js +5 -8
  28. package/dist/server.js +10 -40
  29. package/dist/settingsRoutes.js +16 -0
  30. package/dist/shared/readableParagraphs.js +36 -0
  31. package/dist/transcriptionBatchRoutes.js +30 -0
  32. package/dist/types/transcription.js +1 -1
  33. package/dist/ui/_app/immutable/assets/0.B69skhCs.css +1 -0
  34. package/dist/ui/_app/immutable/assets/2.Blx5G8xG.css +1 -0
  35. package/dist/ui/_app/immutable/chunks/{DpsvofgY.js → Bi1VM3Jw.js} +4 -4
  36. package/dist/ui/_app/immutable/chunks/C7guAji9.js +33 -0
  37. package/dist/ui/_app/immutable/chunks/CAP67d6z.js +1 -0
  38. package/dist/ui/_app/immutable/entry/app.B48KwFLP.js +2 -0
  39. package/dist/ui/_app/immutable/entry/start.Ce-kwKua.js +1 -0
  40. package/dist/ui/_app/immutable/nodes/0.CVvd3w2o.js +1 -0
  41. package/dist/ui/_app/immutable/nodes/{1.Bb0zU2Nn.js → 1.CSK_dXuX.js} +1 -1
  42. package/dist/ui/_app/immutable/nodes/2.q68umeBl.js +9 -0
  43. package/dist/ui/_app/version.json +1 -1
  44. package/dist/ui/index.html +10 -9
  45. package/docs/BARK_EXPERIMENT.md +76 -0
  46. package/package.json +8 -3
  47. package/scripts/requirements-bark.txt +8 -0
  48. package/scripts/setup-bark.ps1 +19 -0
  49. package/scripts/setup_bark.py +22 -0
  50. package/scripts/transcribe_bark.py +171 -0
  51. package/scripts/transcribe_faster_whisper.py +5 -9
  52. package/dist/ui/_app/immutable/assets/0.DxyUNkjo.css +0 -1
  53. package/dist/ui/_app/immutable/assets/2.C-1u_S-5.css +0 -1
  54. package/dist/ui/_app/immutable/chunks/BLrj1tul.js +0 -33
  55. package/dist/ui/_app/immutable/entry/app.D-B1cgpe.js +0 -2
  56. package/dist/ui/_app/immutable/entry/start.aEIENpgj.js +0 -1
  57. package/dist/ui/_app/immutable/nodes/0.D4IgEGDw.js +0 -1
  58. package/dist/ui/_app/immutable/nodes/2.RFEJlN1x.js +0 -9
package/README.md CHANGED
@@ -1,18 +1,24 @@
1
1
  <p align="center">
2
- <img src="site/static/logo.png" alt="DictaWhisper logo" width="128" />
2
+ <img src="https://dictawhisper.com/logo.png" alt="DictaWhisper logo" width="128" />
3
3
  </p>
4
4
 
5
5
  # DictaWhisper
6
6
 
7
- **A local voice journal.** Record in the browser, drop a file, or (optionally) sync a phone folder. Transcribe on your GPU with [faster-whisper](https://github.com/SYSTRAN/faster-whisper). Clean the note with [ollanet](https://ollanet.dev). The `.json` next to each recording is the journal.
7
+ **A local voice journal.** Record in the browser, drop a file, or (optionally) sync a phone folder. Transcribe locally with **Bark Pro** (English, default) or [faster-whisper](https://github.com/SYSTRAN/faster-whisper). Clean the note with [ollanet](https://ollanet.dev). The `.json` next to each recording is the journal.
8
8
 
9
- Install from a Git checkout ([below](#from-a-checkout)). Starting with 0.1.11, the npm package ships compiled JavaScript and the inbox UI. Earlier releases through 0.1.10 do not start from npm installations. Python and faster-whisper remain separate prerequisites.
9
+ Install from a Git checkout ([below](#from-a-checkout)). Starting with 0.1.11, the npm package ships compiled JavaScript and the inbox UI. Earlier releases through 0.1.10 do not start from npm installations. The selected model and its Python runtime remain separate prerequisites.
10
10
 
11
11
  **Docs:** [dictawhisper.com/docs](https://dictawhisper.com/docs) · **Site:** [dictawhisper.com](https://dictawhisper.com)
12
12
 
13
+ ## Find the note after you record it
14
+
15
+ ![DictaWhisper Inbox with a searchable library on the left and the selected Kitchen repair plan transcript, timestamps, audio controls, and MayDos tab on the right.](https://raw.githubusercontent.com/Catalyst-Forge-LLC/dictawhisper/main/site/static/screenshots/dictawhisper-inbox.jpg)
16
+
17
+ Actual Inbox showing three fictional entries. The transcript, timestamps, and action suggestions are synthetic fixture data; this capture does not demonstrate transcription or cleanup quality. Select an entry to read it beside the library, search for it later, or review its suggested actions in MayDos.
18
+
13
19
  ## Before you clone
14
20
 
15
- Exercised path: Windows, Node 22.13+ (checkout scripts use `--experimental-strip-types`), pnpm, Python with [faster-whisper](https://github.com/SYSTRAN/faster-whisper), and an NVIDIA GPU with CUDA. `ffmpeg` is required when denoise is on. macOS and Linux are unverified. CPU mode works and is slow. Cleanup via [ollanet](https://ollanet.dev) is optional.
21
+ Exercised path: Windows, Node 22.13+ (checkout scripts use `--experimental-strip-types`), pnpm, and an NVIDIA GPU with CUDA. Bark Pro uses an isolated Python 3.11 / CUDA 12.8 / BF16 runtime; Whisper uses Python with [faster-whisper](https://github.com/SYSTRAN/faster-whisper). `ffmpeg` and `ffprobe` are required for audio assessment and preparation, including unfiltered mode. macOS and Linux are unverified. Bark CPU mode is available but unverified; Whisper CPU mode is slower. Cleanup via [ollanet](https://ollanet.dev) is optional.
16
22
 
17
23
  ## From a checkout
18
24
 
@@ -21,11 +27,13 @@ git clone https://github.com/Catalyst-Forge-LLC/dictawhisper.git
21
27
  cd dictawhisper
22
28
  cp config.example.json config.json
23
29
  pnpm install
30
+ # Set up Bark Pro first (PowerShell; use your Python 3.11 path):
31
+ # & ./scripts/setup-bark.ps1 -Python C:/path/to/Python311/python.exe
24
32
  pnpm run doctor
25
33
  pnpm dev
26
34
  ```
27
35
 
28
- Ready signal: `pnpm run doctor` exits 0, or only warnings remain. Then open [http://localhost:7777](http://localhost:7777). Point `whisper.python` at the interpreter that has CUDA Whisper. Cleanup host and model are optional; raw transcripts still work.
36
+ Ready signal: `pnpm run doctor` exits 0, or only warnings remain. Then open [http://localhost:7777](http://localhost:7777). For Whisper instead, set `transcription.model` to `large-v3` or `turbo` and point `whisper.python` at its interpreter. Once running, change the default in **Settings → Transcription**. Cleanup host and model are optional; raw transcripts still work.
29
37
 
30
38
  LocalSlip is optional. `pnpm dev` and `pnpm serve` read existing `dictawhisper` and `dictawhisper-api` claims when available; they do not create claims. Without LocalSlip, the UI uses 7777 and the API uses `http.port` (8008 by default). Startup names both endpoints and gives the UI proxy the same API port. Claim ports separately if you want named assignments.
31
39
 
@@ -40,38 +48,45 @@ Hit Record, or drag an audio file onto the page. Flags, HTTP, MCP, and `retransc
40
48
 
41
49
  ## What you get
42
50
 
43
- Files are the database. Each note is the recording plus a neighboring `.json`. The Whisper model stays loaded. Audio and transcript files stay on this computer. Cleanup sends transcript text only to the host in `ollanet.machine`, and is skipped when that is empty. Search indexing embeds note text on this computer by default; it goes to another host only if you set `journal.embedHost` (`machine`, a host name, or `any`). With no embedding model on an allowed host, or with `journal.search` set to `lex`, nothing is embedded and search matches words only; doctor says which. The inbox is loopback by default; `http.tailscale` puts the same page on your tailnet. Playback follows cleaned paragraphs using Whisper word times. If cleanup is skipped or fails, the inbox shows **raw only** or **cleanup failed**, and **Retry** is available. Transcription itself still needs faster-whisper.
51
+ Files are the database. Each note is the recording plus a neighboring `.json`. Bark loads per recording; Whisper keeps a worker loaded between its jobs. Audio and transcript files stay on this computer. Cleanup sends transcript text only to the host in `ollanet.machine`, and is skipped when that is empty. Search indexing embeds note text on this computer by default; it goes to another host only if you set `journal.embedHost` (`machine`, a host name, or `any`). With no embedding model on an allowed host, or with `journal.search` set to `lex`, nothing is embedded and search matches words only; doctor says which. The inbox is loopback by default; `http.tailscale` puts the same page on your tailnet. Playback follows cleaned paragraphs using the transcription engine’s word timing estimates. If cleanup is skipped or fails, the inbox shows **raw only** or **cleanup failed**, and **Retry** is available. Transcription requires the selected engine’s local runtime.
52
+
53
+ ## Capture and Activity
54
+
55
+ Record opens the compact capture panel and requests the microphone. Stop keeps a draft in the current tab; Save recording sends it, and Discard releases it without saving. Failed saves retain playback, download, and retry. Download unsaved audio before leaving the tab if you need to keep it.
56
+
57
+ Import accepts multiple WebM, MP3, M4A, WAV, or OGG files up to 80 MiB each, with two simultaneous uploads and per-file outcomes. Saved means the server acknowledged the audio bytes; transcription and cleanup continue separately. Imports never overwrite an existing audio file or sidecar. Stable upload identities let retries reconcile an unknown previous outcome.
58
+
59
+ Activity shows actual processing stages, sync eligibility deadlines, failures with specific recovery actions, recent completions, and bulk-job progress. Open a newly saved entry there to play it before its transcript is ready. System diagnostics run when expanded or explicitly refreshed. After a server restart, files still waiting to finish syncing are rechecked automatically against their latest write time. Queued or running processing appears as Interrupted and requires explicit resume; existing transcripts remain readable.
44
60
 
45
- ## Browsing and MayDos
61
+ ## Read, search, and listen
46
62
 
47
63
  Select a card to read its full entry in the right pane. The entry list and detail pane scroll separately. On narrow screens, **Back to entries** returns to the cards. Hover over a transcript passage (or focus it with the keyboard) to copy its text; its timestamp still plays the audio.
48
64
 
49
65
  Library and search results load in pages of 50. **Load more** keeps the current reading position, and reader **Next** continues onto the next page. Word searches show an exact matching count and sort the full matching set before paging. Meaning searches label their bounded ranked results; **Search words only** switches to exhaustive word matching. If results change or a ranked snapshot expires, **Refresh results** keeps the selected entry open.
50
66
 
51
- HTTP callers can opt into the page contract with `GET /notes/search?page=1`: `items`, `total`, `countKind` (`exact` or `ranked`), `hasMore`, `nextCursor`, and actual `mode`. Ranked responses also include `candidateLimit` (500) and `expiresAt` (60 seconds). Pass `cursor` with the same query/filter/sort parameters to continue. Cursor errors return `invalid_cursor` (400) or `cursor_expired` (409); refresh by omitting the cursor. Without `page=1`, the legacy `{ hits, count }` response remains available; its `count` is the returned array length, not a corpus total. Existing MCP search keeps its array response.
67
+ ## Improve difficult recordings
52
68
 
53
- Cleanup also extracts **MayDos**: possible actions stated in the transcript, including tentative intentions. Each starts as **Suggested** and can become **Selected**, **Done**, or **Dismissed**. Its source quote and available audio timing stay with it. Re-extraction preserves IDs and your status choices, keeping decided items even when the model omits them. Filter entries by any MayDos or a particular status.
69
+ New imports automatically measure audio levels over time, then keep adequate audio unfiltered, boost quiet speech, or level uneven volume. Preparation preserves pauses and timestamps in a PCM WAV working copy. The configured model is used (default **Bark Pro**) with speech gating off to include faint speech. Sidecars record preparation, source identity, model and pipeline version. Suspect passages have listening signals; Whisper can also produce up to three short alternate passes. The reader's **Transcription details** disclosure exposes the evidence. These signals are not an accuracy score. Automatic noise reduction remains off; the manual preset is available below. Set `audio.preprocess` to false for unfiltered preparation.
54
70
 
55
- Existing entries have an **Extract MayDos** action in their detail pane. **Tools → MayDo backfill** processes the library serially using the configured cleanup host and model. Optionally restrict it to a year. It skips entries already extracted from unchanged text unless refresh is enabled, shows progress and errors, and stops after the current entry when requested. Starting it again skips completed work. Progress is held in memory; results are saved in the sidecars.
71
+ For quiet, uneven, or noisy recordings, use the reader's **More → Improve audio & retranscribe…** action. Prepare an audio preview from the original when available, switch between original and enhanced at the same position, then create a candidate with **Bark Pro**, **Whisper large-v3** or **turbo**. The action starts with your saved default. Review and accept the candidate or keep your current version. Preparation uses FFmpeg and a lossless working copy without trimming silence. Your title, stars, tags, MayDo decisions, and original recordings remain saved. Existing MayDo timings are cleared until refreshed against the new transcript. Reopen the action to check progress or undo acceptance before further transcript changes. Readable cleanup is a separate action after acceptance. See [audio improvement behavior and verification](docs/AUDIO_IMPROVEMENT.md).
56
72
 
57
- Sidecars store a `mayDos` array (ID, title, verb, source quote, audio start/end, status, and timestamps), plus `mayDoExtraction` version, source hash, model, host, and timestamp. Failures set `mayDoError` without replacing existing actions. ExoMetaCortex synchronization is a future step.
73
+ ## Retranscribe an existing library
58
74
 
59
- <!-- xfacts-label -->
75
+ Open **Settings → Transcription → Retranscribe library** (also in Data and maintenance). Select a year or all years, Bark Pro, large-v3 or turbo, and older-pipeline entries, flagged entries, or all entries. **Preview eligible entries** reads sidecars and source metadata without running models. Already-current entries and missing sources are accounted for. Prepared audio and saved versions require extra disk space.
60
76
 
61
- ## xFacts label
77
+ Choose **Review before replacing**, or explicitly choose **Replace entries without review signals** to update unflagged entries with saved previous versions while holding flagged candidates for review. Optionally queue readable text, tags and MayDos for replacements. Then Start the previewed batch. Progress and candidate review are available in **Activity**; only 50 result summaries load at a time. Stop finishes the current entry. Restart never automatically starts a saved batch: Resume continues its checkpoints and Retry failed only targets failures. Current titles, stars, tags and MayDo decisions are retained. Undo is conditional on no later transcript/cleanup changes; earlier sidecars remain saved even when automatic Undo is blocked. A first import has no earlier transcript to undo.
62
78
 
63
- - **AppFacts:** [viewer](https://appfacts.dev/v#af1.eNpVkltr3DAQhf-KmKcWtGvyqqeASaG5lFDnrZQwK0-8imVJaMbemGX_e5H31n0Tc74ZnTnSHiYwdxoCDgQGWmcFd1vHiTJokDmV6o42ClNS35rm93fQwIIyMhhAK24i0OCdpcCFffn5diRsD2YPHkM3YleUtzlRY7NLotUjTng-v86yjQE05DGIW2z8ii2tP_k_7SPjQLuYezDQTOSFnpxo9fCVMjEvN87eha7IddNoVTcNaEAHBj6QhfLqtJZW0XsMJKDBxmEYg7MoLgYwwNH2JGsX4aChpcRg_uyhKPe8XPrJVe9KZypzcwxCoVVXcwd9xOniq4AbtH3hmPJE-QLd-jqxkjHwkoyLQVHoXKBLx9V5Qb0flF0siEo5WmIuCZzh6zJHPBP6VQlY3a595u-H2JJfBn5JylGijb7itj_1v9Sv6lxWPKYUs8Dhr4bN6HxbHjuh7bGj9wEDdpTBQAppKFlmSpGdxDyDga1IYlNVnZPtuFnbOFQ1CvqZZfUj5o5Wz891dfMVD_8Ay7foKw) · [raw](https://github.com/Catalyst-Forge-LLC/dictawhisper/blob/main/APP_FACTS.md)
64
- - **ToolFacts:** [viewer](https://toolfacts.dev/v#tf1.eNrFlE1v2zAMhv-KoHM-1h2zY4YCA1JgQAfsUBSBKjG2WltyKdppEOS_76XSYB9Yz7kksfiSLx_G1NFOdnUzs8n1ZFf2a_TifraxDMTmbv3d3BNPxHZmA03UZRxDtXbiukMRc5u5IQQhKTEnhD4tbhafcVLEyVhw4LzESTVd9JSKmtx9-4Hnl5gCHno_zMvFhMckURs5WnojP8q5aJe96-YDZ0-lQCbsUhkyC2JFQsz2NLOeKRDSXVc0n-l1jDiyq4dHRKlhzUVAqKOehA9ITjlRZSsSk1O38q6XnLXOw_EymaCT2Z8nsy3k2LeKGQNtabcjLwrL5IJikEMUXrvYUcGgqNdOPcan4USyz_zy2_-djFBih_YJ_qjbD1kAZFfCI0E08pDr_O6ru5kyJmoSRMU8HQxKhjIz4hp8Zjbqrb1_MUwycipmcNIiFhwyLkKXgnGmtBimGZimSHsM8__UDclW7a7FfUsCbKRWaGXWNrzjSqbMY4qvIyKu_IPuO3KJghF6k8qs9B-CdrHItiquRLpBA7VFE5ORlsxzBobrzD4C1GesycfdM3ktes3WE-1Jv-q7uYuM37XzPOiOgQNs9W8I7mCwzA3p6yq4RezpcWbb3NPgGi3XigxltVz-Sbjwua8gsIyS6yZfdA1sxidVLC-31LzeUvPNZv1XlXqPjMljG8IZ4_QLRYvHeA) · [raw](https://github.com/Catalyst-Forge-LLC/dictawhisper/blob/main/TOOL_FACTS.md)
79
+ ## Choose the default model
65
80
 
66
- ## Capture and Activity
81
+ **Settings → Transcription → Default transcription model → Save default model** chooses Bark Pro (English), Whisper large-v3 (multilingual) or Whisper turbo. Bark Pro is the default when `transcription.model` is absent. Saving persists to the loaded config and takes effect for the next transcription without restarting. Imports, browser captures, Activity retries and the retranscribe CLI use that choice. Improvement attempts and new batches start with it; running jobs and saved batch scopes retain their selected model. `DICTA_TRANSCRIPTION_MODEL` can explicitly override and lock the choice for a process; `WHISPER_MODEL` no longer selects the default engine.
67
82
 
68
- Record opens the compact capture panel and requests the microphone. Stop keeps a draft in the current tab; Save recording sends it, and Discard releases it without saving. Failed saves retain playback, download, and retry. Download unsaved audio before leaving the tab if you need to keep it.
83
+ Bark has native estimated word and passage timing. Timed candidates—including earlier comparison-only candidates—can be accepted with **Use new audio & transcript**, followed by optional Readable cleanup or Undo. Untimed candidates remain available for listening and export but cannot replace an entry. A normal import without usable timing fails safely and retains the audio for retry; select Whisper in Settings if needed. No automatic fallback or library-wide retranscription happens when you change the default.
69
84
 
70
- Import accepts multiple WebM, MP3, M4A, WAV, or OGG files up to 80 MiB each, with two simultaneous uploads and per-file outcomes. Saved means the server acknowledged the audio bytes; transcription and cleanup continue separately. Imports never overwrite an existing audio file or sidecar. Stable upload identities let retries reconcile an unknown previous outcome.
85
+ Install once in PowerShell with `& ./scripts/setup-bark.ps1 -Python 'C:/path/to/Python311/python.exe'`, then restart DictaWhisper. The separate `.venv-bark` uses pinned PyTorch/Transformers and model weights; it does not change the Whisper runtime. The exercised setup uses NVIDIA CUDA/BF16. Full setup, limitations and test evidence: [Bark setup and behavior guide](docs/BARK_EXPERIMENT.md).
71
86
 
72
- Activity shows actual processing stages, sync eligibility deadlines, failures with specific recovery actions, recent completions, and bulk-job progress. Open a newly saved entry there to play it before its transcript is ready. System diagnostics run when expanded or explicitly refreshed. After a server restart, unfinished work appears as Interrupted and requires explicit resume; existing transcripts remain readable. Activity history is stored in an internal `.dictawhisper/activity.json` beneath the configured index's directory. Resumable bulk backfill is tracked separately in the UX upgrade spec.
87
+ ## Review MayDos and extract older entries
73
88
 
74
- ## MayDo review and backfill
89
+ Cleanup also extracts **MayDos**: possible actions stated in the transcript, including tentative intentions. Each starts as **Suggested** and can become **Selected**, **Done**, or **Dismissed**. Its source quote and available audio timing stay with it. Re-extraction preserves IDs and your status choices, keeping decided items even when the model omits them. Filter entries by any MayDos or a particular status.
75
90
 
76
91
  The MayDos view shows entries with Suggested or Selected actions by default. Cards and reader tabs distinguish active actions from all saved actions. Open the MayDos tab to Select, Mark done, Reopen, or Restore an action; More offers every status, and Undo is available for five seconds. Source quote expands to selectable text and Copy; Play source appears only when timing is grounded.
77
92
 
@@ -79,7 +94,15 @@ Extract again refreshes suggestions while retaining stable IDs and selected, com
79
94
 
80
95
  Use **MayDos → More → Extract from older entries** or **Tools** for backfill. Preview the scope and configured model, then Start. Unchanged entries are skipped by default. Activity and the MayDos view expose progress and recovery. Stop finishes the current entry; Resume continues saved scope; Retry failed processes only failures. Restarted work shows Interrupted and requires explicit Resume. Closing a panel does not stop extraction.
81
96
 
82
- API clients retain `mayDoCount` and numeric `skipped` compatibility. Summaries also expose `mayDoTotalCount`, `mayDoStatusCounts`, and `skipReasons`. `POST /tools/may-dos/backfill` accepts `action: start | stop | resume | retry_failed`, plus `id` for stale-job protection; Start accepts `year` and `refresh`. `GET /notes/may-dos/capabilities` reports configuration availability without calling the model.
97
+ Backfill scope and acknowledged outcomes are saved on disk; extracted actions remain in each entry’s sidecar. Resume continues unfinished work and retries failed entries from that saved scope. Retry failed targets failures only. If progress cannot be saved, the job stops with a storage error so you can repair it before continuing.
98
+
99
+ ## Recover entries and check settings
100
+
101
+ Reader More → Move to Trash waits for a verified whole-entry bundle receipt, then offers eight-second Undo. Settings → Data and maintenance restores later; collisions keep both. Replacement requires a preview and saves the existing bundle in Trash. Assign recording date moves all variants together and records confirmed date provenance. New entries and edits receive stable IDs; explicit legacy backfill is available in maintenance.
102
+
103
+ Settings shows effective folders, models, processing destinations, search coverage, and MCP configuration. The default transcription model is editable here and saves immediately. Edit the loaded file and restart for other configuration options. Maintenance includes scoped MayDo extraction, reviewed cleanup retries, tag merge preview/apply, and check-only audio scanning with separate reviewed marking. Help follows search, reading/listening, capture, MayDos, and recovery tasks. On small screens access Help through Settings. No ExoMetaCortex synchronization is active.
104
+
105
+ For HTTP pagination, sidecar fields, and backfill actions, see the [HTTP reference](https://dictawhisper.com/docs/http).
83
106
 
84
107
  ## Development
85
108
 
@@ -92,14 +115,15 @@ pnpm site:dev
92
115
 
93
116
  Site (FilePress + docs mount): `pnpm --dir site ship`
94
117
 
95
- ## License
118
+ <!-- xfacts-label -->
96
119
 
97
- MIT · [Catalyst Forge LLC](https://www.catalystforge.com)
120
+ ## xFacts label
98
121
 
99
- [See the rest of the Catalyst Forge shelf.](https://catalystforge.com/tools/)
122
+ - **AppFacts:** [viewer](https://appfacts.dev/v#af1.eNpVkltr3DAQhf-KmKcWtGvyqqeASaG5lFDnrZQwK0-8imVJaMbemGX_e5H31n0Tc74ZnTnSHiYwdxoCDgQGWmcFd1vHiTJokDmV6o42ClNS35rm93fQwIIyMhhAK24i0OCdpcCFffn5diRsD2YPHkM3YleUtzlRY7NLotUjTng-v86yjQE05DGIW2z8ii2tP_k_7SPjQLuYezDQTOSFnpxo9fCVMjEvN87eha7IddNoVTcNaEAHBj6QhfLqtJZW0XsMJKDBxmEYg7MoLgYwwNH2JGsX4aChpcRg_uyhKPe8XPrJVe9KZypzcwxCoVVXcwd9xOniq4AbtH3hmPJE-QLd-jqxkjHwkoyLQVHoXKBLx9V5Qb0flF0siEo5WmIuCZzh6zJHPBP6VQlY3a595u-H2JJfBn5JylGijb7itj_1v9Sv6lxWPKYUs8Dhr4bN6HxbHjuh7bGj9wEDdpTBQAppKFlmSpGdxDyDga1IYlNVnZPtuFnbOFQ1CvqZZfUj5o5Wz891dfMVD_8Ay7foKw) · [raw](https://github.com/Catalyst-Forge-LLC/dictawhisper/blob/main/APP_FACTS.md)
123
+ - **ToolFacts:** [viewer](https://toolfacts.dev/v#tf1.eNrFlE1v2zAMhv-KoHM-1h2zY4YCA1JgQAfsUBSBKjG2WltyKdppEOS_76XSYB9Yz7kksfiSLx_G1NFOdnUzs8n1ZFf2a_TifraxDMTmbv3d3BNPxHZmA03UZRxDtXbiukMRc5u5IQQhKTEnhD4tbhafcVLEyVhw4LzESTVd9JSKmtx9-4Hnl5gCHno_zMvFhMckURs5WnojP8q5aJe96-YDZ0-lQCbsUhkyC2JFQsz2NLOeKRDSXVc0n-l1jDiyq4dHRKlhzUVAqKOehA9ITjlRZSsSk1O38q6XnLXOw_EymaCT2Z8nsy3k2LeKGQNtabcjLwrL5IJikEMUXrvYUcGgqNdOPcan4USyz_zy2_-djFBih_YJ_qjbD1kAZFfCI0E08pDr_O6ru5kyJmoSRMU8HQxKhjIz4hp8Zjbqrb1_MUwycipmcNIiFhwyLkKXgnGmtBimGZimSHsM8__UDclW7a7FfUsCbKRWaGXWNrzjSqbMY4qvIyKu_IPuO3KJghF6k8qs9B-CdrHItiquRLpBA7VFE5ORlsxzBobrzD4C1GesycfdM3ktes3WE-1Jv-q7uYuM37XzPOiOgQNs9W8I7mCwzA3p6yq4RezpcWbb3NPgGi3XigxltVz-Sbjwua8gsIyS6yZfdA1sxidVLC-31LzeUvPNZv1XlXqPjMljG8IZ4_QLRYvHeA) · [raw](https://github.com/Catalyst-Forge-LLC/dictawhisper/blob/main/TOOL_FACTS.md)
100
124
 
101
- ## Recovery and effective settings
125
+ ## License
102
126
 
103
- Reader More → Move to Trash waits for a verified whole-entry bundle receipt, then offers eight-second Undo. Settings → Data and maintenance restores later; collisions keep both. Replacement requires a preview and saves the existing bundle in Trash. Assign recording date moves all variants together and records confirmed date provenance. New entries and edits receive stable IDs; explicit legacy backfill is available in maintenance.
127
+ MIT · [Catalyst Forge LLC](https://www.catalystforge.com)
104
128
 
105
- Settings shows effective folders, models, processing destinations, search coverage, and MCP configuration. Configuration is read-only here: edit the loaded file and restart. Maintenance includes scoped MayDo extraction, reviewed cleanup retries, tag merge preview/apply, and check-only audio scanning with separate reviewed marking. Help follows search, reading/listening, capture, MayDos, and recovery tasks. On small screens access Help through Settings. No ExoMetaCortex synchronization is active.
129
+ [See the rest of the Catalyst Forge shelf.](https://catalystforge.com/tools/)
@@ -13,6 +13,9 @@
13
13
  "recursiveYears": true,
14
14
  "createMissingRoots": false
15
15
  },
16
+ "transcription": {
17
+ "model": "bark-pro"
18
+ },
16
19
  "whisper": {
17
20
  "python": "python",
18
21
  "model": "large-v3",
package/dist/apiRoutes.js CHANGED
@@ -1,4 +1,7 @@
1
1
  import { ollanetIsConfigured } from "./lib/ollanetReadyLib.js";
2
+ import { audioRescueRoutes } from "./audioRescueRoutes.js";
3
+ import { createTranscriptionBatchRoutes } from "./transcriptionBatchRoutes.js";
4
+ import { getTranscriptionBatch } from "./lib/transcriptionBatchLib.js";
2
5
  import multer from 'multer';
3
6
  import { process, countStatus, getTranscriptionFilename, listNoteSummaries, patchTranscription, readTranscription, skipCleanup, emitNotesIndex } from "./lib/transcriptionLib.js";
4
7
  import { journalStats, journalTags, journalYears, listInboxNotes, notesIndexPayload, searchJournalIndex, searchJournalPage, } from "./lib/journalService.js";
@@ -23,6 +26,7 @@ import { BundleError, listTrash, moveToTrash, previewRestore, restoreTrash, purg
23
26
  import { organizationPreview, organizeEntry } from "./lib/entryOrganizationLib.js";
24
27
  import { backfillEntryIds } from "./lib/entryIdentityLib.js";
25
28
  import { effectiveSettings } from "./lib/settingsLib.js";
29
+ import { createTranscriptionSettingsRoutes } from "./settingsRoutes.js";
26
30
  import { rebuildJournalIndex } from "./lib/journalService.js";
27
31
  const upload = multer({
28
32
  storage: multer.memoryStorage(),
@@ -52,6 +56,9 @@ function requireAllowedFile(req, res) {
52
56
  return allowed.path;
53
57
  }
54
58
  export const apiRoutes = [
59
+ ...createTranscriptionSettingsRoutes(),
60
+ ...audioRescueRoutes,
61
+ ...createTranscriptionBatchRoutes(),
55
62
  { path: '/tools/probe/mark', method: 'POST', handler: async (req, res) => { try {
56
63
  res.json(await markReviewedAudio(req.body?.files));
57
64
  }
@@ -110,7 +117,7 @@ export const apiRoutes = [
110
117
  res.json({ found: Boolean(item), item });
111
118
  } },
112
119
  { path: '/notes/activity', method: 'GET', handler: (_req, res) => {
113
- res.json({ ...activity.snapshot(), mayDoBackfill: getMayDoBackfill(), queues: { transcription: { queued: q.transcription?.length() || 0, running: q.transcription?.running() || 0 }, cleanup: { queued: q.processing?.length() || 0, running: q.processing?.running() || 0 } } });
120
+ res.json({ ...activity.snapshot(), mayDoBackfill: getMayDoBackfill(), transcriptionBatch: getTranscriptionBatch().summary(), queues: { transcription: { queued: q.transcription?.length() || 0, running: q.transcription?.running() || 0 }, cleanup: { queued: q.processing?.length() || 0, running: q.processing?.running() || 0 } } });
114
121
  } },
115
122
  { path: '/notes/activity/retry', method: 'POST', handler: async (req, res) => {
116
123
  const item = activity.byId(String(req.body?.id || ''));
@@ -187,6 +194,7 @@ export const apiRoutes = [
187
194
  host: config.http.host,
188
195
  port: config.http.port,
189
196
  whisper: resolveWhisperModel(),
197
+ transcription: report.transcription,
190
198
  whisperWorker: report.whisper.worker,
191
199
  device: report.whisper.device,
192
200
  ollanet: {
@@ -0,0 +1,61 @@
1
+ import { audioRescue } from "./lib/audioRescueService.js";
2
+ import { audioContentType } from "./lib/audioLib.js";
3
+ const failure = (res, error) => {
4
+ const message = error instanceof Error ? error.message : String(error);
5
+ res.status(message.includes('outside configured watch roots') ? 403 : 409).json({ error: message });
6
+ };
7
+ export function createAudioRescueRoutes(service = audioRescue) {
8
+ return [
9
+ { path: '/notes/audio-improvement/summaries', method: 'POST', handler: (req, res) => {
10
+ const files = req.body?.files;
11
+ if (!Array.isArray(files) || files.length > 100 || files.some(file => typeof file !== 'string' || file.length > 4096)) {
12
+ res.status(400).json({ error: 'Choose up to 100 entry paths.' });
13
+ return;
14
+ }
15
+ res.setHeader('Cache-Control', 'private, no-store');
16
+ res.json({ items: [...new Set(files)].map(file => {
17
+ try {
18
+ return { file, ...service.summary(file) };
19
+ }
20
+ catch {
21
+ return { file, error: 'Version status is unavailable.' };
22
+ }
23
+ }) });
24
+ } },
25
+ { path: '/notes/audio-improvement', method: 'GET', handler: (req, res) => {
26
+ try {
27
+ res.json(service.status(String(req.query.file || ''), typeof req.query.id === 'string' ? req.query.id : undefined));
28
+ }
29
+ catch (error) {
30
+ failure(res, error);
31
+ }
32
+ } },
33
+ { path: '/notes/audio-improvement/audio', method: 'GET', handler: (req, res) => {
34
+ try {
35
+ const audio = service.audio(String(req.query.file || ''), String(req.query.id || ''), String(req.query.variant || ''));
36
+ res.setHeader('Content-Type', audioContentType(audio.path));
37
+ res.setHeader('Cache-Control', 'private, no-store');
38
+ res.sendFile(audio.relative, { root: audio.root, dotfiles: 'allow' });
39
+ }
40
+ catch (error) {
41
+ failure(res, error);
42
+ }
43
+ } },
44
+ ...['prepare', 'transcribe', 'apply', 'restore', 'discard'].map(action => ({
45
+ path: `/notes/audio-improvement/${action}`, method: 'POST', handler: async (req, res) => {
46
+ try {
47
+ const file = String(req.body?.file || ''), id = String(req.body?.id || '');
48
+ const result = action === 'prepare' ? service.prepare(file, req.body || {})
49
+ : action === 'transcribe' ? service.transcribe(file, id)
50
+ : action === 'apply' ? await service.apply(file, id, req.body?.reviewToken)
51
+ : action === 'restore' ? await service.restore(file, id) : service.discard(file, id);
52
+ res.status(['prepare', 'transcribe'].includes(action) ? 202 : 200).json(result);
53
+ }
54
+ catch (error) {
55
+ failure(res, error);
56
+ }
57
+ },
58
+ })),
59
+ ];
60
+ }
61
+ export const audioRescueRoutes = createAudioRescueRoutes();
package/dist/cli.js CHANGED
@@ -28,7 +28,7 @@ Config (first hit wins):
28
28
  ./config.json
29
29
  ~/.dictawhisper/config.json
30
30
 
31
- Requires Node 22.13+ and a Python with faster-whisper. See dictawhisper.com
31
+ Requires Node 22.13+ and a local Bark Pro or faster-whisper runtime. See dictawhisper.com
32
32
  `;
33
33
  }
34
34
  function fail(message, code = 1) {
@@ -62,7 +62,7 @@ function init(home) {
62
62
  mkdirSync(path.dirname(dest), { recursive: true });
63
63
  copyFileSync(exampleConfig, dest);
64
64
  console.log(`Wrote ${dest}`);
65
- console.log('Edit whisper.python, then: dictawhisper doctor');
65
+ console.log('Install Bark Pro (scripts/setup-bark.ps1), or set transcription.model to large-v3/turbo and configure whisper.python. Then: dictawhisper doctor');
66
66
  }
67
67
  function runNode(script, extraEnv) {
68
68
  const nodeArgs = script.endsWith('.ts') ? ['--experimental-strip-types', script] : [script];
@@ -80,7 +80,7 @@ function runNode(script, extraEnv) {
80
80
  function start() {
81
81
  const configFile = resolveExistingConfig();
82
82
  if (!configFile) {
83
- fail('No config.json. Run: dictawhisper init\nThen edit whisper.python and run: dictawhisper doctor');
83
+ fail('No config.json. Run: dictawhisper init\nThen set up the selected transcription model and run: dictawhisper doctor');
84
84
  }
85
85
  if (!existsSync(uiDir)) {
86
86
  console.warn(`[ui] no packaged inbox at ${uiDir} — API only. In a checkout: pnpm --dir client build`);
package/dist/config.js CHANGED
@@ -4,6 +4,7 @@ import path from 'path';
4
4
  import { z } from 'zod';
5
5
  import { localslipGet } from "./lib/localslipGet.js";
6
6
  import { resolveApiListenPort } from "./lib/devPorts.js";
7
+ import { transcriptionModels } from "./lib/asrModels.js";
7
8
  export { resolveApiListenPort } from "./lib/devPorts.js";
8
9
  export function resolveConfigPath(env = process.env, cwd = process.cwd(), home = os.homedir()) {
9
10
  const fromEnv = env.DICTA_CONFIG?.trim();
@@ -79,6 +80,7 @@ export const dictaConfigFileSchema = z
79
80
  http: httpSchema,
80
81
  watch: watchSchema,
81
82
  whisper: whisperSchema,
83
+ transcription: z.object({ model: z.enum(transcriptionModels).default('bark-pro') }).default({}),
82
84
  audio: z.object({ preprocess: z.boolean().default(true) }).default({}),
83
85
  queues: queuesSchema,
84
86
  ollanet: ollanetSchema,
@@ -116,6 +118,7 @@ export function loadConfig(configPath = resolveConfigPath()) {
116
118
  const parsed = dictaConfigFileSchema.parse(readJson(configPath));
117
119
  const configDir = path.dirname(path.resolve(configPath));
118
120
  const config = {
121
+ transcription: { model: z.enum(transcriptionModels).parse(process.env.DICTA_TRANSCRIPTION_MODEL?.trim() || parsed.transcription.model) },
119
122
  http: {
120
123
  host: process.env.HOST?.trim() || parsed.http.host,
121
124
  port: resolveListenPort(process.env.PORT, localslipGet('dictawhisper'), localslipGet('dictawhisper-api'), parsed.http.port, servePackagedUi(), process.env.DICTA_UI_PORT),
@@ -5,7 +5,7 @@ const unfinished = new Set(['waiting_for_file', 'queued_transcription', 'transcr
5
5
  export const isWorkingStage = (stage) => unfinished.has(stage);
6
6
  export const isFailureStage = (stage) => stage.startsWith('failed_') || stage === 'interrupted';
7
7
  const fileKey = (file) => process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file);
8
- /** Durable summaries, never a queue replay. Restarted work requires explicit recovery. */
8
+ /** Durable summaries, never a queue replay. Watchers recheck waiting files; interrupted processing requires explicit recovery. */
9
9
  export class ActivityStore {
10
10
  items = new Map();
11
11
  emit = () => { };
@@ -23,7 +23,17 @@ export class ActivityStore {
23
23
  for (const item of saved.items) {
24
24
  if (!item.id || !item.jsonFile || !item.audioFile)
25
25
  continue;
26
- if (unfinished.has(item.stage)) {
26
+ if (item.stage === 'waiting_for_file' || item.stage === 'interrupted' && item.resumeStage === 'waiting_for_file') {
27
+ // No model work started. Let the watcher reconstruct the timer from
28
+ // the current mtime, including waits stranded by older versions.
29
+ item.stage = 'waiting_for_file';
30
+ delete item.resumeStage;
31
+ delete item.error;
32
+ delete item.eligibleAt;
33
+ item.reason = 'Checking file stability after restart.';
34
+ item.updatedAt = Date.now();
35
+ }
36
+ else if (unfinished.has(item.stage)) {
27
37
  item.resumeStage = item.stage;
28
38
  item.stage = 'interrupted';
29
39
  item.error = 'Server stopped before processing finished. Resume explicitly.';
@@ -47,7 +57,7 @@ export class ActivityStore {
47
57
  const jsonFile = file.replace(/\.[^.\\/]+$/, '.json');
48
58
  const item = { id: old?.id || randomUUID(), audioFile: old?.audioFile || file,
49
59
  jsonFile: old?.jsonFile || jsonFile, createdAt: old?.createdAt || Date.now(), hasTranscript: old?.hasTranscript || false,
50
- ...old, eligibleAt: undefined, error: undefined, reason: undefined, ...patch, stage, updatedAt: Date.now() };
60
+ ...old, eligibleAt: undefined, error: undefined, reason: undefined, resumeStage: undefined, ...patch, stage, updatedAt: Date.now() };
51
61
  this.items.set(item.id, item);
52
62
  const completed = [...this.items.values()].filter(row => !unfinished.has(row.stage) && !isFailureStage(row.stage)).sort((a, b) => b.updatedAt - a.updatedAt);
53
63
  for (const row of completed.slice(200))
@@ -1,16 +1,6 @@
1
+ import { readableParagraphs as splitCleanedSections } from "../shared/readableParagraphs.js";
2
+ export { readableParagraphs as splitCleanedSections } from "../shared/readableParagraphs.js";
1
3
  const FILLERS = new Set(['uh', 'um', 'umm', 'hmm', 'yeah', 'like']);
2
- export function splitCleanedSections(cleaned) {
3
- const trimmed = String(cleaned || '').trim();
4
- if (!trimmed)
5
- return [];
6
- const paras = trimmed.split(/\n{2,}/).map((part) => part.trim()).filter(Boolean);
7
- if (paras.length >= 2)
8
- return paras;
9
- const sentences = trimmed.split(/(?<=[.!?])\s+/).map((part) => part.trim()).filter(Boolean);
10
- if (sentences.length >= 2)
11
- return sentences;
12
- return [trimmed];
13
- }
14
4
  function normWord(text) {
15
5
  return String(text || '')
16
6
  .toLowerCase()
@@ -101,13 +91,16 @@ export function ensurePlaybackCues(json) {
101
91
  const sections = cleaned ? splitCleanedSections(cleaned) : [];
102
92
  if (words.length && cleaned && sections.length) {
103
93
  const existing = json.playbackCues;
94
+ // Text can change without the paragraph count changing, and a new ASR pass
95
+ // can move word timestamps without changing text. Validate both.
96
+ const aligned = alignCleanedToWords(cleaned, words);
104
97
  if (json.playbackCuesSource === 'words' &&
105
98
  Array.isArray(existing) &&
106
- existing.length === sections.length &&
107
- existing.every((cue) => cue && typeof cue.text === 'string')) {
99
+ existing.length === aligned.length &&
100
+ existing.every((cue, index) => cue && cue.text === aligned[index].text && cue.start === aligned[index].start && cue.end === aligned[index].end)) {
108
101
  return false;
109
102
  }
110
- json.playbackCues = alignCleanedToWords(cleaned, words);
103
+ json.playbackCues = aligned;
111
104
  json.playbackCuesSource = 'words';
112
105
  return true;
113
106
  }
@@ -0,0 +1,7 @@
1
+ export const transcriptionModels = ['bark-pro', 'large-v3', 'turbo'];
2
+ export function isTranscriptionModel(model) {
3
+ return transcriptionModels.includes(model);
4
+ }
5
+ export function asrModelLabel(model) {
6
+ return model === 'bark-pro' ? 'Bark Pro' : `Whisper ${model}`;
7
+ }
@@ -0,0 +1,93 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { probeAudioFile } from "./audioLib.js";
3
+ const db = (value) => Math.max(-120, 20 * Math.log10(Math.max(value, 1e-6)));
4
+ const rounded = (n) => Math.round(n * 100) / 100;
5
+ const percentile = (sorted, p) => sorted[Math.floor((sorted.length - 1) * p)] ?? -120;
6
+ /** One-second energy windows, not speech recognition or an intelligibility score. */
7
+ export function assessWindows(windows, peak, channelEnergy, frames, nearFullScale) {
8
+ const sorted = windows.slice().sort((a, b) => a - b);
9
+ const threshold = Math.max(-65, percentile(sorted, .9) - 35);
10
+ const active = sorted.filter(level => level > threshold);
11
+ const median = percentile(active, .5), spread = percentile(active, .9) - percentile(active, .1);
12
+ const mean = db(Math.sqrt(channelEnergy.reduce((sum, n) => sum + n, 0) / Math.max(1, frames * channelEnergy.length)));
13
+ const channelDb = channelEnergy.map(sum => db(Math.sqrt(sum / Math.max(1, frames))));
14
+ const channelDifference = Math.max(...channelDb) - Math.min(...channelDb);
15
+ const peakDb = db(peak), warnings = [], reasons = [];
16
+ let preset = 'original';
17
+ if (peakDb < -65 || !active.length)
18
+ warnings.push('Very little signal was detected. Amplification cannot recover speech that was not captured.');
19
+ else if (spread >= 14 && percentile(active, .1) < -30) {
20
+ preset = 'uneven';
21
+ reasons.push('Audible levels vary substantially across the recording.');
22
+ }
23
+ else if (median < -28) {
24
+ preset = peakDb < -4 ? 'quiet' : 'uneven';
25
+ reasons.push(preset === 'quiet' ? 'Consistently low levels have room for a clean gain increase.' : 'Quiet passages and loud peaks need different gain.');
26
+ }
27
+ else
28
+ reasons.push('Levels are adequate; keep the signal unfiltered.');
29
+ if (channelDifference > 18)
30
+ warnings.push('The decoded channels have very different levels. Listen for a missing or weak channel.');
31
+ if (nearFullScale / Math.max(1, frames * channelEnergy.length) > .001)
32
+ warnings.push('Frequent near-full-scale samples may indicate distortion. Normalization cannot repair clipping.');
33
+ const ranges = [];
34
+ windows.forEach((level, second) => {
35
+ if (level <= threshold)
36
+ return;
37
+ const last = ranges.at(-1);
38
+ if (last && last.end === second)
39
+ last.end = second + 1;
40
+ else
41
+ ranges.push({ start: second, end: second + 1 });
42
+ });
43
+ return { version: 1, durationSec: frames / 16000, meanDb: rounded(mean), peakDb: rounded(peakDb), activeMedianDb: rounded(median), activeSpreadDb: rounded(spread), channelDifferenceDb: rounded(channelDifference), nearFullScaleFraction: nearFullScale / Math.max(1, frames * channelEnergy.length), energeticRanges: ranges.slice(0, 2000), suggestedPreset: preset, reasons, warnings };
44
+ }
45
+ /** Decode to a bounded streaming PCM buffer; retain only one number per second. */
46
+ export async function assessAudio(file) {
47
+ const probe = await probeAudioFile(file);
48
+ if (!probe.ok)
49
+ throw new Error('Audio assessment failed: ' + probe.reason);
50
+ const channels = probe.channels || 1;
51
+ if (!Number.isInteger(channels) || channels < 1 || channels > 32)
52
+ throw new Error('Unsupported audio channel count.');
53
+ return new Promise((resolve, reject) => {
54
+ const child = spawn('ffmpeg', ['-v', 'error', '-nostdin', '-i', file, '-map', '0:a:0', '-vn', '-ar', '16000', '-ac', String(channels), '-f', 'f32le', 'pipe:1'], { windowsHide: true });
55
+ let tail = Buffer.alloc(0), stderr = '', frames = 0, peak = 0, near = 0, windowEnergy = 0, windowFrames = 0, timedOut = false;
56
+ const energy = Array(channels).fill(0), windows = [];
57
+ const timer = setTimeout(() => { timedOut = true; child.kill(); }, 30 * 60_000);
58
+ child.stderr.on('data', data => { stderr = (stderr + data).slice(-800); });
59
+ child.stdout.on('data', (chunk) => {
60
+ const buffer = tail.length ? Buffer.concat([tail, chunk]) : chunk;
61
+ const end = buffer.length - buffer.length % (channels * 4);
62
+ for (let i = 0; i < end; i += channels * 4) {
63
+ for (let channel = 0; channel < channels; channel++) {
64
+ const sample = buffer.readFloatLE(i + channel * 4), square = sample * sample;
65
+ energy[channel] += square;
66
+ windowEnergy += square;
67
+ peak = Math.max(peak, Math.abs(sample));
68
+ if (Math.abs(sample) >= .999)
69
+ near++;
70
+ }
71
+ frames++;
72
+ windowFrames++;
73
+ if (windowFrames === 16000) {
74
+ windows.push(db(Math.sqrt(windowEnergy / (windowFrames * channels))));
75
+ windowEnergy = 0;
76
+ windowFrames = 0;
77
+ }
78
+ }
79
+ tail = Buffer.from(buffer.subarray(end));
80
+ });
81
+ child.on('error', error => { clearTimeout(timer); reject(error); });
82
+ child.on('close', code => {
83
+ clearTimeout(timer);
84
+ if (timedOut || code !== 0 || !frames) {
85
+ reject(new Error(`Audio assessment failed: ${timedOut ? 'timed out' : stderr || 'no decoded samples'}`));
86
+ return;
87
+ }
88
+ if (windowFrames)
89
+ windows.push(db(Math.sqrt(windowEnergy / (windowFrames * channels))));
90
+ resolve(assessWindows(windows, peak, energy, frames, near));
91
+ });
92
+ });
93
+ }
@@ -0,0 +1,71 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { audioExtensions, probeAudioFile } from "./audioLib.js";
5
+ import { assessAudio } from "./audioAssessmentLib.js";
6
+ export const enhancementPresets = ['auto', 'quiet', 'uneven', 'noise', 'original'];
7
+ /** Originals may have a different container from the working MP3. Never enhance an enhancement. */
8
+ export function findOriginalAudio(jsonFile) {
9
+ const base = jsonFile.replace(/\.json$/i, '');
10
+ for (const suffix of ['_original', ''])
11
+ for (const ext of audioExtensions) {
12
+ const file = `${base}${suffix}.${ext}`;
13
+ if (fs.existsSync(file))
14
+ return { file, original: suffix === '_original' };
15
+ }
16
+ return null;
17
+ }
18
+ export function ffmpeg(args) {
19
+ return new Promise((resolve, reject) => {
20
+ const child = spawn('ffmpeg', ['-hide_banner', '-nostdin', ...args], { windowsHide: true });
21
+ let output = '', timedOut = false;
22
+ const timer = setTimeout(() => { timedOut = true; child.kill(); }, 30 * 60_000);
23
+ child.stderr.on('data', data => { output = (output + data.toString()).slice(-65536); });
24
+ child.on('error', error => { clearTimeout(timer); reject(error); });
25
+ child.on('close', code => {
26
+ clearTimeout(timer);
27
+ if (timedOut)
28
+ reject(new Error('Audio preparation timed out. Your current entry is unchanged.'));
29
+ else if (code !== 0)
30
+ reject(new Error(`Audio preparation failed: ${output.slice(-800)}`));
31
+ else
32
+ resolve(output);
33
+ });
34
+ });
35
+ }
36
+ export async function measureAudio(file) {
37
+ const probe = await probeAudioFile(file);
38
+ if (!probe.ok)
39
+ throw new Error(`Cannot read source audio: ${probe.reason}`);
40
+ const log = await ffmpeg(['-i', file, '-vn', '-af', 'volumedetect', '-f', 'null', '-']);
41
+ const meanDb = Number(log.match(/mean_volume:\s*(-?[\d.]+) dB/)?.[1]);
42
+ const peakDb = Number(log.match(/max_volume:\s*(-?[\d.]+) dB/)?.[1]);
43
+ if (!Number.isFinite(meanDb) || !Number.isFinite(peakDb))
44
+ throw new Error('Could not measure audio levels.');
45
+ return { meanDb, peakDb, durationSec: probe.durationSec };
46
+ }
47
+ export function enhancementFilter(preset, levels) {
48
+ const gainDb = Math.max(0, Math.min(30, -22 - levels.meanDb, -1 - levels.peakDb));
49
+ const leveling = 'dynaudnorm=f=250:g=15:p=0.89:m=10';
50
+ const filter = preset === 'quiet' ? `volume=${gainDb.toFixed(3)}dB`
51
+ : preset === 'uneven' ? leveling
52
+ : preset === 'noise' ? `highpass=f=80,afftdn=nr=8:nf=-50:tn=1,${leveling}` : null;
53
+ return { filter, gainDb: preset === 'quiet' ? gainDb : 0 };
54
+ }
55
+ /** PCM output: no silence removal, time stretching, MP3 encoding, or source writes. */
56
+ export async function enhanceAudio(source, output, preset) {
57
+ if (!enhancementPresets.includes(preset))
58
+ throw new Error('Unknown audio preset.');
59
+ if (path.resolve(source) === path.resolve(output) || fs.existsSync(output))
60
+ throw new Error('Audio output must be a new file.');
61
+ const assessment = await assessAudio(source);
62
+ const levels = assessment;
63
+ const selectedPreset = preset === 'auto' ? assessment.suggestedPreset : preset;
64
+ const { filter, gainDb } = enhancementFilter(selectedPreset, { ...levels, meanDb: assessment.activeMedianDb });
65
+ await ffmpeg(['-n', '-i', source, '-map', '0:a:0', '-vn', ...(filter ? ['-af', filter] : []), '-ar', '16000', '-c:a', 'pcm_s16le', output]);
66
+ const result = await probeAudioFile(output);
67
+ if (!result.ok || Math.abs(result.durationSec - levels.durationSec) > 0.15)
68
+ throw new Error('Prepared audio did not preserve the recording duration.');
69
+ return { meanDb: levels.meanDb, peakDb: levels.peakDb, durationSec: levels.durationSec, gainDb, selectedPreset, assessment, suggestedPreset: assessment.suggestedPreset,
70
+ warning: assessment.warnings.join(' ') || (selectedPreset === 'quiet' && gainDb < 1 && levels.meanDb < -30 ? 'Loud peaks limit the boost. Try Uneven volume to lift quieter passages.' : undefined) };
71
+ }