@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
package/README.md CHANGED
@@ -181,9 +181,9 @@ Per CLAUDE.md **rule 9**, LLM client wiring never belongs in app `src/`. If you
181
181
 
182
182
  ## Speech (`@startsimpli/llm/speech`)
183
183
 
184
- Pluggable speech-to-text, same provider pattern as the text service. **It picks the
185
- cheapest vendor that has a key** — each provider states its list price — and falls
186
- through to the next cheapest on failure.
184
+ Pluggable speech-to-text and text-to-speech, same provider pattern as the text service.
185
+ **Each direction picks the cheapest vendor that has a key** — each provider states its
186
+ list price — and falls through to the next cheapest on failure.
187
187
 
188
188
  ```ts
189
189
  import { SpeechService } from '@startsimpli/llm/speech';
@@ -196,11 +196,39 @@ const { text, words } = await speech.transcribe({
196
196
 
197
197
  | Env | Provider | List price |
198
198
  | --- | --- | --- |
199
- | `OPENAI_API_KEY` | OpenAI `gpt-4o-mini-transcribe` (default; `gpt-4o-transcribe` / `whisper-1` cost 2x) | $0.18 / hour |
200
- | `ELEVENLABS_API_KEY` | ElevenLabs Scribe (keyterm hints are a billed add-on, so off unless `sendKeyterms`) | $0.22 / hour |
199
+ | `OPENAI_API_KEY` | OpenAI TTS (`gpt-4o-mini-tts`, no alignment) and `gpt-4o-mini-transcribe` (default; `gpt-4o-transcribe` / `whisper-1` cost 2x) | $0.18 / hour |
200
+ | `ELEVENLABS_API_KEY` | ElevenLabs Scribe + text-to-speech with alignment (`ELEVENLABS_VOICE_ID` = default voice); Scribe (keyterm hints are a billed add-on, so off unless `sendKeyterms`) | $0.22 / hour |
201
201
  | `SPEECH_PROVIDER=mock` | deterministic, for tests | — |
202
202
  | `SPEECH_PROVIDER` / `SPEECH_LANGUAGE` | force a vendor / pin the language (e.g. `en`) so short clips don't mis-detect | — |
203
203
 
204
+ **Text to speech**
205
+
206
+ ```ts
207
+ const { audio, mimeType, words, characters, durationSec, usage } = await speech.synthesize({
208
+ text: 'Plan mode changes that.',
209
+ voiceId, model: 'eleven_v3', outputFormat: 'mp3_44100_128', // voiceId defaults to ELEVENLABS_VOICE_ID
210
+ style: { tags: ['excited'], speed: 1.05, stability: 0.4 }, // tags only on models that read them
211
+ });
212
+ // usage = { characters, costUsd } at the model's list rate
213
+ ```
214
+
215
+ `words` come from the vendor's character alignment (absent when it gives none, as OpenAI's doesn't).
216
+ `[tag]` audio markup is not a word: it is dropped from `words` with the timing offsets kept; `characters`
217
+ is the raw alignment and `keepTags: true` keeps tags in `words`. `voiceSettings` passes raw vendor
218
+ settings and wins over `style`.
219
+
220
+ **CLI for non-TS callers** — `startsimpli-speech` (JSON in via `--in` or stdin, JSON on stdout, audio to a file;
221
+ keys from env only; exit 0 ok, 1 provider error as `{"error":{code,retryable,...}}`, 2 bad command line):
222
+
223
+ ```bash
224
+ echo '{"text":"[excited] Hello.","model":"eleven_v3","style":{"speed":1.0}}' \
225
+ | startsimpli-speech synthesize --out hello.mp3 [--voice ID --model ID --format pcm_22050 --voice-settings '{"stability":0.3}']
226
+ # -> {"mimeType":"audio/mpeg","audioPath":"hello.mp3","bytes":...,"words":[{"text":"Hello.","start":..,"end":..}],"characters":[...],"usage":{"characters":..,"costUsd":..},...}
227
+ echo '{"audioPath":"hello.mp3"}' | startsimpli-speech transcribe # -> {"text":..,"words":[..],"usage":{..}}
228
+ ```
229
+
230
+ It runs the tsup bundle when built (`pnpm --filter @startsimpli/llm build`), else the source via `tsx`.
231
+
204
232
  Retryable errors back off and retry; a failure that survives falls through to the next
205
233
  vendor (`fallback: false` to pin one). Bad audio (`INVALID_REQUEST`) never falls through.
206
234
 
@@ -213,10 +241,53 @@ registerSpeechProvider('mine', () => (process.env.MINE_KEY ? new MyProvider(proc
213
241
  ```
214
242
 
215
243
  `registerLLMProvider(name, factory)` does the same for text. Both sit on `ProviderRegistry`,
244
+ <<<<<<< HEAD
216
245
  so a new modality (TTS, embeddings) reuses it rather than hard-coding a provider list.
217
246
 
247
+ ### Voice cast library (`@startsimpli/llm/speech/voices`)
248
+
249
+ Voices keyed by neutral ids (never by avatar), with model, per-model settings, per-language rows, an emotion map, and the
250
+ retired / expiring ElevenLabs Default voice ids (they die 2026-12-31). Data is `src/speech/voices/cast-voices.json`.
251
+
252
+ ```ts
253
+ const cv = createCastVoices(defaultCastLibrary, { expiring: 'warn' }); // warn | allow | error; `today` injectable
254
+ const voices = cv.resolveCast([{ id: 'maya', voice: 'maya' }, { id: 'b' }], { language: 'es' });
255
+ const { textSent, settings } = directEmotion(cv, voices.b, 'Hello.', 'excited');
256
+ ```
257
+
258
+ A member's `voice` is a library id or a real voice id; anything else is an alias and the member takes the next `default_pool`
259
+ entry. Unfilled slot, retired / expiring id, unknown model or language, and two members on one voice throw named errors
260
+ (`VoiceNotResolvableError` subclasses). CLI: `echo '{"cast":[{"id":"a"}]}' | startsimpli-speech voices resolve [--model --language --expiring --today --library]`.
261
+
262
+ ### Captions (`@startsimpli/llm/speech/captions`)
263
+
264
+ Pure caption logic, no vendor and no I/O: `cuesFromWords` / `cuesFromText` / `cuesForBeat` (clause-aware splitting),
265
+ `holdGaps`, `cueFrameRange` (round half to even), `toSrt` / `toVtt`, and word checks: `wordsAreProportional` (is this
266
+ vendor alignment real or spread by character count?), `assertValidWords` (`InvalidWordsError` on empty or disordered
267
+ transcripts), `splitWhitespaceTokens`, `captionTiming`. Pixel and line-width layout stays with the renderer.
268
+ CLI: `startsimpli-speech captions --format json|srt|vtt [--out FILE] < request.json`.
269
+
270
+ ## Refine with gates (`@startsimpli/llm/refine`)
271
+
272
+ ```ts
273
+ const gates = createGateRegistry<Doc, Ctx>();
274
+ gates.register('schema', (doc, ctx) => validate(doc)); // string[] | { text, level: 'info' }[] , sync or async
275
+ const out = await refineWithGates({
276
+ step: llmRefineStep({ provider, systemPrompt, brief, validate }), // one model call per try; inject any client
277
+ gates, tries: 3, // an integer >= 1, or null to loop until every gate passes
278
+ });
279
+ // out.attempts[i] = { n, result, report, notes, cost, passed }; out.cost, out.tally, out.passed
280
+ ```
281
+
282
+ Each try is exactly one step call; the step receives the previous result and the gates' failures as data. Informational
283
+ lines (`level: 'info'`) never fail a gate. A step that throws ends the loop with `RefineStepError`. This is the multi-gate,
284
+ caller-bounded loop; `extractValidateCorrect` stays the single-validator JSON repair loop.
285
+
218
286
  ## Links
219
287
 
220
288
  - Source: https://github.com/qosha1/start-simpli/tree/main/packages/llm
221
289
  - Homepage: https://startsimpli.com
222
290
  - License: MIT, see [LICENSE](./LICENSE)
291
+ =======
292
+ so a new modality (embeddings) reuses it rather than hard-coding a provider list.
293
+ >>>>>>> eda91372 (speech: synthesize() with alignment, ElevenLabs TTS, tags and style, startsimpli-speech CLI)
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ // Launcher for the speech CLI. Packages here publish TypeScript source, so this
3
+ // runs the tsup bundle (`pnpm build`) when it exists, else the source via tsx.
4
+ const { existsSync } = require('node:fs');
5
+ const { join } = require('node:path');
6
+ const { spawnSync } = require('node:child_process');
7
+
8
+ const root = join(__dirname, '..');
9
+ const bundle = join(root, 'dist', 'speech', 'bin.js');
10
+ const localTsx = join(root, 'node_modules', '.bin', 'tsx');
11
+ const args = process.argv.slice(2);
12
+ if (existsSync(bundle)) {
13
+ process.argv.splice(1, 1, bundle);
14
+ require(bundle);
15
+ } else {
16
+ const tsx = spawnSync(existsSync(localTsx) ? localTsx : 'tsx', [join(root, 'src', 'speech', 'bin.ts'), ...args], { stdio: 'inherit' });
17
+ if (tsx.error) {
18
+ process.stdout.write(JSON.stringify({ error: { code: 'USAGE', message: 'startsimpli-speech needs a build (pnpm --filter @startsimpli/llm build) or tsx on PATH' } }) + '\n');
19
+ process.exit(2);
20
+ }
21
+ process.exit(tsx.status ?? 1);
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/llm",
3
- "version": "0.4.29",
3
+ "version": "0.4.31",
4
4
  "description": "Generic LLM service infrastructure — provider abstraction, retry logic, JSON extraction",
5
5
  "license": "MIT",
6
6
  "homepage": "https://startsimpli.com",
@@ -17,10 +17,14 @@
17
17
  "exports": {
18
18
  ".": "./src/index.ts",
19
19
  "./translation": "./src/translation/index.ts",
20
- "./speech": "./src/speech/index.ts"
20
+ "./speech": "./src/speech/index.ts",
21
+ "./refine": "./src/refine/index.ts",
22
+ "./speech/voices": "./src/speech/voices/index.ts",
23
+ "./speech/captions": "./src/speech/captions/index.ts"
21
24
  },
22
25
  "files": [
23
26
  "src",
27
+ "bin",
24
28
  "README.md",
25
29
  "LICENSE"
26
30
  ],
@@ -29,7 +33,7 @@
29
33
  },
30
34
  "dependencies": {
31
35
  "zod": "^4.3.6",
32
- "@startsimpli/i18n": "^0.1.4"
36
+ "@startsimpli/i18n": "^0.1.3"
33
37
  },
34
38
  "devDependencies": {
35
39
  "@types/node": "^20.19.39",
@@ -37,6 +41,9 @@
37
41
  "typescript": "^6.0.3",
38
42
  "vitest": "^4.1.5"
39
43
  },
44
+ "bin": {
45
+ "startsimpli-speech": "./bin/startsimpli-speech.js"
46
+ },
40
47
  "scripts": {
41
48
  "build": "tsup",
42
49
  "dev": "tsup --watch",