@agfpd/voicetalk-runtime 0.1.0 → 0.2.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/README.md CHANGED
@@ -27,9 +27,9 @@ voicetalk gives a person presence in an [iapeer](https://github.com/agfpd/iapeer
27
27
 
28
28
  ## Quick start
29
29
 
30
- > Status: **text caркас (Фаза 2).** The bidirectional router loop works end to end over text; the audio touchpoints (wake-word, STT, TTS) are stubbed and land in Фаза 3 — see the roadmap below.
30
+ > Status: **live voice (Фаза 3).** The microphone, wake words, recognition and spoken replies all run; latency tuning is Фаза 4 — see the roadmap below.
31
31
 
32
- You will need a working iapeer install (and, for Фаза 3, the `voice-connect` voice service).
32
+ You will need a working iapeer install, the `voice-connect` voice service running locally, and `ffmpeg`/`ffplay` on `PATH`.
33
33
 
34
34
  ```bash
35
35
  npx @agfpd/voicetalk-runtime # install: binary on PATH + runtime manifest
@@ -38,17 +38,21 @@ voicetalk-runtime doctor # check the runtime + this pee
38
38
  ```
39
39
 
40
40
  Once a peer is created, the always-on `voicetalk-runtime run` router pumps IAP
41
- envelopes both directions. In Фаза 2 the inbound audio source is stubbed by a local
42
- injector you can drive by hand — the round trip works without a microphone:
41
+ envelopes both directions: the microphone listens, and the owner speaks a peer's wake
42
+ word to address it.
43
43
 
44
44
  ```bash
45
- voicetalk-runtime interface --wake-word "hey jarvis" --device mic0 # set the binding
45
+ voicetalk-runtime devices # list audio inputs
46
+ voicetalk-runtime interface --wake-words "дарвин=darwin,борис=boris" \
47
+ --default-target darwin --device reSpeaker # set the binding
46
48
  # (run is launched by the foundation under the always-on supervisor)
47
- voicetalk-runtime say --peer linus "what's the build status" # inject an utterance (mic+STT stand-in)
49
+ voicetalk-runtime say --peer linus "what's the build status" # inject an utterance without the mic (debug)
48
50
  ```
49
51
 
50
- An outbound peer reply arrives as an `<iap>` envelope on the router's stdin and is
51
- spoken by the AudioSink — a logging stub today, `voice-connect` TTS in Фаза 3.
52
+ Say «Борис, привет» and the rest of the sentence is delivered to boris; the reply is
53
+ spoken on the host speaker, and the mic stays open for a follow-up. `VOICETALK_AUDIO=0`
54
+ runs the same router text-only, with no microphone at all. Configuration details:
55
+ [`docs/`](docs/README.md).
52
56
 
53
57
  ## What makes it different
54
58
 
@@ -60,8 +64,8 @@ spoken by the AudioSink — a logging stub today, `voice-connect` TTS in Фаз
60
64
 
61
65
  ## What it does not do (yet)
62
66
 
63
- - No audio yet: the wake-word, STT, and TTS touchpoints are stubbed (the inbound source is a local socket injector; the outbound sink logs instead of speaking). The bidirectional **router loop itself works** over text. Audio lands in Фаза 3 via `voice-connect`.
64
- - Foundation integration is a coordinated step: the `voicetalk` launch-adapter and runtime classification on the iapeer side gate `iapeer create --runtime voicetalk` (Фаза 1).
67
+ - No barge-in: the owner cannot interrupt a spoken answer by talking over it (that needs acoustic echo cancellation plus a neural VAD). While the runtime speaks, it does not listen.
68
+ - Wake detection runs on the transcript, not on an acoustic wake-word model — so a wake word costs one line of config and no training, at the price of transcribing every voiced segment. An acoustic pre-gate is the Фаза-4 latency upgrade.
65
69
  - Single host, single owner, local microphone and speaker. Multi-device and multi-owner are out of scope for the MVP.
66
70
 
67
71
  ## Roadmap
@@ -69,7 +73,7 @@ spoken by the AudioSink — a logging stub today, `voice-connect` TTS in Фаз
69
73
  - **Фаза 0 — Scaffold.** Package skeleton, CLI stub, contract constants. **(done)**
70
74
  - **Фаза 1 — Foundation integration (iapeer).** `voicetalk` launch-adapter, runtime classification, `iapeer create --runtime voicetalk`. **(adapter code-complete; deploy coordinated with Фаза 2)**
71
75
  - **Фаза 2 — Runtime skeleton.** `runtime.json` manifest + `selfConfig` hook; `interfaces.voicetalk` schema; bidirectional router loop with a text-only IAP round-trip (audio stubbed). **(done)**
72
- - **Фаза 3 — Voice pipeline.** Always-on wake-word → STT (inbound), TTS → speaker (outbound) via `voice-connect`; modality framing.
76
+ - **Фаза 3 — Voice pipeline.** Always-on microphone → endpointing → STT → wake map (inbound), TTS → speaker (outbound) via `voice-connect`; turn-taking, follow-up window, earcons. **(done)**
73
77
  - **Фаза 4 — Latency and live acceptance.** Tune the chain, conversational round-trip live.
74
78
 
75
79
  ## Documentation
package/docs/README.md CHANGED
@@ -4,9 +4,9 @@ voicetalk is the voice-presence runtime of [iapeer](https://github.com/agfpd/iap
4
4
  a person speaks to the agent team aloud (wake-word + microphone) and hears replies
5
5
  from the host speaker, as one more runtime of the peer's single identity.
6
6
 
7
- > Status: scaffold (Фаза 0). The audio pipeline is not implemented yet. This
8
- > documentation describes the design; it grows as the runtime is built out across
9
- > Фазы 1–4. See the root [README](../README.md) for the roadmap.
7
+ > Status: the voice pipeline is live (Фаза 3) — microphone, wake words, speech
8
+ > recognition and spoken replies all run; latency tuning is Фаза 4. See the root
9
+ > [README](../README.md) for the roadmap.
10
10
 
11
11
  ## Contents
12
12
 
@@ -14,7 +14,58 @@ from the host speaker, as one more runtime of the peer's single identity.
14
14
  - Quick start — operator flow (`iapeer create --runtime voicetalk`).
15
15
  - Architecture — the two-layer split (foundation adapter + runtime binary) and the
16
16
  `voice-connect` voice service it consumes.
17
- - Configuration — `interfaces.voicetalk` (`wake_word`, `device`).
17
+ - Configuration — `interfaces.voicetalk`: the wake map, the default agent and the
18
+ input device (below).
19
+
20
+ ## Configuration — `interfaces.voicetalk`
21
+
22
+ ```jsonc
23
+ {
24
+ "wake_words": { "дарвин": "darwin", "борис": "boris", "линус": "linus" },
25
+ "default_target": "darwin",
26
+ "device": "reSpeaker" // optional: name substring or avfoundation index
27
+ }
28
+ ```
29
+
30
+ The wake word IS the address: saying a peer's word routes the rest of the sentence to
31
+ that peer. The map is CURATED, not derived from the fleet roster — a word per each of
32
+ 30+ peers was rejected as a false-trigger source. `default_target` is the agent the
33
+ owner reaches by default; with no map at all, that agent's own name becomes the
34
+ activation word (`default_target: "darwin"` → «Дарвин, …»). `wake_word` (a single
35
+ string) is the legacy form and is still read.
36
+
37
+ Set it with `voicetalk-runtime interface --wake-words «дарвин=darwin,борис=boris»
38
+ --default-target darwin --device reSpeaker`, or at provisioning time through the
39
+ `VOICETALK_WAKE_WORDS` / `VOICETALK_DEFAULT_TARGET` / `VOICETALK_DEVICE` env of the
40
+ self-config hook. `voicetalk-runtime devices` lists the host's inputs;
41
+ `voicetalk-runtime doctor` reports the whole contour (binding, wake map,
42
+ voice-connect, microphone).
43
+
44
+ ## How a turn works
45
+
46
+ 1. The microphone runs always-on; energy endpointing cuts an utterance when the
47
+ speaker pauses (700 ms).
48
+ 2. voice-connect transcribes the utterance (locally, on this host).
49
+ 3. If it opens by addressing a peer, the body is delivered to that peer as an IAP
50
+ envelope from the owner's identity, framed for the spoken modality.
51
+ 4. The agent's answer arrives on stdin, is spoken through voice-connect on the host
52
+ speaker, and the microphone stays open for ~9 s so the owner can continue without
53
+ naming the peer again.
54
+
55
+ Earcons mark the states: rising = the microphone is yours, short descending = heard
56
+ you, double descending = nothing was routed. Processing is silent.
57
+
58
+ Everything is tunable by env: `VOICETALK_AUDIO=0` (text-only, no microphone),
59
+ `VOICETALK_FOLLOWUP_MS`, `VOICETALK_HANGOVER_MS`, `VOICETALK_VAD_MIN_RMS`,
60
+ `VOICETALK_VAD_RATIO`, `VOICETALK_STT_LANG`, `VOICETALK_STT_ENGINE`,
61
+ `VOICETALK_TTS_MAX_CHARS`, `VOICETALK_EARCONS=0`.
62
+
63
+ ## Requirements
64
+
65
+ - `ffmpeg` (microphone capture) and `ffplay` (playback) on `PATH`.
66
+ - The voice-connect HTTP service running locally — it is discovered through
67
+ `<IAPEER_ROOT>/voice-provider.json` (override with `VOICETALK_VOICE_ENDPOINT`).
68
+ - Microphone permission (TCC) granted once to the process that runs the runtime.
18
69
 
19
70
  These pages are written as each phase lands. Russian translation lives in
20
71
  [`ru/`](ru/README.md).
package/docs/ru/README.md CHANGED
@@ -4,9 +4,9 @@ voicetalk — голосовой presence-рантайм [iapeer](https://github
4
4
  человек говорит флоту агентов вслух (wake-word + микрофон) и слышит ответы из
5
5
  динамика хоста — это ещё один рантайм единой личности пира.
6
6
 
7
- > Статус: каркас (Фаза 0). Голосовой пайплайн ещё не реализован. Документация
8
- > описывает дизайн и наполняется по мере сборки рантайма в Фазах 1–4. Дорожная
9
- > карта — в корневом [README](../../README.md).
7
+ > Статус: голосовой пайплайн живой (Фаза 3) — микрофон, wake-слова, распознавание и
8
+ > голосовые ответы работают; тюнинг латентности — Фаза 4. Дорожная карта — в корневом
9
+ > [README](../../README.md).
10
10
 
11
11
  ## Содержание
12
12
 
@@ -14,7 +14,57 @@ voicetalk — голосовой presence-рантайм [iapeer](https://github
14
14
  - Быстрый старт — операторский поток (`iapeer create --runtime voicetalk`).
15
15
  - Архитектура — двухслойность (foundation-адаптер + бинарь рантайма) и голосовой
16
16
  сервис `voice-connect`, который voicetalk потребляет.
17
- - Настройка — `interfaces.voicetalk` (`wake_word`, `device`).
17
+ - Настройка — `interfaces.voicetalk`: карта wake-слов, дефолтный агент, устройство
18
+ ввода (ниже).
19
+
20
+ ## Настройка — `interfaces.voicetalk`
21
+
22
+ ```jsonc
23
+ {
24
+ "wake_words": { "дарвин": "darwin", "борис": "boris", "линус": "linus" },
25
+ "default_target": "darwin",
26
+ "device": "reSpeaker" // опционально: часть имени или индекс avfoundation
27
+ }
28
+ ```
29
+
30
+ Wake-слово И ЕСТЬ адрес: произнесённое слово пира отправляет ему остаток фразы.
31
+ Карта — КУРИРУЕМАЯ, она не выводится из ростера флота: слово на каждого из 30+ пиров
32
+ отвергнуто как источник ложных срабатываний. `default_target` — агент по умолчанию;
33
+ если карта не задана вовсе, словом активации становится имя самого этого агента
34
+ (`default_target: "darwin"` → «Дарвин, …»). `wake_word` (одна строка) — легаси-форма,
35
+ читается по-прежнему.
36
+
37
+ Задаётся командой `voicetalk-runtime interface --wake-words «дарвин=darwin,борис=boris»
38
+ --default-target darwin --device reSpeaker` либо при провижининге через env
39
+ self-config-хука (`VOICETALK_WAKE_WORDS`, `VOICETALK_DEFAULT_TARGET`,
40
+ `VOICETALK_DEVICE`). `voicetalk-runtime devices` показывает входы хоста,
41
+ `voicetalk-runtime doctor` — весь контур целиком (привязка, карта, voice-connect,
42
+ микрофон).
43
+
44
+ ## Как устроен ход разговора
45
+
46
+ 1. Микрофон слушает постоянно; энергетический эндпойнтер закрывает реплику по паузе
47
+ (700 мс).
48
+ 2. voice-connect распознаёт реплику (локально, на этом хосте).
49
+ 3. Если реплика начинается с обращения к пиру, тело уходит ему IAP-конвертом от
50
+ личности владельца, с рамкой голосовой модальности.
51
+ 4. Ответ агента приходит на stdin, озвучивается через voice-connect в динамик, и
52
+ микрофон остаётся открытым ~9 с — продолжить можно без повторного обращения.
53
+
54
+ Earcon'ы размечают состояния: восходящий — микрофон ваш, короткий нисходящий —
55
+ принял, двойной нисходящий — не отправлено. Обработка идёт молча.
56
+
57
+ Всё настраивается через env: `VOICETALK_AUDIO=0` (текстовый режим, без микрофона),
58
+ `VOICETALK_FOLLOWUP_MS`, `VOICETALK_HANGOVER_MS`, `VOICETALK_VAD_MIN_RMS`,
59
+ `VOICETALK_VAD_RATIO`, `VOICETALK_STT_LANG`, `VOICETALK_STT_ENGINE`,
60
+ `VOICETALK_TTS_MAX_CHARS`, `VOICETALK_EARCONS=0`.
61
+
62
+ ## Требования
63
+
64
+ - `ffmpeg` (захват микрофона) и `ffplay` (воспроизведение) на `PATH`.
65
+ - Локально запущенный HTTP-сервис voice-connect — обнаруживается через
66
+ `<IAPEER_ROOT>/voice-provider.json` (переопределяется `VOICETALK_VOICE_ENDPOINT`).
67
+ - Разрешение на микрофон (TCC), выданное один раз процессу рантайма.
18
68
 
19
69
  Страницы пишутся по мере прохождения фаз.
20
70
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/voicetalk-runtime",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Voice runtime-router for IAP peers.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/audio.ts ADDED
@@ -0,0 +1,428 @@
1
+ // audio.ts — the host's ears and mouth: microphone capture, endpointing (VAD), WAV
2
+ // framing and speaker playback. Everything here is host-mechanics only; not a single
3
+ // byte of speech recognition or synthesis lives in this file (that is voice-connect).
4
+ //
5
+ // Capture is ffmpeg/avfoundation → raw s16le PCM on stdout, one long-lived child. The
6
+ // segmenter turns that byte river into utterances by energy endpointing: an utterance
7
+ // starts when speech rises above an adaptive noise floor and ends on a pause — never on
8
+ // a fixed timer, so the speaker sets the pace (voice-UX baseline, Описание §continuation).
9
+
10
+ import { spawn, type ChildProcessByStdio } from 'child_process'
11
+ import type { Readable } from 'stream'
12
+ import { writeFileSync } from 'fs'
13
+
14
+ export const SAMPLE_RATE = 16_000
15
+ export const FRAME_MS = 20
16
+ export const FRAME_BYTES = (SAMPLE_RATE / 1000) * FRAME_MS * 2 // 640 B — 20 ms mono s16le
17
+
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+ // WAV framing
20
+ // ─────────────────────────────────────────────────────────────────────────────
21
+
22
+ /** Wrap raw mono s16le PCM in a canonical 44-byte WAV header (what the STT route eats). */
23
+ export function pcmToWav(pcm: Buffer, sampleRate = SAMPLE_RATE): Buffer {
24
+ const header = Buffer.alloc(44)
25
+ header.write('RIFF', 0)
26
+ header.writeUInt32LE(36 + pcm.length, 4)
27
+ header.write('WAVE', 8)
28
+ header.write('fmt ', 12)
29
+ header.writeUInt32LE(16, 16) // PCM fmt chunk size
30
+ header.writeUInt16LE(1, 20) // format = PCM
31
+ header.writeUInt16LE(1, 22) // channels = mono
32
+ header.writeUInt32LE(sampleRate, 24)
33
+ header.writeUInt32LE(sampleRate * 2, 28) // byte rate
34
+ header.writeUInt16LE(2, 32) // block align
35
+ header.writeUInt16LE(16, 34) // bits per sample
36
+ header.write('data', 36)
37
+ header.writeUInt32LE(pcm.length, 40)
38
+ return Buffer.concat([header, pcm])
39
+ }
40
+
41
+ export function writeWav(pcm: Buffer, path: string, sampleRate = SAMPLE_RATE): string {
42
+ writeFileSync(path, pcmToWav(pcm, sampleRate), { mode: 0o600 })
43
+ return path
44
+ }
45
+
46
+ /** Root-mean-square amplitude of one s16le frame, 0…32767. */
47
+ export function frameRms(frame: Buffer): number {
48
+ const samples = Math.floor(frame.length / 2)
49
+ if (samples === 0) return 0
50
+ let sum = 0
51
+ for (let i = 0; i < samples; i++) {
52
+ const s = frame.readInt16LE(i * 2)
53
+ sum += s * s
54
+ }
55
+ return Math.sqrt(sum / samples)
56
+ }
57
+
58
+ // ─────────────────────────────────────────────────────────────────────────────
59
+ // endpointing
60
+ // ─────────────────────────────────────────────────────────────────────────────
61
+
62
+ export type SegmenterOptions = {
63
+ /** Speech must exceed noiseFloor × this to open an utterance. */
64
+ ratio?: number
65
+ /** Absolute floor under which nothing counts as speech (dead-quiet room guard). */
66
+ minRms?: number
67
+ /** Consecutive loud frames required to open an utterance (debounce). */
68
+ onsetFrames?: number
69
+ /** Trailing silence that closes an utterance — the endpointing pause. */
70
+ hangoverMs?: number
71
+ /** Audio kept from BEFORE the onset, so the first syllable is not clipped. */
72
+ prerollMs?: number
73
+ /** Shorter than this = a cough, not an utterance. */
74
+ minUtteranceMs?: number
75
+ /** Hard cap so one long monologue still reaches STT in bounded time. */
76
+ maxUtteranceMs?: number
77
+ /** Listen-only warm-up: no utterance may open until the room's noise floor has been
78
+ * measured for this long. Without it the very first frames (with the floor still
79
+ * unknown) can open a segment on nothing but room tone. */
80
+ warmupMs?: number
81
+ }
82
+
83
+ export type Segment = {
84
+ pcm: Buffer
85
+ ms: number
86
+ peakRms: number
87
+ /** The adaptive noise floor at the moment the utterance opened, and the level it had
88
+ * to clear — the two numbers that explain every capture decision in a log. */
89
+ noiseFloor: number
90
+ threshold: number
91
+ /** True when the cap closed the segment rather than a pause. */
92
+ truncated: boolean
93
+ }
94
+
95
+ /** Energy endpointer over a stream of 20 ms frames. Pure and synchronous — feed it PCM,
96
+ * it hands back finished utterances; a test drives it with synthetic tone/silence. */
97
+ export class Segmenter {
98
+ private readonly ratio: number
99
+ private readonly minRms: number
100
+ private readonly onsetFrames: number
101
+ private readonly hangoverFrames: number
102
+ private readonly prerollFrames: number
103
+ private readonly minFrames: number
104
+ private readonly maxFrames: number
105
+ private readonly warmupFrames: number
106
+ private seenFrames = 0
107
+
108
+ private buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0)
109
+ private preroll: Buffer<ArrayBufferLike>[] = []
110
+ private speech: Buffer<ArrayBufferLike>[] = []
111
+ private inSpeech = false
112
+ private loudRun = 0
113
+ private silenceRun = 0
114
+ private peak = 0
115
+ private floor = -1
116
+
117
+ constructor(opts: SegmenterOptions = {}) {
118
+ this.ratio = opts.ratio ?? 3
119
+ this.minRms = opts.minRms ?? 200
120
+ this.onsetFrames = opts.onsetFrames ?? 3
121
+ this.hangoverFrames = Math.round((opts.hangoverMs ?? 900) / FRAME_MS)
122
+ this.prerollFrames = Math.round((opts.prerollMs ?? 320) / FRAME_MS)
123
+ this.minFrames = Math.round((opts.minUtteranceMs ?? 350) / FRAME_MS)
124
+ this.maxFrames = Math.round((opts.maxUtteranceMs ?? 18_000) / FRAME_MS)
125
+ this.warmupFrames = Math.round((opts.warmupMs ?? 600) / FRAME_MS)
126
+ }
127
+
128
+ get noiseFloor(): number {
129
+ return this.floor < 0 ? 0 : this.floor
130
+ }
131
+
132
+ /** Feed captured bytes; returns every utterance completed by this chunk. */
133
+ push(chunk: Buffer): Segment[] {
134
+ this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk
135
+ const out: Segment[] = []
136
+ while (this.buffer.length >= FRAME_BYTES) {
137
+ const frame = this.buffer.subarray(0, FRAME_BYTES)
138
+ this.buffer = this.buffer.subarray(FRAME_BYTES)
139
+ const seg = this.pushFrame(Buffer.from(frame))
140
+ if (seg) out.push(seg)
141
+ }
142
+ return out
143
+ }
144
+
145
+ /** Abandon anything half-captured (used when the mic is gated — e.g. while the
146
+ * speaker is talking — so the runtime never transcribes its own voice). */
147
+ reset(): void {
148
+ this.buffer = Buffer.alloc(0)
149
+ this.preroll = []
150
+ this.speech = []
151
+ this.inSpeech = false
152
+ this.loudRun = 0
153
+ this.silenceRun = 0
154
+ this.peak = 0
155
+ }
156
+
157
+ private threshold(): number {
158
+ const adaptive = this.floor < 0 ? this.minRms : this.floor * this.ratio
159
+ return Math.max(this.minRms, adaptive)
160
+ }
161
+
162
+ private pushFrame(frame: Buffer): Segment | null {
163
+ const rms = frameRms(frame)
164
+ this.seenFrames += 1
165
+ const loud = rms >= this.threshold() && this.seenFrames > this.warmupFrames
166
+
167
+ if (!this.inSpeech) {
168
+ // Track the room's noise floor from quiet frames only, so a long utterance can
169
+ // never drag the threshold up above the speaker's own voice.
170
+ if (!loud) this.floor = this.floor < 0 ? rms : this.floor * 0.95 + rms * 0.05
171
+ this.preroll.push(frame)
172
+ if (this.preroll.length > this.prerollFrames) this.preroll.shift()
173
+ this.loudRun = loud ? this.loudRun + 1 : 0
174
+ if (this.loudRun >= this.onsetFrames) {
175
+ this.inSpeech = true
176
+ this.speech = [...this.preroll]
177
+ this.preroll = []
178
+ this.silenceRun = 0
179
+ this.peak = rms
180
+ }
181
+ return null
182
+ }
183
+
184
+ this.speech.push(frame)
185
+ if (rms > this.peak) this.peak = rms
186
+ this.silenceRun = loud ? 0 : this.silenceRun + 1
187
+ if (this.silenceRun >= this.hangoverFrames) return this.close(false)
188
+ if (this.speech.length >= this.maxFrames) return this.close(true)
189
+ return null
190
+ }
191
+
192
+ private close(truncated: boolean): Segment | null {
193
+ const frames = this.speech
194
+ const peak = this.peak
195
+ const voiced = frames.length - (truncated ? 0 : this.silenceRun)
196
+ this.inSpeech = false
197
+ this.speech = []
198
+ this.preroll = []
199
+ this.loudRun = 0
200
+ this.silenceRun = 0
201
+ this.peak = 0
202
+ if (voiced < this.minFrames) return null
203
+ const pcm = Buffer.concat(frames)
204
+ return {
205
+ pcm,
206
+ ms: Math.round((pcm.length / 2 / SAMPLE_RATE) * 1000),
207
+ peakRms: peak,
208
+ noiseFloor: this.noiseFloor,
209
+ threshold: Math.round(this.threshold()),
210
+ truncated,
211
+ }
212
+ }
213
+ }
214
+
215
+ // ─────────────────────────────────────────────────────────────────────────────
216
+ // devices + capture
217
+ // ─────────────────────────────────────────────────────────────────────────────
218
+
219
+ export type InputDevice = { index: number; name: string }
220
+
221
+ function run(cmd: string, args: string[], timeoutMs = 10_000): Promise<{ code: number; stdout: string; stderr: string }> {
222
+ return new Promise(resolve => {
223
+ let stdout = ''
224
+ let stderr = ''
225
+ let done = false
226
+ const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] })
227
+ const timer = setTimeout(() => {
228
+ try {
229
+ child.kill('SIGKILL')
230
+ } catch {}
231
+ }, timeoutMs)
232
+ const finish = (code: number) => {
233
+ if (done) return
234
+ done = true
235
+ clearTimeout(timer)
236
+ resolve({ code, stdout, stderr })
237
+ }
238
+ child.stdout.on('data', d => (stdout += String(d)))
239
+ child.stderr.on('data', d => (stderr += String(d)))
240
+ child.on('error', () => finish(-1))
241
+ child.on('close', code => finish(code ?? -1))
242
+ })
243
+ }
244
+
245
+ /** Parse the avfoundation device dump — the audio section only. Exported for tests
246
+ * (ffmpeg prints the listing on stderr and always exits non-zero). */
247
+ export function parseAvfoundationAudioDevices(stderr: string): InputDevice[] {
248
+ const out: InputDevice[] = []
249
+ let inAudio = false
250
+ for (const line of stderr.split('\n')) {
251
+ if (/AVFoundation video devices:/.test(line)) inAudio = false
252
+ if (/AVFoundation audio devices:/.test(line)) {
253
+ inAudio = true
254
+ continue
255
+ }
256
+ if (!inAudio) continue
257
+ const m = /\[(\d+)\]\s+(.+?)\s*$/.exec(line.replace(/^\[AVFoundation indev[^\]]*\]\s*/, ''))
258
+ if (m) out.push({ index: Number(m[1]), name: m[2] })
259
+ }
260
+ return out
261
+ }
262
+
263
+ export async function listInputDevices(ffmpegBin = 'ffmpeg'): Promise<InputDevice[]> {
264
+ const r = await run(ffmpegBin, ['-hide_banner', '-f', 'avfoundation', '-list_devices', 'true', '-i', ''])
265
+ return parseAvfoundationAudioDevices(r.stderr)
266
+ }
267
+
268
+ /** Resolve `interfaces.voicetalk.device` to an avfoundation input index. A number is
269
+ * taken as-is; a string is matched (case-insensitive substring) against device names;
270
+ * unset = index 0, the system default input. */
271
+ export async function resolveInputDevice(
272
+ spec: string | undefined,
273
+ ffmpegBin = 'ffmpeg',
274
+ ): Promise<{ index: number; name?: string; matched: 'default' | 'index' | 'name'; devices: InputDevice[] }> {
275
+ const devices = await listInputDevices(ffmpegBin)
276
+ if (spec === undefined || spec.trim() === '') {
277
+ return { index: 0, name: devices[0]?.name, matched: 'default', devices }
278
+ }
279
+ const trimmed = spec.trim()
280
+ if (/^\d+$/.test(trimmed)) {
281
+ const idx = Number(trimmed)
282
+ return { index: idx, name: devices.find(d => d.index === idx)?.name, matched: 'index', devices }
283
+ }
284
+ const needle = trimmed.toLowerCase()
285
+ const hit = devices.find(d => d.name.toLowerCase().includes(needle))
286
+ if (!hit) {
287
+ throw new Error(
288
+ `audio input device "${spec}" not found — available: ${devices.map(d => `[${d.index}] ${d.name}`).join(', ') || 'none'}`,
289
+ )
290
+ }
291
+ return { index: hit.index, name: hit.name, matched: 'name', devices }
292
+ }
293
+
294
+ export type MicOptions = {
295
+ deviceIndex: number
296
+ ffmpegBin?: string
297
+ onData: (chunk: Buffer) => void
298
+ onError?: (detail: string) => void
299
+ /** Called on every (re)start of the capture child — the caller logs/announces it. */
300
+ onStart?: (attempt: number) => void
301
+ }
302
+
303
+ /** A self-healing microphone: one ffmpeg child streaming raw PCM, restarted with backoff
304
+ * if it dies (device unplugged, sleep/wake). Owns nothing else — the caller decides what
305
+ * to do with the bytes. */
306
+ export class Microphone {
307
+ private child: ChildProcessByStdio<null, Readable, Readable> | null = null
308
+ private stopped = false
309
+ private attempt = 0
310
+ private timer: ReturnType<typeof setTimeout> | null = null
311
+
312
+ constructor(private readonly opts: MicOptions) {}
313
+
314
+ start(): void {
315
+ this.stopped = false
316
+ this.spawnChild()
317
+ }
318
+
319
+ stop(): void {
320
+ this.stopped = true
321
+ if (this.timer) clearTimeout(this.timer)
322
+ this.timer = null
323
+ try {
324
+ this.child?.kill('SIGKILL')
325
+ } catch {}
326
+ this.child = null
327
+ }
328
+
329
+ get running(): boolean {
330
+ return this.child !== null
331
+ }
332
+
333
+ private spawnChild(): void {
334
+ if (this.stopped) return
335
+ this.attempt += 1
336
+ const bin = this.opts.ffmpegBin ?? 'ffmpeg'
337
+ const args = [
338
+ '-hide_banner',
339
+ '-loglevel', 'error',
340
+ '-f', 'avfoundation',
341
+ '-i', `:${this.opts.deviceIndex}`,
342
+ '-ac', '1',
343
+ '-ar', String(SAMPLE_RATE),
344
+ '-f', 's16le',
345
+ '-',
346
+ ]
347
+ let child: ChildProcessByStdio<null, Readable, Readable>
348
+ try {
349
+ child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] })
350
+ } catch (err) {
351
+ this.opts.onError?.(err instanceof Error ? err.message : String(err))
352
+ this.scheduleRestart()
353
+ return
354
+ }
355
+ this.child = child
356
+ this.opts.onStart?.(this.attempt)
357
+ child.stdout.on('data', (d: Buffer) => this.opts.onData(d))
358
+ child.stderr.on('data', (d: Buffer) => {
359
+ const detail = String(d).trim()
360
+ if (detail) this.opts.onError?.(detail)
361
+ })
362
+ child.on('error', err => this.opts.onError?.(err.message))
363
+ child.on('close', code => {
364
+ this.child = null
365
+ if (this.stopped) return
366
+ this.opts.onError?.(`capture exited (code ${code}) — restarting`)
367
+ this.scheduleRestart()
368
+ })
369
+ }
370
+
371
+ private scheduleRestart(): void {
372
+ if (this.stopped || this.timer) return
373
+ const delay = Math.min(30_000, 1_000 * Math.min(8, this.attempt))
374
+ this.timer = setTimeout(() => {
375
+ this.timer = null
376
+ this.spawnChild()
377
+ }, delay)
378
+ }
379
+ }
380
+
381
+ // ─────────────────────────────────────────────────────────────────────────────
382
+ // playback + earcons
383
+ // ─────────────────────────────────────────────────────────────────────────────
384
+
385
+ /** Play a file on the system default output. `.ogg/opus` needs ffplay; WAV can go
386
+ * through afplay (no ffmpeg dependency for the earcons). Resolves when playback ends. */
387
+ export function playFile(path: string, opts: { bin?: string; timeoutMs?: number } = {}): Promise<void> {
388
+ const isWav = /\.wav$/i.test(path)
389
+ const bin = opts.bin ?? (isWav ? 'afplay' : 'ffplay')
390
+ const args = isWav && bin === 'afplay' ? [path] : ['-hide_banner', '-loglevel', 'error', '-nodisp', '-autoexit', path]
391
+ return new Promise((resolve, reject) => {
392
+ let settled = false
393
+ const child = spawn(bin, args, { stdio: ['ignore', 'ignore', 'pipe'] })
394
+ let stderr = ''
395
+ const timer = setTimeout(() => {
396
+ try {
397
+ child.kill('SIGKILL')
398
+ } catch {}
399
+ }, opts.timeoutMs ?? 180_000)
400
+ const finish = (err?: Error) => {
401
+ if (settled) return
402
+ settled = true
403
+ clearTimeout(timer)
404
+ err ? reject(err) : resolve()
405
+ }
406
+ child.stderr.on('data', d => (stderr += String(d)))
407
+ child.on('error', err => finish(new Error(`${bin}: ${err.message}`)))
408
+ child.on('close', code => finish(code === 0 ? undefined : new Error(`${bin} exited ${code}: ${stderr.trim().slice(0, 200)}`)))
409
+ })
410
+ }
411
+
412
+ /** Synthesize a short earcon (state beep) as a WAV buffer — no external process, no
413
+ * network. Two-tone rising = "I heard you"; falling = "not for me / closed". */
414
+ export function earconWav(tones: Array<{ hz: number; ms: number }>, gain = 0.22): Buffer {
415
+ const parts: Buffer[] = []
416
+ for (const tone of tones) {
417
+ const samples = Math.round((SAMPLE_RATE * tone.ms) / 1000)
418
+ const buf = Buffer.alloc(samples * 2)
419
+ for (let i = 0; i < samples; i++) {
420
+ // Raised-cosine envelope — a click-free beep.
421
+ const env = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / Math.max(1, samples - 1))
422
+ const v = Math.sin((2 * Math.PI * tone.hz * i) / SAMPLE_RATE) * env * gain
423
+ buf.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round(v * 32767))), i * 2)
424
+ }
425
+ parts.push(buf)
426
+ }
427
+ return pcmToWav(Buffer.concat(parts))
428
+ }