dictum-cli 0.1.0
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/LICENSE +21 -0
- package/README.md +280 -0
- package/bin/dictum.js +7 -0
- package/package.json +41 -0
- package/src/cli.ts +399 -0
- package/src/config.ts +250 -0
- package/src/core/pipeline.ts +120 -0
- package/src/core/types.ts +102 -0
- package/src/doctor.ts +276 -0
- package/src/mcp/prompts.ts +186 -0
- package/src/mcp/server.ts +363 -0
- package/src/modules.d.ts +7 -0
- package/src/polisher/anthropic.ts +90 -0
- package/src/polisher/claude_cli.ts +128 -0
- package/src/polisher/factory.ts +89 -0
- package/src/polisher/layered.ts +43 -0
- package/src/polisher/openai_compat.ts +76 -0
- package/src/polisher/rules.ts +332 -0
- package/src/polisher/templates/agent-prompt.md +18 -0
- package/src/polisher/templates/commit.md +13 -0
- package/src/polisher/templates/decompose.md +20 -0
- package/src/polisher/templates/note.md +11 -0
- package/src/polisher/templates/spec.md +29 -0
- package/src/polisher/templates.ts +151 -0
- package/src/recorder/file.ts +35 -0
- package/src/recorder/sox.ts +165 -0
- package/src/recorder/vad.ts +100 -0
- package/src/recorder/wav.ts +150 -0
- package/src/sink/clipboard.ts +153 -0
- package/src/sink/factory.ts +31 -0
- package/src/sink/stdout.ts +17 -0
- package/src/stt/factory.ts +89 -0
- package/src/stt/local_http.ts +75 -0
- package/src/stt/openai_compat.ts +83 -0
- package/src/ui/choose.ts +95 -0
- package/src/ui/keys.ts +110 -0
- package/src/ui/status.ts +91 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* polisher/templates.ts — resolve polishing templates by name.
|
|
3
|
+
*
|
|
4
|
+
* Built-in templates are embedded as text (so they survive `bun build
|
|
5
|
+
* --compile`). User overrides in ~/.config/dictum/templates/<name>.md take
|
|
6
|
+
* precedence and are loaded at runtime (added in step 1.4). Frontmatter:
|
|
7
|
+
*
|
|
8
|
+
* ---
|
|
9
|
+
* description: ...
|
|
10
|
+
* language: en|ru|auto
|
|
11
|
+
* ---
|
|
12
|
+
* <instruction body>
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readdir } from "node:fs/promises"
|
|
16
|
+
import { resolve, sep } from "node:path"
|
|
17
|
+
import type { Template } from "../core/types.ts"
|
|
18
|
+
import agentPromptRaw from "./templates/agent-prompt.md" with { type: "text" }
|
|
19
|
+
import commitRaw from "./templates/commit.md" with { type: "text" }
|
|
20
|
+
import decomposeRaw from "./templates/decompose.md" with { type: "text" }
|
|
21
|
+
import noteRaw from "./templates/note.md" with { type: "text" }
|
|
22
|
+
import specRaw from "./templates/spec.md" with { type: "text" }
|
|
23
|
+
|
|
24
|
+
// Null prototype: a lookup like BUILTINS["constructor"] must miss instead of
|
|
25
|
+
// returning an inherited Object.prototype member.
|
|
26
|
+
const BUILTINS: Record<string, string> = Object.assign(Object.create(null), {
|
|
27
|
+
"agent-prompt": agentPromptRaw,
|
|
28
|
+
commit: commitRaw,
|
|
29
|
+
decompose: decomposeRaw,
|
|
30
|
+
note: noteRaw,
|
|
31
|
+
spec: specRaw,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/** Parse a markdown template (frontmatter + body) into a Template. */
|
|
35
|
+
export function parseTemplate(name: string, raw: string): Template {
|
|
36
|
+
let description = ""
|
|
37
|
+
let language = "auto"
|
|
38
|
+
let body = raw
|
|
39
|
+
|
|
40
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw)
|
|
41
|
+
if (fm?.[1] !== undefined) {
|
|
42
|
+
for (const line of fm[1].split(/\r?\n/)) {
|
|
43
|
+
const m = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line.trim())
|
|
44
|
+
if (!m) continue
|
|
45
|
+
const key = m[1]!.toLowerCase()
|
|
46
|
+
const value = m[2]!.trim().replace(/^["']|["']$/g, "")
|
|
47
|
+
if (key === "description") description = value
|
|
48
|
+
else if (key === "language") language = value
|
|
49
|
+
}
|
|
50
|
+
body = raw.slice(fm[0].length)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { name, description, language, instruction: body.trim() }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Names of the built-in templates. */
|
|
57
|
+
export function builtinTemplateNames(): string[] {
|
|
58
|
+
return Object.keys(BUILTINS)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Resolve a built-in template by name; throws with a helpful list if unknown. */
|
|
62
|
+
export function resolveBuiltinTemplate(name: string): Template {
|
|
63
|
+
const raw = BUILTINS[name]
|
|
64
|
+
if (raw === undefined) {
|
|
65
|
+
throw new Error(`Unknown template '${name}'. Available: ${builtinTemplateNames().join(", ")}`)
|
|
66
|
+
}
|
|
67
|
+
return parseTemplate(name, raw)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* All template names available: built-ins plus user overrides in `userDir`.
|
|
72
|
+
* Only valid slugs are advertised — the listing and the resolver share one
|
|
73
|
+
* validator, so every advertised name is guaranteed to resolve.
|
|
74
|
+
*/
|
|
75
|
+
export async function availableTemplateNames(userDir?: string): Promise<string[]> {
|
|
76
|
+
const names = new Set(builtinTemplateNames())
|
|
77
|
+
if (userDir) {
|
|
78
|
+
try {
|
|
79
|
+
for (const entry of await readdir(userDir, { withFileTypes: true })) {
|
|
80
|
+
// Regular files only: a directory named `x.md` must not be advertised
|
|
81
|
+
// (it can never resolve). Symlinked templates resolve but are not
|
|
82
|
+
// listed — advertised ⊆ resolvable is the invariant.
|
|
83
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) continue
|
|
84
|
+
const stem = entry.name.slice(0, -3)
|
|
85
|
+
if (isValidTemplateName(stem)) names.add(stem)
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// user template directory may not exist — ignore
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return [...names].sort()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A template name (the file stem, i.e. the `mode`) is a portable ASCII slug:
|
|
96
|
+
* starts with a letter/digit/underscore, then word characters, dots or dashes
|
|
97
|
+
* — no path separators, no `..`, no NULs, no spaces or non-ASCII. Template
|
|
98
|
+
* titles, descriptions and bodies stay full Unicode; only the technical file
|
|
99
|
+
* ID is restricted so behavior is identical across Linux/macOS/Windows and a
|
|
100
|
+
* name can never escape the template directory (`mode` reaches this from the
|
|
101
|
+
* CLI and from MCP callers). Shared by the resolver and the listing.
|
|
102
|
+
*/
|
|
103
|
+
const SAFE_NAME_RE = /^[A-Za-z0-9_][\w.-]*$/
|
|
104
|
+
|
|
105
|
+
/** Windows reserves these device names (bare or with any extension). */
|
|
106
|
+
const WINDOWS_RESERVED_RE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i
|
|
107
|
+
|
|
108
|
+
/** True when `name` is a portable template slug the resolver will accept. */
|
|
109
|
+
export function isValidTemplateName(name: string): boolean {
|
|
110
|
+
return SAFE_NAME_RE.test(name) && !name.includes("..") && !WINDOWS_RESERVED_RE.test(name)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function assertSafeTemplateName(name: string): void {
|
|
114
|
+
if (!name || !isValidTemplateName(name)) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Invalid template name '${name}': names must be plain file stems without path separators`,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve a template by name. A user override at `<userDir>/<name>.md` takes
|
|
123
|
+
* precedence over the built-in. Throws with the available list if not found,
|
|
124
|
+
* and rejects names that could traverse outside the template directory.
|
|
125
|
+
*
|
|
126
|
+
* Trust model: the user template directory is the user's own config dir and
|
|
127
|
+
* is treated as trusted — containment is lexical (resolve + prefix), and
|
|
128
|
+
* symlinks inside it are followed. This blocks caller-controlled traversal
|
|
129
|
+
* via `mode`; it does not defend against a hostile local filesystem.
|
|
130
|
+
*/
|
|
131
|
+
export async function resolveTemplate(name: string, userDir?: string): Promise<Template> {
|
|
132
|
+
assertSafeTemplateName(name)
|
|
133
|
+
if (userDir) {
|
|
134
|
+
const root = resolve(userDir)
|
|
135
|
+
const path = resolve(root, `${name}.md`)
|
|
136
|
+
// Second belt after the name check: the resolved path must stay inside.
|
|
137
|
+
if (!path.startsWith(root + sep)) {
|
|
138
|
+
throw new Error(`Invalid template name '${name}': escapes the template directory`)
|
|
139
|
+
}
|
|
140
|
+
const file = Bun.file(path)
|
|
141
|
+
if (await file.exists()) {
|
|
142
|
+
return parseTemplate(name, await file.text())
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const raw = BUILTINS[name]
|
|
146
|
+
if (raw !== undefined) {
|
|
147
|
+
return parseTemplate(name, raw)
|
|
148
|
+
}
|
|
149
|
+
const available = await availableTemplateNames(userDir)
|
|
150
|
+
throw new Error(`Unknown template '${name}'. Available: ${available.join(", ")}`)
|
|
151
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recorder/file.ts — a Recorder that reads audio from a WAV file (`--input`).
|
|
3
|
+
*
|
|
4
|
+
* Used for testing and SSH/headless flows where there is no microphone. The
|
|
5
|
+
* input is normalized to canonical PCM16/16k/mono via ffmpeg when needed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFile } from "node:fs/promises"
|
|
9
|
+
import type { AudioData, Recorder } from "../core/types.ts"
|
|
10
|
+
import { isCanonical, parseWavFormat, transcodeToCanonical } from "./wav.ts"
|
|
11
|
+
|
|
12
|
+
export class FileRecorder implements Recorder {
|
|
13
|
+
constructor(private readonly path: string) {}
|
|
14
|
+
|
|
15
|
+
async record(_signal: AbortSignal): Promise<AudioData> {
|
|
16
|
+
let bytes: Uint8Array
|
|
17
|
+
try {
|
|
18
|
+
bytes = new Uint8Array(await readFile(this.path))
|
|
19
|
+
} catch (err) {
|
|
20
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
21
|
+
throw new Error(`Cannot read input file '${this.path}': ${reason}`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let fmt: ReturnType<typeof parseWavFormat>
|
|
25
|
+
try {
|
|
26
|
+
fmt = parseWavFormat(bytes)
|
|
27
|
+
} catch (err) {
|
|
28
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
29
|
+
throw new Error(`'${this.path}' is not a valid WAV file: ${reason}`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const wav = isCanonical(fmt) ? bytes : await transcodeToCanonical(bytes)
|
|
33
|
+
return { wav, sampleRate: 16000, channels: 1 }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recorder/sox.ts — capture from the microphone via sox `rec`, streaming raw
|
|
3
|
+
* PCM16 so we can endpoint live (energy VAD) instead of waiting for a file.
|
|
4
|
+
*
|
|
5
|
+
* The recording loop (`captureStream`) is pure over a ReadableStream and is
|
|
6
|
+
* unit-tested with synthetic PCM — no microphone needed. Stops on: the abort
|
|
7
|
+
* signal (Enter / push-to-talk / SIGINT, wired in cli via ui/keys), an energy
|
|
8
|
+
* VAD endpoint, or the max-duration cap. Live mic capture is verified manually.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AudioData, Recorder } from "../core/types.ts"
|
|
12
|
+
import { SilenceDetector, type VadOptions } from "./vad.ts"
|
|
13
|
+
import { pcmToWav } from "./wav.ts"
|
|
14
|
+
|
|
15
|
+
const SAMPLE_RATE = 16000
|
|
16
|
+
const BYTES_PER_SAMPLE = 2
|
|
17
|
+
const VAD_FRAME_SAMPLES = 320 // 20 ms @ 16 kHz
|
|
18
|
+
|
|
19
|
+
export type SoxOptions = {
|
|
20
|
+
/** Binary to invoke; sox ships `rec` as a recording front-end. */
|
|
21
|
+
bin?: string
|
|
22
|
+
/** Hard safety cap in seconds. */
|
|
23
|
+
maxDuration?: number
|
|
24
|
+
/** Enable energy-based silence auto-stop. */
|
|
25
|
+
vad?: boolean
|
|
26
|
+
/** VAD tuning (threshold, silence timeout, …). */
|
|
27
|
+
vadOptions?: VadOptions
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type CaptureOptions = {
|
|
31
|
+
signal: AbortSignal
|
|
32
|
+
/** Optional detector; when present, the loop stops at the speech endpoint. */
|
|
33
|
+
vad?: SilenceDetector
|
|
34
|
+
/** Hard byte cap; the loop stops once this many PCM bytes are buffered. */
|
|
35
|
+
maxBytes: number
|
|
36
|
+
frameSamples?: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function concatChunks(chunks: Uint8Array[], total: number): Uint8Array {
|
|
40
|
+
const out = new Uint8Array(total)
|
|
41
|
+
let off = 0
|
|
42
|
+
for (const c of chunks) {
|
|
43
|
+
out.set(c, off)
|
|
44
|
+
off += c.length
|
|
45
|
+
}
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Decode little-endian PCM16 bytes to Int16 samples for energy analysis. */
|
|
50
|
+
function pcm16le(bytes: Uint8Array): Int16Array {
|
|
51
|
+
const n = bytes.length >> 1
|
|
52
|
+
const out = new Int16Array(n)
|
|
53
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
54
|
+
for (let i = 0; i < n; i++) out[i] = view.getInt16(i * 2, true)
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Consume a raw-PCM stream until the signal aborts, the VAD endpoints, or the
|
|
60
|
+
* byte cap is hit. Returns the accumulated PCM16 bytes. Pure and testable.
|
|
61
|
+
*/
|
|
62
|
+
export async function captureStream(
|
|
63
|
+
stream: ReadableStream<Uint8Array>,
|
|
64
|
+
opts: CaptureOptions,
|
|
65
|
+
): Promise<Uint8Array> {
|
|
66
|
+
const frameBytes = (opts.frameSamples ?? VAD_FRAME_SAMPLES) * BYTES_PER_SAMPLE
|
|
67
|
+
const chunks: Uint8Array[] = []
|
|
68
|
+
let total = 0
|
|
69
|
+
let leftover: Uint8Array = new Uint8Array(0)
|
|
70
|
+
const reader = stream.getReader()
|
|
71
|
+
const onAbort = () => {
|
|
72
|
+
reader.cancel().catch(() => {})
|
|
73
|
+
}
|
|
74
|
+
opts.signal.addEventListener("abort", onAbort, { once: true })
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
while (!opts.signal.aborted) {
|
|
78
|
+
const { done, value } = await reader.read()
|
|
79
|
+
if (done || !value) break
|
|
80
|
+
chunks.push(value)
|
|
81
|
+
total += value.length
|
|
82
|
+
if (total >= opts.maxBytes) break
|
|
83
|
+
|
|
84
|
+
if (opts.vad) {
|
|
85
|
+
// frame leftover+value into fixed VAD windows
|
|
86
|
+
const merged = leftover.length
|
|
87
|
+
? concatChunks([leftover, value], leftover.length + value.length)
|
|
88
|
+
: value
|
|
89
|
+
let off = 0
|
|
90
|
+
let endpoint = false
|
|
91
|
+
while (merged.length - off >= frameBytes) {
|
|
92
|
+
if (opts.vad.push(pcm16le(merged.subarray(off, off + frameBytes)))) {
|
|
93
|
+
endpoint = true
|
|
94
|
+
break
|
|
95
|
+
}
|
|
96
|
+
off += frameBytes
|
|
97
|
+
}
|
|
98
|
+
leftover = merged.subarray(off)
|
|
99
|
+
if (endpoint) break
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
opts.signal.removeEventListener("abort", onAbort)
|
|
104
|
+
reader.releaseLock()
|
|
105
|
+
}
|
|
106
|
+
return concatChunks(chunks, total)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export class SoxRecorder implements Recorder {
|
|
110
|
+
private readonly bin: string
|
|
111
|
+
private readonly maxDuration: number
|
|
112
|
+
private readonly vadEnabled: boolean
|
|
113
|
+
private readonly vadOptions: VadOptions
|
|
114
|
+
|
|
115
|
+
constructor(opts: SoxOptions = {}) {
|
|
116
|
+
this.bin = opts.bin ?? "rec"
|
|
117
|
+
this.maxDuration = opts.maxDuration ?? 120
|
|
118
|
+
this.vadEnabled = opts.vad ?? false
|
|
119
|
+
this.vadOptions = opts.vadOptions ?? {}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async record(signal: AbortSignal): Promise<AudioData> {
|
|
123
|
+
const proc = Bun.spawn(
|
|
124
|
+
[
|
|
125
|
+
this.bin,
|
|
126
|
+
"-q",
|
|
127
|
+
"-c",
|
|
128
|
+
"1",
|
|
129
|
+
"-r",
|
|
130
|
+
String(SAMPLE_RATE),
|
|
131
|
+
"-b",
|
|
132
|
+
"16",
|
|
133
|
+
"-e",
|
|
134
|
+
"signed-integer",
|
|
135
|
+
"-t",
|
|
136
|
+
"raw",
|
|
137
|
+
"-", // stream raw PCM to stdout
|
|
138
|
+
],
|
|
139
|
+
{ stdin: "ignore", stdout: "pipe", stderr: "pipe" },
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
const vad = this.vadEnabled
|
|
143
|
+
? new SilenceDetector({ sampleRate: SAMPLE_RATE, ...this.vadOptions })
|
|
144
|
+
: undefined
|
|
145
|
+
const maxBytes = Math.max(1, this.maxDuration * SAMPLE_RATE * BYTES_PER_SAMPLE)
|
|
146
|
+
|
|
147
|
+
const captureOpts: CaptureOptions = vad ? { signal, vad, maxBytes } : { signal, maxBytes }
|
|
148
|
+
|
|
149
|
+
let pcm: Uint8Array
|
|
150
|
+
try {
|
|
151
|
+
pcm = await captureStream(proc.stdout, captureOpts)
|
|
152
|
+
} finally {
|
|
153
|
+
proc.kill("SIGINT")
|
|
154
|
+
await proc.exited
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (pcm.length < BYTES_PER_SAMPLE) {
|
|
158
|
+
const err = (await new Response(proc.stderr).text()).trim()
|
|
159
|
+
throw new Error(
|
|
160
|
+
`${this.bin}: no audio captured. ${err || "is sox installed? → apt install sox / brew install sox"}`,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
return { wav: pcmToWav(pcm), sampleRate: 16000, channels: 1 }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recorder/vad.ts — energy-based voice-activity endpointing.
|
|
3
|
+
*
|
|
4
|
+
* Pure math over PCM16 samples: compute frame energy, and detect the end of an
|
|
5
|
+
* utterance once trailing silence exceeds a configurable timeout. No I/O, no
|
|
6
|
+
* project imports — fully unit-testable on synthetic buffers. The live wiring
|
|
7
|
+
* (feeding frames from the recorder) is done in cli.ts/recorder during step 1.6.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type VadOptions = {
|
|
11
|
+
/** Sample rate of the PCM data (Hz). */
|
|
12
|
+
sampleRate?: number
|
|
13
|
+
/** Voicing threshold as normalized RMS in [0, 1]. */
|
|
14
|
+
energyThreshold?: number
|
|
15
|
+
/** Stop after this much trailing silence, in seconds. */
|
|
16
|
+
silenceTimeoutSec?: number
|
|
17
|
+
/** Minimum cumulative voiced time before endpointing can trigger (ms). */
|
|
18
|
+
minSpeechMs?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const PCM16_FULL_SCALE = 32768
|
|
22
|
+
|
|
23
|
+
/** Root-mean-square energy of a PCM16 frame, normalized to [0, 1]. */
|
|
24
|
+
export function rmsEnergy(samples: Int16Array): number {
|
|
25
|
+
if (samples.length === 0) return 0
|
|
26
|
+
let sumSq = 0
|
|
27
|
+
for (let i = 0; i < samples.length; i++) {
|
|
28
|
+
const s = samples[i]! / PCM16_FULL_SCALE
|
|
29
|
+
sumSq += s * s
|
|
30
|
+
}
|
|
31
|
+
return Math.sqrt(sumSq / samples.length)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Streaming endpoint detector. Feed PCM16 frames via push(); it returns true the
|
|
36
|
+
* first time end-of-speech is detected (≥ silenceTimeout of trailing silence
|
|
37
|
+
* after at least minSpeechMs of voiced audio).
|
|
38
|
+
*/
|
|
39
|
+
export class SilenceDetector {
|
|
40
|
+
private readonly sampleRate: number
|
|
41
|
+
private readonly threshold: number
|
|
42
|
+
private readonly silenceTimeoutMs: number
|
|
43
|
+
private readonly minSpeechMs: number
|
|
44
|
+
private speechMs = 0
|
|
45
|
+
private silenceMs = 0
|
|
46
|
+
private started = false
|
|
47
|
+
|
|
48
|
+
constructor(opts: VadOptions = {}) {
|
|
49
|
+
this.sampleRate = opts.sampleRate ?? 16000
|
|
50
|
+
this.threshold = opts.energyThreshold ?? 0.015
|
|
51
|
+
this.silenceTimeoutMs = (opts.silenceTimeoutSec ?? 2.0) * 1000
|
|
52
|
+
this.minSpeechMs = opts.minSpeechMs ?? 100
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True once trailing silence after speech reaches the timeout. */
|
|
56
|
+
push(frame: Int16Array): boolean {
|
|
57
|
+
if (frame.length === 0) return false
|
|
58
|
+
const frameMs = (frame.length / this.sampleRate) * 1000
|
|
59
|
+
const voiced = rmsEnergy(frame) >= this.threshold
|
|
60
|
+
if (voiced) {
|
|
61
|
+
this.speechMs += frameMs
|
|
62
|
+
this.silenceMs = 0
|
|
63
|
+
if (this.speechMs >= this.minSpeechMs) this.started = true
|
|
64
|
+
} else if (this.started) {
|
|
65
|
+
this.silenceMs += frameMs
|
|
66
|
+
if (this.silenceMs >= this.silenceTimeoutMs) return true
|
|
67
|
+
}
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether enough voiced audio has been seen to consider speech started. */
|
|
72
|
+
get hasStarted(): boolean {
|
|
73
|
+
return this.started
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
reset(): void {
|
|
77
|
+
this.speechMs = 0
|
|
78
|
+
this.silenceMs = 0
|
|
79
|
+
this.started = false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Batch helper: run the detector over a whole PCM16 buffer in fixed frames and
|
|
85
|
+
* return the sample index at which endpointing fires, or null if it never does.
|
|
86
|
+
*/
|
|
87
|
+
export function detectEndpoint(
|
|
88
|
+
samples: Int16Array,
|
|
89
|
+
opts: VadOptions & { frameMs?: number } = {},
|
|
90
|
+
): number | null {
|
|
91
|
+
const sampleRate = opts.sampleRate ?? 16000
|
|
92
|
+
const frameMs = opts.frameMs ?? 20
|
|
93
|
+
const frameSize = Math.max(1, Math.round((sampleRate * frameMs) / 1000))
|
|
94
|
+
const detector = new SilenceDetector(opts)
|
|
95
|
+
for (let i = 0; i < samples.length; i += frameSize) {
|
|
96
|
+
const end = Math.min(i + frameSize, samples.length)
|
|
97
|
+
if (detector.push(samples.subarray(i, end))) return end
|
|
98
|
+
}
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recorder/wav.ts — minimal RIFF/WAVE parsing and canonicalization helpers.
|
|
3
|
+
*
|
|
4
|
+
* Part of the recorder module. Depends only on Node/Bun built-ins (and ffmpeg
|
|
5
|
+
* at runtime for transcoding non-canonical inputs). The canonical format used
|
|
6
|
+
* across Dictum is PCM16, 16 kHz, mono — see core/types.ts AudioData.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
|
|
10
|
+
import { tmpdir } from "node:os"
|
|
11
|
+
import { join } from "node:path"
|
|
12
|
+
|
|
13
|
+
export type WavFormat = {
|
|
14
|
+
audioFormat: number // 1 = PCM
|
|
15
|
+
channels: number
|
|
16
|
+
sampleRate: number
|
|
17
|
+
bitsPerSample: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const CANONICAL_RATE = 16000
|
|
21
|
+
const CANONICAL_CHANNELS = 1
|
|
22
|
+
const CANONICAL_BITS = 16
|
|
23
|
+
|
|
24
|
+
function readFourCC(bytes: Uint8Array, offset: number): string {
|
|
25
|
+
return String.fromCharCode(
|
|
26
|
+
bytes[offset]!,
|
|
27
|
+
bytes[offset + 1]!,
|
|
28
|
+
bytes[offset + 2]!,
|
|
29
|
+
bytes[offset + 3]!,
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse the `fmt ` chunk of a WAV file. Tolerates extra chunks (LIST, fact, …)
|
|
35
|
+
* appearing before `data`. Throws on a non-RIFF/WAVE input.
|
|
36
|
+
*/
|
|
37
|
+
export function parseWavFormat(bytes: Uint8Array): WavFormat {
|
|
38
|
+
if (bytes.length < 12 || readFourCC(bytes, 0) !== "RIFF" || readFourCC(bytes, 8) !== "WAVE") {
|
|
39
|
+
throw new Error("Not a RIFF/WAVE file")
|
|
40
|
+
}
|
|
41
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
42
|
+
let offset = 12
|
|
43
|
+
while (offset + 8 <= bytes.length) {
|
|
44
|
+
const id = readFourCC(bytes, offset)
|
|
45
|
+
const size = view.getUint32(offset + 4, true)
|
|
46
|
+
const body = offset + 8
|
|
47
|
+
if (id === "fmt ") {
|
|
48
|
+
return {
|
|
49
|
+
audioFormat: view.getUint16(body, true),
|
|
50
|
+
channels: view.getUint16(body + 2, true),
|
|
51
|
+
sampleRate: view.getUint32(body + 4, true),
|
|
52
|
+
bitsPerSample: view.getUint16(body + 14, true),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// chunks are word-aligned (pad to even length)
|
|
56
|
+
offset = body + size + (size % 2)
|
|
57
|
+
}
|
|
58
|
+
throw new Error("WAV is missing a 'fmt ' chunk")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeAscii(buf: Uint8Array, offset: number, text: string): void {
|
|
62
|
+
for (let i = 0; i < text.length; i++) buf[offset + i] = text.charCodeAt(i)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Wrap raw PCM16 little-endian bytes in a canonical WAV container (44-byte
|
|
67
|
+
* header). Defaults to 16 kHz mono — the format Dictum captures.
|
|
68
|
+
*/
|
|
69
|
+
export function pcmToWav(
|
|
70
|
+
pcm: Uint8Array,
|
|
71
|
+
opts: { sampleRate?: number; channels?: number } = {},
|
|
72
|
+
): Uint8Array {
|
|
73
|
+
const sampleRate = opts.sampleRate ?? CANONICAL_RATE
|
|
74
|
+
const channels = opts.channels ?? CANONICAL_CHANNELS
|
|
75
|
+
const bitsPerSample = CANONICAL_BITS
|
|
76
|
+
const blockAlign = (channels * bitsPerSample) / 8
|
|
77
|
+
const byteRate = sampleRate * blockAlign
|
|
78
|
+
const dataSize = pcm.length
|
|
79
|
+
|
|
80
|
+
const buf = new Uint8Array(44 + dataSize)
|
|
81
|
+
const view = new DataView(buf.buffer)
|
|
82
|
+
writeAscii(buf, 0, "RIFF")
|
|
83
|
+
view.setUint32(4, 36 + dataSize, true)
|
|
84
|
+
writeAscii(buf, 8, "WAVE")
|
|
85
|
+
writeAscii(buf, 12, "fmt ")
|
|
86
|
+
view.setUint32(16, 16, true) // fmt chunk size (PCM)
|
|
87
|
+
view.setUint16(20, 1, true) // audioFormat = PCM
|
|
88
|
+
view.setUint16(22, channels, true)
|
|
89
|
+
view.setUint32(24, sampleRate, true)
|
|
90
|
+
view.setUint32(28, byteRate, true)
|
|
91
|
+
view.setUint16(32, blockAlign, true)
|
|
92
|
+
view.setUint16(34, bitsPerSample, true)
|
|
93
|
+
writeAscii(buf, 36, "data")
|
|
94
|
+
view.setUint32(40, dataSize, true)
|
|
95
|
+
buf.set(pcm, 44)
|
|
96
|
+
return buf
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** True when the WAV is already PCM16 / 16 kHz / mono. */
|
|
100
|
+
export function isCanonical(fmt: WavFormat): boolean {
|
|
101
|
+
return (
|
|
102
|
+
fmt.audioFormat === 1 &&
|
|
103
|
+
fmt.channels === CANONICAL_CHANNELS &&
|
|
104
|
+
fmt.sampleRate === CANONICAL_RATE &&
|
|
105
|
+
fmt.bitsPerSample === CANONICAL_BITS
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Transcode arbitrary WAV bytes into canonical PCM16/16k/mono using ffmpeg.
|
|
111
|
+
* Used only when the input is not already canonical.
|
|
112
|
+
*/
|
|
113
|
+
export async function transcodeToCanonical(bytes: Uint8Array): Promise<Uint8Array> {
|
|
114
|
+
const dir = await mkdtemp(join(tmpdir(), "dictum-wav-"))
|
|
115
|
+
const inPath = join(dir, "in.wav")
|
|
116
|
+
const outPath = join(dir, "out.wav")
|
|
117
|
+
try {
|
|
118
|
+
await writeFile(inPath, bytes)
|
|
119
|
+
const proc = Bun.spawn(
|
|
120
|
+
[
|
|
121
|
+
"ffmpeg",
|
|
122
|
+
"-hide_banner",
|
|
123
|
+
"-loglevel",
|
|
124
|
+
"error",
|
|
125
|
+
"-y",
|
|
126
|
+
"-i",
|
|
127
|
+
inPath,
|
|
128
|
+
"-ar",
|
|
129
|
+
String(CANONICAL_RATE),
|
|
130
|
+
"-ac",
|
|
131
|
+
String(CANONICAL_CHANNELS),
|
|
132
|
+
"-sample_fmt",
|
|
133
|
+
"s16",
|
|
134
|
+
"-f",
|
|
135
|
+
"wav",
|
|
136
|
+
outPath,
|
|
137
|
+
],
|
|
138
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
139
|
+
)
|
|
140
|
+
const code = await proc.exited
|
|
141
|
+
if (code !== 0) {
|
|
142
|
+
const err = await new Response(proc.stderr).text()
|
|
143
|
+
throw new Error(`ffmpeg transcode failed (exit ${code}): ${err.trim() || "unknown error"}`)
|
|
144
|
+
}
|
|
145
|
+
const out = await readFile(outPath)
|
|
146
|
+
return new Uint8Array(out)
|
|
147
|
+
} finally {
|
|
148
|
+
await rm(dir, { recursive: true, force: true })
|
|
149
|
+
}
|
|
150
|
+
}
|