@pellux/goodvibes-agent 1.5.8 → 1.5.9

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.
@@ -15,6 +15,14 @@ export interface StreamingAudioPlayer {
15
15
  readonly available: boolean;
16
16
  play(chunks: AsyncIterable<VoiceAudioChunk>, options: StreamingAudioPlaybackOptions): Promise<void>;
17
17
  stop(): void;
18
+ /**
19
+ * Resolves once the currently playing sink has finished naturally (its
20
+ * process closed) or after `timeoutMs`, whichever comes first; resolves
21
+ * immediately when nothing is playing. The exit path uses this to let the
22
+ * audio the user is already hearing finish inside a short bounded window
23
+ * instead of killing it mid-drain. stop() remains the instant cut.
24
+ */
25
+ waitForDrain(timeoutMs: number): Promise<void>;
18
26
  }
19
27
 
20
28
  export interface StreamingAudioPlaybackOptions {
@@ -25,6 +33,8 @@ export interface StreamingAudioPlaybackOptions {
25
33
  interface SpawnProcess {
26
34
  readonly stdin: Writable;
27
35
  once(event: 'close', listener: () => void): this;
36
+ once(event: 'spawn', listener: () => void): this;
37
+ once(event: 'error', listener: (error: unknown) => void): this;
28
38
  kill(signal?: NodeJS.Signals | number): boolean;
29
39
  }
30
40
  type SpawnProcessFactory = (command: string, args: readonly string[]) => SpawnProcess;
@@ -32,6 +42,13 @@ type SpawnProcessFactory = (command: string, args: readonly string[]) => SpawnPr
32
42
  export interface LocalStreamingAudioPlayerOptions {
33
43
  readonly env?: NodeJS.ProcessEnv;
34
44
  readonly spawnProcess?: SpawnProcessFactory;
45
+ /**
46
+ * Injected player command — the test seam companion to `spawnProcess`.
47
+ * When set, the constructor uses it verbatim instead of scanning PATH, so
48
+ * deterministic fake-sink tests never depend on mpv/ffplay being installed
49
+ * on the machine running them. Pass null to model "no player found".
50
+ */
51
+ readonly command?: StreamingAudioPlayerCommand | null;
35
52
  }
36
53
 
37
54
  export class LocalStreamingAudioPlayer implements StreamingAudioPlayer {
@@ -40,7 +57,9 @@ export class LocalStreamingAudioPlayer implements StreamingAudioPlayer {
40
57
  private readonly spawnProcess: SpawnProcessFactory;
41
58
 
42
59
  constructor(options: LocalStreamingAudioPlayerOptions = {}) {
43
- this.command = resolveStreamingAudioPlayerCommand(options.env ?? process.env);
60
+ this.command = options.command !== undefined
61
+ ? options.command
62
+ : resolveStreamingAudioPlayerCommand(options.env ?? process.env);
44
63
  this.spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
45
64
  }
46
65
 
@@ -67,12 +86,27 @@ export class LocalStreamingAudioPlayer implements StreamingAudioPlayer {
67
86
  options.signal?.addEventListener('abort', abort, { once: true });
68
87
 
69
88
  try {
89
+ // Head survival: hold the first audio byte until the sink has actually
90
+ // started (its 'spawn' event) instead of writing into a process that has
91
+ // not exec'd yet. Writing before the player is up is the spawn race that
92
+ // clipped the beginning of playback. A spawn failure rejects here so the
93
+ // caller can report it honestly rather than swallowing a dead player.
94
+ await awaitReady(proc, options.signal);
95
+ if (options.signal?.aborted) return;
96
+
70
97
  for await (const chunk of chunks) {
71
98
  if (options.signal?.aborted) break;
72
99
  if (chunk.data.byteLength === 0) continue;
73
100
  await writeStdin(proc, chunk.data);
74
101
  }
75
- proc.stdin.end();
102
+
103
+ // An intentional interrupt (turn cancel / quit chord / /tts stop) has
104
+ // already torn the process down via `abort` and must cut immediately —
105
+ // do not wait on a graceful drain. A natural end-of-speech, by contrast,
106
+ // closes stdin and waits for the sink to play out every buffered sample
107
+ // so the tail of the response is never truncated.
108
+ if (options.signal?.aborted) return;
109
+ try { proc.stdin.end(); } catch { /* ignore */ }
76
110
  await waitForExit(proc);
77
111
  } finally {
78
112
  options.signal?.removeEventListener('abort', abort);
@@ -87,14 +121,33 @@ export class LocalStreamingAudioPlayer implements StreamingAudioPlayer {
87
121
  try { proc.stdin.destroy(); } catch { /* ignore */ }
88
122
  try { proc.kill('SIGTERM'); } catch { /* ignore */ }
89
123
  }
124
+
125
+ waitForDrain(timeoutMs: number): Promise<void> {
126
+ const proc = this.activeProcess;
127
+ if (!proc) return Promise.resolve();
128
+ return new Promise((resolve) => {
129
+ let settled = false;
130
+ const settle = () => {
131
+ if (settled) return;
132
+ settled = true;
133
+ clearTimeout(timer);
134
+ resolve();
135
+ };
136
+ const timer = setTimeout(settle, timeoutMs);
137
+ proc.once('close', settle);
138
+ });
139
+ }
90
140
  }
91
141
 
92
142
  export function resolveStreamingAudioPlayerCommand(env: NodeJS.ProcessEnv = process.env): StreamingAudioPlayerCommand | null {
93
143
  const mpv = findExecutable('mpv', env);
94
144
  if (mpv) {
145
+ // No --cache=no: mpv's read-ahead cache buffers the incoming pipe so the
146
+ // opening audio survives device-open latency and network jitter instead of
147
+ // underrunning while the output device is still spinning up.
95
148
  return {
96
149
  command: mpv,
97
- args: ['--no-terminal', '--really-quiet', '--force-window=no', '--cache=no', '-'],
150
+ args: ['--no-terminal', '--really-quiet', '--force-window=no', '-'],
98
151
  label: 'mpv',
99
152
  };
100
153
  }
@@ -102,18 +155,51 @@ export function resolveStreamingAudioPlayerCommand(env: NodeJS.ProcessEnv = proc
102
155
  if (ffplay) {
103
156
  return {
104
157
  command: ffplay,
105
- args: ['-nodisp', '-autoexit', '-loglevel', 'error', '-i', 'pipe:0'],
158
+ args: FFPLAY_BASE_ARGS,
106
159
  label: 'ffplay',
107
160
  };
108
161
  }
109
162
  return null;
110
163
  }
111
164
 
165
+ // ffplay -autoexit quits as soon as its input ends, before the audio output
166
+ // buffer has drained — that clips the tail of the response. `apad` appends a
167
+ // short run of silence so the real audio is fully played out and only the
168
+ // trailing silence gets trimmed.
169
+ const FFPLAY_APAD = ['-af', 'apad=pad_dur=0.3'] as const;
170
+ const FFPLAY_BASE_ARGS = ['-nodisp', '-autoexit', '-loglevel', 'error', ...FFPLAY_APAD, '-i', 'pipe:0'] as const;
171
+
112
172
  function buildPlayerArgs(command: StreamingAudioPlayerCommand, format?: string): readonly string[] {
113
173
  if (command.label !== 'ffplay' || !format) return command.args;
114
174
  const normalized = format.trim().toLowerCase();
115
175
  if (!normalized || normalized.includes('/')) return command.args;
116
- return ['-nodisp', '-autoexit', '-loglevel', 'error', '-f', normalized, '-i', 'pipe:0'];
176
+ return ['-nodisp', '-autoexit', '-loglevel', 'error', ...FFPLAY_APAD, '-f', normalized, '-i', 'pipe:0'];
177
+ }
178
+
179
+ /**
180
+ * awaitReady — resolves once the spawned player has actually started (its
181
+ * 'spawn' event), rejects if it fails to start ('error'), and resolves early
182
+ * if the caller aborts during startup so an intentional interrupt is never
183
+ * blocked. This is the readiness gate that keeps the first audio byte from
184
+ * being written into a not-yet-running sink.
185
+ */
186
+ function awaitReady(proc: SpawnProcess, signal?: AbortSignal): Promise<void> {
187
+ if (signal?.aborted) return Promise.resolve();
188
+ return new Promise<void>((resolve, reject) => {
189
+ let settled = false;
190
+ const settle = (action: () => void) => {
191
+ if (settled) return;
192
+ settled = true;
193
+ signal?.removeEventListener('abort', onAbort);
194
+ action();
195
+ };
196
+ const onSpawn = () => settle(resolve);
197
+ const onError = (error: unknown) => settle(() => reject(error instanceof Error ? error : new Error(String(error))));
198
+ const onAbort = () => settle(resolve);
199
+ proc.once('spawn', onSpawn);
200
+ proc.once('error', onError);
201
+ signal?.addEventListener('abort', onAbort, { once: true });
202
+ });
117
203
  }
118
204
 
119
205
  function findExecutable(name: string, env: NodeJS.ProcessEnv): string | null {
@@ -6,6 +6,35 @@ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
6
6
  import { TtsTextChunker } from './text-chunker.ts';
7
7
  import type { StreamingAudioPlayer } from './player.ts';
8
8
 
9
+ /**
10
+ * How many synthesis requests may sit in the pipeline at once (synthesizing,
11
+ * waiting to play, or playing). 2 = the chunk being played plus ONE prefetch,
12
+ * so the next audio is ready the moment the current sink drains. Bounding
13
+ * this is what keeps a streaming answer from bursting N concurrent requests
14
+ * at the voice provider — ElevenLabs plans allow as few as 3 concurrent, and
15
+ * an unbounded burst 429s the whole turn. The SDK config schema has no tts.*
16
+ * key for pipeline tuning, so this is a constant by design.
17
+ */
18
+ const SYNTHESIS_PIPELINE_WINDOW = 2;
19
+
20
+ /**
21
+ * Upper bound for one merged synthesis request's text. The ElevenLabs
22
+ * provider passes request text through verbatim (no cap of its own); the API
23
+ * caps text per request by plan — 2,500 chars on the lowest tiers, 5,000 on
24
+ * most others. 1,500 stays safely under every plan while still folding a
25
+ * multi-paragraph answer into one or two requests.
26
+ */
27
+ const SYNTHESIS_MERGE_MAX_CHARS = 1500;
28
+
29
+ /**
30
+ * Backoff schedule for transient synthesis failures (429 rate/concurrency
31
+ * limits, transient 5xx, network drops): first retry after 1s, second after
32
+ * 2.5s, then the chunk is skipped honestly and the turn continues. The SDK's
33
+ * provider errors are plain Error strings with the HTTP status embedded — no
34
+ * Retry-After header is exposed — so the schedule is fixed, not server-driven.
35
+ */
36
+ const SYNTHESIS_RETRY_DELAYS_MS = [1000, 2500] as const;
37
+
9
38
  export interface SpokenTurnControllerOptions {
10
39
  readonly voiceService: Pick<VoiceService, 'synthesizeStream'>;
11
40
  readonly configManager: Pick<ConfigManager, 'get'>;
@@ -14,6 +43,9 @@ export interface SpokenTurnControllerOptions {
14
43
  readonly now?: () => number;
15
44
  readonly setInterval?: typeof setInterval;
16
45
  readonly clearInterval?: typeof clearInterval;
46
+ /** Injectable clock for retry backoff timers (tests use fakes). */
47
+ readonly setTimeout?: typeof setTimeout;
48
+ readonly clearTimeout?: typeof clearTimeout;
17
49
  }
18
50
 
19
51
  export class SpokenTurnController {
@@ -25,6 +57,16 @@ export class SpokenTurnController {
25
57
  private readonly abortControllers = new Set<AbortController>();
26
58
  private timer: ReturnType<typeof setInterval> | null = null;
27
59
  private errorReportedForTurn = false;
60
+ private noPlayerNoticed = false;
61
+ /** Chunker output waiting to be merged into a synthesis request. */
62
+ private pendingTexts: string[] = [];
63
+ /** Requests currently in the pipeline (synthesizing / waiting / playing). */
64
+ private pipelineDepth = 0;
65
+ /** Bumped on every teardown so stale pipeline releases are ignored. */
66
+ private pipelineGeneration = 0;
67
+ private pumpScheduled = false;
68
+ /** Set when TURN_COMPLETED arrives; the turn releases once the pipeline drains. */
69
+ private completedTurnId: string | null = null;
28
70
  private readonly voiceService: Pick<VoiceService, 'synthesizeStream'>;
29
71
  private readonly configManager: Pick<ConfigManager, 'get'>;
30
72
  private readonly player: StreamingAudioPlayer;
@@ -32,6 +74,8 @@ export class SpokenTurnController {
32
74
  private readonly now: () => number;
33
75
  private readonly setIntervalImpl: typeof setInterval;
34
76
  private readonly clearIntervalImpl: typeof clearInterval;
77
+ private readonly setTimeoutImpl: typeof setTimeout;
78
+ private readonly clearTimeoutImpl: typeof clearTimeout;
35
79
 
36
80
  constructor(options: SpokenTurnControllerOptions) {
37
81
  this.voiceService = options.voiceService;
@@ -41,6 +85,8 @@ export class SpokenTurnController {
41
85
  this.now = options.now ?? (() => Date.now());
42
86
  this.setIntervalImpl = options.setInterval ?? setInterval;
43
87
  this.clearIntervalImpl = options.clearInterval ?? clearInterval;
88
+ this.setTimeoutImpl = options.setTimeout ?? setTimeout;
89
+ this.clearTimeoutImpl = options.clearTimeout ?? clearTimeout;
44
90
  }
45
91
 
46
92
  submitNextTurn(prompt: string): boolean {
@@ -48,25 +94,65 @@ export class SpokenTurnController {
48
94
  if (!normalized) return false;
49
95
  this.stop();
50
96
  if (!this.player.available) {
51
- this.notify?.('[TTS] Text response will continue, but live audio is unavailable. Install mpv or ffplay.');
97
+ if (!this.noPlayerNoticed) {
98
+ this.noPlayerNoticed = true;
99
+ this.notify?.('[TTS] Text response will continue, but live audio is unavailable. Install mpv or ffplay.');
100
+ }
52
101
  return false;
53
102
  }
103
+ // Reset the no-player notice if player becomes available again.
104
+ this.noPlayerNoticed = false;
54
105
  this.pendingPrompt = normalized;
55
106
  return true;
56
107
  }
57
108
 
58
- stop(message?: string): void {
109
+ /**
110
+ * Returns whether speech was actually ACTIVE when stopped. The notice only
111
+ * prints in that case — stop() on an idle controller used to notify anyway,
112
+ * spamming "[TTS] Spoken output stopped." on every Ctrl+C (an earlier replay
113
+ * fix); callers use the return to decide whether the press "did a
114
+ * job" (see handleCtrlC's consume-on-speech-stop).
115
+ */
116
+ stop(message?: string): boolean {
117
+ const wasActive = this.pendingPrompt !== null || this.activeTurnId !== null
118
+ || this.chunker !== null || this.abortControllers.size > 0;
59
119
  this.pendingPrompt = null;
60
120
  this.activeTurnId = null;
61
121
  this.chunker?.reset();
62
122
  this.chunker = null;
63
123
  this.stopTimer();
124
+ this.resetPipeline();
64
125
  for (const controller of this.abortControllers) controller.abort();
65
126
  this.abortControllers.clear();
66
127
  this.player.stop();
67
128
  this.playbackChain = Promise.resolve();
68
129
  this.errorReportedForTurn = false;
69
- if (message) this.notify?.(`[TTS] ${message}`);
130
+ if (message && wasActive) this.notify?.(`[TTS] ${message}`);
131
+ return wasActive;
132
+ }
133
+
134
+ /**
135
+ * Exit-path teardown: drops everything not yet audible (pending arm,
136
+ * buffered text, queued chunks) but lets the audio the user is already
137
+ * hearing finish naturally, capped at `drainTimeoutMs`, before the hard
138
+ * stop. Deliberate interrupts (Ctrl+C, /tts stop, turn cancel) keep their
139
+ * instant path through stop(); this is only for exiting the app while the
140
+ * final audio of a completed response is still draining.
141
+ */
142
+ async stopForExit(drainTimeoutMs = 2000): Promise<void> {
143
+ this.pendingPrompt = null;
144
+ this.activeTurnId = null;
145
+ this.chunker?.reset();
146
+ this.chunker = null;
147
+ this.stopTimer();
148
+ this.resetPipeline();
149
+ // Cancel chunks that have not started playing; the chunk currently in the
150
+ // sink is not in this set (its controller is released before playback).
151
+ for (const controller of this.abortControllers) controller.abort();
152
+ this.abortControllers.clear();
153
+ await this.player.waitForDrain(drainTimeoutMs);
154
+ // Backstop: anything still alive after the window is torn down hard.
155
+ this.stop();
70
156
  }
71
157
 
72
158
  handleTurnEvent(event: TurnEvent): void {
@@ -77,7 +163,7 @@ export class SpokenTurnController {
77
163
  if (!this.activeTurnId || event.turnId !== this.activeTurnId) return;
78
164
 
79
165
  if (event.type === 'STREAM_DELTA') {
80
- this.enqueueChunks(this.chunker?.push(event.content) ?? []);
166
+ this.queueTexts(this.chunker?.push(event.content) ?? []);
81
167
  return;
82
168
  }
83
169
  if (event.type === 'STREAM_END') {
@@ -101,30 +187,33 @@ export class SpokenTurnController {
101
187
  this.errorReportedForTurn = false;
102
188
  this.chunker = new TtsTextChunker({ now: this.now });
103
189
  this.playbackChain = Promise.resolve();
190
+ this.resetPipeline();
104
191
  this.startTimer();
105
192
  this.notify?.(`[TTS] Live playback queued through ${this.player.label}.`);
106
193
  }
107
194
 
108
195
  private finishTurn(turnId: string): void {
109
196
  if (turnId !== this.activeTurnId) return;
110
- this.enqueueChunks(this.chunker?.flushAll() ?? []);
197
+ this.queueTexts(this.chunker?.flushAll() ?? []);
111
198
  this.stopTimer();
112
- const chain = this.playbackChain;
113
- chain.finally(() => {
114
- if (this.activeTurnId !== turnId) return;
115
- this.activeTurnId = null;
116
- this.chunker = null;
117
- this.abortControllers.clear();
118
- }).catch(() => {
119
- // Errors are already reported in the queued task.
120
- });
199
+ this.completedTurnId = turnId;
200
+ // Nothing pending and nothing in flight releases immediately; otherwise
201
+ // the last pipeline slot to free performs the release.
202
+ this.maybeReleaseTurn();
203
+ }
204
+
205
+ private resetPipeline(): void {
206
+ this.pendingTexts = [];
207
+ this.pipelineDepth = 0;
208
+ this.pipelineGeneration++;
209
+ this.completedTurnId = null;
121
210
  }
122
211
 
123
212
  private startTimer(): void {
124
213
  this.stopTimer();
125
214
  this.timer = this.setIntervalImpl(() => {
126
215
  if (!this.activeTurnId || !this.chunker) return;
127
- this.enqueueChunks(this.chunker.flushDue());
216
+ this.queueTexts(this.chunker.flushDue());
128
217
  }, 250);
129
218
  }
130
219
 
@@ -134,45 +223,163 @@ export class SpokenTurnController {
134
223
  this.timer = null;
135
224
  }
136
225
 
137
- private enqueueChunks(chunks: readonly string[]): void {
226
+ /**
227
+ * Chunker output does NOT map 1:1 to synthesis requests. Text queues here
228
+ * and the pump merges everything pending into one request whenever a
229
+ * pipeline slot is free — so the request count tracks how often the model
230
+ * out-paces the audio, not how many sentences it wrote. A short answer that
231
+ * arrives before the first pump tick is exactly one request.
232
+ */
233
+ private queueTexts(chunks: readonly string[]): void {
138
234
  for (const chunk of chunks) {
139
- this.enqueueChunk(chunk);
235
+ if (chunk.trim()) this.pendingTexts.push(chunk);
236
+ }
237
+ if (this.pendingTexts.length > 0) this.schedulePump();
238
+ }
239
+
240
+ /**
241
+ * Deferred one tick so text delivered in the same synchronous burst (fast
242
+ * deltas, or a turn that completes instantly) coalesces into a single
243
+ * request instead of firing per sentence boundary.
244
+ */
245
+ private schedulePump(): void {
246
+ if (this.pumpScheduled) return;
247
+ this.pumpScheduled = true;
248
+ queueMicrotask(() => {
249
+ this.pumpScheduled = false;
250
+ this.pump();
251
+ });
252
+ }
253
+
254
+ private pump(): void {
255
+ while (this.activeTurnId && this.pendingTexts.length > 0 && this.pipelineDepth < SYNTHESIS_PIPELINE_WINDOW) {
256
+ this.dispatchChunk(this.takeMergedText());
140
257
  }
258
+ this.maybeReleaseTurn();
141
259
  }
142
260
 
143
- private enqueueChunk(text: string): void {
261
+ /** Merge everything pending into one request, capped at the per-request text limit. */
262
+ private takeMergedText(): string {
263
+ let merged = '';
264
+ while (this.pendingTexts.length > 0) {
265
+ const next = this.pendingTexts[0]!;
266
+ if (!merged && next.length > SYNTHESIS_MERGE_MAX_CHARS) {
267
+ // A single oversized entry (e.g. a large end-of-turn flush): split at
268
+ // a word boundary under the per-request cap; the rest stays queued.
269
+ const cut = findSplitIndex(next, SYNTHESIS_MERGE_MAX_CHARS);
270
+ this.pendingTexts[0] = next.slice(cut).trim();
271
+ return next.slice(0, cut).trim();
272
+ }
273
+ if (merged && merged.length + 1 + next.length > SYNTHESIS_MERGE_MAX_CHARS) break;
274
+ merged = merged ? `${merged} ${next}` : next;
275
+ this.pendingTexts.shift();
276
+ }
277
+ return merged;
278
+ }
279
+
280
+ private dispatchChunk(text: string): void {
144
281
  const turnId = this.activeTurnId;
145
282
  if (!turnId || !text.trim()) return;
146
283
  const sequence = ++this.chunkSequence;
284
+ const generation = this.pipelineGeneration;
285
+ this.pipelineDepth++;
147
286
  const abortController = new AbortController();
148
287
  this.abortControllers.add(abortController);
149
- const resultPromise = this.synthesize(text, turnId, sequence, abortController.signal)
288
+ const resultPromise = this.synthesizeWithRetry(text, turnId, sequence, abortController.signal)
150
289
  .then((result) => ({ ok: true as const, result }))
151
290
  .catch((error: unknown) => ({ ok: false as const, error }));
152
291
 
153
292
  this.playbackChain = this.playbackChain.then(async () => {
154
- if (abortController.signal.aborted) return;
155
- const result = await resultPromise;
156
- this.abortControllers.delete(abortController);
157
- if (!result.ok) {
158
- this.reportError(result.error);
159
- return;
293
+ try {
294
+ if (abortController.signal.aborted) {
295
+ this.abortControllers.delete(abortController);
296
+ return;
297
+ }
298
+ const result = await resultPromise;
299
+ this.abortControllers.delete(abortController);
300
+ // Re-check after the await: an abort that landed while synthesis was
301
+ // in flight (deliberate stop or exit) makes the rejection expected —
302
+ // it must not be reported, and it must not hard-stop a sink that may
303
+ // still be draining the previous chunk.
304
+ if (abortController.signal.aborted) return;
305
+ if (!result.ok) {
306
+ // Retries are exhausted (or the failure was not transient). Skip
307
+ // just this chunk and keep speaking the rest of the turn — a gap in
308
+ // speech beats losing the whole response.
309
+ this.reportSkippedChunk(result.error);
310
+ return;
311
+ }
312
+ await this.player.play(result.result.chunks, {
313
+ format: String(result.result.format ?? 'mp3'),
314
+ signal: abortController.signal,
315
+ });
316
+ } finally {
317
+ this.releasePipelineSlot(generation);
160
318
  }
161
- await this.player.play(result.result.chunks, {
162
- format: String(result.result.format ?? 'mp3'),
163
- signal: abortController.signal,
164
- });
165
319
  }).catch((error: unknown) => {
166
320
  this.abortControllers.delete(abortController);
167
321
  this.reportError(error);
168
322
  });
169
323
  }
170
324
 
325
+ private releasePipelineSlot(generation: number): void {
326
+ if (generation !== this.pipelineGeneration) return;
327
+ this.pipelineDepth = Math.max(0, this.pipelineDepth - 1);
328
+ if (this.pendingTexts.length > 0) {
329
+ this.schedulePump();
330
+ return;
331
+ }
332
+ this.maybeReleaseTurn();
333
+ }
334
+
335
+ private maybeReleaseTurn(): void {
336
+ if (!this.completedTurnId || this.completedTurnId !== this.activeTurnId) return;
337
+ if (this.pendingTexts.length > 0 || this.pipelineDepth > 0 || this.pumpScheduled) return;
338
+ this.activeTurnId = null;
339
+ this.completedTurnId = null;
340
+ this.chunker = null;
341
+ this.abortControllers.clear();
342
+ }
343
+
344
+ private async synthesizeWithRetry(text: string, turnId: string, sequence: number, signal: AbortSignal): Promise<VoiceSynthesisStreamResult> {
345
+ for (let attempt = 0; ; attempt++) {
346
+ try {
347
+ return await this.synthesize(text, turnId, sequence, signal);
348
+ } catch (error) {
349
+ const retryable = attempt < SYNTHESIS_RETRY_DELAYS_MS.length
350
+ && !signal.aborted
351
+ && isTransientSynthesisError(error);
352
+ if (!retryable) throw error;
353
+ await this.delay(SYNTHESIS_RETRY_DELAYS_MS[attempt]!, signal);
354
+ }
355
+ }
356
+ }
357
+
358
+ /** Abortable backoff sleep — an abort clears the timer and rejects, so a stop mid-backoff leaves nothing running. */
359
+ private delay(ms: number, signal: AbortSignal): Promise<void> {
360
+ return new Promise((resolve, reject) => {
361
+ if (signal.aborted) {
362
+ reject(new Error('Synthesis retry cancelled'));
363
+ return;
364
+ }
365
+ const timer = this.setTimeoutImpl(() => {
366
+ signal.removeEventListener('abort', onAbort);
367
+ resolve();
368
+ }, ms);
369
+ const onAbort = () => {
370
+ this.clearTimeoutImpl(timer);
371
+ reject(new Error('Synthesis retry cancelled'));
372
+ };
373
+ signal.addEventListener('abort', onAbort, { once: true });
374
+ });
375
+ }
376
+
171
377
  private synthesize(text: string, turnId: string, sequence: number, signal: AbortSignal): Promise<VoiceSynthesisStreamResult> {
172
378
  return this.voiceService.synthesizeStream(readOptionalConfigString(this.configManager, 'tts.provider'), {
173
379
  text,
174
380
  voiceId: readOptionalConfigString(this.configManager, 'tts.voice'),
175
381
  format: 'mp3',
382
+ speed: readOptionalConfigNumber(this.configManager, 'tts.speed'),
176
383
  signal,
177
384
  metadata: {
178
385
  source: 'goodvibes-agent',
@@ -183,12 +390,23 @@ export class SpokenTurnController {
183
390
  });
184
391
  }
185
392
 
393
+ /**
394
+ * One synthesis request failed after its retries. Report once per turn and
395
+ * keep going — the rest of the response still plays.
396
+ */
397
+ private reportSkippedChunk(error: unknown): void {
398
+ if (this.errorReportedForTurn) return;
399
+ this.errorReportedForTurn = true;
400
+ this.notify?.(`[TTS] Skipping part of the spoken response — synthesis kept failing (${summarizeError(error)}). Playback continues with the rest.`);
401
+ }
402
+
186
403
  private reportError(error: unknown): void {
187
404
  if (this.errorReportedForTurn) return;
188
405
  this.errorReportedForTurn = true;
189
406
  this.activeTurnId = null;
190
407
  this.chunker = null;
191
408
  this.stopTimer();
409
+ this.resetPipeline();
192
410
  for (const controller of this.abortControllers) controller.abort();
193
411
  this.abortControllers.clear();
194
412
  this.player.stop();
@@ -197,7 +415,41 @@ export class SpokenTurnController {
197
415
  }
198
416
  }
199
417
 
418
+ /**
419
+ * Transient = worth a bounded retry: rate/concurrency limits (HTTP 429),
420
+ * transient server errors (5xx), and network-level drops. The SDK's voice
421
+ * providers throw plain Error strings with the HTTP status embedded in the
422
+ * message, so classification is by message content.
423
+ */
424
+ function isTransientSynthesisError(error: unknown): boolean {
425
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
426
+ if (message.includes('429') || message.includes('rate limit') || message.includes('rate_limit')
427
+ || message.includes('too many requests') || message.includes('concurrent')) return true;
428
+ if (/http 5\d\d/.test(message)) return true;
429
+ return message.includes('fetch failed') || message.includes('network')
430
+ || message.includes('timed out') || message.includes('timeout')
431
+ || message.includes('econnreset') || message.includes('socket');
432
+ }
433
+
434
+ /** Split point at or under `limit`, preferring the last word boundary. */
435
+ function findSplitIndex(text: string, limit: number): number {
436
+ const space = text.lastIndexOf(' ', limit);
437
+ return space > 0 ? space : limit;
438
+ }
439
+
200
440
  function readOptionalConfigString(configManager: Pick<ConfigManager, 'get'>, key: ConfigKey): string | undefined {
201
441
  const value = String(configManager.get(key) ?? '').trim();
202
442
  return value || undefined;
203
443
  }
444
+
445
+ /**
446
+ * readOptionalConfigNumber — reads a numeric config value by key.
447
+ *
448
+ * Accepts a string key and casts it, returning undefined when the value is
449
+ * absent, zero, or not a finite positive number.
450
+ */
451
+ function readOptionalConfigNumber(configManager: Pick<ConfigManager, 'get'>, key: string): number | undefined {
452
+ const raw = configManager.get(key as ConfigKey);
453
+ const value = typeof raw === 'number' ? raw : parseFloat(String(raw ?? ''));
454
+ return isFinite(value) && value > 0 ? value : undefined;
455
+ }
@@ -1,13 +1,17 @@
1
1
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
2
2
  import type { UiRuntimeEvents } from '@/runtime/index.ts';
3
3
  import type { VoiceService } from '@pellux/goodvibes-sdk/platform/voice';
4
+ import type { StreamingAudioPlayer } from './player.ts';
4
5
  import { LocalStreamingAudioPlayer } from './player.ts';
5
6
  import { SpokenTurnController } from './spoken-turn-controller.ts';
6
7
 
7
8
  export interface SpokenTurnRuntime {
8
9
  readonly unsubs: readonly (() => void)[];
9
10
  submitNextTurn(prompt: string): boolean;
10
- stop(message?: string): void;
11
+ /** Returns whether speech was actually active (see controller.stop). */
12
+ stop(message?: string): boolean;
13
+ /** Exit-path stop: lets the audio already playing drain, bounded (see controller.stopForExit). */
14
+ stopForExit(drainTimeoutMs?: number): Promise<void>;
11
15
  }
12
16
 
13
17
  export interface WireSpokenTurnRuntimeOptions {
@@ -15,13 +19,19 @@ export interface WireSpokenTurnRuntimeOptions {
15
19
  readonly configManager: ConfigManager;
16
20
  readonly events: UiRuntimeEvents;
17
21
  readonly notify: (message: string) => void;
22
+ /**
23
+ * Optional player factory — injected in tests to avoid spawning real
24
+ * subprocesses. Defaults to LocalStreamingAudioPlayer.
25
+ */
26
+ readonly playerFactory?: () => StreamingAudioPlayer;
18
27
  }
19
28
 
20
29
  export function wireSpokenTurnRuntime(options: WireSpokenTurnRuntimeOptions): SpokenTurnRuntime {
30
+ const player = options.playerFactory ? options.playerFactory() : new LocalStreamingAudioPlayer();
21
31
  const controller = new SpokenTurnController({
22
32
  voiceService: options.voiceService,
23
33
  configManager: options.configManager,
24
- player: new LocalStreamingAudioPlayer(),
34
+ player,
25
35
  notify: options.notify,
26
36
  });
27
37
 
@@ -40,5 +50,6 @@ export function wireSpokenTurnRuntime(options: WireSpokenTurnRuntimeOptions): Sp
40
50
  unsubs,
41
51
  submitNextTurn: (prompt) => controller.submitNextTurn(prompt),
42
52
  stop: (message) => controller.stop(message),
53
+ stopForExit: (drainTimeoutMs) => controller.stopForExit(drainTimeoutMs),
43
54
  };
44
55
  }