dictawhisper 0.0.13 → 0.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/bin/dictawhisper.mjs +14 -0
- package/config.example.json +41 -0
- package/dist/ui/_app/immutable/assets/0.C6M3gHo0.css +1 -0
- package/dist/ui/_app/immutable/assets/2.C96vgj_4.css +1 -0
- package/dist/ui/_app/immutable/chunks/BSs9MmST.js +3 -0
- package/dist/ui/_app/immutable/chunks/Bjy-W4x2.js +81 -0
- package/dist/ui/_app/immutable/chunks/BslYtab-.js +1 -0
- package/dist/ui/_app/immutable/chunks/xihTtKlq.js +1 -0
- package/dist/ui/_app/immutable/entry/app.BJlRju4c.js +2 -0
- package/dist/ui/_app/immutable/entry/start.C5XBbXTT.js +1 -0
- package/dist/ui/_app/immutable/nodes/0.D23pkrHr.js +1 -0
- package/dist/ui/_app/immutable/nodes/1.C5T-U4uV.js +1 -0
- package/dist/ui/_app/immutable/nodes/2.59uAq_ko.js +5 -0
- package/dist/ui/_app/version.json +1 -0
- package/dist/ui/favicon.png +0 -0
- package/dist/ui/favicon.svg +3 -0
- package/dist/ui/index.html +38 -0
- package/dist/ui/logo.png +0 -0
- package/package.json +12 -3
- package/scripts/transcribe_faster_whisper.py +428 -0
- package/src/apiRoutes.ts +464 -0
- package/src/classes/Queue.ts +77 -0
- package/src/classes/Watcher.ts +117 -0
- package/src/cli.ts +132 -0
- package/src/config.ts +238 -0
- package/src/dev.ts +63 -0
- package/src/doctor.ts +12 -0
- package/src/lib/alignLib.ts +154 -0
- package/src/lib/audioLib.ts +202 -0
- package/src/lib/audioProbeLib.ts +112 -0
- package/src/lib/cleanupProvenanceLib.ts +80 -0
- package/src/lib/downloadLib.ts +8 -0
- package/src/lib/fileSettleLib.ts +213 -0
- package/src/lib/fsLib.ts +37 -0
- package/src/lib/healthLib.ts +377 -0
- package/src/lib/inboxUrl.ts +123 -0
- package/src/lib/journalEmbedLib.ts +127 -0
- package/src/lib/journalIndexLib.ts +923 -0
- package/src/lib/journalQueryLib.ts +269 -0
- package/src/lib/journalService.ts +287 -0
- package/src/lib/jsonLib.ts +71 -0
- package/src/lib/localberthGet.ts +14 -0
- package/src/lib/ollanetLib.ts +117 -0
- package/src/lib/ollanetReadyLib.ts +7 -0
- package/src/lib/organizationLib.ts +182 -0
- package/src/lib/pathAllowLib.ts +60 -0
- package/src/lib/queueLib.ts +30 -0
- package/src/lib/speechCleanupLib.ts +14 -0
- package/src/lib/tagConsolidateLib.ts +431 -0
- package/src/lib/tailscaleLib.ts +78 -0
- package/src/lib/transcriptionLib.ts +484 -0
- package/src/lib/whisperLib.ts +409 -0
- package/src/mcp.ts +151 -0
- package/src/prompts/cleanTranscription.ts +62 -0
- package/src/prompts/consolidateTags.ts +38 -0
- package/src/prompts/whisperInitial.ts +11 -0
- package/src/server.ts +220 -0
- package/src/socketEvents.ts +72 -0
- package/src/types/transcription.ts +56 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { config } from '../config.ts';
|
|
6
|
+
|
|
7
|
+
export const audioExtensions = ['webm', 'mp3', 'm4a', 'wav', 'ogg'];
|
|
8
|
+
|
|
9
|
+
export const audioFileRegex = new RegExp(`\\.(${audioExtensions.join('|')})$`, 'i');
|
|
10
|
+
|
|
11
|
+
const AUDIO_STAMP =
|
|
12
|
+
/^(?:MTIME_)?(\d{4})-(\d{2})-(\d{2})(?:[_ T](\d{2})[-:](\d{2})[-:](\d{2}))?(Z)?/;
|
|
13
|
+
|
|
14
|
+
/** Recording time from the basename, or mtime if the name is not dated. */
|
|
15
|
+
export function audioRecencyMs(filePath: string): number {
|
|
16
|
+
const match = path.basename(filePath).match(AUDIO_STAMP);
|
|
17
|
+
if (match) {
|
|
18
|
+
const [, year, month, day, hour, minute, second, z] = match;
|
|
19
|
+
const y = Number(year);
|
|
20
|
+
const mo = Number(month) - 1;
|
|
21
|
+
const d = Number(day);
|
|
22
|
+
const h = Number(hour || 0);
|
|
23
|
+
const mi = Number(minute || 0);
|
|
24
|
+
const s = Number(second || 0);
|
|
25
|
+
const ms = z ? Date.UTC(y, mo, d, h, mi, s) : new Date(y, mo, d, h, mi, s).getTime();
|
|
26
|
+
if (!Number.isNaN(ms)) return ms;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
return fs.statSync(filePath).mtimeMs;
|
|
30
|
+
} catch {
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function compareAudioNewestFirst(a: string, b: string): number {
|
|
36
|
+
return audioRecencyMs(b) - audioRecencyMs(a);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const AUDIO_TYPES: Record<string, string> = {
|
|
40
|
+
'.mp3': 'audio/mpeg',
|
|
41
|
+
'.webm': 'audio/webm',
|
|
42
|
+
'.m4a': 'audio/mp4',
|
|
43
|
+
'.wav': 'audio/wav',
|
|
44
|
+
'.ogg': 'audio/ogg',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function audioContentType(filePath: string): string {
|
|
48
|
+
return AUDIO_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Working audio next to a sidecar JSON (not `_original` / `_clean`). */
|
|
52
|
+
export function findAudioForSidecar(jsonFile: string): string | null {
|
|
53
|
+
const parsed = path.parse(jsonFile);
|
|
54
|
+
const base = path.join(parsed.dir, parsed.name);
|
|
55
|
+
for (const ext of audioExtensions) {
|
|
56
|
+
const candidate = `${base}.${ext}`;
|
|
57
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
58
|
+
}
|
|
59
|
+
for (const ext of audioExtensions) {
|
|
60
|
+
const candidate = `${base}_original.${ext}`;
|
|
61
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function saveAudioFile(dataUrl: string, clipName: string): string {
|
|
67
|
+
const folder = config.watch.browserDropFolder;
|
|
68
|
+
fs.mkdirSync(folder, { recursive: true });
|
|
69
|
+
const safeName = (clipName || 'voice-recording').replace(/[<>:"/\\|?*]/g, '-');
|
|
70
|
+
const filePath = path.join(folder, `${safeName}.webm`);
|
|
71
|
+
const data = dataUrl.replace(/data:.*?;base64,/i, '');
|
|
72
|
+
console.log('[save-audio-file] Saving Audio File', { filePath, dataLength: data.length });
|
|
73
|
+
fs.writeFileSync(filePath, Buffer.from(data, 'base64'));
|
|
74
|
+
return filePath;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function saveUploadedAudio(buffer: Buffer, originalName: string, clipName?: string): string {
|
|
78
|
+
const folder = config.watch.browserDropFolder;
|
|
79
|
+
fs.mkdirSync(folder, { recursive: true });
|
|
80
|
+
const ext = path.extname(originalName || '').toLowerCase() || '.webm';
|
|
81
|
+
const safeExt = audioExtensions.includes(ext.slice(1)) ? ext : '.webm';
|
|
82
|
+
const base = (clipName || path.basename(originalName || 'voice-recording', ext) || 'voice-recording').replace(
|
|
83
|
+
/[<>:"/\\|?*]/g,
|
|
84
|
+
'-'
|
|
85
|
+
);
|
|
86
|
+
const filePath = path.join(folder, `${base}${safeExt}`);
|
|
87
|
+
fs.writeFileSync(filePath, buffer);
|
|
88
|
+
console.log('[save-audio-file] saved upload', { filePath, bytes: buffer.length });
|
|
89
|
+
return filePath;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type AudioProbe = {
|
|
93
|
+
ok: boolean;
|
|
94
|
+
reason: string;
|
|
95
|
+
durationSec?: number;
|
|
96
|
+
size: number;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const MIN_AUDIO_BYTES = 512;
|
|
100
|
+
|
|
101
|
+
/** Cheap ffprobe: reject stubs, empty files, and containers with no duration. */
|
|
102
|
+
export function probeAudioFile(filePath: string): AudioProbe {
|
|
103
|
+
if (!fs.existsSync(filePath)) return { ok: false, reason: 'missing', size: 0 };
|
|
104
|
+
const size = fs.statSync(filePath).size;
|
|
105
|
+
if (size < MIN_AUDIO_BYTES) return { ok: false, reason: `too small (${size} bytes)`, size };
|
|
106
|
+
const result = spawnSync(
|
|
107
|
+
'ffprobe',
|
|
108
|
+
['-v', 'error', '-show_entries', 'format=duration', '-of', 'json', filePath],
|
|
109
|
+
{ encoding: 'utf8', timeout: 15_000, windowsHide: true },
|
|
110
|
+
);
|
|
111
|
+
if (result.status !== 0) {
|
|
112
|
+
const line = String(result.stderr || result.stdout || 'ffprobe failed')
|
|
113
|
+
.trim()
|
|
114
|
+
.split(/\r?\n/)
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.pop();
|
|
117
|
+
return { ok: false, reason: line || 'ffprobe failed', size };
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const duration = Number(JSON.parse(result.stdout || '{}')?.format?.duration);
|
|
121
|
+
if (!Number.isFinite(duration) || duration <= 0) {
|
|
122
|
+
return { ok: false, reason: 'no duration', size };
|
|
123
|
+
}
|
|
124
|
+
return { ok: true, reason: `${duration.toFixed(1)}s`, durationSec: duration, size };
|
|
125
|
+
} catch {
|
|
126
|
+
return { ok: false, reason: 'ffprobe json', size };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function unlinkIfExists(filePath: string): void {
|
|
131
|
+
try {
|
|
132
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
133
|
+
} catch {
|
|
134
|
+
// leftover cleanup is best-effort
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function runFfmpeg(args: string[]): Promise<void> {
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
const child = spawn('ffmpeg', args, { windowsHide: true });
|
|
141
|
+
let stderr = '';
|
|
142
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
143
|
+
stderr += chunk.toString();
|
|
144
|
+
});
|
|
145
|
+
child.on('error', reject);
|
|
146
|
+
child.on('close', (code) => {
|
|
147
|
+
if (code === 0) {
|
|
148
|
+
resolve();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(-400)}`));
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Denoise to a working `.mp3`. Keeps `*_original` with the source bytes.
|
|
158
|
+
* Returns the path Whisper should read (may differ from `file` if the ext changed).
|
|
159
|
+
*/
|
|
160
|
+
export async function cleanAudioFile(file: string, cleanAgain = false): Promise<string> {
|
|
161
|
+
const parsed = path.parse(file);
|
|
162
|
+
const base = path.join(parsed.dir, parsed.name.replace(/_original$/i, ''));
|
|
163
|
+
const workingMp3 = `${base}.mp3`;
|
|
164
|
+
const originalFile = `${base}_original${parsed.ext}`;
|
|
165
|
+
const tmpMp3 = path.join(os.tmpdir(), `dicta-clean-${Date.now()}-${path.basename(base)}.mp3`);
|
|
166
|
+
|
|
167
|
+
unlinkIfExists(`${base}_clean${parsed.ext}`);
|
|
168
|
+
unlinkIfExists(`${base}_clean.mp3`);
|
|
169
|
+
|
|
170
|
+
if (fs.existsSync(originalFile) && fs.existsSync(workingMp3) && !cleanAgain) {
|
|
171
|
+
return workingMp3;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const source = fs.existsSync(originalFile) && cleanAgain ? originalFile : file;
|
|
175
|
+
const probe = probeAudioFile(source);
|
|
176
|
+
if (!probe.ok) {
|
|
177
|
+
throw new Error(`unreadable audio: ${probe.reason}`);
|
|
178
|
+
}
|
|
179
|
+
await runFfmpeg([
|
|
180
|
+
'-y',
|
|
181
|
+
'-i',
|
|
182
|
+
source,
|
|
183
|
+
'-af',
|
|
184
|
+
'silenceremove=1:0:-50dB,afftdn=nr=15:nf=-40,highpass=f=200,volume=1.5',
|
|
185
|
+
'-c:a',
|
|
186
|
+
'libmp3lame',
|
|
187
|
+
'-b:a',
|
|
188
|
+
'128k',
|
|
189
|
+
tmpMp3,
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
if (!fs.existsSync(originalFile)) {
|
|
193
|
+
fs.copyFileSync(file, originalFile);
|
|
194
|
+
}
|
|
195
|
+
fs.copyFileSync(tmpMp3, workingMp3);
|
|
196
|
+
unlinkIfExists(tmpMp3);
|
|
197
|
+
if (path.resolve(file) !== path.resolve(workingMp3)) {
|
|
198
|
+
unlinkIfExists(file);
|
|
199
|
+
}
|
|
200
|
+
console.log(`[ffmpeg] cleaned working file ${workingMp3}`);
|
|
201
|
+
return workingMp3;
|
|
202
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { audioFileRegex, probeAudioFile } from './audioLib.ts';
|
|
4
|
+
import { isSkippedWatchPath } from './fileSettleLib.ts';
|
|
5
|
+
import { checkTranscription, emitNotesIndex, recordAudioFailure } from './transcriptionLib.ts';
|
|
6
|
+
|
|
7
|
+
export type ProbeHit = { file: string; reason: string };
|
|
8
|
+
|
|
9
|
+
export type ProbeReport = {
|
|
10
|
+
audio: number;
|
|
11
|
+
pending: number;
|
|
12
|
+
bad: number;
|
|
13
|
+
marked: number;
|
|
14
|
+
files: ProbeHit[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ProbeJob = {
|
|
18
|
+
running: boolean;
|
|
19
|
+
apply: boolean;
|
|
20
|
+
startedAt: string | null;
|
|
21
|
+
finishedAt: string | null;
|
|
22
|
+
scanned: number;
|
|
23
|
+
error: string | null;
|
|
24
|
+
} & ProbeReport;
|
|
25
|
+
|
|
26
|
+
const idleJob = (): ProbeJob => ({
|
|
27
|
+
running: false,
|
|
28
|
+
apply: false,
|
|
29
|
+
startedAt: null,
|
|
30
|
+
finishedAt: null,
|
|
31
|
+
scanned: 0,
|
|
32
|
+
error: null,
|
|
33
|
+
audio: 0,
|
|
34
|
+
pending: 0,
|
|
35
|
+
bad: 0,
|
|
36
|
+
marked: 0,
|
|
37
|
+
files: [],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
let job: ProbeJob = idleJob();
|
|
41
|
+
|
|
42
|
+
function walkAudio(dir: string, out: string[]) {
|
|
43
|
+
if (!fs.existsSync(dir)) return;
|
|
44
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
45
|
+
const full = path.join(dir, entry.name);
|
|
46
|
+
if (entry.isDirectory()) {
|
|
47
|
+
if (isSkippedWatchPath(full)) continue;
|
|
48
|
+
walkAudio(full, out);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!audioFileRegex.test(full)) continue;
|
|
52
|
+
if (full.includes('_original') || full.includes('_clean')) continue;
|
|
53
|
+
out.push(full);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function listWatchAudio(roots: string[]): string[] {
|
|
58
|
+
const files: string[] = [];
|
|
59
|
+
for (const root of roots) walkAudio(root, files);
|
|
60
|
+
return files;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function scanPendingAudio(roots: string[], options: { apply?: boolean } = {}): ProbeReport {
|
|
64
|
+
const apply = Boolean(options.apply);
|
|
65
|
+
const files = listWatchAudio(roots);
|
|
66
|
+
const hits: ProbeHit[] = [];
|
|
67
|
+
let pending = 0;
|
|
68
|
+
let marked = 0;
|
|
69
|
+
for (const file of files) {
|
|
70
|
+
const { isProcessed, transcriptionFile } = checkTranscription(file);
|
|
71
|
+
if (isProcessed) continue;
|
|
72
|
+
pending += 1;
|
|
73
|
+
const probe = probeAudioFile(file);
|
|
74
|
+
if (probe.ok) continue;
|
|
75
|
+
hits.push({ file, reason: probe.reason });
|
|
76
|
+
if (!apply) continue;
|
|
77
|
+
recordAudioFailure(transcriptionFile, new Error(`unreadable audio: ${probe.reason}`));
|
|
78
|
+
marked += 1;
|
|
79
|
+
}
|
|
80
|
+
if (apply && marked) emitNotesIndex();
|
|
81
|
+
return { audio: files.length, pending, bad: hits.length, marked, files: hits };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getProbeJob(): ProbeJob {
|
|
85
|
+
return { ...job, files: job.files.slice() };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function startProbeJob(roots: string[], options: { apply?: boolean } = {}): ProbeJob {
|
|
89
|
+
if (job.running) return getProbeJob();
|
|
90
|
+
const apply = Boolean(options.apply);
|
|
91
|
+
job = { ...idleJob(), running: true, apply, startedAt: new Date().toISOString() };
|
|
92
|
+
setImmediate(() => {
|
|
93
|
+
try {
|
|
94
|
+
const report = scanPendingAudio(roots, { apply });
|
|
95
|
+
job = {
|
|
96
|
+
...job,
|
|
97
|
+
...report,
|
|
98
|
+
running: false,
|
|
99
|
+
finishedAt: new Date().toISOString(),
|
|
100
|
+
scanned: report.pending,
|
|
101
|
+
};
|
|
102
|
+
} catch (error) {
|
|
103
|
+
job = {
|
|
104
|
+
...job,
|
|
105
|
+
running: false,
|
|
106
|
+
finishedAt: new Date().toISOString(),
|
|
107
|
+
error: error instanceof Error ? error.message : String(error),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
return getProbeJob();
|
|
112
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import type { CleanupRecord, TranscriptionDocument } from '../types/transcription.ts';
|
|
5
|
+
|
|
6
|
+
export const CLEANUP_HISTORY_CAP = 20;
|
|
7
|
+
|
|
8
|
+
let cachedVersion = '';
|
|
9
|
+
|
|
10
|
+
export function dictawhisperVersion(): string {
|
|
11
|
+
if (cachedVersion) return cachedVersion;
|
|
12
|
+
try {
|
|
13
|
+
const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
14
|
+
const version = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version;
|
|
15
|
+
cachedVersion = typeof version === 'string' && version.trim() ? version.trim() : '0.0.0';
|
|
16
|
+
} catch {
|
|
17
|
+
cachedVersion = '0.0.0';
|
|
18
|
+
}
|
|
19
|
+
return cachedVersion;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function asRecord(value: unknown): CleanupRecord | null {
|
|
23
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
24
|
+
const row = value as CleanupRecord;
|
|
25
|
+
const text = String(row.text || '').trim();
|
|
26
|
+
if (!text) return null;
|
|
27
|
+
const out: CleanupRecord = { text };
|
|
28
|
+
if (typeof row.createdAt === 'string' && row.createdAt.trim()) out.createdAt = row.createdAt;
|
|
29
|
+
if (typeof row.model === 'string' && row.model.trim()) out.model = row.model;
|
|
30
|
+
if (typeof row.host === 'string' && row.host.trim()) out.host = row.host;
|
|
31
|
+
if (typeof row.promptVersion === 'number' && Number.isFinite(row.promptVersion)) {
|
|
32
|
+
out.promptVersion = row.promptVersion;
|
|
33
|
+
}
|
|
34
|
+
if (typeof row.dictawhisperVersion === 'string' && row.dictawhisperVersion.trim()) {
|
|
35
|
+
out.dictawhisperVersion = row.dictawhisperVersion;
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Current structured record, or a stub from legacy `cleanedTranscription` + `meta`. */
|
|
41
|
+
export function currentCleanupRecord(json: TranscriptionDocument): CleanupRecord | null {
|
|
42
|
+
const stored = asRecord(json.cleanup);
|
|
43
|
+
if (stored) return stored;
|
|
44
|
+
const text = String(json.cleanedTranscription || '').trim();
|
|
45
|
+
if (!text) return null;
|
|
46
|
+
const meta = json.meta && typeof json.meta === 'object' ? json.meta : {};
|
|
47
|
+
const stub: CleanupRecord = { text };
|
|
48
|
+
if (typeof meta.model === 'string' && meta.model.trim()) stub.model = meta.model;
|
|
49
|
+
if (typeof meta.machine === 'string' && meta.machine.trim()) stub.host = meta.machine;
|
|
50
|
+
if (typeof json.promptVersion === 'number' && Number.isFinite(json.promptVersion)) {
|
|
51
|
+
stub.promptVersion = json.promptVersion;
|
|
52
|
+
}
|
|
53
|
+
return stub;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function applyCleanupProvenance(
|
|
57
|
+
json: TranscriptionDocument,
|
|
58
|
+
next: CleanupRecord
|
|
59
|
+
): CleanupRecord {
|
|
60
|
+
const previous = currentCleanupRecord(json);
|
|
61
|
+
if (previous) {
|
|
62
|
+
const history = [previous, ...(Array.isArray(json.cleanupHistory) ? json.cleanupHistory : [])]
|
|
63
|
+
.map(asRecord)
|
|
64
|
+
.filter((row): row is CleanupRecord => Boolean(row))
|
|
65
|
+
.slice(0, CLEANUP_HISTORY_CAP);
|
|
66
|
+
json.cleanupHistory = history;
|
|
67
|
+
}
|
|
68
|
+
const cleanup: CleanupRecord = {
|
|
69
|
+
text: next.text,
|
|
70
|
+
createdAt: next.createdAt || new Date().toISOString(),
|
|
71
|
+
promptVersion: next.promptVersion,
|
|
72
|
+
dictawhisperVersion: next.dictawhisperVersion || dictawhisperVersion(),
|
|
73
|
+
};
|
|
74
|
+
if (next.model) cleanup.model = next.model;
|
|
75
|
+
if (next.host) cleanup.host = next.host;
|
|
76
|
+
json.cleanup = cleanup;
|
|
77
|
+
json.cleanedTranscription = next.text;
|
|
78
|
+
if (typeof next.promptVersion === 'number') json.promptVersion = next.promptVersion;
|
|
79
|
+
return cleanup;
|
|
80
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
export function contentDisposition(filePath: string, kind: 'inline' | 'attachment' = 'inline'): string {
|
|
4
|
+
const base = path.basename(filePath).replace(/[\r\n"]/g, '_');
|
|
5
|
+
const fallback = base.replace(/[^\x20-\x7E]/g, '_');
|
|
6
|
+
const encoded = encodeURIComponent(base).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
7
|
+
return `${kind}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
|
|
8
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { config } from '../config.ts';
|
|
4
|
+
|
|
5
|
+
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
6
|
+
const started = new Set<string>();
|
|
7
|
+
|
|
8
|
+
const RETRY_SLACK_MS = 2000;
|
|
9
|
+
|
|
10
|
+
export function getSettleMs(explicit?: number): number {
|
|
11
|
+
if (typeof explicit === 'number' && Number.isFinite(explicit) && explicit >= 0) return explicit;
|
|
12
|
+
const msRaw = process.env.VOICE_SETTLE_MS?.trim();
|
|
13
|
+
if (msRaw) {
|
|
14
|
+
const parsed = Number(msRaw);
|
|
15
|
+
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
|
16
|
+
}
|
|
17
|
+
return config.watch.settleMinutes * 60_000;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatDuration(ms: number): string {
|
|
21
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
22
|
+
const seconds = Math.round(ms / 1000);
|
|
23
|
+
if (seconds < 90) return `${seconds}s`;
|
|
24
|
+
const minutes = Math.round(seconds / 60);
|
|
25
|
+
if (minutes < 90) return `${minutes}m`;
|
|
26
|
+
const hours = (minutes / 60).toFixed(1);
|
|
27
|
+
return `${hours}h`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SKIP_FOLDERS = new Set(['__inbox']);
|
|
31
|
+
|
|
32
|
+
/** Syncthing / incomplete-transfer names we should never organize or transcribe. */
|
|
33
|
+
export function isTransientSyncFile(filePath: string): boolean {
|
|
34
|
+
const name = path.basename(filePath);
|
|
35
|
+
const lower = filePath.replace(/\\/g, '/').toLowerCase();
|
|
36
|
+
return (
|
|
37
|
+
name.includes('sync-conflict') ||
|
|
38
|
+
name.includes('~syncthing~') ||
|
|
39
|
+
name.startsWith('.syncthing.') ||
|
|
40
|
+
name.endsWith('.tmp') ||
|
|
41
|
+
lower.includes('/.stfolder') ||
|
|
42
|
+
lower.includes('/.stversions/')
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Staging folders that stay on disk but are out of the pipeline for now. */
|
|
47
|
+
export function isSkippedWatchFolder(filePath: string): boolean {
|
|
48
|
+
return filePath
|
|
49
|
+
.replace(/\\/g, '/')
|
|
50
|
+
.split('/')
|
|
51
|
+
.some((part) => SKIP_FOLDERS.has(part.toLowerCase()));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isSkippedWatchPath(filePath: string): boolean {
|
|
55
|
+
return isTransientSyncFile(filePath) || isSkippedWatchFolder(filePath);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type FileReadiness = {
|
|
59
|
+
ready: boolean;
|
|
60
|
+
reason: string;
|
|
61
|
+
ageMs: number;
|
|
62
|
+
waitMs: number;
|
|
63
|
+
size: number;
|
|
64
|
+
mtime: Date | null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function inspectFileReadiness(filePath: string, settleMs: number = getSettleMs()): FileReadiness {
|
|
68
|
+
if (isSkippedWatchPath(filePath)) {
|
|
69
|
+
return {
|
|
70
|
+
ready: false,
|
|
71
|
+
reason: 'skipped folder',
|
|
72
|
+
ageMs: 0,
|
|
73
|
+
waitMs: 0,
|
|
74
|
+
size: 0,
|
|
75
|
+
mtime: null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (!fs.existsSync(filePath)) {
|
|
79
|
+
return { ready: false, reason: 'missing', ageMs: 0, waitMs: 0, size: 0, mtime: null };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const stats = fs.statSync(filePath);
|
|
83
|
+
if (!stats.isFile()) {
|
|
84
|
+
return { ready: false, reason: 'not a file', ageMs: 0, waitMs: 0, size: 0, mtime: null };
|
|
85
|
+
}
|
|
86
|
+
if (stats.size <= 0) {
|
|
87
|
+
return {
|
|
88
|
+
ready: false,
|
|
89
|
+
reason: 'empty',
|
|
90
|
+
ageMs: 0,
|
|
91
|
+
waitMs: Math.max(settleMs, 10_000),
|
|
92
|
+
size: 0,
|
|
93
|
+
mtime: stats.mtime,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const ageMs = Date.now() - stats.mtimeMs;
|
|
98
|
+
if (settleMs > 0 && ageMs < settleMs) {
|
|
99
|
+
return {
|
|
100
|
+
ready: false,
|
|
101
|
+
reason: `mtime only ${formatDuration(ageMs)} old (need ${formatDuration(settleMs)} since last write)`,
|
|
102
|
+
ageMs,
|
|
103
|
+
waitMs: settleMs - ageMs + RETRY_SLACK_MS,
|
|
104
|
+
size: stats.size,
|
|
105
|
+
mtime: stats.mtime,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
ready: true,
|
|
111
|
+
reason: settleMs === 0 ? 'settle disabled' : `mtime stable for ${formatDuration(ageMs)}`,
|
|
112
|
+
ageMs,
|
|
113
|
+
waitMs: 0,
|
|
114
|
+
size: stats.size,
|
|
115
|
+
mtime: stats.mtime,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function fileKey(filePath: string): string {
|
|
120
|
+
return path.normalize(filePath);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type SettleOptions = {
|
|
124
|
+
/** Skip the mtime wait. Does not re-run if this file already started this process. */
|
|
125
|
+
force?: boolean;
|
|
126
|
+
/** Skip the wait and allow a file that already ran this process to run again. */
|
|
127
|
+
retry?: boolean;
|
|
128
|
+
label?: string;
|
|
129
|
+
settleMs?: number;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Run `action` only after the file has not been written for VOICE_SETTLE_MINUTES
|
|
134
|
+
* (default 30). Syncthing updates mtime while a phone recording is still syncing,
|
|
135
|
+
* so this waits until the last write — not until first appearance.
|
|
136
|
+
*
|
|
137
|
+
* Pass `{ force: true }` to skip the wait. Pass `{ retry: true }` to skip the wait
|
|
138
|
+
* and clear the in-process "already started" latch. Reschedules if the file is written again.
|
|
139
|
+
*/
|
|
140
|
+
export function requestWhenSettled(
|
|
141
|
+
filePath: string,
|
|
142
|
+
action: () => void | Promise<void>,
|
|
143
|
+
options: SettleOptions = {}
|
|
144
|
+
): void {
|
|
145
|
+
const label = options.label ?? 'settle';
|
|
146
|
+
const key = `${label}:${fileKey(filePath)}`;
|
|
147
|
+
const settleMs = options.settleMs ?? getSettleMs();
|
|
148
|
+
|
|
149
|
+
const clearTimer = () => {
|
|
150
|
+
const timer = pendingTimers.get(key);
|
|
151
|
+
if (timer) {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
pendingTimers.delete(key);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const run = () => {
|
|
158
|
+
if (started.has(key)) return;
|
|
159
|
+
started.add(key);
|
|
160
|
+
Promise.resolve(action()).catch((error) => {
|
|
161
|
+
started.delete(key);
|
|
162
|
+
console.error('[settle] action failed', label, filePath, error);
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
if (options.retry) {
|
|
167
|
+
started.delete(key);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (options.force || options.retry) {
|
|
171
|
+
clearTimer();
|
|
172
|
+
console.log(
|
|
173
|
+
`[${label}] ${options.retry ? 'retry' : 'forced'}; skipping settle for ${filePath}`
|
|
174
|
+
);
|
|
175
|
+
run();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (started.has(key)) return;
|
|
180
|
+
|
|
181
|
+
const status = inspectFileReadiness(filePath, settleMs);
|
|
182
|
+
if (status.ready) {
|
|
183
|
+
clearTimer();
|
|
184
|
+
console.log(`[${label}] ready (${status.reason}, ${status.size} bytes): ${filePath}`);
|
|
185
|
+
run();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (status.waitMs <= 0) {
|
|
190
|
+
console.log(`[${label}] skipping ${filePath}: ${status.reason}`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
clearTimer();
|
|
195
|
+
console.log(
|
|
196
|
+
`[${label}] waiting ${formatDuration(status.waitMs)} for ${filePath} (${status.reason}, ${status.size} bytes)`
|
|
197
|
+
);
|
|
198
|
+
pendingTimers.set(
|
|
199
|
+
key,
|
|
200
|
+
setTimeout(() => {
|
|
201
|
+
pendingTimers.delete(key);
|
|
202
|
+
requestWhenSettled(filePath, action, options);
|
|
203
|
+
}, status.waitMs)
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function logSettleConfig(label = 'settle'): void {
|
|
208
|
+
const settleMs = getSettleMs();
|
|
209
|
+
console.log(
|
|
210
|
+
`[${label}] phone/watch files ${settleMs === 0 ? 'immediately (settle disabled)' : `${formatDuration(settleMs)} after last write`}` +
|
|
211
|
+
` (watch.settleMinutes; retry via POST /transcribe/force)`
|
|
212
|
+
);
|
|
213
|
+
}
|
package/src/lib/fsLib.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
|
|
3
|
+
export async function moveFile(filePath: string, destPath: string): Promise<void> {
|
|
4
|
+
console.log(`[fs-move] Moving file from ${filePath} to ${destPath}`);
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const fileExists = fs.existsSync(destPath);
|
|
7
|
+
// if the file exist in destPath, reject with an error
|
|
8
|
+
if (fileExists) {
|
|
9
|
+
const error = new Error(`Destination file already exists: ${destPath}`);
|
|
10
|
+
console.error(`[fs-move-error] ${error.message}`);
|
|
11
|
+
return resolve();
|
|
12
|
+
}
|
|
13
|
+
fs.copyFile(filePath, destPath, (err) => {
|
|
14
|
+
if (err) {
|
|
15
|
+
console.error('[fs-copy-error] copy failed', filePath, destPath, err);
|
|
16
|
+
reject(err);
|
|
17
|
+
} else {
|
|
18
|
+
fs.unlink(filePath, (unlinkErr) => {
|
|
19
|
+
if (unlinkErr) {
|
|
20
|
+
console.error('[fs-delete-error] unlink failed', filePath, unlinkErr);
|
|
21
|
+
reject(unlinkErr);
|
|
22
|
+
} else {
|
|
23
|
+
console.log(`[fs-delete] Moved ${filePath} to ${destPath}`);
|
|
24
|
+
resolve();
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function confirmFolder(folderPath: string): string {
|
|
33
|
+
if (!fs.existsSync(folderPath)) {
|
|
34
|
+
fs.mkdirSync(folderPath, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
return folderPath;
|
|
37
|
+
}
|