@agfpd/voicetalk-runtime 0.3.0 → 0.3.3
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/CHANGELOG.md +112 -0
- package/README.md +7 -1
- package/docs/README.md +62 -1
- package/docs/ru/README.md +62 -1
- package/package.json +8 -1
- package/scripts/verify-install-pair.ts +145 -0
- package/scripts/verify-install.ts +202 -0
- package/src/cli.ts +2 -0
- package/src/installPair.ts +67 -0
- package/src/installSignature.ts +58 -0
- package/src/selfInstall.ts +56 -42
- package/src/voiceConnect.ts +6 -5
- package/src/voiceSession.ts +132 -17
- package/src/wake.ts +57 -24
- package/tsconfig.json +1 -1
package/src/voiceSession.ts
CHANGED
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
// which exists here yet. Mic gating during playback is the honest substitute — the
|
|
31
31
|
// runtime simply does not listen while it talks.
|
|
32
32
|
|
|
33
|
-
import { mkdirSync, rmSync } from 'fs'
|
|
33
|
+
import { copyFileSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'fs'
|
|
34
34
|
import { join } from 'path'
|
|
35
|
-
import { randomUUID } from 'crypto'
|
|
35
|
+
import { createHash, randomUUID } from 'crypto'
|
|
36
36
|
import {
|
|
37
37
|
Microphone,
|
|
38
38
|
Segmenter,
|
|
@@ -45,16 +45,16 @@ import {
|
|
|
45
45
|
import { synthesize, transcribe } from './voiceConnect.ts'
|
|
46
46
|
import {
|
|
47
47
|
buildAddressBook,
|
|
48
|
-
|
|
48
|
+
hallucinationReason,
|
|
49
49
|
matchWake,
|
|
50
|
+
objectionToActivation,
|
|
50
51
|
objectionToSwitch,
|
|
51
52
|
readFleetPeers,
|
|
52
|
-
sttPrompt,
|
|
53
53
|
type Address,
|
|
54
54
|
type FleetPeer,
|
|
55
55
|
type VoicetalkBinding,
|
|
56
|
+
type WakeMatch,
|
|
56
57
|
} from './wake.ts'
|
|
57
|
-
import { writeFileSync } from 'fs'
|
|
58
58
|
|
|
59
59
|
const num = (v: string | undefined, d: number) => (v && Number(v) > 0 ? Number(v) : d)
|
|
60
60
|
|
|
@@ -98,6 +98,12 @@ const STT_ENGINE = process.env.VOICETALK_STT_ENGINE?.trim() || undefined
|
|
|
98
98
|
* real voice channel is in use by its owner. */
|
|
99
99
|
const MIC_FILE = process.env.VOICETALK_MIC_FILE?.trim() || undefined
|
|
100
100
|
const PLAYBACK_ENABLED = process.env.VOICETALK_PLAYBACK !== '0'
|
|
101
|
+
/** Keep a small, private ring of audio segments whose transcript looked like a wake.
|
|
102
|
+
* A false wake cannot be diagnosed from text alone: the audio is the evidence of what
|
|
103
|
+
* the owner actually said. Only candidate segments are retained, never room audio; files
|
|
104
|
+
* are mode 0600 under the runtime scratch dir and the oldest pair is evicted. */
|
|
105
|
+
const WAKE_CAPTURE_ENABLED = process.env.VOICETALK_WAKE_CAPTURE !== '0'
|
|
106
|
+
const WAKE_CAPTURE_MAX = Math.min(100, num(process.env.VOICETALK_WAKE_CAPTURE_MAX, 16))
|
|
101
107
|
|
|
102
108
|
export type DeliverFn = (target: string, text: string) => Promise<{ ok: boolean; detail?: string }>
|
|
103
109
|
export type LogFn = (evt: string, fields: Record<string, unknown>) => void
|
|
@@ -112,6 +118,12 @@ export type VoiceSessionOptions = {
|
|
|
112
118
|
peers?: FleetPeer[]
|
|
113
119
|
}
|
|
114
120
|
|
|
121
|
+
type WakeRoutingDecision = {
|
|
122
|
+
candidate: WakeMatch
|
|
123
|
+
accepted: boolean
|
|
124
|
+
reason?: string
|
|
125
|
+
}
|
|
126
|
+
|
|
115
127
|
/** State sounds, per the earcon convention in the research: `open` = the microphone is
|
|
116
128
|
* yours (rising, ≤300 ms), `end` = turn captured, processing (short descending,
|
|
117
129
|
* ≤200 ms), `close` = the open-mic window expired, a name is needed again (low, soft),
|
|
@@ -126,7 +138,6 @@ export class VoiceSession {
|
|
|
126
138
|
minRms: num(process.env.VOICETALK_VAD_MIN_RMS, 700),
|
|
127
139
|
})
|
|
128
140
|
private book: Address[] = []
|
|
129
|
-
private prompt = ''
|
|
130
141
|
private earcons: Earcons | null = null
|
|
131
142
|
private busy = false
|
|
132
143
|
private queue: Array<Segment & { speechEndedAt: number }> = []
|
|
@@ -181,7 +192,6 @@ export class VoiceSession {
|
|
|
181
192
|
binding: this.opts.binding,
|
|
182
193
|
peers: this.opts.peers ?? readFleetPeers(),
|
|
183
194
|
})
|
|
184
|
-
this.prompt = sttPrompt(this.book)
|
|
185
195
|
}
|
|
186
196
|
|
|
187
197
|
private writeEarcons(): Earcons {
|
|
@@ -269,21 +279,37 @@ export class VoiceSession {
|
|
|
269
279
|
transcript = await transcribe(wav, {
|
|
270
280
|
lang: STT_LANG || undefined,
|
|
271
281
|
engine: STT_ENGINE,
|
|
272
|
-
prompt: this.prompt || undefined,
|
|
273
282
|
})
|
|
274
283
|
} catch (err) {
|
|
275
284
|
this.opts.log('stt.error', { ms: segment.ms, detail: err instanceof Error ? err.message : String(err) })
|
|
276
285
|
return
|
|
277
286
|
}
|
|
278
287
|
const sttMs = Date.now() - started
|
|
279
|
-
|
|
288
|
+
const candidate = matchWake(transcript, this.book)
|
|
289
|
+
const hallucination = hallucinationReason(transcript)
|
|
290
|
+
if (hallucination) {
|
|
280
291
|
this.opts.log('stt.ignored', {
|
|
281
292
|
ms: segment.ms,
|
|
282
293
|
sttMs,
|
|
283
294
|
peak: Math.round(segment.peakRms),
|
|
284
295
|
floor: Math.round(segment.noiseFloor),
|
|
296
|
+
reason: hallucination,
|
|
285
297
|
text: transcript,
|
|
286
298
|
})
|
|
299
|
+
if (candidate) {
|
|
300
|
+
this.opts.log('wake.rejected', {
|
|
301
|
+
target: candidate.target,
|
|
302
|
+
alias: candidate.alias,
|
|
303
|
+
exact: candidate.exact,
|
|
304
|
+
reason: hallucination,
|
|
305
|
+
text: transcript,
|
|
306
|
+
})
|
|
307
|
+
this.captureWakeCandidate(wav, segment, transcript, {
|
|
308
|
+
candidate,
|
|
309
|
+
accepted: false,
|
|
310
|
+
reason: hallucination,
|
|
311
|
+
})
|
|
312
|
+
}
|
|
287
313
|
return
|
|
288
314
|
}
|
|
289
315
|
this.opts.log('stt.text', {
|
|
@@ -295,12 +321,79 @@ export class VoiceSession {
|
|
|
295
321
|
text: transcript,
|
|
296
322
|
})
|
|
297
323
|
|
|
298
|
-
this.absorb(transcript)
|
|
324
|
+
const decision = this.absorb(transcript, candidate)
|
|
325
|
+
if (decision) this.captureWakeCandidate(wav, segment, transcript, decision)
|
|
299
326
|
} finally {
|
|
300
327
|
rmSync(wav, { force: true })
|
|
301
328
|
}
|
|
302
329
|
}
|
|
303
330
|
|
|
331
|
+
/** Persist the evidence for an apparent wake in a bounded private ring. Text-only logs
|
|
332
|
+
* cannot distinguish a spoken name from a decoder invention; this seam makes the next
|
|
333
|
+
* disputed activation replayable through VOICETALK_MIC_FILE without recording the room
|
|
334
|
+
* continuously. */
|
|
335
|
+
private captureWakeCandidate(
|
|
336
|
+
wav: string,
|
|
337
|
+
segment: Segment,
|
|
338
|
+
transcript: string,
|
|
339
|
+
decision: WakeRoutingDecision,
|
|
340
|
+
): void {
|
|
341
|
+
if (!WAKE_CAPTURE_ENABLED) return
|
|
342
|
+
const dir = join(this.opts.tmpDir, 'wake-captures')
|
|
343
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
344
|
+
const stem = `wake-${stamp}-${randomUUID()}`
|
|
345
|
+
const audioPath = join(dir, `${stem}.wav`)
|
|
346
|
+
const metaPath = join(dir, `${stem}.json`)
|
|
347
|
+
try {
|
|
348
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
349
|
+
copyFileSync(wav, audioPath)
|
|
350
|
+
writeFileSync(
|
|
351
|
+
metaPath,
|
|
352
|
+
JSON.stringify(
|
|
353
|
+
{
|
|
354
|
+
capturedAt: new Date().toISOString(),
|
|
355
|
+
audio: `${stem}.wav`,
|
|
356
|
+
pcmSha256: createHash('sha256').update(segment.pcm).digest('hex'),
|
|
357
|
+
segment: {
|
|
358
|
+
ms: segment.ms,
|
|
359
|
+
peakRms: Math.round(segment.peakRms),
|
|
360
|
+
noiseFloor: Math.round(segment.noiseFloor),
|
|
361
|
+
threshold: segment.threshold,
|
|
362
|
+
truncated: segment.truncated,
|
|
363
|
+
},
|
|
364
|
+
transcript,
|
|
365
|
+
wake: {
|
|
366
|
+
target: decision.candidate.target,
|
|
367
|
+
alias: decision.candidate.alias,
|
|
368
|
+
exact: decision.candidate.exact,
|
|
369
|
+
accepted: decision.accepted,
|
|
370
|
+
...(decision.reason ? { reason: decision.reason } : {}),
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
null,
|
|
374
|
+
2,
|
|
375
|
+
) + '\n',
|
|
376
|
+
{ mode: 0o600 },
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
const records = readdirSync(dir).filter(name => /^wake-.*\.json$/.test(name)).sort()
|
|
380
|
+
for (const oldMeta of records.slice(0, Math.max(0, records.length - WAKE_CAPTURE_MAX))) {
|
|
381
|
+
const oldStem = oldMeta.slice(0, -'.json'.length)
|
|
382
|
+
rmSync(join(dir, oldMeta), { force: true })
|
|
383
|
+
rmSync(join(dir, `${oldStem}.wav`), { force: true })
|
|
384
|
+
}
|
|
385
|
+
this.opts.log('wake.capture', {
|
|
386
|
+
target: decision.candidate.target,
|
|
387
|
+
accepted: decision.accepted,
|
|
388
|
+
path: audioPath,
|
|
389
|
+
})
|
|
390
|
+
} catch (err) {
|
|
391
|
+
rmSync(audioPath, { force: true })
|
|
392
|
+
rmSync(metaPath, { force: true })
|
|
393
|
+
this.opts.log('wake.capture-error', { detail: err instanceof Error ? err.message : String(err) })
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
304
397
|
/** Route one recognized fragment into the turn being composed.
|
|
305
398
|
*
|
|
306
399
|
* The decision is deliberately conservative in one direction: an unaddressed fragment
|
|
@@ -309,9 +402,30 @@ export class VoiceSession {
|
|
|
309
402
|
* when no conversation is open. Mid-dialogue, the cheap error (one more line to the
|
|
310
403
|
* peer already listening) is far cheaper than the expensive one (the conversation
|
|
311
404
|
* silently handed to another agent). */
|
|
312
|
-
private absorb(transcript: string):
|
|
405
|
+
private absorb(transcript: string, candidate = matchWake(transcript, this.book)): WakeRoutingDecision | null {
|
|
313
406
|
const active = this.pending?.target ?? (this.followTarget && Date.now() < this.followUntil ? this.followTarget : null)
|
|
314
|
-
|
|
407
|
+
if (candidate) {
|
|
408
|
+
const decline = objectionToActivation(candidate, this.book)
|
|
409
|
+
if (decline) {
|
|
410
|
+
this.opts.log('wake.rejected', {
|
|
411
|
+
target: candidate.target,
|
|
412
|
+
alias: candidate.alias,
|
|
413
|
+
exact: candidate.exact,
|
|
414
|
+
reason: decline,
|
|
415
|
+
text: transcript,
|
|
416
|
+
})
|
|
417
|
+
// An open dialogue intentionally needs no wake word. If its next fragment merely
|
|
418
|
+
// resembles a name, preserve it as ordinary continuation rather than dropping or
|
|
419
|
+
// stripping a word from the owner's thought.
|
|
420
|
+
if (active && decline === 'inexact name match') {
|
|
421
|
+
this.opts.log('turn.continue', { target: active, reason: this.pending ? 'compose' : 'follow-up' })
|
|
422
|
+
this.append(active, transcript.trim())
|
|
423
|
+
}
|
|
424
|
+
return { candidate, accepted: false, reason: decline }
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const match = candidate
|
|
315
429
|
const switching = !!match && !!active && match.target !== active
|
|
316
430
|
|
|
317
431
|
if (switching) {
|
|
@@ -319,14 +433,14 @@ export class VoiceSession {
|
|
|
319
433
|
if (decline) {
|
|
320
434
|
this.opts.log('wake.switch-declined', { from: active, to: match!.target, reason: decline, text: transcript })
|
|
321
435
|
this.append(active!, transcript.trim())
|
|
322
|
-
return
|
|
436
|
+
return { candidate: match!, accepted: false, reason: decline }
|
|
323
437
|
}
|
|
324
438
|
// A deliberate switch: the thought in flight belongs to the previous peer, so it
|
|
325
439
|
// goes out before the new one starts.
|
|
326
440
|
this.opts.log('wake.switch', { from: active, to: match!.target })
|
|
327
441
|
this.flush('switch')
|
|
328
442
|
this.append(match!.target, match!.body)
|
|
329
|
-
return
|
|
443
|
+
return { candidate: match!, accepted: true }
|
|
330
444
|
}
|
|
331
445
|
|
|
332
446
|
if (match) {
|
|
@@ -336,19 +450,20 @@ export class VoiceSession {
|
|
|
336
450
|
this.openWindow(match.target)
|
|
337
451
|
this.opts.log('wake.await-body', { target: match.target, alias: match.alias })
|
|
338
452
|
void this.earcon('open')
|
|
339
|
-
return
|
|
453
|
+
return { candidate: match, accepted: true }
|
|
340
454
|
}
|
|
341
455
|
this.append(match.target, match.body)
|
|
342
|
-
return
|
|
456
|
+
return { candidate: match, accepted: true }
|
|
343
457
|
}
|
|
344
458
|
|
|
345
459
|
if (active) {
|
|
346
460
|
this.opts.log('turn.continue', { target: active, reason: this.pending ? 'compose' : 'follow-up' })
|
|
347
461
|
this.append(active, transcript.trim())
|
|
348
|
-
return
|
|
462
|
+
return null
|
|
349
463
|
}
|
|
350
464
|
|
|
351
465
|
this.opts.log('wake.no-match', { text: transcript })
|
|
466
|
+
return null
|
|
352
467
|
}
|
|
353
468
|
|
|
354
469
|
/** Add a fragment to the turn in flight and (re)arm the compose timer. */
|
package/src/wake.ts
CHANGED
|
@@ -10,15 +10,13 @@
|
|
|
10
10
|
// enrich them with their own human name («наталья» → «наталья вячеславовна»).
|
|
11
11
|
//
|
|
12
12
|
// Why the address is matched on the TRANSCRIPT rather than by an acoustic wake-word model
|
|
13
|
-
// (openWakeWord et al.): an acoustic detector needs a trained model per phrase,
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// on the curated map — cheap in parallel, one shared feature backbone) is the natural
|
|
21
|
-
// Фаза-4 latency upgrade behind this same interface.
|
|
13
|
+
// (openWakeWord et al.): an acoustic detector needs a trained model per phrase, while
|
|
14
|
+
// transcript matching needs no per-name asset. That transcript MUST be decoded without a
|
|
15
|
+
// name prompt. Whisper's `initial_prompt` is generative context, not evidence: priming it
|
|
16
|
+
// with «дарвин, борис, линус» made unrelated audio decode as those names and woke agents
|
|
17
|
+
// in a quiet conversation (live defect, 23–24.07). The address layer therefore accepts
|
|
18
|
+
// only an exact, unprimed name at the start and rejects decoder loops before routing.
|
|
19
|
+
// A dedicated acoustic gate remains the stronger future upgrade behind this interface.
|
|
22
20
|
|
|
23
21
|
import { readFileSync } from 'fs'
|
|
24
22
|
import { homedir } from 'os'
|
|
@@ -205,9 +203,8 @@ export type WakeMatch = {
|
|
|
205
203
|
/** The utterance with the address (and any leading filler) stripped. */
|
|
206
204
|
body: string
|
|
207
205
|
alias: string
|
|
208
|
-
/** True when every word of the address matched letter for letter.
|
|
209
|
-
*
|
|
210
|
-
* TAKE one away from the peer already in it — see the switch bar in voiceSession. */
|
|
206
|
+
/** True when every word of the address matched letter for letter. Inexact candidates
|
|
207
|
+
* remain observable for diagnostics, but may never activate or retarget a dialogue. */
|
|
211
208
|
exact: boolean
|
|
212
209
|
}
|
|
213
210
|
|
|
@@ -266,9 +263,28 @@ export function objectionToSwitch(match: WakeMatch): string | null {
|
|
|
266
263
|
return null
|
|
267
264
|
}
|
|
268
265
|
|
|
269
|
-
/**
|
|
270
|
-
*
|
|
271
|
-
*
|
|
266
|
+
/** May this candidate START a dialogue?
|
|
267
|
+
*
|
|
268
|
+
* The recognizer is allowed to suggest an inexact name so the diagnostic seam can retain
|
|
269
|
+
* the audio, but a suggestion is not presence evidence. The initial address must be exact.
|
|
270
|
+
* Likewise, a transcript made only from configured names is decoder echo, not a request:
|
|
271
|
+
* the live failures were «Дарвин» followed by dozens of «борис» tokens. A bare exact name
|
|
272
|
+
* remains valid — it opens the short awaiting-body window without sending an envelope. */
|
|
273
|
+
export function objectionToActivation(match: WakeMatch, book: Address[]): string | null {
|
|
274
|
+
if (!match.exact) return 'inexact name match'
|
|
275
|
+
const body = normalizeSpoken(match.body)
|
|
276
|
+
if (!body) return null
|
|
277
|
+
const addressTokens = new Set(
|
|
278
|
+
book.flatMap(address => address.aliases.flatMap(alias => normalizeSpoken(alias).split(' '))).filter(Boolean),
|
|
279
|
+
)
|
|
280
|
+
const words = body.split(' ')
|
|
281
|
+
if (words.every(word => addressTokens.has(word))) return 'body only echoes configured names'
|
|
282
|
+
return null
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Whisper-family recognizers emit both stock captions on near-silence and low-entropy
|
|
286
|
+
* token loops. They are decoder output, not speech; identify them before wake matching so
|
|
287
|
+
* a recognizer cannot fabricate presence from its own repeated hypothesis. */
|
|
272
288
|
const HALLUCINATIONS = [
|
|
273
289
|
'продолжение следует',
|
|
274
290
|
'субтитры',
|
|
@@ -282,16 +298,33 @@ const HALLUCINATIONS = [
|
|
|
282
298
|
'you',
|
|
283
299
|
]
|
|
284
300
|
|
|
285
|
-
export function
|
|
301
|
+
export function hallucinationReason(transcript: string): string | null {
|
|
286
302
|
const n = normalizeSpoken(transcript)
|
|
287
|
-
if (!n) return
|
|
288
|
-
if (n.length <= 2) return
|
|
289
|
-
|
|
303
|
+
if (!n) return 'empty transcript'
|
|
304
|
+
if (n.length <= 2) return 'transcript too short'
|
|
305
|
+
if (HALLUCINATIONS.some(h => n === h || n.startsWith(h))) return 'stock whisper caption'
|
|
306
|
+
|
|
307
|
+
const words = n.split(' ')
|
|
308
|
+
if (words.length >= 3) {
|
|
309
|
+
let longestRun = 1
|
|
310
|
+
let run = 1
|
|
311
|
+
for (let i = 1; i < words.length; i++) {
|
|
312
|
+
run = words[i] === words[i - 1] ? run + 1 : 1
|
|
313
|
+
longestRun = Math.max(longestRun, run)
|
|
314
|
+
}
|
|
315
|
+
// Three repeats can be a human correcting or emphasizing themselves («нет, нет,
|
|
316
|
+
// нет»). Five identical consecutive tokens are outside ordinary turn-taking and
|
|
317
|
+
// still far below the 10/70-token loops observed live.
|
|
318
|
+
if (longestRun >= 5) return 'repeated-token decoder loop'
|
|
319
|
+
|
|
320
|
+
const counts = new Map<string, number>()
|
|
321
|
+
for (const word of words) counts.set(word, (counts.get(word) ?? 0) + 1)
|
|
322
|
+
const dominant = Math.max(...counts.values())
|
|
323
|
+
if (words.length >= 6 && dominant / words.length >= 0.75) return 'low-entropy decoder loop'
|
|
324
|
+
}
|
|
325
|
+
return null
|
|
290
326
|
}
|
|
291
327
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
export function sttPrompt(book: Address[]): string {
|
|
295
|
-
const names = book.flatMap(a => a.aliases).slice(0, 40)
|
|
296
|
-
return names.length ? `Обращение к ассистенту по имени: ${names.join(', ')}.` : ''
|
|
328
|
+
export function isLikelyHallucination(transcript: string): boolean {
|
|
329
|
+
return hallucinationReason(transcript) !== null
|
|
297
330
|
}
|
package/tsconfig.json
CHANGED