416 lines
14 KiB
Plaintext
416 lines
14 KiB
Plaintext
/**
|
|
* 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();
|
|
})(); |