dictawhisper 0.1.15 → 0.1.17
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/README.md +31 -7
- package/config.example.json +3 -0
- package/dist/apiRoutes.js +9 -1
- package/dist/audioRescueRoutes.js +61 -0
- package/dist/cli.js +3 -3
- package/dist/config.js +3 -0
- package/dist/lib/activityLib.js +13 -3
- package/dist/lib/alignLib.js +8 -15
- package/dist/lib/asrModels.js +7 -0
- package/dist/lib/audioAssessmentLib.js +93 -0
- package/dist/lib/audioEnhancementLib.js +71 -0
- package/dist/lib/audioImprovementStorageLib.js +47 -0
- package/dist/lib/audioLib.js +13 -3
- package/dist/lib/audioRescueService.js +485 -0
- package/dist/lib/barkLib.js +48 -0
- package/dist/lib/cleanupProvenanceLib.js +3 -2
- package/dist/lib/entryBundleLib.js +12 -1
- package/dist/lib/fileSettleLib.js +1 -0
- package/dist/lib/healthLib.js +55 -33
- package/dist/lib/journalIndexLib.js +2 -8
- package/dist/lib/organizationLib.js +11 -4
- package/dist/lib/settingsLib.js +30 -4
- package/dist/lib/transcriptionBatchLib.js +338 -0
- package/dist/lib/transcriptionQualityLib.js +100 -0
- package/dist/lib/whisperLib.js +51 -11
- package/dist/prompts/cleanTranscription.js +16 -3
- package/dist/prompts/whisperInitial.js +5 -8
- package/dist/server.js +10 -40
- package/dist/settingsRoutes.js +16 -0
- package/dist/shared/readableParagraphs.js +36 -0
- package/dist/transcriptionBatchRoutes.js +30 -0
- package/dist/types/transcription.js +1 -1
- package/dist/ui/_app/immutable/assets/0.B69skhCs.css +1 -0
- package/dist/ui/_app/immutable/assets/2.Blx5G8xG.css +1 -0
- package/dist/ui/_app/immutable/chunks/C7guAji9.js +33 -0
- package/dist/ui/_app/immutable/chunks/CAP67d6z.js +1 -0
- package/dist/ui/_app/immutable/chunks/{DbZVNsp6.js → ChDcFRoo.js} +4 -4
- package/dist/ui/_app/immutable/entry/app.EDzSoVMa.js +2 -0
- package/dist/ui/_app/immutable/entry/start.vlDKPlW_.js +1 -0
- package/dist/ui/_app/immutable/nodes/0.CVvd3w2o.js +1 -0
- package/dist/ui/_app/immutable/nodes/{1.DOPh7-3l.js → 1.Bgbu9jg4.js} +1 -1
- package/dist/ui/_app/immutable/nodes/2.q68umeBl.js +9 -0
- package/dist/ui/_app/version.json +1 -1
- package/dist/ui/index.html +10 -9
- package/docs/BARK_EXPERIMENT.md +76 -0
- package/package.json +8 -3
- package/scripts/requirements-bark.txt +8 -0
- package/scripts/setup-bark.ps1 +19 -0
- package/scripts/setup_bark.py +22 -0
- package/scripts/transcribe_bark.py +171 -0
- package/scripts/transcribe_faster_whisper.py +5 -9
- package/dist/ui/_app/immutable/assets/0.DxyUNkjo.css +0 -1
- package/dist/ui/_app/immutable/assets/2.C-1u_S-5.css +0 -1
- package/dist/ui/_app/immutable/chunks/BLrj1tul.js +0 -33
- package/dist/ui/_app/immutable/entry/app.sjUO6MdQ.js +0 -2
- package/dist/ui/_app/immutable/entry/start.BEHhOYkx.js +0 -1
- package/dist/ui/_app/immutable/nodes/0.D4IgEGDw.js +0 -1
- package/dist/ui/_app/immutable/nodes/2.RFEJlN1x.js +0 -9
package/README.md
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
# DictaWhisper
|
|
6
6
|
|
|
7
|
-
**A
|
|
7
|
+
**A searchable voice journal on your computer.** This local web app lets you record or import voice notes, find them later, read the transcript, and replay the recording. Optional cleanup and MayDos help you turn a note into readable text and suggested actions to review.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Transcribe locally with **Bark Pro** (English, default) or [faster-whisper](https://github.com/SYSTRAN/faster-whisper). Cleanup uses [ollanet](https://ollanet.dev) with the host you configure. Each recording and its neighboring `.json` form a journal entry; a synced phone folder is optional.
|
|
10
|
+
|
|
11
|
+
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
12
|
|
|
11
13
|
**Docs:** [dictawhisper.com/docs](https://dictawhisper.com/docs) · **Site:** [dictawhisper.com](https://dictawhisper.com)
|
|
12
14
|
|
|
@@ -18,7 +20,7 @@ Actual Inbox showing three fictional entries. The transcript, timestamps, and ac
|
|
|
18
20
|
|
|
19
21
|
## Before you clone
|
|
20
22
|
|
|
21
|
-
Exercised path: Windows, Node 22.13+ (checkout scripts use `--experimental-strip-types`), pnpm, Python with [faster-whisper](https://github.com/SYSTRAN/faster-whisper)
|
|
23
|
+
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.
|
|
22
24
|
|
|
23
25
|
## From a checkout
|
|
24
26
|
|
|
@@ -27,11 +29,13 @@ git clone https://github.com/Catalyst-Forge-LLC/dictawhisper.git
|
|
|
27
29
|
cd dictawhisper
|
|
28
30
|
cp config.example.json config.json
|
|
29
31
|
pnpm install
|
|
32
|
+
# Set up Bark Pro first (PowerShell; use your Python 3.11 path):
|
|
33
|
+
# & ./scripts/setup-bark.ps1 -Python C:/path/to/Python311/python.exe
|
|
30
34
|
pnpm run doctor
|
|
31
35
|
pnpm dev
|
|
32
36
|
```
|
|
33
37
|
|
|
34
|
-
Ready signal: `pnpm run doctor` exits 0, or only warnings remain. Then open [http://localhost:7777](http://localhost:7777).
|
|
38
|
+
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.
|
|
35
39
|
|
|
36
40
|
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.
|
|
37
41
|
|
|
@@ -46,7 +50,7 @@ Hit Record, or drag an audio file onto the page. Flags, HTTP, MCP, and `retransc
|
|
|
46
50
|
|
|
47
51
|
## What you get
|
|
48
52
|
|
|
49
|
-
Files are the database. Each note is the recording plus a neighboring `.json`.
|
|
53
|
+
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.
|
|
50
54
|
|
|
51
55
|
## Capture and Activity
|
|
52
56
|
|
|
@@ -54,7 +58,7 @@ Record opens the compact capture panel and requests the microphone. Stop keeps a
|
|
|
54
58
|
|
|
55
59
|
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.
|
|
56
60
|
|
|
57
|
-
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,
|
|
61
|
+
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.
|
|
58
62
|
|
|
59
63
|
## Read, search, and listen
|
|
60
64
|
|
|
@@ -62,6 +66,26 @@ Select a card to read its full entry in the right pane. The entry list and detai
|
|
|
62
66
|
|
|
63
67
|
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.
|
|
64
68
|
|
|
69
|
+
## Improve difficult recordings
|
|
70
|
+
|
|
71
|
+
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.
|
|
72
|
+
|
|
73
|
+
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).
|
|
74
|
+
|
|
75
|
+
## Retranscribe an existing library
|
|
76
|
+
|
|
77
|
+
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.
|
|
78
|
+
|
|
79
|
+
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.
|
|
80
|
+
|
|
81
|
+
## Choose the default model
|
|
82
|
+
|
|
83
|
+
**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.
|
|
84
|
+
|
|
85
|
+
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.
|
|
86
|
+
|
|
87
|
+
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).
|
|
88
|
+
|
|
65
89
|
## Review MayDos and extract older entries
|
|
66
90
|
|
|
67
91
|
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.
|
|
@@ -78,7 +102,7 @@ Backfill scope and acknowledged outcomes are saved on disk; extracted actions re
|
|
|
78
102
|
|
|
79
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.
|
|
80
104
|
|
|
81
|
-
Settings shows effective folders, models, processing destinations, search coverage, and MCP configuration.
|
|
105
|
+
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.
|
|
82
106
|
|
|
83
107
|
For HTTP pagination, sidecar fields, and backfill actions, see the [HTTP reference](https://dictawhisper.com/docs/http).
|
|
84
108
|
|
package/config.example.json
CHANGED
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
|
|
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('
|
|
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
|
|
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),
|
package/dist/lib/activityLib.js
CHANGED
|
@@ -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.
|
|
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 (
|
|
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))
|
package/dist/lib/alignLib.js
CHANGED
|
@@ -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 ===
|
|
107
|
-
existing.every((cue) => cue &&
|
|
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 =
|
|
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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { allowedRoots, resolveAllowedPath } from "./pathAllowLib.js";
|
|
4
|
+
/** Called only by confirmed permanent Trash deletion. Validate every target before unlinking. */
|
|
5
|
+
export function purgeAudioImprovementHistory(entryId) {
|
|
6
|
+
const files = [], directories = [];
|
|
7
|
+
function exact(file) {
|
|
8
|
+
const allowed = resolveAllowedPath(file);
|
|
9
|
+
if (!allowed.ok)
|
|
10
|
+
throw new Error(allowed.error);
|
|
11
|
+
const canonical = (value) => process.platform === 'win32' ? path.resolve(value).toLowerCase() : path.resolve(value);
|
|
12
|
+
if (canonical(allowed.path) !== canonical(file))
|
|
13
|
+
throw new Error('Audio improvement storage redirects elsewhere. Saved files were kept.');
|
|
14
|
+
return allowed.path;
|
|
15
|
+
}
|
|
16
|
+
for (const root of [...new Set(allowedRoots())]) {
|
|
17
|
+
const base = exact(path.join(root, '.dictawhisper', 'audio-improvements'));
|
|
18
|
+
if (!fs.existsSync(base))
|
|
19
|
+
continue;
|
|
20
|
+
for (const id of fs.readdirSync(base)) {
|
|
21
|
+
if (!/^[a-f0-9-]{36}$/.test(id))
|
|
22
|
+
continue;
|
|
23
|
+
const directory = exact(path.join(base, id)), manifest = exact(path.join(directory, 'job.json'));
|
|
24
|
+
if (!fs.existsSync(manifest))
|
|
25
|
+
continue;
|
|
26
|
+
const job = JSON.parse(fs.readFileSync(manifest, 'utf8'));
|
|
27
|
+
if (job.entryId !== entryId)
|
|
28
|
+
continue;
|
|
29
|
+
if (job.id !== id || !/^\.(webm|mp3|m4a|wav|ogg)$/i.test(job.sourceExt))
|
|
30
|
+
throw new Error('Invalid audio improvement manifest. Saved files were kept.');
|
|
31
|
+
for (const name of ['source' + job.sourceExt, 'enhanced.wav', 'candidate.json', 'previous.json', 'job.json']) {
|
|
32
|
+
const file = exact(path.join(directory, name));
|
|
33
|
+
if (fs.existsSync(file)) {
|
|
34
|
+
if (!fs.statSync(file).isFile())
|
|
35
|
+
throw new Error('Unexpected audio improvement artifact.');
|
|
36
|
+
files.push(file);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
directories.push(directory);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
for (const file of files)
|
|
43
|
+
fs.unlinkSync(file);
|
|
44
|
+
for (const directory of directories)
|
|
45
|
+
if (!fs.readdirSync(directory).length)
|
|
46
|
+
fs.rmdirSync(directory);
|
|
47
|
+
}
|
package/dist/lib/audioLib.js
CHANGED
|
@@ -46,6 +46,15 @@ export function audioContentType(filePath) {
|
|
|
46
46
|
export function findAudioForSidecar(jsonFile) {
|
|
47
47
|
const parsed = path.parse(jsonFile);
|
|
48
48
|
const base = path.join(parsed.dir, parsed.name);
|
|
49
|
+
try {
|
|
50
|
+
const id = JSON.parse(fs.readFileSync(jsonFile, 'utf8'))?.audioEnhancement?.versionId;
|
|
51
|
+
if (typeof id === 'string' && /^[a-f0-9-]{36}$/.test(id)) {
|
|
52
|
+
const enhanced = `${base}_enhanced-${id}.wav`;
|
|
53
|
+
// Missing enhanced audio must not silently play an older, differently timed file.
|
|
54
|
+
return fs.existsSync(enhanced) ? enhanced : null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch { /* Legacy sidecars have no enhanced version. */ }
|
|
49
58
|
for (const ext of audioExtensions) {
|
|
50
59
|
const candidate = `${base}.${ext}`;
|
|
51
60
|
if (fs.existsSync(candidate))
|
|
@@ -112,7 +121,7 @@ export function probeAudioFile(filePath) {
|
|
|
112
121
|
return Promise.resolve({ ok: false, reason: `too small (${size} bytes)`, size });
|
|
113
122
|
}
|
|
114
123
|
return new Promise((resolve) => {
|
|
115
|
-
const child = spawn('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'json', filePath], { windowsHide: true });
|
|
124
|
+
const child = spawn('ffprobe', ['-v', 'error', '-select_streams', 'a:0', '-show_entries', 'format=duration:stream=channels', '-of', 'json', filePath], { windowsHide: true });
|
|
116
125
|
let stdout = '';
|
|
117
126
|
let stderr = '';
|
|
118
127
|
const timer = setTimeout(() => {
|
|
@@ -141,12 +150,13 @@ export function probeAudioFile(filePath) {
|
|
|
141
150
|
return;
|
|
142
151
|
}
|
|
143
152
|
try {
|
|
144
|
-
const
|
|
153
|
+
const parsed = JSON.parse(stdout || '{}');
|
|
154
|
+
const duration = Number(parsed?.format?.duration);
|
|
145
155
|
if (!Number.isFinite(duration) || duration <= 0) {
|
|
146
156
|
resolve({ ok: false, reason: 'no duration', size });
|
|
147
157
|
return;
|
|
148
158
|
}
|
|
149
|
-
resolve({ ok: true, reason: `${duration.toFixed(1)}s`, durationSec: duration, size });
|
|
159
|
+
resolve({ ok: true, reason: `${duration.toFixed(1)}s`, durationSec: duration, channels: Number(parsed.streams?.[0]?.channels) || 1, size });
|
|
150
160
|
}
|
|
151
161
|
catch {
|
|
152
162
|
resolve({ ok: false, reason: 'ffprobe json', size });
|