@agfpd/voicetalk-runtime 0.1.1 → 0.2.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 +15 -11
- package/docs/README.md +57 -4
- package/docs/ru/README.md +56 -4
- package/package.json +1 -1
- package/src/audio.ts +428 -0
- package/src/cli.ts +244 -67
- package/src/selfConfig.ts +32 -1
- package/src/voiceConnect.ts +202 -0
- package/src/voiceSession.ts +350 -0
- package/src/wake.ts +272 -0
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// voicetalk-runtime — the voice presence-runtime router (Фаза
|
|
2
|
+
// voicetalk-runtime — the voice presence-runtime router (Фаза 3: live voice pipeline).
|
|
3
3
|
//
|
|
4
4
|
// voicetalk is the third *-runtime after telegram and notifier: it makes the host's
|
|
5
5
|
// microphone + speaker one more runtime through which the OWNER (a human, natural peer)
|
|
@@ -8,18 +8,19 @@
|
|
|
8
8
|
// both directions through its stdio + a local inbound channel:
|
|
9
9
|
//
|
|
10
10
|
// • OUTBOUND (fleet → owner): the supervisor writes <iap>…</iap> envelopes to this
|
|
11
|
-
// process's STDIN; the reader parses each and hands it to the AudioSink —
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
11
|
+
// process's STDIN; the reader parses each and hands it to the AudioSink — TTS through
|
|
12
|
+
// voice-connect onto the owner's speaker (and a spoken.log line, always).
|
|
13
|
+
// • INBOUND (owner → fleet): the microphone runs always-on; endpointing cuts utterances,
|
|
14
|
+
// voice-connect transcribes them, and an utterance that opens by addressing a fleet
|
|
15
|
+
// peer («Борис, привет») is framed for the spoken modality and delivered to that peer
|
|
16
|
+
// through the daemon (`iapeer send`) FROM the owner's identity (PEER_RUNTIME=voicetalk).
|
|
17
|
+
// The `say` subcommand injects an utterance over the local socket without the mic —
|
|
18
|
+
// the Фаза-2 stand-in, kept as the debugging seam and the automated E2E's inbound.
|
|
19
19
|
//
|
|
20
|
-
// Speech ↔ text is NOT in this package — STT/TTS live in the shared voice-connect service
|
|
21
|
-
//
|
|
22
|
-
//
|
|
20
|
+
// Speech ↔ text is NOT in this package — STT/TTS live in the shared voice-connect service,
|
|
21
|
+
// a pure HTTP backend this runtime CONSUMES (src/voiceConnect.ts is the only door to it).
|
|
22
|
+
// Host audio mechanics (capture, endpointing, playback) are in src/audio.ts; turn-taking
|
|
23
|
+
// and the address book in src/voiceSession.ts + src/wake.ts.
|
|
23
24
|
//
|
|
24
25
|
// NO sender-guard (decision: «Sender-guard: NO for the MVP»): the speaker is a single
|
|
25
26
|
// local endpoint of the owner, so any fleet peer may answer by voice — there is no
|
|
@@ -41,8 +42,17 @@ import { randomUUID } from 'crypto'
|
|
|
41
42
|
import { spawn } from 'child_process'
|
|
42
43
|
import { BIN_NAME, IAPEER_DIR, NAME_RE, PACKAGE_NAME, PEER_PROFILE_FILE, RUNTIME, VERSION } from './constants.ts'
|
|
43
44
|
import { selfInstall } from './selfInstall.ts'
|
|
44
|
-
import { runSelfConfig } from './selfConfig.ts'
|
|
45
|
+
import { parseWakeWords, runSelfConfig } from './selfConfig.ts'
|
|
45
46
|
import { writeJsonAtomic } from './fsAtomic.ts'
|
|
47
|
+
import { VoiceSession } from './voiceSession.ts'
|
|
48
|
+
import { voiceHealth } from './voiceConnect.ts'
|
|
49
|
+
import { listInputDevices, resolveInputDevice } from './audio.ts'
|
|
50
|
+
import { buildAddressBook, unresolvedWakeWords, wakeEntries, type VoicetalkBinding } from './wake.ts'
|
|
51
|
+
|
|
52
|
+
// Фаза 3: the audio half is LIVE by default (mic + wake-word + STT inbound, TTS outbound).
|
|
53
|
+
// VOICETALK_AUDIO=0 falls back to the Фаза-2 text carcass — no microphone, no speaker —
|
|
54
|
+
// which is what the automated E2E and any headless host run on.
|
|
55
|
+
const VOICETALK_AUDIO_ENABLED = process.env.VOICETALK_AUDIO !== '0'
|
|
46
56
|
|
|
47
57
|
// Inbound (owner → fleet) delivery is synchronous from the speaker's point of view: if
|
|
48
58
|
// the core router refuses the send (ok=false/err) or our local waiter times out, the
|
|
@@ -96,12 +106,12 @@ function defaultIntelligenceForRuntime(runtime: string): Intelligence {
|
|
|
96
106
|
return 'artificial'
|
|
97
107
|
}
|
|
98
108
|
|
|
99
|
-
/** The voicetalk peer binding
|
|
100
|
-
*
|
|
101
|
-
|
|
102
|
-
wake_word
|
|
103
|
-
|
|
104
|
-
|
|
109
|
+
/** The voicetalk peer binding. STT/TTS/voice config is DELIBERATELY absent — it lives in
|
|
110
|
+
* voice-connect, not the passport (design dialog 21.06). Фаза 3 evolves the single
|
|
111
|
+
* `wake_word` into the HYBRID wake map (22.07): a curated «word → target peer» set plus
|
|
112
|
+
* a default agent for the neutral word. `wake_word` stays readable as the legacy
|
|
113
|
+
* single-word form, so an already-provisioned peer keeps working untouched. */
|
|
114
|
+
type VoicetalkInterface = VoicetalkBinding
|
|
105
115
|
|
|
106
116
|
type PeerProfile = {
|
|
107
117
|
personality: string
|
|
@@ -153,10 +163,12 @@ function usage(): string {
|
|
|
153
163
|
${BIN_NAME} # self-install (npx contract): bin on PATH + manifest
|
|
154
164
|
${BIN_NAME} self-install # explicit self-install (idempotent)
|
|
155
165
|
${BIN_NAME} self-config # per-peer self-config hook (foundation-invoked)
|
|
156
|
-
${BIN_NAME} interface --wake-
|
|
157
|
-
|
|
158
|
-
${BIN_NAME}
|
|
159
|
-
${BIN_NAME}
|
|
166
|
+
${BIN_NAME} interface --wake-words «дарвин=darwin,борис=boris» [--default-target <peer>] [--device <d>]
|
|
167
|
+
# set this peer's voicetalk binding (wake map)
|
|
168
|
+
${BIN_NAME} run # router loop: live mic ↔ speaker (VOICETALK_AUDIO=0 → text only)
|
|
169
|
+
${BIN_NAME} say --peer <personality> <text…> # inject an utterance without the mic (debug seam)
|
|
170
|
+
${BIN_NAME} devices [--json] # list audio input devices (for --device)
|
|
171
|
+
${BIN_NAME} doctor [--json] # health: binding, manifest, voice-connect, mic
|
|
160
172
|
${BIN_NAME} --version
|
|
161
173
|
${BIN_NAME} --help`
|
|
162
174
|
}
|
|
@@ -247,6 +259,15 @@ function logEvt(evt: string, fields: Record<string, unknown>): void {
|
|
|
247
259
|
process.stderr.write(`${BIN_NAME} ${JSON.stringify({ ts: nowIso(), evt, ...fields })}\n`)
|
|
248
260
|
}
|
|
249
261
|
|
|
262
|
+
/** Is an executable resolvable on PATH? (`doctor`'s host-tooling probe.) */
|
|
263
|
+
function hasBinary(bin: string): Promise<boolean> {
|
|
264
|
+
return new Promise(resolve => {
|
|
265
|
+
const child = spawn('command', ['-v', bin], { stdio: 'ignore', shell: true })
|
|
266
|
+
child.on('error', () => resolve(false))
|
|
267
|
+
child.on('close', code => resolve(code === 0))
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
250
271
|
function oneLine(text: string, max = 200): string {
|
|
251
272
|
const flat = text.replace(/\s+/g, ' ').trim()
|
|
252
273
|
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
|
|
@@ -376,6 +397,8 @@ function setVoicetalkInterface(cwd: string, patch: VoicetalkInterface): Record<s
|
|
|
376
397
|
? { ...((interfaces as { voicetalk: Record<string, unknown> }).voicetalk) }
|
|
377
398
|
: {}
|
|
378
399
|
if (patch.wake_word !== undefined) voicetalk.wake_word = patch.wake_word
|
|
400
|
+
if (patch.wake_words !== undefined) voicetalk.wake_words = patch.wake_words
|
|
401
|
+
if (patch.default_target !== undefined) voicetalk.default_target = patch.default_target
|
|
379
402
|
if (patch.device !== undefined) voicetalk.device = patch.device
|
|
380
403
|
;(interfaces as Record<string, unknown>).voicetalk = voicetalk
|
|
381
404
|
const merged = { ...raw, interfaces }
|
|
@@ -713,9 +736,31 @@ export async function runIapSendCommand(args: {
|
|
|
713
736
|
// outbound — the AudioSink (fleet → owner's speaker), Фаза-2 stub
|
|
714
737
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
715
738
|
|
|
716
|
-
/**
|
|
717
|
-
*
|
|
718
|
-
*
|
|
739
|
+
/** Append one spoken envelope to spoken.log — the durable, greppable record of everything
|
|
740
|
+
* the runtime said out loud (and the deterministic assertion target of the text E2E).
|
|
741
|
+
* Written in BOTH modes: the artifact does not depend on whether audio is live. */
|
|
742
|
+
function appendSpokenLog(owner: string, envelope: IapEnvelope): void {
|
|
743
|
+
try {
|
|
744
|
+
appendFileSync(
|
|
745
|
+
spokenLogPath(),
|
|
746
|
+
`${JSON.stringify({
|
|
747
|
+
ts: nowIso(),
|
|
748
|
+
to: owner,
|
|
749
|
+
from: envelope.fromPersonality,
|
|
750
|
+
fromRuntime: envelope.fromRuntime,
|
|
751
|
+
message: envelope.message,
|
|
752
|
+
attachments: envelope.attachments,
|
|
753
|
+
})}\n`,
|
|
754
|
+
{ mode: 0o600 },
|
|
755
|
+
)
|
|
756
|
+
} catch (err) {
|
|
757
|
+
// The artifact is best-effort observability; never let it break delivery.
|
|
758
|
+
logEvt('speak.log-error', { reason: formatError(err) })
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Text-only AudioSink (VOICETALK_AUDIO=0 / no usable audio device): log the spoken text
|
|
763
|
+
* instead of synthesizing it. This is the Фаза-2 carcass, kept as the headless floor. */
|
|
719
764
|
function makeStubAudioSink(owner: string): AudioSink {
|
|
720
765
|
return {
|
|
721
766
|
async speak(envelope: IapEnvelope): Promise<void> {
|
|
@@ -729,22 +774,23 @@ function makeStubAudioSink(owner: string): AudioSink {
|
|
|
729
774
|
chars: envelope.message.length,
|
|
730
775
|
attachments: envelope.attachments.length,
|
|
731
776
|
})
|
|
777
|
+
appendSpokenLog(owner, envelope)
|
|
778
|
+
},
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** Фаза-3 AudioSink: the agent's answer becomes speech on the owner's speaker through
|
|
783
|
+
* voice-connect, and the session opens its follow-up window afterwards. A TTS or
|
|
784
|
+
* playback failure degrades to the logged line — the answer is never silently lost. */
|
|
785
|
+
function makeVoiceAudioSink(owner: string, session: VoiceSession): AudioSink {
|
|
786
|
+
return {
|
|
787
|
+
async speak(envelope: IapEnvelope): Promise<void> {
|
|
788
|
+
process.stderr.write(`🔊 ${BIN_NAME} ${envelope.fromPersonality} → ${owner}: ${oneLine(envelope.message)}\n`)
|
|
789
|
+
appendSpokenLog(owner, envelope)
|
|
732
790
|
try {
|
|
733
|
-
|
|
734
|
-
spokenLogPath(),
|
|
735
|
-
`${JSON.stringify({
|
|
736
|
-
ts: nowIso(),
|
|
737
|
-
to: owner,
|
|
738
|
-
from: envelope.fromPersonality,
|
|
739
|
-
fromRuntime: envelope.fromRuntime,
|
|
740
|
-
message: envelope.message,
|
|
741
|
-
attachments: envelope.attachments,
|
|
742
|
-
})}\n`,
|
|
743
|
-
{ mode: 0o600 },
|
|
744
|
-
)
|
|
791
|
+
await session.speak(envelope.fromPersonality, envelope.message)
|
|
745
792
|
} catch (err) {
|
|
746
|
-
|
|
747
|
-
logEvt('speak.log-error', { reason: formatError(err) })
|
|
793
|
+
logEvt('speak.fallback', { from: envelope.fromPersonality, reason: formatError(err) })
|
|
748
794
|
}
|
|
749
795
|
},
|
|
750
796
|
}
|
|
@@ -798,25 +844,18 @@ function installStdinEnvelopeReader(ctx: RuntimeContext): void {
|
|
|
798
844
|
process.stdin.resume()
|
|
799
845
|
}
|
|
800
846
|
|
|
801
|
-
/** Frame + deliver one inbound utterance to the fleet through the daemon.
|
|
802
|
-
*
|
|
803
|
-
|
|
847
|
+
/** Frame + deliver one inbound utterance to the fleet through the daemon. The single
|
|
848
|
+
* delivery path for BOTH inbound sources — the live microphone (Фаза 3) and the `say`
|
|
849
|
+
* socket injector (Фаза 2 stand-in, still the debugging seam). */
|
|
850
|
+
async function deliverUtterance(
|
|
804
851
|
ctx: RuntimeContext,
|
|
805
|
-
|
|
852
|
+
rawTarget: string,
|
|
853
|
+
text: string,
|
|
854
|
+
attachments: string[] = [],
|
|
806
855
|
): Promise<{ ok: boolean; target?: string; detail?: string }> {
|
|
807
|
-
|
|
808
|
-
try {
|
|
809
|
-
req = JSON.parse(raw)
|
|
810
|
-
} catch (err) {
|
|
811
|
-
return { ok: false, detail: `invalid utterance JSON: ${formatError(err)}` }
|
|
812
|
-
}
|
|
813
|
-
const target = normalizeName(String(req.target ?? ''))
|
|
856
|
+
const target = normalizeName(rawTarget)
|
|
814
857
|
if (!NAME_RE.test(target)) return { ok: false, detail: `invalid target "${target}"` }
|
|
815
|
-
const text = typeof req.text === 'string' ? req.text : ''
|
|
816
858
|
if (!text.trim()) return { ok: false, target, detail: 'empty utterance' }
|
|
817
|
-
const attachments = Array.isArray(req.attachments)
|
|
818
|
-
? req.attachments.filter((s): s is string => typeof s === 'string')
|
|
819
|
-
: []
|
|
820
859
|
|
|
821
860
|
logEvt('inbound.utterance', { target, chars: text.length, attachments: attachments.length })
|
|
822
861
|
const result = await runIapSendCommand({
|
|
@@ -836,6 +875,28 @@ async function handleInboundUtterance(
|
|
|
836
875
|
return { ok: false, target, detail }
|
|
837
876
|
}
|
|
838
877
|
|
|
878
|
+
/** The socket wire-form of one utterance: {target, text, attachments?} as a JSON line. */
|
|
879
|
+
async function handleInboundUtterance(
|
|
880
|
+
ctx: RuntimeContext,
|
|
881
|
+
raw: string,
|
|
882
|
+
): Promise<{ ok: boolean; target?: string; detail?: string }> {
|
|
883
|
+
let req: { target?: unknown; text?: unknown; attachments?: unknown }
|
|
884
|
+
try {
|
|
885
|
+
req = JSON.parse(raw)
|
|
886
|
+
} catch (err) {
|
|
887
|
+
return { ok: false, detail: `invalid utterance JSON: ${formatError(err)}` }
|
|
888
|
+
}
|
|
889
|
+
const attachments = Array.isArray(req.attachments)
|
|
890
|
+
? req.attachments.filter((s): s is string => typeof s === 'string')
|
|
891
|
+
: []
|
|
892
|
+
return await deliverUtterance(
|
|
893
|
+
ctx,
|
|
894
|
+
String(req.target ?? ''),
|
|
895
|
+
typeof req.text === 'string' ? req.text : '',
|
|
896
|
+
attachments,
|
|
897
|
+
)
|
|
898
|
+
}
|
|
899
|
+
|
|
839
900
|
/** INBOUND source (Фаза-2 mic+STT stand-in): a unix socket the running router listens on.
|
|
840
901
|
* One connection = one utterance: the client writes a JSON line {target,text,attachments?}
|
|
841
902
|
* then half-closes; the router frames + delivers it and writes back a verdict line. */
|
|
@@ -899,10 +960,37 @@ async function runCommand(extraArgs: string[]): Promise<void> {
|
|
|
899
960
|
sink: makeStubAudioSink(owner.personality),
|
|
900
961
|
}
|
|
901
962
|
|
|
963
|
+
// Фаза 3 — bring the audio half up. A failure here is NOT fatal: the router keeps
|
|
964
|
+
// pumping envelopes in text mode (socket inbound + logged outbound) and says so, which
|
|
965
|
+
// beats a dead presence-runtime because a microphone was unplugged.
|
|
966
|
+
let session: VoiceSession | null = null
|
|
967
|
+
let audio: { device?: number; deviceName?: string; addresses?: number; error?: string } = {}
|
|
968
|
+
if (VOICETALK_AUDIO_ENABLED) {
|
|
969
|
+
const candidate = new VoiceSession({
|
|
970
|
+
owner: owner.personality,
|
|
971
|
+
binding,
|
|
972
|
+
deliver: (target, text) => deliverUtterance(ctx, target, text),
|
|
973
|
+
log: logEvt,
|
|
974
|
+
tmpDir: join(globalVoicetalkRoot(), 'tmp'),
|
|
975
|
+
})
|
|
976
|
+
try {
|
|
977
|
+
const started = await candidate.start()
|
|
978
|
+
session = candidate
|
|
979
|
+
ctx.sink = makeVoiceAudioSink(owner.personality, candidate)
|
|
980
|
+
audio = { device: started.deviceIndex, deviceName: started.deviceName, addresses: started.addresses }
|
|
981
|
+
} catch (err) {
|
|
982
|
+
audio = { error: formatError(err) }
|
|
983
|
+
logEvt('audio.unavailable', { reason: audio.error, note: 'router continues in text mode' })
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
902
987
|
const server = startInboundServer(ctx)
|
|
903
988
|
installStdinEnvelopeReader(ctx)
|
|
904
989
|
|
|
905
990
|
const cleanup = () => {
|
|
991
|
+
try {
|
|
992
|
+
session?.stop()
|
|
993
|
+
} catch {}
|
|
906
994
|
try {
|
|
907
995
|
server.close()
|
|
908
996
|
} catch {}
|
|
@@ -923,16 +1011,21 @@ async function runCommand(extraArgs: string[]): Promise<void> {
|
|
|
923
1011
|
|
|
924
1012
|
logEvt('run', {
|
|
925
1013
|
owner: owner.personality,
|
|
926
|
-
|
|
1014
|
+
wake_words: wakeEntries(binding).map(([w]) => w),
|
|
927
1015
|
device: binding.device ?? null,
|
|
928
1016
|
iapBin: ctx.iapBin,
|
|
929
1017
|
inbound_socket: inboundSocketPath(owner.personality),
|
|
930
|
-
|
|
1018
|
+
audio: session ? { live: true, ...audio } : { live: false, ...audio },
|
|
931
1019
|
})
|
|
1020
|
+
const audioNote = session
|
|
1021
|
+
? `mic [${audio.device}] ${audio.deviceName ?? '?'}, ${audio.addresses} addressable peers`
|
|
1022
|
+
: VOICETALK_AUDIO_ENABLED
|
|
1023
|
+
? `audio UNAVAILABLE (${audio.error ?? 'unknown'}) — text mode`
|
|
1024
|
+
: 'audio disabled (VOICETALK_AUDIO=0) — text mode'
|
|
932
1025
|
process.stderr.write(
|
|
933
1026
|
`${BIN_NAME}: running ${RUNTIME}-${owner.personality}` +
|
|
934
|
-
`${binding.
|
|
935
|
-
|
|
1027
|
+
`${wakeEntries(binding).length ? ` (wake: ${wakeEntries(binding).map(([w]) => w).join('/')})` : ' (no wake words)'}; ` +
|
|
1028
|
+
`${audioNote}\n`,
|
|
936
1029
|
)
|
|
937
1030
|
|
|
938
1031
|
// Run forever — the supervisor (launchd KeepAlive) owns the lifecycle; we exit only on
|
|
@@ -1019,13 +1112,19 @@ async function sayCommand(args: string[]): Promise<void> {
|
|
|
1019
1112
|
async function interfaceCommand(args: string[]): Promise<void> {
|
|
1020
1113
|
const { flags } = parseFlags(args)
|
|
1021
1114
|
const wakeWord = stringFlag(flags, 'wake-word')
|
|
1115
|
+
const wakeWords = parseWakeWords(stringFlag(flags, 'wake-words'))
|
|
1116
|
+
const defaultTarget = stringFlag(flags, 'default-target')
|
|
1022
1117
|
const device = stringFlag(flags, 'device')
|
|
1023
|
-
if (wakeWord === undefined && device === undefined) {
|
|
1024
|
-
throw new VoicetalkRuntimeError(
|
|
1118
|
+
if (wakeWord === undefined && wakeWords === undefined && defaultTarget === undefined && device === undefined) {
|
|
1119
|
+
throw new VoicetalkRuntimeError(
|
|
1120
|
+
'interface requires --wake-words <«слово=пир,…»>, --default-target <peer>, --wake-word <w> and/or --device <d>',
|
|
1121
|
+
)
|
|
1025
1122
|
}
|
|
1026
1123
|
ensureCurrentProfile() // assert the profile exists before patching
|
|
1027
1124
|
const merged = setVoicetalkInterface(process.cwd(), {
|
|
1028
1125
|
...(wakeWord !== undefined ? { wake_word: wakeWord } : {}),
|
|
1126
|
+
...(wakeWords !== undefined ? { wake_words: wakeWords } : {}),
|
|
1127
|
+
...(defaultTarget !== undefined ? { default_target: defaultTarget } : {}),
|
|
1029
1128
|
...(device !== undefined ? { device } : {}),
|
|
1030
1129
|
})
|
|
1031
1130
|
process.stdout.write(`${peerProfilePath(process.cwd())}\n`)
|
|
@@ -1063,9 +1162,19 @@ async function selfConfigCommand(): Promise<void> {
|
|
|
1063
1162
|
process.stdout.write(`${r.profilePath}\n`)
|
|
1064
1163
|
process.stdout.write(
|
|
1065
1164
|
`${BIN_NAME} self-config: configured peer "${r.personality}"` +
|
|
1066
|
-
`${r.wakeWord ? ` wake_word="${r.wakeWord}"` : ''}
|
|
1165
|
+
`${r.wakeWord ? ` wake_word="${r.wakeWord}"` : ''}` +
|
|
1166
|
+
`${r.wakeWords ? ` wake_words=${JSON.stringify(r.wakeWords)}` : ''}` +
|
|
1167
|
+
`${r.defaultTarget ? ` default_target="${r.defaultTarget}"` : ''}` +
|
|
1168
|
+
`${r.device ? ` device="${r.device}"` : ''}\n`,
|
|
1067
1169
|
)
|
|
1068
|
-
logEvt('self-config', {
|
|
1170
|
+
logEvt('self-config', {
|
|
1171
|
+
personality: r.personality,
|
|
1172
|
+
wake_word: r.wakeWord,
|
|
1173
|
+
wake_words: r.wakeWords,
|
|
1174
|
+
default_target: r.defaultTarget,
|
|
1175
|
+
device: r.device,
|
|
1176
|
+
profile: r.profilePath,
|
|
1177
|
+
})
|
|
1069
1178
|
}
|
|
1070
1179
|
|
|
1071
1180
|
/** The contract self-description (runtime id / version / bin / package). Pure + exported
|
|
@@ -1088,11 +1197,47 @@ async function doctorCommand(args: string[]): Promise<void> {
|
|
|
1088
1197
|
problems.push(`peer intelligence is ${profile.intelligence}, voicetalk requires natural`)
|
|
1089
1198
|
}
|
|
1090
1199
|
const binding = profile ? voicetalkInterface(profile) : {}
|
|
1091
|
-
if (profile &&
|
|
1092
|
-
//
|
|
1093
|
-
//
|
|
1094
|
-
problems.push('interfaces.voicetalk
|
|
1200
|
+
if (profile && wakeEntries(binding).length === 0) {
|
|
1201
|
+
// Without a wake word nothing the owner says can be addressed — text routing over the
|
|
1202
|
+
// socket still works, but the microphone half is inert.
|
|
1203
|
+
problems.push('interfaces.voicetalk has no wake words (set wake_words — live voice cannot route without one)')
|
|
1095
1204
|
}
|
|
1205
|
+
// The Фаза-3 audio contour: the speech service, the two host binaries and the input
|
|
1206
|
+
// device the passport points at. Reported always; only counted as a problem when audio
|
|
1207
|
+
// is actually enabled — a headless run (VOICETALK_AUDIO=0) legitimately has none.
|
|
1208
|
+
const voice = await voiceHealth()
|
|
1209
|
+
if (VOICETALK_AUDIO_ENABLED && !voice.ok) {
|
|
1210
|
+
problems.push(`voice-connect unreachable at ${voice.endpoint} (${voice.detail ?? 'no detail'}) — STT/TTS will fail`)
|
|
1211
|
+
}
|
|
1212
|
+
const tools: Record<string, boolean> = {}
|
|
1213
|
+
for (const bin of ['ffmpeg', 'ffplay', 'afplay']) {
|
|
1214
|
+
tools[bin] = await hasBinary(bin)
|
|
1215
|
+
if (VOICETALK_AUDIO_ENABLED && !tools[bin]) {
|
|
1216
|
+
problems.push(`${bin} not found on PATH (${bin === 'ffmpeg' ? 'microphone capture' : 'playback'})`)
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
let device: unknown = null
|
|
1220
|
+
if (tools.ffmpeg) {
|
|
1221
|
+
try {
|
|
1222
|
+
const resolved = await resolveInputDevice(binding.device)
|
|
1223
|
+
device = { index: resolved.index, name: resolved.name, matched: resolved.matched }
|
|
1224
|
+
if (resolved.devices.length === 0 && VOICETALK_AUDIO_ENABLED) {
|
|
1225
|
+
problems.push('no avfoundation audio input devices — is the microphone connected / TCC granted?')
|
|
1226
|
+
}
|
|
1227
|
+
} catch (err) {
|
|
1228
|
+
device = { error: formatError(err) }
|
|
1229
|
+
if (VOICETALK_AUDIO_ENABLED) problems.push(formatError(err))
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
const book = profile ? buildAddressBook({ owner: profile.personality, binding }) : []
|
|
1233
|
+
const orphans = profile ? unresolvedWakeWords(binding) : []
|
|
1234
|
+
if (orphans.length) {
|
|
1235
|
+
problems.push(
|
|
1236
|
+
`wake word(s) ${orphans.map(w => `"${w}"`).join(', ')} resolve to no peer — ` +
|
|
1237
|
+
'name the target explicitly in interfaces.voicetalk.wake_words or set default_target',
|
|
1238
|
+
)
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1096
1241
|
const result = {
|
|
1097
1242
|
...doctorReport(),
|
|
1098
1243
|
ok: problems.length === 0,
|
|
@@ -1101,17 +1246,46 @@ async function doctorCommand(args: string[]): Promise<void> {
|
|
|
1101
1246
|
owner: profile ? { personality: profile.personality, intelligence: profile.intelligence } : null,
|
|
1102
1247
|
binding,
|
|
1103
1248
|
inbound_socket: profile ? inboundSocketPath(profile.personality) : null,
|
|
1249
|
+
audio: { enabled: VOICETALK_AUDIO_ENABLED, tools, device },
|
|
1250
|
+
voice_connect: voice,
|
|
1251
|
+
wake_map: book.map(a => ({ target: a.personality, aliases: a.aliases })),
|
|
1104
1252
|
problems,
|
|
1105
1253
|
}
|
|
1106
1254
|
if (flags.json) printJson(result)
|
|
1107
1255
|
else {
|
|
1108
1256
|
process.stdout.write(`runtime: ${result.runtime}\nversion: ${result.version}\nbin: ${result.bin}\n`)
|
|
1257
|
+
process.stdout.write(
|
|
1258
|
+
`voice: ${voice.ok ? `ok ${voice.endpoint} (${voice.source}${voice.version ? `, v${voice.version}` : ''})` : `DOWN ${voice.endpoint} — ${voice.detail ?? ''}`}\n`,
|
|
1259
|
+
)
|
|
1260
|
+
const dev = device as { index?: number; name?: string; error?: string } | null
|
|
1261
|
+
process.stdout.write(
|
|
1262
|
+
`mic: ${dev?.error ? `ERROR ${dev.error}` : dev ? `[${dev.index}] ${dev.name ?? '?'}` : 'not probed'}\n`,
|
|
1263
|
+
)
|
|
1264
|
+
process.stdout.write(
|
|
1265
|
+
`wake: ${book.length ? book.map(a => `${a.aliases[0]} → ${a.personality}`).join(', ') : 'no wake words configured'}\n`,
|
|
1266
|
+
)
|
|
1109
1267
|
process.stdout.write(`${result.ok ? 'ok' : 'not ok'}\n`)
|
|
1110
1268
|
for (const problem of problems) process.stdout.write(`- ${problem}\n`)
|
|
1111
1269
|
}
|
|
1112
1270
|
if (!result.ok) process.exitCode = 1
|
|
1113
1271
|
}
|
|
1114
1272
|
|
|
1273
|
+
/** `devices` — list the host's audio inputs so an operator can pick one for
|
|
1274
|
+
* `interface --device`. Pure diagnostics. */
|
|
1275
|
+
async function devicesCommand(args: string[]): Promise<void> {
|
|
1276
|
+
const { flags } = parseFlags(args)
|
|
1277
|
+
const devices = await listInputDevices()
|
|
1278
|
+
if (flags.json) {
|
|
1279
|
+
printJson(devices)
|
|
1280
|
+
return
|
|
1281
|
+
}
|
|
1282
|
+
if (devices.length === 0) {
|
|
1283
|
+
process.stdout.write('no audio input devices found (is the microphone connected / TCC granted?)\n')
|
|
1284
|
+
return
|
|
1285
|
+
}
|
|
1286
|
+
for (const d of devices) process.stdout.write(`[${d.index}] ${d.name}\n`)
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1115
1289
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1116
1290
|
// entry
|
|
1117
1291
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1149,6 +1323,9 @@ async function main(): Promise<void> {
|
|
|
1149
1323
|
case 'say':
|
|
1150
1324
|
await sayCommand(rest)
|
|
1151
1325
|
return
|
|
1326
|
+
case 'devices':
|
|
1327
|
+
await devicesCommand(rest)
|
|
1328
|
+
return
|
|
1152
1329
|
case 'doctor':
|
|
1153
1330
|
await doctorCommand(rest)
|
|
1154
1331
|
return
|
package/src/selfConfig.ts
CHANGED
|
@@ -46,9 +46,34 @@ export interface SelfConfigOutcome {
|
|
|
46
46
|
profilePath: string
|
|
47
47
|
/** What was written into interfaces.voicetalk this run (for the CLI summary/log). */
|
|
48
48
|
wakeWord?: string
|
|
49
|
+
wakeWords?: Record<string, string> | string[]
|
|
50
|
+
defaultTarget?: string
|
|
49
51
|
device?: string
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
/** Parse the operator's wake map from one env string: "дарвин=darwin,борис=boris".
|
|
55
|
+
* A bare word ("борис") inherits its target from the peer it names / default_target,
|
|
56
|
+
* and is written as a bare list entry. Mixed forms collapse into the map form. */
|
|
57
|
+
export function parseWakeWords(spec: string | undefined): Record<string, string> | string[] | undefined {
|
|
58
|
+
const raw = spec?.trim()
|
|
59
|
+
if (!raw) return undefined
|
|
60
|
+
const pairs: Array<[string, string | undefined]> = []
|
|
61
|
+
for (const part of raw.split(/[,;\n]/)) {
|
|
62
|
+
const item = part.trim()
|
|
63
|
+
if (!item) continue
|
|
64
|
+
const eq = item.indexOf('=')
|
|
65
|
+
if (eq < 0) pairs.push([item, undefined])
|
|
66
|
+
else {
|
|
67
|
+
const word = item.slice(0, eq).trim()
|
|
68
|
+
const target = item.slice(eq + 1).trim()
|
|
69
|
+
if (word) pairs.push([word, target || undefined])
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (pairs.length === 0) return undefined
|
|
73
|
+
if (pairs.every(([, target]) => target === undefined)) return pairs.map(([word]) => word)
|
|
74
|
+
return Object.fromEntries(pairs.map(([word, target]) => [word, target ?? word]))
|
|
75
|
+
}
|
|
76
|
+
|
|
52
77
|
function trimmed(value: string | undefined): string | undefined {
|
|
53
78
|
const v = value?.trim()
|
|
54
79
|
return v && v.length > 0 ? v : undefined
|
|
@@ -102,6 +127,10 @@ export function runSelfConfig(opts: SelfConfigOptions = {}): SelfConfigOutcome {
|
|
|
102
127
|
// Operator-supplied voice binding (env, forwarded by the foundation).
|
|
103
128
|
const wakeWord = trimmed(env.VOICETALK_WAKE_WORD)
|
|
104
129
|
const device = trimmed(env.VOICETALK_DEVICE)
|
|
130
|
+
// Hybrid wake map (Фаза 3): "борис=boris,линус=linus" — a bare word inherits the
|
|
131
|
+
// default target. Written only when the operator supplies it.
|
|
132
|
+
const wakeWords = parseWakeWords(trimmed(env.VOICETALK_WAKE_WORDS))
|
|
133
|
+
const defaultTarget = trimmed(env.VOICETALK_DEFAULT_TARGET)
|
|
105
134
|
|
|
106
135
|
// Merge interfaces.voicetalk (preserve any existing voicetalk fields). Only set
|
|
107
136
|
// wake_word/device when provided — never blank out a value the operator set earlier.
|
|
@@ -115,6 +144,8 @@ export function runSelfConfig(opts: SelfConfigOptions = {}): SelfConfigOutcome {
|
|
|
115
144
|
? { ...((interfaces as { voicetalk: Record<string, unknown> }).voicetalk) }
|
|
116
145
|
: {}
|
|
117
146
|
if (wakeWord) voicetalk.wake_word = wakeWord
|
|
147
|
+
if (wakeWords) voicetalk.wake_words = wakeWords
|
|
148
|
+
if (defaultTarget) voicetalk.default_target = defaultTarget
|
|
118
149
|
if (device) voicetalk.device = device
|
|
119
150
|
;(interfaces as Record<string, unknown>).voicetalk = voicetalk
|
|
120
151
|
|
|
@@ -125,5 +156,5 @@ export function runSelfConfig(opts: SelfConfigOptions = {}): SelfConfigOutcome {
|
|
|
125
156
|
|
|
126
157
|
writeJsonAtomic(profilePath, merged)
|
|
127
158
|
|
|
128
|
-
return { personality, profilePath, wakeWord, device }
|
|
159
|
+
return { personality, profilePath, wakeWord, wakeWords, defaultTarget, device }
|
|
129
160
|
}
|