@agfpd/voicetalk-runtime 0.2.0 → 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/docs/README.md +4 -2
- package/docs/ru/README.md +4 -2
- package/package.json +1 -1
- package/src/voiceConnect.ts +13 -2
- package/src/voiceSession.ts +9 -12
- package/src/wake.ts +1 -8
package/docs/README.md
CHANGED
|
@@ -63,8 +63,10 @@ Everything is tunable by env: `VOICETALK_AUDIO=0` (text-only, no microphone),
|
|
|
63
63
|
## Requirements
|
|
64
64
|
|
|
65
65
|
- `ffmpeg` (microphone capture) and `ffplay` (playback) on `PATH`.
|
|
66
|
-
- The voice-connect HTTP service running locally —
|
|
67
|
-
|
|
66
|
+
- The voice-connect HTTP service running locally (**≥0.4.0** — replies are voiced by
|
|
67
|
+
sender identity, `peer`, so an agent keeps its own voice on every fallback rung) — it
|
|
68
|
+
is discovered through `<IAPEER_ROOT>/voice-provider.json` (override with
|
|
69
|
+
`VOICETALK_VOICE_ENDPOINT`).
|
|
68
70
|
- Microphone permission (TCC) granted once to the process that runs the runtime.
|
|
69
71
|
|
|
70
72
|
These pages are written as each phase lands. Russian translation lives in
|
package/docs/ru/README.md
CHANGED
|
@@ -62,8 +62,10 @@ Earcon'ы размечают состояния: восходящий — мик
|
|
|
62
62
|
## Требования
|
|
63
63
|
|
|
64
64
|
- `ffmpeg` (захват микрофона) и `ffplay` (воспроизведение) на `PATH`.
|
|
65
|
-
- Локально запущенный HTTP-сервис voice-connect —
|
|
66
|
-
|
|
65
|
+
- Локально запущенный HTTP-сервис voice-connect (**≥0.4.0** — ответы озвучиваются по
|
|
66
|
+
идентичности отправителя, параметр `peer`, поэтому агент сохраняет свой голос на любой
|
|
67
|
+
ступени каскада) — обнаруживается через `<IAPEER_ROOT>/voice-provider.json`
|
|
68
|
+
(переопределяется `VOICETALK_VOICE_ENDPOINT`).
|
|
67
69
|
- Разрешение на микрофон (TCC), выданное один раз процессу рантайма.
|
|
68
70
|
|
|
69
71
|
Страницы пишутся по мере прохождения фаз.
|
package/package.json
CHANGED
package/src/voiceConnect.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// CONSUMER of its OpenAI-compatible routes:
|
|
7
7
|
//
|
|
8
8
|
// POST /v1/audio/transcriptions multipart(file, language?, engine?) → { text }
|
|
9
|
-
// POST /v1/audio/speech json(input,
|
|
9
|
+
// POST /v1/audio/speech json(input, peer?, lang?, engine?) → ogg/opus bytes
|
|
10
10
|
// GET /health → { status }
|
|
11
11
|
//
|
|
12
12
|
// Engine selection, provider cascade and fallbacks live INSIDE that service — never
|
|
@@ -143,7 +143,14 @@ export async function transcribe(audioPath: string, opts: TranscribeOptions = {}
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
export type SynthesizeOptions = {
|
|
146
|
-
/**
|
|
146
|
+
/** The SPEAKER's identity (voice-connect ≥0.4.0). The service resolves that peer's
|
|
147
|
+
* voice from `interfaces.voice` AFTER the serving engine is known, so the identity
|
|
148
|
+
* survives every rung of the cascade — including the F5 reference. Always prefer this
|
|
149
|
+
* over `voice`: a voice NAME belongs to one engine's namespace, so it only applies to
|
|
150
|
+
* the engine tried first and means nothing on the rung that actually serves. */
|
|
151
|
+
peer?: string
|
|
152
|
+
/** Legacy voice-name override — covers the PRIMARY engine only. Kept for callers with
|
|
153
|
+
* no peer identity to offer; `peer` is what a runtime should send. */
|
|
147
154
|
voice?: string
|
|
148
155
|
lang?: string
|
|
149
156
|
style?: string
|
|
@@ -155,6 +162,8 @@ export type SynthesizeResult = {
|
|
|
155
162
|
path: string
|
|
156
163
|
engine?: string
|
|
157
164
|
voice?: string
|
|
165
|
+
/** The identity the service voiced this as (echo of `peer`). */
|
|
166
|
+
peer?: string
|
|
158
167
|
fallbackFrom?: string
|
|
159
168
|
bytes: number
|
|
160
169
|
}
|
|
@@ -168,6 +177,7 @@ export async function synthesize(text: string, outPath: string, opts: Synthesize
|
|
|
168
177
|
headers: { 'content-type': 'application/json' },
|
|
169
178
|
body: JSON.stringify({
|
|
170
179
|
input: text,
|
|
180
|
+
...(opts.peer ? { peer: opts.peer } : {}),
|
|
171
181
|
...(opts.voice ? { voice: opts.voice } : {}),
|
|
172
182
|
...(opts.lang ? { lang: opts.lang } : {}),
|
|
173
183
|
...(opts.style ? { style: opts.style } : {}),
|
|
@@ -185,6 +195,7 @@ export async function synthesize(text: string, outPath: string, opts: Synthesize
|
|
|
185
195
|
path: outPath,
|
|
186
196
|
engine: res.headers.get('x-voice-engine') ?? undefined,
|
|
187
197
|
voice: res.headers.get('x-voice-voice') ?? undefined,
|
|
198
|
+
peer: res.headers.get('x-voice-peer') ?? undefined,
|
|
188
199
|
fallbackFrom: res.headers.get('x-voice-fallback-from') ?? undefined,
|
|
189
200
|
bytes: buf.length,
|
|
190
201
|
}
|
package/src/voiceSession.ts
CHANGED
|
@@ -268,15 +268,6 @@ export class VoiceSession {
|
|
|
268
268
|
}
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
-
/** Voice of the SENDER — each agent keeps the voice configured in its own passport
|
|
272
|
-
* (`interfaces.voice`), so the owner hears who is talking. */
|
|
273
|
-
private voiceFor(personality: string): string | undefined {
|
|
274
|
-
const peers = this.opts.peers ?? readFleetPeers()
|
|
275
|
-
const voice = peers.find(p => p.personality === personality)?.voice
|
|
276
|
-
if (!voice) return undefined
|
|
277
|
-
return voice['gemini-3.1-flash-tts-preview'] ?? Object.values(voice)[0]
|
|
278
|
-
}
|
|
279
|
-
|
|
280
271
|
/** Speak an agent's answer on the owner's speaker, then hold the follow-up window
|
|
281
272
|
* open — the conversational turn passes back to the human. */
|
|
282
273
|
async speak(from: string, message: string): Promise<void> {
|
|
@@ -285,7 +276,12 @@ export class VoiceSession {
|
|
|
285
276
|
this.opts.log('tts.skip', { from, reason: 'nothing speakable' })
|
|
286
277
|
return
|
|
287
278
|
}
|
|
288
|
-
|
|
279
|
+
// Voice by IDENTITY, not by voice name: voice-connect resolves the sender's voice
|
|
280
|
+
// after picking the engine, so the owner hears WHO is talking even when the primary
|
|
281
|
+
// engine is down and a fallback rung serves. Sending a voice name instead would only
|
|
282
|
+
// bind to the primary engine — the very defect seen live on 22.07 (boris answered in
|
|
283
|
+
// a stranger's voice once gemini was unavailable).
|
|
284
|
+
await this.say(text, { from })
|
|
289
285
|
this.followTarget = from
|
|
290
286
|
this.followUntil = Date.now() + FOLLOWUP_MS
|
|
291
287
|
// The turn passes back to the human: the mic is open for a follow-up without a
|
|
@@ -293,11 +289,11 @@ export class VoiceSession {
|
|
|
293
289
|
await this.earcon('open')
|
|
294
290
|
}
|
|
295
291
|
|
|
296
|
-
private async say(text: string, opts: { from?: string
|
|
292
|
+
private async say(text: string, opts: { from?: string } = {}): Promise<void> {
|
|
297
293
|
const out = join(this.opts.tmpDir, `say-${randomUUID()}.ogg`)
|
|
298
294
|
const started = Date.now()
|
|
299
295
|
try {
|
|
300
|
-
const result = await synthesize(text, out, {
|
|
296
|
+
const result = await synthesize(text, out, { peer: opts.from })
|
|
301
297
|
const ttsMs = Date.now() - started
|
|
302
298
|
// Gate the mic for the whole playback: the runtime must not hear itself.
|
|
303
299
|
this.muteUntil = Date.now() + 3_600_000
|
|
@@ -313,6 +309,7 @@ export class VoiceSession {
|
|
|
313
309
|
chars: text.length,
|
|
314
310
|
engine: result.engine,
|
|
315
311
|
voice: result.voice,
|
|
312
|
+
voicedAs: result.peer,
|
|
316
313
|
fallbackFrom: result.fallbackFrom,
|
|
317
314
|
ttsMs,
|
|
318
315
|
totalMs: Date.now() - started,
|
package/src/wake.ts
CHANGED
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
// was explicitly REJECTED — it couples the spoken vocabulary to the roster and multiplies
|
|
8
8
|
// false triggers — so nothing here ever sweeps the fleet registry for addressable names.
|
|
9
9
|
// The registry is consulted only for peers the operator ALREADY named in the map, to
|
|
10
|
-
// enrich them with their own human name («наталья» → «наталья вячеславовна»)
|
|
11
|
-
// voice.
|
|
10
|
+
// enrich them with their own human name («наталья» → «наталья вячеславовна»).
|
|
12
11
|
//
|
|
13
12
|
// Why the address is matched on the TRANSCRIPT rather than by an acoustic wake-word model
|
|
14
13
|
// (openWakeWord et al.): an acoustic detector needs a trained model per phrase, and Фаза 3
|
|
@@ -29,8 +28,6 @@ export type FleetPeer = {
|
|
|
29
28
|
personality: string
|
|
30
29
|
intelligence?: string
|
|
31
30
|
description?: string
|
|
32
|
-
/** Per-peer TTS voice from `interfaces.voice` — so each agent answers in its own voice. */
|
|
33
|
-
voice?: Record<string, string>
|
|
34
31
|
}
|
|
35
32
|
|
|
36
33
|
export type Address = {
|
|
@@ -109,10 +106,6 @@ export function readFleetPeers(path = join(iapeerRoot(), 'peers-profiles.json'))
|
|
|
109
106
|
personality: String(p.personality ?? ''),
|
|
110
107
|
intelligence: typeof p.intelligence === 'string' ? p.intelligence : undefined,
|
|
111
108
|
description: typeof p.description === 'string' ? p.description : undefined,
|
|
112
|
-
voice:
|
|
113
|
-
p.interfaces && typeof p.interfaces === 'object' && (p.interfaces as any).voice
|
|
114
|
-
? ((p.interfaces as any).voice as Record<string, string>)
|
|
115
|
-
: undefined,
|
|
116
109
|
}))
|
|
117
110
|
.filter(p => p.personality)
|
|
118
111
|
} catch {
|