@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 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 — it is discovered through
67
- `<IAPEER_ROOT>/voice-provider.json` (override with `VOICETALK_VOICE_ENDPOINT`).
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
- `<IAPEER_ROOT>/voice-provider.json` (переопределяется `VOICETALK_VOICE_ENDPOINT`).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/voicetalk-runtime",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Voice runtime-router for IAP peers.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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, voice?, lang?, engine?) → ogg/opus bytes
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
- /** Per-peer voice (the sender's `interfaces.voice` entry) — so agents sound distinct. */
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
  }
@@ -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
- await this.say(text, { from, voice: this.voiceFor(from) })
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; voice?: string } = {}): Promise<void> {
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, { voice: opts.voice })
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 («наталья» → «наталья вячеславовна») and their
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 {