Files
jenna-tools/xfetch.txt
bipproduction 822b68c10f tambahannya
2025-12-07 09:00:54 +08:00

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();
})();