@gentbajko/slopify 3.0.0 → 3.0.2
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 +6 -4
- package/dist/adapter-registry.js +28 -0
- package/dist/adapters/alignment/text.js +3 -1
- package/dist/adapters/key-probes.js +57 -8
- package/dist/adapters/tts/gemini.js +206 -0
- package/dist/adapters/tts/pcm-mp3.js +62 -0
- package/dist/adapters/tts/system.js +147 -0
- package/dist/assets/models.yaml +38 -0
- package/dist/assets/style-preview/narration.mp3 +0 -0
- package/dist/assets/style-preview/tall-1.jpg +0 -0
- package/dist/assets/style-preview/tall-2.jpg +0 -0
- package/dist/assets/style-preview/tall-3.jpg +0 -0
- package/dist/assets/style-preview/wide-1.jpg +0 -0
- package/dist/assets/style-preview/wide-2.jpg +0 -0
- package/dist/assets/style-preview/wide-3.jpg +0 -0
- package/dist/catalog/registry.js +4 -1
- package/dist/catalog/validate.js +2 -2
- package/dist/edge/autostart/docker-record.js +2 -2
- package/dist/edge/autostart/docker.js +68 -0
- package/dist/edge/autostart/prompt.js +7 -1
- package/dist/edge/autostart/service.js +18 -9
- package/dist/edge/cli-args.js +71 -0
- package/dist/edge/cli.js +11 -16
- package/dist/edge/docker-install/run.js +7 -1
- package/dist/edge/events/hub.js +3 -1
- package/dist/edge/http/actions.js +6 -1
- package/dist/edge/http/app.js +2 -0
- package/dist/edge/http/byte-range.js +42 -0
- package/dist/edge/http/diagnostics.js +1 -0
- package/dist/edge/http/draft-files.js +2 -4
- package/dist/edge/http/files.js +10 -6
- package/dist/edge/http/onboarding.js +64 -4
- package/dist/edge/http/project-templates.js +19 -2
- package/dist/edge/http/projects.js +6 -1
- package/dist/edge/http/providers.js +39 -9
- package/dist/edge/http/reviews.js +18 -8
- package/dist/edge/http/revision-files.js +2 -4
- package/dist/edge/http/schedules.js +9 -5
- package/dist/edge/http/storage.js +16 -2
- package/dist/edge/http/studio.js +111 -31
- package/dist/edge/http/style-preview.js +3 -42
- package/dist/edge/http/tutorials.js +59 -0
- package/dist/edge/http/youtube-edits.js +11 -2
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/host-cli/gemini-login.js +83 -0
- package/dist/host-cli/status.js +4 -2
- package/dist/kernel/db/migrations/0041-cast-hosts.sql +3 -0
- package/dist/kernel/ports/model.js +5 -3
- package/dist/kernel/ports/system-speech.js +203 -0
- package/dist/kernel/runner/graph.js +17 -1
- package/dist/kernel/runner/index.js +4 -4
- package/dist/kernel/runner/providers.js +1 -1
- package/dist/main.js +5 -0
- package/dist/model-catalog.js +2 -0
- package/dist/patch-notes/3.0.1.md +105 -0
- package/dist/patch-notes/3.0.2.md +20 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/admission/rules.js +3 -3
- package/dist/slices/admission/start.js +1 -1
- package/dist/slices/channels/cast-match.js +1 -1
- package/dist/slices/channels/repo.js +3 -0
- package/dist/slices/channels/schema.js +23 -0
- package/dist/slices/channels/service.js +6 -6
- package/dist/slices/control/index.js +3 -1
- package/dist/slices/control/providers.js +4 -1
- package/dist/slices/episodes/related.js +2 -2
- package/dist/slices/estimate/requests.js +9 -1
- package/dist/slices/eta/history.js +56 -0
- package/dist/slices/eta/model.js +63 -0
- package/dist/slices/eta/view.js +25 -0
- package/dist/slices/fixes/rules.js +1 -1
- package/dist/slices/fonts/coverage.js +1 -1
- package/dist/slices/loudness/model.js +1 -1
- package/dist/slices/model-upkeep/usage.js +4 -2
- package/dist/slices/narration/pauses-model.js +1 -1
- package/dist/slices/narration/pronunciation.js +2 -2
- package/dist/slices/notifications/notifier.js +28 -2
- package/dist/slices/notifications/rules.js +26 -2
- package/dist/slices/onboarding/first-run.js +40 -14
- package/dist/slices/onboarding/full-video.js +81 -0
- package/dist/slices/onboarding/model.js +21 -0
- package/dist/slices/onboarding/quick-short.js +25 -10
- package/dist/slices/play-drafts/convert.js +3 -3
- package/dist/slices/play-drafts/readiness.js +1 -1
- package/dist/slices/project-templates/from-project.js +10 -3
- package/dist/slices/project-templates/next-chapter.js +94 -0
- package/dist/slices/project-templates/one-off.js +1 -1
- package/dist/slices/project-templates/schema.js +7 -1
- package/dist/slices/rebuild/recipe-exports.js +5 -0
- package/dist/slices/rebuild/recipe-save.js +1 -1
- package/dist/slices/rebuild/recipe-voices.js +1 -1
- package/dist/slices/rebuild/review-redo.js +15 -0
- package/dist/slices/rebuild/runtime-export-bed.js +1 -1
- package/dist/slices/rebuild/runtime-review.js +35 -5
- package/dist/slices/rebuild/runtime-store.js +23 -1
- package/dist/slices/rebuild/runtime-voices.js +2 -2
- package/dist/slices/rebuild/service-readiness.js +3 -3
- package/dist/slices/reviews/repo.js +16 -4
- package/dist/slices/run-cost/panel.js +30 -8
- package/dist/slices/run-cost/pricing.js +4 -1
- package/dist/slices/schedules/agenda.js +66 -2
- package/dist/slices/schedules/repo.js +1 -1
- package/dist/slices/schedules/scheduler.js +1 -1
- package/dist/slices/schedules/schema.js +43 -2
- package/dist/slices/schedules/similar.js +41 -3
- package/dist/slices/schedules/topic-list.js +2 -2
- package/dist/slices/schedules/topics.js +118 -17
- package/dist/slices/settings/first-run.js +5 -1
- package/dist/slices/settings/health.js +64 -7
- package/dist/slices/settings/key-guides.js +17 -7
- package/dist/slices/settings/key-test.js +12 -4
- package/dist/slices/settings/keys.js +10 -5
- package/dist/slices/settings/model-reach.js +52 -0
- package/dist/slices/settings/model.js +49 -0
- package/dist/slices/settings/models.js +5 -1
- package/dist/slices/settings/readiness.js +14 -2
- package/dist/slices/settings/tutorial-schema.js +5 -0
- package/dist/slices/shorts/render.js +18 -17
- package/dist/slices/storage/documents.js +11 -3
- package/dist/slices/storage/downloads.js +33 -1
- package/dist/slices/storage/portable.js +4 -2
- package/dist/slices/studio/model.js +2 -0
- package/dist/slices/studio/pack.js +6 -4
- package/dist/slices/studio/queue.js +69 -0
- package/dist/slices/studio/settings.js +42 -5
- package/dist/slices/style-preview/narration.js +33 -0
- package/dist/slices/style-preview/render.js +43 -56
- package/dist/slices/style-preview/schema.js +17 -3
- package/dist/slices/style-preview/service.js +3 -4
- package/dist/slices/style-preview/settings.js +75 -25
- package/dist/slices/tutorials/anchors.js +57 -0
- package/dist/slices/tutorials/library.js +185 -0
- package/dist/slices/voices/audio-files.js +7 -2
- package/dist/slices/voices/cast.js +26 -8
- package/dist/slices/voices/grouping.js +10 -0
- package/dist/slices/voices/model.js +49 -10
- package/dist/slices/voices/script.js +1 -1
- package/dist/slices/youtube/edits-repo.js +19 -4
- package/dist/slices/youtube/placeholders.js +2 -2
- package/dist/tutorials/AI-CLIs.md +146 -0
- package/dist/tutorials/Backups.md +121 -0
- package/dist/tutorials/Calendar.md +103 -0
- package/dist/tutorials/Cast-Library.md +112 -0
- package/dist/tutorials/Channels.md +148 -0
- package/dist/tutorials/Costs-and-Run-Cost.md +136 -0
- package/dist/tutorials/Docker.md +171 -0
- package/dist/tutorials/Document-Themes.md +207 -0
- package/dist/tutorials/Editing-a-Project.md +204 -0
- package/dist/tutorials/FAQ.md +134 -0
- package/dist/tutorials/First-Launch-and-Welcome.md +153 -0
- package/dist/tutorials/Home-and-Projects.md +174 -0
- package/dist/tutorials/Home.md +119 -0
- package/dist/tutorials/Install.md +154 -0
- package/dist/tutorials/Intros-and-Outros.md +86 -0
- package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +215 -0
- package/dist/tutorials/Library-Overview.md +75 -0
- package/dist/tutorials/Models.md +133 -0
- package/dist/tutorials/Multiple-Voices.md +161 -0
- package/dist/tutorials/Narration-Aliases-and-Glossary.md +129 -0
- package/dist/tutorials/Notifications.md +86 -0
- package/dist/tutorials/Other-Languages.md +110 -0
- package/dist/tutorials/PDF-Documents.md +100 -0
- package/dist/tutorials/Play-Images.md +135 -0
- package/dist/tutorials/Play-Narration.md +167 -0
- package/dist/tutorials/Play-Outputs.md +109 -0
- package/dist/tutorials/Play-Overview.md +216 -0
- package/dist/tutorials/Play-Title-and-Article.md +135 -0
- package/dist/tutorials/Play-Video-and-Style.md +143 -0
- package/dist/tutorials/Project-Page.md +228 -0
- package/dist/tutorials/Prompts.md +136 -0
- package/dist/tutorials/Providers-and-Keys.md +290 -0
- package/dist/tutorials/Publishing-to-YouTube.md +135 -0
- package/dist/tutorials/Recovery-and-Retries.md +141 -0
- package/dist/tutorials/Reviews-and-Checkpoints.md +143 -0
- package/dist/tutorials/Schedules.md +154 -0
- package/dist/tutorials/Settings-Reference.md +258 -0
- package/dist/tutorials/Shorts.md +116 -0
- package/dist/tutorials/Start-at-Login.md +126 -0
- package/dist/tutorials/Studio-Extension.md +146 -0
- package/dist/tutorials/Templates.md +146 -0
- package/dist/tutorials/Trash.md +87 -0
- package/dist/tutorials/Troubleshooting.md +149 -0
- package/dist/tutorials/Uninstalling-and-Moving.md +113 -0
- package/dist/tutorials/Updating-and-Patch-Notes.md +120 -0
- package/dist/tutorials/Video-Editing.md +135 -0
- package/dist/tutorials/Where-Your-Files-Live.md +149 -0
- package/dist/tutorials/YouTube-Description.md +132 -0
- package/dist/tutorials/Your-First-Short.md +125 -0
- package/dist/tutorials/_Footer.md +1 -0
- package/dist/tutorials/_Sidebar.md +69 -0
- package/dist/web/assets/index-C4McGNke.js +307 -0
- package/dist/web/assets/index-DBLSfqA-.css +1 -0
- package/dist/web/assets/{pdf-BG8n_uc5.js → pdf-COowcLcz.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +2 -2
- package/dist/web/assets/index-CUA2sUxU.css +0 -1
- package/dist/web/assets/index-CX590kE-.js +0 -303
package/README.md
CHANGED
|
@@ -34,14 +34,16 @@ For Docker on Linux with Node 26+, Docker Engine and the Docker Compose plugin,
|
|
|
34
34
|
`slopify --docker` or `npx @gentbajko/slopify@latest --docker`. It writes one
|
|
35
35
|
`compose.yaml` and its `.env` under `~/.local/share/slopify/docker/slopify/` and starts
|
|
36
36
|
Slopify at `http://127.0.0.1:6969`, restarting with Docker. Project files go to
|
|
37
|
-
|
|
37
|
+
`Documents/Slopify/Projects` (`--projects-dir` to choose another, `--port` for the port); the
|
|
38
38
|
database, keys and logs stay private in the `slopify-data` volume.
|
|
39
39
|
|
|
40
40
|
If Claude Code, Codex or Gemini is installed, it asks once before setting up the host CLI
|
|
41
41
|
bridge, a private helper that runs those CLIs on your machine with their existing logins.
|
|
42
42
|
Use `--host-cli=off` for API keys only, or `--accept-host-cli` to approve without a prompt.
|
|
43
43
|
|
|
44
|
-
Update with `npx @gentbajko/slopify@latest update`, Docker or not.
|
|
44
|
+
Update with `npx @gentbajko/slopify@latest update`, Docker or not. Without Docker, `update`
|
|
45
|
+
asks the running app to update itself, so it works only while Slopify is running (to just
|
|
46
|
+
start the newest version, run `npx @gentbajko/slopify@latest`). It waits for running
|
|
45
47
|
work, keeps a copy to go back to (the database, or a recovery volume in Docker), and puts
|
|
46
48
|
the previous version back if the new one doesn't start. Installations made by 2.5.0 or
|
|
47
49
|
earlier are taken over as they are: same volume, same project folder. See the
|
|
@@ -88,10 +90,10 @@ for account limits. Both model IDs are bundled; Inworld's LLM catalogue does not
|
|
|
88
90
|
| `--port` | `SLOPIFY_PORT` | `6969` |
|
|
89
91
|
| `--host` | `SLOPIFY_HOST` | `127.0.0.1` |
|
|
90
92
|
| `--data-dir` | `SLOPIFY_DATA_DIR` | `~/.slopify` |
|
|
91
|
-
| `--projects-dir` (Docker only) | `SLOPIFY_DOCKER_PROJECTS_DIR` | remembered path; otherwise
|
|
93
|
+
| `--projects-dir` (Docker only) | `SLOPIFY_DOCKER_PROJECTS_DIR` | remembered path; otherwise `<Documents>/Slopify/Projects` (custom names: `<Documents>/Slopify/<container>/Projects`); installs from before 3.0 keep `~/Slopify/Projects` |
|
|
92
94
|
| `--no-open` | `SLOPIFY_NO_OPEN` | the browser opens |
|
|
93
95
|
| `--docker` | — | run locally; with the flag, install or update the Docker version |
|
|
94
|
-
| `update` | — | update the running app, or the Docker installation if you have one |
|
|
96
|
+
| `update` | — | update the running app (it must be running), or the Docker installation if you have one |
|
|
95
97
|
| — | `SLOPIFY_FFMPEG` | the bundled binary |
|
|
96
98
|
|
|
97
99
|
There is no login. Binding to anything but `127.0.0.1` hands the app and every key in
|
package/dist/adapter-registry.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { tmpdir } from "node:os";
|
|
1
2
|
import { codexImage } from "./adapters/image/codex.js";
|
|
2
3
|
import { falImage } from "./adapters/image/fal.js";
|
|
3
4
|
import { googleImage } from "./adapters/image/google.js";
|
|
@@ -13,9 +14,13 @@ import { nodeGeminiModels } from "./adapters/llm/gemini-models.js";
|
|
|
13
14
|
import { openRouterLlm } from "./adapters/llm/openrouter.js";
|
|
14
15
|
import { cartesiaTts } from "./adapters/tts/cartesia.js";
|
|
15
16
|
import { elevenLabsTts } from "./adapters/tts/elevenlabs.js";
|
|
17
|
+
import { geminiTts } from "./adapters/tts/gemini.js";
|
|
16
18
|
import { inworldTts } from "./adapters/tts/inworld.js";
|
|
17
19
|
import { openAiTts } from "./adapters/tts/openai.js";
|
|
20
|
+
import { ffmpegPcmToMp3 } from "./adapters/tts/pcm-mp3.js";
|
|
21
|
+
import { systemTts, systemVoiceId } from "./adapters/tts/system.js";
|
|
18
22
|
import { hostLlmIds } from "./kernel/ports/host-cli.js";
|
|
23
|
+
import { detectSpeechCached } from "./kernel/ports/system-speech.js";
|
|
19
24
|
import { cliBinary } from "./slices/settings/cli-paths.js";
|
|
20
25
|
import { keyForAttempt } from "./slices/settings/keys.js";
|
|
21
26
|
import { providerStatuses } from "./slices/settings/readiness.js";
|
|
@@ -63,6 +68,28 @@ export function buildRegistry(deps) {
|
|
|
63
68
|
["openai-tts", openAiTts({ fetch: deps.fetch, key: keyOf("openai-tts") })],
|
|
64
69
|
["cartesia", cartesiaTts({ fetch: deps.fetch, key: keyOf("cartesia") })],
|
|
65
70
|
["inworld", inworldTts({ fetch: deps.fetch, key: keyOf("inworld"), clock: deps.clock })],
|
|
71
|
+
// No key: the speech program this computer has, run like a CLI.
|
|
72
|
+
[
|
|
73
|
+
systemVoiceId,
|
|
74
|
+
systemTts({
|
|
75
|
+
detect: () => detectSpeechCached(deps.probe, deps.host ?? process),
|
|
76
|
+
run: deps.spawn,
|
|
77
|
+
ffmpeg: deps.ffmpeg,
|
|
78
|
+
tempRoot: tmpdir(),
|
|
79
|
+
env: (deps.host ?? process).env,
|
|
80
|
+
}),
|
|
81
|
+
],
|
|
82
|
+
// Its own key, or else the one saved for Google images (`sharedKeyOf`).
|
|
83
|
+
[
|
|
84
|
+
"google-tts",
|
|
85
|
+
geminiTts({
|
|
86
|
+
fetch: deps.fetch,
|
|
87
|
+
key: keyOf("google-tts"),
|
|
88
|
+
toMp3: deps.ffmpeg === undefined
|
|
89
|
+
? () => Promise.reject(new Error("ffmpeg is not available to encode Gemini speech"))
|
|
90
|
+
: ffmpegPcmToMp3(deps.ffmpeg),
|
|
91
|
+
}),
|
|
92
|
+
],
|
|
66
93
|
]);
|
|
67
94
|
// Keyed image providers receive only their own key; Codex uses the shared CLI login.
|
|
68
95
|
// Replicate also takes the clock: `Prefer: wait` gives up after 60 s and the prediction has
|
|
@@ -100,6 +127,7 @@ export function buildRegistry(deps) {
|
|
|
100
127
|
db: deps.db,
|
|
101
128
|
probe: deps.probe,
|
|
102
129
|
hostCliStatus: deps.hostCli?.status,
|
|
130
|
+
host: deps.host,
|
|
103
131
|
})).map((status) => ({
|
|
104
132
|
family: status.family,
|
|
105
133
|
id: status.id,
|
|
@@ -53,7 +53,9 @@ export function speechWords(text, observed = "", aliases = [], formsOf = wordFor
|
|
|
53
53
|
}
|
|
54
54
|
if (words.length === 0)
|
|
55
55
|
throw new Error(empty);
|
|
56
|
-
|
|
56
|
+
// An aliased word keeps its forms even when there is one: `respoken` would otherwise read
|
|
57
|
+
// the written text again without the aliases, and "&" alone says nothing.
|
|
58
|
+
return words.map(({ text: shown, spoken, forms, aliased }) => forms.length > 1 || aliased ? { text: shown, spoken, forms } : { text: shown, spoken });
|
|
57
59
|
}
|
|
58
60
|
// The form of a word the recording seems to say, else its default.
|
|
59
61
|
export function respoken(word, observed, formsOf = wordForms) {
|
|
@@ -4,25 +4,74 @@ import { cartesiaVersion } from "./tts/cartesia.js";
|
|
|
4
4
|
// invalid_api_key, Inworld with 403 "does not exist". Observed on 2026-09-27; their
|
|
5
5
|
// documentation says 401.
|
|
6
6
|
const bearer = (key) => ({ Authorization: `Bearer ${key}` });
|
|
7
|
+
// Health check → "Model reachable": one read per chosen model, or one of the list. A model's
|
|
8
|
+
// own page answers 404 when the key can't use it.
|
|
9
|
+
const openAiModel = {
|
|
10
|
+
url: (model) => `https://api.openai.com/v1/models/${encodeURIComponent(model)}`,
|
|
11
|
+
};
|
|
12
|
+
function listed(ids) {
|
|
13
|
+
return (body, model) => {
|
|
14
|
+
try {
|
|
15
|
+
const found = ids(JSON.parse(body));
|
|
16
|
+
return found.length === 0 ? undefined : found.includes(model);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const field = (value, key) => typeof value === "object" && value !== null ? value[key] : undefined;
|
|
24
|
+
const google = {
|
|
25
|
+
url: "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1",
|
|
26
|
+
headers: (key) => ({ "x-goog-api-key": key }),
|
|
27
|
+
badKey: (status, body) => status === 400 && body.includes("API_KEY_INVALID"),
|
|
28
|
+
model: {
|
|
29
|
+
url: (model) => `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model.replace(/^models\//, ""))}`,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
7
32
|
export const keyProbes = {
|
|
8
|
-
openrouter: {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
33
|
+
openrouter: {
|
|
34
|
+
url: "https://openrouter.ai/api/v1/key",
|
|
35
|
+
headers: bearer,
|
|
36
|
+
model: {
|
|
37
|
+
url: () => "https://openrouter.ai/api/v1/models",
|
|
38
|
+
lists: listed((body) => {
|
|
39
|
+
const data = field(body, "data");
|
|
40
|
+
return Array.isArray(data) ? data.map((one) => field(one, "id")) : [];
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
15
43
|
},
|
|
44
|
+
"openai-tts": { url: "https://api.openai.com/v1/models", headers: bearer, model: openAiModel },
|
|
45
|
+
"openai-image": { url: "https://api.openai.com/v1/models", headers: bearer, model: openAiModel },
|
|
46
|
+
"google-image": google,
|
|
47
|
+
"google-tts": google,
|
|
16
48
|
// fal.ai's model list answers without a key; its price lookup requires one.
|
|
17
49
|
fal: {
|
|
18
50
|
url: "https://api.fal.ai/v1/models/pricing?endpoint_id=fal-ai/flux/dev",
|
|
19
51
|
headers: (key) => ({ Authorization: `Key ${key}` }),
|
|
52
|
+
model: {
|
|
53
|
+
url: (model) => `https://api.fal.ai/v1/models/pricing?endpoint_id=${encodeURIComponent(model)}`,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
replicate: {
|
|
57
|
+
url: "https://api.replicate.com/v1/account",
|
|
58
|
+
headers: bearer,
|
|
59
|
+
// "owner/name", without a ":version".
|
|
60
|
+
model: {
|
|
61
|
+
url: (model) => `https://api.replicate.com/v1/models/${(model.split(":")[0] ?? model)
|
|
62
|
+
.split("/")
|
|
63
|
+
.map(encodeURIComponent)
|
|
64
|
+
.join("/")}`,
|
|
65
|
+
},
|
|
20
66
|
},
|
|
21
|
-
replicate: { url: "https://api.replicate.com/v1/account", headers: bearer },
|
|
22
67
|
elevenlabs: {
|
|
23
68
|
url: "https://api.elevenlabs.io/v1/models",
|
|
24
69
|
headers: (key) => ({ "xi-api-key": key }),
|
|
25
70
|
badKey: (status, body) => status === 400 && body.includes("invalid_api_key"),
|
|
71
|
+
model: {
|
|
72
|
+
url: () => "https://api.elevenlabs.io/v1/models",
|
|
73
|
+
lists: listed((body) => Array.isArray(body) ? body.map((one) => field(one, "model_id")) : []),
|
|
74
|
+
},
|
|
26
75
|
},
|
|
27
76
|
cartesia: {
|
|
28
77
|
url: "https://api.cartesia.ai/voices?limit=1",
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { redact } from "../../kernel/log.js";
|
|
3
|
+
import { providerError } from "../../kernel/ports/model.js";
|
|
4
|
+
import { httpFailure, internalError, missingKey, noAudio, providerSaid, refreshList, unreadable, voiceFix, } from "../explain.js";
|
|
5
|
+
import { retryAfter } from "../retry-after.js";
|
|
6
|
+
// The HTTP gateway adapter for Gemini's speech generation (generateContent with an AUDIO
|
|
7
|
+
// response, https://ai.google.dev/gemini-api/docs/speech-generation), billed to a Gemini API
|
|
8
|
+
// key. One voice is `speechConfig.voiceConfig`; a conversation of exactly two voices is
|
|
9
|
+
// `speechConfig.multiSpeakerVoiceConfig`, Gemini's native two-speaker narration. The answer is
|
|
10
|
+
// raw 16-bit mono PCM (24 kHz), which the port cannot carry, so it is encoded to MP3 by the
|
|
11
|
+
// injected encoder before it is handed back.
|
|
12
|
+
export const geminiTtsBase = "https://generativelanguage.googleapis.com/v1beta";
|
|
13
|
+
export const geminiTtsModel = "gemini-2.5-flash-preview-tts";
|
|
14
|
+
// Offline choices only; the picker normally loads the provider catalogue.
|
|
15
|
+
export const geminiTtsModels = [
|
|
16
|
+
{ id: "gemini-3.1-flash-tts-preview", name: "Gemini 3.1 Flash TTS (preview)" },
|
|
17
|
+
{ id: "gemini-2.5-flash-preview-tts", name: "Gemini 2.5 Flash TTS (preview)" },
|
|
18
|
+
{ id: "gemini-2.5-pro-preview-tts", name: "Gemini 2.5 Pro TTS (preview)" },
|
|
19
|
+
];
|
|
20
|
+
// multiSpeakerVoiceConfig takes exactly two speakers.
|
|
21
|
+
export const geminiDialogueVoices = 2;
|
|
22
|
+
const defaultSampleRate = 24_000;
|
|
23
|
+
const inlineData = z.object({ mimeType: z.string().nullish(), data: z.string().nullish() });
|
|
24
|
+
const answer = z.object({
|
|
25
|
+
candidates: z
|
|
26
|
+
.array(z.object({
|
|
27
|
+
content: z
|
|
28
|
+
.object({ parts: z.array(z.object({ inlineData: inlineData.nullish() })).nullish() })
|
|
29
|
+
.nullish(),
|
|
30
|
+
finishReason: z.string().nullish(),
|
|
31
|
+
}))
|
|
32
|
+
.nullish(),
|
|
33
|
+
promptFeedback: z.object({ blockReason: z.string().nullish() }).nullish(),
|
|
34
|
+
});
|
|
35
|
+
const modelList = z.object({
|
|
36
|
+
models: z
|
|
37
|
+
.array(z.object({
|
|
38
|
+
name: z.string(),
|
|
39
|
+
displayName: z.string().nullish(),
|
|
40
|
+
supportedGenerationMethods: z.array(z.string()).nullish(),
|
|
41
|
+
}))
|
|
42
|
+
.nullish(),
|
|
43
|
+
});
|
|
44
|
+
const errorBody = z.object({
|
|
45
|
+
error: z.object({ message: z.string().nullish(), status: z.string().nullish() }),
|
|
46
|
+
});
|
|
47
|
+
export function geminiTts(deps) {
|
|
48
|
+
return {
|
|
49
|
+
id: "google-tts",
|
|
50
|
+
capabilities: { streams: false, dialogue: true },
|
|
51
|
+
models: async () => {
|
|
52
|
+
const response = await deps.fetch(`${geminiTtsBase}/models?pageSize=1000`, {
|
|
53
|
+
headers: { "x-goog-api-key": keyOf(deps) },
|
|
54
|
+
signal: AbortSignal.timeout(10_000),
|
|
55
|
+
});
|
|
56
|
+
if (!response.ok)
|
|
57
|
+
throw await failure(response, undefined, refreshList);
|
|
58
|
+
const parsed = modelList.safeParse(safeJson(await response.text()));
|
|
59
|
+
if (!parsed.success)
|
|
60
|
+
throw providerError({ kind: "other", message: unreadable("Google") });
|
|
61
|
+
return (parsed.data.models ?? [])
|
|
62
|
+
.filter((model) => /-tts\b|tts-/.test(model.name) &&
|
|
63
|
+
(model.supportedGenerationMethods ?? ["generateContent"]).includes("generateContent"))
|
|
64
|
+
.map((model) => {
|
|
65
|
+
const id = model.name.replace(/^models\//, "");
|
|
66
|
+
return { id, name: model.displayName || id };
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
synthesize: async (req) => {
|
|
70
|
+
const model = req.model ?? geminiTtsModel;
|
|
71
|
+
const response = await deps.fetch(`${geminiTtsBase}/models/${encodeURIComponent(model)}:generateContent`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
signal: req.signal,
|
|
74
|
+
// The key rides Google's own header rather than the query string, which would put it
|
|
75
|
+
// in every proxy log on the way.
|
|
76
|
+
headers: { "x-goog-api-key": keyOf(deps), "Content-Type": "application/json" },
|
|
77
|
+
body: JSON.stringify(geminiSpeechBody(req)),
|
|
78
|
+
});
|
|
79
|
+
if (!response.ok)
|
|
80
|
+
throw await failure(response, req.voiceId);
|
|
81
|
+
const { pcm, sampleRate } = audioOf(await response.text());
|
|
82
|
+
const mp3 = await deps.toMp3(pcm, sampleRate, req.signal);
|
|
83
|
+
return { audio: streamOf(mp3), container: "mp3" };
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// The request body. A dialogue of two voices names each voice as its speaker, since the
|
|
88
|
+
// transcript's speaker names must match `speakerVoiceConfigs`; a dialogue whose lines all share
|
|
89
|
+
// one voice is that voice reading the lines. More than two voices is a grouping bug upstream.
|
|
90
|
+
export function geminiSpeechBody(req) {
|
|
91
|
+
const voices = [...new Set((req.dialogue ?? []).map((line) => line.voiceId))];
|
|
92
|
+
if (voices.length > geminiDialogueVoices)
|
|
93
|
+
throw providerError({
|
|
94
|
+
kind: "unsupported",
|
|
95
|
+
message: internalError(`a Gemini narration request had ${String(voices.length)} voices; Gemini takes at most two`),
|
|
96
|
+
});
|
|
97
|
+
const prebuilt = (voiceName) => ({ prebuiltVoiceConfig: { voiceName } });
|
|
98
|
+
const two = req.dialogue !== undefined && voices.length === geminiDialogueVoices;
|
|
99
|
+
const text = req.dialogue === undefined
|
|
100
|
+
? req.text
|
|
101
|
+
: two
|
|
102
|
+
? conversation(req.dialogue, voices)
|
|
103
|
+
: req.dialogue.map((line) => line.text).join("\n");
|
|
104
|
+
return {
|
|
105
|
+
contents: [{ parts: [{ text }] }],
|
|
106
|
+
generationConfig: {
|
|
107
|
+
responseModalities: ["AUDIO"],
|
|
108
|
+
speechConfig: two
|
|
109
|
+
? {
|
|
110
|
+
multiSpeakerVoiceConfig: {
|
|
111
|
+
speakerVoiceConfigs: voices.map((voice) => ({
|
|
112
|
+
speaker: voice,
|
|
113
|
+
voiceConfig: prebuilt(voice),
|
|
114
|
+
})),
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
: { voiceConfig: prebuilt(req.dialogue?.[0]?.voiceId ?? req.voiceId) },
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function conversation(lines, voices) {
|
|
122
|
+
return [
|
|
123
|
+
`TTS the following conversation between ${voices.join(" and ")}:`,
|
|
124
|
+
...lines.map((line) => `${line.voiceId}: ${line.text.replace(/\s*\n\s*/g, " ")}`),
|
|
125
|
+
].join("\n");
|
|
126
|
+
}
|
|
127
|
+
function audioOf(text) {
|
|
128
|
+
const parsed = answer.safeParse(safeJson(text));
|
|
129
|
+
if (!parsed.success)
|
|
130
|
+
throw providerError({ kind: "other", message: unreadable("Google") });
|
|
131
|
+
const blocked = parsed.data.promptFeedback?.blockReason;
|
|
132
|
+
if (blocked)
|
|
133
|
+
throw providerError({
|
|
134
|
+
kind: "refusal",
|
|
135
|
+
message: providerSaid("Google", "refused to speak this narration under its content rules", blocked, "Reword that part of the script or article, or choose another voice provider under Speakers (Edit project → Providers)."),
|
|
136
|
+
});
|
|
137
|
+
for (const candidate of parsed.data.candidates ?? [])
|
|
138
|
+
for (const part of candidate.content?.parts ?? []) {
|
|
139
|
+
const data = part.inlineData?.data;
|
|
140
|
+
if (data) {
|
|
141
|
+
const rate = /rate=(\d+)/.exec(part.inlineData?.mimeType ?? "")?.[1];
|
|
142
|
+
return {
|
|
143
|
+
pcm: new Uint8Array(Buffer.from(data, "base64")),
|
|
144
|
+
sampleRate: rate === undefined ? defaultSampleRate : Number(rate),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
throw providerError({ kind: "other", message: noAudio("Google") });
|
|
149
|
+
}
|
|
150
|
+
function streamOf(bytes) {
|
|
151
|
+
return new ReadableStream({
|
|
152
|
+
start(controller) {
|
|
153
|
+
controller.enqueue(bytes);
|
|
154
|
+
controller.close();
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
function keyOf(deps) {
|
|
159
|
+
// `missing_key`, not `auth`, because that rule makes it terminal.
|
|
160
|
+
const key = deps.key();
|
|
161
|
+
if (key === undefined || key === "") {
|
|
162
|
+
throw providerError({ kind: "missing_key", message: missingKey("Google") });
|
|
163
|
+
}
|
|
164
|
+
return key;
|
|
165
|
+
}
|
|
166
|
+
function kindOf(status) {
|
|
167
|
+
if (status === 401 || status === 403)
|
|
168
|
+
return "auth";
|
|
169
|
+
if (status === 429)
|
|
170
|
+
return "rate_limit";
|
|
171
|
+
if (status >= 500)
|
|
172
|
+
return "dropped";
|
|
173
|
+
return "other";
|
|
174
|
+
}
|
|
175
|
+
async function failure(response, voiceId, next) {
|
|
176
|
+
const text = await response.text().catch(() => "");
|
|
177
|
+
const parsed = errorBody.safeParse(safeJson(text));
|
|
178
|
+
// Through the redactor: an error body may quote the key back.
|
|
179
|
+
const detail = redact((parsed.success ? parsed.data.error.message : undefined) ??
|
|
180
|
+
(text.trim() || response.statusText));
|
|
181
|
+
// Google answers an unknown key with 400 API_KEY_INVALID rather than 401.
|
|
182
|
+
const badKey = response.status === 400 && text.includes("API_KEY_INVALID");
|
|
183
|
+
const status = badKey ? 401 : response.status;
|
|
184
|
+
const retryAfterMs = retryAfter(response.headers.get("retry-after"));
|
|
185
|
+
return providerError({
|
|
186
|
+
kind: badKey ? "auth" : kindOf(response.status),
|
|
187
|
+
message: httpFailure({
|
|
188
|
+
provider: "Google",
|
|
189
|
+
status,
|
|
190
|
+
detail,
|
|
191
|
+
next,
|
|
192
|
+
...(voiceId === undefined
|
|
193
|
+
? {}
|
|
194
|
+
: { subject: `narration request for voice "${voiceId}"`, fix: voiceFix(voiceId) }),
|
|
195
|
+
}),
|
|
196
|
+
...(retryAfterMs === undefined ? {} : { retryAfterMs }),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
function safeJson(text) {
|
|
200
|
+
try {
|
|
201
|
+
return JSON.parse(text);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { providerError } from "../../kernel/ports/model.js";
|
|
3
|
+
// Raw 16-bit mono PCM in on stdin, MP3 out on stdout, through the app's own ffmpeg. The bitrate
|
|
4
|
+
// is the one the narration join re-encodes at (`slices/narration/concat.ts`).
|
|
5
|
+
export function pcmToMp3Args(sampleRate) {
|
|
6
|
+
return [
|
|
7
|
+
"-hide_banner",
|
|
8
|
+
"-nostdin",
|
|
9
|
+
"-loglevel",
|
|
10
|
+
"error",
|
|
11
|
+
"-f",
|
|
12
|
+
"s16le",
|
|
13
|
+
"-ar",
|
|
14
|
+
String(sampleRate),
|
|
15
|
+
"-ac",
|
|
16
|
+
"1",
|
|
17
|
+
"-i",
|
|
18
|
+
"pipe:0",
|
|
19
|
+
"-c:a",
|
|
20
|
+
"libmp3lame",
|
|
21
|
+
"-b:a",
|
|
22
|
+
"128k",
|
|
23
|
+
"-f",
|
|
24
|
+
"mp3",
|
|
25
|
+
"pipe:1",
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
export function ffmpegPcmToMp3(bin) {
|
|
29
|
+
return (pcm, sampleRate, signal) => new Promise((resolve, reject) => {
|
|
30
|
+
const child = spawn(bin, pcmToMp3Args(sampleRate), {
|
|
31
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
32
|
+
windowsHide: true,
|
|
33
|
+
signal,
|
|
34
|
+
});
|
|
35
|
+
const out = [];
|
|
36
|
+
let err = "";
|
|
37
|
+
child.stdout.on("data", (chunk) => out.push(chunk));
|
|
38
|
+
child.stderr.on("data", (chunk) => {
|
|
39
|
+
err = `${err}${chunk.toString("utf8")}`.slice(-2000);
|
|
40
|
+
});
|
|
41
|
+
child.on("error", (error) => {
|
|
42
|
+
reject(signal.aborted
|
|
43
|
+
? error
|
|
44
|
+
: providerError({
|
|
45
|
+
kind: "other",
|
|
46
|
+
message: `Slopify could not turn Google's audio into MP3 because ffmpeg did not start (${error.message}). Restart Slopify; if it keeps happening, use Download diagnostics in Settings and report it.`,
|
|
47
|
+
}));
|
|
48
|
+
});
|
|
49
|
+
child.on("close", (code) => {
|
|
50
|
+
if (code === 0)
|
|
51
|
+
resolve(new Uint8Array(Buffer.concat(out)));
|
|
52
|
+
else if (!signal.aborted)
|
|
53
|
+
reject(providerError({
|
|
54
|
+
kind: "other",
|
|
55
|
+
message: `Slopify could not turn Google's audio into MP3 (ffmpeg: ${err.trim() || `exit ${String(code)}`}). Use Try again; if it keeps happening, use Download diagnostics in Settings and report it.`,
|
|
56
|
+
}));
|
|
57
|
+
});
|
|
58
|
+
// A closed stdin (ffmpeg gave up early) is reported by the close handler.
|
|
59
|
+
child.stdin.on("error", () => { });
|
|
60
|
+
child.stdin.end(Buffer.from(pcm));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { providerError } from "../../kernel/ports/model.js";
|
|
4
|
+
import { defaultSpeechVoice, noSpeechFix, primaryLanguage, } from "../../kernel/ports/system-speech.js";
|
|
5
|
+
export const systemVoiceId = "system-voice";
|
|
6
|
+
// Windows speaks through System.Speech. Every input arrives in an environment variable, so no
|
|
7
|
+
// part of the text or the voice name is ever parsed as PowerShell.
|
|
8
|
+
export const windowsSpeakScript = "Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; try { $s.SelectVoice($env:SLOPIFY_SPEECH_VOICE); $s.SetOutputToWaveFile($env:SLOPIFY_SPEECH_OUT); $s.Speak([System.IO.File]::ReadAllText($env:SLOPIFY_SPEECH_TEXT, [System.Text.Encoding]::UTF8)) } finally { $s.Dispose() }";
|
|
9
|
+
// The program's command for one request. Text goes through a file (or stdin for Piper, an
|
|
10
|
+
// argument for Pico, which takes nothing else), so a line starting with "-" is never a flag.
|
|
11
|
+
export function speakCommand(engine, voice, files, text, env) {
|
|
12
|
+
switch (engine.id) {
|
|
13
|
+
case "say":
|
|
14
|
+
return { binary: "say", args: ["-v", voice.id, "-o", files.out, "-f", files.text] };
|
|
15
|
+
case "sapi":
|
|
16
|
+
return {
|
|
17
|
+
binary: "powershell",
|
|
18
|
+
args: ["-NoProfile", "-NonInteractive", "-Command", windowsSpeakScript],
|
|
19
|
+
env: {
|
|
20
|
+
...env,
|
|
21
|
+
SLOPIFY_SPEECH_VOICE: voice.id,
|
|
22
|
+
SLOPIFY_SPEECH_OUT: files.out,
|
|
23
|
+
SLOPIFY_SPEECH_TEXT: files.text,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
case "piper":
|
|
27
|
+
return {
|
|
28
|
+
binary: "piper",
|
|
29
|
+
args: ["--model", voice.id, "--output_file", files.out],
|
|
30
|
+
stdin: text,
|
|
31
|
+
};
|
|
32
|
+
case "pico2wave":
|
|
33
|
+
return { binary: "pico2wave", args: ["-l", voice.id, "-w", files.out, "--", text] };
|
|
34
|
+
case "espeak-ng":
|
|
35
|
+
case "espeak":
|
|
36
|
+
return { binary: engine.id, args: ["-v", voice.id, "-w", files.out, "-f", files.text] };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function systemTts(deps) {
|
|
40
|
+
const engineFor = async (model) => {
|
|
41
|
+
const found = await deps.detect();
|
|
42
|
+
const engine = model === undefined || model === ""
|
|
43
|
+
? found.engines[0]
|
|
44
|
+
: found.engines.find((one) => one.id === model);
|
|
45
|
+
if (engine !== undefined)
|
|
46
|
+
return engine;
|
|
47
|
+
throw providerError({
|
|
48
|
+
kind: "unavailable",
|
|
49
|
+
message: found.engines.length === 0
|
|
50
|
+
? (found.issue ?? `No speech program was found on this computer, ${noSpeechFix}`)
|
|
51
|
+
: `The speech program "${model}" chosen for this narration isn't on this computer any more. In the Edit tab, choose Edit project and pick another System voice model (${found.engines.map((one) => one.name).join(", ")}) under Providers, then use Continue the run.`,
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
id: systemVoiceId,
|
|
56
|
+
capabilities: { streams: false },
|
|
57
|
+
models: async () => (await deps.detect()).engines.map((engine) => ({ id: engine.id, name: engine.name })),
|
|
58
|
+
voiceLanguages: async (voiceId) => {
|
|
59
|
+
try {
|
|
60
|
+
const found = await deps.detect();
|
|
61
|
+
for (const engine of found.engines) {
|
|
62
|
+
const voice = engine.voices.find((one) => one.id === voiceId);
|
|
63
|
+
const language = voice === undefined ? undefined : primaryLanguage(voice);
|
|
64
|
+
if (language !== undefined)
|
|
65
|
+
return [language];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Never throws: an unknown answer is undefined.
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
},
|
|
73
|
+
synthesize: async (req) => {
|
|
74
|
+
const engine = await engineFor(req.model);
|
|
75
|
+
const voice = engine.voices.find((one) => one.id === req.voiceId) ??
|
|
76
|
+
(req.voiceId === "" || req.voiceId === "default" ? defaultSpeechVoice(engine) : undefined);
|
|
77
|
+
if (voice === undefined)
|
|
78
|
+
throw providerError({
|
|
79
|
+
kind: "unsupported",
|
|
80
|
+
message: `The system voice "${req.voiceId}" isn't installed for ${engine.name} on this computer. Pick one it has in Settings → Voices (Add voice, provider System voice), then choose it for this project and use Continue the run.`,
|
|
81
|
+
});
|
|
82
|
+
if (deps.ffmpeg === undefined)
|
|
83
|
+
throw providerError({
|
|
84
|
+
kind: "unavailable",
|
|
85
|
+
message: "Slopify's ffmpeg isn't ready, so the system voice's recording can't be converted. Restart Slopify and try again.",
|
|
86
|
+
});
|
|
87
|
+
const dir = await mkdtemp(join(deps.tempRoot, "slopify-speech-"));
|
|
88
|
+
try {
|
|
89
|
+
const files = {
|
|
90
|
+
text: join(dir, "text.txt"),
|
|
91
|
+
out: join(dir, engine.id === "say" ? "speech.aiff" : "speech.wav"),
|
|
92
|
+
};
|
|
93
|
+
await writeFile(files.text, req.text, "utf8");
|
|
94
|
+
await runToEnd(deps.run, speakCommand(engine, voice, files, req.text, deps.env), req.signal, engine.name);
|
|
95
|
+
const mp3 = join(dir, "speech.mp3");
|
|
96
|
+
await runToEnd(deps.run, {
|
|
97
|
+
binary: deps.ffmpeg,
|
|
98
|
+
args: [
|
|
99
|
+
"-hide_banner",
|
|
100
|
+
"-loglevel",
|
|
101
|
+
"error",
|
|
102
|
+
"-y",
|
|
103
|
+
"-i",
|
|
104
|
+
files.out,
|
|
105
|
+
"-codec:a",
|
|
106
|
+
"libmp3lame",
|
|
107
|
+
"-q:a",
|
|
108
|
+
"4",
|
|
109
|
+
mp3,
|
|
110
|
+
],
|
|
111
|
+
}, req.signal, "ffmpeg");
|
|
112
|
+
const bytes = new Uint8Array(await readFile(mp3));
|
|
113
|
+
return { audio: streamOf(bytes), container: "mp3" };
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
await rm(dir, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
async function runToEnd(run, command, signal, label) {
|
|
122
|
+
const child = run(command.binary, command.args, signal, {
|
|
123
|
+
...(command.stdin === undefined ? {} : { stdin: command.stdin }),
|
|
124
|
+
...(command.env === undefined ? {} : { env: { ...command.env } }),
|
|
125
|
+
});
|
|
126
|
+
// Drained so a chatty program never blocks on a full pipe.
|
|
127
|
+
for await (const _ of child.stdout) {
|
|
128
|
+
// nothing to keep
|
|
129
|
+
}
|
|
130
|
+
const ended = await child.ended;
|
|
131
|
+
signal.throwIfAborted();
|
|
132
|
+
if (ended.error !== null || ended.code !== 0) {
|
|
133
|
+
const said = child.stderr().trim().split(/\r?\n/).slice(-3).join(" ");
|
|
134
|
+
throw providerError({
|
|
135
|
+
kind: "other",
|
|
136
|
+
message: `The system voice (${label}) couldn't make this narration${said === "" ? (ended.error === null ? "" : `: ${ended.error.message}`) : `: ${said}`}. Try another system voice in Settings → Voices, ${noSpeechFix}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function streamOf(bytes) {
|
|
141
|
+
return new ReadableStream({
|
|
142
|
+
start(controller) {
|
|
143
|
+
controller.enqueue(bytes);
|
|
144
|
+
controller.close();
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
package/dist/assets/models.yaml
CHANGED
|
@@ -14,6 +14,8 @@ providers:
|
|
|
14
14
|
maxConcurrent: 3
|
|
15
15
|
inworld:
|
|
16
16
|
maxConcurrent: 5
|
|
17
|
+
google-tts:
|
|
18
|
+
maxConcurrent: 3
|
|
17
19
|
fal:
|
|
18
20
|
maxConcurrent: 3
|
|
19
21
|
replicate:
|
|
@@ -435,3 +437,39 @@ tts:
|
|
|
435
437
|
tts:
|
|
436
438
|
maxCharacters: 4096
|
|
437
439
|
streaming: true
|
|
440
|
+
- provider: google-tts
|
|
441
|
+
id: gemini-2.5-flash-preview-tts
|
|
442
|
+
name: Gemini 2.5 Flash TTS (preview)
|
|
443
|
+
source: https://ai.google.dev/gemini-api/docs/speech-generation
|
|
444
|
+
keywords:
|
|
445
|
+
- tts
|
|
446
|
+
pricing:
|
|
447
|
+
note: Billed per text token in and audio token out on your Gemini API key; estimate
|
|
448
|
+
unavailable.
|
|
449
|
+
tts:
|
|
450
|
+
maxCharacters: 4000
|
|
451
|
+
streaming: false
|
|
452
|
+
- provider: google-tts
|
|
453
|
+
id: gemini-2.5-pro-preview-tts
|
|
454
|
+
name: Gemini 2.5 Pro TTS (preview)
|
|
455
|
+
source: https://ai.google.dev/gemini-api/docs/speech-generation
|
|
456
|
+
keywords:
|
|
457
|
+
- tts
|
|
458
|
+
pricing:
|
|
459
|
+
note: Billed per text token in and audio token out on your Gemini API key; estimate
|
|
460
|
+
unavailable.
|
|
461
|
+
tts:
|
|
462
|
+
maxCharacters: 4000
|
|
463
|
+
streaming: false
|
|
464
|
+
- provider: google-tts
|
|
465
|
+
id: gemini-3.1-flash-tts-preview
|
|
466
|
+
name: Gemini 3.1 Flash TTS (preview)
|
|
467
|
+
source: https://ai.google.dev/gemini-api/docs/speech-generation
|
|
468
|
+
keywords:
|
|
469
|
+
- tts
|
|
470
|
+
pricing:
|
|
471
|
+
note: Billed per text token in and audio token out on your Gemini API key; estimate
|
|
472
|
+
unavailable.
|
|
473
|
+
tts:
|
|
474
|
+
maxCharacters: 4000
|
|
475
|
+
streaming: false
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|