rvlib-shipkit 2.3.0 → 2.4.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +11 -2
  2. package/package.json +1 -1
  3. package/src/assets/kits/identity-kit.ts +5 -2
  4. package/src/cli/cmd_Commands.ts +4 -0
  5. package/src/cli/cmd_Review.ts +36 -10
  6. package/src/cli/cmd_Sound.ts +209 -0
  7. package/src/cli/cmd_Trailer.ts +7 -2
  8. package/src/cli/kit/verb.ts +11 -3
  9. package/src/cli.ts +2 -0
  10. package/src/core/app-icon.ts +10 -6
  11. package/src/repo-config.ts +3 -0
  12. package/src/review/format.ts +11 -3
  13. package/src/review/model.ts +47 -5
  14. package/src/review/project.ts +20 -4
  15. package/src/review/routes.ts +12 -1
  16. package/src/review/store.ts +15 -6
  17. package/src/review/unit.ts +19 -1
  18. package/src/serve/hub-app/admin-svc.ts +1 -1
  19. package/src/serve/hub-app/admin-view.tsx +1 -1
  20. package/src/serve/hub-app/hash-route.ts +12 -4
  21. package/src/serve/hub-app/home-st.ts +7 -1
  22. package/src/serve/hub-app/hub.css +38 -1
  23. package/src/serve/hub-app/icons.tsx +6 -0
  24. package/src/serve/hub-app/layout-st.ts +2 -2
  25. package/src/serve/hub-app/md.ts +32 -0
  26. package/src/serve/hub-app/project-list.css +9 -7
  27. package/src/serve/hub-app/project-list.tsx +5 -2
  28. package/src/serve/hub-app/projects-svc.ts +3 -2
  29. package/src/serve/hub-app/repo-st.ts +22 -7
  30. package/src/serve/hub-app/review/clip-st.ts +57 -7
  31. package/src/serve/hub-app/review/kind-view.tsx +78 -9
  32. package/src/serve/hub-app/review/review-answer.tsx +83 -53
  33. package/src/serve/hub-app/review/review-page.tsx +9 -14
  34. package/src/serve/hub-app/review/review-st.ts +141 -18
  35. package/src/serve/hub-app/review/review-views.tsx +154 -9
  36. package/src/serve/hub-app/review.css +70 -10
  37. package/src/serve/hub-app/wiki-st.ts +190 -0
  38. package/src/serve/hub-app/wikis-view.tsx +134 -5
  39. package/src/serve/hub-runtime.ts +4 -4
  40. package/src/serve/project-gutter-route.ts +25 -1
  41. package/src/serve/project-gutter.ts +1 -1
  42. package/src/serve/serve-hub.ts +7 -0
  43. package/src/shot/client.ts +17 -5
  44. package/src/shot/shooter.ts +19 -3
  45. package/src/shot/shot-ask.ts +9 -1
  46. package/src/shot/stepped-clip.ts +53 -0
  47. package/src/trailer/adapter.ts +33 -4
  48. package/src/trailer/build.ts +170 -29
  49. package/src/trailer/fonts.ts +55 -0
  50. package/src/trailer/footage.ts +4 -4
  51. package/src/trailer/grammar.ts +429 -67
  52. package/src/trailer/graph.ts +130 -56
  53. package/src/trailer/look.ts +3 -0
  54. package/src/trailer/motion.ts +207 -0
  55. package/src/trailer/plan.ts +2 -2
  56. package/src/trailer/prune.ts +13 -2
  57. package/src/trailer/score/compose.ts +18 -2
  58. package/src/trailer/score/gm-render.ts +43 -0
  59. package/src/trailer/score/gm.ts +90 -0
  60. package/src/trailer/score/midi.ts +143 -0
  61. package/src/trailer/score/pattern.ts +13 -6
  62. package/src/trailer/score/score.ts +28 -14
  63. package/src/trailer/score/sound.ts +12 -1
  64. package/src/trailer/score/synth.ts +24 -2
  65. package/src/trailer/sound/audio.ts +148 -0
  66. package/src/trailer/sound/brand.ts +42 -0
  67. package/src/trailer/sound/comfy.ts +38 -0
  68. package/src/trailer/sound/eleven.ts +72 -0
  69. package/src/trailer/sound/fluid.ts +71 -0
  70. package/src/trailer/sound/gen.ts +139 -0
  71. package/src/trailer/sound/index.ts +60 -0
  72. package/src/trailer/sound/presets.ts +69 -0
  73. package/src/trailer/sound/run.ts +189 -0
  74. package/src/trailer/titles.ts +451 -235
  75. package/src/trailer/url.ts +18 -3
  76. package/src/trailer/vocab.ts +12 -1
  77. package/src/trailer/words.ts +29 -8
  78. package/src/wiki/graph-mount.ts +3 -0
  79. package/src/wiki/graph-page.ts +45 -9
  80. package/src/wiki/graph-pages.ts +128 -0
@@ -0,0 +1,42 @@
1
+ // the voices a brand declares for its trailers (`.shipkit/identity/brand.ts`, its `trailer.voice` and
2
+ // `trailer.voices`): an ElevenLabs voice, a clip comfy-ts clones, or a voice comfy-ts designs from words. Pure
3
+ export type TrailerVoice =
4
+ /** ElevenLabs, paid: a voice id of the account or its library */
5
+ | { eleven: string; model?: string; stability?: number; similarity?: number; speed?: number; language?: string }
6
+ /** comfy-ts clones a clean 5 to 10 s clip (repo relative); its transcript, default the text of `<clip>.txt` */
7
+ | { clone: string; transcript?: string; engine?: string; language?: string }
8
+ /** comfy-ts designs the voice once from its description (the reference reads `line`), then clones it, free */
9
+ | { design: string; line?: string; seed?: number; engine?: string; language?: string }
10
+
11
+ export interface TrailerSoundInput {
12
+ /** the comfy-ts module folder the free generators run (its tts-voice-clone, tts-voice-design, stable-audio,
13
+ * ace-step): `comfy-ts run <comfy>/<module>` */
14
+ comfy?: string
15
+ /** argv printing the ElevenLabs key on stdout, as a secret manager read (ELEVENLABS_API_KEY wins); never the key */
16
+ elevenKey?: string[]
17
+ /** the voice every `voice "<line>"` reads */
18
+ voice?: TrailerVoice
19
+ /** more voices, by name, for `voice "<line>" by <name>` */
20
+ voices?: Record<string, TrailerVoice>
21
+ }
22
+
23
+ /** a repo with no voice: the house narrator, designed by comfy-ts */
24
+ export const HOUSE_VOICE: TrailerVoice = {
25
+ design: 'a warm, clear narrator in their thirties, natural and unhurried, close to the microphone, studio quality',
26
+ line: 'Every story starts somewhere. This one starts right here, with you, and it is only getting better.',
27
+ seed: 7,
28
+ }
29
+
30
+ const kinds = (v: TrailerVoice): string[] => ['eleven', 'clone', 'design'].filter((k) => k in v && typeof (v as Record<string, unknown>)[k] === 'string')
31
+
32
+ /** the voice a line reads with: `by` one of the brand's voices, else its voice, else the house narrator */
33
+ export function voiceOf(brand: TrailerSoundInput | undefined, by?: string): TrailerVoice {
34
+ const v = by === undefined ? (brand?.voice ?? HOUSE_VOICE) : brand?.voices?.[by]
35
+ if (!v) {
36
+ const names = Object.keys(brand?.voices ?? {})
37
+ throw new Error(`no voice ${by} in the brand: ${names.length ? `its voices are ${names.join(', ')}` : 'it names none (trailer.voices in .shipkit/identity/brand.ts)'}`)
38
+ }
39
+ const k = kinds(v)
40
+ if (k.length !== 1) throw new Error(`the brand's voice${by ? ` ${by}` : ''} names ${k.length ? k.join(' and ') : 'nothing'}: one of eleven (a voice id), clone (a clip) or design (a description)`)
41
+ return v
42
+ }
@@ -0,0 +1,38 @@
1
+ // a comfy-ts module run on the GPU box: its vars as flags, the file at `out`, its progress on stderr, a loud failure
2
+ // that names the box when it is down. The modules sit in the folder the brand names (`trailer.comfy`)
3
+ import { killGroup } from './run.ts'
4
+
5
+ /** the module `name` of the folder `comfy`, or a loud error when no folder is named */
6
+ export function comfyModule(comfy: string | undefined, name: string): string {
7
+ if (!comfy) throw new Error(`no comfy-ts module folder for ${name}: name it in the brand (trailer.comfy) or pass --comfy <folder>`)
8
+ return `${comfy}/${name}`
9
+ }
10
+
11
+ /** a module run that has not answered in this long is killed, loud */
12
+ const COMFY_TIMEOUT_S = 15 * 60
13
+
14
+ export async function comfyRun(module: string, vars: Record<string, string | number | boolean | undefined>, out: string): Promise<void> {
15
+ const args = ['comfy-ts', 'run', module]
16
+ for (const [k, v] of Object.entries(vars)) if (v !== undefined) args.push(`--${k}`, String(v))
17
+ args.push('--out', out, '--json')
18
+ const p = Bun.spawn(args, { stdout: 'pipe', stderr: 'inherit', detached: true })
19
+ let timedOut = false
20
+ const timer = setTimeout(() => {
21
+ timedOut = true
22
+ killGroup(p.pid)
23
+ }, COMFY_TIMEOUT_S * 1000)
24
+ const text = await new Response(p.stdout).text()
25
+ const code = await p.exited.finally(() => clearTimeout(timer))
26
+ if (timedOut) throw new Error(`comfy-ts run ${module} did not answer in ${COMFY_TIMEOUT_S} s and was killed. Is the GPU box up?`)
27
+ const last = text.trim().split('\n').at(-1) ?? ''
28
+ let res: { ok?: boolean; error?: string } = {}
29
+ try {
30
+ res = JSON.parse(last) as typeof res
31
+ } catch {
32
+ // not json: the tail below says what happened
33
+ }
34
+ if (code !== 0 || res.ok === false) {
35
+ const why = res.error ?? text.trim().split('\n').slice(-3).join(' ')
36
+ throw new Error(`comfy-ts run ${module} failed (exit ${code}): ${why || 'no output'}. Is the GPU box up?`)
37
+ }
38
+ }
@@ -0,0 +1,72 @@
1
+ // ElevenLabs, the paid voice: one line spoken by a voice id, and the account's characters left. The key comes from
2
+ // ELEVENLABS_API_KEY, else from the argv the brand names (`trailer.elevenKey`, a secret manager read), and is never
3
+ // printed
4
+ const API = 'https://api.elevenlabs.io'
5
+ const FORMAT = 'mp3_44100_128'
6
+ export const ELEVEN_MODEL = 'eleven_v3'
7
+
8
+ /** the key: ELEVENLABS_API_KEY, else what `command` prints */
9
+ export function elevenKey(command: readonly string[] | undefined): string {
10
+ const env = process.env.ELEVENLABS_API_KEY?.trim()
11
+ if (env) return env
12
+ if (!command?.length) throw new Error('no ElevenLabs key: set ELEVENLABS_API_KEY, or name the argv that prints it in the brand (trailer.elevenKey)')
13
+ const r = Bun.spawnSync([...command], { stdout: 'pipe', stderr: 'pipe' })
14
+ const k = r.stdout.toString().trim()
15
+ if (r.exitCode !== 0 || !k) throw new Error(`no ElevenLabs key: ${command.join(' ')} printed none (exit ${r.exitCode})`)
16
+ return k
17
+ }
18
+
19
+ /** a call that has not answered in this long fails, loud */
20
+ const CALL_TIMEOUT_MS = 120_000
21
+
22
+ /** a fetch that gives up after CALL_TIMEOUT_MS with a line that says so */
23
+ async function call(url: string, init: RequestInit, what: string): Promise<Response> {
24
+ try {
25
+ return await fetch(url, { ...init, signal: AbortSignal.timeout(CALL_TIMEOUT_MS) })
26
+ } catch (e) {
27
+ if ((e as Error).name === 'TimeoutError') throw new Error(`ElevenLabs ${what} did not answer in ${CALL_TIMEOUT_MS / 1000} s`)
28
+ throw e
29
+ }
30
+ }
31
+
32
+ async function ok(r: Response, what: string): Promise<Response> {
33
+ if (!r.ok) throw new Error(`ElevenLabs ${what}: ${r.status} ${(await r.text()).slice(0, 300)}`)
34
+ return r
35
+ }
36
+
37
+ export interface ElevenLine {
38
+ key: string
39
+ voice: string
40
+ text: string
41
+ seed: number
42
+ model?: string
43
+ stability?: number
44
+ similarity?: number
45
+ speed?: number
46
+ /** the API's language_code: en, fr… */
47
+ language?: string
48
+ }
49
+
50
+ /** what makes a line: the request's own words, the key of its kept answer */
51
+ export function elevenAsk(q: ElevenLine): string {
52
+ const { key: _, ...ask } = q
53
+ return new Bun.CryptoHasher('sha1').update(JSON.stringify({ ...ask, model: q.model ?? ELEVEN_MODEL })).digest('hex').slice(0, 16)
54
+ }
55
+
56
+ /** one line as mp3 bytes and the characters it cost */
57
+ export async function elevenSpeak(q: ElevenLine): Promise<{ audio: Uint8Array; chars: number }> {
58
+ const settings = { ...(q.stability !== undefined ? { stability: q.stability } : {}), ...(q.similarity !== undefined ? { similarity_boost: q.similarity } : {}), ...(q.speed !== undefined ? { speed: q.speed } : {}) }
59
+ const body = { text: q.text, model_id: q.model ?? ELEVEN_MODEL, seed: q.seed, ...(q.language ? { language_code: q.language } : {}), ...(Object.keys(settings).length ? { voice_settings: settings } : {}) }
60
+ const r = await ok(
61
+ await call(`${API}/v1/text-to-speech/${encodeURIComponent(q.voice)}?output_format=${FORMAT}`, { method: 'POST', headers: { 'xi-api-key': q.key, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, 'text to speech'),
62
+ 'text to speech',
63
+ )
64
+ return { audio: new Uint8Array(await r.arrayBuffer()), chars: Number(r.headers.get('character-cost') ?? q.text.length) }
65
+ }
66
+
67
+ /** the characters used and allowed this billing period */
68
+ export async function elevenCredits(key: string): Promise<{ used: number; limit: number }> {
69
+ const r = await ok(await call(`${API}/v1/user/subscription`, { headers: { 'xi-api-key': key } }, 'subscription'), 'subscription')
70
+ const d = (await r.json()) as { character_count: number; character_limit: number }
71
+ return { used: d.character_count, limit: d.character_limit }
72
+ }
@@ -0,0 +1,71 @@
1
+ // FluidSynth through a General MIDI soundfont: a .mid to a float wav, and that wav read back as samples. The machine's
2
+ // fluidsynth and the house soundfont, SK_SOUNDFONT names another. Used by the score's GM tracks (score/gm-render.ts)
3
+ // and by `sk sound midi`
4
+ import { existsSync, readFileSync, statSync } from 'node:fs'
5
+ import { homedir } from 'node:os'
6
+ import { join } from 'node:path'
7
+
8
+ const FLUID = ['/opt/homebrew/bin/fluidsynth', '/usr/local/bin/fluidsynth', '/usr/bin/fluidsynth']
9
+
10
+ /** the fluidsynth binary, or a loud error naming the install */
11
+ export function fluidsynth(): string {
12
+ const found = FLUID.find((f) => existsSync(f)) ?? Bun.which('fluidsynth')
13
+ if (!found) throw new Error('no fluidsynth on this machine: brew install fluid-synth')
14
+ return found
15
+ }
16
+
17
+ /** the soundfont: SK_SOUNDFONT, else GeneralUser GS in ~/.local/share/soundfonts */
18
+ export function soundfont(): string {
19
+ const f = process.env.SK_SOUNDFONT ?? join(homedir(), '.local/share/soundfonts/GeneralUser-GS.sf2')
20
+ if (!existsSync(f)) throw new Error(`no soundfont ${f}: put GeneralUser-GS.sf2 there, or name one in SK_SOUNDFONT`)
21
+ return f
22
+ }
23
+
24
+ let version: string | null = null
25
+ /** what a GM render is keyed on beside its notes: the soundfont by path, size and time, and fluidsynth's version */
26
+ export function fluidIdentity(): string {
27
+ version ??= Bun.spawnSync([fluidsynth(), '--version'], { stdout: 'pipe' }).stdout.toString().split('\n')[0] ?? ''
28
+ const sf = soundfont()
29
+ const st = statSync(sf)
30
+ return `${version} ${sf} ${st.size} ${st.mtimeMs}`
31
+ }
32
+
33
+ /** a .mid to a 32 bit float stereo wav at `rate`; `room`: fluidsynth's own reverb and chorus on (a song alone), off for
34
+ * a stem the score's room takes */
35
+ export function fluidRender(mid: string, out: string, o: { rate: number; room: boolean; gain?: number }): void {
36
+ // prettier-ignore
37
+ const args = [fluidsynth(), '-ni', '-q', '-g', String(o.gain ?? 1), '-r', String(o.rate), '-R', o.room ? '1' : '0', '-C', o.room ? '1' : '0', '-O', 'float', '-T', 'wav', '-F', out, soundfont(), mid]
38
+ const r = Bun.spawnSync(args, { stdout: 'pipe', stderr: 'pipe' })
39
+ if (r.exitCode !== 0 || !existsSync(out)) throw new Error(`fluidsynth ${mid}: exit ${r.exitCode}: ${r.stderr.toString().trim().split('\n').slice(-3).join(' ')}`)
40
+ }
41
+
42
+ /** a float or 16 bit pcm wav's two channels (a mono one doubled) */
43
+ export function readWav(file: string): { rate: number; left: Float32Array; right: Float32Array } {
44
+ const b = readFileSync(file)
45
+ if (b.toString('latin1', 0, 4) !== 'RIFF' || b.toString('latin1', 8, 12) !== 'WAVE') throw new Error(`${file}: not a wav`)
46
+ let at = 12
47
+ let fmt: { format: number; channels: number; rate: number; bits: number } | null = null
48
+ while (at + 8 <= b.length) {
49
+ const id = b.toString('latin1', at, at + 4)
50
+ const size = b.readUInt32LE(at + 4)
51
+ const body = at + 8
52
+ if (id === 'fmt ') fmt = { format: b.readUInt16LE(body), channels: b.readUInt16LE(body + 2), rate: b.readUInt32LE(body + 4), bits: b.readUInt16LE(body + 14) }
53
+ if (id === 'data') {
54
+ if (!fmt) throw new Error(`${file}: data before fmt`)
55
+ const float = fmt.format === 3 || (fmt.format === 0xfffe && fmt.bits === 32)
56
+ const width = fmt.bits / 8
57
+ const n = Math.floor(Math.min(size, b.length - body) / (width * fmt.channels))
58
+ const left = new Float32Array(n)
59
+ const right = new Float32Array(n)
60
+ for (let i = 0; i < n; i++) {
61
+ const p = body + i * width * fmt.channels
62
+ const read = (q: number): number => (float ? b.readFloatLE(q) : fmt!.bits === 16 ? b.readInt16LE(q) / 32768 : b.readInt32LE(q) / 2147483648)
63
+ left[i] = read(p)
64
+ right[i] = fmt.channels > 1 ? read(p + width) : left[i]!
65
+ }
66
+ return { rate: fmt.rate, left, right }
67
+ }
68
+ at = body + size + (size % 2)
69
+ }
70
+ throw new Error(`${file}: no data chunk`)
71
+ }
@@ -0,0 +1,139 @@
1
+ // the house generators, the bodies of the `sk sound` verbs: a voice line (ElevenLabs, or comfy-ts cloning a clip or a
2
+ // designed voice), a sound effect and a music bed from a prompt (comfy-ts on the GPU box), a MIDI file through the
3
+ // soundfont. Each writes `out` trimmed and set at its level, then says its measures. A made file is the sound cache's
4
+ // to keep (run.ts), so nothing here caches but the reference clip of a designed voice
5
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
6
+ import { tmpdir } from 'node:os'
7
+ import { dirname, join } from 'node:path'
8
+ import { decode, envelope, finish, measure, measureLine, trimOf, waveformPng } from './audio.ts'
9
+ import type { TrailerVoice } from './brand.ts'
10
+ import { comfyModule, comfyRun } from './comfy.ts'
11
+ import { elevenAsk, elevenCredits, elevenKey, elevenSpeak } from './eleven.ts'
12
+ import { fluidRender } from './fluid.ts'
13
+ import { soundCache } from './run.ts'
14
+
15
+ type Say = (line: string) => void
16
+
17
+ /** a line read at this loudness, a bed and a MIDI song too */
18
+ const LINE_LUFS = -16
19
+ /** an sfx's sample peak */
20
+ const SFX_PEAK = -3
21
+
22
+ const scratch = <T>(f: (dir: string) => Promise<T>): Promise<T> => {
23
+ const dir = mkdtempSync(join(tmpdir(), 'sk-gen-'))
24
+ return f(dir).finally(() => rmSync(dir, { recursive: true, force: true }))
25
+ }
26
+
27
+ /** the file written whole or not at all, then its measures said and its waveform beside it as `<out>.png` */
28
+ async function land(tmp: string, out: string, say: Say): Promise<void> {
29
+ mkdirSync(dirname(out), { recursive: true })
30
+ renameSync(tmp, out)
31
+ say(`ok: ${out}: ${measureLine(await measure(out))}`)
32
+ await waveformPng(out, `${out}.png`)
33
+ }
34
+
35
+ /** a line for an engine that reads plain text: `[pause]` becomes a breath, the other tags drop */
36
+ export function plain(line: string): string {
37
+ return line
38
+ .replace(/[.…]*\s*\[pause\]\s*/g, '... ')
39
+ .replace(/\[[^\]]+\]\s*/g, '')
40
+ .replace(/’/g, "'")
41
+ .trim()
42
+ }
43
+
44
+ /** comfy-ts takes these language names */
45
+ const LANGUAGE: Readonly<Record<string, string>> = { en: 'English', fr: 'French', de: 'German', es: 'Spanish', it: 'Italian', pt: 'Portuguese', ru: 'Russian', ja: 'Japanese', ko: 'Korean', zh: 'Chinese' }
46
+ const comfyLanguage = (l: string | undefined): string | undefined => (l === undefined ? undefined : (LANGUAGE[l] ?? l))
47
+
48
+ /** the reference clip of a designed voice, made once: `.shipkit/cache/sound/design-<key>.wav` */
49
+ async function designedClip(root: string, comfy: string | undefined, v: Extract<TrailerVoice, { design: string }>, say: Say): Promise<{ clip: string; transcript: string }> {
50
+ const line = v.line ?? 'Every story starts somewhere. This one starts right here, with you, and it is only getting better.'
51
+ const module = comfyModule(comfy, 'tts-voice-design')
52
+ const key = new Bun.CryptoHasher('sha1').update(JSON.stringify([module, v.design, line, v.seed ?? 7, v.language ?? null])).digest('hex').slice(0, 16)
53
+ const clip = join(soundCache(root), `design-${key}.wav`)
54
+ if (!existsSync(clip)) {
55
+ say(`designing the voice "${v.design.slice(0, 60)}…" (comfy-ts ${module}, about 45 s)`)
56
+ await scratch(async (dir) => {
57
+ const raw = join(dir, 'design.flac')
58
+ await comfyRun(module, { description: v.design, line, seed: v.seed ?? 7, language: comfyLanguage(v.language) }, raw)
59
+ await finish(raw, join(dir, 'clip.wav'), trimOf(envelope(await decode(raw)), { tailDb: 45 }), { lufs: LINE_LUFS })
60
+ mkdirSync(soundCache(root), { recursive: true })
61
+ renameSync(join(dir, 'clip.wav'), clip)
62
+ })
63
+ }
64
+ return { clip, transcript: line }
65
+ }
66
+
67
+ /** one spoken line in a voice: silence trimmed at both ends, -16 LUFS */
68
+ export async function genVoice(o: { root: string; text: string; voice: TrailerVoice; seed: number; comfy?: string; elevenKey?: string[]; out: string; say: Say }): Promise<void> {
69
+ const v = o.voice
70
+ await scratch(async (dir) => {
71
+ let raw: string
72
+ if ('eleven' in v) {
73
+ const key = elevenKey(o.elevenKey)
74
+ const line = { key, voice: v.eleven, text: o.text, seed: o.seed, model: v.model, stability: v.stability, similarity: v.similarity, speed: v.speed, language: v.language }
75
+ // the paid answer is kept the moment it lands: a make killed after it never bills again
76
+ raw = join(soundCache(o.root), `eleven-${elevenAsk(line)}.mp3`)
77
+ if (existsSync(raw)) o.say('ElevenLabs: the answer kept from an earlier call, no new call')
78
+ else {
79
+ const r = await elevenSpeak(line)
80
+ mkdirSync(soundCache(o.root), { recursive: true })
81
+ writeFileSync(`${raw}.part`, r.audio)
82
+ renameSync(`${raw}.part`, raw)
83
+ const c = await elevenCredits(key).catch((e: Error) => e)
84
+ o.say(`ElevenLabs: ${r.chars} characters, ${c instanceof Error ? `the characters left unread (${c.message.slice(0, 80)})` : `${(c.limit - c.used).toLocaleString('en')} of ${c.limit.toLocaleString('en')} left this period`}`)
85
+ }
86
+ } else {
87
+ const ref = 'clone' in v ? { clip: join(o.root, v.clone), transcript: v.transcript ?? readTranscript(join(o.root, v.clone)) } : await designedClip(o.root, o.comfy, v, o.say)
88
+ raw = join(dir, 'line.flac')
89
+ await comfyRun(comfyModule(o.comfy, 'tts-voice-clone'), { voice: ref.clip, transcript: ref.transcript, prompt: plain(o.text), seed: o.seed, engine: v.engine, language: comfyLanguage(v.language) }, raw)
90
+ }
91
+ const t = trimOf(envelope(await decode(raw)), { tailDb: 45 })
92
+ await finish(raw, join(dir, 'out.wav'), { ...t, start: Math.max(0, t.start - 0.03), end: t.end + 0.05 }, { lufs: LINE_LUFS })
93
+ await land(join(dir, 'out.wav'), o.out, o.say)
94
+ })
95
+ }
96
+
97
+ function readTranscript(clip: string): string {
98
+ const txt = clip.replace(/\.[a-z0-9]+$/i, '.txt')
99
+ if (!existsSync(txt)) throw new Error(`the voice clip ${clip} has no transcript: write what it says into ${txt}, or give transcript in the brand`)
100
+ return readFileSync(txt, 'utf8').trim()
101
+ }
102
+
103
+ /** a sound effect from a prompt: its silence before cut, its tail cut 45 dB under its peak (`hit`: its first hit
104
+ * alone), its peak at -3 dBFS */
105
+ export async function genSfx(o: { prompt: string; seconds: number; seed: number; hit: boolean; comfy?: string; out: string; say: Say }): Promise<void> {
106
+ await scratch(async (dir) => {
107
+ const raw = join(dir, 'sfx.flac')
108
+ await comfyRun(comfyModule(o.comfy, 'stable-audio'), { category: 'SFX', prompt: o.prompt, seconds: o.seconds, seed: o.seed }, raw)
109
+ await finish(raw, join(dir, 'out.wav'), trimOf(envelope(await decode(raw)), { tailDb: 45, hit: o.hit }), { peak: SFX_PEAK })
110
+ await land(join(dir, 'out.wav'), o.out, o.say)
111
+ })
112
+ }
113
+
114
+ /** a music bed or a song from a prompt: stable-audio plays it, ace-step sings its lyrics (lines after the first),
115
+ * else it plays instrumental. -16 LUFS, its tail trimmed and its end faded */
116
+ export async function genMusic(o: { prompt: string; engine: 'stable-audio' | 'ace-step'; seconds: number; seed: number; comfy?: string; out: string; say: Say }): Promise<void> {
117
+ await scratch(async (dir) => {
118
+ const raw = join(dir, 'music.flac')
119
+ if (o.engine === 'ace-step') {
120
+ const [caption = '', ...lyrics] = o.prompt.split('\n')
121
+ const prompt = `${caption}\n${lyrics.join('\n').trim() || '[Instrumental]'}`
122
+ await comfyRun(comfyModule(o.comfy, 'ace-step'), { prompt, seconds: Math.max(10, o.seconds), seed: o.seed }, raw)
123
+ } else await comfyRun(comfyModule(o.comfy, 'stable-audio'), { category: 'Music', prompt: o.prompt, seconds: Math.min(300, o.seconds), seed: o.seed }, raw)
124
+ const t = trimOf(envelope(await decode(raw)), { tailDb: 50 })
125
+ await finish(raw, join(dir, 'out.wav'), { ...t, fade: 0.5 }, { lufs: LINE_LUFS })
126
+ await land(join(dir, 'out.wav'), o.out, o.say)
127
+ })
128
+ }
129
+
130
+ /** a MIDI file through the soundfont, fluidsynth's own room on: -16 LUFS, its tail trimmed */
131
+ export async function genMidi(o: { file: string; out: string; say: Say }): Promise<void> {
132
+ await scratch(async (dir) => {
133
+ const raw = join(dir, 'midi.wav')
134
+ fluidRender(o.file, raw, { rate: 48000, room: true, gain: 0.8 })
135
+ const t = trimOf(envelope(await decode(raw)), { tailDb: 50 })
136
+ await finish(raw, join(dir, 'out.wav'), { ...t, start: 0, fade: 0.3 }, { lufs: LINE_LUFS })
137
+ await land(join(dir, 'out.wav'), o.out, o.say)
138
+ })
139
+ }
@@ -0,0 +1,60 @@
1
+ // the trailer's sounds the repo's unit does not make: a line's own `run`, else the house preset its words name, made
2
+ // through the one sound run (run.ts). What the build calls when a sound is no file and no unit answers it, and what
3
+ // `sk trailer check` prints for each
4
+ import { existsSync } from 'node:fs'
5
+ import { isAbsolute, relative } from 'node:path'
6
+ import type { SoundRequest } from '../adapter.ts'
7
+ import type { SoundRun } from '../grammar.ts'
8
+ import type { TrailerSoundInput } from './brand.ts'
9
+ import { paidChars, presetOf, type SoundAsk } from './presets.ts'
10
+ import { runSpec, soundRun } from './run.ts'
11
+
12
+ async function brandSound(root: string): Promise<TrailerSoundInput | undefined> {
13
+ const { loadBrand } = await import('../../assets/run-assets.ts')
14
+ const b = await loadBrand(root)
15
+ if (typeof b === 'string') throw new Error(b)
16
+ return b?.trailer
17
+ }
18
+
19
+ /** the run a request makes: its own, else its preset; a file in the repo named by its repo relative path, so the key
20
+ * holds no checkout's place */
21
+ export function runOf(root: string, r: SoundRequest, brand: TrailerSoundInput | undefined): SoundRun {
22
+ if (r.run) return r.run
23
+ const inside = isAbsolute(r.text) && !relative(root, r.text).startsWith('..')
24
+ return presetOf({ ...r, text: inside ? relative(root, r.text) : r.text }, brand)
25
+ }
26
+
27
+ /** the request's sound file, made when the cache misses */
28
+ export async function resolveSound(root: string, r: SoundRequest, say: (line: string) => void): Promise<string> {
29
+ const run = runOf(root, r, r.run ? undefined : await brandSound(root))
30
+ return (await soundRun(run, { root, dir: r.dir, ...(r.seconds !== undefined ? { seconds: r.seconds } : {}) }, say)).file
31
+ }
32
+
33
+ export interface SoundState {
34
+ line: number
35
+ kind: SoundAsk['kind']
36
+ cmd: string
37
+ made: boolean
38
+ /** a paid line not made yet: the characters it will cost */
39
+ cost: number | null
40
+ }
41
+
42
+ /** each request's command and whether it is made, for `sk trailer check`; an error is the line's */
43
+ export async function soundStates(root: string, rs: readonly SoundRequest[]): Promise<(SoundState | { line: number; error: string })[]> {
44
+ const brand = rs.some((r) => !r.run) ? await brandSound(root) : undefined
45
+ return rs.map((r) => {
46
+ try {
47
+ const run = runOf(root, r, brand)
48
+ const spec = runSpec(run, { root, dir: r.dir, ...(r.seconds !== undefined ? { seconds: r.seconds } : {}) })
49
+ const made = existsSync(spec.file)
50
+ return { line: r.line, kind: r.kind, cmd: spec.cmd, made, cost: made ? null : paidChars(run, r.text) }
51
+ } catch (e) {
52
+ return { line: r.line, error: e instanceof Error ? e.message : String(e) }
53
+ }
54
+ })
55
+ }
56
+
57
+ /** a state as the check prints it */
58
+ export const stateLine = (s: SoundState | { line: number; error: string }): string =>
59
+ 'error' in s ? `line ${s.line}: ${s.error}` : `line ${s.line} ${s.kind} ${s.made ? 'made' : `to make${s.cost !== null ? ` (ElevenLabs, about ${s.cost} characters)` : ''}`}: ${s.cmd}`
60
+
@@ -0,0 +1,69 @@
1
+ // the house generators as presets of the one sound run: each short form of a .trailer (`voice "<line>"`,
2
+ // `sfx "<prompt>"`, `music "<prompt>"`, `music <file>.mid`) becomes the `sk sound` command that makes it, every input
3
+ // written in its words, so the key holds the voice, the seed and the length (and a .mid's soundfont)
4
+ import { type TrailerSoundInput, type TrailerVoice, voiceOf } from './brand.ts'
5
+ import type { SoundRun } from '../grammar.ts'
6
+ import { ELEVEN_MODEL } from './eleven.ts'
7
+ import { fluidIdentity } from './fluid.ts'
8
+
9
+ /** what a line of the trailer asks for */
10
+ export interface SoundAsk {
11
+ kind: 'voice' | 'sfx' | 'music'
12
+ /** the line read, the prompt, or a .mid file */
13
+ text: string
14
+ /** a retake: another seed is another take (default 1) */
15
+ seed?: number
16
+ /** voice: a voice of the brand; music: the engine */
17
+ by?: string
18
+ /** seconds made: an sfx's length, a music's length */
19
+ for?: number
20
+ /** sfx: its first hit alone */
21
+ hit?: boolean
22
+ /** music: what its cue covers */
23
+ seconds?: number
24
+ }
25
+
26
+ export const SFX_SECONDS = 2
27
+ export const MUSIC_ENGINES = ['stable-audio', 'ace-step'] as const
28
+
29
+ const q = (s: string): string => `'${s.replaceAll("'", "'\\''")}'`
30
+ const num = (n: number): string => String(+n.toFixed(3))
31
+ const isMidi = (w: string): boolean => /\.midi?$/i.test(w)
32
+
33
+ /** a music cue's length as the generator makes it: rounded up to 30 s, so a small edit of the cut makes nothing again */
34
+ export const musicSeconds = (s: number | undefined): number => Math.min(300, Math.max(30, Math.ceil((s ?? 0) / 30) * 30))
35
+
36
+ /** the flags that name a voice for `sk sound voice` */
37
+ export function voiceFlags(v: TrailerVoice): { flags: string[]; reads: string[] } {
38
+ const opt = (k: string, x: string | number | undefined): string[] => (x === undefined ? [] : [`--${k}`, typeof x === 'number' ? num(x) : q(x)])
39
+ if ('eleven' in v)
40
+ return { flags: ['--eleven', q(v.eleven), '--model', q(v.model ?? ELEVEN_MODEL), ...opt('stability', v.stability), ...opt('similarity', v.similarity), ...opt('speed', v.speed), ...opt('language', v.language)], reads: [] }
41
+ if ('clone' in v) {
42
+ const txt = v.clone.replace(/\.[a-z0-9]+$/i, '.txt')
43
+ return { flags: ['--clone', q(v.clone), ...opt('transcript', v.transcript), ...opt('engine', v.engine), ...opt('language', v.language)], reads: v.transcript === undefined ? [v.clone, txt] : [v.clone] }
44
+ }
45
+ return { flags: ['--design', q(v.design), ...opt('design-line', v.line), ...opt('design-seed', v.seed), ...opt('engine', v.engine), ...opt('language', v.language)], reads: [] }
46
+ }
47
+
48
+ /** the run that makes what the line asks for */
49
+ export function presetOf(a: SoundAsk, brand: TrailerSoundInput | undefined): SoundRun {
50
+ const seed = ['--seed', String(a.seed ?? 1)]
51
+ // the comfy-ts folder is in the words, so another folder makes the sound again
52
+ const comfy = brand?.comfy ? ['--comfy', q(brand.comfy)] : []
53
+ if (a.kind === 'voice') {
54
+ const voice = voiceOf(brand, a.by)
55
+ const v = voiceFlags(voice)
56
+ return { cmd: ['sk sound voice', q(a.text), ...v.flags, ...('eleven' in voice ? [] : comfy), ...seed, '--out {out}'].join(' '), ...(v.reads.length ? { reads: v.reads } : {}) }
57
+ }
58
+ if (a.kind === 'sfx') return { cmd: ['sk sound sfx', q(a.text), '--seconds', num(a.for ?? SFX_SECONDS), ...seed, ...(a.hit ? ['--hit'] : []), ...comfy, '--out {out}'].join(' ') }
59
+ // the soundfont and fluidsynth are in the words as a comment: another one renders the file again
60
+ if (isMidi(a.text)) return { cmd: `sk sound midi ${q(a.text)} --out {out} # fluid ${new Bun.CryptoHasher('sha1').update(fluidIdentity()).digest('hex').slice(0, 12)}`, reads: [a.text] }
61
+ const engine = a.by ?? 'stable-audio'
62
+ if (!(MUSIC_ENGINES as readonly string[]).includes(engine)) throw new Error(`music by ${engine}: the engines are ${MUSIC_ENGINES.join(', ')}`)
63
+ return { cmd: ['sk sound music', q(a.text), '--engine', engine, '--seconds', num(a.for ?? musicSeconds(a.seconds)), ...seed, ...comfy, '--out {out}'].join(' ') }
64
+ }
65
+
66
+ /** a paid run: the characters its line costs, else null */
67
+ export function paidChars(r: SoundRun, text: string): number | null {
68
+ return r.cmd.includes(' --eleven ') ? text.length : null
69
+ }