@mineo71/showtime 0.2.1 → 0.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 +32 -0
- package/README.md +155 -19
- package/docs/script.schema.json +395 -0
- package/package.json +1 -1
- package/skill/SKILL.md +70 -13
- package/src/audio/decode.ts +19 -0
- package/src/audio/mix.ts +72 -0
- package/src/audio/sfx.ts +55 -0
- package/src/audio/soundtrack.ts +49 -0
- package/src/audio/tts.ts +186 -0
- package/src/audio/wav.ts +96 -0
- package/src/cli/format.ts +10 -0
- package/src/cli/main.ts +125 -40
- package/src/convert.ts +48 -0
- package/src/ffmpeg.ts +21 -0
- package/src/lint.ts +128 -0
- package/src/logo.ts +20 -0
- package/src/paths.ts +19 -0
- package/src/pipeline.ts +97 -19
- package/src/render/remotion/Intro.tsx +3 -1
- package/src/render/remotion/Logo.tsx +6 -0
- package/src/render/remotion/Outro.tsx +38 -0
- package/src/render/remotion/Overlays.tsx +41 -16
- package/src/render/remotion/ShowtimeVideo.tsx +56 -18
- package/src/render/render.ts +1 -1
- package/src/runner/browser.ts +47 -6
- package/src/runner/check.ts +10 -1
- package/src/runner/events.ts +2 -0
- package/src/runner/hooks.ts +111 -0
- package/src/runner/record.ts +53 -34
- package/src/runner/steps.ts +4 -1
- package/src/script/config.ts +74 -0
- package/src/script/hash.ts +7 -1
- package/src/script/load.ts +15 -2
- package/src/script/schema.ts +115 -1
- package/src/setup.ts +13 -4
- package/src/sheet.ts +47 -0
- package/src/timeline/build.ts +113 -13
- package/src/timeline/camera.ts +34 -11
- package/src/timeline/fades.ts +3 -0
- package/src/timeline/frames.ts +15 -4
- package/src/timeline/layout.ts +70 -0
- package/src/timeline/narration.ts +160 -0
- package/src/timeline/timemap.ts +40 -5
- package/src/timeline/types.ts +44 -1
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { run, runBuffer } from '../ffmpeg';
|
|
2
|
+
import { RATE } from './wav';
|
|
3
|
+
|
|
4
|
+
const LOUDNORM = (lufs: number) => `loudnorm=I=${lufs}:TP=-1.5:LRA=11`;
|
|
5
|
+
|
|
6
|
+
/** A synthesized line as a loudness-normalised mono 48 kHz 16-bit WAV. */
|
|
7
|
+
export async function normalizeVoice(src: string, dest: string): Promise<void> {
|
|
8
|
+
await run('ffmpeg', ['-v', 'error', '-y', '-i', src, '-af', LOUDNORM(-16), '-ac', '1', '-ar', String(RATE), '-c:a', 'pcm_s16le', '-f', 'wav', dest]);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Any audio file ffmpeg reads, as float channels at 48 kHz (optionally loudness-normalised). */
|
|
12
|
+
export async function decodeAudio(file: string, o: { channels: 1 | 2; lufs?: number }): Promise<Float32Array[]> {
|
|
13
|
+
const af = o.lufs === undefined ? [] : ['-af', LOUDNORM(o.lufs)];
|
|
14
|
+
const raw = await runBuffer('ffmpeg', ['-v', 'error', '-i', file, ...af, '-ac', String(o.channels), '-ar', String(RATE), '-f', 'f32le', '-']);
|
|
15
|
+
const n = Math.floor(raw.length / 4 / o.channels);
|
|
16
|
+
const channels = Array.from({ length: o.channels }, () => new Float32Array(n));
|
|
17
|
+
for (let i = 0; i < n; i++) for (let c = 0; c < o.channels; c++) channels[c][i] = raw.readFloatLE((i * o.channels + c) * 4);
|
|
18
|
+
return channels;
|
|
19
|
+
}
|
package/src/audio/mix.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The soundtrack mixer: voices, a looped music bed that ducks under them, and click/key sounds.
|
|
2
|
+
|
|
3
|
+
/** Music level while a voice plays. */
|
|
4
|
+
export const DUCK = 0.3;
|
|
5
|
+
export const DUCK_RAMP_MS = 300;
|
|
6
|
+
export const MUSIC_FADE_IN_MS = 800;
|
|
7
|
+
export const MUSIC_FADE_OUT_MS = 2000;
|
|
8
|
+
/** Peak the finished track is scaled under when parts overlap. */
|
|
9
|
+
const CEILING = 0.98;
|
|
10
|
+
|
|
11
|
+
export interface MixPlan {
|
|
12
|
+
rate: number;
|
|
13
|
+
totalMs: number;
|
|
14
|
+
/** mono voice clips; `atMs` from the start of the track */
|
|
15
|
+
voices: { samples: Float32Array; atMs: number }[];
|
|
16
|
+
/** one or two channels, looped */
|
|
17
|
+
music?: { channels: Float32Array[]; volume: number };
|
|
18
|
+
sounds: { samples: Float32Array; atMs: number; gain: number }[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
|
22
|
+
|
|
23
|
+
/** Music gain (before its volume) at `tMs`: fade in, fade out, and ducking around voice spans. */
|
|
24
|
+
export function musicGain(tMs: number, spans: { startMs: number; endMs: number }[], totalMs: number): number {
|
|
25
|
+
const fade = Math.min(clamp01(tMs / MUSIC_FADE_IN_MS), clamp01((totalMs - tMs) / MUSIC_FADE_OUT_MS));
|
|
26
|
+
let duck = 0;
|
|
27
|
+
for (const s of spans) {
|
|
28
|
+
duck = Math.max(duck, clamp01(Math.min((tMs - (s.startMs - DUCK_RAMP_MS)) / DUCK_RAMP_MS, (s.endMs + DUCK_RAMP_MS - tMs) / DUCK_RAMP_MS)));
|
|
29
|
+
}
|
|
30
|
+
return fade * (1 - (1 - DUCK) * duck);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function addMono(into: Float32Array[], samples: Float32Array, at: number, gain: number) {
|
|
34
|
+
const end = Math.min(into[0].length, at + samples.length);
|
|
35
|
+
for (let i = Math.max(0, at); i < end; i++) {
|
|
36
|
+
const v = samples[i - at] * gain;
|
|
37
|
+
into[0][i] += v;
|
|
38
|
+
into[1][i] += v;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Mix the plan into a stereo track of `totalMs`. */
|
|
43
|
+
export function mixTrack(plan: MixPlan): Float32Array[] {
|
|
44
|
+
const { rate } = plan;
|
|
45
|
+
const n = Math.round((plan.totalMs / 1000) * rate);
|
|
46
|
+
const out = [new Float32Array(n), new Float32Array(n)];
|
|
47
|
+
const at = (ms: number) => Math.round((ms / 1000) * rate);
|
|
48
|
+
|
|
49
|
+
if (plan.music && plan.music.channels[0]?.length) {
|
|
50
|
+
const src = plan.music.channels;
|
|
51
|
+
const len = src[0].length;
|
|
52
|
+
const spans = plan.voices.map((v) => ({ startMs: v.atMs, endMs: v.atMs + (v.samples.length / rate) * 1000 }));
|
|
53
|
+
// Gain per millisecond is plenty for ramps of hundreds of ms.
|
|
54
|
+
const gains = Float32Array.from({ length: Math.ceil(plan.totalMs) + 1 }, (_, ms) => musicGain(ms, spans, plan.totalMs) * plan.music!.volume);
|
|
55
|
+
for (let i = 0; i < n; i++) {
|
|
56
|
+
const g = gains[Math.floor((i / rate) * 1000)];
|
|
57
|
+
const j = i % len;
|
|
58
|
+
out[0][i] += src[0][j] * g;
|
|
59
|
+
out[1][i] += (src[1] ?? src[0])[j] * g;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const v of plan.voices) addMono(out, v.samples, at(v.atMs), 1);
|
|
63
|
+
for (const s of plan.sounds) addMono(out, s.samples, at(s.atMs), s.gain);
|
|
64
|
+
|
|
65
|
+
let peak = 0;
|
|
66
|
+
for (const ch of out) for (let i = 0; i < n; i++) peak = Math.max(peak, Math.abs(ch[i]));
|
|
67
|
+
if (peak > CEILING) {
|
|
68
|
+
const k = CEILING / peak;
|
|
69
|
+
for (const ch of out) for (let i = 0; i < n; i++) ch[i] *= k;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
package/src/audio/sfx.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Click and keystroke sounds, synthesized so the package ships no audio files.
|
|
2
|
+
|
|
3
|
+
/** Small deterministic PRNG (mulberry32), so every render sounds the same. */
|
|
4
|
+
function prng(seed: number) {
|
|
5
|
+
let a = seed >>> 0;
|
|
6
|
+
return () => {
|
|
7
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
8
|
+
let t = a;
|
|
9
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
10
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
11
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Voice {
|
|
16
|
+
ms: number;
|
|
17
|
+
seed: number;
|
|
18
|
+
noiseDecayMs: number;
|
|
19
|
+
tone: number;
|
|
20
|
+
toneDecayMs: number;
|
|
21
|
+
thump: number;
|
|
22
|
+
peak: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function synth(rate: number, v: Voice): Float32Array {
|
|
26
|
+
const n = Math.round((v.ms / 1000) * rate);
|
|
27
|
+
const out = new Float32Array(n);
|
|
28
|
+
const rand = prng(v.seed);
|
|
29
|
+
const fade = Math.round(0.005 * rate);
|
|
30
|
+
let low = 0;
|
|
31
|
+
let max = 0;
|
|
32
|
+
for (let i = 0; i < n; i++) {
|
|
33
|
+
const t = i / rate;
|
|
34
|
+
// A one-pole low-pass takes the hiss off the noise burst.
|
|
35
|
+
low += 0.35 * (rand() * 2 - 1 - low);
|
|
36
|
+
const s =
|
|
37
|
+
low * Math.exp(-t / (v.noiseDecayMs / 1000)) +
|
|
38
|
+
0.6 * Math.sin(2 * Math.PI * v.tone * t) * Math.exp(-t / (v.toneDecayMs / 1000)) +
|
|
39
|
+
v.thump * Math.sin(2 * Math.PI * 140 * t) * Math.exp(-t / 0.012);
|
|
40
|
+
out[i] = s * Math.min(1, (n - 1 - i) / fade);
|
|
41
|
+
max = Math.max(max, Math.abs(out[i]));
|
|
42
|
+
}
|
|
43
|
+
if (max > 0) for (let i = 0; i < n; i++) out[i] *= v.peak / max;
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A mouse click: a soft tick with a little body, 45 ms. */
|
|
48
|
+
export const clickSamples = (rate: number) =>
|
|
49
|
+
synth(rate, { ms: 45, seed: 7, noiseDecayMs: 4, tone: 1800, toneDecayMs: 9, thump: 0.5, peak: 0.9 });
|
|
50
|
+
|
|
51
|
+
/** A keystroke: a lighter, shorter tick; variants differ a little so typing doesn't drone. */
|
|
52
|
+
export const keySamples = (rate: number, variant: number) =>
|
|
53
|
+
synth(rate, { ms: 26, seed: 101 + variant, noiseDecayMs: 3, tone: 2600 + 350 * (variant % 4), toneDecayMs: 5, thump: 0.15, peak: 0.7 });
|
|
54
|
+
|
|
55
|
+
export const KEY_VARIANTS = 4;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { workPaths } from '../paths';
|
|
4
|
+
import type { LoadedScript } from '../script/load';
|
|
5
|
+
import { introFrames, totalFrames } from '../timeline/frames';
|
|
6
|
+
import type { Timeline } from '../timeline/types';
|
|
7
|
+
import { decodeAudio } from './decode';
|
|
8
|
+
import { mixTrack } from './mix';
|
|
9
|
+
import { clickSamples, KEY_VARIANTS, keySamples } from './sfx';
|
|
10
|
+
import type { VoiceClip } from './tts';
|
|
11
|
+
import { RATE, readWav, writeWav } from './wav';
|
|
12
|
+
|
|
13
|
+
export const CLICK_GAIN = 0.5;
|
|
14
|
+
export const KEY_GAIN = 0.2;
|
|
15
|
+
/** Music is normalised to this loudness before its volume applies (voices sit at -16 LUFS). */
|
|
16
|
+
const MUSIC_LUFS = -20;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Mix narration, music and sounds into `.showtime/<name>/audio/mix.wav`, as long as the whole video
|
|
20
|
+
* (cards included). Returns its path relative to the renderer's public folder, or null when the
|
|
21
|
+
* video has no sound.
|
|
22
|
+
*/
|
|
23
|
+
export async function mixSoundtrack(loaded: LoadedScript, t: Timeline, clips: Record<string, VoiceClip>): Promise<string | null> {
|
|
24
|
+
const music = loaded.script.audio.music;
|
|
25
|
+
if (!t.narration.length && !t.sounds.length && !music) return null;
|
|
26
|
+
const frameMs = 1000 / t.fps;
|
|
27
|
+
const introMs = introFrames(t) * frameMs;
|
|
28
|
+
const voices = await Promise.all(
|
|
29
|
+
t.narration.map(async (n) => ({ samples: (await readWav(clips[n.where].file)).channels[0], atMs: introMs + n.atMs })),
|
|
30
|
+
);
|
|
31
|
+
const click = clickSamples(RATE);
|
|
32
|
+
const keys = Array.from({ length: KEY_VARIANTS }, (_, i) => keySamples(RATE, i));
|
|
33
|
+
const sounds = t.sounds.map((s, i) =>
|
|
34
|
+
s.kind === 'click'
|
|
35
|
+
? { samples: click, atMs: introMs + s.atMs, gain: CLICK_GAIN }
|
|
36
|
+
: { samples: keys[i % KEY_VARIANTS], atMs: introMs + s.atMs, gain: KEY_GAIN },
|
|
37
|
+
);
|
|
38
|
+
const track = mixTrack({
|
|
39
|
+
rate: RATE,
|
|
40
|
+
totalMs: totalFrames(t) * frameMs,
|
|
41
|
+
voices,
|
|
42
|
+
sounds,
|
|
43
|
+
music: music ? { channels: await decodeAudio(music.file, { channels: 2, lufs: MUSIC_LUFS }), volume: music.volume } : undefined,
|
|
44
|
+
});
|
|
45
|
+
const paths = workPaths(loaded);
|
|
46
|
+
await mkdir(paths.audio, { recursive: true });
|
|
47
|
+
await writeWav(paths.mix, track);
|
|
48
|
+
return path.relative(paths.root, paths.mix).split(path.sep).join('/');
|
|
49
|
+
}
|
package/src/audio/tts.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { workPaths } from '../paths';
|
|
7
|
+
import type { LoadedScript } from '../script/load';
|
|
8
|
+
import type { Voice } from '../script/schema';
|
|
9
|
+
import { stepKey } from '../timeline/narration';
|
|
10
|
+
import { normalizeVoice } from './decode';
|
|
11
|
+
import { wavDurationMs } from './wav';
|
|
12
|
+
|
|
13
|
+
export const TTS_TIMEOUT_MS = 120_000;
|
|
14
|
+
/** `say` and `espeak-ng` speak at about this many words per minute at speed 1. */
|
|
15
|
+
const BASE_WPM = 175;
|
|
16
|
+
|
|
17
|
+
export interface EngineOptions {
|
|
18
|
+
/** Where API keys and base URLs come from (process.env by default). */
|
|
19
|
+
env?: NodeJS.ProcessEnv;
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface VoiceClip {
|
|
24
|
+
/** normalised mono 48 kHz WAV in the voice cache */
|
|
25
|
+
file: string;
|
|
26
|
+
durationMs: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Cache key of a line: every voice setting plus the text. */
|
|
30
|
+
export function voiceKey(voice: Voice, text: string): string {
|
|
31
|
+
const parts = [voice.engine, voice.name ?? null, voice.speed, voice.model ?? null, voice.instructions ?? null, voice.command ?? null, text];
|
|
32
|
+
return createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const tail = (s: string) => {
|
|
36
|
+
const t = s.trim().slice(-600);
|
|
37
|
+
return t ? `\n${t}` : '';
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Run a program (or a shell command) with optional stdin; reject with the exit code and output tail. */
|
|
41
|
+
function exec(cmd: string, args: string[], o: { input?: string; shell?: boolean; env?: NodeJS.ProcessEnv; timeoutMs: number }): Promise<void> {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const child = spawn(cmd, args, { shell: o.shell ?? false, env: o.env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
44
|
+
let output = '';
|
|
45
|
+
const keep = (d: Buffer) => {
|
|
46
|
+
output = (output + d.toString()).slice(-4000);
|
|
47
|
+
};
|
|
48
|
+
child.stdout.on('data', keep);
|
|
49
|
+
child.stderr.on('data', keep);
|
|
50
|
+
const timer = setTimeout(() => {
|
|
51
|
+
child.kill('SIGKILL');
|
|
52
|
+
reject(new Error(`timed out after ${o.timeoutMs / 1000} s${tail(output)}`));
|
|
53
|
+
}, o.timeoutMs);
|
|
54
|
+
child.on('error', (err: NodeJS.ErrnoException) => {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
reject(err.code === 'ENOENT' ? new Error(`${cmd} not found`) : err);
|
|
57
|
+
});
|
|
58
|
+
child.on('close', (code) => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
if (code === 0) resolve();
|
|
61
|
+
else reject(new Error(`exit code ${code}${tail(output)}`));
|
|
62
|
+
});
|
|
63
|
+
child.stdin.on('error', () => undefined); // a command that ignores stdin is fine
|
|
64
|
+
child.stdin.end(o.input ?? '');
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const quote = (s: string) => (process.platform === 'win32' ? `"${s}"` : `'${s.replace(/'/g, `'\\''`)}'`);
|
|
69
|
+
|
|
70
|
+
async function post(url: string, headers: Record<string, string>, body: unknown, out: string, timeoutMs: number): Promise<void> {
|
|
71
|
+
const res = await fetch(url, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
76
|
+
});
|
|
77
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}${tail(await res.text().catch(() => ''))}`);
|
|
78
|
+
await writeFile(out, Buffer.from(await res.arrayBuffer()));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function keyFrom(env: NodeJS.ProcessEnv, name: string): string {
|
|
82
|
+
const key = env[name];
|
|
83
|
+
if (!key) throw new Error(`${name} is not set. Export it in the shell that runs showtime.`);
|
|
84
|
+
return key;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Speak `text` with the voice's engine into `<base>.<ext>` and return that path. The text never
|
|
89
|
+
* reaches a command line: `say`/`espeak-ng` read it from a file, `command` from stdin.
|
|
90
|
+
*/
|
|
91
|
+
export async function synthesize(voice: Voice, text: string, base: string, opts: EngineOptions = {}): Promise<string> {
|
|
92
|
+
const env = opts.env ?? process.env;
|
|
93
|
+
const timeoutMs = opts.timeoutMs ?? TTS_TIMEOUT_MS;
|
|
94
|
+
switch (voice.engine) {
|
|
95
|
+
case 'system': {
|
|
96
|
+
const txt = `${base}.txt`;
|
|
97
|
+
await writeFile(txt, text);
|
|
98
|
+
const rate = voice.speed === 1 ? [] : [String(Math.round(BASE_WPM * voice.speed))];
|
|
99
|
+
if (process.platform === 'darwin') {
|
|
100
|
+
const out = `${base}.aiff`;
|
|
101
|
+
const args = ['-f', txt, '-o', out, ...(voice.name ? ['-v', voice.name] : []), ...(rate.length ? ['-r', ...rate] : [])];
|
|
102
|
+
await exec('say', args, { timeoutMs, env });
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
if (process.platform === 'linux') {
|
|
106
|
+
const out = `${base}.wav`;
|
|
107
|
+
const args = ['-f', txt, '-w', out, ...(voice.name ? ['-v', voice.name] : []), ...(rate.length ? ['-s', ...rate] : [])];
|
|
108
|
+
await exec('espeak-ng', args, { timeoutMs, env }).catch((err: Error) => {
|
|
109
|
+
throw new Error(`${err.message}. Install espeak-ng (apt install espeak-ng) or set audio.voice.engine to openai, elevenlabs or command.`);
|
|
110
|
+
});
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
throw new Error('the system voice works on macOS and Linux; set audio.voice.engine to openai, elevenlabs or command.');
|
|
114
|
+
}
|
|
115
|
+
case 'openai': {
|
|
116
|
+
const key = keyFrom(env, 'OPENAI_API_KEY');
|
|
117
|
+
const url = `${(env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1').replace(/\/$/, '')}/audio/speech`;
|
|
118
|
+
const out = `${base}.wav`;
|
|
119
|
+
const body = {
|
|
120
|
+
model: voice.model ?? 'gpt-4o-mini-tts',
|
|
121
|
+
voice: voice.name ?? 'alloy',
|
|
122
|
+
input: text,
|
|
123
|
+
response_format: 'wav',
|
|
124
|
+
speed: voice.speed,
|
|
125
|
+
...(voice.instructions ? { instructions: voice.instructions } : {}),
|
|
126
|
+
};
|
|
127
|
+
await post(url, { authorization: `Bearer ${key}` }, body, out, timeoutMs);
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
case 'elevenlabs': {
|
|
131
|
+
const key = keyFrom(env, 'ELEVENLABS_API_KEY');
|
|
132
|
+
const root = (env.ELEVENLABS_BASE_URL ?? 'https://api.elevenlabs.io').replace(/\/$/, '');
|
|
133
|
+
const url = `${root}/v1/text-to-speech/${encodeURIComponent(voice.name ?? '')}?output_format=mp3_44100_128`;
|
|
134
|
+
const out = `${base}.mp3`;
|
|
135
|
+
const body = { text, model_id: voice.model ?? 'eleven_multilingual_v2', voice_settings: { speed: Math.min(1.2, Math.max(0.7, voice.speed)) } };
|
|
136
|
+
await post(url, { 'xi-api-key': key }, body, out, timeoutMs);
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
case 'command': {
|
|
140
|
+
// .wav so tools that pick the format from the extension (say -o, sox) write something ffmpeg reads.
|
|
141
|
+
const out = `${base}.wav`;
|
|
142
|
+
const command = (voice.command ?? '').replaceAll('{out}', quote(out));
|
|
143
|
+
await exec(command, [], { shell: true, input: text, env: { ...env, SHOWTIME_OUT: out }, timeoutMs });
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Synthesize every `show.say` line that is not cached yet, and return all lines by step. Lines are
|
|
151
|
+
* normalised (loudness, mono 48 kHz WAV) into `.showtime/<name>/audio/voice-<key>.wav`.
|
|
152
|
+
*/
|
|
153
|
+
export async function ensureVoices(
|
|
154
|
+
loaded: LoadedScript,
|
|
155
|
+
opts: EngineOptions & { log?: (line: string) => void } = {},
|
|
156
|
+
): Promise<Record<string, VoiceClip>> {
|
|
157
|
+
const { voice } = loaded.script.audio;
|
|
158
|
+
const dir = workPaths(loaded).audio;
|
|
159
|
+
const clips: Record<string, VoiceClip> = {};
|
|
160
|
+
for (const scene of loaded.script.scenes) {
|
|
161
|
+
for (const [index, step] of scene.steps.entries()) {
|
|
162
|
+
const text = step.show?.say;
|
|
163
|
+
if (!text) continue;
|
|
164
|
+
const where = stepKey(scene.id, index);
|
|
165
|
+
const key = voiceKey(voice, text);
|
|
166
|
+
const file = path.join(dir, `voice-${key}.wav`);
|
|
167
|
+
if (!existsSync(file)) {
|
|
168
|
+
await mkdir(dir, { recursive: true });
|
|
169
|
+
opts.log?.(`Voice for ${where}…`);
|
|
170
|
+
const base = path.join(dir, `tmp-${key}`);
|
|
171
|
+
try {
|
|
172
|
+
const raw = await synthesize(voice, text, base, opts);
|
|
173
|
+
await normalizeVoice(raw, `${file}.part`);
|
|
174
|
+
await rename(`${file}.part`, file);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
throw new Error(`audio.voice (${voice.engine}) failed for ${where}: ${(err as Error).message}`);
|
|
177
|
+
} finally {
|
|
178
|
+
for (const ext of ['.txt', '.aiff', '.wav', '.mp3']) await rm(`${base}${ext}`, { force: true });
|
|
179
|
+
await rm(`${file}.part`, { force: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
clips[where] = { file, durationMs: await wavDurationMs(file) };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return clips;
|
|
186
|
+
}
|
package/src/audio/wav.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { open, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
/** Sample rate of everything showTime mixes. */
|
|
4
|
+
export const RATE = 48_000;
|
|
5
|
+
const HEADER = 44;
|
|
6
|
+
|
|
7
|
+
/** 16-bit PCM WAV from channels of float samples in [-1, 1] (out-of-range samples clip). */
|
|
8
|
+
export function encodeWav(channels: Float32Array[], rate = RATE): Buffer {
|
|
9
|
+
const n = channels[0]?.length ?? 0;
|
|
10
|
+
const ch = channels.length;
|
|
11
|
+
const data = n * ch * 2;
|
|
12
|
+
const buf = Buffer.alloc(HEADER + data);
|
|
13
|
+
buf.write('RIFF', 0, 'ascii');
|
|
14
|
+
buf.writeUInt32LE(36 + data, 4);
|
|
15
|
+
buf.write('WAVE', 8, 'ascii');
|
|
16
|
+
buf.write('fmt ', 12, 'ascii');
|
|
17
|
+
buf.writeUInt32LE(16, 16);
|
|
18
|
+
buf.writeUInt16LE(1, 20); // PCM
|
|
19
|
+
buf.writeUInt16LE(ch, 22);
|
|
20
|
+
buf.writeUInt32LE(rate, 24);
|
|
21
|
+
buf.writeUInt32LE(rate * ch * 2, 28);
|
|
22
|
+
buf.writeUInt16LE(ch * 2, 32);
|
|
23
|
+
buf.writeUInt16LE(16, 34);
|
|
24
|
+
buf.write('data', 36, 'ascii');
|
|
25
|
+
buf.writeUInt32LE(data, 40);
|
|
26
|
+
let o = HEADER;
|
|
27
|
+
for (let i = 0; i < n; i++) {
|
|
28
|
+
for (let c = 0; c < ch; c++) {
|
|
29
|
+
const v = Math.max(-1, Math.min(1, channels[c][i]));
|
|
30
|
+
buf.writeInt16LE(Math.round(v * 32767), o);
|
|
31
|
+
o += 2;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return buf;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Format {
|
|
38
|
+
channels: number;
|
|
39
|
+
rate: number;
|
|
40
|
+
dataStart: number;
|
|
41
|
+
dataBytes: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Find the fmt and data chunks of a 16-bit PCM WAV. */
|
|
45
|
+
function parseHeader(buf: Buffer): Format {
|
|
46
|
+
const bad = () => new Error('not a 16-bit PCM WAV file');
|
|
47
|
+
if (buf.length < 12 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WAVE') throw bad();
|
|
48
|
+
let o = 12;
|
|
49
|
+
let fmt: Omit<Format, 'dataStart' | 'dataBytes'> | null = null;
|
|
50
|
+
while (o + 8 <= buf.length) {
|
|
51
|
+
const id = buf.toString('ascii', o, o + 4);
|
|
52
|
+
const size = buf.readUInt32LE(o + 4);
|
|
53
|
+
if (id === 'fmt ') {
|
|
54
|
+
if (buf.readUInt16LE(o + 8) !== 1 || buf.readUInt16LE(o + 22) !== 16) throw bad();
|
|
55
|
+
fmt = { channels: buf.readUInt16LE(o + 10), rate: buf.readUInt32LE(o + 12) };
|
|
56
|
+
} else if (id === 'data') {
|
|
57
|
+
if (!fmt) throw bad();
|
|
58
|
+
return { ...fmt, dataStart: o + 8, dataBytes: Math.min(size, buf.length - o - 8) };
|
|
59
|
+
}
|
|
60
|
+
o += 8 + size + (size % 2);
|
|
61
|
+
}
|
|
62
|
+
throw bad();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function decodeWav(buf: Buffer): { rate: number; channels: Float32Array[] } {
|
|
66
|
+
const f = parseHeader(buf);
|
|
67
|
+
const n = Math.floor(f.dataBytes / (2 * f.channels));
|
|
68
|
+
const channels = Array.from({ length: f.channels }, () => new Float32Array(n));
|
|
69
|
+
let o = f.dataStart;
|
|
70
|
+
for (let i = 0; i < n; i++) {
|
|
71
|
+
for (let c = 0; c < f.channels; c++) {
|
|
72
|
+
channels[c][i] = buf.readInt16LE(o) / 32767;
|
|
73
|
+
o += 2;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { rate: f.rate, channels };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const writeWav = (file: string, channels: Float32Array[], rate = RATE) => writeFile(file, encodeWav(channels, rate));
|
|
80
|
+
|
|
81
|
+
export const readWav = async (file: string) => decodeWav(await readFile(file));
|
|
82
|
+
|
|
83
|
+
/** Length of a WAV file from its header alone. */
|
|
84
|
+
export async function wavDurationMs(file: string): Promise<number> {
|
|
85
|
+
const fh = await open(file, 'r');
|
|
86
|
+
try {
|
|
87
|
+
const head = Buffer.alloc(4096);
|
|
88
|
+
const { bytesRead } = await fh.read(head, 0, head.length, 0);
|
|
89
|
+
const f = parseHeader(head.subarray(0, bytesRead));
|
|
90
|
+
const { size } = await fh.stat();
|
|
91
|
+
const bytes = Math.min(size - f.dataStart, head.readUInt32LE(f.dataStart - 4));
|
|
92
|
+
return Math.round((bytes / (2 * f.channels) / f.rate) * 1000);
|
|
93
|
+
} finally {
|
|
94
|
+
await fh.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/cli/format.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import type { Issue } from '../lint';
|
|
2
3
|
import type { CheckReport } from '../runner/check';
|
|
3
4
|
|
|
4
5
|
const ARIA_LINES = 80;
|
|
@@ -17,3 +18,12 @@ export function formatCheck(report: CheckReport): string {
|
|
|
17
18
|
...aria.map((line) => ` ${line}`),
|
|
18
19
|
].join('\n');
|
|
19
20
|
}
|
|
21
|
+
|
|
22
|
+
export function formatLint(issues: Issue[], timing: boolean): string {
|
|
23
|
+
const lines = issues.map((i) => `${i.level === 'warn' ? '⚠' : 'ℹ'} ${i.where} ${i.message} [${i.rule}]`);
|
|
24
|
+
const warns = issues.filter((i) => i.level === 'warn').length;
|
|
25
|
+
const notes = issues.length - warns;
|
|
26
|
+
lines.push(issues.length ? `${warns} warning${warns === 1 ? '' : 's'}, ${notes} note${notes === 1 ? '' : 's'}` : '✓ no issues');
|
|
27
|
+
if (!timing) lines.push('(record a take to also check timing)');
|
|
28
|
+
return lines.join('\n');
|
|
29
|
+
}
|