@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,193 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import type { ILLMProvider } from '../providers/interface';
4
+ import type { GenerateOptions, LLMResponse } from '../types';
5
+ import {
6
+ InvalidTriesError, RefineStepError, UnknownGateError, createGateRegistry, llmRefineStep, refineWithGates,
7
+ } from '../refine';
8
+ import type { RefineStep, StepInput } from '../refine';
9
+
10
+ type Doc = { title: string; screens: number };
11
+
12
+ /** Gates in the shape the anim loop has: a schema-ish one and a policy one. */
13
+ const registry = () => {
14
+ const gates = createGateRegistry<Doc, { kind: string }>();
15
+ gates.register('title', (d) => (d.title.length > 3 ? ['title is too long'] : []));
16
+ gates.register('screens', (d) => (d.screens > 0 ? [`a skit expects no screens, found ${d.screens}`] : []));
17
+ return gates;
18
+ };
19
+
20
+ /** A step that answers call i with replies[i] (the last repeats) and records what it was handed. */
21
+ const sequenced = (...replies: Array<Doc | Error | { result: Doc | null; errors?: string[]; costUsd?: number }>) => {
22
+ const calls: StepInput<Doc>[] = [];
23
+ const step: RefineStep<Doc> = async (input) => {
24
+ calls.push(structuredClone(input));
25
+ const r = replies[Math.min(calls.length - 1, replies.length - 1)];
26
+ if (r instanceof Error) throw r;
27
+ return 'title' in r ? { result: r, costUsd: 0.01, usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 }, model: 'm' } : r;
28
+ };
29
+ return { step, calls };
30
+ };
31
+ const good: Doc = { title: 'Ok', screens: 0 };
32
+ const withScreen: Doc = { title: 'Ok', screens: 2 };
33
+ const run = (step: RefineStep<Doc>, tries: number | null, extra: Record<string, unknown> = {}) =>
34
+ refineWithGates<Doc, { kind: string }>({ step, gates: registry(), tries, context: { kind: 'skit' }, ...extra });
35
+
36
+ describe('refineWithGates: the loop', () => {
37
+ it('a result that passes every gate costs exactly one step call', async () => {
38
+ const { step, calls } = sequenced(good);
39
+ const out = await run(step, 3);
40
+ expect(out.passed).toBe(true);
41
+ expect(calls).toHaveLength(1);
42
+ expect(out.attempts).toHaveLength(1);
43
+ expect(out.attempts[0]).toMatchObject({ n: 1, report: [], result: good, passed: true });
44
+ });
45
+
46
+ it('the step receives the previous result and the gates failures as data', async () => {
47
+ const { step, calls } = sequenced(withScreen, good);
48
+ const out = await run(step, 3);
49
+ expect(out.passed).toBe(true);
50
+ expect(calls[0]).toMatchObject({ n: 1, previous: null, failures: [] });
51
+ expect(calls[1].previous).toEqual(withScreen);
52
+ expect(calls[1].failures).toEqual([{ gate: 'screens', failures: ['a skit expects no screens, found 2'] }]);
53
+ expect(out.attempts[0].passed).toBe(false);
54
+ });
55
+
56
+ it('tries=N makes N calls at most and returns every attempt', async () => {
57
+ const { step, calls } = sequenced(withScreen);
58
+ const out = await run(step, 3);
59
+ expect(out.passed).toBe(false);
60
+ expect(calls).toHaveLength(3);
61
+ expect(out.attempts.map((a) => a.n)).toEqual([1, 2, 3]);
62
+ expect(out.result).toEqual(withScreen);
63
+ expect((await run(sequenced(withScreen, good).step, 1)).passed).toBe(false);
64
+ });
65
+
66
+ it('tries=null loops until every gate passes, with no hidden cap', async () => {
67
+ const { step, calls } = sequenced(...Array(25).fill(withScreen), good);
68
+ const out = await run(step, null);
69
+ expect(out.passed).toBe(true);
70
+ expect(calls).toHaveLength(26);
71
+ });
72
+
73
+ it('a bad tries value is refused, not clamped, before any call', async () => {
74
+ const { step, calls } = sequenced(good);
75
+ for (const bad of [0, -1, 1.5, true as any, 'x' as any, undefined as any]) await expect(run(step, bad)).rejects.toBeInstanceOf(InvalidTriesError);
76
+ expect(calls).toHaveLength(0);
77
+ });
78
+
79
+ it('every attempt carries {result, report, cost} and the outcome tallies usage', async () => {
80
+ const { step } = sequenced(withScreen, good);
81
+ const out = await run(step, 3);
82
+ expect(out.attempts[0].cost).toMatchObject({ usd: 0.01, promptTokens: 100, completionTokens: 50 });
83
+ expect(out.cost.usd).toBeCloseTo(0.02, 6);
84
+ expect(out.tally).toMatchObject({ calls: 2, promptTokens: 200, completionTokens: 100 });
85
+ });
86
+
87
+ it('a step that throws is surfaced with the attempts so far, not retried', async () => {
88
+ const boom = new Error('network down');
89
+ const { step, calls } = sequenced(withScreen, boom, good);
90
+ const error = await run(step, 5).catch((e) => e);
91
+ expect(error).toBeInstanceOf(RefineStepError);
92
+ expect(error.cause).toBe(boom);
93
+ expect(error.attempts).toHaveLength(1);
94
+ expect(error.n).toBe(2);
95
+ expect(calls).toHaveLength(2);
96
+ });
97
+
98
+ it('a reply the step rejected, or no result, is a failure under the rejected-gate name and the loop goes on', async () => {
99
+ const { step, calls } = sequenced({ result: null, errors: ['not json'] }, good);
100
+ const out = await run(step, 2);
101
+ expect(out.passed).toBe(true);
102
+ expect(out.attempts[0].report).toEqual([{ gate: 'step', failures: ['not json'] }]);
103
+ expect(calls[1]).toMatchObject({ previous: null, failures: [{ gate: 'step', failures: ['not json'] }] });
104
+ const named = await run(sequenced({ result: null }).step, 1, { rejectedGate: 'schema' });
105
+ expect(named.attempts[0].report[0]).toMatchObject({ gate: 'schema' });
106
+ });
107
+
108
+ it('calls a step made in one try (e.g. per-scene regeneration) are counted', async () => {
109
+ const out = await run(async () => ({ result: good, calls: 3 }), 1);
110
+ expect(out.attempts[0].calls).toBe(3);
111
+ });
112
+ });
113
+
114
+ describe('refineWithGates: gates', () => {
115
+ it('are registered by name; the context reaches them; a new one plugs in; a named subset runs', async () => {
116
+ const seen: string[] = [];
117
+ const gates = registry();
118
+ gates.register('ctx', (_d, ctx) => { seen.push(ctx.kind); return []; });
119
+ const out = await refineWithGates({ step: sequenced(withScreen).step, gates, tries: 1, context: { kind: 'skit' } });
120
+ expect(seen).toEqual(['skit']);
121
+ expect(out.attempts[0].report.map((r) => r.gate)).toEqual(['screens']);
122
+ const only = await refineWithGates({ step: sequenced(withScreen).step, gates, tries: 1, context: { kind: 'skit' }, only: ['title'] });
123
+ expect(only.passed).toBe(true);
124
+ });
125
+
126
+ it('an unknown gate name throws before any step call', async () => {
127
+ const { step, calls } = sequenced(good);
128
+ await expect(run(step, 1, { only: ['nope'] })).rejects.toBeInstanceOf(UnknownGateError);
129
+ expect(calls).toHaveLength(0);
130
+ });
131
+
132
+ it('opt-in gates are registered but run only when named', async () => {
133
+ const gates = registry();
134
+ gates.register('storyboard', () => ['nobody on stage'], { optIn: true });
135
+ expect(gates.defaultNames()).toEqual(['title', 'screens']);
136
+ const base = { step: sequenced(good).step, gates, tries: 1, context: { kind: 'k' } };
137
+ expect((await refineWithGates(base)).passed).toBe(true);
138
+ expect((await refineWithGates({ ...base, step: sequenced(good).step, only: ['storyboard'] })).passed).toBe(false);
139
+ });
140
+
141
+ it('informational lines never fail a gate and are kept on the attempt', async () => {
142
+ const gates = createGateRegistry<Doc>();
143
+ gates.register('lint', () => [{ text: 'unrealized data: screens', level: 'info' }, { text: 'note', level: 'info' }]);
144
+ const out = await refineWithGates({ step: sequenced(good).step, gates, tries: 2 });
145
+ expect(out.passed).toBe(true);
146
+ expect(out.attempts[0].notes).toEqual([{ gate: 'lint', text: 'unrealized data: screens' }, { gate: 'lint', text: 'note' }]);
147
+ gates.register('mixed', () => [{ text: 'fine', level: 'info' }, { text: 'broken', level: 'defect' }]);
148
+ const failed = await refineWithGates({ step: sequenced(good).step, gates, tries: 1 });
149
+ expect(failed.attempts[0].report).toEqual([{ gate: 'mixed', failures: ['broken'] }]);
150
+ });
151
+
152
+ it('async gates work, and replace=false refuses a duplicate name', async () => {
153
+ const gates = createGateRegistry<Doc>();
154
+ gates.register('slow', async () => ['late']);
155
+ expect(() => gates.register('slow', () => [], { replace: false })).toThrow(/already registered/);
156
+ const out = await refineWithGates({ step: sequenced(good).step, gates, tries: 1 });
157
+ expect(out.attempts[0].report).toEqual([{ gate: 'slow', failures: ['late'] }]);
158
+ });
159
+ });
160
+
161
+ describe('llmRefineStep: an injectable client', () => {
162
+ const provider = (answers: string[]) => {
163
+ const generate = vi.fn(async (_u: string, _s: string, _o?: GenerateOptions): Promise<LLMResponse> => ({
164
+ content: answers.shift() ?? '{}', model: 'fake', usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
165
+ }));
166
+ return { name: 'fake', availableModels: ['fake'], defaultModel: 'fake', generate, isAvailable: () => true } as ILLMProvider & { generate: typeof generate };
167
+ };
168
+ const validate = (json: any) => (typeof json?.title === 'string'
169
+ ? { valid: true as const, data: { title: json.title, screens: json.screens ?? 0 } }
170
+ : { valid: false as const, errors: ['title: expected string'] });
171
+
172
+ it('sends the brief, previous result and failures as data, and feeds a rejected reply back as a failure', async () => {
173
+ const p = provider(['not json', JSON.stringify({ title: 'Ok' })]);
174
+ const step = llmRefineStep<Doc>({ provider: p, systemPrompt: 'sys', brief: 'a skit', validate });
175
+ const out = await refineWithGates({ step, gates: createGateRegistry<Doc>(), tries: 3 });
176
+ expect(out.passed).toBe(true);
177
+ expect(out.attempts[0].report[0].gate).toBe('step');
178
+ expect(p.generate).toHaveBeenCalledTimes(2);
179
+ const first = JSON.parse(p.generate.mock.calls[0][0]);
180
+ const second = JSON.parse(p.generate.mock.calls[1][0]);
181
+ expect(first).toEqual({ brief: 'a skit' });
182
+ expect(second.brief).toBe('a skit');
183
+ expect(second.gate_failures[0].gate).toBe('step');
184
+ expect(out.attempts[1].cost).toMatchObject({ promptTokens: 10, completionTokens: 5 });
185
+ });
186
+
187
+ it('a provider that throws is surfaced as a step error', async () => {
188
+ const p = provider([]);
189
+ p.generate.mockRejectedValueOnce(new Error('401'));
190
+ const step = llmRefineStep<Doc>({ provider: p, systemPrompt: 's', brief: 'b', validate });
191
+ await expect(refineWithGates({ step, gates: createGateRegistry<Doc>(), tries: 3 })).rejects.toBeInstanceOf(RefineStepError);
192
+ });
193
+ });
@@ -0,0 +1,37 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { runCli } from '../speech/cli';
6
+
7
+ const golden: Array<{ fn: string; input: any; expected: any }> = JSON.parse(readFileSync(join(__dirname, 'fixtures', 'captions-golden.json'), 'utf8'));
8
+ const run = (argv: string[], body: unknown) => runCli(argv, { readStdin: async () => JSON.stringify(body) });
9
+
10
+ describe('startsimpli-speech captions', () => {
11
+ it.each(golden.map((g, n) => [n, g] as const).filter(([, g]) => g.fn === 'cues_for_beat' || (g.fn === 'cues_from_words' && g.input.words.length)))(
12
+ 'json format equals the Python cues (case %i)', async (_n, g) => {
13
+ const i = g.input;
14
+ const words = i.words?.map(({ word, ...r }: any) => ({ text: word, ...r }));
15
+ const res = await run(['captions', '--format', 'json'], {
16
+ words, text: i.text, duration: i.duration, offset: i.offset, clauseBreaks: i.clause_breaks, maxWords: i.max_words, maxChars: i.max_chars, maxGap: i.max_gap,
17
+ });
18
+ expect(res.exitCode).toBe(0);
19
+ expect((res.output as any).cues).toEqual(g.expected);
20
+ });
21
+
22
+ it('srt and vtt return the file text; holdGap and fps are applied', async () => {
23
+ const body = { words: [{ text: 'a', start: 0, end: 1 }, { text: 'b', start: 1.2, end: 2 }], maxWords: 1, holdGapS: 0.5, fps: 10 };
24
+ const srt = await run(['captions', '--format', 'srt'], body);
25
+ expect(srt.exitCode).toBe(0);
26
+ expect((srt.output as any).content).toContain('00:00:00,000 --> 00:00:01,200');
27
+ expect((srt.output as any).frames).toEqual([[0, 12], [12, 20]]);
28
+ expect(((await run(['captions', '--format', 'vtt'], body)).output as any).content.startsWith('WEBVTT')).toBe(true);
29
+ });
30
+
31
+ it('usage errors exit 2; unusable words exit 1 with a named error', async () => {
32
+ expect((await run(['captions', '--format', 'pdf'], { text: 'x', duration: 1 })).exitCode).toBe(2);
33
+ expect((await run(['captions', '--format', 'json'], {})).exitCode).toBe(2);
34
+ const bad = await run(['captions', '--format', 'json'], { words: [{ text: 'a', start: 2, end: 1 }] });
35
+ expect(bad).toMatchObject({ exitCode: 1, output: { error: { code: 'INVALID_WORDS' } } });
36
+ });
37
+ });
@@ -0,0 +1,136 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import {
6
+ InvalidWordsError, assertValidWords, captionTiming, cueFrameRange, cuesForBeat, cuesFromText, cuesFromWords,
7
+ holdGaps, splitWhitespaceTokens, toSrt, toVtt, wordsAreProportional,
8
+ } from '../speech';
9
+ import type { TranscriptWord } from '../speech';
10
+
11
+ type Golden = { fn: string; input: any; expected: any };
12
+ const golden: Golden[] = JSON.parse(readFileSync(join(__dirname, 'fixtures', 'captions-golden.json'), 'utf8'));
13
+
14
+ /** The Python code names a word's text `word`. */
15
+ const toWords = (ws: any[] | null | undefined): TranscriptWord[] | undefined =>
16
+ ws?.map(({ word, ...rest }) => ({ text: word, ...rest }));
17
+ const camel = (o: Record<string, any>, drop: string[]) => ({
18
+ ...(o.max_words !== undefined && { maxWords: o.max_words }),
19
+ ...(o.max_chars !== undefined && { maxChars: o.max_chars }),
20
+ ...(o.max_gap !== undefined && { maxGap: o.max_gap }),
21
+ ...(o.clause_breaks !== undefined && { clauseBreaks: o.clause_breaks }),
22
+ ...(o.offset !== undefined && { offset: o.offset }),
23
+ ...Object.fromEntries(Object.entries(o).filter(([k]) => drop.includes(k))),
24
+ });
25
+
26
+ const replay: Record<string, (i: any) => unknown> = {
27
+ cues_from_words: (i) => cuesFromWords(toWords(i.words)!, camel(i, [])),
28
+ cues_from_text: (i) => cuesFromText(i.text, i.duration, camel(i, [])),
29
+ cues_for_beat: (i) => cuesForBeat(toWords(i.words), i.text, i.duration, camel(i, [])),
30
+ hold_gaps: (i) => holdGaps(i.cues, i.max_gap_s),
31
+ cue_frame_range: (i) => cueFrameRange(i.cue, i.fps),
32
+ words_are_proportional: (i) => wordsAreProportional(toWords(i.words)!.map((w, n) => ({ ...w, ...(i.flagged ? { alignment: i.flagged } : {}), ...(i.words[n].alignment ? { alignment: i.words[n].alignment } : {}) })), { stepMax: i.step_max, minWords: i.min_words }),
33
+ check_words: (i) => {
34
+ try { assertValidWords(toWords(i.words)!); return { ok: true }; } catch (e) {
35
+ if (!(e instanceof InvalidWordsError)) throw e;
36
+ return { error: e.message };
37
+ }
38
+ },
39
+ };
40
+
41
+ describe('captions: parity with the Python implementation (golden)', () => {
42
+ it('has golden cases', () => expect(golden.length).toBeGreaterThanOrEqual(35));
43
+ it.each(golden.map((g, n) => [`${n} ${g.fn}`, g] as const))('%s', (_n, g) => {
44
+ expect(replay[g.fn](g.input)).toEqual(g.expected);
45
+ });
46
+ });
47
+
48
+ const w = (text: string, step = 0.3): TranscriptWord[] => text.split(' ').map((t, i) => ({ text: t, start: i * step, end: i * step + 0.25 }));
49
+
50
+ describe('captions: cue building', () => {
51
+ it('splits at a clause end with at least two tokens before the cut, never mid-clause when it can avoid it', () => {
52
+ const text = 'Today the frog will hop three feet to the right, and the elephant will watch.';
53
+ expect(cuesFromWords(w(text), { clauseBreaks: true }).map((c) => c.text))
54
+ .toEqual(['Today the frog will hop three feet', 'to the right,', 'and the elephant will watch.']);
55
+ expect(cuesFromWords(w('I watched. It was a very good three feet.'), { clauseBreaks: true }).map((c) => c.text))
56
+ .toEqual(['I watched.', 'It was a very good three feet.']);
57
+ // a single-token head is not a cut ("Hi, there ..." keeps the limit split)
58
+ expect(cuesFromWords(w('Hi, one two three four five six seven'), { clauseBreaks: true }).map((c) => c.text)[0]).toBe('Hi, one two three four five six');
59
+ });
60
+ it('holdGaps fills gaps up to the limit and never moves a start or mutates its input', () => {
61
+ const cues = [{ start: 0, end: 1, text: 'a' }, { start: 1.2, end: 2, text: 'b' }, { start: 3.5, end: 4, text: 'c' }];
62
+ expect(holdGaps(cues, 0.6).map((c) => c.end)).toEqual([1.2, 2, 4]);
63
+ expect(holdGaps(cues, 0)).toEqual(cues);
64
+ expect(cues[0].end).toBe(1);
65
+ });
66
+ it('cueFrameRange rounds half to even like Python', () => {
67
+ expect(cueFrameRange({ start: 1.25, end: 2.75, text: '' }, 10)).toEqual([12, 28]);
68
+ });
69
+ });
70
+
71
+ describe('captions: word checks', () => {
72
+ it('proportional detection: evenly spread characters yes, real vendor alignment no', () => {
73
+ const even = 'one two three four five six seven eight nine ten'.split(' ').map((t, i, a) => {
74
+ const start = a.slice(0, i).reduce((s, x) => s + 0.06 * x.length + 0.06, 0);
75
+ return { text: t, start, end: start + 0.06 * t.length };
76
+ });
77
+ expect(wordsAreProportional(even)).toBe(true);
78
+ const real = [[0, .31], [.35, .52], [.55, 1.1], [1.4, 1.5], [1.52, 2.4], [2.45, 2.6], [2.9, 3.0], [3.02, 3.9]].map(([s, e], i) => ({ text: `word${i}`, start: s, end: e }));
79
+ expect(wordsAreProportional(real)).toBe(false);
80
+ });
81
+ it('assertValidWords rejects empty and disordered transcripts with a named error', () => {
82
+ expect(() => assertValidWords([])).toThrow(InvalidWordsError);
83
+ expect(() => assertValidWords([{ text: 'a', start: 1, end: 0.5 }])).toThrow(/ends before it starts/);
84
+ expect(() => assertValidWords([{ text: 'a', start: 1, end: 2 }, { text: 'b', start: 0.5, end: 3 }])).toThrow(/not in time order/);
85
+ expect(assertValidWords(w('a b'))).toHaveLength(2);
86
+ });
87
+ it('splitWhitespaceTokens shares a multi-word token time evenly', () => {
88
+ expect(splitWhitespaceTokens([{ text: 'a b', start: 0, end: 1 }, { text: ' ', start: 1, end: 2 }])).toEqual([
89
+ { text: 'a', start: 0, end: 0.5 }, { text: 'b', start: 0.5, end: 1 },
90
+ ]);
91
+ });
92
+ it('captionTiming is not_measured for proportional words, else reports start errors', () => {
93
+ const real = [[0, .31], [.35, .52], [.55, 1.1], [1.4, 1.5], [1.52, 2.4], [2.45, 2.6], [2.9, 3.0], [3.02, 3.9]].map(([s, e], i) => ({ text: `w${i}`, start: s, end: e }));
94
+ const exact = captionTiming([{ start: 0, end: 1.1, text: 'w0 w1 w2' }], real);
95
+ expect(exact).toMatchObject({ measured: true, n: 1, pass: true, max_abs_start_err_ms: 0 });
96
+ expect(captionTiming([{ start: 0.5, end: 1.1, text: 'w0 w1 w2' }], real)).toMatchObject({ pass: false });
97
+ });
98
+ });
99
+
100
+ /** A strict reader for the two formats, independent of the writer. */
101
+ function parseCues(text: string, kind: 'srt' | 'vtt') {
102
+ const stamp = kind === 'srt' ? /^(\d\d):([0-5]\d):([0-5]\d),(\d{3})$/ : /^(\d\d):([0-5]\d):([0-5]\d)\.(\d{3})$/;
103
+ const ms = (s: string) => { const m = stamp.exec(s); if (!m) throw new Error(`bad timestamp ${s}`); return ((+m[1] * 60 + +m[2]) * 60 + +m[3]) * 1000 + +m[4]; };
104
+ let body = text;
105
+ if (kind === 'vtt') { if (!body.startsWith('WEBVTT\n\n')) throw new Error('missing WEBVTT header'); body = body.slice(8); }
106
+ if (!body.endsWith('\n')) throw new Error('no trailing newline');
107
+ return body.trimEnd().split('\n\n').map((block, n) => {
108
+ const lines = block.split('\n');
109
+ let i = 0;
110
+ if (kind === 'srt') { if (lines[i++] !== String(n + 1)) throw new Error(`bad index in ${block}`); }
111
+ const [a, arrow, b] = lines[i++].split(' ');
112
+ if (arrow !== '-->') throw new Error(`bad timing line in ${block}`);
113
+ const start = ms(a); const end = ms(b);
114
+ if (end <= start) throw new Error('end before start');
115
+ const cueText = lines.slice(i).join('\n');
116
+ if (!cueText || cueText.includes('\n\n')) throw new Error('empty cue text');
117
+ return { start: start / 1000, end: end / 1000, text: cueText };
118
+ });
119
+ }
120
+
121
+ describe('captions: SRT and VTT output', () => {
122
+ const cues = [{ start: 0, end: 1.2345, text: 'Hello there,' }, { start: 3661.5, end: 3662, text: 'second --> cue' }, { start: 3662, end: 3662.0004, text: 'x' }];
123
+ it.each([['srt', toSrt], ['vtt', toVtt]] as const)('%s parses back to the same cues (millisecond precision)', (kind, write) => {
124
+ const back = parseCues(write(cues), kind);
125
+ expect(back).toHaveLength(2 + 1);
126
+ expect(back[0]).toEqual({ start: 0, end: 1.235, text: 'Hello there,' });
127
+ expect(back[1].start).toBe(3661.5);
128
+ });
129
+ it('formats carry the right separators and header', () => {
130
+ expect(toSrt(cues)).toContain('00:00:00,000 --> 00:00:01,235');
131
+ expect(toVtt(cues).startsWith('WEBVTT\n\n00:00:00.000 --> 00:00:01.235')).toBe(true);
132
+ });
133
+ it('an end that rounds to its start is lifted one millisecond so the cue stays valid', () => {
134
+ expect(parseCues(toSrt(cues), 'srt')[2]).toEqual({ start: 3662, end: 3662.001, text: 'x' });
135
+ });
136
+ });
@@ -0,0 +1,67 @@
1
+ import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import { resetMockSpeechBehavior, setMockSpeechBehavior } from '../speech';
7
+ import { runCli } from '../speech/cli';
8
+
9
+ afterEach(() => { resetMockSpeechBehavior(); vi.unstubAllEnvs(); });
10
+
11
+ const run = (argv: string[], stdin = '{}') => {
12
+ vi.stubEnv('SPEECH_PROVIDER', 'mock');
13
+ return runCli(argv, { readStdin: async () => stdin });
14
+ };
15
+ const tmp = () => mkdtemp(join(tmpdir(), 'speech-cli-'));
16
+
17
+ describe('startsimpli-speech', () => {
18
+ it('synthesize: JSON in on stdin, audio to --out, JSON result on stdout', async () => {
19
+ const dir = await tmp();
20
+ const out = join(dir, 'a.mp3');
21
+ const res = await run(['synthesize', '--out', out], JSON.stringify({ text: '[calm] hi you', voiceId: 'v' }));
22
+ expect(res.exitCode).toBe(0);
23
+ expect(res.output).toMatchObject({ audioPath: out, mimeType: 'audio/mpeg', provider: 'mock', usage: { characters: 13 } });
24
+ expect((res.output as any).words.map((w: any) => w.text)).toEqual(['hi', 'you']);
25
+ expect((res.output as any).audio).toBeUndefined();
26
+ expect((await readFile(out)).byteLength).toBe((res.output as any).bytes);
27
+ });
28
+
29
+ it('synthesize: --in file and flags override the request', async () => {
30
+ const dir = await tmp();
31
+ const input = join(dir, 'req.json');
32
+ await writeFile(input, JSON.stringify({ text: 'hi', voiceId: 'from-file' }));
33
+ const res = await run(['synthesize', '--in', input, '--out', join(dir, 'o.mp3'), '--voice', 'from-flag', '--voice-settings', '{"stability":0.2}']);
34
+ expect(res.exitCode).toBe(0);
35
+ expect((res.output as any).voiceId).toBe('from-flag');
36
+ });
37
+
38
+ it('transcribe: reads audioPath, infers the mime type, returns the transcript', async () => {
39
+ const dir = await tmp();
40
+ const audio = join(dir, 'n.m4a');
41
+ await writeFile(audio, new Uint8Array([1, 2, 3]));
42
+ setMockSpeechBehavior({ text: 'hello' });
43
+ const res = await run(['transcribe'], JSON.stringify({ audioPath: audio }));
44
+ expect(res).toMatchObject({ exitCode: 0, output: { text: 'hello', provider: 'mock' } });
45
+ });
46
+
47
+ it('a provider failure is JSON on stdout with exit 1 and the typed code', async () => {
48
+ setMockSpeechBehavior({ authError: true });
49
+ const res = await run(['synthesize', '--out', join(await tmp(), 'x.mp3')], '{"text":"hi"}');
50
+ expect(res).toMatchObject({ exitCode: 1, output: { error: { code: 'AUTHENTICATION_ERROR', retryable: false, statusCode: 401 } } });
51
+ });
52
+
53
+ it.each([
54
+ [[], '{}', /usage/],
55
+ [['sing'], '{}', /usage/],
56
+ [['synthesize'], '{"text":"hi"}', /--out/],
57
+ [['synthesize', '--out'], '{}', /needs a value/],
58
+ [['synthesize', '--bogus', 'x'], '{}', /unknown argument/],
59
+ [['synthesize', '--out', 'x'], 'not json', /JSON object/],
60
+ [['transcribe'], '{}', /audioPath/],
61
+ [['synthesize', '--out', 'x', '--voice-settings', '[1]'], '{"text":"hi"}', /voice-settings/],
62
+ ])('malformed command %j exits 2 with a message', async (argv, stdin, message) => {
63
+ const res = await run(argv as string[], stdin);
64
+ expect(res.exitCode).toBe(2);
65
+ expect((res.output as any).error.message).toMatch(message);
66
+ });
67
+ });