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.
- package/CHANGELOG.md +11 -2
- package/package.json +1 -1
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +4 -0
- package/src/cli/cmd_Review.ts +36 -10
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Trailer.ts +7 -2
- package/src/cli/kit/verb.ts +11 -3
- package/src/cli.ts +2 -0
- package/src/core/app-icon.ts +10 -6
- package/src/repo-config.ts +3 -0
- package/src/review/format.ts +11 -3
- package/src/review/model.ts +47 -5
- package/src/review/project.ts +20 -4
- package/src/review/routes.ts +12 -1
- package/src/review/store.ts +15 -6
- package/src/review/unit.ts +19 -1
- package/src/serve/hub-app/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/hash-route.ts +12 -4
- package/src/serve/hub-app/home-st.ts +7 -1
- package/src/serve/hub-app/hub.css +38 -1
- package/src/serve/hub-app/icons.tsx +6 -0
- package/src/serve/hub-app/layout-st.ts +2 -2
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/project-list.css +9 -7
- package/src/serve/hub-app/project-list.tsx +5 -2
- package/src/serve/hub-app/projects-svc.ts +3 -2
- package/src/serve/hub-app/repo-st.ts +22 -7
- package/src/serve/hub-app/review/clip-st.ts +57 -7
- package/src/serve/hub-app/review/kind-view.tsx +78 -9
- package/src/serve/hub-app/review/review-answer.tsx +83 -53
- package/src/serve/hub-app/review/review-page.tsx +9 -14
- package/src/serve/hub-app/review/review-st.ts +141 -18
- package/src/serve/hub-app/review/review-views.tsx +154 -9
- package/src/serve/hub-app/review.css +70 -10
- package/src/serve/hub-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +4 -4
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/serve-hub.ts +7 -0
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +33 -4
- package/src/trailer/build.ts +170 -29
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +4 -4
- package/src/trailer/grammar.ts +429 -67
- package/src/trailer/graph.ts +130 -56
- package/src/trailer/look.ts +3 -0
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/plan.ts +2 -2
- package/src/trailer/prune.ts +13 -2
- package/src/trailer/score/compose.ts +18 -2
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/pattern.ts +13 -6
- package/src/trailer/score/score.ts +28 -14
- package/src/trailer/score/sound.ts +12 -1
- package/src/trailer/score/synth.ts +24 -2
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/titles.ts +451 -235
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +12 -1
- package/src/trailer/words.ts +29 -8
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- 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
|
+
}
|