@agfpd/voicetalk-runtime 0.3.1 → 0.4.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/CHANGELOG.md +129 -0
- package/docs/README.md +80 -0
- package/docs/ru/README.md +80 -0
- package/package.json +8 -1
- package/scripts/verify-install-pair.ts +145 -0
- package/scripts/verify-install.ts +202 -0
- package/src/audio.ts +36 -2
- package/src/cli.ts +22 -3
- package/src/installPair.ts +67 -0
- package/src/installSignature.ts +58 -0
- package/src/nativeVoice.ts +119 -0
- package/src/selfInstall.ts +56 -42
- package/src/voiceSession.ts +199 -4
- package/src/wake.ts +4 -0
- package/tsconfig.json +1 -1
package/src/selfInstall.ts
CHANGED
|
@@ -19,13 +19,20 @@
|
|
|
19
19
|
// (the package now owns this path); a sandbox proof sets IAPEER_BIN_DIR so a test never
|
|
20
20
|
// touches the real ~/.local/bin.
|
|
21
21
|
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
22
|
+
import { chmodSync, copyFileSync, mkdirSync, renameSync, rmSync, statSync } from 'fs'
|
|
23
|
+
import { publishInstallPair } from './installPair.ts'
|
|
24
|
+
import { randomUUID } from 'crypto'
|
|
24
25
|
import { homedir } from 'os'
|
|
25
26
|
import { dirname, join } from 'path'
|
|
26
27
|
import { BIN_NAME, PACKAGE_NAME } from './constants.ts'
|
|
27
28
|
import { scaffoldHostDocs, type HostDocsResult } from './hostDocs.ts'
|
|
28
|
-
import { buildManifest, resolveIapeerRoot
|
|
29
|
+
import { buildManifest, resolveIapeerRoot } from './manifest.ts'
|
|
30
|
+
import {
|
|
31
|
+
ensureExecutableSignature,
|
|
32
|
+
runInstallCommand,
|
|
33
|
+
type ExecutableSignatureState,
|
|
34
|
+
type InstallCommandRunner,
|
|
35
|
+
} from './installSignature.ts'
|
|
29
36
|
|
|
30
37
|
/** The launcher binary name (`voicetalk-runtime`). Re-exported so callers/tests have a
|
|
31
38
|
* single import surface for the self-install contract. */
|
|
@@ -44,6 +51,11 @@ export interface SelfInstallOptions {
|
|
|
44
51
|
* (`<pkg>/src/cli.ts` → `<pkg>/docs`). Missing (e.g. running from a compiled bin with
|
|
45
52
|
* no source tree) → soft-skip, never fails the install. */
|
|
46
53
|
docsSource?: string
|
|
54
|
+
/** Test seams: no host-global mocks or environment-based signature bypass. */
|
|
55
|
+
runner?: InstallCommandRunner
|
|
56
|
+
platform?: NodeJS.Platform
|
|
57
|
+
/** Synchronous publication fault seam for tests; no environment bypass. */
|
|
58
|
+
publishRename?: typeof renameSync
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
export type SelfInstallBinMode = 'compiled' | 'copied-self'
|
|
@@ -54,6 +66,7 @@ export interface SelfInstallResult {
|
|
|
54
66
|
binMode: SelfInstallBinMode
|
|
55
67
|
root: string
|
|
56
68
|
binDir: string
|
|
69
|
+
signature: ExecutableSignatureState
|
|
57
70
|
/** On-host docs scaffolding verdict (best-effort; never blocks the install). */
|
|
58
71
|
docs: HostDocsResult
|
|
59
72
|
}
|
|
@@ -88,20 +101,12 @@ function isBunInterpreter(execPath: string): boolean {
|
|
|
88
101
|
|
|
89
102
|
function cleanup(path: string): void {
|
|
90
103
|
try {
|
|
91
|
-
|
|
104
|
+
rmSync(path, { force: true })
|
|
92
105
|
} catch {
|
|
93
106
|
// best-effort
|
|
94
107
|
}
|
|
95
108
|
}
|
|
96
109
|
|
|
97
|
-
/** Tiny deterministic string hash for a unique-ish tmp suffix (Math.random is avoided
|
|
98
|
-
* to keep the tmp name reproducible within a run). */
|
|
99
|
-
function hashStr(s: string): number {
|
|
100
|
-
let h = 0
|
|
101
|
-
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0
|
|
102
|
-
return h
|
|
103
|
-
}
|
|
104
|
-
|
|
105
110
|
/**
|
|
106
111
|
* Self-install: place the launcher on PATH + write the manifest. IDEMPOTENT — a
|
|
107
112
|
* repeat run produces the same on-disk bin and a byte-identical manifest (atomic
|
|
@@ -116,42 +121,51 @@ export function selfInstall(opts: SelfInstallOptions = {}): SelfInstallResult {
|
|
|
116
121
|
const binDir = resolveBinDir(env)
|
|
117
122
|
const binPath = join(binDir, BIN_NAME)
|
|
118
123
|
|
|
124
|
+
// Fail closed BEFORE any write/process: a sandbox must isolate BOTH surfaces, so
|
|
125
|
+
// both must be EXPLICIT overrides. Comparing against a "host home" cannot work —
|
|
126
|
+
// Bun's os.userInfo().homedir follows $HOME, which a sandbox points at itself.
|
|
127
|
+
if ((env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1')
|
|
128
|
+
&& (!env.IAPEER_BIN_DIR?.trim() || !env.IAPEER_ROOT?.trim())) {
|
|
129
|
+
throw new Error('self-install: refusing real host bin/root under IAPEER_TEST_SANDBOX=1 — set IAPEER_BIN_DIR and IAPEER_ROOT')
|
|
130
|
+
}
|
|
119
131
|
mkdirSync(binDir, { recursive: true, mode: 0o755 })
|
|
120
132
|
|
|
121
133
|
// Build to a sibling tmp in the SAME dir (rename is atomic only within one FS), then
|
|
122
|
-
// chmod +x
|
|
123
|
-
// half-written file
|
|
124
|
-
const tmp = `${binPath}.tmp.${process.pid}.${
|
|
134
|
+
// chmod +x, verify/repair its macOS signature, THEN rename over the target. A
|
|
135
|
+
// reader never sees a half-written file; a legacy symlink is replaced atomically.
|
|
136
|
+
const tmp = `${binPath}.tmp.${process.pid}.${randomUUID()}`
|
|
137
|
+
const run = opts.runner ?? runInstallCommand
|
|
125
138
|
let binMode: SelfInstallBinMode
|
|
139
|
+
let signature: ExecutableSignatureState
|
|
140
|
+
let manifestPath: string
|
|
126
141
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
)
|
|
142
|
+
try {
|
|
143
|
+
if (isCompilableSource(sourceEntry) && isBunInterpreter(execPath)) {
|
|
144
|
+
// PRIMARY: compile a snapshot decoupled from the npm cache/source tree.
|
|
145
|
+
const r = run(execPath, ['build', '--compile', '--outfile', tmp, sourceEntry], env)
|
|
146
|
+
if (r.error || (r.status ?? 1) !== 0) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`self-install: bun build --compile failed: ${(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim()}`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
binMode = 'compiled'
|
|
152
|
+
} else {
|
|
153
|
+
// FALLBACK: running from a compiled bin; re-assert by copying the executable.
|
|
154
|
+
copyFileSync(execPath, tmp)
|
|
155
|
+
binMode = 'copied-self'
|
|
140
156
|
}
|
|
141
|
-
binMode = 'compiled'
|
|
142
|
-
} else {
|
|
143
|
-
// FALLBACK: running from an already-compiled bin (no .ts source / execPath is not
|
|
144
|
-
// bun). Re-assert the install by copying the running executable into binDir.
|
|
145
|
-
copyFileSync(execPath, tmp)
|
|
146
|
-
binMode = 'copied-self'
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
chmodSync(tmp, 0o755)
|
|
150
|
-
renameSync(tmp, binPath)
|
|
151
157
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
158
|
+
chmodSync(tmp, 0o755)
|
|
159
|
+
signature = ensureExecutableSignature(tmp, { env, runner: run, platform: opts.platform })
|
|
160
|
+
// Pair publication: the manifest (pinning the ABSOLUTE installed bin into the
|
|
161
|
+
// self-config descriptor) is staged BEFORE the binary is published; a manifest
|
|
162
|
+
// rename failure restores the previous binary inode (or removes a first install).
|
|
163
|
+
manifestPath = publishInstallPair(tmp, binPath, buildManifest(binPath), env, opts.publishRename)
|
|
164
|
+
} finally {
|
|
165
|
+
// Includes partial compiler/copy output and chmod/sign/rename failures. Do not
|
|
166
|
+
// leave .dead binaries; a failed signature gate never reaches the manifest.
|
|
167
|
+
cleanup(tmp)
|
|
168
|
+
}
|
|
155
169
|
|
|
156
170
|
// On-host docs — stage this version's public docs to <root>/docs/voicetalk-runtime/.
|
|
157
171
|
// Best-effort: scaffoldHostDocs already soft-skips missing source / copy errors; the
|
|
@@ -168,5 +182,5 @@ export function selfInstall(opts: SelfInstallOptions = {}): SelfInstallResult {
|
|
|
168
182
|
}
|
|
169
183
|
}
|
|
170
184
|
|
|
171
|
-
return { binPath, manifestPath, binMode, root: resolveIapeerRoot(env), binDir, docs }
|
|
185
|
+
return { binPath, manifestPath, binMode, signature, root: resolveIapeerRoot(env), binDir, docs }
|
|
172
186
|
}
|
package/src/voiceSession.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
type Segment,
|
|
44
44
|
} from './audio.ts'
|
|
45
45
|
import { synthesize, transcribe } from './voiceConnect.ts'
|
|
46
|
+
import { nativeVoiceTargets, type NativeVoiceControl } from './nativeVoice.ts'
|
|
46
47
|
import {
|
|
47
48
|
buildAddressBook,
|
|
48
49
|
hallucinationReason,
|
|
@@ -104,6 +105,11 @@ const PLAYBACK_ENABLED = process.env.VOICETALK_PLAYBACK !== '0'
|
|
|
104
105
|
* are mode 0600 under the runtime scratch dir and the oldest pair is evicted. */
|
|
105
106
|
const WAKE_CAPTURE_ENABLED = process.env.VOICETALK_WAKE_CAPTURE !== '0'
|
|
106
107
|
const WAKE_CAPTURE_MAX = Math.min(100, num(process.env.VOICETALK_WAKE_CAPTURE_MAX, 16))
|
|
108
|
+
/** Native voice hand-off: how often the listener asks whether the peer's voice is closed,
|
|
109
|
+
* and the hard ceiling after which it asks iapeer to close it. Idle-close itself belongs
|
|
110
|
+
* to iapeer; the ceiling only bounds a hand-off whose closing was never confirmed. */
|
|
111
|
+
const NATIVE_POLL_MS = num(process.env.VOICETALK_NATIVE_POLL_MS, 1_000)
|
|
112
|
+
const NATIVE_MAX_MS = num(process.env.VOICETALK_NATIVE_MAX_MS, 30 * 60_000)
|
|
107
113
|
|
|
108
114
|
export type DeliverFn = (target: string, text: string) => Promise<{ ok: boolean; detail?: string }>
|
|
109
115
|
export type LogFn = (evt: string, fields: Record<string, unknown>) => void
|
|
@@ -116,6 +122,21 @@ export type VoiceSessionOptions = {
|
|
|
116
122
|
/** Scratch dir for captured WAVs and synthesized OGGs. */
|
|
117
123
|
tmpDir: string
|
|
118
124
|
peers?: FleetPeer[]
|
|
125
|
+
/** The door to a peer's native voice mode; absent = every target goes through STT. */
|
|
126
|
+
nativeVoice?: NativeVoiceControl
|
|
127
|
+
/** Override the hand-off poll / hold ceiling (tests); defaults come from the env. */
|
|
128
|
+
nativePollMs?: number
|
|
129
|
+
nativeMaxMs?: number
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The microphone is lent to a peer's native voice. `releasing` and `reclaiming` are the
|
|
133
|
+
* edges where nobody listens (earcons are allowed there); during `opening` and `open`
|
|
134
|
+
* the room belongs to the peer and voicetalk stays silent. */
|
|
135
|
+
type NativeHandOff = {
|
|
136
|
+
target: string
|
|
137
|
+
phase: 'releasing' | 'opening' | 'open' | 'reclaiming'
|
|
138
|
+
since: number
|
|
139
|
+
session?: string | null
|
|
119
140
|
}
|
|
120
141
|
|
|
121
142
|
type WakeRoutingDecision = {
|
|
@@ -153,8 +174,18 @@ export class VoiceSession {
|
|
|
153
174
|
/** When the current turn was handed to the fleet — the clock for «ответ за N с». */
|
|
154
175
|
private deliveredAt = new Map<string, number>()
|
|
155
176
|
private stopped = false
|
|
177
|
+
private nativeTargets: Set<string>
|
|
178
|
+
private native: NativeHandOff | null = null
|
|
179
|
+
private nativeWaiters: Array<() => void> = []
|
|
180
|
+
|
|
181
|
+
constructor(private readonly opts: VoiceSessionOptions) {
|
|
182
|
+
this.nativeTargets = opts.nativeVoice ? nativeVoiceTargets(opts.binding) : new Set()
|
|
183
|
+
}
|
|
156
184
|
|
|
157
|
-
|
|
185
|
+
/** Who currently holds the microphone instead of the wake listener (null = we do). */
|
|
186
|
+
get nativeHolder(): { target: string; phase: string } | null {
|
|
187
|
+
return this.native ? { target: this.native.target, phase: this.native.phase } : null
|
|
188
|
+
}
|
|
158
189
|
|
|
159
190
|
/** Resolve the input device, prime the address book, open the mic. Throws if the mic
|
|
160
191
|
* cannot be resolved — the caller decides whether that is fatal (it is not: the
|
|
@@ -173,7 +204,10 @@ export class VoiceSession {
|
|
|
173
204
|
onStart: attempt => this.opts.log('mic.start', { device: device.index, name: device.name, attempt }),
|
|
174
205
|
onError: detail => this.opts.log('mic.error', { detail }),
|
|
175
206
|
})
|
|
176
|
-
|
|
207
|
+
// A restart must not grab a microphone a peer is still talking on: adopt an open
|
|
208
|
+
// native voice and wait for its close before listening.
|
|
209
|
+
const adopted = await this.adoptOpenNativeVoice()
|
|
210
|
+
if (!adopted) this.mic.start()
|
|
177
211
|
return { deviceIndex: device.index, deviceName: device.name, addresses: this.book.length }
|
|
178
212
|
}
|
|
179
213
|
|
|
@@ -216,6 +250,9 @@ export class VoiceSession {
|
|
|
216
250
|
|
|
217
251
|
private async earcon(kind: keyof Earcons): Promise<void> {
|
|
218
252
|
if (!this.earcons || process.env.VOICETALK_EARCONS === '0' || !PLAYBACK_ENABLED) return
|
|
253
|
+
// The peer's own voice session is listening to the room — a beep from us would be
|
|
254
|
+
// heard (and possibly answered) by it.
|
|
255
|
+
if (this.native && (this.native.phase === 'opening' || this.native.phase === 'open')) return
|
|
219
256
|
try {
|
|
220
257
|
this.muteUntil = Math.max(this.muteUntil, Date.now() + 600)
|
|
221
258
|
await playFile(this.earcons[kind], { timeoutMs: 5_000 })
|
|
@@ -229,7 +266,7 @@ export class VoiceSession {
|
|
|
229
266
|
}
|
|
230
267
|
|
|
231
268
|
private onAudio(chunk: Buffer): void {
|
|
232
|
-
if (this.stopped) return
|
|
269
|
+
if (this.stopped || this.native) return
|
|
233
270
|
if (this.muted()) {
|
|
234
271
|
// Our own speech (or an earcon) is in the room — throw the audio away rather than
|
|
235
272
|
// letting the endpointer build an utterance out of it.
|
|
@@ -439,10 +476,18 @@ export class VoiceSession {
|
|
|
439
476
|
// goes out before the new one starts.
|
|
440
477
|
this.opts.log('wake.switch', { from: active, to: match!.target })
|
|
441
478
|
this.flush('switch')
|
|
442
|
-
this.
|
|
479
|
+
if (this.nativeTargets.has(match!.target)) this.handOff(match!)
|
|
480
|
+
else this.append(match!.target, match!.body)
|
|
443
481
|
return { candidate: match!, accepted: true }
|
|
444
482
|
}
|
|
445
483
|
|
|
484
|
+
if (match && this.nativeTargets.has(match.target)) {
|
|
485
|
+
// An accepted, spoken, exact address to a native-voice peer: the conversation moves
|
|
486
|
+
// to the peer's own realtime voice. Nothing is transcribed onward or sent as text.
|
|
487
|
+
this.handOff(match)
|
|
488
|
+
return { candidate: match, accepted: true }
|
|
489
|
+
}
|
|
490
|
+
|
|
446
491
|
if (match) {
|
|
447
492
|
if (!match.body) {
|
|
448
493
|
// Addressed with nothing to say yet («Борис?») — acknowledge and hold the mic
|
|
@@ -569,6 +614,151 @@ export class VoiceSession {
|
|
|
569
614
|
this.followTimer = null
|
|
570
615
|
}
|
|
571
616
|
|
|
617
|
+
// ── native voice hand-off ─────────────────────────────────────────────────────
|
|
618
|
+
|
|
619
|
+
/** One accepted wake → one hand-off. Synchronously marks the microphone as lent (so no
|
|
620
|
+
* second wake can be formed from audio already captured), then runs the hand-off. */
|
|
621
|
+
private handOff(match: WakeMatch): void {
|
|
622
|
+
if (this.native) {
|
|
623
|
+
this.opts.log('native.ignored', { target: match.target, holder: this.native.target, phase: this.native.phase })
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
this.native = { target: match.target, phase: 'releasing', since: Date.now() }
|
|
627
|
+
// Audio captured before the wake was handled belongs to no one now: the owner is
|
|
628
|
+
// about to talk to the peer directly. A thought in flight to someone ELSE still goes.
|
|
629
|
+
const dropped = this.queue.length
|
|
630
|
+
this.queue = []
|
|
631
|
+
if (this.pending) this.flush('native-voice')
|
|
632
|
+
this.clearWindow()
|
|
633
|
+
this.followTarget = null
|
|
634
|
+
this.opts.log('native.wake', { target: match.target, alias: match.alias, body: match.body || null, droppedSegments: dropped })
|
|
635
|
+
void this.runHandOff(match.target)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
private async runHandOff(target: string): Promise<void> {
|
|
639
|
+
const control = this.opts.nativeVoice!
|
|
640
|
+
const released = this.mic ? await this.mic.pause() : { released: true }
|
|
641
|
+
this.segmenter.reset()
|
|
642
|
+
this.opts.log('mic.released', { to: target, confirmed: released.released })
|
|
643
|
+
if (!released.released) {
|
|
644
|
+
// Our capture has not provably let go of the device; opening the peer's voice now
|
|
645
|
+
// could put two listeners on one microphone. Abort this activation instead.
|
|
646
|
+
this.native!.phase = 'reclaiming'
|
|
647
|
+
await this.earcon('error')
|
|
648
|
+
this.reclaim(target, 'release-unconfirmed')
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
// "Heard you" — played while NOBODY is listening: our capture is gone and the peer's
|
|
652
|
+
// voice is not open yet.
|
|
653
|
+
await this.earcon('end')
|
|
654
|
+
if (this.stopped) return
|
|
655
|
+
this.native!.phase = 'opening'
|
|
656
|
+
this.opts.log('native.open-requested', { target })
|
|
657
|
+
const started = Date.now()
|
|
658
|
+
const receipt = await control.open(target)
|
|
659
|
+
if (!receipt.ok || receipt.state !== 'open') {
|
|
660
|
+
// A failed open is NOT evidence that the peer is off the microphone: the realtime
|
|
661
|
+
// session may still start late, and a timeout or an unreadable answer says nothing
|
|
662
|
+
// at all. The microphone comes back only on a confirmed `closed` (contract with
|
|
663
|
+
// iapeer, 26.10) — until then we hold exactly as if the voice were open.
|
|
664
|
+
this.opts.log('native.open-failed', { target, state: receipt.state, error: receipt.error, openMs: Date.now() - started })
|
|
665
|
+
this.native!.phase = 'open'
|
|
666
|
+
await this.holdUntilClosed(target, { failedOpen: true })
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
this.native!.phase = 'open'
|
|
670
|
+
this.native!.session = receipt.session ?? null
|
|
671
|
+
this.opts.log('native.opened', { target, session: receipt.session ?? null, already: !!receipt.already, openMs: Date.now() - started })
|
|
672
|
+
await this.holdUntilClosed(target)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Poll the peer's voice state until iapeer confirms it is closed, then give the
|
|
676
|
+
* microphone back to the wake listener. */
|
|
677
|
+
private async holdUntilClosed(target: string, opts: { failedOpen?: boolean } = {}): Promise<void> {
|
|
678
|
+
const control = this.opts.nativeVoice!
|
|
679
|
+
const openedAt = Date.now()
|
|
680
|
+
let closeRequested = false
|
|
681
|
+
let lastAlarm = Date.now()
|
|
682
|
+
let lastState = ''
|
|
683
|
+
while (!this.stopped) {
|
|
684
|
+
await new Promise(r => setTimeout(r, this.opts.nativePollMs ?? NATIVE_POLL_MS))
|
|
685
|
+
if (this.stopped) return
|
|
686
|
+
const st = await control.status(target)
|
|
687
|
+
if (st.state !== lastState) {
|
|
688
|
+
this.opts.log('native.status', { target, state: st.state, reason: st.reason ?? null, error: st.error ?? null })
|
|
689
|
+
lastState = st.state
|
|
690
|
+
}
|
|
691
|
+
if (st.state === 'closed') {
|
|
692
|
+
this.opts.log('native.closed', { target, session: st.session ?? this.native?.session ?? null, reason: st.reason ?? null, heldMs: Date.now() - openedAt })
|
|
693
|
+
break
|
|
694
|
+
}
|
|
695
|
+
if (!closeRequested && Date.now() - openedAt >= (this.opts.nativeMaxMs ?? NATIVE_MAX_MS)) {
|
|
696
|
+
closeRequested = true
|
|
697
|
+
this.opts.log('native.close-requested', { target, reason: 'hold ceiling', heldMs: Date.now() - openedAt })
|
|
698
|
+
const closed = await control.close(target)
|
|
699
|
+
this.opts.log('native.close-receipt', { target, ok: closed.ok, state: closed.state, error: closed.error ?? null })
|
|
700
|
+
}
|
|
701
|
+
if (closeRequested && Date.now() - lastAlarm >= 60_000) {
|
|
702
|
+
// Past the ceiling and still no confirmed close. Taking the microphone back
|
|
703
|
+
// unconfirmed would put two listeners on one device; staying deaf is the
|
|
704
|
+
// recoverable failure, so it is reported loudly instead of resolved by guess.
|
|
705
|
+
lastAlarm = Date.now()
|
|
706
|
+
this.opts.log('native.stuck', { target, lastState, heldMs: Date.now() - openedAt })
|
|
707
|
+
const closed = await control.close(target)
|
|
708
|
+
this.opts.log('native.close-receipt', { target, ok: closed.ok, state: closed.state, error: closed.error ?? null })
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (this.stopped) return
|
|
712
|
+
this.native!.phase = 'reclaiming'
|
|
713
|
+
if (opts.failedOpen) await this.earcon('error')
|
|
714
|
+
this.reclaim(target, opts.failedOpen ? 'open-failed' : 'closed')
|
|
715
|
+
if (!opts.failedOpen) void this.earcon('close')
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
private reclaim(target: string, reason: string): void {
|
|
719
|
+
this.segmenter.reset()
|
|
720
|
+
this.native = null
|
|
721
|
+
// Back to wake mode proper: after a native conversation the next turn needs a name.
|
|
722
|
+
this.clearWindow()
|
|
723
|
+
this.followTarget = null
|
|
724
|
+
this.mic?.resume()
|
|
725
|
+
this.opts.log('wake.listening', { after: target, reason })
|
|
726
|
+
const waiters = this.nativeWaiters
|
|
727
|
+
this.nativeWaiters = []
|
|
728
|
+
for (const w of waiters) w()
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** On start: if a native voice is already open (the runtime restarted mid-conversation),
|
|
732
|
+
* stay off the microphone until it closes. */
|
|
733
|
+
private async adoptOpenNativeVoice(): Promise<boolean> {
|
|
734
|
+
const control = this.opts.nativeVoice
|
|
735
|
+
if (!control) return false
|
|
736
|
+
for (const target of [...this.nativeTargets]) {
|
|
737
|
+
const st = await control.status(target)
|
|
738
|
+
if (st.state === 'unknown') {
|
|
739
|
+
// Preflight: the contract does not answer at all (an iapeer without `voice`, a
|
|
740
|
+
// broken CLI). Nothing can have been opened by THIS process yet, so the safe
|
|
741
|
+
// degradation is the STT path for this target — not a listener that goes deaf
|
|
742
|
+
// forever on the first wake because no `closed` can ever be confirmed.
|
|
743
|
+
this.nativeTargets.delete(target)
|
|
744
|
+
this.opts.log('native.unavailable', { target, error: st.error ?? null, fallback: 'stt' })
|
|
745
|
+
continue
|
|
746
|
+
}
|
|
747
|
+
if (st.state === 'open' || st.state === 'opening' || st.state === 'closing') {
|
|
748
|
+
this.native = { target, phase: 'open', since: Date.now(), session: st.session ?? null }
|
|
749
|
+
this.opts.log('native.adopted', { target, state: st.state, session: st.session ?? null })
|
|
750
|
+
void this.holdUntilClosed(target)
|
|
751
|
+
return true
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return false
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
private waitNativeIdle(): Promise<void> {
|
|
758
|
+
if (!this.native) return Promise.resolve()
|
|
759
|
+
return new Promise(resolve => this.nativeWaiters.push(resolve))
|
|
760
|
+
}
|
|
761
|
+
|
|
572
762
|
/** Speak an agent's answer on the owner's speaker, then hold the follow-up window
|
|
573
763
|
* open — the conversational turn passes back to the human. */
|
|
574
764
|
async speak(from: string, message: string): Promise<void> {
|
|
@@ -596,6 +786,11 @@ export class VoiceSession {
|
|
|
596
786
|
}
|
|
597
787
|
|
|
598
788
|
private async say(text: string, opts: { from?: string } = {}): Promise<void> {
|
|
789
|
+
if (this.native) {
|
|
790
|
+
// The room belongs to a peer's native voice; speaking now would be heard by it.
|
|
791
|
+
this.opts.log('tts.deferred', { from: opts.from ?? null, holder: this.native.target })
|
|
792
|
+
await this.waitNativeIdle()
|
|
793
|
+
}
|
|
599
794
|
const out = join(this.opts.tmpDir, `say-${randomUUID()}.ogg`)
|
|
600
795
|
const started = Date.now()
|
|
601
796
|
try {
|
package/src/wake.ts
CHANGED
|
@@ -120,6 +120,10 @@ export type VoicetalkBinding = {
|
|
|
120
120
|
wake_words?: Record<string, string> | string[]
|
|
121
121
|
default_target?: string
|
|
122
122
|
device?: string
|
|
123
|
+
/** Targets answered in their own NATIVE voice mode (Codex `/voice`) instead of
|
|
124
|
+
* STT → envelope → TTS. The wake word still has to be spoken and pass every activation
|
|
125
|
+
* check — this only changes what an accepted activation does. */
|
|
126
|
+
native_voice?: string[]
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
export type AddressBookOptions = {
|
package/tsconfig.json
CHANGED