#!/usr/bin/env bun import fs from "fs"; import path from "path"; let sessionId: string | null = null; const BASE = "https://api16-normal-c-useast1a.tiktokv.com/media/api/text/speech/invoke/"; /** Konfigurasi sessionId */ export function config(sid: string) { sessionId = sid; } /** ---- Utility: split text pintar (maxLen, tidak memotong kata) ---- */ function splitTextSmart(text: string, maxLen = 200): string[] { const parts: string[] = []; let remaining = text.trim(); while (remaining.length > maxLen) { let splitPos = remaining.lastIndexOf(" ", maxLen); if (splitPos === -1) { // tidak ada spasi — paksa split di maxLen splitPos = maxLen; } const chunk = remaining.slice(0, splitPos).trim(); if (chunk.length === 0) { // safety guard: jika chunk kosong karena whitespace, skip satu char parts.push(remaining.slice(0, maxLen)); remaining = remaining.slice(maxLen).trim(); } else { parts.push(chunk); remaining = remaining.slice(splitPos).trim(); } } if (remaining.length > 0) parts.push(remaining); return parts; } /** ---- Simple semaphore / concurrency limiter ---- */ class Semaphore { private permits: number; private waiters: Array<() => void> = []; constructor(permits: number) { this.permits = permits; } async acquire(): Promise { if (this.permits > 0) { this.permits--; return; } await new Promise((resolve) => { this.waiters.push(() => { this.permits--; resolve(); }); }); } release(): void { this.permits++; const next = this.waiters.shift(); if (next) { // immediately give to next waiter next(); } } } /** ---- Helper: sleep ms ---- */ function sleep(ms: number) { return new Promise((res) => setTimeout(res, ms)); } /** ---- Fetch single TTS part with retry logic ---- */ async function fetchTTSPartWithRetry( partText: string, partIndex: number, fileName: string, speaker: string, maxRetries: number ): Promise { const attemptFetch = async (attempt: number): Promise => { if (!sessionId) throw new Error("sessionId belum dikonfigurasi"); const url = BASE + "?" + new URLSearchParams({ text_speaker: speaker, req_text: partText, speaker_map_type: "0", aid: "1233", }).toString(); const headers = { Cookie: `sessionid=${sessionId}`, "User-Agent": "com.zhiliaoapp.musically/2023101630 (Linux; U; Android 13; en_US; Pixel 7; Build/TQ3A.230805.001)", }; let resp: Response; try { resp = await fetch(url, { method: "POST", headers }); } catch (err) { throw new Error(`Network error on fetch (attempt ${attempt}): ${err}`); } const contentType = resp.headers.get("content-type"); const outputName = `${fileName}_part-${partIndex + 1}.mp3`; const outputPath = path.resolve(outputName); try { if (contentType?.includes("application/json")) { const json = await resp.json(); if (json.status_code !== 0) { throw new Error( `TikTok TTS error (status_code != 0) on attempt ${attempt}: ${JSON.stringify( json )}` ); } const base64 = json.data?.v_str; if (!base64) throw new Error("Tidak menemukan v_str pada respons"); const buffer = Buffer.from(base64, "base64"); await fs.promises.writeFile(outputPath, buffer); } else { // langsung audio const arr = await resp.arrayBuffer(); const buf = Buffer.from(arr); await fs.promises.writeFile(outputPath, buf); } // success return outputPath; } catch (err) { // jika file ditulis parsial — hapus sebelum retry try { if (fs.existsSync(outputPath)) await fs.promises.unlink(outputPath); } catch (_) { // ignore } throw err; } }; let attempt = 0; let lastErr: any = null; while (attempt <= maxRetries) { try { return await attemptFetch(attempt + 1); } catch (err) { lastErr = err; attempt++; if (attempt > maxRetries) break; // exponential backoff: 500ms * 2^(attempt-1) const backoff = 500 * 2 ** (attempt - 1); await sleep(backoff + Math.random() * 200); } } throw new Error( `Failed to fetch TTS part after ${maxRetries} retries. Last error: ${lastErr}` ); } /** ---- Merge parts lossless (concatenate bytes) ---- */ async function mergeMP3Files(parts: string[], outputFile: string) { // buat write stream (Bun + Node compatible) const writeStream = fs.createWriteStream(outputFile); for (const file of parts) { const buffer = await fs.promises.readFile(file); writeStream.write(buffer); } writeStream.end(); // pastikan stream selesai (wrap event) await new Promise((resolve, reject) => { writeStream.on("finish", () => resolve()); writeStream.on("error", (e) => reject(e)); }); } /** ---- Cleanup helper: try delete files (ignore errors) ---- */ async function tryCleanupFiles(files: string[]) { for (const f of files) { try { if (fs.existsSync(f)) await fs.promises.unlink(f); } catch (_) { // ignore cleanup errors } } } /** * MAIN FUNCTION: * - text: string * - fileName: basename (without extension) * - speaker: tiktok speaker id (ex: id_001) * - concurrency: maximum parallel requests (default 5) * - maxRetries: retry per part (default 3) */ export async function createAudioFromText( text: string, fileName = "audio", speaker = "id_001", options?: { concurrency?: number; maxRetries?: number } ): Promise { const concurrency = options?.concurrency ?? 5; const maxRetries = options?.maxRetries ?? 3; if (!text || !text.trim()) throw new Error("Text kosong"); if (!sessionId) throw new Error("sessionId belum dikonfigurasi"); // split teks const chunks = splitTextSmart(text, 200); // prepare semaphore const sem = new Semaphore(concurrency); const partFiles: string[] = []; const tasks: Promise[] = []; let failed = false; let failureError: any = null; // for each chunk, create a task that respects concurrency for (let i = 0; i < chunks.length; i++) { const idx = i; const chunk = chunks[i]; const task = (async () => { await sem.acquire(); try { const outputPath = await fetchTTSPartWithRetry( chunk!, idx, fileName, speaker, maxRetries ); partFiles[idx] = outputPath; // keep order } catch (err) { failed = true; failureError = err; } finally { sem.release(); } })(); tasks.push(task); } // wait all tasks await Promise.all(tasks); if (failed) { // cleanup any part files created await tryCleanupFiles(partFiles.filter(Boolean)); throw new Error( `Gagal membuat beberapa part TTS. Error: ${String(failureError)}` ); } // merge parts const finalFile = path.resolve(`${fileName}_FINAL.mp3`); try { // ensure parts are in order const orderedParts = partFiles.slice(0, chunks.length); await mergeMP3Files(orderedParts, finalFile); // cleanup part files after merge await tryCleanupFiles(orderedParts); } catch (err) { // cleanup partial final file and parts try { if (fs.existsSync(finalFile)) await fs.promises.unlink(finalFile); } catch (_) {} await tryCleanupFiles(partFiles.filter(Boolean)); throw new Error(`Gagal merge/cleanup: ${err}`); } return finalFile; } /** ================= DEMO RUN ================= */ async function main() { // ganti session id kamu config("7b7a48e1313f9413825a8544e52b1481"); const text = ` Saat ini layanan pengurusan KTP secara langsung di Desa Darmasaba sedang tidak tersedia. Namun Anda tetap bisa mengurus KTP dengan langkah-langkah berikut: 1. Persiapkan dokumen sesuai kebutuhan, misalnya fotokopi KK, akta kelahiran, surat nikah (jika sudah menikah), atau surat keterangan hilang jika KTP lama hilang. 2. Datang ke kantor Desa Darmasaba untuk mendapatkan surat pengantar pembuatan KTP. 3. Serahkan dokumen dan surat pengantar tersebut ke kantor Kecamatan Abiansemal untuk proses verifikasi. 4. Permohonan akan diteruskan ke Disdukcapil Kabupaten Badung untuk pencetakan KTP. 5. Pengambilan KTP biasanya setelah 14 hari kerja. Anda juga bisa memanfaatkan layanan perekaman e-KTP yang sudah tersedia di kantor desa agar proses lebih mudah. Untuk informasi lebih lengkap, Anda dapat mengunjungi situs resmi desa atau Disdukcapil Badung. Mau saya bantu carikan info prosedur surat lain seperti surat keterangan domisili atau surat lainnya yang bisa diurus di Desa Darmasaba? `; try { const final = await createAudioFromText(text, "hasilTTS", "id_001", { concurrency: 5, maxRetries: 3, }); console.log("Final MP3 saved:", final); } catch (err) { console.error("Error:", err); } } if (require.main === module) { main(); }