1+ import { NextRequest , NextResponse } from 'next/server' ;
2+ import { spawn } from 'child_process' ;
3+ import { writeFile , mkdir , unlink , rmdir } from 'fs/promises' ;
4+ import { createReadStream } from 'fs' ;
5+ import { existsSync } from 'fs' ;
6+ import { join } from 'path' ;
7+ import { randomUUID } from 'crypto' ;
8+
9+ interface Chapter {
10+ title : string ;
11+ buffer : number [ ] ;
12+ }
13+
14+ interface ConversionRequest {
15+ chapters : Chapter [ ] ;
16+ }
17+
18+ async function getAudioDuration ( filePath : string ) : Promise < number > {
19+ return new Promise ( ( resolve , reject ) => {
20+ const ffprobe = spawn ( 'ffprobe' , [
21+ '-i' , filePath ,
22+ '-show_entries' , 'format=duration' ,
23+ '-v' , 'quiet' ,
24+ '-of' , 'csv=p=0'
25+ ] ) ;
26+
27+ let output = '' ;
28+ ffprobe . stdout . on ( 'data' , ( data ) => {
29+ output += data . toString ( ) ;
30+ } ) ;
31+
32+ ffprobe . on ( 'close' , ( code ) => {
33+ if ( code === 0 ) {
34+ const duration = parseFloat ( output . trim ( ) ) ;
35+ resolve ( duration ) ;
36+ } else {
37+ reject ( new Error ( `ffprobe process exited with code ${ code } ` ) ) ;
38+ }
39+ } ) ;
40+
41+ ffprobe . on ( 'error' , ( err ) => {
42+ reject ( err ) ;
43+ } ) ;
44+ } ) ;
45+ }
46+
47+ async function runFFmpeg ( args : string [ ] ) : Promise < void > {
48+ return new Promise < void > ( ( resolve , reject ) => {
49+ const ffmpeg = spawn ( 'ffmpeg' , args ) ;
50+
51+ ffmpeg . stderr . on ( 'data' , ( data ) => {
52+ console . error ( `ffmpeg stderr: ${ data } ` ) ;
53+ } ) ;
54+
55+ ffmpeg . on ( 'close' , ( code ) => {
56+ if ( code === 0 ) {
57+ resolve ( ) ;
58+ } else {
59+ reject ( new Error ( `FFmpeg process exited with code ${ code } ` ) ) ;
60+ }
61+ } ) ;
62+
63+ ffmpeg . on ( 'error' , ( err ) => {
64+ reject ( err ) ;
65+ } ) ;
66+ } ) ;
67+ }
68+
69+ async function cleanup ( files : string [ ] , directories : string [ ] ) {
70+ await Promise . all ( [
71+ ...files . map ( f => unlink ( f ) . catch ( console . error ) ) ,
72+ ...directories . map ( d => rmdir ( d ) . catch ( console . error ) )
73+ ] ) ;
74+ }
75+
76+ export async function POST ( request : NextRequest ) {
77+ const tempFiles : string [ ] = [ ] ;
78+ const tempDirs : string [ ] = [ ] ;
79+
80+ try {
81+ // Parse the request body as a stream
82+ const data : ConversionRequest = await request . json ( ) ;
83+
84+ // Create temp directory if it doesn't exist
85+ const tempDir = join ( process . cwd ( ) , 'temp' ) ;
86+ if ( ! existsSync ( tempDir ) ) {
87+ await mkdir ( tempDir ) ;
88+ }
89+
90+ // Generate unique filenames
91+ const id = randomUUID ( ) ;
92+ const outputPath = join ( tempDir , `${ id } .m4b` ) ;
93+ const metadataPath = join ( tempDir , `${ id } .txt` ) ;
94+ const intermediateDir = join ( tempDir , `${ id } -intermediate` ) ;
95+
96+ tempFiles . push ( outputPath , metadataPath ) ;
97+ tempDirs . push ( intermediateDir ) ;
98+
99+ // Create intermediate directory
100+ if ( ! existsSync ( intermediateDir ) ) {
101+ await mkdir ( intermediateDir ) ;
102+ }
103+
104+ // Process chapters sequentially to avoid memory issues
105+ const chapterFiles : { path : string ; title : string ; duration : number } [ ] = [ ] ;
106+ let currentTime = 0 ;
107+
108+ for ( let i = 0 ; i < data . chapters . length ; i ++ ) {
109+ const chapter = data . chapters [ i ] ;
110+ const inputPath = join ( intermediateDir , `${ i } -input.mp3` ) ;
111+ const outputPath = join ( intermediateDir , `${ i } .wav` ) ;
112+
113+ tempFiles . push ( inputPath , outputPath ) ;
114+
115+ // Write the chapter audio to a temp file using a Buffer chunk size of 64KB
116+ const chunkSize = 64 * 1024 ; // 64KB chunks
117+ const buffer = Buffer . from ( new Uint8Array ( chapter . buffer ) ) ;
118+ const chunks : Buffer [ ] = [ ] ;
119+
120+ for ( let offset = 0 ; offset < buffer . length ; offset += chunkSize ) {
121+ chunks . push ( buffer . slice ( offset , offset + chunkSize ) ) ;
122+ }
123+
124+ await writeFile ( inputPath , Buffer . concat ( chunks ) ) ;
125+ chunks . length = 0 ; // Clear chunks array
126+
127+ // Convert to WAV with consistent format
128+ await runFFmpeg ( [
129+ '-i' , inputPath ,
130+ '-acodec' , 'pcm_s16le' ,
131+ '-ar' , '44100' ,
132+ '-ac' , '2' ,
133+ outputPath
134+ ] ) ;
135+
136+ const duration = await getAudioDuration ( outputPath ) ;
137+
138+ chapterFiles . push ( {
139+ path : outputPath ,
140+ title : chapter . title ,
141+ duration
142+ } ) ;
143+
144+ // Clean up input file early
145+ await unlink ( inputPath ) . catch ( console . error ) ;
146+ const index = tempFiles . indexOf ( inputPath ) ;
147+ if ( index > - 1 ) {
148+ tempFiles . splice ( index , 1 ) ;
149+ }
150+ }
151+
152+ // Create chapter metadata file
153+ const metadata : string [ ] = [ ] ;
154+
155+ chapterFiles . forEach ( ( chapter ) => {
156+ const startMs = Math . floor ( currentTime * 1000 ) ;
157+ currentTime += chapter . duration ;
158+ const endMs = Math . floor ( currentTime * 1000 ) ;
159+
160+ metadata . push (
161+ `[CHAPTER]` ,
162+ `TIMEBASE=1/1000` ,
163+ `START=${ startMs } ` ,
164+ `END=${ endMs } ` ,
165+ `title=${ chapter . title } `
166+ ) ;
167+ } ) ;
168+
169+ await writeFile ( metadataPath , ';FFMETADATA1\n' + metadata . join ( '\n' ) ) ;
170+
171+ // Create list file for concat
172+ const listPath = join ( tempDir , `${ id } -list.txt` ) ;
173+ tempFiles . push ( listPath ) ;
174+
175+ await writeFile (
176+ listPath ,
177+ chapterFiles . map ( f => `file '${ f . path } '` ) . join ( '\n' )
178+ ) ;
179+
180+ // Combine all files into a single M4B
181+ await runFFmpeg ( [
182+ '-f' , 'concat' ,
183+ '-safe' , '0' ,
184+ '-i' , listPath ,
185+ '-i' , metadataPath ,
186+ '-map_metadata' , '1' ,
187+ '-c:a' , 'aac' ,
188+ '-b:a' , '192k' ,
189+ '-movflags' , '+faststart' ,
190+ outputPath
191+ ] ) ;
192+
193+ // Create a readable stream from the output file
194+ const fileStream = createReadStream ( outputPath ) ;
195+
196+ // Create a web-compatible ReadableStream from the Node.js stream
197+ const webStream = new ReadableStream ( {
198+ start ( controller ) {
199+ fileStream . on ( 'data' , ( chunk ) => {
200+ controller . enqueue ( chunk ) ;
201+ } ) ;
202+
203+ fileStream . on ( 'end' , ( ) => {
204+ controller . close ( ) ;
205+ // Clean up only after the stream has been fully sent
206+ cleanup ( tempFiles , tempDirs ) . catch ( console . error ) ;
207+ } ) ;
208+
209+ fileStream . on ( 'error' , ( error ) => {
210+ console . error ( 'Stream error:' , error ) ;
211+ controller . error ( error ) ;
212+ cleanup ( tempFiles , tempDirs ) . catch ( console . error ) ;
213+ } ) ;
214+ } ,
215+ cancel ( ) {
216+ fileStream . destroy ( ) ;
217+ cleanup ( tempFiles , tempDirs ) . catch ( console . error ) ;
218+ }
219+ } ) ;
220+
221+ // Return the streaming response
222+ return new NextResponse ( webStream , {
223+ headers : {
224+ 'Content-Type' : 'audio/mp4' ,
225+ 'Transfer-Encoding' : 'chunked'
226+ } ,
227+ } ) ;
228+
229+ } catch ( error ) {
230+ // Clean up in case of error
231+ await cleanup ( tempFiles , tempDirs ) . catch ( console . error ) ;
232+
233+ console . error ( 'Error converting audio:' , error ) ;
234+ return NextResponse . json (
235+ { error : 'Failed to convert audio format' } ,
236+ { status : 500 }
237+ ) ;
238+ }
239+ }
0 commit comments