@startsimpli/llm 0.4.30 → 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.
@@ -0,0 +1,35 @@
1
+ import type { ILLMProvider } from '../providers/interface';
2
+ import type { GenerateOptions } from '../types';
3
+ import { extractJson } from '../utils/json-extraction';
4
+ import type { ExtractValidation } from '../utils/extract-validate-correct';
5
+ import type { RefineStep } from './types';
6
+
7
+ export interface LLMRefineStepOptions<T> {
8
+ /** Any `ILLMProvider` (or a fake with `generate`): the injectable client. */
9
+ provider: Pick<ILLMProvider, 'generate'>;
10
+ systemPrompt: string;
11
+ /** The brief, sent on every try. */
12
+ brief: string;
13
+ /** Validates the parsed JSON; its errors become the step's rejections and go back to the model. */
14
+ validate: (json: unknown) => ExtractValidation<T>;
15
+ options?: GenerateOptions;
16
+ }
17
+
18
+ /**
19
+ * A `RefineStep` over one provider call: the user message is JSON `{brief, previous_result?, gate_failures?}`, so
20
+ * the model sees its previous result and what the gates said about it. An unreadable or invalid reply is returned as
21
+ * `errors` (a failure the loop feeds back); a provider error throws and ends the loop.
22
+ */
23
+ export function llmRefineStep<T>(config: LLMRefineStepOptions<T>): RefineStep<T> {
24
+ return async ({ previous, failures }) => {
25
+ const payload: Record<string, unknown> = { brief: config.brief };
26
+ if (previous !== null) payload.previous_result = previous;
27
+ if (failures.length) payload.gate_failures = failures;
28
+ const response = await config.provider.generate(JSON.stringify(payload), config.systemPrompt, config.options);
29
+ const meta = { model: response.model, usage: response.usage };
30
+ const extracted = extractJson(response.content);
31
+ if (!extracted.success) return { result: null, errors: [extracted.error ?? 'No valid JSON object found in response'], ...meta };
32
+ const checked = config.validate(extracted.json);
33
+ return checked.valid ? { result: checked.data, ...meta } : { result: null, errors: checked.errors, ...meta };
34
+ };
35
+ }
@@ -0,0 +1,81 @@
1
+ import { addUsage, newUsageTally, type UsageTally } from '../utils/usage-tally';
2
+ import { InvalidTriesError, RefineStepError } from './errors';
3
+ import type { GateRegistry } from './gates';
4
+ import type { RefineAttempt, RefineCost, GateFailure, RefineStep } from './types';
5
+
6
+ export interface RefineOptions<T, C = unknown> {
7
+ /** One model call per try (see `llmRefineStep`). Hand it the client you want; tests hand it a fake. */
8
+ step: RefineStep<T>;
9
+ gates: GateRegistry<T, C>;
10
+ /** An integer >= 1 is the number of step calls at most. `null` loops until every gate passes: nothing else caps it. */
11
+ tries: number | null;
12
+ /** Passed to every gate. */
13
+ context?: C;
14
+ /** Run only these gates (default: every registered gate except opt-in ones). Unknown names throw before any call. */
15
+ only?: string[];
16
+ /** RefineGate name under which the step's own rejections (`errors`, or no result) are reported. Default `step`. */
17
+ rejectedGate?: string;
18
+ /** Clock for `seconds`, injectable. Default `Date.now`. */
19
+ now?: () => number;
20
+ }
21
+
22
+ export interface RefineOutcome<T> {
23
+ /** The last result (the passing one when `passed`). */
24
+ result: T | null;
25
+ passed: boolean;
26
+ attempts: RefineAttempt<T>[];
27
+ /** Summed over every attempt. */
28
+ cost: RefineCost;
29
+ /** Per-model token usage over every attempt (the shared `UsageTally`). */
30
+ tally: UsageTally;
31
+ }
32
+
33
+ const zeroCost = (): RefineCost => ({ usd: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0 });
34
+ const round6 = (n: number) => Math.round(n * 1e6) / 1e6;
35
+
36
+ /**
37
+ * Prompt refinement: brief -> result -> gates -> the failures go back to the step -> a revised result.
38
+ * Each try is exactly one step call; the step gets the previous result and the gates' failures as data, so the
39
+ * model revises its own work instead of starting over. A step that throws ends the loop with `RefineStepError`.
40
+ * Every attempt's result, report and cost is returned.
41
+ */
42
+ export async function refineWithGates<T, C = unknown>(options: RefineOptions<T, C>): Promise<RefineOutcome<T>> {
43
+ const { step, gates, tries, context = undefined as C, only, rejectedGate = 'step', now = Date.now } = options;
44
+ if (tries !== null && !(typeof tries === 'number' && Number.isInteger(tries) && tries >= 1)) throw new InvalidTriesError(tries);
45
+ const names = gates.select(only);
46
+
47
+ const attempts: RefineAttempt<T>[] = [];
48
+ const tally = newUsageTally();
49
+ const total = zeroCost();
50
+ let previous: T | null = null;
51
+ let failures: GateFailure[] = [];
52
+
53
+ for (let n = 1; tries === null || n <= tries; n += 1) {
54
+ const t0 = now();
55
+ let out;
56
+ try {
57
+ out = await step({ n, previous, failures });
58
+ } catch (error) {
59
+ throw new RefineStepError(n, attempts, error);
60
+ }
61
+ const errors = out.errors ?? [];
62
+ let report: GateFailure[];
63
+ let notes: RefineAttempt<T>['notes'] = [];
64
+ if (errors.length || out.result === null) {
65
+ report = [{ gate: rejectedGate, failures: errors.length ? errors : ['the reply could not be read as a result'] }];
66
+ } else {
67
+ ({ report, notes } = await gates.run(out.result, context, names));
68
+ }
69
+ const cost: RefineCost = {
70
+ usd: out.costUsd ?? 0, promptTokens: out.usage?.promptTokens ?? 0, completionTokens: out.usage?.completionTokens ?? 0, cachedTokens: out.usage?.cachedTokens ?? 0,
71
+ };
72
+ if (out.usage) addUsage(tally, { model: out.model, usage: out.usage });
73
+ total.usd = round6(total.usd + cost.usd);
74
+ total.promptTokens += cost.promptTokens; total.completionTokens += cost.completionTokens; total.cachedTokens += cost.cachedTokens;
75
+ attempts.push({ n, result: out.result, report, notes, cost, passed: report.length === 0, seconds: Math.round(((now() - t0) / 1000) * 1000) / 1000, calls: out.calls ?? 1 });
76
+ if (report.length === 0) return { result: out.result, passed: true, attempts, cost: total, tally };
77
+ previous = out.result;
78
+ failures = report;
79
+ }
80
+ return { result: attempts[attempts.length - 1]?.result ?? null, passed: false, attempts, cost: total, tally };
81
+ }
@@ -0,0 +1,54 @@
1
+ import type { TokenUsage } from '../types';
2
+
3
+ /** One line a gate reports. A bare string is a defect; `level: 'info'` is information and never fails the gate. */
4
+ export type GateFinding = string | { text: string; level?: 'defect' | 'info' };
5
+
6
+ /** Gates see the step's result and a caller-owned context. They may be async (a render check, a validator service). */
7
+ export type RefineGate<T, C = unknown> = (result: T, context: C) => GateFinding[] | Promise<GateFinding[]>;
8
+
9
+ /** The failures one gate found; a report is the list of gates that failed, empty when every gate passed. */
10
+ export interface GateFailure { gate: string; failures: string[] }
11
+
12
+ export interface GateNote { gate: string; text: string }
13
+
14
+ /** What the step is handed on every try. Data only: the step decides how to put it in a prompt. */
15
+ export interface StepInput<T> {
16
+ /** 1-based try number. */
17
+ n: number;
18
+ /** The previous try's result, or null on the first try and after a try that produced none. */
19
+ previous: T | null;
20
+ /** The previous try's gate report; empty on the first try. */
21
+ failures: GateFailure[];
22
+ }
23
+
24
+ export interface StepOutput<T> {
25
+ /** The revised result, or null when the reply could not be read. */
26
+ result: T | null;
27
+ /** What the step rejected in the reply (parse / schema errors). They count as failures of the `rejectedGate` gate. */
28
+ errors?: string[];
29
+ /** Model that answered, for the usage tally. */
30
+ model?: string;
31
+ usage?: TokenUsage;
32
+ /** USD this try's calls cost, when the step prices them. */
33
+ costUsd?: number;
34
+ /** Step calls this try made (more than one only for a step that regenerates parts). Default 1. */
35
+ calls?: number;
36
+ }
37
+
38
+ /** One call of the model (or several, for a step that regenerates parts of a result). The client is closed over, so it is injectable. */
39
+ export type RefineStep<T> = (input: StepInput<T>) => Promise<StepOutput<T>>;
40
+
41
+ export interface RefineCost { usd: number; promptTokens: number; completionTokens: number; cachedTokens: number }
42
+
43
+ export interface RefineAttempt<T> {
44
+ n: number;
45
+ result: T | null;
46
+ /** Empty = every gate passed. */
47
+ report: GateFailure[];
48
+ /** Informational gate lines; never sent back to the step. */
49
+ notes: GateNote[];
50
+ cost: RefineCost;
51
+ passed: boolean;
52
+ seconds: number;
53
+ calls: number;
54
+ }
@@ -0,0 +1,108 @@
1
+ import type { CaptionWord, Cue, CueOptions } from './types';
2
+
3
+ const CLAUSE_END = [',', ';', ':', '.', '?', '!'];
4
+ const endsClause = (token: string) => CLAUSE_END.some((c) => token.endsWith(c));
5
+ /** Length in code points, as Python counts. */
6
+ const len = (s: string) => [...s].length;
7
+
8
+ /**
9
+ * Index k of the last token (not the final one) that ends a clause with at least two tokens before the cut, else null.
10
+ * A cue that has to end early ends there instead of in the middle of a clause.
11
+ */
12
+ function clauseCut(tokens: string[]): number | null {
13
+ if (endsClause(tokens[tokens.length - 1])) return null;
14
+ for (let k = tokens.length - 2; k > 0; k -= 1) if (endsClause(tokens[k])) return k;
15
+ return null;
16
+ }
17
+
18
+ /** Words -> cues of at most `maxWords` words and `maxChars` characters, also split at a silence over `maxGap`. */
19
+ export function cuesFromWords(words: CaptionWord[], options: CueOptions = {}): Cue[] {
20
+ const { maxWords = 7, maxChars = 42, maxGap = 0.6, clauseBreaks = false } = options;
21
+ const cues: Cue[] = [];
22
+ let cur: CaptionWord[] = [];
23
+ const flush = (upto?: number) => {
24
+ const part = upto ? cur.slice(0, upto) : [...cur];
25
+ if (part.length) {
26
+ cues.push({ start: part[0].start, end: part[part.length - 1].end, text: part.map((w) => w.text).join(' ') });
27
+ cur = cur.slice(part.length);
28
+ }
29
+ };
30
+ for (const w of words) {
31
+ if (cur.length) {
32
+ const tooLong = len([...cur, w].map((x) => x.text).join(' ')) > maxChars;
33
+ const gap = w.start - cur[cur.length - 1].end;
34
+ if (cur.length >= maxWords || tooLong || gap > maxGap) {
35
+ const k = clauseBreaks && gap <= maxGap ? clauseCut(cur.map((x) => x.text)) : null;
36
+ flush(k !== null ? k + 1 : undefined);
37
+ }
38
+ }
39
+ cur.push(w);
40
+ }
41
+ flush();
42
+ return cues;
43
+ }
44
+
45
+ /** Text with no timings: spread evenly over [0, duration], each chunk weighted by its character count. */
46
+ export function cuesFromText(text: string, duration: number, options: CueOptions = {}): Cue[] {
47
+ const { maxWords = 7, maxChars = 42, clauseBreaks = false } = options;
48
+ const tokens = text.split(/\s+/).filter(Boolean);
49
+ if (!tokens.length) return [];
50
+ const chunks: string[][] = [];
51
+ let cur: string[] = [];
52
+ for (const t of tokens) {
53
+ if (cur.length && (cur.length >= maxWords || len([...cur, t].join(' ')) > maxChars)) {
54
+ const k = clauseBreaks ? clauseCut(cur) : null;
55
+ chunks.push(k !== null ? cur.slice(0, k + 1) : cur);
56
+ cur = k !== null ? cur.slice(k + 1) : [];
57
+ }
58
+ cur.push(t);
59
+ }
60
+ chunks.push(cur);
61
+ const texts = chunks.map((c) => c.join(' '));
62
+ const total = texts.reduce((n, t) => n + len(t), 0);
63
+ let t0 = 0;
64
+ return texts.map((s, i) => {
65
+ const t1 = i === texts.length - 1 ? duration : t0 + (duration * len(s)) / total;
66
+ const cue = { start: t0, end: t1, text: s };
67
+ t0 = t1;
68
+ return cue;
69
+ });
70
+ }
71
+
72
+ /** One beat's cues: from its word timings when it has them, else from its text; shifted by `offset` seconds. */
73
+ export function cuesForBeat(
74
+ words: CaptionWord[] | null | undefined, text: string | null | undefined, duration: number,
75
+ options: CueOptions & { offset?: number } = {},
76
+ ): Cue[] {
77
+ const { offset = 0, ...cueOptions } = options;
78
+ const cues = words?.length ? cuesFromWords(words, cueOptions) : cuesFromText(text ?? '', duration, cueOptions);
79
+ return cues.map((c) => ({ start: c.start + offset, end: c.end + offset, text: c.text }));
80
+ }
81
+
82
+ /**
83
+ * Cues with each end moved to the next cue's start when the silence between them is at most `maxGapS`, so a caption stays
84
+ * up across a short pause. Starts never move; 0 returns the cues unchanged. The input is not mutated.
85
+ */
86
+ export function holdGaps(cues: Cue[], maxGapS: number): Cue[] {
87
+ const out = cues.map((c) => ({ ...c }));
88
+ if (maxGapS > 0) {
89
+ for (let i = 0; i + 1 < out.length; i += 1) {
90
+ const gap = out[i + 1].start - out[i].end;
91
+ if (gap > 0 && gap <= maxGapS) out[i].end = out[i + 1].start;
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+
97
+ /** Round half to even, as Python's `round`. */
98
+ function roundHalfEven(x: number): number {
99
+ const floor = Math.floor(x);
100
+ const diff = x - floor;
101
+ if (diff === 0.5) return floor % 2 === 0 ? floor : floor + 1;
102
+ return Math.round(x);
103
+ }
104
+
105
+ /** [first frame, end frame exclusive]: frame i is shown at time i / fps. */
106
+ export function cueFrameRange(cue: Pick<Cue, 'start' | 'end'>, fps: number): [number, number] {
107
+ return [roundHalfEven(cue.start * fps), roundHalfEven(cue.end * fps)];
108
+ }
@@ -0,0 +1,33 @@
1
+ import type { Cue } from './types';
2
+
3
+ function stamp(ms: number, sep: ',' | '.'): string {
4
+ const pad = (n: number, w = 2) => String(n).padStart(w, '0');
5
+ return `${pad(Math.floor(ms / 3_600_000))}:${pad(Math.floor(ms / 60_000) % 60)}:${pad(Math.floor(ms / 1000) % 60)}${sep}${pad(ms % 1000, 3)}`;
6
+ }
7
+
8
+ /** Millisecond start/end; an end that rounds onto its start is lifted one millisecond so the cue stays valid. */
9
+ function span(c: Cue): [number, number] {
10
+ const start = Math.max(0, Math.round(c.start * 1000));
11
+ return [start, Math.max(start + 1, Math.round(c.end * 1000))];
12
+ }
13
+
14
+ const oneBlock = (text: string) => text.split(/\n\s*\n/).map((s) => s.trim()).filter(Boolean).join('\n');
15
+
16
+ /** SubRip. Cue text keeps its line breaks; blank lines inside a cue are dropped (they would end it). */
17
+ export function toSrt(cues: Cue[]): string {
18
+ return cues.map((c, i) => {
19
+ const [a, b] = span(c);
20
+ return `${i + 1}\n${stamp(a, ',')} --> ${stamp(b, ',')}\n${oneBlock(c.text)}\n`;
21
+ }).join('\n');
22
+ }
23
+
24
+ const vttText = (text: string) => oneBlock(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/-->/g, '--&gt;');
25
+
26
+ /** WebVTT. `&`, `<` and `-->` in cue text are escaped as the spec requires. */
27
+ export function toVtt(cues: Cue[]): string {
28
+ const body = cues.map((c) => {
29
+ const [a, b] = span(c);
30
+ return `${stamp(a, '.')} --> ${stamp(b, '.')}\n${vttText(c.text)}\n`;
31
+ }).join('\n');
32
+ return `WEBVTT\n\n${body}`;
33
+ }
@@ -0,0 +1,6 @@
1
+ /** Caption cues from word timings (or bare text), word-alignment checks, SRT / VTT output. Pure: no vendor, no I/O. */
2
+ export { cueFrameRange, cuesForBeat, cuesFromText, cuesFromWords, holdGaps } from './cues';
3
+ export { toSrt, toVtt } from './formats';
4
+ export type { CaptionWord, Cue, CueOptions } from './types';
5
+ export { InvalidWordsError, assertValidWords, captionTiming, splitWhitespaceTokens, wordsAreProportional } from './words';
6
+ export type { CaptionTiming } from './words';
@@ -0,0 +1,18 @@
1
+ import type { TranscriptWord } from '../types';
2
+
3
+ /** A word with an optional vendor flag: `alignment: 'proportional'` says the times are guesses spread by character count. */
4
+ export type CaptionWord = TranscriptWord & { alignment?: string };
5
+
6
+ /** A caption on the video timeline, seconds. */
7
+ export interface Cue { start: number; end: number; text: string }
8
+
9
+ export interface CueOptions {
10
+ /** Most words in one cue. Default 7. */
11
+ maxWords?: number;
12
+ /** Most characters in one cue. Default 42. */
13
+ maxChars?: number;
14
+ /** A silence longer than this (seconds) starts a new cue. Default 0.6. */
15
+ maxGap?: number;
16
+ /** A cue that must end early ends at its last clause end (comma, full stop, ...) when it holds one. Default off. */
17
+ clauseBreaks?: boolean;
18
+ }
@@ -0,0 +1,91 @@
1
+ import { pyFloat, repr } from '../repr';
2
+ import type { CaptionWord, Cue } from './types';
3
+
4
+ /** A transcript that cannot be captioned: empty, or its times run backwards. Callers stop; nothing substitutes another. */
5
+ export class InvalidWordsError extends Error {
6
+ constructor(message: string) { super(message); this.name = 'InvalidWordsError'; }
7
+ }
8
+
9
+ /** Throws `InvalidWordsError` unless there is at least one word and the words are in time order, each ending after it starts. */
10
+ export function assertValidWords<T extends CaptionWord>(words: T[]): T[] {
11
+ if (!words.length) throw new InvalidWordsError('transcription returned no words');
12
+ let prev = -1.0;
13
+ words.forEach((w, i) => {
14
+ if (w.end < w.start) throw new InvalidWordsError(`word ${i} (${repr(w.text)}) ends before it starts (${pyFloat(w.start)} > ${pyFloat(w.end)})`);
15
+ if (w.start < prev) {
16
+ throw new InvalidWordsError(`words are not in time order: word ${i} (${repr(w.text)}) starts at ${pyFloat(w.start)}, before ${pyFloat(prev)}`);
17
+ }
18
+ prev = w.start;
19
+ });
20
+ return words;
21
+ }
22
+
23
+ const round3 = (n: number) => Math.round(n * 1000) / 1000;
24
+
25
+ /** A token holding spaces becomes several words sharing its time evenly; an all-space token is dropped. */
26
+ export function splitWhitespaceTokens(words: CaptionWord[]): CaptionWord[] {
27
+ const out: CaptionWord[] = [];
28
+ for (const w of words) {
29
+ const parts = w.text.split(/\s+/).filter(Boolean);
30
+ if (parts.length <= 1) {
31
+ if (parts.length) out.push({ ...w, text: parts[0] });
32
+ continue;
33
+ }
34
+ const step = (w.end - w.start) / parts.length;
35
+ parts.forEach((p, i) => out.push({ ...w, text: p, start: round3(w.start + i * step), end: round3(w.start + (i + 1) * step) }));
36
+ }
37
+ return out;
38
+ }
39
+
40
+ const median = (xs: number[]) => {
41
+ const s = [...xs].sort((a, b) => a - b);
42
+ const m = s.length >> 1;
43
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
44
+ };
45
+
46
+ /**
47
+ * True when the words say so (`alignment: 'proportional'`) or, unflagged, when seconds-per-character barely changes from one
48
+ * word to the next (median relative step). Real vendor alignments step by 30% and more; characters spread evenly by 0%.
49
+ */
50
+ export function wordsAreProportional(words: CaptionWord[], options: { stepMax?: number; minWords?: number } = {}): boolean {
51
+ const { stepMax = 0.02, minWords = 8 } = options;
52
+ const flags = new Set(words.map((w) => w.alignment).filter(Boolean));
53
+ if (flags.size) return flags.has('proportional');
54
+ const rate = words.filter((w) => w.end > w.start).map((w) => (w.end - w.start) / Math.max(1, [...w.text].length));
55
+ if (rate.length < minWords) return false;
56
+ const mean = rate.reduce((a, b) => a + b, 0) / rate.length;
57
+ if (mean <= 0) return false;
58
+ const steps = rate.slice(1).map((r, i) => Math.abs(r - rate[i]));
59
+ return steps.length > 0 && median(steps) / mean < stepMax;
60
+ }
61
+
62
+ export type CaptionTiming =
63
+ | { measured: false; pass: null; n: number; reason: string }
64
+ | { measured: true; n: number; max_abs_start_err_ms: number; median_abs_start_err_ms: number; max_abs_end_err_ms: number; pass: boolean };
65
+
66
+ /**
67
+ * Each caption covers the next `text.split().length` words; compare its start and end with those words'. Proportional words
68
+ * make the error 0 by construction, which is not a pass: it is `measured: false`. Report keys match the Python review reports.
69
+ */
70
+ export function captionTiming(captions: Cue[], words: CaptionWord[], options: { maxStartErrMs?: number } = {}): CaptionTiming {
71
+ const { maxStartErrMs = 250 } = options;
72
+ if (wordsAreProportional(words)) {
73
+ return { measured: false, pass: null, n: captions.length, reason: 'word times are proportional guesses (no forced alignment), so caption timing cannot be checked against them' };
74
+ }
75
+ let i = 0;
76
+ const startErr: number[] = [];
77
+ const endErr: number[] = [];
78
+ for (const c of captions) {
79
+ const k = Math.max(1, c.text.split(/\s+/).filter(Boolean).length);
80
+ const part = words.slice(i, i + k);
81
+ i += k;
82
+ if (!part.length) break;
83
+ startErr.push(Math.abs(c.start - part[0].start) * 1000);
84
+ endErr.push(Math.abs(c.end - part[part.length - 1].end) * 1000);
85
+ }
86
+ if (!startErr.length) return { measured: true, n: 0, max_abs_start_err_ms: 0, median_abs_start_err_ms: 0, max_abs_end_err_ms: 0, pass: false };
87
+ return {
88
+ measured: true, n: startErr.length, max_abs_start_err_ms: Math.max(...startErr), median_abs_start_err_ms: median(startErr),
89
+ max_abs_end_err_ms: Math.max(...endErr), pass: Math.max(...startErr) <= maxStartErrMs,
90
+ };
91
+ }
@@ -0,0 +1,50 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+
3
+ import {
4
+ InvalidWordsError, assertValidWords, cueFrameRange, cuesForBeat, holdGaps, toSrt, toVtt,
5
+ type CaptionWord,
6
+ } from './captions';
7
+
8
+ export interface CaptionsOutcome { exitCode: 0 | 1 | 2; output: unknown }
9
+
10
+ const FORMATS = ['json', 'srt', 'vtt'];
11
+
12
+ /** The Python side names a word's text `word`; either spelling is read. */
13
+ const toWord = (w: Record<string, any>): CaptionWord => {
14
+ const { word, text, ...rest } = w;
15
+ return { ...rest, text: text ?? word } as CaptionWord;
16
+ };
17
+
18
+ /**
19
+ * `startsimpli-speech captions [--format json|srt|vtt] [--out FILE]` — caption cues from word timings or bare text:
20
+ * {"words":[{"text"|"word","start","end"}] | "text" + "duration", "offset"?, "clauseBreaks"?, "maxWords"?, "maxChars"?,
21
+ * "maxGap"?, "holdGapS"?, "fps"?}
22
+ * Output `{format, cues, content?, frames?, path?}`: `content` is the SRT / VTT text (also written to --out), `frames` the
23
+ * [first, end) frame of each cue when `fps` is given. Unusable words are `{error: {code: "INVALID_WORDS"}}`, exit 1.
24
+ */
25
+ export async function runCaptions(body: Record<string, any>, flags: Record<string, string>): Promise<CaptionsOutcome> {
26
+ const usage = (message: string): CaptionsOutcome => ({ exitCode: 2, output: { error: { code: 'USAGE', message } } });
27
+ const format = flags.format ?? 'json';
28
+ if (!FORMATS.includes(format)) return usage(`--format must be ${FORMATS.join(', ')}, got "${format}"`);
29
+ const words: CaptionWord[] | undefined = Array.isArray(body.words) ? body.words.map(toWord) : undefined;
30
+ if (!words?.length && !(typeof body.text === 'string' && typeof body.duration === 'number')) {
31
+ return usage('captions needs "words" (timed), or "text" and "duration" in the request');
32
+ }
33
+ try {
34
+ if (words?.length) assertValidWords(words);
35
+ let cues = cuesForBeat(words, body.text, body.duration ?? 0, {
36
+ offset: body.offset, clauseBreaks: body.clauseBreaks, maxWords: body.maxWords, maxChars: body.maxChars, maxGap: body.maxGap,
37
+ });
38
+ if (typeof body.holdGapS === 'number') cues = holdGaps(cues, body.holdGapS);
39
+ const output: Record<string, unknown> = { format, cues };
40
+ if (typeof body.fps === 'number') output.frames = cues.map((c) => cueFrameRange(c, body.fps));
41
+ if (format !== 'json') {
42
+ output.content = format === 'srt' ? toSrt(cues) : toVtt(cues);
43
+ if (flags.out) { await writeFile(flags.out, output.content as string); output.path = flags.out; }
44
+ }
45
+ return { exitCode: 0, output };
46
+ } catch (error) {
47
+ if (error instanceof InvalidWordsError) return { exitCode: 1, output: { error: { code: 'INVALID_WORDS', message: error.message } } };
48
+ throw error;
49
+ }
50
+ }
package/src/speech/cli.ts CHANGED
@@ -16,6 +16,8 @@ import { extname } from 'node:path';
16
16
 
17
17
  import { LLMProviderError } from '../errors';
18
18
  import { SpeechService } from './service';
19
+ import { runCaptions } from './captions-cli';
20
+ import { runVoices } from './voices-cli';
19
21
  import type { SynthesizeRequest, TranscribeRequest } from './types';
20
22
 
21
23
  export interface CliIO {
@@ -36,9 +38,9 @@ const MIME_BY_EXT: Record<string, string> = {
36
38
  '.wav': 'audio/wav', '.flac': 'audio/flac',
37
39
  };
38
40
 
39
- const FLAGS = new Set(['in', 'out', 'provider', 'model', 'voice', 'format', 'voice-settings', 'language']);
41
+ const FLAGS = new Set(['in', 'out', 'provider', 'model', 'voice', 'format', 'voice-settings', 'language', 'library', 'expiring', 'today']);
40
42
 
41
- const USAGE = 'usage: startsimpli-speech <synthesize|transcribe> [--in request.json] [--out audio-file] '
43
+ const USAGE = 'usage: startsimpli-speech <synthesize|transcribe|voices resolve|captions> [--in request.json] [--out audio-file] '
42
44
  + '[--provider NAME] [--model ID] [--voice ID] [--format FMT] [--voice-settings JSON] [--language CODE]';
43
45
 
44
46
  class UsageError extends Error {}
@@ -70,6 +72,15 @@ async function readRequest(flags: Record<string, string>, io: CliIO): Promise<Re
70
72
  export async function runCli(argv: string[], io: CliIO): Promise<CliOutcome> {
71
73
  try {
72
74
  const [command, ...rest] = argv;
75
+ if (command === 'voices') {
76
+ const [sub, ...flagArgs] = rest;
77
+ const flags = parseFlags(flagArgs);
78
+ return runVoices(sub, sub === 'resolve' ? await readRequest(flags, io) : {}, flags);
79
+ }
80
+ if (command === 'captions') {
81
+ const flags = parseFlags(rest);
82
+ return runCaptions(await readRequest(flags, io), flags);
83
+ }
73
84
  if (command !== 'synthesize' && command !== 'transcribe') throw new UsageError(USAGE);
74
85
  const flags = parseFlags(rest);
75
86
  const body = await readRequest(flags, io);
@@ -20,3 +20,5 @@ export type { OpenAISpeechOptions } from './providers/openai';
20
20
  export { MockSpeechProvider, setMockSpeechBehavior, resetMockSpeechBehavior } from './providers/mock';
21
21
  export { audioExtension, speechHttpError } from './http';
22
22
  export { wordsFromCharacters } from './alignment';
23
+ export * from './voices';
24
+ export * from './captions';
@@ -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
+ }