@agfpd/voicetalk-runtime 0.1.1 → 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 +15 -11
- package/docs/README.md +55 -4
- package/docs/ru/README.md +54 -4
- package/package.json +1 -1
- package/src/audio.ts +428 -0
- package/src/cli.ts +244 -67
- package/src/selfConfig.ts +32 -1
- package/src/voiceConnect.ts +191 -0
- package/src/voiceSession.ts +353 -0
- package/src/wake.ts +279 -0
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: **
|
|
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
|
|
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
|
|
42
|
-
|
|
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
|
|
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"
|
|
49
|
+
voicetalk-runtime say --peer linus "what's the build status" # inject an utterance without the mic (debug)
|
|
48
50
|
```
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
spoken
|
|
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
|
|
64
|
-
-
|
|
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
|
|
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:
|
|
8
|
-
>
|
|
9
|
-
>
|
|
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
|
|
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
|
-
> Статус:
|
|
8
|
-
>
|
|
9
|
-
>
|
|
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
|
|
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
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
|
+
}
|