@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.
@@ -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
- isLikelyHallucination,
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
- if (isLikelyHallucination(transcript)) {
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): void {
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
- const match = matchWake(transcript, this.book)
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, and Фаза 3
14
- // is proving the pipeline end to end — transcript matching gives the same routing with
15
- // zero training and no per-name asset to maintain. Privacy is preserved by the property
16
- // that made an on-device detector attractive in the first place: the recognizer is the
17
- // LOCAL floor of voice-connect (mlx-whisper on this host), so no audio leaves the machine
18
- // to decide whether a name was spoken. The cost is running the recognizer on every voiced
19
- // segment; endpointing keeps that to actual speech, and an acoustic pre-gate (openWakeWord
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. A match that only
209
- * survived the one-edit tolerance is good enough to START a conversation, but not to
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
- /** Whisper-family recognizers emit stock phrases on near-silence («Продолжение
270
- * следует…», «Субтитры сделал…»). They are not speech; drop them before matching so a
271
- * quiet room cannot fabricate an utterance. */
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 isLikelyHallucination(transcript: string): boolean {
301
+ export function hallucinationReason(transcript: string): string | null {
286
302
  const n = normalizeSpoken(transcript)
287
- if (!n) return true
288
- if (n.length <= 2) return true
289
- return HALLUCINATIONS.some(h => n === h || n.startsWith(h))
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
- /** Names spoken to the recognizer as a decoder prompt — biases spelling toward the
293
- * fleet's own vocabulary so the matcher sees «Борис», not «Барис». */
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
@@ -11,5 +11,5 @@
11
11
  "allowImportingTsExtensions": true,
12
12
  "forceConsistentCasingInFileNames": true
13
13
  },
14
- "include": ["src/**/*.ts", "tests/**/*.ts"]
14
+ "include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"]
15
15
  }