@gtanczyk/diorama 0.0.0-stage → 0.1.0
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/LICENSE +202 -0
- package/README.md +157 -2
- package/app/bin/diorama.mjs +13 -0
- package/app/src/actions/budget.ts +152 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +248 -0
- package/app/src/actions/depth.ts +95 -0
- package/app/src/actions/extend.ts +36 -0
- package/app/src/actions/faces.ts +160 -0
- package/app/src/actions/feedback.ts +87 -0
- package/app/src/actions/fix.ts +382 -0
- package/app/src/actions/history.ts +93 -0
- package/app/src/actions/idea-preview.ts +65 -0
- package/app/src/actions/idea-review.ts +102 -0
- package/app/src/actions/idea-sketch.ts +172 -0
- package/app/src/actions/index.ts +31 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -0
- package/app/src/actions/models.ts +64 -0
- package/app/src/actions/motion.ts +119 -0
- package/app/src/actions/pictures.ts +49 -0
- package/app/src/actions/reactions.ts +36 -0
- package/app/src/actions/registry.ts +101 -0
- package/app/src/actions/share.ts +32 -0
- package/app/src/actions/sound.ts +73 -0
- package/app/src/actions/start.ts +154 -0
- package/app/src/actions/step-work.ts +150 -0
- package/app/src/actions/story.ts +101 -0
- package/app/src/actions/studio.ts +133 -0
- package/app/src/actions/style.ts +72 -0
- package/app/src/actions/video.ts +46 -0
- package/app/src/actions/view.ts +109 -0
- package/app/src/actions/weather.ts +38 -0
- package/app/src/actions/world.ts +289 -0
- package/app/src/commands/action.ts +39 -0
- package/app/src/commands/backdrop.ts +188 -0
- package/app/src/commands/browsers.ts +21 -0
- package/app/src/commands/build.ts +503 -0
- package/app/src/commands/cast.ts +142 -0
- package/app/src/commands/clip.ts +431 -0
- package/app/src/commands/derive.ts +167 -0
- package/app/src/commands/doctor.ts +74 -0
- package/app/src/commands/explore.ts +123 -0
- package/app/src/commands/export.ts +142 -0
- package/app/src/commands/extend.ts +75 -0
- package/app/src/commands/faces.ts +80 -0
- package/app/src/commands/icons.ts +69 -0
- package/app/src/commands/index.ts +36 -0
- package/app/src/commands/layout.ts +31 -0
- package/app/src/commands/masks.ts +117 -0
- package/app/src/commands/mcp.ts +52 -0
- package/app/src/commands/model.ts +284 -0
- package/app/src/commands/paint.ts +182 -0
- package/app/src/commands/plates.ts +97 -0
- package/app/src/commands/play.ts +192 -0
- package/app/src/commands/playthrough.ts +111 -0
- package/app/src/commands/publish.ts +78 -0
- package/app/src/commands/reactions.ts +40 -0
- package/app/src/commands/reel.ts +46 -0
- package/app/src/commands/setup.ts +85 -0
- package/app/src/commands/sound.ts +90 -0
- package/app/src/commands/sprites.ts +232 -0
- package/app/src/commands/story.ts +37 -0
- package/app/src/commands/studio.ts +65 -0
- package/app/src/commands/style.ts +87 -0
- package/app/src/commands/verify.ts +45 -0
- package/app/src/commands/voice-retry.ts +14 -0
- package/app/src/commands/voices.ts +145 -0
- package/app/src/commands/weather.ts +52 -0
- package/app/src/commands/world.ts +104 -0
- package/app/src/film/clip.ts +386 -0
- package/app/src/film/reel.ts +126 -0
- package/app/src/film/screencast.ts +123 -0
- package/app/src/image/mask.ts +29 -0
- package/app/src/image/overlay.ts +38 -0
- package/app/src/image/raster.ts +224 -0
- package/app/src/main.ts +28 -0
- package/app/src/meta/choice.ts +27 -0
- package/app/src/meta/http.ts +99 -0
- package/app/src/meta/memo.ts +91 -0
- package/app/src/meta/muse.ts +70 -0
- package/app/src/meta/openrouter.ts +93 -0
- package/app/src/meta/prices.ts +25 -0
- package/app/src/meta/sam.ts +83 -0
- package/app/src/meta/schema.ts +63 -0
- package/app/src/meta/spark.ts +220 -0
- package/app/src/meta/step.ts +14 -0
- package/app/src/meta/voice-file.ts +78 -0
- package/app/src/meta/voice.ts +103 -0
- package/app/src/model/animation.ts +38 -0
- package/app/src/model/blender/relax.py +284 -0
- package/app/src/model/blender.ts +115 -0
- package/app/src/model/build.ts +50 -0
- package/app/src/model/face.ts +21 -0
- package/app/src/model/fit.ts +181 -0
- package/app/src/model/glb.ts +241 -0
- package/app/src/model/plan.ts +151 -0
- package/app/src/model/quality.ts +48 -0
- package/app/src/model/space.ts +168 -0
- package/app/src/model/sprites.ts +293 -0
- package/app/src/model/views.ts +163 -0
- package/app/src/play/creature.ts +43 -0
- package/app/src/play/depth-occlusion.ts +150 -0
- package/app/src/play/exit-outline.ts +48 -0
- package/app/src/play/face3d.ts +34 -0
- package/app/src/play/focus.ts +78 -0
- package/app/src/play/geometry.ts +272 -0
- package/app/src/play/gestures.ts +23 -0
- package/app/src/play/glow.ts +187 -0
- package/app/src/play/lipsync.ts +69 -0
- package/app/src/play/living.ts +83 -0
- package/app/src/play/outline-path.ts +130 -0
- package/app/src/play/parallax.ts +200 -0
- package/app/src/play/path.ts +262 -0
- package/app/src/play/preview.ts +71 -0
- package/app/src/play/reactions.ts +92 -0
- package/app/src/play/rig.ts +172 -0
- package/app/src/play/shadow.ts +253 -0
- package/app/src/play/sound.ts +51 -0
- package/app/src/play/weather.ts +58 -0
- package/app/src/qa/explore.ts +136 -0
- package/app/src/qa/probe.ts +139 -0
- package/app/src/qa/route.ts +118 -0
- package/app/src/registry.ts +23 -0
- package/app/src/scene/animation-source.ts +36 -0
- package/app/src/scene/assets.ts +37 -0
- package/app/src/scene/autofix.ts +133 -0
- package/app/src/scene/backdrop.ts +326 -0
- package/app/src/scene/blockin.ts +189 -0
- package/app/src/scene/brief.ts +74 -0
- package/app/src/scene/carry.ts +177 -0
- package/app/src/scene/cast-fixes.ts +31 -0
- package/app/src/scene/cast.ts +192 -0
- package/app/src/scene/corrections.ts +164 -0
- package/app/src/scene/critic.ts +169 -0
- package/app/src/scene/depthmap.ts +149 -0
- package/app/src/scene/derive.ts +475 -0
- package/app/src/scene/diagnose.ts +132 -0
- package/app/src/scene/face-calibration.ts +65 -0
- package/app/src/scene/face-registration.ts +21 -0
- package/app/src/scene/face-work.ts +71 -0
- package/app/src/scene/faces-data.ts +48 -0
- package/app/src/scene/faces-settings.ts +20 -0
- package/app/src/scene/faces.ts +435 -0
- package/app/src/scene/glows.ts +153 -0
- package/app/src/scene/held.ts +55 -0
- package/app/src/scene/icons.ts +185 -0
- package/app/src/scene/layers.ts +822 -0
- package/app/src/scene/layout.ts +216 -0
- package/app/src/scene/locate.ts +118 -0
- package/app/src/scene/maskcheck.ts +43 -0
- package/app/src/scene/masks.ts +392 -0
- package/app/src/scene/occluders.ts +123 -0
- package/app/src/scene/occlusion-check.ts +138 -0
- package/app/src/scene/paint-layout.ts +135 -0
- package/app/src/scene/paint.ts +81 -0
- package/app/src/scene/photo.ts +123 -0
- package/app/src/scene/plates.ts +256 -0
- package/app/src/scene/playbook.ts +121 -0
- package/app/src/scene/preview.ts +23 -0
- package/app/src/scene/reactions.ts +118 -0
- package/app/src/scene/restyle.ts +53 -0
- package/app/src/scene/rig-check.ts +68 -0
- package/app/src/scene/rig.ts +375 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +207 -0
- package/app/src/scene/stairs.ts +183 -0
- package/app/src/scene/status.ts +32 -0
- package/app/src/scene/verify.ts +156 -0
- package/app/src/scene/walk.ts +326 -0
- package/app/src/scene/warp.ts +417 -0
- package/app/src/scene/weather.ts +118 -0
- package/app/src/scene/words.ts +65 -0
- package/app/src/secrets.ts +219 -0
- package/app/src/setup/checks.ts +110 -0
- package/app/src/sound/cost.ts +74 -0
- package/app/src/sound/data.ts +89 -0
- package/app/src/sound/fixes.ts +23 -0
- package/app/src/sound/issues.ts +21 -0
- package/app/src/sound/language.ts +11 -0
- package/app/src/sound/lipsync-make.ts +98 -0
- package/app/src/sound/lipsync.ts +24 -0
- package/app/src/sound/make.ts +229 -0
- package/app/src/sound/musicgen.py +20 -0
- package/app/src/sound/pcm.ts +280 -0
- package/app/src/sound/plan.ts +154 -0
- package/app/src/sound/retry.ts +43 -0
- package/app/src/sound/settings.ts +28 -0
- package/app/src/sound/sources.ts +81 -0
- package/app/src/sound/spoken.ts +70 -0
- package/app/src/sound/voices.ts +579 -0
- package/app/src/speech/transcribe.ts +38 -0
- package/app/src/speech/types.ts +18 -0
- package/app/src/story/check.ts +230 -0
- package/app/src/story/design.ts +108 -0
- package/app/src/story/engine.ts +233 -0
- package/app/src/story/fixes.ts +54 -0
- package/app/src/story/npc-guidance.ts +7 -0
- package/app/src/story/phrases.ts +4 -0
- package/app/src/story/schema.ts +92 -0
- package/app/src/studio/agent.ts +554 -0
- package/app/src/studio/discovery.ts +28 -0
- package/app/src/studio/events.ts +48 -0
- package/app/src/studio/history.ts +124 -0
- package/app/src/studio/jobs.ts +291 -0
- package/app/src/studio/locks.ts +33 -0
- package/app/src/studio/mcp.ts +86 -0
- package/app/src/studio/operation.ts +18 -0
- package/app/src/studio/repair.ts +31 -0
- package/app/src/studio/server.ts +421 -0
- package/app/src/studio/trouble.ts +48 -0
- package/app/src/world/design.ts +72 -0
- package/app/src/world/drawings.ts +198 -0
- package/app/src/world/extend-stage.ts +109 -0
- package/app/src/world/extend.ts +123 -0
- package/app/src/world/extension-progress.ts +10 -0
- package/app/src/world/files.ts +62 -0
- package/app/src/world/idea-review.ts +32 -0
- package/app/src/world/photo-limits.ts +2 -0
- package/app/src/world/pool.ts +41 -0
- package/app/src/world/schedule.ts +64 -0
- package/app/src/world/schema.ts +74 -0
- package/app/src/world/sketch.ts +81 -0
- package/app/src/world/style-plan.ts +90 -0
- package/app/src/world/style-reference.ts +16 -0
- package/app/src/world/styled.ts +51 -0
- package/app/src/world/told.ts +105 -0
- package/app/src/world/video-limits.ts +3 -0
- package/app/src/world/video.ts +98 -0
- package/app/studio/activity.tsx +124 -0
- package/app/studio/agent.tsx +384 -0
- package/app/studio/api.ts +111 -0
- package/app/studio/atelier.tsx +234 -0
- package/app/studio/audio-missing.tsx +25 -0
- package/app/studio/audio-setup.tsx +33 -0
- package/app/studio/blender-setup.tsx +30 -0
- package/app/studio/build.tsx +677 -0
- package/app/studio/camera.tsx +59 -0
- package/app/studio/cast-work.tsx +207 -0
- package/app/studio/cast.tsx +178 -0
- package/app/studio/concept-edit.tsx +47 -0
- package/app/studio/creator-idea.tsx +46 -0
- package/app/studio/custom-look.tsx +36 -0
- package/app/studio/faces-setup.tsx +28 -0
- package/app/studio/favicon.svg +11 -0
- package/app/studio/figure.tsx +314 -0
- package/app/studio/fonts/OFL.txt +93 -0
- package/app/studio/fonts/fonts.css +7 -0
- package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
- package/app/studio/icons.tsx +42 -0
- package/app/studio/idea-card.tsx +42 -0
- package/app/studio/idea-review.tsx +181 -0
- package/app/studio/idea-storyboard.tsx +134 -0
- package/app/studio/index.html +15 -0
- package/app/studio/job-words.ts +33 -0
- package/app/studio/lightbox.tsx +49 -0
- package/app/studio/main.tsx +1328 -0
- package/app/studio/nav.ts +14 -0
- package/app/studio/place.tsx +264 -0
- package/app/studio/review-sketch.tsx +47 -0
- package/app/studio/say-it.tsx +49 -0
- package/app/studio/step-work.tsx +134 -0
- package/app/studio/story.tsx +394 -0
- package/app/studio/style-menu.tsx +81 -0
- package/app/studio/style.css +1390 -0
- package/app/studio/sure.tsx +54 -0
- package/app/studio/tip.tsx +74 -0
- package/app/studio/tsconfig.json +5 -0
- package/app/studio/video.tsx +73 -0
- package/app/studio/world.tsx +188 -0
- package/app/tsconfig.json +13 -0
- package/app/web/audio.ts +315 -0
- package/app/web/face.ts +131 -0
- package/app/web/face3d.ts +35 -0
- package/app/web/glows.ts +387 -0
- package/app/web/hero3d.ts +728 -0
- package/app/web/index.html +118 -0
- package/app/web/living.ts +404 -0
- package/app/web/main.ts +3751 -0
- package/app/web/pad.ts +174 -0
- package/app/web/reactions.ts +79 -0
- package/app/web/sheet.ts +36 -0
- package/app/web/synth.ts +140 -0
- package/app/web/tsconfig.json +5 -0
- package/app/web/ui.ts +263 -0
- package/app/web/weather.ts +306 -0
- package/app/web/words.ts +12 -0
- package/docs/characters.md +58 -0
- package/docs/npc-motion.md +69 -0
- package/docs/scenes.md +75 -0
- package/docs/setup.md +60 -0
- package/docs/sound.md +23 -0
- package/docs/studio.md +62 -0
- package/package.json +70 -4
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
// Spoken lines: Spark casts a voice for the narrator, the hero and every NPC from how they look and talk,
|
|
2
|
+
// ElevenLabs reads each line, and Muse Voice Transcribe listens back: a take that does not say its line is read again.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { randomInt } from 'node:crypto';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { trackOperation } from '../studio/operation.ts';
|
|
8
|
+
import { inputHash, type Memo } from '../meta/memo.ts';
|
|
9
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
10
|
+
import { transcribe } from '../meta/voice.ts';
|
|
11
|
+
import { quickTranscribe } from '../meta/voice-file.ts';
|
|
12
|
+
import { speakerOf } from '../story/engine.ts';
|
|
13
|
+
import { goodbye } from '../story/phrases.ts';
|
|
14
|
+
import type { Story } from '../story/schema.ts';
|
|
15
|
+
import type { World } from '../world/schema.ts';
|
|
16
|
+
import { decode, encodeMp3, normalise, pitchHz, pitchShift, audioProblem, RATE, seconds, trim, type Pcm } from './pcm.ts';
|
|
17
|
+
import { checkCredits, FINAL_TTS, lineTakes, noteRefusal, sharedDir, ttsModel } from './cost.ts';
|
|
18
|
+
import { elevenKey } from './sources.ts';
|
|
19
|
+
import { languageKey } from './language.ts';
|
|
20
|
+
import { numberWords, spokenText } from './spoken.ts';
|
|
21
|
+
|
|
22
|
+
export const VOICE_PIPELINE = 2;
|
|
23
|
+
|
|
24
|
+
export const NARRATOR = 'narrator';
|
|
25
|
+
|
|
26
|
+
export type Line = { speaker: string; text: string; previousText?: string; nextText?: string };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The lines to speak and who says them: conversations (the NPC's lines, the hero's answers) and the narrator's
|
|
30
|
+
* intro and endings. The hero's remarks on things stay text only unless `all`: a game that talks at every click tires.
|
|
31
|
+
*/
|
|
32
|
+
export function storyLines(world: World, story: Story, all = false): Line[] {
|
|
33
|
+
const lines: Line[] = [{ speaker: NARRATOR, text: story.intro }];
|
|
34
|
+
// A person's own talk lines (a fisherman who answers when spoken to) are spoken like a conversation.
|
|
35
|
+
for (const i of story.interactions) if (speakerOf(world, i) !== world.hero.key) lines.push({ speaker: speakerOf(world, i), text: i.say });
|
|
36
|
+
if (all) {
|
|
37
|
+
for (const i of story.interactions) if (speakerOf(world, i) === world.hero.key) lines.push({ speaker: world.hero.key, text: i.say });
|
|
38
|
+
for (const l of story.locks) lines.push({ speaker: world.hero.key, text: l.say });
|
|
39
|
+
for (const it of story.items) if (it.nope) lines.push({ speaker: world.hero.key, text: it.nope });
|
|
40
|
+
}
|
|
41
|
+
for (const d of story.dialogues) {
|
|
42
|
+
for (const l of d.lines) {
|
|
43
|
+
const incoming = d.lines.flatMap((n) => n.choices.filter((c) => c.next && c.next === l.key).map((c) => c.text));
|
|
44
|
+
const previous = [...new Set(incoming)];
|
|
45
|
+
lines.push({ speaker: d.npc, text: l.says, ...(previous.length === 1 && { previousText: previous[0] }) });
|
|
46
|
+
for (const c of l.choices) {
|
|
47
|
+
const next = c.next ? d.lines.find((n) => n.key === c.next)?.says : undefined;
|
|
48
|
+
lines.push({ speaker: world.hero.key, text: c.text, previousText: l.says, ...(next && { nextText: next }) });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (story.dialogues.length) lines.push({ speaker: world.hero.key, text: goodbye(world.language) });
|
|
53
|
+
for (const e of story.endings) lines.push({ speaker: NARRATOR, text: e.text });
|
|
54
|
+
const unique = new Map<string, Line>();
|
|
55
|
+
for (const line of lines) {
|
|
56
|
+
const key = JSON.stringify([line.speaker, line.text]);
|
|
57
|
+
const prior = unique.get(key);
|
|
58
|
+
if (!prior) unique.set(key, { ...line });
|
|
59
|
+
else for (const field of ['previousText', 'nextText'] as const) {
|
|
60
|
+
// A repeated sentence with ambiguous branches must not inherit a different branch's context.
|
|
61
|
+
if (prior[field] !== line[field]) delete prior[field];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return [...unique.values()];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const AGES = ['child', 'teen', 'young', 'middle_aged', 'old'] as const;
|
|
68
|
+
export const Profile = z.object({
|
|
69
|
+
gender: z.enum(['male', 'female', 'any']),
|
|
70
|
+
age: z.enum(AGES),
|
|
71
|
+
/** How they should sound, in a few words ("bright, quick, cheeky"). */
|
|
72
|
+
sound: z.string().default(''),
|
|
73
|
+
language: z.string().optional(),
|
|
74
|
+
});
|
|
75
|
+
export type Profile = z.infer<typeof Profile>;
|
|
76
|
+
|
|
77
|
+
export const Cast = z.record(z.string(), z.object({
|
|
78
|
+
voice: z.string(),
|
|
79
|
+
why: z.string().default(''),
|
|
80
|
+
/** ElevenLabs voice settings: lower stability is more expressive, style exaggerates the delivery. */
|
|
81
|
+
stability: z.number().min(0).max(1).default(0.45),
|
|
82
|
+
style: z.number().min(0).max(1).default(0.2),
|
|
83
|
+
speed: z.number().min(0.7).max(1.2).default(1),
|
|
84
|
+
/** Who Spark saw in the figure; the voice was picked to match it. Missing on casts made before profiles. */
|
|
85
|
+
profile: Profile.optional(),
|
|
86
|
+
/** Semitones the takes are raised. Only a stand-in: raising an adult voice sounds sped up, not young. */
|
|
87
|
+
pitch: z.number().default(0),
|
|
88
|
+
warnings: z.array(z.string()).optional(),
|
|
89
|
+
/** No substitute is spoken while the required design or voice slot is unavailable. */
|
|
90
|
+
pending: z.string().optional(),
|
|
91
|
+
/**
|
|
92
|
+
* A voice made for this character by ElevenLabs Voice Design (voice is then DESIGNED): no stock voice is a child or
|
|
93
|
+
* truly old. `generated` is the design ElevenLabs returned; the voice is created from it only while lines are read.
|
|
94
|
+
*/
|
|
95
|
+
design: z.object({ description: z.string(), generated: z.string(), hz: z.number().optional(), profileKey: z.string().optional(), request: z.string().optional() }).optional(),
|
|
96
|
+
}));
|
|
97
|
+
export type Cast = z.infer<typeof Cast>;
|
|
98
|
+
|
|
99
|
+
export const VoiceLine = z.object({
|
|
100
|
+
speaker: z.string(),
|
|
101
|
+
text: z.string(),
|
|
102
|
+
file: z.string(),
|
|
103
|
+
seconds: z.number(),
|
|
104
|
+
/** What Muse Voice Transcribe heard, and how close that is to the line (heardMatch). */
|
|
105
|
+
heard: z.string(),
|
|
106
|
+
match: z.number(),
|
|
107
|
+
flags: z.array(z.string()).default([]),
|
|
108
|
+
});
|
|
109
|
+
export const Voices = z.object({ pipeline: z.number().optional(), castingFrom: z.string().optional(), cast: Cast, lines: z.array(VoiceLine), failures: z.array(z.object({ speaker: z.string(), text: z.string(), reason: z.string() })).default([]) });
|
|
110
|
+
export type Voices = z.infer<typeof Voices>;
|
|
111
|
+
|
|
112
|
+
export type Voice = { voice_id: string; name: string; category?: string; labels?: Record<string, string>; description?: string };
|
|
113
|
+
|
|
114
|
+
async function eleven(route: string, init?: RequestInit): Promise<Response> {
|
|
115
|
+
if (init?.method === 'POST') checkCredits();
|
|
116
|
+
const operation = route.startsWith('/text-to-speech/') ? 'speech response headers' : route.startsWith('/text-to-voice') ? 'voice design response headers' : route.startsWith('/voices') ? 'voices response headers' : 'API response headers';
|
|
117
|
+
return trackOperation('elevenlabs', operation, async () => {
|
|
118
|
+
for (let n = 1; ; n++) {
|
|
119
|
+
const res = await fetch(`https://api.elevenlabs.io/v1${route}`, { ...init, headers: { 'xi-api-key': elevenKey() ?? '', 'Content-Type': 'application/json' } });
|
|
120
|
+
if (res.ok) return res;
|
|
121
|
+
const text = (await res.text()).slice(0, 300);
|
|
122
|
+
noteRefusal(res.status, text);
|
|
123
|
+
if (![429, 500, 502, 503].includes(res.status) || n === 4) throw new Error(`ElevenLabs ${route}: ${res.status} ${text}`);
|
|
124
|
+
await new Promise((r) => setTimeout(r, 1000 * 2 ** n));
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The voices to cast from: the stock voices every account has, and labelled voices in all languages on this account
|
|
131
|
+
* (from the voice library or made before) that say their gender and age. Using them costs nothing extra.
|
|
132
|
+
*/
|
|
133
|
+
export async function stockVoices(): Promise<Voice[]> {
|
|
134
|
+
return trackOperation('elevenlabs', 'load available voices', async () => {
|
|
135
|
+
const body = (await (await eleven('/voices')).json()) as { voices: Voice[] };
|
|
136
|
+
return body.voices.filter((v) => v.category === 'premade' || (!v.name.startsWith('diorama ') && !!v.labels?.gender && !!v.labels?.age));
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The voice id of a character whose voice is designed for them rather than picked from stock. */
|
|
141
|
+
export const DESIGNED = 'design';
|
|
142
|
+
export const PENDING_DESIGN = 'pending-design';
|
|
143
|
+
|
|
144
|
+
/** Who stock voices cannot play: every stock voice is an adult, and the old ones are mature narrators, not old people. */
|
|
145
|
+
export const needsDesign = (p: Profile) => p.age === 'child' || p.age === 'teen' || p.age === 'old';
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What Voice Design is asked for, best first. ElevenLabs refuses descriptions that name a child, a girl or a boy, or an
|
|
149
|
+
* age in years. Describe a youthful animated performance without forcing a squeak or changing the language.
|
|
150
|
+
*/
|
|
151
|
+
export function designDescriptions(p: Profile): string[] {
|
|
152
|
+
const sound = p.sound.replace(/\b(child(ren|ish|like)?|kid(s|dy|die)?|girl(s|ish)?|boy(s|ish)?|little|young(ster)?|tiny|baby|teen(age(r|d)?)?|\d+[- ]?(years?|yo)[- ]?(old)?|year[- ]old|aged? \d+)\b/gi, '')
|
|
153
|
+
.replace(/\s+/g, ' ').replace(/\s*,\s*(,\s*)*/g, ', ').replace(/^[\s,]+|[\s,]+$/g, '');
|
|
154
|
+
const tail = sound ? ` Persona and delivery: ${sound}.` : '';
|
|
155
|
+
const sex = p.gender === 'female' ? 'female' : 'male';
|
|
156
|
+
// The last rung drops the persona: ElevenLabs refuses some words in it (blocked_generation), and a voice of the
|
|
157
|
+
// right age without the persona is better than none.
|
|
158
|
+
const ladder = (base: string, fallback: string) => [...[base, fallback].map((text) => `Native ${p.language ?? 'English'} speaker. Studio-quality clean recording. ${text}.${tail} Natural conversational intonation, clear diction, comfortable pitch, no forced squeak.`),
|
|
159
|
+
`Native ${p.language ?? 'English'} speaker. Studio-quality clean recording. ${fallback}. Natural conversational intonation, clear diction.`];
|
|
160
|
+
if (p.age === 'child') return ladder(`Youthful ${sex} character voice for an animated adventure game, warm and expressive`, `Warm cheerful animated ${sex} voice, clear and expressive`);
|
|
161
|
+
if (p.age === 'teen') return ladder(`Youthful ${sex} character voice for an animated adventure game, not yet adult, light`, `Youthful cheerful cartoon ${sex === 'female' ? 'heroine' : 'hero'} voice, light and eager.`);
|
|
162
|
+
if (p.age === 'old') return ladder(`A very old ${p.gender === 'female' ? 'woman' : 'man'} in their late seventies, aged weathered voice, slower and a little creaky`, `An elderly ${p.gender === 'female' ? 'woman' : 'man'}, aged thin voice, slow.`);
|
|
163
|
+
return ladder(`A ${p.age.replace('_', '-')} ${p.gender === 'female' ? 'woman' : 'man'}`, `A clear warm ${sex} voice.`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A design belongs to a language and a specific performance brief, not just gender and age. */
|
|
167
|
+
export const profileKey = (p: Profile) => inputHash('voice-design-v2', languageKey(p.language), p.gender, p.age, p.sound.trim().toLowerCase());
|
|
168
|
+
|
|
169
|
+
export function previewText(says: string[]): string {
|
|
170
|
+
const sample = says.join(' ').trim();
|
|
171
|
+
if (!sample) throw new Error('voice design needs sample dialogue in the story language');
|
|
172
|
+
let text = sample;
|
|
173
|
+
while (text.length < 100) text += ` ${sample}`;
|
|
174
|
+
return text.length <= 1000 ? text : text.slice(0, 1000).replace(/\s+\S*$/, '');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Preview selection checks intelligibility and recording integrity; it does not claim to judge acting. */
|
|
178
|
+
export async function designVoice(memo: Memo, p: Profile, says: string[], description?: string): Promise<NonNullable<Cast[string]['design']>> {
|
|
179
|
+
return trackOperation('voice-design', 'create and validate voice', () => prepareVoice(memo, p, says, description));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function prepareVoice(memo: Memo, p: Profile, says: string[], description?: string): Promise<NonNullable<Cast[string]['design']>> {
|
|
183
|
+
const text = spokenText(previewText(says), p.language);
|
|
184
|
+
const tries = [...new Set(description ? [`Native ${p.language ?? 'English'} speaker. ${description}`, ...designDescriptions(p).slice(1)] : designDescriptions(p))];
|
|
185
|
+
let last: unknown;
|
|
186
|
+
for (const d of tries) {
|
|
187
|
+
try {
|
|
188
|
+
// Keep the paid previews until validation finishes; a transcriber outage must not buy a new design.
|
|
189
|
+
const pendingFile = path.join(memo.dir, 'voice-design', `${inputHash(elevenKey() ?? '', d, text)}.json`);
|
|
190
|
+
let body: { previews: Array<{ generated_voice_id: string; audio_base_64: string; language?: string }> };
|
|
191
|
+
if (fs.existsSync(pendingFile)) body = JSON.parse(fs.readFileSync(pendingFile, 'utf8'));
|
|
192
|
+
else {
|
|
193
|
+
const res = await eleven('/text-to-voice/design', { method: 'POST', body: JSON.stringify({ voice_description: d, model_id: 'eleven_multilingual_ttv_v2', text, auto_generate_text: false }) });
|
|
194
|
+
body = await res.json() as typeof body;
|
|
195
|
+
fs.mkdirSync(path.dirname(pendingFile), { recursive: true });
|
|
196
|
+
fs.writeFileSync(pendingFile, JSON.stringify(body));
|
|
197
|
+
}
|
|
198
|
+
let best: { generated: string; hz: number; match: number } | undefined;
|
|
199
|
+
for (const v of body.previews ?? []) {
|
|
200
|
+
if (v.language && languageKey(v.language) !== languageKey(p.language)) continue;
|
|
201
|
+
const pcm = decode(Buffer.from(v.audio_base_64, 'base64'));
|
|
202
|
+
if (audioProblem(pcm)) continue;
|
|
203
|
+
let heard: string | undefined;
|
|
204
|
+
// Transcribe owns the bounded retry/backoff. Do not multiply it with another retry loop here.
|
|
205
|
+
let match: number;
|
|
206
|
+
try { ({ heard, match } = await listenBack(memo, `voice-preview:${v.generated_voice_id}`, pcm24k(pcm), text, p.language)); }
|
|
207
|
+
catch (error) { throw new Error(`Voice Design validation pending: ${error instanceof Error ? error.message : error}`); }
|
|
208
|
+
if (!heardOk(text, match)) continue;
|
|
209
|
+
// Pitch is diagnostic only. Highest pitch is not evidence of a convincing character.
|
|
210
|
+
if (!best || match > best.match) best = { generated: v.generated_voice_id, hz: pitchHz(pcm), match };
|
|
211
|
+
}
|
|
212
|
+
if (best) {
|
|
213
|
+
const design = { description: d, generated: best.generated, hz: best.hz, profileKey: description ? inputHash(profileKey(p), description) : profileKey(p), ...(description && { request: description }) };
|
|
214
|
+
keepDesign(p, design);
|
|
215
|
+
fs.rmSync(pendingFile, { force: true });
|
|
216
|
+
return design;
|
|
217
|
+
}
|
|
218
|
+
fs.rmSync(pendingFile, { force: true });
|
|
219
|
+
last = new Error('no intelligible, clean preview in the story language');
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error instanceof Error && error.message.startsWith('Voice Design validation pending:')) throw error;
|
|
222
|
+
last = error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
throw new Error(`Voice Design failed: ${last instanceof Error ? last.message : last}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Resume saved previews for pending actors without recasting the actors whose voices already work. */
|
|
229
|
+
export async function resumePendingVoices(memo: Memo, cast: Cast, lines: Line[]): Promise<Cast> {
|
|
230
|
+
const next = { ...cast };
|
|
231
|
+
for (const [key, voice] of Object.entries(cast)) {
|
|
232
|
+
if (voice.voice !== PENDING_DESIGN || !voice.profile) continue;
|
|
233
|
+
try {
|
|
234
|
+
const design = await designVoice(memo, voice.profile, lines.filter((l) => l.speaker === key).map((l) => l.text));
|
|
235
|
+
next[key] = { ...voice, voice: DESIGNED, design, pending: undefined, why: design.description };
|
|
236
|
+
} catch (error) {
|
|
237
|
+
next[key] = { ...voice, pending: error instanceof Error ? error.message : String(error) };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return next;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Whether the ElevenLabs account can hold one more voice (a designed voice needs it while its lines are read). */
|
|
244
|
+
export async function freeVoiceSlot(): Promise<boolean> {
|
|
245
|
+
try {
|
|
246
|
+
const sub = (await (await eleven('/user/subscription')).json()) as { voice_slots_used?: number; voice_limit?: number };
|
|
247
|
+
return sub.voice_limit === undefined || (sub.voice_slots_used ?? 0) < sub.voice_limit;
|
|
248
|
+
} catch {
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
type DesignKept = NonNullable<Cast[string]['design']> & { gender: Profile['gender']; age: Profile['age'] };
|
|
254
|
+
const designsFile = () => sharedDir('voices', 'designs.json');
|
|
255
|
+
|
|
256
|
+
/** Voices designed before, by any world on this machine: a design is kept by ElevenLabs and can be made into a voice again. */
|
|
257
|
+
export function keptDesigns(): DesignKept[] {
|
|
258
|
+
try {
|
|
259
|
+
return JSON.parse(fs.readFileSync(designsFile(), 'utf8')) as DesignKept[];
|
|
260
|
+
} catch {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function keepDesign(p: Profile, d: NonNullable<Cast[string]['design']>) {
|
|
266
|
+
const all = keptDesigns().filter((k) => k.generated !== d.generated);
|
|
267
|
+
fs.mkdirSync(path.dirname(designsFile()), { recursive: true });
|
|
268
|
+
fs.writeFileSync(designsFile(), JSON.stringify([...all, { ...d, gender: p.gender, age: p.age }], null, 1));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** A kept design of the same gender and age that this cast does not use yet: reused instead of designing (and paying) again. */
|
|
272
|
+
export function reusableDesign(p: Profile, taken: Set<string>): NonNullable<Cast[string]['design']> | undefined {
|
|
273
|
+
const k = keptDesigns().find((d) => d.profileKey === profileKey(p) && !taken.has(d.generated));
|
|
274
|
+
return k && { description: k.description, generated: k.generated, profileKey: k.profileKey, ...(k.hz && { hz: k.hz }) };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Designed voices exist on the account only while their lines are read: a voice is created from its design when a
|
|
279
|
+
* line needs it, and deleted by `close`, so a world takes no voice slot. ElevenLabs allows few voices per account.
|
|
280
|
+
*/
|
|
281
|
+
export class DesignedVoices {
|
|
282
|
+
private made = new Map<string, Promise<string>>();
|
|
283
|
+
constructor(private readonly tag: string) {}
|
|
284
|
+
|
|
285
|
+
/** The live voice id for a design, created on first use. */
|
|
286
|
+
idFor(design: NonNullable<Cast[string]['design']>): Promise<string> {
|
|
287
|
+
let id = this.made.get(design.generated);
|
|
288
|
+
if (!id) {
|
|
289
|
+
id = (async () => {
|
|
290
|
+
const res = await eleven('/text-to-voice', { method: 'POST', body: JSON.stringify({ voice_name: `${this.tag} ${design.generated.slice(0, 6)}`, voice_description: design.description, generated_voice_id: design.generated }) });
|
|
291
|
+
return ((await res.json()) as { voice_id: string }).voice_id;
|
|
292
|
+
})();
|
|
293
|
+
this.made.set(design.generated, id);
|
|
294
|
+
}
|
|
295
|
+
return id;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Deletes the voices this run created, and any a crashed run of the same world left behind. */
|
|
299
|
+
async close() {
|
|
300
|
+
const ids = new Set((await Promise.allSettled(this.made.values())).flatMap((r) => (r.status === 'fulfilled' ? [r.value] : [])));
|
|
301
|
+
try {
|
|
302
|
+
const all = (await (await eleven('/voices')).json()) as { voices: Voice[] };
|
|
303
|
+
for (const v of all.voices) if (v.name.startsWith(`${this.tag} `)) ids.add(v.voice_id);
|
|
304
|
+
} catch { /* listing is best effort */ }
|
|
305
|
+
await Promise.allSettled([...ids].map((id) => eleven(`/voices/${id}`, { method: 'DELETE' })));
|
|
306
|
+
this.made.clear();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const ageOf = (v: Voice) => AGES.indexOf((v.labels?.age as Profile['age']) ?? 'middle_aged');
|
|
310
|
+
|
|
311
|
+
/** How far a stock voice is from who should speak: a wrong gender rules it out, then age steps apart. */
|
|
312
|
+
export function voiceDistance(v: Voice, p: Profile): number {
|
|
313
|
+
const gender = v.labels?.gender;
|
|
314
|
+
if (p.gender !== 'any' && gender && gender !== 'neutral' && gender !== p.gender) return Infinity;
|
|
315
|
+
const age = ageOf(v);
|
|
316
|
+
const languagePenalty = p.language ? (!v.labels?.language ? 10 : languageKey(v.labels.language) === languageKey(p.language) ? 0 : 20) : 0;
|
|
317
|
+
return languagePenalty + Math.abs((age < 0 ? 3 : age) - Math.max(AGES.indexOf(p.age), AGES.indexOf('young')));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The voices that fit a profile: right gender, nearest in age (a child gets the youngest voices there are). */
|
|
321
|
+
export function voiceCandidates(voices: Voice[], p: Profile): Voice[] {
|
|
322
|
+
const scored = voices.map((v) => ({ v, d: voiceDistance(v, p) })).filter((x) => Number.isFinite(x.d));
|
|
323
|
+
const best = Math.min(...scored.map((x) => x.d));
|
|
324
|
+
return scored.filter((x) => x.d <= Math.max(best, 0)).map((x) => x.v);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export type Speaker = { key: string; looks: string; figure?: Buffer; says?: string[] };
|
|
328
|
+
|
|
329
|
+
/** Spark looks at the figure (and reads its description): gender, age and how they should sound. */
|
|
330
|
+
export async function profileOf(memo: Memo, world: World, who: Speaker): Promise<Profile> {
|
|
331
|
+
if (who.key === NARRATOR) return { gender: 'any', age: 'middle_aged', sound: `a warm, natural storyteller for: ${world.style}`, language: world.language ?? 'English' };
|
|
332
|
+
const hero = who.key === world.hero.key;
|
|
333
|
+
const prompt = `This is a character of a 2D adventure game${who.figure ? ' (the picture shows them)' : ''}.
|
|
334
|
+
Game: ${world.title}. ${world.pitch}
|
|
335
|
+
Spoken language: ${world.language ?? 'English'}. Give each character a distinct temperament and natural delivery. Do not infer that every animal or small drawing must sound like a squeaky baby. Avoid toy-robot or exaggerated pitch unless the character is explicitly a robot or the author requested it.
|
|
336
|
+
Character key: ${who.key}${hero ? ' (the hero the player plays)' : ''}
|
|
337
|
+
Description: ${who.looks}
|
|
338
|
+
${who.says?.length ? `They say: ${who.says.map((l) => `"${l}"`).join(' ')}\n` : ''}
|
|
339
|
+
Who are they, as far as their voice goes? The key, the description, the game text and how they talk decide gender when they say it (a "girl", "he", a name); otherwise judge by the picture. Judge age by the face and body${who.figure ? ' in the picture' : ''} and the text: a child is under 14 (a boy, a girl, a kid, an errand boy, a small figure next to the adults), a teen 14-19 with a grown body. When unsure between child and teen, say child: an adult-sounding boy is worse than a young-sounding one.
|
|
340
|
+
Reply with JSON only: {"gender": "male | female", "age": "child | teen | young | middle_aged | old", "sound": "how their voice should sound, a few words"}`;
|
|
341
|
+
return { ...Profile.parse(await askSparkJson(memo, { schema: Profile, label: `voice-profile:${who.key}`, prompt, images: who.figure ? [who.figure] : [] })), language: world.language ?? 'English' };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Casts a stock voice for each speaker. Spark sees each figure and says who they are; code keeps only the voices
|
|
346
|
+
* of that gender and age; Spark picks among those for temperament; code checks the pick and falls back to the
|
|
347
|
+
* nearest unused voice. Children, teens and the old get a voice designed for them (no stock voice fits them).
|
|
348
|
+
*/
|
|
349
|
+
export async function castVoices(memo: Memo, world: World, lines: Line[], voices: Voice[], speakers: Speaker[]): Promise<Cast> {
|
|
350
|
+
const keys = [...new Set(lines.map((l) => l.speaker))];
|
|
351
|
+
const byKey = new Map(speakers.map((s) => [s.key, s]));
|
|
352
|
+
const profiles = Object.fromEntries(await Promise.all(keys.map(async (k) => [k, await profileOf(memo, world, { ...(byKey.get(k) ?? { key: k, looks: k }), says: lines.filter((l) => l.speaker === k).slice(0, 4).map((l) => l.text) })] as const)));
|
|
353
|
+
const wantsDesign = (k: string) => needsDesign(profiles[k]) || !voiceCandidates(voices, profiles[k]).some((v) => v.labels?.language && languageKey(v.labels.language) === languageKey(profiles[k].language));
|
|
354
|
+
const taken = new Set<string>();
|
|
355
|
+
const reused = Object.fromEntries(keys.filter(wantsDesign).flatMap((k) => {
|
|
356
|
+
const d = reusableDesign(profiles[k], taken);
|
|
357
|
+
if (d) taken.add(d.generated);
|
|
358
|
+
return d ? [[k, d] as const] : [];
|
|
359
|
+
}));
|
|
360
|
+
const designErrors: Record<string, string> = {};
|
|
361
|
+
const designs = Object.fromEntries(await Promise.all(keys.filter(wantsDesign).map(async (k) => {
|
|
362
|
+
if (reused[k]) return [k, reused[k]] as const;
|
|
363
|
+
try {
|
|
364
|
+
return [k, await designVoice(memo, profiles[k], lines.filter((l) => l.speaker === k).map((l) => l.text))] as const;
|
|
365
|
+
} catch (error) {
|
|
366
|
+
designErrors[k] = error instanceof Error ? error.message : String(error);
|
|
367
|
+
console.log(` ${k}: ${designErrors[k]}; voice remains pending`);
|
|
368
|
+
return [k, undefined] as const;
|
|
369
|
+
}
|
|
370
|
+
})));
|
|
371
|
+
const stock = keys.filter((k) => !wantsDesign(k));
|
|
372
|
+
const candidates = Object.fromEntries(keys.map((k) => [k, voiceCandidates(voices, profiles[k])]));
|
|
373
|
+
const sample = (key: string) => lines.filter((l) => l.speaker === key).slice(0, 2).map((l) => `"${l.text}"`).join(' ');
|
|
374
|
+
const describe = (v: Voice) => `${v.voice_id}: ${v.name}; ${Object.values(v.labels ?? {}).join(', ')}; ${v.description ?? ''}`;
|
|
375
|
+
const prompt = `You cast voice actors for a 2D painted point-and-click adventure: ${world.title}. ${world.pitch}
|
|
376
|
+
|
|
377
|
+
For each character pick ONE voice from that character's own list (the lists already match their gender and age). Pick by temperament and how they talk. Give different characters different voices when you can. The spoken language is ${world.language ?? 'English'}. Prefer voices native in that language. Set "stability" 0.3-0.7, "style" 0-0.5 and "speed" 0.85-1.0 to suit their personality (a storybook pace a child can follow: never faster than normal; a narrator 0.85-0.92), avoiding exaggerated cartoon delivery.
|
|
378
|
+
|
|
379
|
+
${stock.map((k) => `## ${k}: ${profiles[k].age.replace('_', ' ')} ${profiles[k].gender === 'any' ? '' : profiles[k].gender}, ${profiles[k].sound}. Says: ${sample(k)}
|
|
380
|
+
${candidates[k].map((v) => `- ${describe(v)}`).join('\n')}`).join('\n\n')}
|
|
381
|
+
|
|
382
|
+
Reply with JSON only: {"<character key>": {"voice": "<voice id>", "why": "few words", "stability": 0.45, "style": 0.2}}`;
|
|
383
|
+
const picked = Cast.safeParse(stock.length ? await askSparkJson<unknown>(memo, { schema: Cast, label: 'cast-voices', prompt }).catch(() => ({})) : {});
|
|
384
|
+
const cast: Cast = {};
|
|
385
|
+
const used = new Set<string>();
|
|
386
|
+
for (const k of keys) {
|
|
387
|
+
const p = profiles[k];
|
|
388
|
+
const design = designs[k];
|
|
389
|
+
if (design) {
|
|
390
|
+
cast[k] = { voice: DESIGNED, why: design.description, stability: 0.45, style: 0.2, speed: 1, profile: p, pitch: 0, design };
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (wantsDesign(k)) {
|
|
394
|
+
cast[k] = { voice: PENDING_DESIGN, why: 'Voice Design pending', stability: 0.45, style: 0.2, speed: 1, profile: p, pitch: 0, pending: designErrors[k] ?? 'Voice Design pending' };
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
const allowed = candidates[k];
|
|
398
|
+
let choice = picked.success ? picked.data[k] : undefined;
|
|
399
|
+
// A pick outside the character's list is not trusted: the nearest unused voice that fits takes its place.
|
|
400
|
+
if (!choice || !allowed.some((v) => v.voice_id === choice!.voice)) {
|
|
401
|
+
const v = allowed.find((x) => !used.has(x.voice_id)) ?? allowed[0];
|
|
402
|
+
if (!v) throw new Error(`no eligible voice for ${k} (${p.language}, ${p.gender})`);
|
|
403
|
+
choice = { voice: v.voice_id, why: `nearest ${p.age.replace('_', ' ')} ${p.gender} voice`, stability: 0.45, style: 0.2, speed: 1, pitch: 0 };
|
|
404
|
+
}
|
|
405
|
+
used.add(choice.voice);
|
|
406
|
+
const selected = voices.find((v) => v.voice_id === choice.voice);
|
|
407
|
+
const warnings = [
|
|
408
|
+
...(wantsDesign(k) ? ['Voice Design unavailable; a stock actor stands in without pitch shifting'] : []),
|
|
409
|
+
...(languageKey(selected?.labels?.language ?? 'unknown') !== languageKey(p.language) ? [`native ${p.language} voice not confirmed; audition this actor`] : []),
|
|
410
|
+
];
|
|
411
|
+
cast[k] = { ...choice, profile: p, pitch: 0, warnings };
|
|
412
|
+
}
|
|
413
|
+
return cast;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const letters = (s: string) => s.normalize('NFC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* How much of the line was heard: 1 - letter edit distance / letters in the line. Letters, not words, so
|
|
420
|
+
* "trap door" for "trapdoor" or "harbor" for "harbour" cost little and a skipped or garbled phrase costs a lot.
|
|
421
|
+
*/
|
|
422
|
+
export function heardMatch(line: string, heard: string): number {
|
|
423
|
+
const a = letters(line);
|
|
424
|
+
const b = letters(heard);
|
|
425
|
+
if (!a.length) return 1;
|
|
426
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
427
|
+
for (let i = 1; i <= a.length; i++) {
|
|
428
|
+
const row = [i];
|
|
429
|
+
for (let j = 1; j <= b.length; j++) row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
430
|
+
prev = row;
|
|
431
|
+
}
|
|
432
|
+
return Math.max(0, 1 - prev[b.length] / a.length);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* heardMatch on what is said, not what is written: "10+10=20" is compared as "dziesięć plus dziesięć równa się
|
|
437
|
+
* dwadzieścia", and a transcript that writes digits is read as words too. Numbers must be the same numbers: a take
|
|
438
|
+
* that says another number than the line fails however close the letters are.
|
|
439
|
+
*/
|
|
440
|
+
export function spokenMatch(line: string, heard: string, language?: string): number {
|
|
441
|
+
// Laughs, hoots and snores in a line ("Hu-hu... dobranoc... Chrrr.") are acted, but the transcriber writes them
|
|
442
|
+
// down rarely and never the same way: 4 lines of one world were rejected after 3 takes for them alone. Only the
|
|
443
|
+
// words are compared. A word written twice in a row that the line has once is a transcriber stutter.
|
|
444
|
+
const said = spokenText(withoutSounds(line), language);
|
|
445
|
+
const raw = spokenText(withoutSounds(heard), language);
|
|
446
|
+
const got = [raw, withoutStutter(raw, said)];
|
|
447
|
+
const want = numberWords(said, language);
|
|
448
|
+
const best = Math.max(...got.map((g) => heardMatch(said, g)));
|
|
449
|
+
if (want.length && got.every((g) => want.join(' ') !== numberWords(g, language).join(' '))) return Math.min(best, 0.5);
|
|
450
|
+
return best;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** The text without words that are only a sound (see soundsOnly); a line of sounds alone stays as it is. */
|
|
454
|
+
export function withoutSounds(text: string): string {
|
|
455
|
+
const kept = text.split(/\s+/).filter((w) => {
|
|
456
|
+
const parts = w.normalize('NFC').toLowerCase().split(/[^\p{L}]+/u).filter(Boolean);
|
|
457
|
+
return !parts.length || !parts.every((p) => p.length > 1 && SOUND.test(p));
|
|
458
|
+
});
|
|
459
|
+
return kept.some((w) => /\p{L}/u.test(w)) ? kept.join(' ') : text;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Drops a word repeated right after itself, unless the line itself repeats it there. */
|
|
463
|
+
function withoutStutter(heard: string, said: string): string {
|
|
464
|
+
const key = (w: string) => w.normalize('NFC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
465
|
+
const lineWords = said.split(/\s+/).map(key);
|
|
466
|
+
const doubled = new Set(lineWords.filter((w, i) => w && w === lineWords[i - 1]));
|
|
467
|
+
const words = heard.split(/\s+/);
|
|
468
|
+
return words.filter((w, i) => !(i > 0 && key(w) && key(w) === key(words[i - 1]) && !doubled.has(key(w)))).join(' ');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** 16-bit mono PCM at 24 kHz, what the transcriber takes. */
|
|
472
|
+
export function pcm24k(pcm: Pcm): Buffer {
|
|
473
|
+
const step = RATE / 24000;
|
|
474
|
+
const n = Math.floor(pcm.left.length / step);
|
|
475
|
+
const out = Buffer.alloc(n * 2);
|
|
476
|
+
for (let i = 0; i < n; i++) {
|
|
477
|
+
const k = Math.floor(i * step);
|
|
478
|
+
const v = Math.max(-1, Math.min(1, (pcm.left[k] + pcm.right[k]) / 2));
|
|
479
|
+
out.writeInt16LE(Math.round(v * 32767), i * 2);
|
|
480
|
+
}
|
|
481
|
+
return out;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Context stays in API fields, never in the spoken text. Multilingual v2 has no language_code support. */
|
|
485
|
+
export function speechRequest(line: Line, voice: Cast[string], model: string, take: number) {
|
|
486
|
+
const language = languageKey(voice.profile?.language);
|
|
487
|
+
// The actor reads the spoken form: "10+10=20" as words, in the line's language.
|
|
488
|
+
return { text: spokenText(line.text, voice.profile?.language), model_id: model,
|
|
489
|
+
voice_settings: { stability: voice.stability, similarity_boost: 0.8, style: voice.style, speed: voice.speed ?? 1, use_speaker_boost: true },
|
|
490
|
+
...(model !== FINAL_TTS && /^[a-z]{2}$/.test(language) && { language_code: language }),
|
|
491
|
+
...(line.previousText && { previous_text: line.previousText }), ...(line.nextText && { next_text: line.nextText }),
|
|
492
|
+
...(take > 1 && { seed: 1000 + take }) };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function speak(memo: Memo, label: string, line: Line, voice: Cast[string], take: number, designed?: DesignedVoices, retrySeed?: number): Promise<Buffer> {
|
|
496
|
+
const body = (model: string) => ({ ...speechRequest(line, voice, model, take), ...(retrySeed !== undefined && { seed: retrySeed + take - 1 }) });
|
|
497
|
+
// A designed voice gets a new id each time it is created, so its takes are remembered by the design.
|
|
498
|
+
const hash = (model: string) => inputHash('eleven-tts', voice.design?.generated ?? voice.voice, JSON.stringify(body(model)));
|
|
499
|
+
// A take already read by the better model is used in a draft too, so lowering the quality never pays again.
|
|
500
|
+
const model = [FINAL_TTS, ttsModel()].find((m) => memo.has('voice', hash(m), 'mp3')) ?? ttsModel();
|
|
501
|
+
return memo.remember('voice', label, hash(model), 'mp3', async () => {
|
|
502
|
+
if (voice.design && !designed) throw new Error('a designed voice needs DesignedVoices to speak');
|
|
503
|
+
const id = voice.design ? await designed!.idFor(voice.design) : voice.voice;
|
|
504
|
+
return Buffer.from(await (await eleven(`/text-to-speech/${id}?output_format=mp3_44100_128`, { method: 'POST', body: JSON.stringify(body(model)) })).arrayBuffer());
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Listens to a take: first on the file route (a few at once, 2-3 s a line), and only a take that does not match goes
|
|
510
|
+
* to the realtime route too (one stream at a time, as long as the clip), because the file route now and then writes
|
|
511
|
+
* a word twice. The better hearing counts. Voices of one world were 15 minutes of realtime listening before.
|
|
512
|
+
*/
|
|
513
|
+
export async function listenBack(memo: Memo, label: string, pcm: Buffer, line: string, language?: string): Promise<{ heard: string; match: number }> {
|
|
514
|
+
const spoken = spokenText(line, language);
|
|
515
|
+
let quick: { heard: string; match: number } | undefined;
|
|
516
|
+
try {
|
|
517
|
+
const heard = (await memo.remember('voice', `${label}#file`, inputHash('quick-stt-v1', pcm), 'txt', async () => Buffer.from(await quickTranscribe(pcm), 'utf8'))).toString('utf8');
|
|
518
|
+
quick = { heard, match: spokenMatch(line, heard, language) };
|
|
519
|
+
if (heardOk(spoken, quick.match)) return quick;
|
|
520
|
+
} catch { /* the realtime route below decides */ }
|
|
521
|
+
try {
|
|
522
|
+
const heard = await transcribe(memo, label, pcm);
|
|
523
|
+
const slow = { heard, match: spokenMatch(line, heard, language) };
|
|
524
|
+
return quick && quick.match > slow.match ? quick : slow;
|
|
525
|
+
} catch (error) {
|
|
526
|
+
if (quick) return quick;
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export const lineId = (line: Line) => `line-${inputHash(line.speaker, line.text).slice(0, 10)}`;
|
|
532
|
+
|
|
533
|
+
/** Close enough: 90% of the letters, or at most two letters off, so a short line is not failed for one misheard word ("a" for "of"). */
|
|
534
|
+
export const heardOk = (line: string, match: number) => match >= 0.9 || (match >= 0.7 && (1 - match) * letters(withoutSounds(line)).length <= 2.01);
|
|
535
|
+
|
|
536
|
+
/** Laughs, sighs and calls: no word a transcriber writes down the way the line spells it. */
|
|
537
|
+
const SOUND = /^(?:c?h+r+|(?:h+[aeiouy]+h*)+|(?:[aeiouy]+h+)+|a+w+|o+w+|u+g+h+|e+w+|h+m+|m+h*m*|m+|u+h*m+|e+r+m*|h+u+h+|h+e+h+|p+s+t+|s+h+|t+s+k+|z+|w+h+e+e+|y+a+y+|w+o+w+|o+o+h*|a+r+g+h+|o+j+(?:e+j+)?|a+c+h+|o+c+h+|e+c+h+|u+f+|h+y+h+y+)$/;
|
|
538
|
+
export function soundsOnly(text: string): boolean {
|
|
539
|
+
const words = text.normalize('NFC').toLowerCase().split(/[^\p{L}]+/u).filter(Boolean);
|
|
540
|
+
return words.length > 0 && words.every((w) => SOUND.test(w));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Reads one line, listens back, and reads it again (up to 3 takes, 2 in draft) until the transcript matches. */
|
|
544
|
+
export async function voiceLine(memo: Memo, line: Line, voice: Cast[string], write: (id: string, mp3: Buffer) => string, designed?: DesignedVoices, opts: { retryRejected?: boolean } = {}): Promise<z.infer<typeof VoiceLine>> {
|
|
545
|
+
const id = lineId(line);
|
|
546
|
+
// A rejected batch needs genuinely new seeds/cache keys. Persist the batch before requesting audio:
|
|
547
|
+
// a later ASR outage must resume these paid takes, not buy another batch or return to rejected ones.
|
|
548
|
+
const retryFile = path.join(memo.dir, 'voice-retakes', `${inputHash(voice.design?.generated ?? voice.voice, JSON.stringify(speechRequest(line, voice, ttsModel(), 1)), String(voice.pitch ?? 0))}.json`);
|
|
549
|
+
let retrySeed: number | undefined;
|
|
550
|
+
if (fs.existsSync(retryFile)) {
|
|
551
|
+
retrySeed = JSON.parse(fs.readFileSync(retryFile, 'utf8')).seed;
|
|
552
|
+
if (!Number.isInteger(retrySeed) || retrySeed! < 1 || retrySeed! > 0xfffffffc) throw new Error('invalid saved voice retake seed');
|
|
553
|
+
}
|
|
554
|
+
if (opts.retryRejected) {
|
|
555
|
+
retrySeed = randomInt(1, 0xfffffffc);
|
|
556
|
+
fs.mkdirSync(path.dirname(retryFile), { recursive: true });
|
|
557
|
+
const tmp = `${retryFile}.${process.pid}.tmp`;
|
|
558
|
+
fs.writeFileSync(tmp, JSON.stringify({ seed: retrySeed }));
|
|
559
|
+
fs.renameSync(tmp, retryFile);
|
|
560
|
+
}
|
|
561
|
+
const spoken = spokenText(line.text, voice.profile?.language);
|
|
562
|
+
// A laugh or a sigh ("hee hee", "hmm") is no words to transcribe: the transcriber hears nothing or something else,
|
|
563
|
+
// and three takes of it were rejected. The first clean take is used, flagged.
|
|
564
|
+
const sound = soundsOnly(spoken);
|
|
565
|
+
let best: { pcm: Pcm; heard: string; match: number; take: number } | undefined;
|
|
566
|
+
const takes = lineTakes();
|
|
567
|
+
for (let take = 1; take <= takes; take++) {
|
|
568
|
+
const pcm = pitchShift(trim(decode(await speak(memo, `${id}-${take}`, line, voice, take, designed, retrySeed)), 0.005), voice.pitch ?? 0);
|
|
569
|
+
if (audioProblem(pcm)) continue;
|
|
570
|
+
if (sound) { best = { pcm, heard: '', match: 0, take }; break; }
|
|
571
|
+
const { heard, match } = await listenBack(memo, `${id}-${take}`, pcm24k(pcm), line.text, voice.profile?.language);
|
|
572
|
+
if (!best || match > best.match) best = { pcm, heard, match, take };
|
|
573
|
+
if (heardOk(spoken, match)) break;
|
|
574
|
+
}
|
|
575
|
+
if (!best || !(sound || heardOk(spoken, best.match))) throw new Error(`speech rejected after ${takes} takes: ${best ? `heard ${Math.round(best.match * 100)}% of the line: "${best.heard}"` : 'no clean audible recording'}`);
|
|
576
|
+
const flags = [...(best.take > 1 ? [`take ${best.take} used`] : []), ...(sound ? ['a sound, not words: not checked by transcript'] : [])];
|
|
577
|
+
const pcm = normalise(best!.pcm, -18);
|
|
578
|
+
return { speaker: line.speaker, text: line.text, file: write(id, encodeMp3(pcm, 128)), seconds: Math.round(seconds(pcm) * 100) / 100, heard: best!.heard, match: Math.round(best!.match * 100) / 100, flags };
|
|
579
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Small provider boundary: the video pipeline only knows PCM in, normalized evidence out.
|
|
2
|
+
import { metaFileStt } from '../meta/voice-file.ts';
|
|
3
|
+
import { inputHash, type Memo } from '../meta/memo.ts';
|
|
4
|
+
import { Transcript, type SpeechOptions, type SttProvider } from './types.ts';
|
|
5
|
+
export { type SttProvider, type SpeechOptions, Transcript } from './types.ts';
|
|
6
|
+
export const defaultStt = metaFileStt;
|
|
7
|
+
const inFlight = new Map<string, Promise<Transcript>>();
|
|
8
|
+
const lanes = new Map<string, Promise<unknown>>();
|
|
9
|
+
export async function transcribeFile(memo: Memo, label: string, pcm: Buffer, options: SpeechOptions = {}, provider: SttProvider = defaultStt): Promise<Transcript> {
|
|
10
|
+
const seconds = pcm.length / 48000;
|
|
11
|
+
if (!pcm.length || pcm.length % 2 || seconds > provider.capabilities.maxSeconds) throw new Error(`Invalid PCM or clip exceeds ${provider.id}'s ${provider.capabilities.maxSeconds}s limit`);
|
|
12
|
+
const check = (raw: unknown) => {
|
|
13
|
+
const result = Transcript.parse(raw);
|
|
14
|
+
if (result.provider !== provider.id || result.model !== provider.model) throw new Error('STT provider/model mismatch');
|
|
15
|
+
const level = { none: 0, turn: 1, word: 2 };
|
|
16
|
+
if (level[result.timing] > level[provider.capabilities.timing]) throw new Error('STT claims unsupported timestamp precision');
|
|
17
|
+
if (result.segments.some(s => s.speaker) && (!provider.capabilities.speakers || !result.speakerScope)) throw new Error('STT speaker labels need supported, session-scoped attribution');
|
|
18
|
+
if (result.timing === 'none' && result.segments.length) throw new Error('Untimed STT must not invent segment timestamps');
|
|
19
|
+
if (result.segments.some(s => s.end < s.start || s.end > seconds + .5)) throw new Error('STT timestamp lies outside its clip');
|
|
20
|
+
if (result.timing !== 'none' && result.text.trim() && !result.segments.length) throw new Error('STT claims timestamps without segments');
|
|
21
|
+
return result;
|
|
22
|
+
};
|
|
23
|
+
const hash = inputHash('file-stt-v1', provider.id, provider.model, JSON.stringify(options), pcm);
|
|
24
|
+
const key = `${memo.dir}:${hash}`;
|
|
25
|
+
if (inFlight.has(key)) return inFlight.get(key)!;
|
|
26
|
+
const lane = `${provider.id}:${provider.model}`;
|
|
27
|
+
const run = async () => {
|
|
28
|
+
const saved = await memo.remember('voice', label, hash, 'json', async () => {
|
|
29
|
+
const request = (lanes.get(lane) ?? Promise.resolve()).catch(() => {}).then(() => provider.transcribe(pcm, options));
|
|
30
|
+
lanes.set(lane, request);
|
|
31
|
+
try { return Buffer.from(JSON.stringify(check(await request))); }
|
|
32
|
+
finally { if (lanes.get(lane) === request) lanes.delete(lane); }
|
|
33
|
+
});
|
|
34
|
+
return check(JSON.parse(saved.toString('utf8')));
|
|
35
|
+
};
|
|
36
|
+
const pending = run(); inFlight.set(key, pending);
|
|
37
|
+
try { return await pending; } finally { inFlight.delete(key); }
|
|
38
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const Transcript = z.object({
|
|
3
|
+
text: z.string(),
|
|
4
|
+
provider: z.string(), model: z.string(),
|
|
5
|
+
timing: z.enum(['none', 'turn', 'word']),
|
|
6
|
+
// Speaker labels are scoped to this response, never identities across clips.
|
|
7
|
+
speakerScope: z.string().optional(),
|
|
8
|
+
segments: z.array(z.object({ start: z.number().nonnegative(), end: z.number().nonnegative(), text: z.string(), speaker: z.string().optional() })),
|
|
9
|
+
});
|
|
10
|
+
export type Transcript = z.infer<typeof Transcript>;
|
|
11
|
+
export type SpeechOptions = { languages?: string[]; keywords?: string[] };
|
|
12
|
+
/** File adapters accept mono signed 16-bit PCM at 24 kHz; times in their result are seconds within this clip. */
|
|
13
|
+
export interface SttProvider {
|
|
14
|
+
id: string;
|
|
15
|
+
model: string;
|
|
16
|
+
capabilities: { timing: Transcript['timing']; speakers: boolean; maxSeconds: number };
|
|
17
|
+
transcribe(pcm: Buffer, options: SpeechOptions): Promise<Transcript>;
|
|
18
|
+
}
|