@startsimpli/llm 0.4.29 → 0.4.31
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 +76 -5
- package/bin/startsimpli-speech.js +22 -0
- package/package.json +10 -3
- package/src/__tests__/fixtures/captions-golden.json +1881 -0
- package/src/__tests__/fixtures/eleven-v3-tagged-alignment.json +1 -0
- package/src/__tests__/fixtures/voices-golden.json +614 -0
- package/src/__tests__/refine.test.ts +193 -0
- package/src/__tests__/speech-captions-cli.test.ts +37 -0
- package/src/__tests__/speech-captions.test.ts +136 -0
- package/src/__tests__/speech-cli.test.ts +67 -0
- package/src/__tests__/speech-synthesize.test.ts +277 -0
- package/src/__tests__/speech-voices-cli.test.ts +45 -0
- package/src/__tests__/speech-voices.test.ts +109 -0
- package/src/index.ts +3 -0
- package/src/refine/errors.ts +23 -0
- package/src/refine/gates.ts +55 -0
- package/src/refine/index.ts +11 -0
- package/src/refine/llm-step.ts +35 -0
- package/src/refine/refine.ts +81 -0
- package/src/refine/types.ts +54 -0
- package/src/speech/alignment.ts +48 -0
- package/src/speech/bin.ts +12 -0
- package/src/speech/captions/cues.ts +108 -0
- package/src/speech/captions/formats.ts +33 -0
- package/src/speech/captions/index.ts +6 -0
- package/src/speech/captions/types.ts +18 -0
- package/src/speech/captions/words.ts +91 -0
- package/src/speech/captions-cli.ts +50 -0
- package/src/speech/cli.ts +140 -0
- package/src/speech/http.ts +11 -4
- package/src/speech/index.ts +8 -2
- package/src/speech/interface.ts +7 -5
- package/src/speech/providers/elevenlabs-models.ts +34 -0
- package/src/speech/providers/elevenlabs-tts.ts +88 -0
- package/src/speech/providers/elevenlabs.ts +23 -5
- package/src/speech/providers/mock.ts +23 -3
- package/src/speech/providers/openai.ts +34 -3
- package/src/speech/repr.ts +14 -0
- package/src/speech/service.ts +25 -14
- package/src/speech/types.ts +55 -1
- package/src/speech/voices/cast-voices.json +127 -0
- package/src/speech/voices/cast-voices.ts +169 -0
- package/src/speech/voices/emotion.ts +51 -0
- package/src/speech/voices/errors.ts +14 -0
- package/src/speech/voices/index.ts +12 -0
- package/src/speech/voices/schema.ts +53 -0
- package/src/speech/voices/tags.ts +11 -0
- package/src/speech/voices-cli.ts +43 -0
package/src/speech/interface.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Selectable } from '../providers/registry';
|
|
2
|
-
import type { TranscribeRequest, TranscriptResult } from './types';
|
|
2
|
+
import type { SynthesizeRequest, SynthesizeResult, TranscribeRequest, TranscriptResult } from './types';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* What a speech vendor implements. A provider supports whichever directions its
|
|
@@ -8,12 +8,11 @@ import type { TranscribeRequest, TranscriptResult } from './types';
|
|
|
8
8
|
*
|
|
9
9
|
* ADDING A VENDOR: implement this, then
|
|
10
10
|
* `registerSpeechProvider('name', () => new Mine(process.env.MINE_KEY) )` — no
|
|
11
|
-
* edit to `SpeechService`.
|
|
12
|
-
* `
|
|
13
|
-
* service needs to change for it either.
|
|
11
|
+
* edit to `SpeechService`. Each direction is an optional method gated by
|
|
12
|
+
* `capabilities`.
|
|
14
13
|
*/
|
|
15
14
|
export interface ISpeechProvider extends Selectable {
|
|
16
|
-
readonly capabilities: { transcribe: boolean };
|
|
15
|
+
readonly capabilities: { transcribe: boolean; synthesize?: boolean };
|
|
17
16
|
readonly defaultModel: string;
|
|
18
17
|
/**
|
|
19
18
|
* List price per audio minute, USD, for `model` (default model when omitted).
|
|
@@ -21,5 +20,8 @@ export interface ISpeechProvider extends Selectable {
|
|
|
21
20
|
* that does not state a price is tried after every provider that does.
|
|
22
21
|
*/
|
|
23
22
|
costPerMinuteUsd?(model?: string): number;
|
|
23
|
+
/** List price per 1,000 synthesized characters, USD. `synthesize` prefers the cheapest. */
|
|
24
|
+
costPer1kCharsUsd?(model?: string): number;
|
|
24
25
|
transcribe?(request: TranscribeRequest): Promise<TranscriptResult>;
|
|
26
|
+
synthesize?(request: SynthesizeRequest): Promise<SynthesizeResult>;
|
|
25
27
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What ElevenLabs' models can do and cost. Data, not logic: a new model is a
|
|
3
|
+
* row here. USD per 1,000 characters, elevenlabs.io/pricing/api.
|
|
4
|
+
*/
|
|
5
|
+
const RATE_PER_1K_CHARS: Record<string, number> = {
|
|
6
|
+
eleven_v3: 0.08,
|
|
7
|
+
eleven_multilingual_v2: 0.08,
|
|
8
|
+
eleven_flash_v2_5: 0.04,
|
|
9
|
+
eleven_v4_turbo: 0.04,
|
|
10
|
+
};
|
|
11
|
+
/** Rate for a model not in the table: the top published tier, so spend is never under-reported. */
|
|
12
|
+
const DEFAULT_RATE_PER_1K_CHARS = 0.10;
|
|
13
|
+
|
|
14
|
+
/** Models that read `[excited]`-style audio tags; the rest would speak them aloud. */
|
|
15
|
+
const AUDIO_TAG_MODELS = new Set(['eleven_v3', 'eleven_v4', 'eleven_v4_turbo', 'eleven_v3_conversational']);
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_TTS_MODEL = 'eleven_multilingual_v2';
|
|
18
|
+
|
|
19
|
+
export function supportsAudioTags(model: string): boolean {
|
|
20
|
+
return AUDIO_TAG_MODELS.has(model);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function costUsd(model: string, characters: number): number {
|
|
24
|
+
return ((RATE_PER_1K_CHARS[model] ?? DEFAULT_RATE_PER_1K_CHARS) * characters) / 1000;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_OUTPUT_FORMAT = 'mp3_44100_128';
|
|
28
|
+
|
|
29
|
+
/** Output formats are `<codec>_<rate>[_<bitrate>]`. */
|
|
30
|
+
export function mimeForFormat(format: string): string {
|
|
31
|
+
const codec = format.split('_')[0];
|
|
32
|
+
return ({ mp3: 'audio/mpeg', pcm: 'audio/pcm', ulaw: 'audio/basic', alaw: 'audio/alaw', opus: 'audio/ogg' } as Record<string, string>)[codec]
|
|
33
|
+
?? 'application/octet-stream';
|
|
34
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ElevenLabs text-to-speech with timestamps. Split from the provider so the
|
|
3
|
+
* request/response mapping reads on its own.
|
|
4
|
+
* https://elevenlabs.io/docs/api-reference/text-to-speech/convert-with-timestamps
|
|
5
|
+
*/
|
|
6
|
+
import { wordsFromCharacters } from '../alignment';
|
|
7
|
+
import { LLMProviderError } from '../../errors';
|
|
8
|
+
import { assertText, speechFetch } from '../http';
|
|
9
|
+
import type { AlignedCharacter, SynthesizeRequest, SynthesizeResult } from '../types';
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_OUTPUT_FORMAT, DEFAULT_TTS_MODEL, costUsd, mimeForFormat, supportsAudioTags,
|
|
12
|
+
} from './elevenlabs-models';
|
|
13
|
+
|
|
14
|
+
interface Alignment {
|
|
15
|
+
characters?: string[];
|
|
16
|
+
character_start_times_seconds?: number[];
|
|
17
|
+
character_end_times_seconds?: number[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TtsContext {
|
|
21
|
+
provider: string;
|
|
22
|
+
apiKey: string;
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
defaultVoiceId?: string;
|
|
25
|
+
fetchImpl?: typeof fetch;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Text as sent: style tags go in front, but only on models that read them. */
|
|
29
|
+
export function spokenText(request: SynthesizeRequest, model: string): string {
|
|
30
|
+
const tags = supportsAudioTags(model) ? (request.style?.tags ?? []) : [];
|
|
31
|
+
return tags.map((t) => `[${t.replace(/^\[|\]$/g, '')}]`).join('') + (tags.length ? ' ' : '') + request.text;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function voiceSettings(request: SynthesizeRequest): Record<string, number | boolean> | undefined {
|
|
35
|
+
const { speed, stability } = request.style ?? {};
|
|
36
|
+
const merged = {
|
|
37
|
+
...(speed !== undefined ? { speed } : {}),
|
|
38
|
+
...(stability !== undefined ? { stability } : {}),
|
|
39
|
+
...request.voiceSettings,
|
|
40
|
+
};
|
|
41
|
+
return Object.keys(merged).length ? merged : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function elevenLabsSynthesize(ctx: TtsContext, request: SynthesizeRequest): Promise<SynthesizeResult> {
|
|
45
|
+
assertText(request, ctx.provider);
|
|
46
|
+
const voiceId = request.voiceId ?? ctx.defaultVoiceId;
|
|
47
|
+
if (!voiceId) {
|
|
48
|
+
throw new LLMProviderError('No voice: pass voiceId or set ELEVENLABS_VOICE_ID.', 'INVALID_REQUEST', false, undefined, ctx.provider);
|
|
49
|
+
}
|
|
50
|
+
const model = request.model ?? DEFAULT_TTS_MODEL;
|
|
51
|
+
const format = request.outputFormat ?? DEFAULT_OUTPUT_FORMAT;
|
|
52
|
+
const text = spokenText(request, model);
|
|
53
|
+
const settings = voiceSettings(request);
|
|
54
|
+
|
|
55
|
+
const response = await speechFetch(
|
|
56
|
+
ctx.provider,
|
|
57
|
+
`${ctx.baseUrl}/v1/text-to-speech/${encodeURIComponent(voiceId)}/with-timestamps?output_format=${encodeURIComponent(format)}`,
|
|
58
|
+
{
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'xi-api-key': ctx.apiKey, 'content-type': 'application/json' },
|
|
61
|
+
body: JSON.stringify({
|
|
62
|
+
text, model_id: model,
|
|
63
|
+
...(request.language ? { language_code: request.language } : {}),
|
|
64
|
+
...(settings ? { voice_settings: settings } : {}),
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
67
|
+
request.timeoutMs, ctx.fetchImpl, 'synthesis',
|
|
68
|
+
);
|
|
69
|
+
const json = (await response.json()) as { audio_base64?: string; alignment?: Alignment | null };
|
|
70
|
+
const audio = new Uint8Array(Buffer.from(json.audio_base64 ?? '', 'base64'));
|
|
71
|
+
const characters = alignedCharacters(json.alignment);
|
|
72
|
+
const words = characters ? wordsFromCharacters(characters, { keepTags: request.keepTags }) : undefined;
|
|
73
|
+
return {
|
|
74
|
+
audio, mimeType: mimeForFormat(format),
|
|
75
|
+
...(characters ? { characters, durationSec: characters[characters.length - 1].end } : {}),
|
|
76
|
+
...(words ? { words } : {}),
|
|
77
|
+
voiceId, model, provider: ctx.provider,
|
|
78
|
+
usage: { characters: text.length, costUsd: costUsd(model, text.length) },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function alignedCharacters(a?: Alignment | null): AlignedCharacter[] | undefined {
|
|
83
|
+
const chars = a?.characters;
|
|
84
|
+
const starts = a?.character_start_times_seconds;
|
|
85
|
+
const ends = a?.character_end_times_seconds;
|
|
86
|
+
if (!chars?.length || !starts || !ends) return undefined;
|
|
87
|
+
return chars.map((text, i) => ({ text, start: starts[i], end: ends[i] }));
|
|
88
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ElevenLabs Scribe speech-to-text. https://elevenlabs.io/docs/api-reference/speech-to-text
|
|
3
|
-
* Env: ELEVENLABS_API_KEY.
|
|
2
|
+
* ElevenLabs Scribe speech-to-text and text-to-speech. https://elevenlabs.io/docs/api-reference/speech-to-text
|
|
3
|
+
* Env: ELEVENLABS_API_KEY; ELEVENLABS_VOICE_ID is the default voice.
|
|
4
4
|
*/
|
|
5
5
|
import type { ISpeechProvider } from '../interface';
|
|
6
6
|
import { assertAudio, audioBlob, speechFetch } from '../http';
|
|
7
|
-
import type { TranscribeRequest, TranscriptResult, TranscriptWord } from '../types';
|
|
7
|
+
import type { SynthesizeRequest, SynthesizeResult, TranscribeRequest, TranscriptResult, TranscriptWord } from '../types';
|
|
8
|
+
import { elevenLabsSynthesize } from './elevenlabs-tts';
|
|
9
|
+
import { DEFAULT_TTS_MODEL, costUsd } from './elevenlabs-models';
|
|
8
10
|
|
|
9
11
|
export interface ElevenLabsOptions {
|
|
10
12
|
/**
|
|
@@ -14,6 +16,8 @@ export interface ElevenLabsOptions {
|
|
|
14
16
|
*/
|
|
15
17
|
sendKeyterms?: boolean;
|
|
16
18
|
apiKey?: string;
|
|
19
|
+
/** Voice used when a call names none. */
|
|
20
|
+
voiceId?: string;
|
|
17
21
|
baseUrl?: string;
|
|
18
22
|
fetchImpl?: typeof fetch;
|
|
19
23
|
}
|
|
@@ -22,10 +26,11 @@ interface ScribeWord { text: string; start?: number; end?: number; type?: string
|
|
|
22
26
|
|
|
23
27
|
export class ElevenLabsSpeechProvider implements ISpeechProvider {
|
|
24
28
|
readonly name = 'elevenlabs';
|
|
25
|
-
readonly capabilities = { transcribe: true };
|
|
29
|
+
readonly capabilities = { transcribe: true, synthesize: true };
|
|
26
30
|
readonly defaultModel = 'scribe_v1';
|
|
27
31
|
private readonly apiKey: string;
|
|
28
32
|
private readonly baseUrl: string;
|
|
33
|
+
private readonly voiceId?: string;
|
|
29
34
|
private readonly fetchImpl?: typeof fetch;
|
|
30
35
|
private readonly sendKeyterms: boolean;
|
|
31
36
|
|
|
@@ -33,6 +38,7 @@ export class ElevenLabsSpeechProvider implements ISpeechProvider {
|
|
|
33
38
|
this.sendKeyterms = options.sendKeyterms ?? false;
|
|
34
39
|
this.apiKey = options.apiKey ?? '';
|
|
35
40
|
this.baseUrl = (options.baseUrl ?? 'https://api.elevenlabs.io').replace(/\/+$/, '');
|
|
41
|
+
this.voiceId = options.voiceId;
|
|
36
42
|
this.fetchImpl = options.fetchImpl;
|
|
37
43
|
}
|
|
38
44
|
|
|
@@ -45,6 +51,18 @@ export class ElevenLabsSpeechProvider implements ISpeechProvider {
|
|
|
45
51
|
return 0.22 / 60;
|
|
46
52
|
}
|
|
47
53
|
|
|
54
|
+
/** Cheapest TTS tier per 1,000 characters (eleven_flash_v2_5). */
|
|
55
|
+
costPer1kCharsUsd(model?: string): number {
|
|
56
|
+
return costUsd(model ?? DEFAULT_TTS_MODEL, 1000);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
synthesize(request: SynthesizeRequest): Promise<SynthesizeResult> {
|
|
60
|
+
return elevenLabsSynthesize(
|
|
61
|
+
{ provider: this.name, apiKey: this.apiKey, baseUrl: this.baseUrl, defaultVoiceId: this.voiceId, fetchImpl: this.fetchImpl },
|
|
62
|
+
request,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
48
66
|
async transcribe(request: TranscribeRequest): Promise<TranscriptResult> {
|
|
49
67
|
assertAudio(request, this.name);
|
|
50
68
|
const model = request.model ?? this.defaultModel;
|
|
@@ -81,5 +99,5 @@ export class ElevenLabsSpeechProvider implements ISpeechProvider {
|
|
|
81
99
|
export function createElevenLabsSpeechProvider(
|
|
82
100
|
env: Record<string, string | undefined> = typeof process !== 'undefined' ? process.env : {},
|
|
83
101
|
): ElevenLabsSpeechProvider | null {
|
|
84
|
-
return env.ELEVENLABS_API_KEY ? new ElevenLabsSpeechProvider({ apiKey: env.ELEVENLABS_API_KEY }) : null;
|
|
102
|
+
return env.ELEVENLABS_API_KEY ? new ElevenLabsSpeechProvider({ apiKey: env.ELEVENLABS_API_KEY, voiceId: env.ELEVENLABS_VOICE_ID || undefined }) : null;
|
|
85
103
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { LLMProviderError } from '../../errors';
|
|
2
2
|
import type { ISpeechProvider } from '../interface';
|
|
3
|
-
import
|
|
3
|
+
import { wordsFromCharacters } from '../alignment';
|
|
4
|
+
import { assertText } from '../http';
|
|
5
|
+
import type { SynthesizeRequest, SynthesizeResult, TranscribeRequest, TranscriptResult } from '../types';
|
|
4
6
|
|
|
5
7
|
export interface MockSpeechBehavior {
|
|
6
8
|
text?: string;
|
|
@@ -24,15 +26,33 @@ export function resetMockSpeechBehavior() {
|
|
|
24
26
|
/** Deterministic transcription for tests — selected with `SPEECH_PROVIDER=mock`. */
|
|
25
27
|
export class MockSpeechProvider implements ISpeechProvider {
|
|
26
28
|
readonly name = 'mock';
|
|
27
|
-
readonly capabilities = { transcribe: true };
|
|
29
|
+
readonly capabilities = { transcribe: true, synthesize: true };
|
|
28
30
|
readonly defaultModel = 'mock-transcribe';
|
|
29
31
|
isAvailable() { return true; }
|
|
30
|
-
|
|
32
|
+
private maybeFail() {
|
|
31
33
|
if (behavior.authError) throw new LLMProviderError('mock auth failure', 'AUTHENTICATION_ERROR', false, 401, this.name);
|
|
32
34
|
if (remainingFailures > 0) {
|
|
33
35
|
remainingFailures -= 1;
|
|
34
36
|
throw new LLMProviderError('mock outage', 'SERVICE_UNAVAILABLE', true, 503, this.name);
|
|
35
37
|
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Fake audio (one byte per character) with 50ms-per-character alignment. */
|
|
41
|
+
async synthesize(request: SynthesizeRequest): Promise<SynthesizeResult> {
|
|
42
|
+
assertText(request, this.name);
|
|
43
|
+
this.maybeFail();
|
|
44
|
+
const characters = [...request.text].map((text, i) => ({ text, start: Math.round(i * 50) / 1000, end: Math.round((i + 1) * 50) / 1000 }));
|
|
45
|
+
return {
|
|
46
|
+
audio: new Uint8Array(characters.length).fill(1), mimeType: 'audio/mpeg',
|
|
47
|
+
characters, words: wordsFromCharacters(characters, { keepTags: request.keepTags }),
|
|
48
|
+
durationSec: characters[characters.length - 1].end,
|
|
49
|
+
voiceId: request.voiceId ?? 'mock-voice', model: 'mock-synthesize', provider: this.name,
|
|
50
|
+
usage: { characters: request.text.length, costUsd: 0 },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async transcribe(request: TranscribeRequest): Promise<TranscriptResult> {
|
|
55
|
+
this.maybeFail();
|
|
36
56
|
return {
|
|
37
57
|
text: behavior.text ?? `mock transcript of ${request.audio.byteLength} bytes`,
|
|
38
58
|
model: this.defaultModel, provider: this.name, usage: { audioSeconds: 1 },
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* URL, default model, word-timing model and price table.
|
|
10
10
|
*/
|
|
11
11
|
import type { ISpeechProvider } from '../interface';
|
|
12
|
-
import { assertAudio, audioBlob, speechFetch } from '../http';
|
|
13
|
-
import type { TranscribeRequest, TranscriptResult, TranscriptWord } from '../types';
|
|
12
|
+
import { assertAudio, assertText, audioBlob, speechFetch } from '../http';
|
|
13
|
+
import type { SynthesizeRequest, SynthesizeResult, TranscribeRequest, TranscriptResult, TranscriptWord } from '../types';
|
|
14
14
|
|
|
15
15
|
export interface OpenAISpeechOptions {
|
|
16
16
|
apiKey?: string;
|
|
@@ -34,7 +34,7 @@ const OPENAI_PRICES: Record<string, number> = {
|
|
|
34
34
|
|
|
35
35
|
export class OpenAISpeechProvider implements ISpeechProvider {
|
|
36
36
|
readonly name: string;
|
|
37
|
-
readonly capabilities = { transcribe: true };
|
|
37
|
+
readonly capabilities = { transcribe: true, synthesize: true };
|
|
38
38
|
readonly defaultModel: string;
|
|
39
39
|
private readonly apiKey: string;
|
|
40
40
|
private readonly baseUrl: string;
|
|
@@ -62,6 +62,37 @@ export class OpenAISpeechProvider implements ISpeechProvider {
|
|
|
62
62
|
return this.prices[model ?? this.defaultModel] ?? this.fallbackPrice;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** TTS list price per 1,000 characters (tts-1 $15/M; gpt-4o-mini-tts bills about the same per spoken minute). */
|
|
66
|
+
costPer1kCharsUsd(): number {
|
|
67
|
+
return 0.015;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `/v1/audio/speech`. OpenAI returns bare audio: no alignment, so no word timings. */
|
|
71
|
+
async synthesize(request: SynthesizeRequest): Promise<SynthesizeResult> {
|
|
72
|
+
assertText(request, this.name);
|
|
73
|
+
const model = request.model ?? 'gpt-4o-mini-tts';
|
|
74
|
+
const voice = request.voiceId ?? 'alloy';
|
|
75
|
+
const format = request.outputFormat ?? 'mp3';
|
|
76
|
+
const response = await speechFetch(
|
|
77
|
+
this.name, `${this.baseUrl}/v1/audio/speech`,
|
|
78
|
+
{
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { authorization: `Bearer ${this.apiKey}`, 'content-type': 'application/json' },
|
|
81
|
+
body: JSON.stringify({
|
|
82
|
+
model, voice, input: request.text, response_format: format,
|
|
83
|
+
...(request.style?.speed !== undefined ? { speed: request.style.speed } : {}),
|
|
84
|
+
}),
|
|
85
|
+
},
|
|
86
|
+
request.timeoutMs, this.fetchImpl, 'synthesis',
|
|
87
|
+
);
|
|
88
|
+
const mimeType = ({ mp3: 'audio/mpeg', wav: 'audio/wav', opus: 'audio/ogg', aac: 'audio/aac', flac: 'audio/flac', pcm: 'audio/pcm' } as Record<string, string>)[format] ?? 'application/octet-stream';
|
|
89
|
+
return {
|
|
90
|
+
audio: new Uint8Array(await response.arrayBuffer()), mimeType,
|
|
91
|
+
voiceId: voice, model, provider: this.name,
|
|
92
|
+
usage: { characters: request.text.length, costUsd: (request.text.length * this.costPer1kCharsUsd()) / 1000 },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
65
96
|
async transcribe(request: TranscribeRequest): Promise<TranscriptResult> {
|
|
66
97
|
assertAudio(request, this.name);
|
|
67
98
|
const wantWords = request.timestamps === 'word';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Python-style repr of a string, null or string list, so messages read the same as the Python code's (shared goldens compare messages). */
|
|
2
|
+
export function repr(value: unknown): string {
|
|
3
|
+
if (value === null || value === undefined) return 'None';
|
|
4
|
+
if (Array.isArray(value)) return `[${value.map(repr).join(', ')}]`;
|
|
5
|
+
const s = String(value);
|
|
6
|
+
const quote = s.includes("'") && !s.includes('"') ? '"' : "'";
|
|
7
|
+
const body = s.replace(/\\/g, '\\\\').replace(new RegExp(quote, 'g'), `\\${quote}`).replace(/\n/g, '\\n');
|
|
8
|
+
return `${quote}${body}${quote}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Python `str(float)` for a finite number: integers keep their `.0`. */
|
|
12
|
+
export function pyFloat(n: number): string {
|
|
13
|
+
return Number.isInteger(n) ? `${n}.0` : String(n);
|
|
14
|
+
}
|
package/src/speech/service.ts
CHANGED
|
@@ -4,7 +4,11 @@ import type { ISpeechProvider } from './interface';
|
|
|
4
4
|
import { createElevenLabsSpeechProvider } from './providers/elevenlabs';
|
|
5
5
|
import { MockSpeechProvider } from './providers/mock';
|
|
6
6
|
import { createOpenAISpeechProvider } from './providers/openai';
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
SpeechServiceConfig, SynthesizeRequest, SynthesizeResult, TranscribeRequest, TranscriptResult,
|
|
9
|
+
} from './types';
|
|
10
|
+
|
|
11
|
+
type Direction = 'transcribe' | 'synthesize';
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* The speech registry. Built-ins register here; a new vendor is
|
|
@@ -48,14 +52,15 @@ export class SpeechService {
|
|
|
48
52
|
return this.getAvailableProviders().length > 0;
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
getAvailableProviders(): string[] {
|
|
52
|
-
return [...this.providers].filter(([, p]) => p.isAvailable() && p.capabilities
|
|
55
|
+
getAvailableProviders(direction: Direction = 'transcribe'): string[] {
|
|
56
|
+
return [...this.providers].filter(([, p]) => p.isAvailable() && p.capabilities[direction]).map(([n]) => n);
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
/** Providers in the order they will be tried: the preferred one (if any), then cheapest first. */
|
|
56
|
-
private order(): ISpeechProvider[] {
|
|
57
|
-
const usable = [...this.providers.values()].filter((p) => p.isAvailable() && p.capabilities
|
|
58
|
-
const price = (p: ISpeechProvider) =>
|
|
60
|
+
private order(direction: Direction): ISpeechProvider[] {
|
|
61
|
+
const usable = [...this.providers.values()].filter((p) => p.isAvailable() && p.capabilities[direction]);
|
|
62
|
+
const price = (p: ISpeechProvider) =>
|
|
63
|
+
(direction === 'synthesize' ? p.costPer1kCharsUsd?.() : p.costPerMinuteUsd?.()) ?? Number.POSITIVE_INFINITY;
|
|
59
64
|
// Array.sort is stable, so equal/unknown prices keep registration order.
|
|
60
65
|
const byPrice = [...usable].sort((a, b) => price(a) - price(b));
|
|
61
66
|
const wanted = this.config.preferredProvider
|
|
@@ -71,18 +76,24 @@ export class SpeechService {
|
|
|
71
76
|
// (an English note came back in French once). A per-call `language` wins.
|
|
72
77
|
const env: Record<string, string | undefined> = typeof process !== 'undefined' ? process.env : {};
|
|
73
78
|
const request = input.language || !env.SPEECH_LANGUAGE ? input : { ...input, language: env.SPEECH_LANGUAGE };
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
return this.run('transcribe', 'No speech providers available. Set OPENAI_API_KEY or ELEVENLABS_API_KEY.',
|
|
80
|
+
(provider) => provider.transcribe!(request));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Text to speech, with the same retry, backoff and fallback as `transcribe`. */
|
|
84
|
+
async synthesize(request: SynthesizeRequest): Promise<SynthesizeResult> {
|
|
85
|
+
return this.run('synthesize', 'No text-to-speech provider available. Set OPENAI_API_KEY or ELEVENLABS_API_KEY.',
|
|
86
|
+
(provider) => provider.synthesize!(request));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async run<T>(direction: Direction, none: string, call: (provider: ISpeechProvider) => Promise<T>): Promise<T> {
|
|
90
|
+
const order = this.order(direction);
|
|
91
|
+
if (!order.length) throw new LLMProviderError(none, 'AUTHENTICATION_ERROR', false);
|
|
81
92
|
let last: unknown;
|
|
82
93
|
for (const provider of order) {
|
|
83
94
|
for (let attempt = 0; ; attempt += 1) {
|
|
84
95
|
try {
|
|
85
|
-
return await provider
|
|
96
|
+
return await call(provider);
|
|
86
97
|
} catch (error) {
|
|
87
98
|
last = error;
|
|
88
99
|
const typed = error instanceof LLMProviderError ? error : null;
|
package/src/speech/types.ts
CHANGED
|
@@ -14,8 +14,10 @@ export interface TranscriptWord {
|
|
|
14
14
|
export interface SpeechUsage {
|
|
15
15
|
/** Billable input, when the provider reports it; else derived from word timings. */
|
|
16
16
|
audioSeconds?: number;
|
|
17
|
-
/** Characters synthesized (TTS). */
|
|
17
|
+
/** Characters synthesized (TTS), as billed: tags included. */
|
|
18
18
|
characters?: number;
|
|
19
|
+
/** Cost at the vendor's list rate, when the provider knows it. */
|
|
20
|
+
costUsd?: number;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export interface TranscribeRequest {
|
|
@@ -47,6 +49,58 @@ export interface TranscriptResult {
|
|
|
47
49
|
usage: SpeechUsage;
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
/** One aligned unit (a character) with its time span, in seconds from the start of the audio. */
|
|
53
|
+
export interface AlignedCharacter {
|
|
54
|
+
text: string;
|
|
55
|
+
start: number;
|
|
56
|
+
end: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Per-call delivery. Providers apply what their model supports and ignore the
|
|
61
|
+
* rest — a caller never needs to know which model reads which knob.
|
|
62
|
+
*/
|
|
63
|
+
export interface SpeechStyle {
|
|
64
|
+
/** Audio tags (`excited`, `whispers`), written as `[tag]` ahead of the text on models that read them. */
|
|
65
|
+
tags?: string[];
|
|
66
|
+
speed?: number;
|
|
67
|
+
/** Lower is more expressive. */
|
|
68
|
+
stability?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface SynthesizeRequest {
|
|
72
|
+
text: string;
|
|
73
|
+
/** Provider voice id. Omit to use the provider's configured default (ELEVENLABS_VOICE_ID). */
|
|
74
|
+
voiceId?: string;
|
|
75
|
+
/** Provider-specific model override. */
|
|
76
|
+
model?: string;
|
|
77
|
+
/** ISO-639 hint. */
|
|
78
|
+
language?: string;
|
|
79
|
+
/** Vendor output format (`mp3_44100_128`, `pcm_22050`). Default: mp3. */
|
|
80
|
+
outputFormat?: string;
|
|
81
|
+
style?: SpeechStyle;
|
|
82
|
+
/** Raw vendor voice settings; wins over `style` where both set a key. */
|
|
83
|
+
voiceSettings?: Record<string, number | boolean>;
|
|
84
|
+
/** Keep `[tag]` markup in the returned words. Default false: tags are delivery, not speech. */
|
|
85
|
+
keepTags?: boolean;
|
|
86
|
+
timeoutMs?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SynthesizeResult {
|
|
90
|
+
audio: Uint8Array;
|
|
91
|
+
mimeType: string;
|
|
92
|
+
/** Per-character timings, exactly as the vendor aligned the text it was sent. Absent when the vendor gives none. */
|
|
93
|
+
characters?: AlignedCharacter[];
|
|
94
|
+
/** Word timings derived from `characters`; audio tags removed unless `keepTags`. */
|
|
95
|
+
words?: TranscriptWord[];
|
|
96
|
+
/** End of the last aligned character; absent without alignment. */
|
|
97
|
+
durationSec?: number;
|
|
98
|
+
voiceId?: string;
|
|
99
|
+
model: string;
|
|
100
|
+
provider: string;
|
|
101
|
+
usage: SpeechUsage;
|
|
102
|
+
}
|
|
103
|
+
|
|
50
104
|
export interface SpeechServiceConfig {
|
|
51
105
|
/** Provider tried first. Default: `SPEECH_PROVIDER`, else the cheapest vendor with a key. */
|
|
52
106
|
preferredProvider?: string;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"about": "Cast voice config: a library of ElevenLabs voices keyed by neutral voice id (never by avatar: a show maps its avatars to these, or to a raw voice id, in its cast), with model and settings, and how timeline emotions steer them. Data only: no keys. Edit this file (or point voice.cast_voices at another) to change a voice, a model or a mapping; nothing here is read from code.",
|
|
4
|
+
"default_model": "eleven_v3",
|
|
5
|
+
"models": {
|
|
6
|
+
"eleven_v3": {"tags": true, "usd_per_1k_chars": 0.08, "price_source": "elevenlabs.io/pricing/api 2026-11-02"},
|
|
7
|
+
"eleven_v4": {"tags": true, "usd_per_1k_chars": 0.08, "price_source": "assumed equal to v3: v4 is not on the pricing page"},
|
|
8
|
+
"eleven_v4_turbo": {"tags": true, "usd_per_1k_chars": 0.04, "price_source": "elevenlabs.io/pricing/api 2026-11-02"},
|
|
9
|
+
"eleven_v3_conversational": {"tags": true, "usd_per_1k_chars": 0.08, "price_source": "assumed equal to v3"},
|
|
10
|
+
"eleven_multilingual_v2": {"tags": false, "usd_per_1k_chars": 0.08, "price_source": "elevenlabs.io/pricing/api 2026-11-02"},
|
|
11
|
+
"eleven_flash_v2_5": {"tags": false, "usd_per_1k_chars": 0.04, "price_source": "elevenlabs.io/pricing/api 2026-11-02"}
|
|
12
|
+
},
|
|
13
|
+
"tag_pattern": "\\[[^\\[\\]]{1,40}\\]",
|
|
14
|
+
"voice_id_pattern": "^[A-Za-z0-9]{20}$",
|
|
15
|
+
"settings_limits": {
|
|
16
|
+
"stability": [0.0, 1.0], "similarity_boost": [0.0, 1.0], "style": [0.0, 1.0], "speed": [0.7, 1.2]
|
|
17
|
+
},
|
|
18
|
+
"emotion_mode_default": "tags",
|
|
19
|
+
"emotion_map": {
|
|
20
|
+
"about": "tag: audio tag prepended to the spoken text on tag-capable models (never shown in captions). settings_delta: added to the role's settings on models that take no tags (or on all models when tag_models_use_delta is true). An emotion not listed here falls back to neutral.",
|
|
21
|
+
"tag_models_use_delta": false,
|
|
22
|
+
"emotions": {
|
|
23
|
+
"neutral": {"tag": null, "settings_delta": {}},
|
|
24
|
+
"excited": {"tag": "[excited]", "settings_delta": {"stability": -0.1, "style": 0.2, "speed": 0.04}},
|
|
25
|
+
"skeptical": {"tag": "[skeptical]", "settings_delta": {"style": 0.1, "speed": -0.03}},
|
|
26
|
+
"explaining": {"tag": "[calm]", "settings_delta": {"stability": 0.05, "speed": -0.02}},
|
|
27
|
+
"thinking": {"tag": "[thoughtful]", "settings_delta": {"stability": 0.05, "speed": -0.06}}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"languages_about": "Per language: the model the library voices speak it with, the Rhubarb recognizer for the lip-sync cues, and optional voice ids per NEUTRAL role id (roles.<role>.voice_id, never an avatar); a role with no row keeps its own voice on the language's model. The default language is the unchanged path. A language not listed here fails loudly. Models and languages: elevenlabs.io/docs (eleven_multilingual_v2 speaks these). The shared speech CLI takes no language field on synthesize: the model infers it from the text. Rhubarb's default recognizer is English-only; `phonetic` follows the sounds of any language.",
|
|
31
|
+
"languages": {
|
|
32
|
+
"en": {"default": true},
|
|
33
|
+
"es": {"model": "eleven_multilingual_v2", "rhubarb_recognizer": "phonetic", "roles": {}},
|
|
34
|
+
"fr": {"model": "eleven_multilingual_v2", "rhubarb_recognizer": "phonetic", "roles": {}},
|
|
35
|
+
"de": {"model": "eleven_multilingual_v2", "rhubarb_recognizer": "phonetic", "roles": {}},
|
|
36
|
+
"it": {"model": "eleven_multilingual_v2", "rhubarb_recognizer": "phonetic", "roles": {}},
|
|
37
|
+
"pt": {"model": "eleven_multilingual_v2", "rhubarb_recognizer": "phonetic", "roles": {}},
|
|
38
|
+
"ja": {"model": "eleven_multilingual_v2", "rhubarb_recognizer": "phonetic", "roles": {}}
|
|
39
|
+
},
|
|
40
|
+
"default_pool": ["presenter", "engineer", "manager", "ai_assistant", "customer"],
|
|
41
|
+
"roles": {
|
|
42
|
+
"presenter": {
|
|
43
|
+
"name": "Mira - Bright & Friendly Narrator", "voice_id": "rdEyNni8e60Jlx1RsV9N", "kind": "library",
|
|
44
|
+
"model": "eleven_v3",
|
|
45
|
+
"settings": {
|
|
46
|
+
"eleven_v3": {"stability": 0.35},
|
|
47
|
+
"eleven_multilingual_v2": {"stability": 0.35, "similarity_boost": 0.75, "style": 0.5, "speed": 1.0}
|
|
48
|
+
},
|
|
49
|
+
"stand_in_for": "anim/out/voices 01/02 (Jessica, a Default voice that dies 2026-12-31): same bright upbeat host"
|
|
50
|
+
},
|
|
51
|
+
"engineer": {
|
|
52
|
+
"name": "Baxter - Dry Calm Aussie", "voice_id": "jSuBIjxMKhqIfb0wCK1F", "kind": "professional",
|
|
53
|
+
"model": "eleven_v3",
|
|
54
|
+
"settings": {
|
|
55
|
+
"eleven_v3": {"stability": 0.5},
|
|
56
|
+
"eleven_multilingual_v2": {"stability": 0.5, "similarity_boost": 0.8, "style": 0.2, "speed": 1.0}
|
|
57
|
+
},
|
|
58
|
+
"stand_in_for": "anim/out/voices 03 (Charlie, a Default voice): casual dry Aussie engineer"
|
|
59
|
+
},
|
|
60
|
+
"manager": {
|
|
61
|
+
"name": "Wyatt - Seasoned Mentor", "voice_id": "FrS6cKLB1wg4WYgPa9GW", "kind": "professional",
|
|
62
|
+
"model": "eleven_v3",
|
|
63
|
+
"settings": {
|
|
64
|
+
"eleven_v3": {"stability": 0.5},
|
|
65
|
+
"eleven_multilingual_v2": {"stability": 0.6, "similarity_boost": 0.8, "style": 0.3, "speed": 0.92}
|
|
66
|
+
},
|
|
67
|
+
"stand_in_for": "anim/out/voices 04/05 (Bill, George: Default voices): older, slow, grey-hair-and-tie"
|
|
68
|
+
},
|
|
69
|
+
"ai_assistant": {
|
|
70
|
+
"name": "CI - Narrator (neutral)", "voice_id": "xB7U4iivDiPcO06WZ47T", "kind": "library",
|
|
71
|
+
"model": "eleven_v3",
|
|
72
|
+
"settings": {
|
|
73
|
+
"eleven_v3": {"stability": 1.0},
|
|
74
|
+
"eleven_multilingual_v2": {"stability": 1.0, "similarity_boost": 0.9, "style": 0.0, "speed": 1.1}
|
|
75
|
+
},
|
|
76
|
+
"stand_in_for": "anim/out/voices 06/07 (River, Daniel: Default voices): flat even machine tone",
|
|
77
|
+
"emotion_map": {"about": "the assistant stays even: no emotion steering", "emotions": {}}
|
|
78
|
+
},
|
|
79
|
+
"customer": {
|
|
80
|
+
"name": "Callum - Husky Trickster (library copy)", "voice_id": "5OSluWlAJntR1KG2pWaB", "kind": "professional",
|
|
81
|
+
"model": "eleven_v3",
|
|
82
|
+
"settings": {
|
|
83
|
+
"eleven_v3": {"stability": 0.0},
|
|
84
|
+
"eleven_multilingual_v2": {"stability": 0.35, "similarity_boost": 0.75, "style": 0.4, "speed": 1.0}
|
|
85
|
+
},
|
|
86
|
+
"stand_in_for": "anim/out/voices 08/09 (Callum, the Default-voice copy): husky trickster, most character of the set"
|
|
87
|
+
},
|
|
88
|
+
"clone_1": {
|
|
89
|
+
"name": "Instant voice clone 1", "voice_id": "WBpAa2ue6cyOek99incw", "kind": "clone",
|
|
90
|
+
"model": "eleven_v3",
|
|
91
|
+
"settings": {
|
|
92
|
+
"eleven_v3": {"stability": 0.5},
|
|
93
|
+
"eleven_multilingual_v2": {"stability": 0.5, "similarity_boost": 0.85, "style": 0.2, "speed": 1.0}
|
|
94
|
+
},
|
|
95
|
+
"stand_in_for": "an instant clone of the 2:27 host recording (bspike-448.15.59); a show or cast member selects it with voice: clone_1"
|
|
96
|
+
},
|
|
97
|
+
"santa": {
|
|
98
|
+
"name": "Mr. Claus - The Classic Jolly Santa", "voice_id": "uDsPstFWFBUXjIBimV7s", "kind": "library",
|
|
99
|
+
"model": "eleven_v3",
|
|
100
|
+
"settings": {
|
|
101
|
+
"eleven_v3": {"stability": 0.4},
|
|
102
|
+
"eleven_multilingual_v2": {"stability": 0.4, "similarity_boost": 0.8, "style": 0.5, "speed": 0.95}
|
|
103
|
+
},
|
|
104
|
+
"stand_in_for": "Santa Claus in the holiday video: a deep, warm, playful stock voice with a hearty laugh (ElevenLabs shared library; the shared-voices search for 'santa', picked 2026-11-04)"
|
|
105
|
+
},
|
|
106
|
+
"open_slot_1": {
|
|
107
|
+
"voice_id": null, "kind": "unfilled", "model": "eleven_v3", "settings": {},
|
|
108
|
+
"fill": "INPUT bspike-448.15.22.10: pick or design this voice (the sidekick's). Set voice_id and settings.<model> here."
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"retired_voice_ids": {
|
|
112
|
+
"21m00Tcm4TlvDq8ikWAM": "Rachel: dead on the v1 pool (402)",
|
|
113
|
+
"MF3mGyEYCl7XYWbV9V6O": "Elli: dead on the v1 pool (402)",
|
|
114
|
+
"TxGEqnHWrfWW9XjX5Xl0": "Josh: dead on the v1 pool (404)",
|
|
115
|
+
"ErXwobaYiN019PkySvjV": "Antoni: v1 pool, legacy voice"
|
|
116
|
+
},
|
|
117
|
+
"expiring_deadline": "2026-12-31",
|
|
118
|
+
"default_voice_ids_expiring_2026_12_31": {
|
|
119
|
+
"CwhRBWXzGAHq8TQ4Fs17": "Roger", "EXAVITQu4vr4xnSDxMaL": "Sarah", "FGY2WhTYpPnrIDTdsKH5": "Laura",
|
|
120
|
+
"IKne3meq5aSn9XLyUdCD": "Charlie", "JBFqnCBsd6RMkjVDRZzb": "George", "N2lVS1w4EtoT3dr4eOWO": "Callum (Default copy)",
|
|
121
|
+
"SAz9YHcvj6GT2YYXdXww": "River", "SOYHLrjzK2X1ezoPC6cr": "Harry", "TX3LPaxmHKxFdv7VOQHJ": "Liam",
|
|
122
|
+
"Xb7hH8MSUJpSbSDYk0k2": "Alice", "XrExE9yKIg1WjnnlVkGX": "Matilda", "bIHbv24MWmeRgasZH58o": "Will",
|
|
123
|
+
"cgSgspJ2msm6clMCkdW9": "Jessica", "cjVigY5qzO86Huf0OWal": "Eric", "hpp4J3VqNfWAUOO0d1Us": "Bella",
|
|
124
|
+
"iP95p4xoKVk53GoZ742B": "Chris", "nPczCjzI2devNBz1zQrb": "Brian", "onwK4e9ZLuTAKqWW03F9": "Daniel",
|
|
125
|
+
"pFZP5JQG7iQjIQuC4Bku": "Lily", "pNInz6obpgDQGcFmaJgB": "Adam", "pqHfZKP75CvOlQylNhV4": "Bill"
|
|
126
|
+
}
|
|
127
|
+
}
|