@@ -36,7 +36,7 @@ import {
3636} from "fs" ;
3737import { join , relative , dirname , basename , extname } from "path" ;
3838import { fileURLToPath } from "url" ;
39- import { spawn } from "child_process" ;
39+ import { spawn , spawnSync } from "child_process" ;
4040import * as readline from "readline" ;
4141
4242// ES module __dirname equivalent
@@ -59,6 +59,44 @@ const API_KEY =
5959 process . env . GEMINI_API_KEY ||
6060 process . env . GCP_API_KEY ;
6161
62+ /**
63+ * Check if ffmpeg is available with libmp3lame support
64+ */
65+ function checkFfmpegAvailable ( ) {
66+ const result = spawnSync ( "ffmpeg" , [ "-version" ] , {
67+ encoding : "utf-8" ,
68+ stdio : [ "pipe" , "pipe" , "pipe" ] ,
69+ } ) ;
70+
71+ if ( result . error || result . status !== 0 ) {
72+ console . error ( `
73+ Error: ffmpeg is required for MP3 conversion but was not found.
74+
75+ Install ffmpeg with:
76+ brew install ffmpeg
77+
78+ Then run this script again.
79+ ` ) ;
80+ process . exit ( 1 ) ;
81+ }
82+
83+ // Check for libmp3lame support
84+ const output = result . stdout || "" ;
85+ if ( ! output . includes ( "--enable-libmp3lame" ) ) {
86+ console . error ( `
87+ Error: ffmpeg is installed but lacks libmp3lame support for MP3 encoding.
88+
89+ Reinstall ffmpeg with MP3 support:
90+ brew reinstall ffmpeg
91+
92+ Then run this script again.
93+ ` ) ;
94+ process . exit ( 1 ) ;
95+ }
96+
97+ return true ;
98+ }
99+
62100// Parse command-line arguments
63101function parseArgs ( ) {
64102 const args = process . argv . slice ( 2 ) ;
@@ -1478,6 +1516,48 @@ async function generateAudio(dialogue, outputPath, genAI) {
14781516 } ;
14791517}
14801518
1519+ /**
1520+ * Convert WAV file to MP3 using ffmpeg with high-quality settings
1521+ */
1522+ async function convertWavToMp3 ( wavPath , mp3Path ) {
1523+ return new Promise ( ( resolve , reject ) => {
1524+ const ffmpeg = spawn ( "ffmpeg" , [
1525+ "-y" , // Overwrite output file
1526+ "-i" ,
1527+ wavPath ,
1528+ "-codec:a" ,
1529+ "libmp3lame" ,
1530+ "-q:a" ,
1531+ "2" , // VBR quality 2 (~190 kbps) - high quality for voice
1532+ mp3Path ,
1533+ ] ) ;
1534+
1535+ let stderr = "" ;
1536+ ffmpeg . stderr . on ( "data" , ( data ) => {
1537+ stderr += data . toString ( ) ;
1538+ } ) ;
1539+
1540+ ffmpeg . on ( "close" , ( code ) => {
1541+ if ( code === 0 ) {
1542+ // Delete the intermediate WAV file
1543+ unlinkSync ( wavPath ) ;
1544+
1545+ const stats = statSync ( mp3Path ) ;
1546+ console . log (
1547+ ` 🔊 Converted to MP3: ${ ( stats . size / 1024 / 1024 ) . toFixed ( 2 ) } MB` ,
1548+ ) ;
1549+ resolve ( { size : stats . size , format : "audio/mp3" } ) ;
1550+ } else {
1551+ reject ( new Error ( `ffmpeg failed with code ${ code } : ${ stderr } ` ) ) ;
1552+ }
1553+ } ) ;
1554+
1555+ ffmpeg . on ( "error" , ( err ) => {
1556+ reject ( new Error ( `Failed to spawn ffmpeg: ${ err . message } ` ) ) ;
1557+ } ) ;
1558+ } ) ;
1559+ }
1560+
14811561// ============================================================================
14821562// FILE DISCOVERY AND SELECTION
14831563// ============================================================================
@@ -1719,32 +1799,37 @@ async function generateAudioFromScript(scriptPath, audioManifest, genAI) {
17191799 ` 📊 Estimated tokens: ${ frontmatter . tokenCount || "unknown" } ` ,
17201800 ) ;
17211801
1722- // Determine audio output path
1802+ // Determine audio output paths
17231803 const relativeDir = dirname ( relativePath ) ;
1724- const outputFileName = `${ fileName } .wav` ;
1725- const outputPath = join ( AUDIO_OUTPUT_DIR , relativeDir , outputFileName ) ;
1804+ const wavFileName = `${ fileName } .wav` ;
1805+ const mp3FileName = `${ fileName } .mp3` ;
1806+ const wavPath = join ( AUDIO_OUTPUT_DIR , relativeDir , wavFileName ) ;
1807+ const mp3Path = join ( AUDIO_OUTPUT_DIR , relativeDir , mp3FileName ) ;
1808+
1809+ // Generate WAV audio first
1810+ const wavInfo = await generateAudio ( dialog , wavPath , genAI ) ;
17261811
1727- // Generate audio
1728- const audioInfo = await generateAudio ( dialog , outputPath , genAI ) ;
1812+ // Convert WAV to MP3
1813+ const mp3Info = await convertWavToMp3 ( wavPath , mp3Path ) ;
17291814
17301815 // Update manifest using the source doc path as key
1731- const audioUrl = `/audio/${ join ( relativeDir , outputFileName ) } ` ;
1816+ const audioUrl = `/audio/${ join ( relativeDir , mp3FileName ) } ` ;
17321817 audioManifest [ frontmatter . source ] = {
17331818 audioUrl,
1734- size : audioInfo . size ,
1735- format : audioInfo . format ,
1736- tokenCount : audioInfo . tokenCount ,
1737- chunks : audioInfo . chunks ,
1819+ size : mp3Info . size ,
1820+ format : mp3Info . format ,
1821+ tokenCount : wavInfo . tokenCount ,
1822+ chunks : wavInfo . chunks ,
17381823 generatedAt : new Date ( ) . toISOString ( ) ,
17391824 scriptSource : relativePath ,
17401825 } ;
17411826
17421827 console . log ( ` ✅ Generated: ${ audioUrl } ` ) ;
17431828 console . log (
1744- ` 📊 Audio size: ${ ( audioInfo . size / 1024 / 1024 ) . toFixed ( 2 ) } MB` ,
1829+ ` 📊 Audio size: ${ ( mp3Info . size / 1024 / 1024 ) . toFixed ( 2 ) } MB` ,
17451830 ) ;
1746- if ( audioInfo . chunks > 1 ) {
1747- console . log ( ` 🧩 Chunks: ${ audioInfo . chunks } ` ) ;
1831+ if ( wavInfo . chunks > 1 ) {
1832+ console . log ( ` 🧩 Chunks: ${ wavInfo . chunks } ` ) ;
17481833 }
17491834 } catch ( error ) {
17501835 console . error ( ` ❌ Error: ${ error . message } ` ) ;
@@ -1810,7 +1895,7 @@ async function main() {
18101895 console . log ( `📋 Pipeline: ${ config . pipeline } ` ) ;
18111896 console . log ( `📋 Mode: ${ config . mode } ` ) ;
18121897
1813- // Initialize Gemini API if needed for audio
1898+ // Initialize Gemini API and check ffmpeg if needed for audio
18141899 let genAI = null ;
18151900 if ( config . pipeline !== "script-only" ) {
18161901 if ( ! API_KEY ) {
@@ -1820,6 +1905,7 @@ async function main() {
18201905 ) ;
18211906 process . exit ( 1 ) ;
18221907 }
1908+ checkFfmpegAvailable ( ) ;
18231909 genAI = new GoogleGenerativeAI ( API_KEY ) ;
18241910 }
18251911
0 commit comments