Skip to content

Commit 76d65e2

Browse files
committed
feat(speech): add FallbackSTTProxy and FallbackTTSProxy for provider chain fallback
1 parent ced86db commit 76d65e2

2 files changed

Lines changed: 536 additions & 0 deletions

File tree

src/speech/FallbackProxy.ts

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { EventEmitter } from 'events';
2+
import type {
3+
SpeechToTextProvider,
4+
TextToSpeechProvider,
5+
SpeechAudioInput,
6+
SpeechTranscriptionOptions,
7+
SpeechTranscriptionResult,
8+
SpeechSynthesisOptions,
9+
SpeechSynthesisResult,
10+
SpeechVoice,
11+
} from './types.js';
12+
13+
/**
14+
* Payload emitted on the `provider_fallback` event when a provider in the chain
15+
* fails and the proxy advances to the next candidate.
16+
*/
17+
export interface ProviderFallbackEvent {
18+
/** ID of the provider that failed. */
19+
from: string;
20+
/** ID of the provider that will be tried next. */
21+
to: string;
22+
/** Whether this is an STT or TTS chain. */
23+
kind: 'stt' | 'tts';
24+
/** The error thrown by the failing provider. */
25+
error: unknown;
26+
}
27+
28+
// ---------------------------------------------------------------------------
29+
// FallbackSTTProxy
30+
// ---------------------------------------------------------------------------
31+
32+
/**
33+
* A {@link SpeechToTextProvider} that wraps an ordered chain of STT providers.
34+
* Providers are tried left-to-right; the first successful result is returned.
35+
* On each failure (except the last) a `provider_fallback` event is emitted on
36+
* the supplied {@link EventEmitter} so callers can observe the fallback path.
37+
*
38+
* @example
39+
* ```ts
40+
* const proxy = new FallbackSTTProxy([whisperProvider, deepgramProvider], emitter);
41+
* const result = await proxy.transcribe(audio);
42+
* ```
43+
*/
44+
export class FallbackSTTProxy implements SpeechToTextProvider {
45+
/** Derived from the first provider in the chain (or `'fallback-stt'` for empty chains). */
46+
readonly id: string;
47+
48+
/** Human-readable name showing the full chain: `"Fallback STT (p1 → p2)"`. */
49+
readonly displayName: string;
50+
51+
/** `true` only when the first provider in the chain supports streaming. */
52+
readonly supportsStreaming: boolean;
53+
54+
/**
55+
* @param chain Ordered list of STT providers to try. Must contain at least one entry
56+
* for `transcribe()` to succeed, though an empty chain is allowed
57+
* (it will always throw).
58+
* @param emitter EventEmitter on which `provider_fallback` events are published.
59+
*/
60+
constructor(
61+
private readonly chain: SpeechToTextProvider[],
62+
private readonly emitter: EventEmitter,
63+
) {
64+
this.id = chain[0]?.id ?? 'fallback-stt';
65+
this.displayName = `Fallback STT (${chain.map((p) => p.id).join(' → ')})`;
66+
this.supportsStreaming = chain[0]?.supportsStreaming ?? false;
67+
}
68+
69+
/**
70+
* Attempt transcription using each provider in order.
71+
*
72+
* Emits a `provider_fallback` event (typed as {@link ProviderFallbackEvent})
73+
* whenever a non-final provider throws. Re-throws the last provider's error
74+
* when the entire chain is exhausted, and throws `Error('No providers in
75+
* fallback chain')` when `chain` is empty.
76+
*/
77+
async transcribe(
78+
audio: SpeechAudioInput,
79+
options?: SpeechTranscriptionOptions,
80+
): Promise<SpeechTranscriptionResult> {
81+
if (this.chain.length === 0) {
82+
throw new Error('No providers in fallback chain');
83+
}
84+
85+
for (let i = 0; i < this.chain.length; i++) {
86+
try {
87+
return await this.chain[i].transcribe(audio, options);
88+
} catch (error) {
89+
if (i < this.chain.length - 1) {
90+
const event: ProviderFallbackEvent = {
91+
from: this.chain[i].id,
92+
to: this.chain[i + 1].id,
93+
kind: 'stt',
94+
error,
95+
};
96+
this.emitter.emit('provider_fallback', event);
97+
} else {
98+
throw error;
99+
}
100+
}
101+
}
102+
103+
// Unreachable — TypeScript requires an explicit throw after the loop.
104+
throw new Error('No providers in fallback chain');
105+
}
106+
107+
/** Delegates to the first provider in the chain, or returns `'fallback'` for an empty chain. */
108+
getProviderName(): string {
109+
return this.chain[0]?.getProviderName() ?? 'fallback';
110+
}
111+
}
112+
113+
// ---------------------------------------------------------------------------
114+
// FallbackTTSProxy
115+
// ---------------------------------------------------------------------------
116+
117+
/**
118+
* A {@link TextToSpeechProvider} that wraps an ordered chain of TTS providers.
119+
* Providers are tried left-to-right; the first successful result is returned.
120+
* On each failure (except the last) a `provider_fallback` event is emitted on
121+
* the supplied {@link EventEmitter}.
122+
*
123+
* Voice listing is delegated to the first provider that exposes
124+
* `listAvailableVoices()`. If none do, an empty array is returned.
125+
*
126+
* @example
127+
* ```ts
128+
* const proxy = new FallbackTTSProxy([elevenlabsProvider, openaiTtsProvider], emitter);
129+
* const audio = await proxy.synthesize('Hello world');
130+
* ```
131+
*/
132+
export class FallbackTTSProxy implements TextToSpeechProvider {
133+
/** Derived from the first provider in the chain (or `'fallback-tts'` for empty chains). */
134+
readonly id: string;
135+
136+
/** Human-readable name showing the full chain: `"Fallback TTS (p1 → p2)"`. */
137+
readonly displayName: string;
138+
139+
/** `true` only when the first provider in the chain supports streaming. */
140+
readonly supportsStreaming: boolean;
141+
142+
/**
143+
* @param chain Ordered list of TTS providers to try.
144+
* @param emitter EventEmitter on which `provider_fallback` events are published.
145+
*/
146+
constructor(
147+
private readonly chain: TextToSpeechProvider[],
148+
private readonly emitter: EventEmitter,
149+
) {
150+
this.id = chain[0]?.id ?? 'fallback-tts';
151+
this.displayName = `Fallback TTS (${chain.map((p) => p.id).join(' → ')})`;
152+
this.supportsStreaming = chain[0]?.supportsStreaming ?? false;
153+
}
154+
155+
/**
156+
* Attempt synthesis using each provider in order.
157+
*
158+
* Emits a `provider_fallback` event (typed as {@link ProviderFallbackEvent})
159+
* whenever a non-final provider throws. Re-throws the last provider's error
160+
* when the entire chain is exhausted, and throws `Error('No providers in
161+
* fallback chain')` when `chain` is empty.
162+
*/
163+
async synthesize(
164+
text: string,
165+
options?: SpeechSynthesisOptions,
166+
): Promise<SpeechSynthesisResult> {
167+
if (this.chain.length === 0) {
168+
throw new Error('No providers in fallback chain');
169+
}
170+
171+
for (let i = 0; i < this.chain.length; i++) {
172+
try {
173+
return await this.chain[i].synthesize(text, options);
174+
} catch (error) {
175+
if (i < this.chain.length - 1) {
176+
const event: ProviderFallbackEvent = {
177+
from: this.chain[i].id,
178+
to: this.chain[i + 1].id,
179+
kind: 'tts',
180+
error,
181+
};
182+
this.emitter.emit('provider_fallback', event);
183+
} else {
184+
throw error;
185+
}
186+
}
187+
}
188+
189+
// Unreachable — TypeScript requires an explicit throw after the loop.
190+
throw new Error('No providers in fallback chain');
191+
}
192+
193+
/** Delegates to the first provider in the chain, or returns `'fallback'` for an empty chain. */
194+
getProviderName(): string {
195+
return this.chain[0]?.getProviderName() ?? 'fallback';
196+
}
197+
198+
/**
199+
* Returns voice list from the first provider in the chain that exposes
200+
* `listAvailableVoices()`. Falls back to an empty array when no provider
201+
* supports this method.
202+
*/
203+
async listAvailableVoices(): Promise<SpeechVoice[]> {
204+
for (const provider of this.chain) {
205+
if (typeof provider.listAvailableVoices === 'function') {
206+
return provider.listAvailableVoices();
207+
}
208+
}
209+
return [];
210+
}
211+
}

0 commit comments

Comments
 (0)