rvlib-shipkit 2.2.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 +47 -0
- package/package.json +1 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +9 -0
- package/src/cli/cmd_Review.ts +974 -0
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +264 -6
- package/src/cli/kit/verb.ts +22 -4
- package/src/cli.ts +8 -4
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/app-icon.ts +10 -6
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/repo-config.ts +3 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +158 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +896 -0
- package/src/review/project.ts +126 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +239 -0
- package/src/review/store.ts +1011 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +183 -0
- package/src/review/watchers.ts +54 -0
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- 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/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +36 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +17 -3
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +83 -14
- package/src/serve/hub-app/icons.tsx +15 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +26 -6
- package/src/serve/hub-app/project-list.tsx +46 -10
- package/src/serve/hub-app/projects-svc.ts +7 -2
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +61 -4
- package/src/serve/hub-app/review/clip-st.ts +258 -0
- package/src/serve/hub-app/review/kind-view.tsx +240 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +269 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +96 -0
- package/src/serve/hub-app/review/review-st.ts +902 -0
- package/src/serve/hub-app/review/review-views.tsx +648 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +389 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- 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 +9 -7
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +15 -0
- package/src/shipkit-files.ts +9 -5
- 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 +90 -6
- package/src/trailer/build.ts +459 -59
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +112 -43
- package/src/trailer/grammar.ts +864 -127
- package/src/trailer/graph.ts +308 -49
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +20 -3
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +82 -23
- package/src/trailer/prune.ts +204 -0
- package/src/trailer/score/compose.ts +256 -0
- package/src/trailer/score/dsp.ts +272 -0
- 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/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +98 -0
- package/src/trailer/score/score.ts +228 -0
- package/src/trailer/score/sound.ts +123 -0
- package/src/trailer/score/synth.ts +383 -0
- 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/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +471 -150
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +34 -4
- package/src/trailer/words.ts +275 -0
- 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,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
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// THE one way a trailer makes a sound: a command that writes `{out}`, cached on its words and the content of each file
|
|
2
|
+
// it reads. A voice, an sfx, a music bed and a MIDI file are presets of it (presets.ts). The cache is
|
|
3
|
+
// `.shipkit/cache/sound/<key>.wav` with `<key>.json` (the command, its measures) and `<key>.png` (its waveform), out of
|
|
4
|
+
// the trailer's cache so a trailer prune never deletes a paid line. One sound is made at a time, under nice, never
|
|
5
|
+
// while the machine's load is over 16
|
|
6
|
+
import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from 'node:fs'
|
|
7
|
+
import { loadavg } from 'node:os'
|
|
8
|
+
import { join, relative, resolve } from 'node:path'
|
|
9
|
+
import { ffmpeg } from '../ffmpeg.ts'
|
|
10
|
+
import type { SoundRun } from '../grammar.ts'
|
|
11
|
+
import { measure, measureLine, SOUND_RATE, waveformPng } from './audio.ts'
|
|
12
|
+
|
|
13
|
+
/** the folder every made sound of the repo is kept in */
|
|
14
|
+
export const soundCache = (root: string): string => join(root, '.shipkit', 'cache', 'sound')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
export interface RunPlace {
|
|
18
|
+
root: string
|
|
19
|
+
/** the .trailer's folder, where a `reads` file is looked for first */
|
|
20
|
+
dir: string
|
|
21
|
+
/** a music cue's length: `{seconds}` is it rounded up to 10 s */
|
|
22
|
+
seconds?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** a music cue's seconds as the command reads them: rounded up to 10, so a small edit makes nothing again */
|
|
26
|
+
export const runSeconds = (s: number | undefined): number => Math.max(10, Math.ceil((s ?? 0) / 10) * 10)
|
|
27
|
+
|
|
28
|
+
const q = (s: string): string => `'${s.replaceAll("'", "'\\''")}'`
|
|
29
|
+
|
|
30
|
+
const escape = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
31
|
+
|
|
32
|
+
/** the command with `{seconds}` filled, each read file found (beside the .trailer first, then from the repo root) and
|
|
33
|
+
* named in the command by its repo relative path, as a film's `@<file>` is: the command, run at the root, reads the
|
|
34
|
+
* file the key hashed. Then the key and the cache file */
|
|
35
|
+
export function runSpec(r: SoundRun, p: RunPlace): { cmd: string; reads: { path: string; abs: string }[]; key: string; file: string } {
|
|
36
|
+
let cmd = r.cmd.replaceAll('{seconds}', String(runSeconds(p.seconds)))
|
|
37
|
+
if (!cmd.includes('{out}')) throw new Error(`a sound run writes {out}: ${r.cmd}`)
|
|
38
|
+
const reads = (r.reads ?? []).map((given) => {
|
|
39
|
+
const abs = [resolve(p.dir, given), resolve(p.root, given)].find((f) => existsSync(f))
|
|
40
|
+
if (!abs) throw new Error(`sound run reads ${given}: no such file beside the .trailer or from the repo root`)
|
|
41
|
+
const rel = relative(p.root, abs)
|
|
42
|
+
const path = rel.startsWith('..') ? abs : rel
|
|
43
|
+
if (path !== given) cmd = cmd.replace(new RegExp(`(^|[\\s'"=])${escape(given)}(?=$|[\\s'"])`, 'g'), (_, before: string) => `${before}${path}`)
|
|
44
|
+
return { path, abs }
|
|
45
|
+
})
|
|
46
|
+
const h = new Bun.CryptoHasher('sha1').update(cmd)
|
|
47
|
+
for (const x of reads) h.update(`\0${x.path}\0`).update(readFileSync(x.abs))
|
|
48
|
+
const key = h.digest('hex').slice(0, 16)
|
|
49
|
+
return { cmd, reads, key, file: join(soundCache(p.root), `${key}.wav`) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let queue: Promise<unknown> = Promise.resolve()
|
|
53
|
+
|
|
54
|
+
/** the sound's file, made when the cache misses: the command run in the repo root, its output kept as a 48 kHz wav */
|
|
55
|
+
export function soundRun(r: SoundRun, p: RunPlace, say: (line: string) => void): Promise<{ file: string; made: boolean }> {
|
|
56
|
+
const spec = runSpec(r, p)
|
|
57
|
+
if (existsSync(spec.file)) return Promise.resolve({ file: spec.file, made: false })
|
|
58
|
+
const job = queue.then(() => make(spec, p.root, say))
|
|
59
|
+
queue = job.catch(() => undefined)
|
|
60
|
+
return job
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** the process holding a lock file: alive, or the lock is stale */
|
|
64
|
+
function holderAlive(lock: string): boolean {
|
|
65
|
+
let pid = 0
|
|
66
|
+
try {
|
|
67
|
+
pid = Number(readFileSync(lock, 'utf8').trim())
|
|
68
|
+
} catch {
|
|
69
|
+
return true // written this instant: its pid is not in it yet
|
|
70
|
+
}
|
|
71
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
72
|
+
try {
|
|
73
|
+
process.kill(pid, 0)
|
|
74
|
+
return true
|
|
75
|
+
} catch (e) {
|
|
76
|
+
return (e as NodeJS.ErrnoException).code === 'EPERM'
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `<key>.lock` beside the sound, across processes: taken with O_EXCL, its pid inside; a lock of a dead process is
|
|
81
|
+
* taken over. Null when the sound was made while this one waited */
|
|
82
|
+
async function lockSound(spec: ReturnType<typeof runSpec>, say: (line: string) => void): Promise<(() => void) | null> {
|
|
83
|
+
const lock = `${spec.file.slice(0, -4)}.lock`
|
|
84
|
+
for (let said = false; ; ) {
|
|
85
|
+
if (existsSync(spec.file)) return null
|
|
86
|
+
try {
|
|
87
|
+
const fd = openSync(lock, 'wx')
|
|
88
|
+
writeSync(fd, String(process.pid))
|
|
89
|
+
closeSync(fd)
|
|
90
|
+
if (existsSync(spec.file)) {
|
|
91
|
+
rmSync(lock, { force: true })
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
return () => rmSync(lock, { force: true })
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
|
|
97
|
+
}
|
|
98
|
+
if (!holderAlive(lock)) {
|
|
99
|
+
rmSync(lock, { force: true })
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if (!said) say(`sound: ${spec.key} is being made by another process: waiting for it`)
|
|
103
|
+
said = true
|
|
104
|
+
await Bun.sleep(200)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function make(spec: ReturnType<typeof runSpec>, root: string, say: (line: string) => void): Promise<{ file: string; made: boolean }> {
|
|
109
|
+
mkdirSync(soundCache(root), { recursive: true })
|
|
110
|
+
const unlock = await lockSound(spec, say)
|
|
111
|
+
if (!unlock) return { file: spec.file, made: false }
|
|
112
|
+
try {
|
|
113
|
+
return await makeLocked(spec, root, say)
|
|
114
|
+
} finally {
|
|
115
|
+
unlock()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** a command that has not answered in this long is killed, loud: a hung box never stalls a build */
|
|
120
|
+
const runTimeoutS = (): number => Number(process.env.SK_SOUND_TIMEOUT_S ?? 30 * 60)
|
|
121
|
+
/** a `.make-*` folder older than this was left by a killed process */
|
|
122
|
+
const STALE_MAKE_MS = 60 * 60 * 1000
|
|
123
|
+
|
|
124
|
+
/** a process group ended: TERM, then KILL 5 s later */
|
|
125
|
+
export function killGroup(pid: number): void {
|
|
126
|
+
const kill = (sig: NodeJS.Signals): void => {
|
|
127
|
+
try {
|
|
128
|
+
process.kill(-pid, sig)
|
|
129
|
+
} catch {
|
|
130
|
+
// already gone
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
kill('SIGTERM')
|
|
134
|
+
setTimeout(() => kill('SIGKILL'), 5000).unref()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const prepared = new Map<string, Promise<void>>()
|
|
138
|
+
|
|
139
|
+
/** once per process and repo, before the first sound it makes: the folders killed makes left swept, then a wait while
|
|
140
|
+
* the machine's load is over 16 */
|
|
141
|
+
async function prepare(root: string, say: (line: string) => void): Promise<void> {
|
|
142
|
+
const cache = soundCache(root)
|
|
143
|
+
for (const e of readdirSync(cache)) {
|
|
144
|
+
const f = join(cache, e)
|
|
145
|
+
if (e.startsWith('.make-') && Date.now() - statSync(f).mtimeMs > STALE_MAKE_MS) rmSync(f, { recursive: true, force: true })
|
|
146
|
+
}
|
|
147
|
+
for (let waited = 0; loadavg()[0]! > 16; waited += 5) {
|
|
148
|
+
if (waited === 0) say(`sound: the load is ${loadavg()[0]!.toFixed(1)}, over 16: waiting before the first sound`)
|
|
149
|
+
if (waited > 600) throw new Error('sound: the load stayed over 16 for 10 minutes')
|
|
150
|
+
await Bun.sleep(5000)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function makeLocked(spec: ReturnType<typeof runSpec>, root: string, say: (line: string) => void): Promise<{ file: string; made: boolean }> {
|
|
155
|
+
let ready = prepared.get(root)
|
|
156
|
+
if (!ready) prepared.set(root, (ready = prepare(root, say)))
|
|
157
|
+
await ready
|
|
158
|
+
const dir = mkdtempSync(join(soundCache(root), '.make-'))
|
|
159
|
+
try {
|
|
160
|
+
const out = join(dir, 'out.wav')
|
|
161
|
+
say(`sound: making ${spec.key}: ${spec.cmd.length > 160 ? `${spec.cmd.slice(0, 157)}…` : spec.cmd}`)
|
|
162
|
+
const t0 = performance.now()
|
|
163
|
+
// its own process group: a timeout kills the command and every child holding its pipes
|
|
164
|
+
const proc = Bun.spawn(['nice', '-n', '15', 'bash', '-c', spec.cmd.replaceAll('{out}', q(out))], { cwd: root, stdout: 'pipe', stderr: 'pipe', detached: true })
|
|
165
|
+
let timedOut = false
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
timedOut = true
|
|
168
|
+
killGroup(proc.pid)
|
|
169
|
+
}, runTimeoutS() * 1000)
|
|
170
|
+
const [text, err, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]).finally(() => clearTimeout(timer))
|
|
171
|
+
if (timedOut) throw new Error(`sound ${spec.key} did not answer in ${runTimeoutS()} s and was killed: ${spec.cmd}`)
|
|
172
|
+
for (const l of text.split('\n')) if (/^(ok|ElevenLabs|WARN|designing)/.test(l)) say(` ${l}`)
|
|
173
|
+
if (code !== 0 || !existsSync(out)) {
|
|
174
|
+
const tail = `${err}\n${text}`.trim().split('\n').slice(-4).join('\n ')
|
|
175
|
+
throw new Error(`sound ${spec.key} ${code !== 0 ? `exited ${code}` : 'wrote no {out}'}: ${spec.cmd}\n ${tail}`)
|
|
176
|
+
}
|
|
177
|
+
const wav = join(dir, 'kept.wav')
|
|
178
|
+
await ffmpeg(['-y', '-v', 'error', '-i', out, '-ac', '2', '-ar', String(SOUND_RATE), '-c:a', 'pcm_s16le', wav])
|
|
179
|
+
const m = await measure(wav)
|
|
180
|
+
if (existsSync(`${out}.png`)) renameSync(`${out}.png`, `${spec.file.slice(0, -4)}.png`)
|
|
181
|
+
else await waveformPng(wav, `${spec.file.slice(0, -4)}.png`)
|
|
182
|
+
writeFileSync(`${spec.file.slice(0, -4)}.json`, `${JSON.stringify({ cmd: spec.cmd, reads: spec.reads.map((x) => x.path), made: new Date().toISOString(), ms: Math.round(performance.now() - t0), measures: m }, null, 1)}\n`)
|
|
183
|
+
renameSync(wav, spec.file)
|
|
184
|
+
say(`sound: ${relative(root, spec.file)} in ${((performance.now() - t0) / 1000).toFixed(1)} s: ${measureLine(m)}`)
|
|
185
|
+
return { file: spec.file, made: true }
|
|
186
|
+
} finally {
|
|
187
|
+
rmSync(dir, { recursive: true, force: true })
|
|
188
|
+
}
|
|
189
|
+
}
|