/** * Production-Ready TTS Async Client - Fixed WAV Merge * Author: ChatGPT (Optimized & Fixed) */ import fs from "fs"; import path from "path"; const HOST = "https://office4-chatterbox.wibudev.com"; const TEMP_DIR = "./temp-tts"; // Pastikan folder temp ada if (!fs.existsSync(TEMP_DIR)) { fs.mkdirSync(TEMP_DIR, { recursive: true }); } /* ============================================================ CLEAN TEXT (AMAN UNTUK UNICODE) ============================================================ */ function cleanText(text: string): string { return text.replace(/[^\p{L}\p{N}\p{P}\p{Zs}]/gu, "").trim(); } /* ============================================================ SPLIT TEXT (MAKSIMAL 200 CHAR + CARI TITIK/KOMA/!?) ============================================================ */ function splitText(text: string, max = 200): string[] { const chunks: string[] = []; text = text.trim(); while (text.length > 0) { if (text.length <= max) { chunks.push(text.trim()); break; } const slice = text.slice(0, max); // cari tanda baca terakhir const lastPunct = Math.max( slice.lastIndexOf("."), slice.lastIndexOf(","), slice.lastIndexOf(";"), slice.lastIndexOf("!"), slice.lastIndexOf("?") ); const cutIndex = lastPunct !== -1 ? lastPunct + 1 : max; const part = text.slice(0, cutIndex).trim(); if (part.length > 0) chunks.push(part); text = text.slice(cutIndex).trim(); if (text.length === 0) break; } return chunks; } /* ============================================================ FETCH WITH RETRY ============================================================ */ async function fetchRetry(url: string, options: any = {}, retries = 3) { for (let i = 0; i < retries; i++) { try { return await fetch(url, options); } catch (err) { console.warn(`Fetch attempt ${i + 1} failed:`, err); if (i === retries - 1) throw err; await new Promise((r) => setTimeout(r, 1000 * (i + 1))); } } } /* ============================================================ TTS ASYNC REQUEST ============================================================ */ async function generate(text: string) { const res = await fetchRetry(`${HOST}/tts-async`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ text, prompt: "dayu" }), }); if (!res) { throw new Error("Failed to fetch result"); } let data; try { data = await res.json(); } catch { throw new Error("Invalid JSON response from server"); } if (!data.job_id) { console.error("❌ Response tidak mengandung job_id:", data); throw new Error("job_id missing in generate response"); } return data; } /* ============================================================ FETCH RESULT + DOWNLOAD AUDIO ============================================================ */ async function fetchResult(jobId: string) { const res = await fetchRetry(`${HOST}/result/${jobId}`); if (!res) { throw new Error("Failed to fetch result"); } const type = res.headers.get("content-type") || ""; if (type.includes("application/json")) { try { return await res.json(); } catch { throw new Error("Invalid JSON response on fetchResult"); } } // Audio response const buf = Buffer.from(await res.arrayBuffer()); if (buf.length < 44) { throw new Error("Invalid WAV: too small"); } const file = `${TEMP_DIR}/audio_${jobId}.wav`; fs.writeFileSync(file, buf); return { status: "done", filePath: file }; } /* ============================================================ READ WAV HEADER INFO ============================================================ */ interface WavInfo { sampleRate: number; numChannels: number; bitsPerSample: number; byteRate: number; blockAlign: number; dataSize: number; } function readWavInfo(buffer: Buffer): WavInfo { // Read WAV header const sampleRate = buffer.readUInt32LE(24); const byteRate = buffer.readUInt32LE(28); const blockAlign = buffer.readUInt16LE(32); const bitsPerSample = buffer.readUInt16LE(34); const numChannels = buffer.readUInt16LE(22); // Find data chunk (skip any extra chunks like LIST, etc) let dataOffset = 36; while (dataOffset < buffer.length - 8) { const chunkId = buffer.toString('ascii', dataOffset, dataOffset + 4); const chunkSize = buffer.readUInt32LE(dataOffset + 4); if (chunkId === 'data') { return { sampleRate, numChannels, bitsPerSample, byteRate, blockAlign, dataSize: chunkSize }; } dataOffset += 8 + chunkSize; } // Fallback to old method if data chunk not found properly const dataSize = buffer.readUInt32LE(40); return { sampleRate, numChannels, bitsPerSample, byteRate, blockAlign, dataSize }; } /* ============================================================ MERGE WAV FILES (PRODUCTION SAFE - FIXED) ============================================================ */ function mergeWav(files: string[], output: string) { if (files.length === 0) throw new Error("No files to merge"); console.log(`\n🔧 Merging ${files.length} WAV files...`); // Read first file to get format info const firstFile = fs.readFileSync(files[0] as string); const wavInfo = readWavInfo(firstFile); console.log(`📊 WAV Format:`); console.log(` Sample Rate: ${wavInfo.sampleRate} Hz`); console.log(` Channels: ${wavInfo.numChannels}`); console.log(` Bits per Sample: ${wavInfo.bitsPerSample}`); console.log(` Block Align: ${wavInfo.blockAlign}`); // Collect all PCM data const pcmBuffers: Buffer[] = []; let totalPCM = 0; for (let i = 0; i < files.length; i++) { const f = files[i]; if (!fs.existsSync(f as string)) { throw new Error(`File not found: ${f}`); } const fileBuffer = fs.readFileSync(f as string); if (fileBuffer.length < 44) { throw new Error(`Invalid WAV file: ${f}`); } // Find data chunk position (handle files with extra metadata) let dataOffset = 36; let dataSize = 0; while (dataOffset < fileBuffer.length - 8) { const chunkId = fileBuffer.toString('ascii', dataOffset, dataOffset + 4); const chunkSize = fileBuffer.readUInt32LE(dataOffset + 4); if (chunkId === 'data') { dataSize = chunkSize; dataOffset += 8; // Skip 'data' + size fields break; } dataOffset += 8 + chunkSize; } // Fallback if data chunk not found properly if (dataSize === 0) { dataSize = fileBuffer.readUInt32LE(40); dataOffset = 44; } // Validate data size const availableData = fileBuffer.length - dataOffset; const actualDataSize = Math.min(dataSize, availableData); if (actualDataSize <= 0) { console.warn(`⚠️ Warning: File ${i + 1} has no valid PCM data, skipping...`); continue; } const pcmData = fileBuffer.slice(dataOffset, dataOffset + actualDataSize); // Ensure data is aligned to block size const remainder = pcmData.length % wavInfo.blockAlign; const alignedData = remainder === 0 ? pcmData : pcmData.slice(0, pcmData.length - remainder); pcmBuffers.push(alignedData); totalPCM += alignedData.length; console.log(` ✓ File ${i + 1}/${files.length}: ${alignedData.length} bytes`); } if (totalPCM === 0) { throw new Error("No valid PCM data found in any files"); } console.log(`\n📦 Total PCM data: ${totalPCM} bytes`); // Create new WAV header const header = Buffer.alloc(44); // RIFF header header.write('RIFF', 0); header.writeUInt32LE(36 + totalPCM, 4); // ChunkSize header.write('WAVE', 8); // fmt subchunk header.write('fmt ', 12); header.writeUInt32LE(16, 16); // Subchunk1Size (16 for PCM) header.writeUInt16LE(1, 20); // AudioFormat (1 for PCM) header.writeUInt16LE(wavInfo.numChannels, 22); // NumChannels header.writeUInt32LE(wavInfo.sampleRate, 24); // SampleRate header.writeUInt32LE(wavInfo.byteRate, 28); // ByteRate header.writeUInt16LE(wavInfo.blockAlign, 32); // BlockAlign header.writeUInt16LE(wavInfo.bitsPerSample, 34); // BitsPerSample // data subchunk header.write('data', 36); header.writeUInt32LE(totalPCM, 40); // Subchunk2Size // Combine header and all PCM data const combined = Buffer.concat([header, ...pcmBuffers]); fs.writeFileSync(output, combined); console.log(`✅ Merged file saved: ${output} (${combined.length} bytes)\n`); } /* ============================================================ CLEANUP TEMP FILES ============================================================ */ function cleanup() { console.log("🧹 Cleaning up temporary files..."); const files = fs.readdirSync(TEMP_DIR); for (const f of files) { fs.unlinkSync(path.join(TEMP_DIR, f)); } console.log(` Removed ${files.length} temporary files\n`); } /* ============================================================ MAIN ============================================================ */ (async () => { console.log("🎙️ TTS Async Client Starting...\n"); let text = ` Ayu dan Niko tinggal serumah sebagai teman kost. Mereka dekat, tapi selalu bersitegang soal satu hal: kopi instan. Ayu percaya kopi instan adalah anugerah Tuhan untuk mahasiswa miskin. Niko, sebaliknya, sok jadi barista—ngopi harus pakai French press, biji kopi digiling sendiri, dan airnya harus 92 derajat Celsius persis. Suatu pagi, Ayu kehabisan stok kopi instannya. Dengan mata setengah terbuka dan rambut acak-acakan, ia merayap ke dapur dan melihat Niko sedang sibuk "mengritik" suhu air yang baru direbus. "Boleh pinjam kopimu?" tanya Ayu lemas. Niko menoleh dramatis. "Boleh… asal kamu janji: jangan campur gula lebih dari satu sendok, jangan tambah susu kental manis, dan jangan bilang ini 'kopi tubruk ala warung'!" Ayu mengangguk patuh Tapi begitu Niko ke kamar ganti baju, Ayu diam-diam mengambil seluruh bubuk kopi Niko, mencampurnya dengan susu kental manis, gula aren, dan… sedikit krimer dari sachet kopi instannya yang terakhir. Saat Niko kembali, ia langsung mencium aroma "kiamat kopi". Matanya melotot. "APA YANG KAMU LAKUKAN PADA BIJI KENYA SINGLE ORIGIN-ku?!" Ayu menyeruput santai. "Namanya sekarang… Kopi Cinta Gagal Fokus. Enak, lho." Niko menghela napas, lalu duduk di sebelahnya. Dengan berat hati, ia mencicipi… dan ternyata… enak juga. Sejak hari itu, mereka punya ritual baru: Sabtu pagi, mereka bikin "Kopi Cinta Gagal Fokus" bersama—dengan tetap berdebat soal apakah itu kopi atau susu manis beraroma kopi. `; text = cleanText(text); const parts = splitText(text, 400); console.log(`📝 Total chunks: ${parts.length}\n`); const jobs: string[] = []; // SEND JOBS console.log("📤 Sending jobs to server..."); for (let i = 0; i < parts.length; i++) { try { const res = await generate(parts[i] as string) ; jobs.push(res.job_id); console.log(` ✓ Job ${i + 1}/${parts.length}: ${res.job_id}`); } catch (error) { console.error(`❌ Failed to generate job for chunk ${i + 1}:`, error); process.exit(1); } } console.log(`\n✅ All ${jobs.length} jobs submitted\n`); // POLLING SEMUA console.log("⏳ Waiting for processing..."); const resultFiles: string[] = []; for (let i = 0; i < jobs.length; i++) { const jobId = jobs[i]; let status = "processing"; let delay = 1500; let attempts = 0; while (status === "processing" || status === "pending") { try { const res = await fetchResult(jobId as string); status = res.status; if (status === "done" && res.filePath) { resultFiles.push(res.filePath as string); console.log(` ✓ Job ${i + 1}/${jobs.length} completed: ${jobId}`); break; } else if (status === "error") { console.error(` ❌ Job ${i + 1}/${jobs.length} failed: ${res.error || 'Unknown error'}`); throw new Error(`Job ${jobId} failed`); } } catch (error) { console.error(`❌ Error fetching result for job ${i + 1}:`, error); process.exit(1); } attempts++; await new Promise((r) => setTimeout(r, delay)); delay = Math.min(delay * 1.2, 5000); // Timeout after 2 minutes if (attempts > 80) { console.error(`❌ Job ${jobId} timeout after 2 minutes`); process.exit(1); } } } console.log(`\n✅ All jobs completed!\n`); // MERGE SEMUA WAV const outputFile = "./final_merged.wav"; mergeWav(resultFiles, outputFile); console.log("🎉 DONE! Output:", outputFile); // Cleanup temp folder cleanup(); })();