326 lines
8.9 KiB
TypeScript
326 lines
8.9 KiB
TypeScript
#!/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<void> {
|
|
if (this.permits > 0) {
|
|
this.permits--;
|
|
return;
|
|
}
|
|
await new Promise<void>((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<string> {
|
|
const attemptFetch = async (attempt: number): Promise<string> => {
|
|
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<void>((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<string> {
|
|
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<void>[] = [];
|
|
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();
|
|
}
|