Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 8 additions & 27 deletions rerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url";
import { ScriptSchema } from "./src/render/script-schema.js";
import { loadConfig } from "./src/config.js";
import { getDurationSec, concatWithSilence, mixSfxOntoVoice, type SfxMixSpec } from "./src/assets/audio-tools.js";
import { indexSfxLibrary, pickSfxForScene, defaultPlayback } from "./src/assets/sfx-selector.js";
import { indexSfxLibrary, pickSfxForScenes } from "./src/assets/sfx-selector.js";
import { existsSync } from "node:fs";
import { composeHtml } from "./src/render/html-composer.js";
import { renderWithHyperframes } from "./src/render/hyperframes-runner.js";
Expand Down Expand Up @@ -64,33 +64,14 @@ async function main() {
sceneStarts[a.id] = cursor;
cursor += a.durationSec + SCENE_GAP_SEC;
}
// Smart SFX selection — same 3-tier logic as pipeline
const sfxIndex = indexSfxLibrary(SFX_DIR);
const sfxList: SfxMixSpec[] = [];
for (const scene of script.scenes) {
const startSec = sceneStarts[scene.id];
if (scene.sfx) {
if (scene.sfx.name === "none") continue;
const sfxPath = join(SFX_DIR, `${scene.sfx.name}.mp3`);
if (existsSync(sfxPath)) {
sfxList.push({ path: sfxPath, startSec: startSec + scene.sfx.startOffsetSec, volume: scene.sfx.volume });
console.log(` scene ${scene.id}: SFX override -> ${scene.sfx.name}`);
}
continue;
}
const picked = pickSfxForScene({
voiceText: scene.voiceText,
templateName: scene.templateData.template,
sceneId: scene.id,
index: sfxIndex,
});
if (!picked) continue;
const sfxPath = join(SFX_DIR, picked.relPath);
const playback = defaultPlayback(picked);
sfxList.push({ path: sfxPath, startSec: startSec + playback.offsetSec, volume: playback.volume });
const why = picked.source === "semantic" ? `semantic "${picked.matchedKeyword}"` : picked.source;
console.log(` scene ${scene.id}: SFX -> ${picked.relPath} (${why})`);
}
const sfxList: SfxMixSpec[] = pickSfxForScenes({
scenes: script.scenes,
sceneStarts,
sfxIndex,
sfxDir: SFX_DIR,
logger: { info: (m) => console.log(m), warn: () => {} },
});
console.log(`mixing ${sfxList.length} SFX into voice.mp3`);
await mixSfxOntoVoice(voiceRawMp3, sfxList, voiceMp3);

Expand Down
172 changes: 172 additions & 0 deletions src/assets/sfx-scenes-picker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { describe, it, expect, vi } from "vitest";
import { pickSfxForScenes, type SfxIndex } from "./sfx-selector.js";
import type { Script } from "../render/script-schema.js";

// Control existsSync via this mutable map: path suffix → boolean
const existsMap: Record<string, boolean> = {};

vi.mock("node:fs", async (importOriginal) => {
const real = await importOriginal<typeof import("node:fs")>();
return {
...real,
existsSync: (p: string) => {
for (const [key, val] of Object.entries(existsMap)) {
if (p.includes(key)) return val;
}
return false;
},
};
});

type Scene = Script["scenes"][number];

const FAKE_INDEX: SfxIndex = {
transition: ["whoosh.mp3"],
success: ["win.mp3"],
alert: ["alarm.mp3"],
emphasis: ["ding.mp3"],
outro: ["tada.mp3"],
};

const FAKE_SFX_DIR = "/fake/sfx";

function makeScene(opts: { id: string; voiceText: string; template: string; sfx?: Scene["sfx"] }): Scene {
return {
id: opts.id,
type: "body",
voiceText: opts.voiceText,
templateData: { template: opts.template, title: "Test", bullets: ["bullet"] } as Scene["templateData"],
sfx: opts.sfx,
} as Scene;
}

describe("pickSfxForScenes", () => {
it("returns empty list when scenes is empty", () => {
const result = pickSfxForScenes({
scenes: [],
sceneStarts: {},
sfxIndex: FAKE_INDEX,
sfxDir: FAKE_SFX_DIR,
});
expect(result).toEqual([]);
});

it("uses explicit sfx override when file exists on disk", () => {
existsMap["transition/whoosh.mp3"] = true;

const scenes: Scene[] = [
makeScene({
id: "s1",
voiceText: "anything",
template: "hook",
sfx: { name: "transition/whoosh", volume: 0.5, startOffsetSec: 0.1 },
}),
];
const result = pickSfxForScenes({
scenes,
sceneStarts: { s1: 5 },
sfxIndex: FAKE_INDEX,
sfxDir: FAKE_SFX_DIR,
});
expect(result).toHaveLength(1);
expect(result[0].path).toContain("transition/whoosh.mp3");
expect(result[0].startSec).toBeCloseTo(5.1);
expect(result[0].volume).toBe(0.5);

delete existsMap["transition/whoosh.mp3"];
});

it('skips scene when sfx.name is "none"', () => {
const scenes: Scene[] = [
makeScene({
id: "s1",
voiceText: "anything",
template: "hook",
sfx: { name: "none", volume: 0.4, startOffsetSec: 0 },
}),
];
const result = pickSfxForScenes({
scenes,
sceneStarts: { s1: 0 },
sfxIndex: FAKE_INDEX,
sfxDir: FAKE_SFX_DIR,
});
expect(result).toHaveLength(0);
});

it("picks semantically matched sfx when voiceText triggers a keyword rule", () => {
const scenes: Scene[] = [
makeScene({
id: "s1",
voiceText: "Mô hình đạt kỷ lục mới trong năm nay.",
template: "stat-hero",
}),
];
const result = pickSfxForScenes({
scenes,
sceneStarts: { s1: 2 },
sfxIndex: FAKE_INDEX,
sfxDir: FAKE_SFX_DIR,
});
expect(result).toHaveLength(1);
expect(result[0].path).toContain("success/");
});

it("falls back to template default when no semantic keyword matches", () => {
const scenes: Scene[] = [
makeScene({
id: "s1",
voiceText: "Một thông tin trung lập không chứa từ khóa đặc biệt.",
template: "outro",
}),
];
const result = pickSfxForScenes({
scenes,
sceneStarts: { s1: 10 },
sfxIndex: FAKE_INDEX,
sfxDir: FAKE_SFX_DIR,
});
expect(result).toHaveLength(1);
expect(result[0].path).toContain("outro/");
});

it("passes log messages to the provided logger", () => {
const infos: string[] = [];
const scenes: Scene[] = [
makeScene({
id: "s1",
voiceText: "neutral",
template: "outro",
}),
];
pickSfxForScenes({
scenes,
sceneStarts: { s1: 0 },
sfxIndex: FAKE_INDEX,
sfxDir: FAKE_SFX_DIR,
logger: { info: (m) => infos.push(m), warn: () => {} },
});
expect(infos.some((m) => m.includes("s1"))).toBe(true);
});

it("skips explicit sfx and warns when file does not exist on disk", () => {
const warns: string[] = [];
const scenes: Scene[] = [
makeScene({
id: "s1",
voiceText: "anything",
template: "hook",
sfx: { name: "transition/nonexistent-file", volume: 0.4, startOffsetSec: 0 },
}),
];
const result = pickSfxForScenes({
scenes,
sceneStarts: { s1: 0 },
sfxIndex: FAKE_INDEX,
sfxDir: FAKE_SFX_DIR,
logger: { info: () => {}, warn: (m) => warns.push(m) },
});
expect(result).toHaveLength(0);
expect(warns.some((m) => m.includes("nonexistent-file"))).toBe(true);
});
});
65 changes: 65 additions & 0 deletions src/assets/sfx-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@

import { readdirSync, statSync, existsSync } from "node:fs";
import { join } from "node:path";
import type { SfxMixSpec } from "./audio-tools.js";
import type { Script } from "../render/script-schema.js";

export interface SfxIndex {
/** category → list of mp3 filenames (just the filename, not full path) */
Expand Down Expand Up @@ -187,6 +189,69 @@ export function pickSfxForScene(args: {
return null;
}

export interface SfxPickerLogger {
info(msg: string): void;
warn(msg: string): void;
}

const NO_LOG: SfxPickerLogger = { info: () => {}, warn: () => {} };

/**
* Build the SFX mix list for all scenes, applying the 3-tier selection
* strategy (explicit override → semantic → template default).
*/
export function pickSfxForScenes(args: {
scenes: Script["scenes"];
sceneStarts: Record<string, number>;
sfxIndex: SfxIndex;
sfxDir: string;
logger?: SfxPickerLogger;
}): SfxMixSpec[] {
const { scenes, sceneStarts, sfxIndex, sfxDir, logger = NO_LOG } = args;
const sfxList: SfxMixSpec[] = [];

for (const scene of scenes) {
const startSec = sceneStarts[scene.id];

if (scene.sfx) {
if (scene.sfx.name === "none") {
logger.info(` scene ${scene.id}: SFX disabled (explicit "none")`);
continue;
}
const sfxPath = join(sfxDir, `${scene.sfx.name}.mp3`);
if (existsSync(sfxPath)) {
sfxList.push({ path: sfxPath, startSec: startSec + scene.sfx.startOffsetSec, volume: scene.sfx.volume });
logger.info(` scene ${scene.id}: SFX override -> ${scene.sfx.name}.mp3`);
} else {
logger.warn(` scene ${scene.id}: explicit SFX not found, skipping: ${scene.sfx.name}.mp3`);
}
continue;
}

const picked = pickSfxForScene({
voiceText: scene.voiceText,
templateName: scene.templateData.template,
sceneId: scene.id,
index: sfxIndex,
});
if (!picked) {
logger.warn(` scene ${scene.id}: no SFX available (empty library?)`);
continue;
}

const sfxPath = join(sfxDir, picked.relPath);
const playback = defaultPlayback(picked);
sfxList.push({ path: sfxPath, startSec: startSec + playback.offsetSec, volume: playback.volume });

const why = picked.source === "semantic"
? `semantic match "${picked.matchedKeyword}"`
: picked.source;
logger.info(` scene ${scene.id}: SFX -> ${picked.relPath} (${why})`);
}

return sfxList;
}

/** Recommended volume + offset per source/category */
export function defaultPlayback(picked: PickedSfx): { volume: number; offsetSec: number } {
const cat = picked.relPath.split("/")[0];
Expand Down
50 changes: 8 additions & 42 deletions src/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { loadConfig } from "./config.js";
import { createTtsClient } from "./tts/tts-client.js";
import { fetchImage } from "./assets/image-fetcher.js";
import { getDurationSec, concatWithSilence, mixSfxOntoVoice, type SfxMixSpec } from "./assets/audio-tools.js";
import { indexSfxLibrary, pickSfxForScene, defaultPlayback } from "./assets/sfx-selector.js";
import { indexSfxLibrary, pickSfxForScenes } from "./assets/sfx-selector.js";
import { existsSync } from "node:fs";
import { composeHtml } from "./render/html-composer.js";
import { renderWithHyperframes } from "./render/hyperframes-runner.js";
Expand Down Expand Up @@ -126,47 +126,13 @@ export async function runPipeline(scriptPath: string): Promise<void> {
const indexFiles = Object.values(sfxIndex).reduce((s, a) => s + a.length, 0);
log.info(` SFX library: ${indexFiles} files in ${indexCats} categories`);

const sfxList: SfxMixSpec[] = [];
for (const scene of script.scenes) {
const startSec = sceneStarts[scene.id];

// Tier 1: explicit override in script.json
if (scene.sfx) {
if (scene.sfx.name === "none") {
log.info(` scene ${scene.id}: SFX disabled (explicit "none")`);
continue;
}
const sfxPath = join(SFX_DIR, `${scene.sfx.name}.mp3`);
if (existsSync(sfxPath)) {
sfxList.push({ path: sfxPath, startSec: startSec + scene.sfx.startOffsetSec, volume: scene.sfx.volume });
log.info(` scene ${scene.id}: SFX override -> ${scene.sfx.name}.mp3`);
} else {
log.warn(` scene ${scene.id}: explicit SFX not found, skipping: ${scene.sfx.name}.mp3`);
}
continue;
}

// Tier 2/3: smart selection by content + template
const picked = pickSfxForScene({
voiceText: scene.voiceText,
templateName: scene.templateData.template,
sceneId: scene.id,
index: sfxIndex,
});
if (!picked) {
log.warn(` scene ${scene.id}: no SFX available (empty library?)`);
continue;
}

const sfxPath = join(SFX_DIR, picked.relPath);
const playback = defaultPlayback(picked);
sfxList.push({ path: sfxPath, startSec: startSec + playback.offsetSec, volume: playback.volume });

const why = picked.source === "semantic"
? `semantic match "${picked.matchedKeyword}"`
: picked.source;
log.info(` scene ${scene.id}: SFX -> ${picked.relPath} (${why})`);
}
const sfxList: SfxMixSpec[] = pickSfxForScenes({
scenes: script.scenes,
sceneStarts,
sfxIndex,
sfxDir: SFX_DIR,
logger: log,
});
log.info(` mixing ${sfxList.length} SFX into voice.mp3`);
await mixSfxOntoVoice(voiceRawMp3, sfxList, voiceMp3);

Expand Down