@agfpd/voicetalk-runtime 0.3.0 → 0.3.1
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 +7 -1
- package/docs/README.md +13 -1
- package/docs/ru/README.md +13 -1
- package/package.json +1 -1
- package/src/voiceConnect.ts +6 -5
- package/src/voiceSession.ts +132 -17
- package/src/wake.ts +57 -24
package/README.md
CHANGED
|
@@ -61,11 +61,17 @@ runs the same router text-only, with no microphone at all. Configuration details
|
|
|
61
61
|
- **Modality framing for a naive agent.** An agent has no senses — only envelope text. voicetalk marks the inbound envelope so a peer with no knowledge of the system answers in the right modality ("spoken, live — reply by voice, briefly").
|
|
62
62
|
- **Voice stays out of the core.** Speech-to-text and text-to-speech live in the shared `voice-connect` service (multi-provider, one place to configure), not inside this runtime and not inside the iapeer foundation.
|
|
63
63
|
- **Conversational turn-taking.** A wake-word only opens a conversation; after a reply, an open-mic window lets the next utterance continue without re-triggering. Endpointing and earcons are owned here, inside the audio session — the agent only sees the next message.
|
|
64
|
+
- **Wake evidence fails closed.** Configured names are never injected into Whisper's
|
|
65
|
+
decoder prompt: only an exact leading name heard in the unprimed transcript can open
|
|
66
|
+
a dialogue, and repeated-name decoder loops are discarded before IAP delivery.
|
|
64
67
|
|
|
65
68
|
## What it does not do (yet)
|
|
66
69
|
|
|
67
70
|
- 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
|
|
71
|
+
- Wake detection runs on an unprimed transcript, not on an acoustic wake-word model —
|
|
72
|
+
so a wake word costs one line of config and no training, at the price of transcribing
|
|
73
|
+
every voiced segment. A bounded private ring retains wake-candidate segments for
|
|
74
|
+
replayable diagnostics; an acoustic pre-gate remains the stronger future upgrade.
|
|
69
75
|
- Single host, single owner, local microphone and speaker. Multi-device and multi-owner are out of scope for the MVP.
|
|
70
76
|
|
|
71
77
|
## Roadmap
|
package/docs/README.md
CHANGED
|
@@ -41,6 +41,11 @@ self-config hook. `voicetalk-runtime devices` lists the host's inputs;
|
|
|
41
41
|
`voicetalk-runtime doctor` reports the whole contour (binding, wake map,
|
|
42
42
|
voice-connect, microphone).
|
|
43
43
|
|
|
44
|
+
Wake evidence is fail-closed. The configured vocabulary is never passed as a Whisper
|
|
45
|
+
decoder prompt, because prompt text can be generated back on unrelated audio. A closed
|
|
46
|
+
dialogue opens only on an exact leading address in the unprimed transcript; repeated
|
|
47
|
+
token loops and name-only echoes are rejected before an IAP envelope exists.
|
|
48
|
+
|
|
44
49
|
## How a turn works
|
|
45
50
|
|
|
46
51
|
1. The microphone runs always-on; energy endpointing cuts an utterance when the
|
|
@@ -69,7 +74,14 @@ Everything is tunable by env: `VOICETALK_AUDIO=0` (text-only, no microphone),
|
|
|
69
74
|
`VOICETALK_FOLLOWUP_MS`, `VOICETALK_HANGOVER_MS`, `VOICETALK_VAD_MIN_RMS`,
|
|
70
75
|
`VOICETALK_VAD_RATIO`, `VOICETALK_STT_LANG`, `VOICETALK_STT_ENGINE`,
|
|
71
76
|
`VOICETALK_TTS_MAX_CHARS`, `VOICETALK_EARCONS=0`, `VOICETALK_COMPOSE_MS`,
|
|
72
|
-
`VOICETALK_COMPOSE_UNFINISHED_MS`, `VOICETALK_TURN_MAX_MS
|
|
77
|
+
`VOICETALK_COMPOSE_UNFINISHED_MS`, `VOICETALK_TURN_MAX_MS`,
|
|
78
|
+
`VOICETALK_WAKE_CAPTURE=0` (disable the default diagnostic ring), and
|
|
79
|
+
`VOICETALK_WAKE_CAPTURE_MAX` (default 16).
|
|
80
|
+
|
|
81
|
+
Every transcript that resembles a configured wake is retained as a mode-0600 WAV plus
|
|
82
|
+
JSON verdict under `<IAPEER_ROOT>/runtimes/voicetalk/tmp/wake-captures`. The ring is
|
|
83
|
+
bounded and contains candidate utterances only, not continuous room audio. Replay a
|
|
84
|
+
disputed segment through the same paced microphone path described below.
|
|
73
85
|
|
|
74
86
|
Two seams exist for testing turn-taking without touching a live channel:
|
|
75
87
|
`VOICETALK_MIC_FILE=<pcm>` replays a recording through the microphone path (paced in
|
package/docs/ru/README.md
CHANGED
|
@@ -41,6 +41,11 @@ self-config-хука (`VOICETALK_WAKE_WORDS`, `VOICETALK_DEFAULT_TARGET`,
|
|
|
41
41
|
`voicetalk-runtime doctor` — весь контур целиком (привязка, карта, voice-connect,
|
|
42
42
|
микрофон).
|
|
43
43
|
|
|
44
|
+
Wake-сигнал работает fail-closed. Настроенные имена не передаются Whisper как decoder
|
|
45
|
+
prompt: такой контекст способен сам породить имя на постороннем аудио. Закрытый диалог
|
|
46
|
+
открывает только точное имя в начале непримированного транскрипта; циклы повторяющихся
|
|
47
|
+
токенов и тело из одних настроенных имён отбрасываются до появления IAP-конверта.
|
|
48
|
+
|
|
44
49
|
## Как устроен ход разговора
|
|
45
50
|
|
|
46
51
|
1. Микрофон слушает постоянно; энергетический эндпойнтер закрывает реплику по паузе
|
|
@@ -68,7 +73,14 @@ Earcon'ы размечают состояния: восходящий — мик
|
|
|
68
73
|
`VOICETALK_FOLLOWUP_MS`, `VOICETALK_HANGOVER_MS`, `VOICETALK_VAD_MIN_RMS`,
|
|
69
74
|
`VOICETALK_VAD_RATIO`, `VOICETALK_STT_LANG`, `VOICETALK_STT_ENGINE`,
|
|
70
75
|
`VOICETALK_TTS_MAX_CHARS`, `VOICETALK_EARCONS=0`, `VOICETALK_COMPOSE_MS`,
|
|
71
|
-
`VOICETALK_COMPOSE_UNFINISHED_MS`, `VOICETALK_TURN_MAX_MS
|
|
76
|
+
`VOICETALK_COMPOSE_UNFINISHED_MS`, `VOICETALK_TURN_MAX_MS`,
|
|
77
|
+
`VOICETALK_WAKE_CAPTURE=0` (отключить диагностическое кольцо) и
|
|
78
|
+
`VOICETALK_WAKE_CAPTURE_MAX` (по умолчанию 16).
|
|
79
|
+
|
|
80
|
+
Каждый транскрипт, похожий на настроенный wake, сохраняется как WAV с правами 0600 и
|
|
81
|
+
JSON-вердикт в `<IAPEER_ROOT>/runtimes/voicetalk/tmp/wake-captures`. Кольцо ограничено
|
|
82
|
+
и хранит только сегменты-кандидаты, не непрерывную запись комнаты. Спорный сегмент
|
|
83
|
+
можно прогнать через тот же микрофонный путь способом ниже.
|
|
72
84
|
|
|
73
85
|
Для проверки turn-taking, не трогая живой канал, есть два шва: `VOICETALK_MIC_FILE=<pcm>`
|
|
74
86
|
проигрывает запись через тот же путь, что и микрофон (в реальном темпе), а
|
package/package.json
CHANGED
package/src/voiceConnect.ts
CHANGED
|
@@ -114,13 +114,15 @@ export type TranscribeOptions = {
|
|
|
114
114
|
lang?: string
|
|
115
115
|
/** Force an STT engine (`mlx-whisper` keeps it fully local); default = service cascade. */
|
|
116
116
|
engine?: string
|
|
117
|
-
/** Decoder-priming prompt — biases spelling of fleet names so the wake matcher sees
|
|
118
|
-
* «Борис», not «Барис». */
|
|
119
|
-
prompt?: string
|
|
120
117
|
timeoutMs?: number
|
|
121
118
|
}
|
|
122
119
|
|
|
123
|
-
/** Transcribe a local audio file. Returns the raw transcript (may be empty).
|
|
120
|
+
/** Transcribe a local audio file. Returns the raw transcript (may be empty).
|
|
121
|
+
*
|
|
122
|
+
* Deliberately no `prompt` option: this client feeds a presence detector, so configured
|
|
123
|
+
* wake words must never be injected into the decoder and then mistaken for acoustic
|
|
124
|
+
* evidence. voice-connect supports prompts for other consumers; voicetalk's door does not.
|
|
125
|
+
*/
|
|
124
126
|
export async function transcribe(audioPath: string, opts: TranscribeOptions = {}): Promise<string> {
|
|
125
127
|
const ep = resolveVoiceEndpoint()
|
|
126
128
|
const form = new FormData()
|
|
@@ -128,7 +130,6 @@ export async function transcribe(audioPath: string, opts: TranscribeOptions = {}
|
|
|
128
130
|
form.append('file', new Blob([new Uint8Array(bytes)], { type: 'audio/wav' }), audioPath.split('/').pop() ?? 'audio.wav')
|
|
129
131
|
if (opts.lang) form.append('language', opts.lang)
|
|
130
132
|
if (opts.engine) form.append('engine', opts.engine)
|
|
131
|
-
if (opts.prompt) form.append('prompt', opts.prompt)
|
|
132
133
|
form.append('response_format', 'json')
|
|
133
134
|
const res = await fetch(ep.stt, {
|
|
134
135
|
method: 'POST',
|
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
|
}
|