@fastagent-sh/voicenote 0.18.5 → 0.18.6
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 +1 -0
- package/README.zh-CN.md +1 -0
- package/package.json +1 -1
- package/src/cli.ts +34 -19
package/README.md
CHANGED
|
@@ -158,6 +158,7 @@ vn run --latest # process only the latest valid recording
|
|
|
158
158
|
vn run --latest --force # re-run the latest one
|
|
159
159
|
vn run --pdf # additionally render a PDF after notes
|
|
160
160
|
vn run --dry-run # print the plan only
|
|
161
|
+
vn run /path/to/audio.m4a # process one file by path (skips the scan, ignores age/size/duration filters)
|
|
161
162
|
vn list # list this month's notes
|
|
162
163
|
vn list --month 2026-05 # specific month
|
|
163
164
|
vn last # print the latest processing summary
|
package/README.zh-CN.md
CHANGED
|
@@ -138,6 +138,7 @@ vn run --latest # 只处理最新有效录音
|
|
|
138
138
|
vn run --latest --force # 重跑最新条
|
|
139
139
|
vn run --pdf # 生成纪要后额外渲染 PDF
|
|
140
140
|
vn run --dry-run # 仅列出计划
|
|
141
|
+
vn run /path/to/audio.m4a # 直接处理单个文件(不扫描目录, 不套用时长/大小/时效过滤)
|
|
141
142
|
vn list # 列出本月笔记
|
|
142
143
|
vn list --month 2026-05 # 指定月份
|
|
143
144
|
vn last # 打印最新处理摘要
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fastagent-sh/voicenote",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.6",
|
|
4
4
|
"description": "Voice recordings → diarized transcripts → integrated semantic Markdown notes. Currently optimized for the PHILIPS VTR6500 recorder, but the workflow is generic.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli.ts
CHANGED
|
@@ -8,12 +8,12 @@ import { createHash, createHmac, randomUUID } from 'node:crypto'
|
|
|
8
8
|
import { appendFile, chmod, mkdir, readFile, writeFile, copyFile, rename, unlink, stat, readdir, rm } from 'node:fs/promises'
|
|
9
9
|
import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, openSync, closeSync, statSync, readSync, unlinkSync, renameSync } from 'node:fs'
|
|
10
10
|
import { dlopen, FFIType, suffix } from 'bun:ffi'
|
|
11
|
-
import { basename, dirname, extname, join } from 'node:path'
|
|
11
|
+
import { basename, dirname, extname, join, resolve } from 'node:path'
|
|
12
12
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
13
13
|
import { spawn, spawnSync } from 'node:child_process'
|
|
14
14
|
import os from 'node:os'
|
|
15
15
|
|
|
16
|
-
const VERSION = '0.18.
|
|
16
|
+
const VERSION = '0.18.6'
|
|
17
17
|
const LAUNCH_AGENT_LABEL = 'sh.fastagent.voicenote'
|
|
18
18
|
const LAUNCH_AGENT_LABEL_LEGACY = 'com.kid7st.voicenote' // pre-fastagent installs; cleaned up on install
|
|
19
19
|
const TASK_NAME = 'VoiceNote' // Windows Task Scheduler name (mac uses LAUNCH_AGENT_LABEL)
|
|
@@ -800,6 +800,18 @@ function isCandidateFile(path: string): boolean {
|
|
|
800
800
|
* treating a half-read device as authoritative would delete live queue entries
|
|
801
801
|
* along with their retry counters.
|
|
802
802
|
*/
|
|
803
|
+
async function toRecording(file: string): Promise<Recording> {
|
|
804
|
+
const st = await stat(file)
|
|
805
|
+
return {
|
|
806
|
+
sourcePath: file,
|
|
807
|
+
sizeBytes: st.size,
|
|
808
|
+
modifiedAt: st.mtime.toISOString(),
|
|
809
|
+
durationSeconds: await ffprobeDuration(file),
|
|
810
|
+
sourceId: await sourceIdFor(file),
|
|
811
|
+
recordedAt: parseRecordedAt(file),
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
803
815
|
async function scanRecordings(config: Config): Promise<{ recordings: Recording[]; complete: boolean }> {
|
|
804
816
|
if (!existsSync(config.recordDir)) return { recordings: [], complete: false }
|
|
805
817
|
const recordings: Recording[] = []
|
|
@@ -813,14 +825,7 @@ async function scanRecordings(config: Config): Promise<{ recordings: Recording[]
|
|
|
813
825
|
if (!st) { complete = false; continue }
|
|
814
826
|
if (!st.isFile()) continue
|
|
815
827
|
try {
|
|
816
|
-
recordings.push(
|
|
817
|
-
sourcePath: file,
|
|
818
|
-
sizeBytes: st.size,
|
|
819
|
-
modifiedAt: st.mtime.toISOString(),
|
|
820
|
-
durationSeconds: await ffprobeDuration(file),
|
|
821
|
-
sourceId: await sourceIdFor(file),
|
|
822
|
-
recordedAt: parseRecordedAt(file),
|
|
823
|
-
})
|
|
828
|
+
recordings.push(await toRecording(file))
|
|
824
829
|
} catch (e) { complete = false; warnSideEffect(`read ${basename(file)} during scan`, e) }
|
|
825
830
|
}
|
|
826
831
|
} catch (e) {
|
|
@@ -2061,9 +2066,10 @@ function pidAlive(pid: number): boolean {
|
|
|
2061
2066
|
try { process.kill(pid, 0); return true } catch (e: any) { return e?.code === 'EPERM' }
|
|
2062
2067
|
}
|
|
2063
2068
|
|
|
2064
|
-
async function runPipeline(opts: any): Promise<void> {
|
|
2069
|
+
async function runPipeline(file: string | undefined, opts: any): Promise<void> {
|
|
2065
2070
|
wireDailyLog()
|
|
2066
2071
|
const config = getConfig()
|
|
2072
|
+
opts = { ...opts, file }
|
|
2067
2073
|
const lock = await acquireRunLock()
|
|
2068
2074
|
if (!lock) {
|
|
2069
2075
|
console.log('voicenote pipeline already running; skip')
|
|
@@ -2089,21 +2095,30 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
|
|
|
2089
2095
|
const interrupted = reconcileInterrupted(store.jobs, nowIso())
|
|
2090
2096
|
if (interrupted.length) console.log(`Reclaimed ${interrupted.length} job(s) left running by an interrupted run: ${interrupted.slice(0, 3).map(j => j.name).join(', ')}`)
|
|
2091
2097
|
|
|
2092
|
-
|
|
2098
|
+
// Explicit file: process exactly that path, wherever it lives. Nothing is
|
|
2099
|
+
// scanned, so the listing is never "complete" (no pruning), and the recorder
|
|
2100
|
+
// filters (age/size/duration) don't apply — the user named the file.
|
|
2101
|
+
const single = opts.file ? resolve(String(opts.file)) : null
|
|
2102
|
+
if (single && !statSync(single, { throwIfNoEntry: false })?.isFile()) throw new Error(`Not a file: ${single}`)
|
|
2103
|
+
if (!single && !existsSync(config.recordDir)) {
|
|
2093
2104
|
if (shouldLogIdleStatus(`missing:${config.recordDir}`)) {
|
|
2094
2105
|
console.log(`Idle: recorder not mounted or record dir missing: ${config.recordDir} (repeated idle logs suppressed for 30m)`)
|
|
2095
2106
|
}
|
|
2096
2107
|
return
|
|
2097
2108
|
}
|
|
2098
|
-
const { recordings, complete: scanComplete } =
|
|
2109
|
+
const { recordings, complete: scanComplete } = single
|
|
2110
|
+
? { recordings: [await toRecording(single)], complete: false }
|
|
2111
|
+
: await scanRecordings(config)
|
|
2099
2112
|
const mode = normalizeRunMode(opts)
|
|
2100
2113
|
const force = Boolean(opts.force)
|
|
2101
2114
|
const eligible: Recording[] = []
|
|
2102
2115
|
const skipCounts: Record<string, number> = {}
|
|
2103
2116
|
const skipSamples: Record<string, string[]> = {}
|
|
2104
|
-
|
|
2117
|
+
// An explicitly named file that gets skipped must say why, not fall into the
|
|
2118
|
+
// idle-suppressed silence meant for the 60s scheduler tick.
|
|
2119
|
+
const verboseSkips = Boolean(opts.verbose || opts.dryRun || single)
|
|
2105
2120
|
const seen = new Set<string>()
|
|
2106
|
-
const limits = limitsOf(config)
|
|
2121
|
+
const limits = single ? { maxAgeHours: 0, minBytes: 0, minDurationSeconds: 0 } : limitsOf(config)
|
|
2107
2122
|
for (const rec of recordings) {
|
|
2108
2123
|
seen.add(rec.sourceId)
|
|
2109
2124
|
const entry = recordFor(store, rec)
|
|
@@ -2117,7 +2132,7 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
|
|
|
2117
2132
|
// Only prune against a listing we believe to be complete: if the recorder went
|
|
2118
2133
|
// away mid-glob the scan is partial, and pruning would wipe live queue entries
|
|
2119
2134
|
// (they'd return on the next scan, but their retry counters would not).
|
|
2120
|
-
const dropped = pruneUnseen(store.jobs, seen, scanComplete && existsSync(config.recordDir))
|
|
2135
|
+
const dropped = pruneUnseen(store.jobs, seen, !single && scanComplete && existsSync(config.recordDir))
|
|
2121
2136
|
// The only routine path that deletes state — never do it silently.
|
|
2122
2137
|
if (dropped.length) console.log(`Forgot ${dropped.length} record(s) whose source is no longer on the recorder: ${dropped.slice(0, 3).map(j => j.name).join(', ')}${dropped.length > 3 ? `…(+${dropped.length - 3})` : ''}`)
|
|
2123
2138
|
const skipSummary = Object.entries(skipCounts).map(([reason, count]) => `${reason}=${count}`).join(', ') || 'none'
|
|
@@ -2202,7 +2217,7 @@ async function runPipelineLocked(config: Config, opts: any): Promise<void> {
|
|
|
2202
2217
|
}
|
|
2203
2218
|
// Whole recorder went away — every remaining target would fail the same way
|
|
2204
2219
|
// and churn ASR-free but noisy retries. Stop and let the next run rescan.
|
|
2205
|
-
if (!existsSync(config.recordDir)) {
|
|
2220
|
+
if (!single && !existsSync(config.recordDir)) {
|
|
2206
2221
|
console.error(`Recorder disappeared mid-run (${config.recordDir}); stopping. Remaining recordings stay queued.`)
|
|
2207
2222
|
break
|
|
2208
2223
|
}
|
|
@@ -2902,7 +2917,7 @@ async function dispatchServe(req: any, send: (o: unknown) => void): Promise<void
|
|
|
2902
2917
|
// request timeout can't misread it as a wedged engine. Progress shows
|
|
2903
2918
|
// via the jobs poll; acquireRunLock inside runPipeline dedupes against
|
|
2904
2919
|
// the scheduler tick and a double-click.
|
|
2905
|
-
void runPipeline({}).catch(e => console.error('manual run failed:', e?.message || e))
|
|
2920
|
+
void runPipeline(undefined, {}).catch(e => console.error('manual run failed:', e?.message || e))
|
|
2906
2921
|
result = { started: true }
|
|
2907
2922
|
break
|
|
2908
2923
|
}
|
|
@@ -2986,7 +3001,7 @@ async function serve(): Promise<void> {
|
|
|
2986
3001
|
|
|
2987
3002
|
const cli = cac('vn')
|
|
2988
3003
|
|
|
2989
|
-
cli.command('run', 'Scan recorder and process recordings (Volcano ASR + pi notes)')
|
|
3004
|
+
cli.command('run [file]', 'Scan recorder and process recordings, or process one audio file by path (Volcano ASR + pi notes)')
|
|
2990
3005
|
.option('--mode <mode>', 'Output mode: notes (default) | transcript', { default: 'notes' })
|
|
2991
3006
|
.option('--latest', 'Only process newest eligible recording')
|
|
2992
3007
|
.option('--force', 'Reprocess already processed recordings')
|