@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
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// voice-connect client — the ONLY place this package talks to speech.
|
|
2
|
+
//
|
|
3
|
+
// Speech ↔ text is not ours (decision: «Голосовой слой voice-connect — единый сервис,
|
|
4
|
+
// core плюс два фасада»). voice-connect runs as an always-on local HTTP service and
|
|
5
|
+
// publishes a discovery slot at <IAPEER_ROOT>/voice-provider.json; we are a pure
|
|
6
|
+
// CONSUMER of its OpenAI-compatible routes:
|
|
7
|
+
//
|
|
8
|
+
// POST /v1/audio/transcriptions multipart(file, language?, engine?) → { text }
|
|
9
|
+
// POST /v1/audio/speech json(input, voice?, lang?, engine?) → ogg/opus bytes
|
|
10
|
+
// GET /health → { status }
|
|
11
|
+
//
|
|
12
|
+
// Engine selection, provider cascade and fallbacks live INSIDE that service — never
|
|
13
|
+
// re-implement any of it here.
|
|
14
|
+
|
|
15
|
+
import { readFileSync, writeFileSync } from 'fs'
|
|
16
|
+
import { homedir } from 'os'
|
|
17
|
+
import { join } from 'path'
|
|
18
|
+
|
|
19
|
+
export type VoiceEndpoint = {
|
|
20
|
+
base: string
|
|
21
|
+
tts: string
|
|
22
|
+
stt: string
|
|
23
|
+
health: string
|
|
24
|
+
/** Where the endpoint came from — surfaced by `doctor` so a misrouted host is obvious. */
|
|
25
|
+
source: 'env' | 'slot' | 'default'
|
|
26
|
+
version?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_BASE = 'http://127.0.0.1:8127'
|
|
30
|
+
|
|
31
|
+
function iapeerRoot(): string {
|
|
32
|
+
const override = process.env.IAPEER_ROOT?.trim()
|
|
33
|
+
return override || join(homedir(), '.iapeer')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function providerSlotPath(): string {
|
|
37
|
+
return join(iapeerRoot(), 'voice-provider.json')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function trimSlash(url: string): string {
|
|
41
|
+
return url.replace(/\/+$/, '')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolve the voice service endpoint: explicit env override → the discovery slot
|
|
45
|
+
* voice-connect writes on install → the documented local default. Never throws;
|
|
46
|
+
* a missing/corrupt slot degrades to the default (and `doctor`/health surfaces it). */
|
|
47
|
+
export function resolveVoiceEndpoint(env: NodeJS.ProcessEnv = process.env): VoiceEndpoint {
|
|
48
|
+
const routes = { tts: '/v1/audio/speech', stt: '/v1/audio/transcriptions', health: '/health' }
|
|
49
|
+
const override = env.VOICETALK_VOICE_ENDPOINT?.trim()
|
|
50
|
+
if (override) {
|
|
51
|
+
const base = trimSlash(override)
|
|
52
|
+
return { base, tts: base + routes.tts, stt: base + routes.stt, health: base + routes.health, source: 'env' }
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const slot = JSON.parse(readFileSync(providerSlotPath(), 'utf8')) as {
|
|
56
|
+
endpoint?: string
|
|
57
|
+
host?: string
|
|
58
|
+
port?: number
|
|
59
|
+
version?: string
|
|
60
|
+
routes?: Partial<typeof routes>
|
|
61
|
+
}
|
|
62
|
+
const base = trimSlash(
|
|
63
|
+
slot.endpoint?.trim() || (slot.host && slot.port ? `http://${slot.host}:${slot.port}` : ''),
|
|
64
|
+
)
|
|
65
|
+
if (base) {
|
|
66
|
+
return {
|
|
67
|
+
base,
|
|
68
|
+
tts: base + (slot.routes?.tts ?? routes.tts),
|
|
69
|
+
stt: base + (slot.routes?.stt ?? routes.stt),
|
|
70
|
+
health: base + (slot.routes?.health ?? routes.health),
|
|
71
|
+
source: 'slot',
|
|
72
|
+
version: slot.version,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// no slot / unreadable → fall through to the default
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
base: DEFAULT_BASE,
|
|
80
|
+
tts: DEFAULT_BASE + routes.tts,
|
|
81
|
+
stt: DEFAULT_BASE + routes.stt,
|
|
82
|
+
health: DEFAULT_BASE + routes.health,
|
|
83
|
+
source: 'default',
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type VoiceHealth = { ok: boolean; endpoint: string; source: string; version?: string; detail?: string }
|
|
88
|
+
|
|
89
|
+
export async function voiceHealth(timeoutMs = 4_000): Promise<VoiceHealth> {
|
|
90
|
+
const ep = resolveVoiceEndpoint()
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(ep.health, { signal: AbortSignal.timeout(timeoutMs) })
|
|
93
|
+
if (!res.ok) return { ok: false, endpoint: ep.base, source: ep.source, detail: `HTTP ${res.status}` }
|
|
94
|
+
const body = (await res.json()) as { status?: string; version?: string }
|
|
95
|
+
return {
|
|
96
|
+
ok: body?.status === 'ok',
|
|
97
|
+
endpoint: ep.base,
|
|
98
|
+
source: ep.source,
|
|
99
|
+
version: body?.version ?? ep.version,
|
|
100
|
+
detail: body?.status === 'ok' ? undefined : `status=${body?.status ?? 'unknown'}`,
|
|
101
|
+
}
|
|
102
|
+
} catch (err) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
endpoint: ep.base,
|
|
106
|
+
source: ep.source,
|
|
107
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type TranscribeOptions = {
|
|
113
|
+
/** ISO-639-1 hint. Omitted → the service auto-detects. */
|
|
114
|
+
lang?: string
|
|
115
|
+
/** Force an STT engine (`mlx-whisper` keeps it fully local); default = service cascade. */
|
|
116
|
+
engine?: string
|
|
117
|
+
/** Decoder-priming prompt — biases spelling of fleet names so the wake matcher sees
|
|
118
|
+
* «Борис», not «Барис». */
|
|
119
|
+
prompt?: string
|
|
120
|
+
timeoutMs?: number
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Transcribe a local audio file. Returns the raw transcript (may be empty). */
|
|
124
|
+
export async function transcribe(audioPath: string, opts: TranscribeOptions = {}): Promise<string> {
|
|
125
|
+
const ep = resolveVoiceEndpoint()
|
|
126
|
+
const form = new FormData()
|
|
127
|
+
const bytes = readFileSync(audioPath)
|
|
128
|
+
form.append('file', new Blob([new Uint8Array(bytes)], { type: 'audio/wav' }), audioPath.split('/').pop() ?? 'audio.wav')
|
|
129
|
+
if (opts.lang) form.append('language', opts.lang)
|
|
130
|
+
if (opts.engine) form.append('engine', opts.engine)
|
|
131
|
+
if (opts.prompt) form.append('prompt', opts.prompt)
|
|
132
|
+
form.append('response_format', 'json')
|
|
133
|
+
const res = await fetch(ep.stt, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
body: form,
|
|
136
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 90_000),
|
|
137
|
+
})
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
throw new Error(`voice-connect stt ${res.status}: ${(await res.text()).slice(0, 300)}`)
|
|
140
|
+
}
|
|
141
|
+
const body = (await res.json()) as { text?: string }
|
|
142
|
+
return (body?.text ?? '').trim()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type SynthesizeOptions = {
|
|
146
|
+
/** Per-peer voice (the sender's `interfaces.voice` entry) — so agents sound distinct. */
|
|
147
|
+
voice?: string
|
|
148
|
+
lang?: string
|
|
149
|
+
style?: string
|
|
150
|
+
engine?: string
|
|
151
|
+
timeoutMs?: number
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type SynthesizeResult = {
|
|
155
|
+
path: string
|
|
156
|
+
engine?: string
|
|
157
|
+
voice?: string
|
|
158
|
+
fallbackFrom?: string
|
|
159
|
+
bytes: number
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Synthesize text to a local .ogg file. The HTTP facade is always synchronous — it
|
|
163
|
+
* returns the audio bytes, delivery is our job (the speaker). */
|
|
164
|
+
export async function synthesize(text: string, outPath: string, opts: SynthesizeOptions = {}): Promise<SynthesizeResult> {
|
|
165
|
+
const ep = resolveVoiceEndpoint()
|
|
166
|
+
const res = await fetch(ep.tts, {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: { 'content-type': 'application/json' },
|
|
169
|
+
body: JSON.stringify({
|
|
170
|
+
input: text,
|
|
171
|
+
...(opts.voice ? { voice: opts.voice } : {}),
|
|
172
|
+
...(opts.lang ? { lang: opts.lang } : {}),
|
|
173
|
+
...(opts.style ? { style: opts.style } : {}),
|
|
174
|
+
...(opts.engine ? { engine: opts.engine } : {}),
|
|
175
|
+
}),
|
|
176
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 120_000),
|
|
177
|
+
})
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
throw new Error(`voice-connect tts ${res.status}: ${(await res.text()).slice(0, 300)}`)
|
|
180
|
+
}
|
|
181
|
+
const buf = Buffer.from(await res.arrayBuffer())
|
|
182
|
+
if (buf.length === 0) throw new Error('voice-connect tts returned 0 bytes')
|
|
183
|
+
writeFileSync(outPath, buf, { mode: 0o600 })
|
|
184
|
+
return {
|
|
185
|
+
path: outPath,
|
|
186
|
+
engine: res.headers.get('x-voice-engine') ?? undefined,
|
|
187
|
+
voice: res.headers.get('x-voice-voice') ?? undefined,
|
|
188
|
+
fallbackFrom: res.headers.get('x-voice-fallback-from') ?? undefined,
|
|
189
|
+
bytes: buf.length,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// voiceSession.ts — the live voice session (Фаза 3): the runtime's ears, its turn-taking
|
|
2
|
+
// and its mouth, wired between audio.ts (host mechanics) and voiceConnect.ts (speech).
|
|
3
|
+
//
|
|
4
|
+
// INBOUND mic → endpointing → STT → address match → deliver to the fleet
|
|
5
|
+
// OUTBOUND agent envelope → TTS → speaker, then an open-mic follow-up window
|
|
6
|
+
//
|
|
7
|
+
// Turn-taking follows the established voice-assistant pattern recorded in Описание
|
|
8
|
+
// voicetalk: the address (wake word) only INITIATES a conversation; after an agent has
|
|
9
|
+
// answered, a short open-mic window lets the owner continue without naming the peer
|
|
10
|
+
// again, and the window closes on silence. The microphone is gated while the speaker is
|
|
11
|
+
// talking, so the runtime never transcribes its own voice.
|
|
12
|
+
//
|
|
13
|
+
// The numeric defaults below are NOT invented here — they come from the turn-taking
|
|
14
|
+
// research prepared for this phase (~/Peers/perplex/research-tree/voice-ux-turntaking/
|
|
15
|
+
// 2026-06-21_turn-taking-defaults.md), which converges Alexa / Google / Siri / Алиса and
|
|
16
|
+
// the open-source stacks: open-mic window 8–10 s, endpointing pause 500 ms (500–800 ms
|
|
17
|
+
// for free dialogue), rising earcon on mic-open / short descending on utterance-end /
|
|
18
|
+
// double descending on error, and NO earcon while processing.
|
|
19
|
+
//
|
|
20
|
+
// Barge-in (interrupting the spoken answer) is deliberately OUT of Фаза 3: the same
|
|
21
|
+
// research shows it requires acoustic echo cancellation plus a neural VAD, neither of
|
|
22
|
+
// which exists here yet. Mic gating during playback is the honest substitute — the
|
|
23
|
+
// runtime simply does not listen while it talks.
|
|
24
|
+
|
|
25
|
+
import { mkdirSync, rmSync } from 'fs'
|
|
26
|
+
import { join } from 'path'
|
|
27
|
+
import { randomUUID } from 'crypto'
|
|
28
|
+
import {
|
|
29
|
+
Microphone,
|
|
30
|
+
Segmenter,
|
|
31
|
+
earconWav,
|
|
32
|
+
playFile,
|
|
33
|
+
resolveInputDevice,
|
|
34
|
+
writeWav,
|
|
35
|
+
type Segment,
|
|
36
|
+
} from './audio.ts'
|
|
37
|
+
import { synthesize, transcribe } from './voiceConnect.ts'
|
|
38
|
+
import {
|
|
39
|
+
buildAddressBook,
|
|
40
|
+
isLikelyHallucination,
|
|
41
|
+
matchWake,
|
|
42
|
+
readFleetPeers,
|
|
43
|
+
sttPrompt,
|
|
44
|
+
type Address,
|
|
45
|
+
type FleetPeer,
|
|
46
|
+
type VoicetalkBinding,
|
|
47
|
+
} from './wake.ts'
|
|
48
|
+
import { writeFileSync } from 'fs'
|
|
49
|
+
|
|
50
|
+
const num = (v: string | undefined, d: number) => (v && Number(v) > 0 ? Number(v) : d)
|
|
51
|
+
|
|
52
|
+
/** How long after a turn the mic stays open for a follow-up without re-addressing.
|
|
53
|
+
* Research default: 8–10 s (Google Continued Conversation ≈10 s; Alexa's 5 s reads as
|
|
54
|
+
* too short). */
|
|
55
|
+
const FOLLOWUP_MS = num(process.env.VOICETALK_FOLLOWUP_MS, 9_000)
|
|
56
|
+
/** Silence that ends an utterance (endpointing pause). Research default 500 ms, band
|
|
57
|
+
* 500–800 ms for free dialogue; the upper half of the band suits a heavier recognizer —
|
|
58
|
+
* it fragments one thought into two transcriptions far less often. */
|
|
59
|
+
const HANGOVER_MS = num(process.env.VOICETALK_HANGOVER_MS, 700)
|
|
60
|
+
/** Gate the mic for a moment after the speaker stops — room echo tail. */
|
|
61
|
+
const ECHO_TAIL_MS = num(process.env.VOICETALK_ECHO_TAIL_MS, 400)
|
|
62
|
+
/** Long answers are truncated for speech: a spoken monologue is worse than a summary. */
|
|
63
|
+
const TTS_MAX_CHARS = num(process.env.VOICETALK_TTS_MAX_CHARS, 1_200)
|
|
64
|
+
/** Language hint for STT; the owner's language. Empty = let the service auto-detect. */
|
|
65
|
+
const STT_LANG = process.env.VOICETALK_STT_LANG ?? 'ru'
|
|
66
|
+
/** Force a local recognizer (mlx-whisper) to keep always-on listening on-device. */
|
|
67
|
+
const STT_ENGINE = process.env.VOICETALK_STT_ENGINE?.trim() || undefined
|
|
68
|
+
|
|
69
|
+
export type DeliverFn = (target: string, text: string) => Promise<{ ok: boolean; detail?: string }>
|
|
70
|
+
export type LogFn = (evt: string, fields: Record<string, unknown>) => void
|
|
71
|
+
|
|
72
|
+
export type VoiceSessionOptions = {
|
|
73
|
+
owner: string
|
|
74
|
+
binding: VoicetalkBinding
|
|
75
|
+
deliver: DeliverFn
|
|
76
|
+
log: LogFn
|
|
77
|
+
/** Scratch dir for captured WAVs and synthesized OGGs. */
|
|
78
|
+
tmpDir: string
|
|
79
|
+
peers?: FleetPeer[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** State sounds, per the earcon convention in the research: `open` = the microphone is
|
|
83
|
+
* yours (rising, ≤300 ms), `end` = utterance captured, processing (short descending,
|
|
84
|
+
* ≤200 ms), `error` = nothing routed (double descending). Processing itself is silent. */
|
|
85
|
+
type Earcons = { open: string; end: string; error: string }
|
|
86
|
+
|
|
87
|
+
export class VoiceSession {
|
|
88
|
+
private mic: Microphone | null = null
|
|
89
|
+
private readonly segmenter = new Segmenter({
|
|
90
|
+
hangoverMs: HANGOVER_MS,
|
|
91
|
+
ratio: num(process.env.VOICETALK_VAD_RATIO, 3.5),
|
|
92
|
+
minRms: num(process.env.VOICETALK_VAD_MIN_RMS, 700),
|
|
93
|
+
})
|
|
94
|
+
private book: Address[] = []
|
|
95
|
+
private prompt = ''
|
|
96
|
+
private earcons: Earcons | null = null
|
|
97
|
+
private busy = false
|
|
98
|
+
private muteUntil = 0
|
|
99
|
+
private followTarget: string | null = null
|
|
100
|
+
private followUntil = 0
|
|
101
|
+
private stopped = false
|
|
102
|
+
|
|
103
|
+
constructor(private readonly opts: VoiceSessionOptions) {}
|
|
104
|
+
|
|
105
|
+
/** Resolve the input device, prime the address book, open the mic. Throws if the mic
|
|
106
|
+
* cannot be resolved — the caller decides whether that is fatal (it is not: the
|
|
107
|
+
* router keeps running text-only). */
|
|
108
|
+
async start(): Promise<{ deviceIndex: number; deviceName?: string; addresses: number }> {
|
|
109
|
+
mkdirSync(this.opts.tmpDir, { recursive: true, mode: 0o700 })
|
|
110
|
+
this.refreshAddressBook()
|
|
111
|
+
this.earcons = this.writeEarcons()
|
|
112
|
+
const device = await resolveInputDevice(this.opts.binding.device)
|
|
113
|
+
this.mic = new Microphone({
|
|
114
|
+
deviceIndex: device.index,
|
|
115
|
+
onData: chunk => this.onAudio(chunk),
|
|
116
|
+
onStart: attempt => this.opts.log('mic.start', { device: device.index, name: device.name, attempt }),
|
|
117
|
+
onError: detail => this.opts.log('mic.error', { detail }),
|
|
118
|
+
})
|
|
119
|
+
this.mic.start()
|
|
120
|
+
return { deviceIndex: device.index, deviceName: device.name, addresses: this.book.length }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
stop(): void {
|
|
124
|
+
this.stopped = true
|
|
125
|
+
this.mic?.stop()
|
|
126
|
+
this.mic = null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private refreshAddressBook(): void {
|
|
130
|
+
this.book = buildAddressBook({
|
|
131
|
+
owner: this.opts.owner,
|
|
132
|
+
binding: this.opts.binding,
|
|
133
|
+
peers: this.opts.peers ?? readFleetPeers(),
|
|
134
|
+
})
|
|
135
|
+
this.prompt = sttPrompt(this.book)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private writeEarcons(): Earcons {
|
|
139
|
+
const files: Earcons = {
|
|
140
|
+
open: join(this.opts.tmpDir, 'earcon-open.wav'),
|
|
141
|
+
end: join(this.opts.tmpDir, 'earcon-end.wav'),
|
|
142
|
+
error: join(this.opts.tmpDir, 'earcon-error.wav'),
|
|
143
|
+
}
|
|
144
|
+
// A5→C6→E6 rising, 240 ms — the microphone is open.
|
|
145
|
+
writeFileSync(files.open, earconWav([{ hz: 880, ms: 80 }, { hz: 1047, ms: 80 }, { hz: 1319, ms: 80 }]), { mode: 0o600 })
|
|
146
|
+
// Short descending, 160 ms — heard you, working on it.
|
|
147
|
+
writeFileSync(files.end, earconWav([{ hz: 880, ms: 80 }, { hz: 660, ms: 80 }]), { mode: 0o600 })
|
|
148
|
+
// Double descending, 260 ms — tonally "closed": nothing was routed.
|
|
149
|
+
writeFileSync(files.error, earconWav([{ hz: 520, ms: 120 }, { hz: 330, ms: 140 }]), { mode: 0o600 })
|
|
150
|
+
return files
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private async earcon(kind: keyof Earcons): Promise<void> {
|
|
154
|
+
if (!this.earcons || process.env.VOICETALK_EARCONS === '0') return
|
|
155
|
+
try {
|
|
156
|
+
this.muteUntil = Math.max(this.muteUntil, Date.now() + 600)
|
|
157
|
+
await playFile(this.earcons[kind], { timeoutMs: 5_000 })
|
|
158
|
+
} catch (err) {
|
|
159
|
+
this.opts.log('earcon.error', { kind, detail: err instanceof Error ? err.message : String(err) })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private muted(): boolean {
|
|
164
|
+
return Date.now() < this.muteUntil
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private onAudio(chunk: Buffer): void {
|
|
168
|
+
if (this.stopped) return
|
|
169
|
+
if (this.muted()) {
|
|
170
|
+
// Our own speech (or an earcon) is in the room — throw the audio away rather than
|
|
171
|
+
// letting the endpointer build an utterance out of it.
|
|
172
|
+
this.segmenter.reset()
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
for (const segment of this.segmenter.push(chunk)) {
|
|
176
|
+
if (this.busy) {
|
|
177
|
+
// One utterance in flight at a time: STT is the expensive step and a pile-up
|
|
178
|
+
// would only add latency to the turn that matters.
|
|
179
|
+
this.opts.log('segment.drop', { reason: 'busy', ms: segment.ms })
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
this.busy = true
|
|
183
|
+
void this.handleSegment(segment).finally(() => {
|
|
184
|
+
this.busy = false
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private async handleSegment(segment: Segment): Promise<void> {
|
|
190
|
+
const wav = join(this.opts.tmpDir, `utt-${randomUUID()}.wav`)
|
|
191
|
+
const started = Date.now()
|
|
192
|
+
try {
|
|
193
|
+
writeWav(segment.pcm, wav)
|
|
194
|
+
let transcript = ''
|
|
195
|
+
try {
|
|
196
|
+
transcript = await transcribe(wav, {
|
|
197
|
+
lang: STT_LANG || undefined,
|
|
198
|
+
engine: STT_ENGINE,
|
|
199
|
+
prompt: this.prompt || undefined,
|
|
200
|
+
})
|
|
201
|
+
} catch (err) {
|
|
202
|
+
this.opts.log('stt.error', { ms: segment.ms, detail: err instanceof Error ? err.message : String(err) })
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
const sttMs = Date.now() - started
|
|
206
|
+
if (isLikelyHallucination(transcript)) {
|
|
207
|
+
this.opts.log('stt.ignored', {
|
|
208
|
+
ms: segment.ms,
|
|
209
|
+
sttMs,
|
|
210
|
+
peak: Math.round(segment.peakRms),
|
|
211
|
+
floor: Math.round(segment.noiseFloor),
|
|
212
|
+
text: transcript,
|
|
213
|
+
})
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
this.opts.log('stt.text', {
|
|
217
|
+
ms: segment.ms,
|
|
218
|
+
sttMs,
|
|
219
|
+
peak: Math.round(segment.peakRms),
|
|
220
|
+
floor: Math.round(segment.noiseFloor),
|
|
221
|
+
threshold: segment.threshold,
|
|
222
|
+
text: transcript,
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
const match = matchWake(transcript, this.book)
|
|
226
|
+
const followOpen = this.followTarget && Date.now() < this.followUntil
|
|
227
|
+
let target: string | null = null
|
|
228
|
+
let body = ''
|
|
229
|
+
if (match) {
|
|
230
|
+
target = match.target
|
|
231
|
+
body = match.body
|
|
232
|
+
if (!body) {
|
|
233
|
+
// Addressed with nothing to say yet («Борис?») — acknowledge and hold the mic
|
|
234
|
+
// open for the actual request, exactly as an assistant beeps and waits.
|
|
235
|
+
this.followTarget = target
|
|
236
|
+
this.followUntil = Date.now() + FOLLOWUP_MS
|
|
237
|
+
this.opts.log('wake.await-body', { target, alias: match.alias })
|
|
238
|
+
await this.earcon('open')
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
} else if (followOpen) {
|
|
242
|
+
target = this.followTarget
|
|
243
|
+
body = transcript.trim()
|
|
244
|
+
this.opts.log('followup.route', { target })
|
|
245
|
+
} else {
|
|
246
|
+
this.opts.log('wake.no-match', { text: transcript })
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The window is extended for the whole turn: the agent's answer will extend it
|
|
251
|
+
// again when it is spoken.
|
|
252
|
+
this.followTarget = target
|
|
253
|
+
this.followUntil = Date.now() + FOLLOWUP_MS
|
|
254
|
+
// Utterance accepted → the "end of listening, now processing" sound. Delivery
|
|
255
|
+
// itself stays SILENT (processing earns no earcon) — the agent's own voice is the
|
|
256
|
+
// next thing the owner hears.
|
|
257
|
+
await this.earcon('end')
|
|
258
|
+
const verdict = await this.opts.deliver(target!, body)
|
|
259
|
+
if (verdict.ok) {
|
|
260
|
+
this.opts.log('voice.delivered', { target, chars: body.length, totalMs: Date.now() - started })
|
|
261
|
+
} else {
|
|
262
|
+
this.opts.log('voice.undelivered', { target, detail: verdict.detail })
|
|
263
|
+
await this.earcon('error')
|
|
264
|
+
await this.say(`Не доставлено: ${verdict.detail ?? 'неизвестная ошибка'}`)
|
|
265
|
+
}
|
|
266
|
+
} finally {
|
|
267
|
+
rmSync(wav, { force: true })
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Voice of the SENDER — each agent keeps the voice configured in its own passport
|
|
272
|
+
* (`interfaces.voice`), so the owner hears who is talking. */
|
|
273
|
+
private voiceFor(personality: string): string | undefined {
|
|
274
|
+
const peers = this.opts.peers ?? readFleetPeers()
|
|
275
|
+
const voice = peers.find(p => p.personality === personality)?.voice
|
|
276
|
+
if (!voice) return undefined
|
|
277
|
+
return voice['gemini-3.1-flash-tts-preview'] ?? Object.values(voice)[0]
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Speak an agent's answer on the owner's speaker, then hold the follow-up window
|
|
281
|
+
* open — the conversational turn passes back to the human. */
|
|
282
|
+
async speak(from: string, message: string): Promise<void> {
|
|
283
|
+
const text = speakableText(message)
|
|
284
|
+
if (!text) {
|
|
285
|
+
this.opts.log('tts.skip', { from, reason: 'nothing speakable' })
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
await this.say(text, { from, voice: this.voiceFor(from) })
|
|
289
|
+
this.followTarget = from
|
|
290
|
+
this.followUntil = Date.now() + FOLLOWUP_MS
|
|
291
|
+
// The turn passes back to the human: the mic is open for a follow-up without a
|
|
292
|
+
// wake word, and the rising earcon says so.
|
|
293
|
+
await this.earcon('open')
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private async say(text: string, opts: { from?: string; voice?: string } = {}): Promise<void> {
|
|
297
|
+
const out = join(this.opts.tmpDir, `say-${randomUUID()}.ogg`)
|
|
298
|
+
const started = Date.now()
|
|
299
|
+
try {
|
|
300
|
+
const result = await synthesize(text, out, { voice: opts.voice })
|
|
301
|
+
const ttsMs = Date.now() - started
|
|
302
|
+
// Gate the mic for the whole playback: the runtime must not hear itself.
|
|
303
|
+
this.muteUntil = Date.now() + 3_600_000
|
|
304
|
+
this.segmenter.reset()
|
|
305
|
+
try {
|
|
306
|
+
await playFile(result.path)
|
|
307
|
+
} finally {
|
|
308
|
+
this.muteUntil = Date.now() + ECHO_TAIL_MS
|
|
309
|
+
this.segmenter.reset()
|
|
310
|
+
}
|
|
311
|
+
this.opts.log('tts.spoken', {
|
|
312
|
+
from: opts.from,
|
|
313
|
+
chars: text.length,
|
|
314
|
+
engine: result.engine,
|
|
315
|
+
voice: result.voice,
|
|
316
|
+
fallbackFrom: result.fallbackFrom,
|
|
317
|
+
ttsMs,
|
|
318
|
+
totalMs: Date.now() - started,
|
|
319
|
+
})
|
|
320
|
+
} catch (err) {
|
|
321
|
+
this.muteUntil = 0
|
|
322
|
+
this.opts.log('tts.error', { from: opts.from, detail: err instanceof Error ? err.message : String(err) })
|
|
323
|
+
throw err
|
|
324
|
+
} finally {
|
|
325
|
+
rmSync(out, { force: true })
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Prepare an agent's text for the speaker: strip what only makes sense on a screen and
|
|
331
|
+
* cap the length — a spoken wall of text is unusable. The modality frame already asks
|
|
332
|
+
* agents for short plain answers; this is the floor for when one forgets. */
|
|
333
|
+
export function speakableText(message: string, maxChars = TTS_MAX_CHARS): string {
|
|
334
|
+
let text = message
|
|
335
|
+
.replace(/```[\s\S]*?```/g, ' (фрагмент кода) ')
|
|
336
|
+
.replace(/`([^`]*)`/g, '$1')
|
|
337
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|
338
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
339
|
+
.replace(/https?:\/\/\S+/g, ' ссылка ')
|
|
340
|
+
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
|
|
341
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
342
|
+
.replace(/(^|\s)\*([^*]+)\*/g, '$1$2')
|
|
343
|
+
.replace(/^\s*[-*•]\s+/gm, '')
|
|
344
|
+
.replace(/[ \t]+/g, ' ')
|
|
345
|
+
.replace(/\n{2,}/g, '\n')
|
|
346
|
+
.trim()
|
|
347
|
+
if (text.length > maxChars) {
|
|
348
|
+
const cut = text.slice(0, maxChars)
|
|
349
|
+
const lastStop = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '))
|
|
350
|
+
text = `${(lastStop > maxChars * 0.5 ? cut.slice(0, lastStop + 1) : cut).trim()} … Дальше — в тексте.`
|
|
351
|
+
}
|
|
352
|
+
return text
|
|
353
|
+
}
|