diff --git a/Dockerfile b/Dockerfile index e04a168..f9b9a78 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,9 @@ # Use Node.js slim image FROM node:slim +# Add ffmpeg +RUN apt-get update && apt-get install -y ffmpeg + # Create app directory WORKDIR /app diff --git a/src/app/api/audio/convert/route.ts b/src/app/api/audio/convert/route.ts new file mode 100644 index 0000000..c516f8f --- /dev/null +++ b/src/app/api/audio/convert/route.ts @@ -0,0 +1,192 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { spawn } from 'child_process'; +import { writeFile, readFile, mkdir, unlink, rmdir } from 'fs/promises'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { randomUUID } from 'crypto'; + +interface Chapter { + title: string; + buffer: number[]; +} + +interface ConversionRequest { + chapters: Chapter[]; +} + +async function getAudioDuration(filePath: string): Promise { + return new Promise((resolve, reject) => { + const ffprobe = spawn('ffprobe', [ + '-i', filePath, + '-show_entries', 'format=duration', + '-v', 'quiet', + '-of', 'csv=p=0' + ]); + + let output = ''; + ffprobe.stdout.on('data', (data) => { + output += data.toString(); + }); + + ffprobe.on('close', (code) => { + if (code === 0) { + const duration = parseFloat(output.trim()); + resolve(duration); + } else { + reject(new Error(`ffprobe process exited with code ${code}`)); + } + }); + + ffprobe.on('error', (err) => { + reject(err); + }); + }); +} + +async function runFFmpeg(args: string[]): Promise { + return new Promise((resolve, reject) => { + const ffmpeg = spawn('ffmpeg', args); + + ffmpeg.stderr.on('data', (data) => { + console.error(`ffmpeg stderr: ${data}`); + }); + + ffmpeg.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`FFmpeg process exited with code ${code}`)); + } + }); + + ffmpeg.on('error', (err) => { + reject(err); + }); + }); +} + +export async function POST(request: NextRequest) { + try { + // Parse the request body + const data: ConversionRequest = await request.json(); + + // Create temp directory if it doesn't exist + const tempDir = join(process.cwd(), 'temp'); + if (!existsSync(tempDir)) { + await mkdir(tempDir); + } + + // Generate unique filenames + const id = randomUUID(); + const outputPath = join(tempDir, `${id}.m4b`); + const metadataPath = join(tempDir, `${id}.txt`); + const intermediateDir = join(tempDir, `${id}-intermediate`); + + // Create intermediate directory + if (!existsSync(intermediateDir)) { + await mkdir(intermediateDir); + } + + // First, write each chapter to a temporary file and get its duration + const chapterFiles: { path: string; title: string; duration: number }[] = []; + let currentTime = 0; + + for (let i = 0; i < data.chapters.length; i++) { + const chapter = data.chapters[i]; + const inputPath = join(intermediateDir, `${i}-input.aac`); + const outputPath = join(intermediateDir, `${i}.wav`); + + // Write the chapter audio to a temp file + await writeFile(inputPath, Buffer.from(new Uint8Array(chapter.buffer))); + + // Convert to WAV with consistent format (this helps with timestamp issues) + await runFFmpeg([ + '-i', inputPath, + '-acodec', 'pcm_s16le', + '-ar', '44100', + '-ac', '2', + outputPath + ]); + + // Get the duration of this chapter + const duration = await getAudioDuration(outputPath); + + chapterFiles.push({ + path: outputPath, + title: chapter.title, + duration + }); + + // Clean up input file + await unlink(inputPath).catch(console.error); + } + + // Create chapter metadata file + const metadata: string[] = []; + metadata.push( + `title=Kokoro Audiobook`, + `artist=KokoroTTS`, + ); + + // Calculate chapter timings based on actual durations + chapterFiles.forEach((chapter, index) => { + const startMs = Math.floor(currentTime * 1000); + currentTime += chapter.duration; + const endMs = Math.floor(currentTime * 1000); + + metadata.push( + `[CHAPTER]`, + `TIMEBASE=1/1000`, + `START=${startMs}`, + `END=${endMs}`, + `title=${chapter.title}` + ); + }); + + await writeFile(metadataPath, ';FFMETADATA1\n' + metadata.join('\n')); + + // Create list file for concat + const listPath = join(tempDir, `${id}-list.txt`); + await writeFile( + listPath, + chapterFiles.map(f => `file '${f.path}'`).join('\n') + ); + + // Combine all files into a single M4B + await runFFmpeg([ + '-f', 'concat', + '-safe', '0', + '-i', listPath, + '-i', metadataPath, + '-map_metadata', '1', + '-c:a', 'aac', + '-b:a', '192k', + '-movflags', '+faststart', + outputPath + ]); + + // Read the converted file + const m4bData = await readFile(outputPath); + + // Clean up temp files + await Promise.all([ + ...chapterFiles.map(f => unlink(f.path)), + unlink(metadataPath), + unlink(listPath), + unlink(outputPath), + rmdir(intermediateDir) + ].map(p => p.catch(console.error))); + + return new NextResponse(m4bData, { + headers: { + 'Content-Type': 'audio/mp4', + }, + }); + } catch (error) { + console.error('Error converting audio:', error); + return NextResponse.json( + { error: 'Failed to convert audio format' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index 604a5ab..b46be78 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -6,8 +6,8 @@ export async function POST(req: NextRequest) { // Get API credentials from headers or fall back to environment variables const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none'; const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE; - const { text, voice, speed } = await req.json(); - console.log('Received TTS request:', text, voice, speed); + const { text, voice, speed, format } = await req.json(); + console.log('Received TTS request:', text, voice, speed, format); if (!openApiKey) { return NextResponse.json({ error: 'Missing OpenAI API key' }, { status: 401 }); @@ -29,6 +29,7 @@ export async function POST(req: NextRequest) { voice: voice as "alloy", input: text, speed: speed, + response_format: format === 'aac' ? 'aac' : 'mp3', }, { signal: req.signal }); // Pass the abort signal to OpenAI client // Get the audio data as array buffer @@ -36,7 +37,12 @@ export async function POST(req: NextRequest) { const stream = response.body; // Return audio data with appropriate headers - return new NextResponse(stream); + const contentType = format === 'aac' ? 'audio/aac' : 'audio/mpeg'; + return new NextResponse(stream, { + headers: { + 'Content-Type': contentType + } + }); } catch (error) { // Check if this was an abort error if (error instanceof Error && error.name === 'AbortError') { diff --git a/src/components/DocumentSettings.tsx b/src/components/DocumentSettings.tsx index 0aea1bc..75fd7c3 100644 --- a/src/components/DocumentSettings.tsx +++ b/src/components/DocumentSettings.tsx @@ -21,21 +21,27 @@ const viewTypes = [ { id: 'scroll', name: 'Continuous Scroll' }, ]; +const audioFormats = [ + { id: 'mp3', name: 'MP3' }, + { id: 'm4b', name: 'M4B' }, +]; + export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsProps) { - const { - viewType, - skipBlank, - epubTheme, + const { + viewType, + skipBlank, + epubTheme, headerMargin, footerMargin, leftMargin, rightMargin, - updateConfigKey + updateConfigKey } = useConfig(); const { createFullAudioBook } = useEPUB(); const { createFullAudioBook: createPDFAudioBook } = usePDF(); const [progress, setProgress] = useState(0); const [isGenerating, setIsGenerating] = useState(false); + const [audioFormat, setAudioFormat] = useState<'mp3' | 'm4b'>('mp3'); const [localMargins, setLocalMargins] = useState({ header: headerMargin, footer: footerMargin, @@ -80,18 +86,21 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro try { const audioBuffer = epub ? await createFullAudioBook( (progress) => setProgress(progress), - abortControllerRef.current.signal + abortControllerRef.current.signal, + audioFormat ) : await createPDFAudioBook( (progress) => setProgress(progress), - abortControllerRef.current.signal + abortControllerRef.current.signal, + audioFormat ); // Create and trigger download - const blob = new Blob([audioBuffer], { type: 'audio/mp3' }); + const mimeType = audioFormat === 'mp3' ? 'audio/mp3' : 'audio/mp4'; + const blob = new Blob([audioBuffer], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = 'audiobook.mp3'; + a.download = `audiobook.${audioFormat}`; document.body.appendChild(a); a.click(); @@ -107,7 +116,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro setProgress(0); abortControllerRef.current = null; } - }, [createFullAudioBook, createPDFAudioBook, epub]); + }, [createFullAudioBook, createPDFAudioBook, epub, audioFormat]); const handleCancel = () => { if (abortControllerRef.current) { @@ -142,46 +151,69 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro leaveTo="opacity-0 scale-95" > -
- {isDev &&
- {!isGenerating ? ( + {isDev &&
+ {!isGenerating ? ( +
- ) : ( -
-
-
+ setAudioFormat(format as 'mp3' | 'm4b')}> +
+ + {audioFormat === 'mp3' ? 'MP3' : 'M4B (Audiobook)'} + + + + {audioFormats.map((format) => ( + + `relative cursor-pointer select-none py-0.5 px-1.5 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium' : ''}` + } + > + {format.name} + + ))} +
-
- {Math.round(progress)}% complete - -
+ onClick={handleCancel} + > + Cancel and download +
- )} -
} +
+ )} +
} +
{!epub &&
-
-