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.
@@ -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
+ }