diff --git a/rerender.ts b/rerender.ts index c7ee219..144dd3c 100644 --- a/rerender.ts +++ b/rerender.ts @@ -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"; @@ -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); diff --git a/src/assets/sfx-scenes-picker.test.ts b/src/assets/sfx-scenes-picker.test.ts new file mode 100644 index 0000000..eb325aa --- /dev/null +++ b/src/assets/sfx-scenes-picker.test.ts @@ -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 = {}; + +vi.mock("node:fs", async (importOriginal) => { + const real = await importOriginal(); + 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); + }); +}); diff --git a/src/assets/sfx-selector.ts b/src/assets/sfx-selector.ts index 3f2da46..a5852d4 100644 --- a/src/assets/sfx-selector.ts +++ b/src/assets/sfx-selector.ts @@ -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) */ @@ -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; + 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]; diff --git a/src/pipeline.ts b/src/pipeline.ts index aedd397..c033319 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -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"; @@ -126,47 +126,13 @@ export async function runPipeline(scriptPath: string): Promise { 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);