@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.
Files changed (48) hide show
  1. package/README.md +76 -5
  2. package/bin/startsimpli-speech.js +22 -0
  3. package/package.json +10 -3
  4. package/src/__tests__/fixtures/captions-golden.json +1881 -0
  5. package/src/__tests__/fixtures/eleven-v3-tagged-alignment.json +1 -0
  6. package/src/__tests__/fixtures/voices-golden.json +614 -0
  7. package/src/__tests__/refine.test.ts +193 -0
  8. package/src/__tests__/speech-captions-cli.test.ts +37 -0
  9. package/src/__tests__/speech-captions.test.ts +136 -0
  10. package/src/__tests__/speech-cli.test.ts +67 -0
  11. package/src/__tests__/speech-synthesize.test.ts +277 -0
  12. package/src/__tests__/speech-voices-cli.test.ts +45 -0
  13. package/src/__tests__/speech-voices.test.ts +109 -0
  14. package/src/index.ts +3 -0
  15. package/src/refine/errors.ts +23 -0
  16. package/src/refine/gates.ts +55 -0
  17. package/src/refine/index.ts +11 -0
  18. package/src/refine/llm-step.ts +35 -0
  19. package/src/refine/refine.ts +81 -0
  20. package/src/refine/types.ts +54 -0
  21. package/src/speech/alignment.ts +48 -0
  22. package/src/speech/bin.ts +12 -0
  23. package/src/speech/captions/cues.ts +108 -0
  24. package/src/speech/captions/formats.ts +33 -0
  25. package/src/speech/captions/index.ts +6 -0
  26. package/src/speech/captions/types.ts +18 -0
  27. package/src/speech/captions/words.ts +91 -0
  28. package/src/speech/captions-cli.ts +50 -0
  29. package/src/speech/cli.ts +140 -0
  30. package/src/speech/http.ts +11 -4
  31. package/src/speech/index.ts +8 -2
  32. package/src/speech/interface.ts +7 -5
  33. package/src/speech/providers/elevenlabs-models.ts +34 -0
  34. package/src/speech/providers/elevenlabs-tts.ts +88 -0
  35. package/src/speech/providers/elevenlabs.ts +23 -5
  36. package/src/speech/providers/mock.ts +23 -3
  37. package/src/speech/providers/openai.ts +34 -3
  38. package/src/speech/repr.ts +14 -0
  39. package/src/speech/service.ts +25 -14
  40. package/src/speech/types.ts +55 -1
  41. package/src/speech/voices/cast-voices.json +127 -0
  42. package/src/speech/voices/cast-voices.ts +169 -0
  43. package/src/speech/voices/emotion.ts +51 -0
  44. package/src/speech/voices/errors.ts +14 -0
  45. package/src/speech/voices/index.ts +12 -0
  46. package/src/speech/voices/schema.ts +53 -0
  47. package/src/speech/voices/tags.ts +11 -0
  48. package/src/speech/voices-cli.ts +43 -0
@@ -0,0 +1,169 @@
1
+ import library from './cast-voices.json';
2
+ import {
3
+ DuplicateVoiceError, ExpiringVoiceError, RetiredVoiceError, UnfilledVoiceSlotError, UnknownLanguageError,
4
+ UnknownModelError, VoiceNotResolvableError,
5
+ } from './errors';
6
+ import { repr } from '../repr';
7
+ import { validateCastLibrary, type CastLibrary } from './schema';
8
+
9
+ /** The shipped library, validated once at import. */
10
+ export const defaultCastLibrary: CastLibrary = validateCastLibrary(library);
11
+
12
+ export type ExpiringPolicy = 'warn' | 'allow' | 'error';
13
+
14
+ export interface CastMember {
15
+ id: string;
16
+ /** A library id, or a real voice id. Anything else (a saved timeline's cast id, e.g. "maya") is an alias and ignored. */
17
+ voice?: string | null;
18
+ [extra: string]: unknown;
19
+ }
20
+
21
+ export interface VoiceSpec {
22
+ voiceId: string;
23
+ model: string;
24
+ settings: Record<string, number>;
25
+ /** The library entry the voice came from; null for a passed-through voice id. */
26
+ role: string | null;
27
+ /** The language row the voice was resolved for; null = the default language. */
28
+ language: string | null;
29
+ }
30
+
31
+ export interface CastVoicesOptions {
32
+ /** warn: warning until the deadline, then an error; allow: never complain; error: always an error. Default warn. */
33
+ expiring?: ExpiringPolicy;
34
+ /** The clock: `YYYY-MM-DD` or a Date. Default: now. */
35
+ today?: string | Date;
36
+ /** Receives each expiring-voice warning (once per voice). Default `console.warn`. */
37
+ onWarn?: (message: string) => void;
38
+ }
39
+
40
+ export interface ResolveOptions {
41
+ /** Overrides the entry's model (settings come from that model's block). */
42
+ model?: string | null;
43
+ language?: string | null;
44
+ /** The member's place among members without a voice of their own; `resolveCast` counts it. */
45
+ poolSlot?: number | null;
46
+ }
47
+
48
+ const isoDay = (d: string | Date) => (typeof d === 'string' ? d : d.toISOString()).slice(0, 10);
49
+ const dayNumber = (iso: string) => Math.floor(Date.parse(`${iso}T00:00:00Z`) / 86_400_000);
50
+ const has = (o: object | undefined, k: string | null | undefined): k is string => !!k && !!o && Object.prototype.hasOwnProperty.call(o, k);
51
+ const get = (o: Record<string, any> | undefined, k: string | null | undefined) => (has(o, k) ? o![k] : undefined);
52
+ const truthy = <T>(...xs: T[]) => xs.find((x) => (x && (typeof x !== 'object' || Object.keys(x as object).length > 0)) as boolean);
53
+
54
+ export class CastVoices {
55
+ readonly warnings: string[] = [];
56
+ readonly deadline: string;
57
+ readonly today: string;
58
+ private readonly idPattern: RegExp;
59
+
60
+ constructor(readonly data: CastLibrary, private readonly options: CastVoicesOptions = {}) {
61
+ const policy = options.expiring ?? 'warn';
62
+ if (!['warn', 'allow', 'error'].includes(policy)) throw new Error(`expiring must be warn, allow or error, got ${repr(policy)}`);
63
+ this.policy = policy;
64
+ this.deadline = data.expiring_deadline ?? '2026-12-31';
65
+ this.today = isoDay(options.today ?? new Date());
66
+ this.idPattern = new RegExp(`^(?:${data.voice_id_pattern})$`);
67
+ }
68
+ private readonly policy: ExpiringPolicy;
69
+
70
+ get roles() { return this.data.roles; }
71
+
72
+ modelInfo(model: string) {
73
+ if (!has(this.data.models, model)) throw new UnknownModelError(`unknown model ${repr(model)}; known: ${repr(Object.keys(this.data.models).sort())}`);
74
+ return this.data.models[model];
75
+ }
76
+
77
+ /** True when the member names a library entry or a real voice id itself (not an alias, not nothing). */
78
+ hasVoiceRef(member: CastMember): boolean {
79
+ const v = member.voice;
80
+ return !!v && (has(this.roles, v) || this.idPattern.test(v));
81
+ }
82
+
83
+ /** [key, row] of the languages table for a tag (`es-MX` falls back to `es`); [null, null] for none or the default language. */
84
+ languageEntry(language?: string | null): [string | null, NonNullable<CastLibrary['languages']>[string] | null] {
85
+ if (!language) return [null, null];
86
+ const table = this.data.languages ?? {};
87
+ for (const key of [language, language.split('-')[0]]) {
88
+ if (has(table, key)) return table[key].default ? [null, null] : [key, table[key]];
89
+ }
90
+ throw new UnknownLanguageError(`language ${repr(language)} is not in the cast voice file; known: ${repr(Object.keys(table).sort())}`);
91
+ }
92
+
93
+ resolve(member: CastMember, opts: ResolveOptions = {}): VoiceSpec {
94
+ const voice = member.voice ?? null;
95
+ let role: string | null = has(this.roles, voice) ? voice : null;
96
+ const explicit = voice && role === null && this.idPattern.test(voice) ? voice : null;
97
+ const pool = this.data.default_pool ?? [];
98
+ if (role === null && explicit === null) {
99
+ if (opts.poolSlot == null || pool.length === 0) {
100
+ throw new VoiceNotResolvableError(
101
+ `cast member ${repr(member.id)}: no voice reference (${repr(voice)}) and no pool place; library: ${repr(Object.keys(this.roles).sort())}`);
102
+ }
103
+ role = pool[opts.poolSlot % pool.length];
104
+ }
105
+ const entry: Record<string, any> = role ? this.entry(role) : { model: this.data.default_model, settings: {} };
106
+ const [lang, row] = this.languageEntry(opts.language);
107
+ const own: Record<string, any> = (role && get(row?.roles, role)) || {};
108
+ const voiceId: string = explicit ?? own.voice_id ?? entry.voice_id;
109
+ this.checkAlive(voiceId);
110
+ const model: string = opts.model || own.model || row?.model || entry.model || this.data.default_model;
111
+ this.modelInfo(model);
112
+ const settings = { ...(truthy(get(own.settings, model), get(entry.settings, model)) ?? {}) } as Record<string, number>;
113
+ if (Object.keys(settings).length === 0 && !explicit) {
114
+ throw new VoiceNotResolvableError(`role ${repr(role)} has no settings for model ${repr(model)}`);
115
+ }
116
+ return { voiceId, model, settings, role, language: lang };
117
+ }
118
+
119
+ /** `{cast id: VoiceSpec}`; two members on one voice id throw (they would sound like one person). */
120
+ resolveCast(cast: CastMember[], opts: Omit<ResolveOptions, 'poolSlot'> = {}): Record<string, VoiceSpec> {
121
+ const out: Record<string, VoiceSpec> = {};
122
+ const seen = new Map<string, string>();
123
+ let slot = 0;
124
+ for (const member of cast) {
125
+ const ref = this.hasVoiceRef(member);
126
+ const spec = this.resolve(member, { ...opts, poolSlot: ref ? null : slot });
127
+ if (!ref) slot += 1;
128
+ if (seen.has(spec.voiceId)) {
129
+ throw new DuplicateVoiceError(`cast members ${repr(seen.get(spec.voiceId))} and ${repr(member.id)} resolve to the same voice ${spec.voiceId}`);
130
+ }
131
+ seen.set(spec.voiceId, member.id);
132
+ out[member.id] = spec;
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /** The emotion mapping entry for a spec; a role's own emotion_map replaces the global one; unknown -> neutral. */
138
+ emotionEntry(spec: VoiceSpec, emotion: string): { tag?: string | null; settings_delta?: Record<string, number> } {
139
+ const own = get(this.roles, spec.role)?.emotion_map;
140
+ const table = (own ?? this.data.emotion_map).emotions;
141
+ return get(table, emotion) ?? table.neutral ?? { tag: null, settings_delta: {} };
142
+ }
143
+
144
+ private entry(role: string) {
145
+ const entry = this.roles[role];
146
+ if (!entry.voice_id) {
147
+ throw new UnfilledVoiceSlotError(`voice slot ${repr(role)} is empty: ${entry.fill ?? 'fill voice_id in the cast voice config'}`);
148
+ }
149
+ return entry;
150
+ }
151
+
152
+ private checkAlive(voiceId: string): void {
153
+ const retired = get(this.data.retired_voice_ids, voiceId);
154
+ if (retired !== undefined) throw new RetiredVoiceError(`voice ${voiceId} is retired: ${retired}`);
155
+ const name = get(this.data.default_voice_ids_expiring_2026_12_31, voiceId);
156
+ if (name === undefined) return;
157
+ const what = `voice ${voiceId} (${name}) is a Default voice that expires ${this.deadline}`;
158
+ if (this.policy === 'error' || (this.policy === 'warn' && this.today >= this.deadline)) throw new ExpiringVoiceError(what);
159
+ if (this.policy === 'warn' && !this.warnings.includes(what)) {
160
+ this.warnings.push(what);
161
+ const days = dayNumber(this.deadline) - dayNumber(this.today);
162
+ (this.options.onWarn ?? ((m) => console.warn(m)))(`${what} (${days} days left): pick a replacement or a clone before then`);
163
+ }
164
+ }
165
+ }
166
+
167
+ export function createCastVoices(data: CastLibrary = defaultCastLibrary, options: CastVoicesOptions = {}): CastVoices {
168
+ return new CastVoices(data, options);
169
+ }
@@ -0,0 +1,51 @@
1
+ import type { CastVoices, VoiceSpec } from './cast-voices';
2
+ import { stripTags } from './tags';
3
+
4
+ export type EmotionMode = 'tags' | 'cli' | 'off';
5
+ const MODES: EmotionMode[] = ['tags', 'cli', 'off'];
6
+
7
+ export interface Direction {
8
+ /** What the vendor speaks (may start with a tag). */
9
+ textSent: string;
10
+ /** `textSent` without tags: for captions, cues and word counts. */
11
+ spokenText: string;
12
+ settings: Record<string, number>;
13
+ model: string;
14
+ /** Mode `cli`: the speech CLI applies the emotion itself. */
15
+ cliEmotion: string | null;
16
+ }
17
+
18
+ const neutral = (key: string) => (key === 'speed' ? 1.0 : 0.5);
19
+ const round4 = (n: number) => Math.round(n * 1e4) / 1e4;
20
+
21
+ function clamped(cv: CastVoices, base: Record<string, number>, delta: Record<string, number>) {
22
+ const out = { ...base };
23
+ for (const [k, d] of Object.entries(delta)) {
24
+ const [lo, hi] = cv.data.settings_limits[k];
25
+ out[k] = round4(Math.min(hi, Math.max(lo, (out[k] ?? neutral(k)) + d)));
26
+ }
27
+ return out;
28
+ }
29
+
30
+ /**
31
+ * Timeline emotion -> voice direction. Tag models get an audio tag in front of the text; other models get a settings
32
+ * delta (clamped to the library's limits). The tag is sent to the vendor and billed, and never belongs in captions.
33
+ */
34
+ export function directEmotion(cv: CastVoices, spec: VoiceSpec, text: string, emotion: string | null | undefined, mode?: EmotionMode): Direction {
35
+ const m = mode ?? cv.data.emotion_mode_default ?? 'tags';
36
+ if (!MODES.includes(m)) throw new Error(`emotion mode ${JSON.stringify(m)} not in ${MODES.join(', ')}`);
37
+ const spokenText = stripTags(text, cv.data.tag_pattern);
38
+ const plain: Direction = { textSent: text, spokenText, settings: { ...spec.settings }, model: spec.model, cliEmotion: null };
39
+ if (m === 'off' || emotion == null) return plain;
40
+ const entry = cv.emotionEntry(spec, emotion);
41
+ const delta = entry.settings_delta ?? {};
42
+ if (m === 'cli') {
43
+ return { ...plain, cliEmotion: entry.tag || Object.keys(delta).length ? emotion : null };
44
+ }
45
+ const hasTags = cv.modelInfo(spec.model).tags;
46
+ let textSent = text;
47
+ let settings = { ...spec.settings };
48
+ if (hasTags && entry.tag && text === spokenText) textSent = `${entry.tag} ${text}`;
49
+ if ((!hasTags || cv.data.emotion_map.tag_models_use_delta) && Object.keys(delta).length) settings = clamped(cv, settings, delta);
50
+ return { textSent, spokenText, settings, model: spec.model, cliEmotion: null };
51
+ }
@@ -0,0 +1,14 @@
1
+ import { repr } from '../repr';
2
+
3
+ /** Named failures of voice resolution. All extend `VoiceNotResolvableError`, so one catch covers them. */
4
+ export class VoiceNotResolvableError extends Error {
5
+ /** Machine-readable kind, stable across the CLI and the Python side. */
6
+ readonly kind: string = 'not_resolvable';
7
+ constructor(message: string) { super(message); this.name = new.target.name; }
8
+ }
9
+ export class UnfilledVoiceSlotError extends VoiceNotResolvableError { readonly kind = 'unfilled'; }
10
+ export class RetiredVoiceError extends VoiceNotResolvableError { readonly kind = 'retired'; }
11
+ export class ExpiringVoiceError extends VoiceNotResolvableError { readonly kind = 'expiring'; }
12
+ export class UnknownModelError extends VoiceNotResolvableError { readonly kind = 'unknown_model'; }
13
+ export class UnknownLanguageError extends VoiceNotResolvableError { readonly kind = 'unknown_language'; }
14
+ export class DuplicateVoiceError extends VoiceNotResolvableError { readonly kind = 'duplicate_voice'; }
@@ -0,0 +1,12 @@
1
+ /** Voice cast library: neutral voice ids, voice-id resolution, expiring/retired Default-voice awareness, emotion direction. */
2
+ export { CastVoices, createCastVoices, defaultCastLibrary } from './cast-voices';
3
+ export type { CastMember, CastVoicesOptions, ExpiringPolicy, ResolveOptions, VoiceSpec } from './cast-voices';
4
+ export { castLibrarySchema, validateCastLibrary } from './schema';
5
+ export type { CastLibrary } from './schema';
6
+ export { directEmotion } from './emotion';
7
+ export type { Direction, EmotionMode } from './emotion';
8
+ export { stripTags } from './tags';
9
+ export {
10
+ DuplicateVoiceError, ExpiringVoiceError, RetiredVoiceError, UnfilledVoiceSlotError, UnknownLanguageError,
11
+ UnknownModelError, VoiceNotResolvableError,
12
+ } from './errors';
@@ -0,0 +1,53 @@
1
+ import { z } from 'zod';
2
+
3
+ const settings = z.record(z.string(), z.number());
4
+ const perModel = z.record(z.string(), settings);
5
+
6
+ const emotionEntry = z.object({ tag: z.string().nullable().optional(), settings_delta: z.record(z.string(), z.number()).optional() });
7
+ const emotionTable = z.object({ about: z.string().optional(), emotions: z.record(z.string(), emotionEntry) });
8
+
9
+ const role = z.object({
10
+ name: z.string().optional(),
11
+ voice_id: z.string().nullable(),
12
+ kind: z.string().optional(),
13
+ model: z.string().optional(),
14
+ settings: perModel.optional(),
15
+ fill: z.string().optional(),
16
+ stand_in_for: z.string().optional(),
17
+ emotion_map: emotionTable.optional(),
18
+ });
19
+
20
+ const languageRow = z.object({
21
+ default: z.boolean().optional(),
22
+ model: z.string().optional(),
23
+ rhubarb_recognizer: z.string().optional(),
24
+ roles: z.record(z.string(), z.object({ voice_id: z.string().optional(), model: z.string().optional(), settings: perModel.optional() })).optional(),
25
+ });
26
+
27
+ /**
28
+ * The cast voice library: voices keyed by NEUTRAL id (never by avatar), models, emotion mapping, languages, and the
29
+ * retired / expiring ElevenLabs voice ids. Data only; no keys. Snake_case keys are the file format, shared with Python.
30
+ */
31
+ export const castLibrarySchema = z.object({
32
+ version: z.number().optional(),
33
+ about: z.string().optional(),
34
+ default_model: z.string(),
35
+ models: z.record(z.string(), z.object({ tags: z.boolean(), usd_per_1k_chars: z.number().optional(), price_source: z.string().optional() })),
36
+ tag_pattern: z.string(),
37
+ voice_id_pattern: z.string(),
38
+ settings_limits: z.record(z.string(), z.tuple([z.number(), z.number()])),
39
+ emotion_mode_default: z.enum(['tags', 'cli', 'off']).optional(),
40
+ emotion_map: emotionTable.extend({ tag_models_use_delta: z.boolean().optional() }),
41
+ languages: z.record(z.string(), languageRow).optional(),
42
+ default_pool: z.array(z.string()).optional(),
43
+ roles: z.record(z.string(), role),
44
+ retired_voice_ids: z.record(z.string(), z.string()),
45
+ expiring_deadline: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
46
+ default_voice_ids_expiring_2026_12_31: z.record(z.string(), z.string()),
47
+ }).passthrough();
48
+
49
+ export type CastLibrary = z.infer<typeof castLibrarySchema>;
50
+
51
+ export function validateCastLibrary(data: unknown): CastLibrary {
52
+ return castLibrarySchema.parse(data);
53
+ }
@@ -0,0 +1,11 @@
1
+ import library from './cast-voices.json';
2
+
3
+ /** Audio tags (`[excited]`, `[sighs]`) the tag-capable models speak. The pattern is library data. */
4
+ export function tagPattern(tagPatternSource: string = library.tag_pattern): RegExp {
5
+ return new RegExp(tagPatternSource, 'g');
6
+ }
7
+
8
+ /** Text without any [tag], whitespace collapsed to single spaces. Idempotent. */
9
+ export function stripTags(text: string, tagPatternSource?: string): string {
10
+ return text.replace(tagPattern(tagPatternSource), ' ').split(/\s+/).filter(Boolean).join(' ');
11
+ }
@@ -0,0 +1,43 @@
1
+ import { readFile } from 'node:fs/promises';
2
+
3
+ import { VoiceNotResolvableError } from './voices/errors';
4
+ import { createCastVoices, defaultCastLibrary, type CastMember, type ExpiringPolicy } from './voices';
5
+ import { validateCastLibrary } from './voices/schema';
6
+
7
+ /** Outcome shape shared with `cli.ts`. Kept here so the voices command stays out of that file. */
8
+ export interface VoicesOutcome { exitCode: 0 | 1 | 2; output: unknown }
9
+
10
+ class VoicesUsageError extends Error {}
11
+
12
+ /**
13
+ * `startsimpli-speech voices resolve` — cast members in, voice specs out (snake_case, the Python resolver's shape):
14
+ * {"cast":[{"id":"maya","voice":"presenter"}], "model"?, "language"?, "expiring"?, "today"?, "library"?}
15
+ * Flags --library FILE, --model, --language, --expiring warn|allow|error, --today YYYY-MM-DD override the request.
16
+ * Output `{voices: {id: {voice_id, model, settings, role, language}}, warnings: []}`; a resolution failure is
17
+ * `{error: {kind, message}, warnings}` with exit 1.
18
+ */
19
+ export async function runVoices(sub: string | undefined, body: Record<string, any>, flags: Record<string, string>): Promise<VoicesOutcome> {
20
+ try {
21
+ if (sub !== 'resolve') throw new VoicesUsageError('usage: startsimpli-speech voices resolve [--in request.json] [--library FILE] [--model ID] [--language CODE] [--expiring warn|allow|error] [--today YYYY-MM-DD]');
22
+ if (!Array.isArray(body.cast)) throw new VoicesUsageError('voices resolve needs "cast": [{"id", "voice"?}] in the request');
23
+ const expiring = (flags.expiring ?? body.expiring ?? 'warn') as ExpiringPolicy;
24
+ if (!['warn', 'allow', 'error'].includes(expiring)) throw new VoicesUsageError(`--expiring must be warn, allow or error, got "${expiring}"`);
25
+ const today = flags.today ?? body.today;
26
+ if (today !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(today)) throw new VoicesUsageError('--today must be YYYY-MM-DD');
27
+ const raw = flags.library ? JSON.parse(await readFile(flags.library, 'utf8')) : body.library;
28
+ const library = raw ? validateCastLibrary(raw) : defaultCastLibrary;
29
+ const cv = createCastVoices(library, { expiring, today, onWarn: (m) => process.stderr.write(`${m}\n`) });
30
+ try {
31
+ const specs = cv.resolveCast(body.cast as CastMember[], { model: flags.model ?? body.model, language: flags.language ?? body.language });
32
+ const voices = Object.fromEntries(Object.entries(specs).map(([id, s]) => [id, {
33
+ voice_id: s.voiceId, model: s.model, settings: s.settings, role: s.role, language: s.language,
34
+ }]));
35
+ return { exitCode: 0, output: { voices, warnings: cv.warnings } };
36
+ } catch (error) {
37
+ if (!(error instanceof VoiceNotResolvableError)) throw error;
38
+ return { exitCode: 1, output: { error: { code: 'VOICE_NOT_RESOLVABLE', kind: error.kind, message: error.message }, warnings: cv.warnings } };
39
+ }
40
+ } catch (error) {
41
+ return { exitCode: 2, output: { error: { code: 'USAGE', message: (error as Error).message } } };
42
+ }
43
+ }