@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 +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 +379 -111
- 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/src/wake.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// wake.ts — «кому из флота адресована реплика».
|
|
2
|
+
//
|
|
3
|
+
// HYBRID wake model (design intent agreed with Arthur, 22.07): the passport carries a
|
|
4
|
+
// small CURATED map «wake word → target peer» plus a default target. A wake word that is
|
|
5
|
+
// a peer's name addresses that peer directly («Борис, привет» → boris); a neutral wake
|
|
6
|
+
// word addresses the default agent, who dispatches onward. A word per each of 30+ peers
|
|
7
|
+
// was explicitly REJECTED — it couples the spoken vocabulary to the roster and multiplies
|
|
8
|
+
// false triggers — so nothing here ever sweeps the fleet registry for addressable names.
|
|
9
|
+
// The registry is consulted only for peers the operator ALREADY named in the map, to
|
|
10
|
+
// enrich them with their own human name («наталья» → «наталья вячеславовна») and their
|
|
11
|
+
// voice.
|
|
12
|
+
//
|
|
13
|
+
// Why the address is matched on the TRANSCRIPT rather than by an acoustic wake-word model
|
|
14
|
+
// (openWakeWord et al.): an acoustic detector needs a trained model per phrase, and Фаза 3
|
|
15
|
+
// is proving the pipeline end to end — transcript matching gives the same routing with
|
|
16
|
+
// zero training and no per-name asset to maintain. Privacy is preserved by the property
|
|
17
|
+
// that made an on-device detector attractive in the first place: the recognizer is the
|
|
18
|
+
// LOCAL floor of voice-connect (mlx-whisper on this host), so no audio leaves the machine
|
|
19
|
+
// to decide whether a name was spoken. The cost is running the recognizer on every voiced
|
|
20
|
+
// segment; endpointing keeps that to actual speech, and an acoustic pre-gate (openWakeWord
|
|
21
|
+
// on the curated map — cheap in parallel, one shared feature backbone) is the natural
|
|
22
|
+
// Фаза-4 latency upgrade behind this same interface.
|
|
23
|
+
|
|
24
|
+
import { readFileSync } from 'fs'
|
|
25
|
+
import { homedir } from 'os'
|
|
26
|
+
import { join } from 'path'
|
|
27
|
+
|
|
28
|
+
export type FleetPeer = {
|
|
29
|
+
personality: string
|
|
30
|
+
intelligence?: string
|
|
31
|
+
description?: string
|
|
32
|
+
/** Per-peer TTS voice from `interfaces.voice` — so each agent answers in its own voice. */
|
|
33
|
+
voice?: Record<string, string>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type Address = {
|
|
37
|
+
personality: string
|
|
38
|
+
/** Every spoken form that addresses this peer, normalized. */
|
|
39
|
+
aliases: string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const FILLERS = new Set(['эй', 'окей', 'ок', 'слушай', 'hey', 'ok', 'okay', 'привет'])
|
|
43
|
+
|
|
44
|
+
/** Normalize a spoken/written form for matching: lowercase, ё→е, punctuation dropped. */
|
|
45
|
+
export function normalizeSpoken(text: string): string {
|
|
46
|
+
return text
|
|
47
|
+
.toLowerCase()
|
|
48
|
+
.replace(/ё/g, 'е')
|
|
49
|
+
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
|
|
50
|
+
.replace(/\s+/g, ' ')
|
|
51
|
+
.trim()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function levenshtein(a: string, b: string): number {
|
|
55
|
+
if (a === b) return 0
|
|
56
|
+
const m = a.length
|
|
57
|
+
const n = b.length
|
|
58
|
+
if (m === 0 || n === 0) return Math.max(m, n)
|
|
59
|
+
let prev = Array.from({ length: n + 1 }, (_, i) => i)
|
|
60
|
+
for (let i = 1; i <= m; i++) {
|
|
61
|
+
const cur = [i]
|
|
62
|
+
for (let j = 1; j <= n; j++) {
|
|
63
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
64
|
+
}
|
|
65
|
+
prev = cur
|
|
66
|
+
}
|
|
67
|
+
return prev[n]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Speech recognition mangles the tail of a name («Борис» → «борись»); allow one edit on
|
|
71
|
+
* a name long enough that a single-edit collision is implausible. */
|
|
72
|
+
function tokenMatches(spoken: string, alias: string): boolean {
|
|
73
|
+
if (spoken === alias) return true
|
|
74
|
+
if (alias.length >= 5 && Math.abs(spoken.length - alias.length) <= 1) return levenshtein(spoken, alias) <= 1
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Derive the spoken forms of a peer: its personality plus the human name that opens its
|
|
79
|
+
* description («Борис — PM и архитектор…» → «борис»). This makes the fleet's own
|
|
80
|
+
* registry the single source of truth for names; no separate alias file to drift. */
|
|
81
|
+
export function peerAliases(peer: FleetPeer): string[] {
|
|
82
|
+
const out = new Set<string>()
|
|
83
|
+
const personality = normalizeSpoken(peer.personality)
|
|
84
|
+
if (personality) out.add(personality)
|
|
85
|
+
const head = normalizeSpoken((peer.description ?? '').split(/\s+[—–-]\s+/)[0] ?? '')
|
|
86
|
+
if (head && head.length <= 40) {
|
|
87
|
+
out.add(head)
|
|
88
|
+
const first = head.split(' ')[0]
|
|
89
|
+
// «mr booker» → also «booker»; «наталья вячеславовна» → also «наталья».
|
|
90
|
+
if (first && first.length >= 3 && !['mr', 'mrs', 'ms', 'dr'].includes(first)) out.add(first)
|
|
91
|
+
const words = head.split(' ')
|
|
92
|
+
if (words.length > 1 && ['mr', 'mrs', 'ms', 'dr'].includes(words[0])) out.add(words.slice(1).join(' '))
|
|
93
|
+
}
|
|
94
|
+
return [...out].filter(Boolean)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function iapeerRoot(): string {
|
|
98
|
+
const override = process.env.IAPEER_ROOT?.trim()
|
|
99
|
+
return override || join(homedir(), '.iapeer')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function readFleetPeers(path = join(iapeerRoot(), 'peers-profiles.json')): FleetPeer[] {
|
|
103
|
+
try {
|
|
104
|
+
const raw = JSON.parse(readFileSync(path, 'utf8')) as { peers?: unknown }
|
|
105
|
+
if (!Array.isArray(raw.peers)) return []
|
|
106
|
+
return raw.peers
|
|
107
|
+
.filter((p): p is Record<string, unknown> => !!p && typeof p === 'object')
|
|
108
|
+
.map(p => ({
|
|
109
|
+
personality: String(p.personality ?? ''),
|
|
110
|
+
intelligence: typeof p.intelligence === 'string' ? p.intelligence : undefined,
|
|
111
|
+
description: typeof p.description === 'string' ? p.description : undefined,
|
|
112
|
+
voice:
|
|
113
|
+
p.interfaces && typeof p.interfaces === 'object' && (p.interfaces as any).voice
|
|
114
|
+
? ((p.interfaces as any).voice as Record<string, string>)
|
|
115
|
+
: undefined,
|
|
116
|
+
}))
|
|
117
|
+
.filter(p => p.personality)
|
|
118
|
+
} catch {
|
|
119
|
+
return []
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The voicetalk peer binding — the hybrid wake map. `wake_words` may be written as a
|
|
124
|
+
* map (word → peer) or as a bare list of words, in which case each word is resolved to
|
|
125
|
+
* the peer it names, falling back to `default_target`. `wake_word` is the legacy
|
|
126
|
+
* single-word form and is folded into the same map. */
|
|
127
|
+
export type VoicetalkBinding = {
|
|
128
|
+
wake_word?: string
|
|
129
|
+
wake_words?: Record<string, string> | string[]
|
|
130
|
+
default_target?: string
|
|
131
|
+
device?: string
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type AddressBookOptions = {
|
|
135
|
+
/** The speaker — never addressable by their own voice. */
|
|
136
|
+
owner: string
|
|
137
|
+
binding: VoicetalkBinding
|
|
138
|
+
peers?: FleetPeer[]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Normalize the two accepted spellings of the wake map into word → target|undefined. */
|
|
142
|
+
export function wakeEntries(binding: VoicetalkBinding): Array<[string, string | undefined]> {
|
|
143
|
+
const out: Array<[string, string | undefined]> = []
|
|
144
|
+
const push = (word: unknown, target?: unknown) => {
|
|
145
|
+
const w = normalizeSpoken(String(word ?? ''))
|
|
146
|
+
if (!w) return
|
|
147
|
+
const t = typeof target === 'string' && target.trim() ? target.trim() : undefined
|
|
148
|
+
out.push([w, t])
|
|
149
|
+
}
|
|
150
|
+
const map = binding.wake_words
|
|
151
|
+
if (Array.isArray(map)) for (const word of map) push(word)
|
|
152
|
+
else if (map && typeof map === 'object') for (const [word, target] of Object.entries(map)) push(word, target)
|
|
153
|
+
if (binding.wake_word) push(binding.wake_word)
|
|
154
|
+
return out
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Build the address book from the CURATED wake map. Every entry resolves to a target:
|
|
158
|
+
* the explicit one, else the peer whose name the wake word is, else the default target.
|
|
159
|
+
* A wake word with no resolvable target is dropped (and `doctor` reports it) — routing
|
|
160
|
+
* speech to a guessed peer is worse than not routing it. */
|
|
161
|
+
export function buildAddressBook(opts: AddressBookOptions): Address[] {
|
|
162
|
+
const owner = normalizeSpoken(opts.owner)
|
|
163
|
+
const peers = opts.peers ?? readFleetPeers()
|
|
164
|
+
const byName = (word: string): string | undefined =>
|
|
165
|
+
peers.find(p => normalizeSpoken(p.personality) === word)?.personality ??
|
|
166
|
+
peers.find(p => peerAliases(p).includes(word))?.personality
|
|
167
|
+
const book = new Map<string, Set<string>>()
|
|
168
|
+
const add = (personality: string, aliases: string[]) => {
|
|
169
|
+
const key = personality.trim()
|
|
170
|
+
if (!key || normalizeSpoken(key) === owner) return
|
|
171
|
+
const set = book.get(key) ?? new Set<string>()
|
|
172
|
+
for (const a of aliases) if (a) set.add(a)
|
|
173
|
+
book.set(key, set)
|
|
174
|
+
}
|
|
175
|
+
const entries = wakeEntries(opts.binding)
|
|
176
|
+
if (entries.length === 0 && opts.binding.default_target) {
|
|
177
|
+
// Out-of-the-box default (22.07): the DEFAULT AGENT'S NAME is the activation word —
|
|
178
|
+
// no separate neutral wake word is introduced. A passport carrying only
|
|
179
|
+
// `default_target: "darwin"` is therefore already usable: «Дарвин, …».
|
|
180
|
+
const target = opts.binding.default_target
|
|
181
|
+
const known = peers.find(p => p.personality === target)
|
|
182
|
+
entries.push(...[normalizeSpoken(target), ...(known ? peerAliases(known) : [])].map(w => [w, target] as [string, string]))
|
|
183
|
+
}
|
|
184
|
+
for (const [word, explicit] of entries) {
|
|
185
|
+
const target = explicit ?? byName(word) ?? opts.binding.default_target
|
|
186
|
+
if (!target) continue
|
|
187
|
+
// The target is addressable by: the configured word, its own personality, and — for a
|
|
188
|
+
// peer the registry knows — its human name. Bounded to CONFIGURED targets only.
|
|
189
|
+
const known = peers.find(p => p.personality === target)
|
|
190
|
+
add(target, [word, normalizeSpoken(target), ...(known ? peerAliases(known) : [])])
|
|
191
|
+
}
|
|
192
|
+
return [...book.entries()].map(([personality, aliases]) => ({ personality, aliases: [...aliases] }))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Wake words the operator configured that resolve to nothing — surfaced by `doctor`. */
|
|
196
|
+
export function unresolvedWakeWords(binding: VoicetalkBinding, peers?: FleetPeer[]): string[] {
|
|
197
|
+
const roster = peers ?? readFleetPeers()
|
|
198
|
+
const out: string[] = []
|
|
199
|
+
for (const [word, explicit] of wakeEntries(binding)) {
|
|
200
|
+
const target =
|
|
201
|
+
explicit ??
|
|
202
|
+
roster.find(p => normalizeSpoken(p.personality) === word)?.personality ??
|
|
203
|
+
roster.find(p => peerAliases(p).includes(word))?.personality ??
|
|
204
|
+
binding.default_target
|
|
205
|
+
if (!target) out.push(word)
|
|
206
|
+
}
|
|
207
|
+
return out
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export type WakeMatch = {
|
|
211
|
+
target: string
|
|
212
|
+
/** The utterance with the address (and any leading filler) stripped. */
|
|
213
|
+
body: string
|
|
214
|
+
alias: string
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Does this transcript open by addressing a fleet peer? Longest alias wins, so
|
|
218
|
+
* «наталья вячеславовна» is preferred over «наталья». */
|
|
219
|
+
export function matchWake(transcript: string, book: Address[]): WakeMatch | null {
|
|
220
|
+
const normalized = normalizeSpoken(transcript)
|
|
221
|
+
if (!normalized) return null
|
|
222
|
+
let tokens = normalized.split(' ')
|
|
223
|
+
const rawTokens = transcript.trim().split(/\s+/)
|
|
224
|
+
let offset = 0
|
|
225
|
+
// Skip a leading filler («эй, Борис…», «окей, Борис…») — one only.
|
|
226
|
+
if (tokens.length > 1 && FILLERS.has(tokens[0])) {
|
|
227
|
+
tokens = tokens.slice(1)
|
|
228
|
+
offset = 1
|
|
229
|
+
}
|
|
230
|
+
const candidates: Array<{ target: string; alias: string; words: string[] }> = []
|
|
231
|
+
for (const entry of book) {
|
|
232
|
+
for (const alias of entry.aliases) candidates.push({ target: entry.personality, alias, words: alias.split(' ') })
|
|
233
|
+
}
|
|
234
|
+
candidates.sort((a, b) => b.words.length - a.words.length || b.alias.length - a.alias.length)
|
|
235
|
+
for (const cand of candidates) {
|
|
236
|
+
if (tokens.length < cand.words.length) continue
|
|
237
|
+
const ok = cand.words.every((w, i) => tokenMatches(tokens[i], w))
|
|
238
|
+
if (!ok) continue
|
|
239
|
+
// Rebuild the body from the ORIGINAL transcript so punctuation and casing of the
|
|
240
|
+
// message survive — only the address is stripped.
|
|
241
|
+
const body = rawTokens
|
|
242
|
+
.slice(offset + cand.words.length)
|
|
243
|
+
.join(' ')
|
|
244
|
+
.replace(/^[\s,.!:;—–-]+/, '')
|
|
245
|
+
.trim()
|
|
246
|
+
return { target: cand.target, alias: cand.alias, body }
|
|
247
|
+
}
|
|
248
|
+
return null
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Whisper-family recognizers emit stock phrases on near-silence («Продолжение
|
|
252
|
+
* следует…», «Субтитры сделал…»). They are not speech; drop them before matching so a
|
|
253
|
+
* quiet room cannot fabricate an utterance. */
|
|
254
|
+
const HALLUCINATIONS = [
|
|
255
|
+
'продолжение следует',
|
|
256
|
+
'субтитры',
|
|
257
|
+
'редактор субтитров',
|
|
258
|
+
'корректор',
|
|
259
|
+
'спасибо за просмотр',
|
|
260
|
+
'подписывайтесь на канал',
|
|
261
|
+
'thanks for watching',
|
|
262
|
+
'subtitles by',
|
|
263
|
+
'thank you',
|
|
264
|
+
'you',
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
export function isLikelyHallucination(transcript: string): boolean {
|
|
268
|
+
const n = normalizeSpoken(transcript)
|
|
269
|
+
if (!n) return true
|
|
270
|
+
if (n.length <= 2) return true
|
|
271
|
+
return HALLUCINATIONS.some(h => n === h || n.startsWith(h))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Names spoken to the recognizer as a decoder prompt — biases spelling toward the
|
|
275
|
+
* fleet's own vocabulary so the matcher sees «Борис», not «Барис». */
|
|
276
|
+
export function sttPrompt(book: Address[]): string {
|
|
277
|
+
const names = book.flatMap(a => a.aliases).slice(0, 40)
|
|
278
|
+
return names.length ? `Обращение к ассистенту по имени: ${names.join(', ')}.` : ''
|
|
279
|
+
}
|