tambahannya

This commit is contained in:
bipproduction
2025-12-07 09:00:54 +08:00
commit 822b68c10f
89 changed files with 16999 additions and 0 deletions

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
DATABASE_URL="postgresql://bip:Production_123@localhost:5432/mydb?schema=public"
JWT_SECRET=super_sangat_rahasia_sekali
BUN_PUBLIC_BASE_URL=http://localhost:3000
PORT=3000

42
.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
# Prisma generated client
generated/
# nextpush
nextpush
nextpush/

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"python.formatting.provider": "yapf"
}

138
README.md Normal file
View File

@@ -0,0 +1,138 @@
# Bun React Template Starter
This template is a starting point for building modern full-stack web applications using Bun, React, ElysiaJS, and Prisma. This project is designed to provide a fast, efficient, and structured development experience with a cutting-edge technology stack.
## Key Features
- **Super-Fast Runtime**: Built on top of [Bun](https://bun.sh/), a high-performance JavaScript runtime.
- **End-to-End Typesafe Backend**: Utilizes [ElysiaJS](https://elysiajs.com/) for a type-safe API from the backend to the frontend.
- **Automatic API Documentation**: Comes with [Elysia Swagger](https://elysiajs.com/plugins/swagger) to automatically generate interactive API documentation.
- **Modern Frontend**: A feature-rich and customizable user interface using [React](https://react.dev/) and [Mantine UI](https://mantine.dev/).
- **Easy Database Access**: Integrated with [Prisma](https://www.prisma.io/) as an ORM for intuitive and secure database interactions.
- **Clear Project Structure**: Logical file and folder organization to facilitate easy navigation and development.
## Tech Stack
- **Runtime**: Bun
- **Backend**:
- **Framework**: ElysiaJS
- **ElysiaJS Modules**:
- `@elysiajs/cors`: Manages Cross-Origin Resource Sharing policies.
- `@elysiajs/jwt`: JSON Web Token-based authentication.
- `@elysiajs/swagger`: Creates API documentation (Swagger/OpenAPI).
- `@elysiajs/eden`: A typesafe RPC-like client to connect the frontend with the Elysia API.
- **Frontend**:
- **Library**: React
- **UI Framework**: Mantine
- **Routing**: React Router
- **Data Fetching**: SWR
- **Database**:
- **ORM**: Prisma
- **Supported Databases**: PostgreSQL (default), MySQL, SQLite, etc.
- **Language**: TypeScript
## Getting Started
### 1. Clone the Repository
```bash
git clone https://github.com/your-username/bun-react-template-starter.git
cd bun-react-template-starter
```
### 2. Install Dependencies
Ensure you have [Bun](https://bun.sh/docs/installation) installed. Then, run the following command:
```bash
bun install
```
### 3. Configure Environment Variables
Copy the `.env.example` file to `.env` and customize the values.
```bash
cp .env.example .env
```
Fill in your `.env` file similar to the example below:
```
DATABASE_URL="postgresql://user:password@host:port/database?schema=public"
JWT_SECRET=a_super_long_and_secure_secret
BUN_PUBLIC_BASE_URL=http://localhost:3000
PORT=3000
```
After that, create TypeScript type declarations for your environment variables with the provided script:
```bash
bun run generate:env
```
This command will generate a `types/env.d.ts` file based on your `.env`.
### 4. Database Preparation
Make sure your PostgreSQL database server is running. Then, apply the Prisma schema to your database:
```bash
bunx prisma db push
```
You can also seed the database with initial data using the following script:
```bash
bun run seed
```
### 5. Running the Development Server
```bash
bun run dev
```
The application will be running at `http://localhost:3000`. The server supports hot-reloading, so changes in the code will be reflected instantly without needing a manual restart.
### 6. Accessing API Documentation (Swagger)
Once the server is running, you can access the automatically generated API documentation at:
`http://localhost:3000/swagger`
## Available Scripts
- `bun run dev`: Runs the development server with hot-reloading.
- `bun run build`: Builds the frontend application for production into the `dist` directory.
- `bun run start`: Runs the application in production mode.
- `bun run seed`: Executes the database seeding script located in `prisma/seed.ts`.
- `bun run generate:route`: A utility to create new route files in the backend.
- `bun run generate:env`: Generates a type definition file (`.d.ts`) from the variables in `.env`.
## Project Structure
```
/
├── bin/ # Utility scripts (generators)
├── prisma/ # Database schema, migrations, and seed
├── src/ # Main source code
│ ├── App.tsx # Root application component
│ ├── clientRoutes.ts # Route definitions for the frontend
│ ├── frontend.tsx # Entry point for client-side rendering (React)
│ ├── index.css # Global CSS file
│ ├── index.html # Main HTML template
│ ├── index.tsx # Main entry point for the app (server and client)
│ ├── components/ # Reusable React components
│ ├── lib/ # Shared libraries/helpers (e.g., apiFetch)
│ ├── pages/ # React page components
│ └── server/ # Backend code (ElysiaJS)
│ ├── lib/ # Server-specific libraries (e.g., prisma client)
│ ├── middlewares/ # Middleware for the API
│ └── routes/ # API route files
└── types/ # TypeScript type definitions
```
## Contributing
Contributions are highly welcome! Please feel free to create a pull request to add features, fix bugs, or improve the documentation.

53
bin/env.generate.ts Normal file
View File

@@ -0,0 +1,53 @@
import * as fs from "fs";
import * as path from "path";
import * as dotenv from "dotenv";
interface GenerateEnvTypesOptions {
envFilePath?: string;
outputDir?: string;
outputFileName?: string;
}
export function generateEnvTypes(options: GenerateEnvTypesOptions = {}) {
const {
envFilePath = path.resolve(process.cwd(), ".env"),
outputDir = path.resolve(process.cwd(), "types"),
outputFileName = "env.d.ts",
} = options;
const outputFile = path.join(outputDir, outputFileName);
// 1. Baca .env
if (!fs.existsSync(envFilePath)) {
console.warn(`⚠️ .env file not found at: ${envFilePath}`);
return;
}
const envContent = fs.readFileSync(envFilePath, "utf-8");
const parsed = dotenv.parse(envContent);
// 2. Generate TypeScript declare
const lines = Object.keys(parsed).map((key) => ` ${key}?: string;`);
const fileContent = `declare namespace NodeJS {
interface ProcessEnv {
${lines.join("\n")}
}
}
`;
// 3. Buat folder kalau belum ada
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// 4. Tulis file
fs.writeFileSync(outputFile, fileContent, "utf-8");
console.log(`✅ Env types generated at: ${outputFile}`);
}
if (import.meta.main) {
generateEnvTypes();
}

416
bin/route.generate.ts Normal file
View File

@@ -0,0 +1,416 @@
#!/usr/bin/env bun
import fs from "fs";
import path from "path";
import * as parser from "@babel/parser";
import traverse from "@babel/traverse";
import * as t from "@babel/types";
import { readdirSync, statSync, writeFileSync } from "fs";
import _ from "lodash";
import { basename, extname, join, relative } from "path";
const PAGES_DIR = join(process.cwd(), "src/pages");
const OUTPUT_FILE = join(process.cwd(), "src/AppRoutes.tsx");
/******************************
* Prefetch Helper Template
******************************/
const PREFETCH_HELPER = `
/**
* Prefetch lazy component:
* - Hover
* - Visible (viewport)
* - Browser idle
*/
export function attachPrefetch(el: HTMLElement | null, preload: () => void) {
if (!el) return;
let done = false;
const run = () => {
if (done) return;
done = true;
preload();
};
// 1) On hover
el.addEventListener("pointerenter", run, { once: true });
// 2) On visible (IntersectionObserver)
const io = new IntersectionObserver((entries) => {
if (entries && entries[0] && entries[0].isIntersecting) {
run();
io.disconnect();
}
});
io.observe(el);
// 3) On idle
if ("requestIdleCallback" in window) {
requestIdleCallback(() => run());
} else {
setTimeout(run, 200);
}
}
`;
/******************************
* Component Name Generator
******************************/
const toComponentName = (fileName: string): string =>
fileName
.replace(/\.[^/.]+$/, "")
.replace(/[_-]+/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\b\w/g, (c) => c.toUpperCase())
.replace(/\s+/g, "");
/******************************
* Route Path Normalizer
******************************/
function toRoutePath(name: string): string {
name = name.replace(/\.[^/.]+$/, "");
if (name.toLowerCase() === "home") return "/";
if (name.toLowerCase() === "login") return "/login";
if (name.toLowerCase() === "notfound") return "/*";
if (name.startsWith("[") && name.endsWith("]"))
return `:${name.slice(1, -1)}`;
name = name.replace(/_page$/i, "").replace(/^form_/i, "");
return _.kebabCase(name);
}
/******************************
* Scan Folder + Validation + Dynamic Duplicate Check
******************************/
function scan(dir: string): any[] {
const items = readdirSync(dir);
const routes: any[] = [];
const dynamicParams = new Set<string>();
for (const item of items) {
const full = join(dir, item);
const stat = statSync(full);
if (stat.isDirectory()) {
if (!/^[a-zA-Z0-9_-]+$/.test(item)) {
console.warn(`⚠️ Invalid folder name: ${item}`);
}
routes.push({
name: item,
path: _.kebabCase(item),
children: scan(full),
});
} else if (extname(item) === ".tsx") {
const base = basename(item, ".tsx");
if (!/^[a-zA-Z0-9_[\]-]+$/.test(base)) {
console.warn(`⚠️ Invalid file name: ${item}`);
}
if (base.startsWith("[") && base.endsWith("]")) {
const p = base.slice(1, -1);
if (dynamicParams.has(p)) {
console.error(`❌ Duplicate dynamic param "${p}" in ${dir}`);
process.exit(1);
}
dynamicParams.add(p);
}
routes.push({
name: base,
filePath: relative(join(process.cwd(), "src"), full).replace(/\\/g, "/"),
});
}
}
return routes;
}
/******************************
* Index Detection
******************************/
function findIndexFile(folderName: string, children: any[]) {
const lower = folderName.toLowerCase();
return (
children.find((r: any) => r.name.toLowerCase().endsWith("_home")) ||
children.find((r: any) => r.name.toLowerCase() === "index") ||
children.find((r: any) => r.name.toLowerCase() === `${lower}_page`)
);
}
/******************************
* Generate JSX <Route> (Lazy + Prefetch)
******************************/
function generateJSX(routes: any[], parentPath = ""): string {
let jsx = "";
for (const route of routes) {
if (route.children) {
const layout = route.children.find((r: any) =>
r.name.endsWith("_layout")
);
if (layout) {
const LayoutComp = toComponentName(
layout.name.replace("_layout", "Layout")
);
const nested = route.children.filter((x: any) => x !== layout);
const nestedRoutes = generateJSX(nested, `${parentPath}/${route.path}`);
const indexFile = findIndexFile(route.name, route.children);
const indexRoute = indexFile
? `<Route index element={<${toComponentName(
indexFile.name
)}.Component />} />`
: `<Route index element={<Navigate to="${(
parentPath +
"/" +
route.path +
"/" +
(nested[0]?.name ?? "")
).replace(/\/+/g, "/")}" replace />}/>`;
jsx += `
<Route path="${parentPath}/${route.path}" element={<${LayoutComp}.Component />}>
${indexRoute}
${nestedRoutes}
</Route>
`;
} else {
jsx += generateJSX(route.children, `${parentPath}/${route.path}`);
}
} else {
const Comp = toComponentName(route.name);
const routePath = toRoutePath(route.name);
const fullPath = routePath.startsWith("/")
? routePath
: `${parentPath}/${routePath}`.replace(/\/+/g, "/");
jsx += `
<Route
path="${fullPath}"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<${Comp}.Component />
</React.Suspense>
}
/>
`;
}
}
return jsx;
}
/******************************
* Lazy Import + Prefetch Injection
******************************/
function generateImports(routes: any[]): string {
const list: string[] = [];
function walk(rs: any[]) {
for (const r of rs) {
if (r.children) walk(r.children);
else {
const C = toComponentName(r.name);
const file = r.filePath.replace(/\.tsx$/, "");
list.push(`
const ${C} = {
Component: React.lazy(() => import("./${file}")),
preload: () => import("./${file}")
};
`);
}
}
}
walk(routes);
return list.join("\n");
}
/******************************
* Generate AppRoutes.tsx
******************************/
function generateRoutes() {
const allRoutes = scan(PAGES_DIR);
const imports = generateImports(allRoutes);
const jsx = generateJSX(allRoutes);
let loadingSkeleton = `
const SkeletonLoading = () => {
return (
<div style={{ padding: "20px" }}>
{Array.from({ length: 5 }, (_, i) => (
<Skeleton key={i} height={70} radius="md" animate={true} mb="sm" />
))}
</div>
);
};
`
const final = `
// ⚡ AUTO-GENERATED — DO NOT EDIT
import React from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { Skeleton } from "@mantine/core";
${loadingSkeleton}
${PREFETCH_HELPER}
${imports}
export default function AppRoutes() {
return (
<BrowserRouter>
<Routes>
${jsx}
</Routes>
</BrowserRouter>
);
}
`;
writeFileSync(OUTPUT_FILE, final);
console.log(`✅ Routes generated → ${OUTPUT_FILE}`);
Bun.spawnSync(["bunx", "prettier", "--write", "src/**/*.tsx"]);
}
/******************************
* Extract flat client routes
******************************/
const SRC_DIR = path.resolve("src");
const APP_ROUTES_FILE = join(SRC_DIR, "AppRoutes.tsx");
interface RouteNode {
path: string;
children: RouteNode[];
}
function getAttributePath(attrs: any[]) {
const attr = attrs.find(
(a) => t.isJSXAttribute(a) && a.name.name === "path"
) as any;
return attr?.value?.value ?? "";
}
function extractRouteNodes(node: t.JSXElement): RouteNode | null {
const op = node.openingElement;
if (!t.isJSXIdentifier(op.name) || op.name.name !== "Route") return null;
const cur = getAttributePath(op.attributes);
const children: RouteNode[] = [];
for (const c of node.children) {
if (t.isJSXElement(c)) {
const n = extractRouteNodes(c);
if (n) children.push(n);
}
}
return { path: cur, children };
}
function flattenRoutes(node: RouteNode, parent = ""): Record<string, string> {
const r: Record<string, string> = {};
let full = node.path;
if (full) {
if (!full.startsWith("/"))
full =
parent && full !== "/"
? `${parent.replace(/\/$/, "")}/${full}`
: "/" + full;
full = full.replace(/\/+/g, "/");
r[full] = full;
}
for (const c of node.children)
Object.assign(r, flattenRoutes(c, full || parent));
return r;
}
function extractRoutes(code: string) {
const ast = parser.parse(code, {
sourceType: "module",
plugins: ["jsx", "typescript"],
});
const routes: Record<string, string> = {};
traverse(ast, {
JSXElement(p) {
const op = p.node.openingElement;
if (t.isJSXIdentifier(op.name) && op.name.name === "Routes") {
for (const c of p.node.children) {
if (t.isJSXElement(c)) {
const root = extractRouteNodes(c);
if (root) Object.assign(routes, flattenRoutes(root));
}
}
}
},
});
return routes;
}
/******************************
* Type-Safe Route Builder
******************************/
function generateTypeSafe(routes: Record<string, string>) {
const keys = Object.keys(routes).filter((x) => !x.includes("*"));
const union = keys.map((x) => `"${x}"`).join(" | ");
const code = `
export type AppRoute = ${union};
export function route(path: AppRoute, params?: Record<string,string|number>) {
if (!params) return path;
let final = path;
for (const k of Object.keys(params)) {
final = final.replace(":" + k, params[k] + "") as AppRoute;
}
return final;
}
`;
fs.writeFileSync(join(SRC_DIR, "routeTypes.ts"), code);
console.log("📄 routeTypes.ts generated.");
}
/******************************
* MAIN
******************************/
export default function run() {
generateRoutes();
const code = fs.readFileSync(APP_ROUTES_FILE, "utf-8");
const routes = extractRoutes(code);
const out = join(SRC_DIR, "clientRoutes.ts");
fs.writeFileSync(
out,
`// AUTO-GENERATED\nconst clientRoutes = ${JSON.stringify(
routes,
null,
2
)} as const;\nexport default clientRoutes;`
);
console.log(`📄 clientRoutes.ts saved → ${out}`);
generateTypeSafe(routes);
}
run();

17
bun-env.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
// Generated by `bun init`
declare module "*.svg" {
/**
* A path to the SVG file
*/
const path: `${string}.svg`;
export = path;
}
declare module "*.module.css" {
/**
* A record of class names to their corresponding CSS module classes
*/
const classes: { readonly [key: string]: string };
export = classes;
}

589
bun.lock Normal file
View File

@@ -0,0 +1,589 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "bun-react-template",
"dependencies": {
"@elysiajs/cors": "^1.4.0",
"@elysiajs/eden": "^1.4.5",
"@elysiajs/jwt": "^1.4.0",
"@elysiajs/swagger": "^1.3.1",
"@gradio/client": "^2.0.0",
"@mantine/core": "^8.3.8",
"@mantine/hooks": "^8.3.8",
"@mantine/modals": "^8.3.8",
"@mantine/notifications": "^8.3.8",
"@prisma/client": "^6.19.0",
"@prisma/extension-accelerate": "^3.0.0",
"@tabler/icons-react": "^3.35.0",
"@types/randomstring": "^1.3.0",
"dotenv": "^17.2.3",
"elysia": "^1.4.16",
"jwt-decode": "^4.0.0",
"lodash": "^4.17.21",
"node-av": "^5.0.2",
"randomstring": "^1.3.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6",
"swr": "^2.3.6",
"tiktok-tts": "^1.1.17",
"zod": "^4.1.13",
},
"devDependencies": {
"@babel/parser": "^7.28.5",
"@babel/traverse": "^7.28.5",
"@babel/types": "^7.28.5",
"@types/babel__traverse": "^7.28.0",
"@types/bun": "latest",
"@types/jwt-decode": "^3.1.0",
"@types/lodash": "^4.17.21",
"@types/react": "^19.2.6",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prisma": "^6.19.0",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, ""],
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="],
"@elysiajs/cors": ["@elysiajs/cors@1.4.0", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, ""],
"@elysiajs/eden": ["@elysiajs/eden@1.4.5", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-hIOeH+S5NU/84A7+t8yB1JjxqjmzRkBF9fnLn6y+AH8EcF39KumOAnciMhIOkhhThVZvXZ3d+GsizRc+Fxoi8g=="],
"@elysiajs/jwt": ["@elysiajs/jwt@1.4.0", "", { "dependencies": { "jose": "^6.0.11" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, ""],
"@elysiajs/swagger": ["@elysiajs/swagger@1.3.1", "", { "dependencies": { "@scalar/themes": "^0.9.52", "@scalar/types": "^0.0.12", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, ""],
"@fidm/asn1": ["@fidm/asn1@1.0.4", "", {}, "sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ=="],
"@fidm/x509": ["@fidm/x509@1.2.1", "", { "dependencies": { "@fidm/asn1": "^1.0.4", "tweetnacl": "^1.0.1" } }, "sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w=="],
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, ""],
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, ""],
"@floating-ui/react": ["@floating-ui/react@0.27.16", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.6", "@floating-ui/utils": "^0.2.10", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, ""],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, ""],
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, ""],
"@gradio/client": ["@gradio/client@2.0.0", "", { "dependencies": { "fetch-event-stream": "^0.1.5" } }, "sha512-AYy0/0nbN3xrjquQ1ScqZp9RHkw2mlg9+zsimYC1yxOW+TpKVpweUvKcuL0QLsf/Dq7zsYJfFxM8CRf9qaKC0w=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="],
"@mantine/core": ["@mantine/core@8.3.9", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", "react-number-format": "^5.4.4", "react-remove-scroll": "^2.7.1", "react-textarea-autosize": "8.5.9", "type-fest": "^4.41.0" }, "peerDependencies": { "@mantine/hooks": "8.3.9", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-ivj0Crn5N521cI2eWZBsBGckg0ZYRqfOJz5vbbvYmfj65bp0EdsyqZuOxXzIcn2aUScQhskfvzyhV5XIUv81PQ=="],
"@mantine/hooks": ["@mantine/hooks@8.3.9", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-Dfz7W0+K1cq4Gb1WFQCZn8tsMXkLH6MV409wZR/ToqsxdNDUMJ/xxbfnwEXWEZjXNJd1wDETHgc+cZG2lTe3Xw=="],
"@mantine/modals": ["@mantine/modals@8.3.9", "", { "peerDependencies": { "@mantine/core": "8.3.9", "@mantine/hooks": "8.3.9", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-0WOikHgECJeWA/1TNf+sxOnpNwQjmpyph3XEhzFkgneimW6Ry7R6qd/i345CDLSu6kP6FGGRI73SUROiTcu2Ng=="],
"@mantine/notifications": ["@mantine/notifications@8.3.9", "", { "dependencies": { "@mantine/store": "8.3.9", "react-transition-group": "4.4.5" }, "peerDependencies": { "@mantine/core": "8.3.9", "@mantine/hooks": "8.3.9", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-emUdoCyaccf/NuNmJ4fQgloJ7hEod0Pde7XIoD9xUUztVchL143oWRU2gYm6cwqzSyjpjTaqPXfz5UvEBRYjZw=="],
"@mantine/store": ["@mantine/store@8.3.9", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-Z4tYW597mD3NxHLlJ3OJ1aKucmwrD9nhqobz+142JNw01aHqzKjxVXlu3L5GGa7F3u3OjXJk/qb1QmUs4sU+Jw=="],
"@minhducsun2002/leb128": ["@minhducsun2002/leb128@1.0.0", "", {}, "sha512-eFrYUPDVHeuwWHluTG1kwNQUEUcFjVKYwPkU8z9DR1JH3AW7JtJsG9cRVGmwz809kKtGfwGJj58juCZxEvnI/g=="],
"@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="],
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "@peculiar/asn1-x509-attr": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA=="],
"@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ=="],
"@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw=="],
"@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.6.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-pkcs8": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ=="],
"@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA=="],
"@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.6.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-pfx": "^2.6.0", "@peculiar/asn1-pkcs8": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "@peculiar/asn1-x509-attr": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw=="],
"@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w=="],
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.6.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg=="],
"@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA=="],
"@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA=="],
"@peculiar/x509": ["@peculiar/x509@1.14.2", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-r2w1Hg6pODDs0zfAKHkSS5HLkOLSeburtcgwvlLLWWCixw+MmW3U6kD5ddyvc2Y2YdbGuVwCF2S2ASoU1cFAag=="],
"@prisma/client": ["@prisma/client@6.19.0", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g=="],
"@prisma/config": ["@prisma/config@6.19.0", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.18.4", "empathic": "2.0.0" } }, "sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg=="],
"@prisma/debug": ["@prisma/debug@6.19.0", "", {}, "sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA=="],
"@prisma/engines": ["@prisma/engines@6.19.0", "", { "dependencies": { "@prisma/debug": "6.19.0", "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", "@prisma/fetch-engine": "6.19.0", "@prisma/get-platform": "6.19.0" } }, "sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw=="],
"@prisma/engines-version": ["@prisma/engines-version@6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", "", {}, "sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ=="],
"@prisma/extension-accelerate": ["@prisma/extension-accelerate@3.0.0", "", { "peerDependencies": { "@prisma/client": ">=4.16.1" } }, "sha512-xOhRCdPTdAwwdbxDr14s0rg73o8LunzRf8VtzFi4P6G/SvA3n/OgRIClXpihEQvoyDWVEIE29MdSxaaYdjsIMw=="],
"@prisma/fetch-engine": ["@prisma/fetch-engine@6.19.0", "", { "dependencies": { "@prisma/debug": "6.19.0", "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", "@prisma/get-platform": "6.19.0" } }, "sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ=="],
"@prisma/get-platform": ["@prisma/get-platform@6.19.0", "", { "dependencies": { "@prisma/debug": "6.19.0" } }, "sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.1.1", "", {}, ""],
"@scalar/themes": ["@scalar/themes@0.9.86", "", { "dependencies": { "@scalar/types": "0.1.7" } }, ""],
"@scalar/types": ["@scalar/types@0.0.12", "", { "dependencies": { "@scalar/openapi-types": "0.1.1", "@unhead/schema": "^1.9.5" } }, ""],
"@seydx/node-av-darwin-arm64": ["@seydx/node-av-darwin-arm64@5.0.2", "", { "dependencies": { "unzipper": "^0.12.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-ddmg1id1GfSvdnYPv1UskJqkzjadNFyluFtMVr2j/pNDizqiCmR0jmX68HqaZjvyKEuttBVN9RUsRJVgrrkQsg=="],
"@seydx/node-av-darwin-x64": ["@seydx/node-av-darwin-x64@5.0.2", "", { "dependencies": { "unzipper": "^0.12.3" }, "os": "darwin", "cpu": "x64" }, "sha512-ahiNSasdjNUx5U37+sjKLgptM0Q/y1ylvoGb110aVjcnNTcGze4HVxychDp9dVh1CJ789KcuKu7UciQxIgCwCg=="],
"@seydx/node-av-linux-arm64": ["@seydx/node-av-linux-arm64@5.0.2", "", { "dependencies": { "unzipper": "^0.12.3" }, "os": "linux", "cpu": "arm64" }, "sha512-2UGd8CeBHPmT2qr9NEQJrr8VOYdzjqbPWgV9xSrIorTObnyJemwk9DggiXsd2gttcHTavgOs/LZ76Q24vby8sA=="],
"@seydx/node-av-linux-x64": ["@seydx/node-av-linux-x64@5.0.2", "", { "dependencies": { "unzipper": "^0.12.3" }, "os": "linux", "cpu": "x64" }, "sha512-k1wJymAdYSTUvKlRdnJrihSDN+jdepwUXD6XOIx0XODrgm0pZCeIL7eUGRrfssr6EnCgM2Xhu7ATsf5K4lHojA=="],
"@seydx/node-av-win32-arm64-mingw": ["@seydx/node-av-win32-arm64-mingw@5.0.2", "", { "dependencies": { "unzipper": "^0.12.3" }, "os": "win32", "cpu": "arm64" }, "sha512-qE8Fy9i5n7N+Bhs22rGGJCF1VrdjEIKS9gVK1spNpGq3/ZPR356RYg05jNfoYrO1/ZBLEqNFIJorjydxslh+yA=="],
"@seydx/node-av-win32-arm64-msvc": ["@seydx/node-av-win32-arm64-msvc@5.0.2", "", { "dependencies": { "unzipper": "^0.12.3" }, "os": "win32", "cpu": "arm64" }, "sha512-vSODX+a254WxOycnudjv2utU0IJ/auoZ1uvoEbIEHD/kyu0uF3Or4EK52rtI0W/9tCsYH5v0gxw0rLM0uz2p6g=="],
"@seydx/node-av-win32-x64-mingw": ["@seydx/node-av-win32-x64-mingw@5.0.2", "", { "dependencies": { "unzipper": "^0.12.3" }, "os": "win32", "cpu": "x64" }, "sha512-kby0c4UI+TAgoGDRZC7HcvqU/E1GAxnAGkzr52Ll0huOuZbBSl6GJFmyTYlwo2etFWSjRKWMiQwdGHLMRf8Yag=="],
"@seydx/node-av-win32-x64-msvc": ["@seydx/node-av-win32-x64-msvc@5.0.2", "", { "dependencies": { "unzipper": "^0.12.3" }, "os": "win32", "cpu": "x64" }, "sha512-wRveR2A6/nldFJ0XEWMbvtM7UAiDMqSNwFolRJqHUdwXvUxfseP4oabyOQZ6wx1z6DFgRS9Z3zobcrRlr/do2A=="],
"@shinyoshiaki/binary-data": ["@shinyoshiaki/binary-data@0.6.1", "", { "dependencies": { "generate-function": "^2.3.1", "is-plain-object": "^2.0.3" } }, "sha512-7HDb/fQAop2bCmvDIzU5+69i+UJaFgIVp99h1VzK1mpg1JwSODOkjbqD7ilTYnqlnadF8C4XjpwpepxDsGY6+w=="],
"@shinyoshiaki/jspack": ["@shinyoshiaki/jspack@0.0.6", "", {}, "sha512-SdsNhLjQh4onBlyPrn4ia1Pdx5bXT88G/LIEpOYAjx2u4xeY/m/HB5yHqlkJB1uQR3Zw4R3hBWLj46STRAN0rg=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, ""],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@tabler/icons": ["@tabler/icons@3.35.0", "", {}, "sha512-yYXe+gJ56xlZFiXwV9zVoe3FWCGuZ/D7/G4ZIlDtGxSx5CGQK110wrnT29gUj52kEZoxqF7oURTk97GQxELOFQ=="],
"@tabler/icons-react": ["@tabler/icons-react@3.35.0", "", { "dependencies": { "@tabler/icons": "3.35.0" }, "peerDependencies": { "react": ">= 16" } }, "sha512-XG7t2DYf3DyHT5jxFNp5xyLVbL4hMJYJhiSdHADzAjLRYfL7AnjlRfiHDHeXxkb2N103rEIvTsBRazxXtAUz2g=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="],
"@types/jwt-decode": ["@types/jwt-decode@3.1.0", "", { "dependencies": { "jwt-decode": "*" } }, "sha512-tthwik7TKkou3mVnBnvVuHnHElbjtdbM63pdBCbZTirCt3WAdM73Y79mOri7+ljsS99ZVwUFZHLMxJuJnv/z1w=="],
"@types/lodash": ["@types/lodash@4.17.21", "", {}, "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ=="],
"@types/node": ["@types/node@24.7.0", "", { "dependencies": { "undici-types": "~7.14.0" } }, ""],
"@types/randomstring": ["@types/randomstring@1.3.0", "", {}, "sha512-kCP61wludjY7oNUeFiMxfswHB3Wn/aC03Cu82oQsNTO6OCuhVN/rCbBs68Cq6Nkgjmp2Sh3Js6HearJPkk7KQA=="],
"@types/react": ["@types/react@19.2.6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, ""],
"aes-js": ["aes-js@3.1.2", "", {}, "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ=="],
"asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
"bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, ""],
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, ""],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
"clsx": ["clsx@2.1.1", "", {}, ""],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"cookie": ["cookie@1.0.2", "", {}, ""],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": "bin/cssesc" }, ""],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, ""],
"dns-packet": ["dns-packet@5.6.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" } }, "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],
"effect": ["effect@3.18.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA=="],
"elysia": ["elysia@1.4.16", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.3", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-KZtKN160/bdWVKg2hEgyoNXY8jRRquc+m6PboyisaLZL891I+Ufb7Ja6lDAD7vMQur8sLEWIcidZOzj5lWw9UA=="],
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"exact-mirror": ["exact-mirror@0.2.3", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-aLdARfO0W0ntufjDyytUJQMbNXoB9g+BbA8KcgIq4XOOTYRw48yUGON/Pr64iDrYNZKcKvKbqE0MPW56FF2BXA=="],
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, ""],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, ""],
"fetch-event-stream": ["fetch-event-stream@0.1.6", "", {}, "sha512-GREtJ5HNikdU2AXtZ6E/5bk+aslMU6ie5mPG6H9nvsdDkkHQ6m5lHwmmmDTOBexok9hApQ7EprsXCdmz9ZC68w=="],
"file-type": ["file-type@21.1.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"fs-extra": ["fs-extra@11.3.2", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, ""],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hookable": ["hookable@5.5.3", "", {}, ""],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"int64-buffer": ["int64-buffer@1.1.0", "", {}, "sha512-94smTCQOvigN4d/2R/YDjz8YVG0Sufvv2aAh8P5m42gwhCsDAJqnbNOrxJsrADuAFAA69Q/ptGzxvNcNuIJcvw=="],
"ip": ["ip@2.0.1", "", {}, "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ=="],
"is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="],
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jose": ["jose@6.1.0", "", {}, ""],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
"jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mp4box": ["mp4box@0.5.4", "", {}, "sha512-GcCH0fySxBurJtvr0dfhz0IxHZjc1RP+F+I8xw+LIwkU1a+7HJx8NCDiww1I5u4Hz6g4eR1JlGADEGJ9r4lSfA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="],
"nano-time": ["nano-time@1.0.0", "", { "dependencies": { "big-integer": "^1.6.16" } }, "sha512-flnngywOoQ0lLQOTRNexn2gGSNuM9bKj9RZAWSzhQ+UJYaAFG9bac4DW9VHjUAzrOaIcajHybCTHe/bkvozQqA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, ""],
"node-av": ["node-av@5.0.2", "", { "dependencies": { "unzipper": "^0.12.3", "werift": "^0.22.2" }, "optionalDependencies": { "@seydx/node-av-darwin-arm64": "^5.0.2", "@seydx/node-av-darwin-x64": "^5.0.2", "@seydx/node-av-linux-arm64": "^5.0.2", "@seydx/node-av-linux-x64": "^5.0.2", "@seydx/node-av-win32-arm64-mingw": "^5.0.2", "@seydx/node-av-win32-arm64-msvc": "^5.0.2", "@seydx/node-av-win32-x64-mingw": "^5.0.2", "@seydx/node-av-win32-x64-msvc": "^5.0.2" } }, "sha512-maqij1UEorOwTBmdU9U744SmG7n54Tv18NmsWVSWzPT7wAo84rvlvCxCOTulRkEH10xNTupDO37zxXEneIbHeA=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, ""],
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
"pathe": ["pathe@1.1.2", "", {}, ""],
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"picocolors": ["picocolors@1.1.1", "", {}, ""],
"picomatch": ["picomatch@4.0.3", "", {}, ""],
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, ""],
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, ""],
"postcss-mixins": ["postcss-mixins@12.1.2", "", { "dependencies": { "postcss-js": "^4.0.1", "postcss-simple-vars": "^7.0.1", "sugarss": "^5.0.0", "tinyglobby": "^0.2.14" }, "peerDependencies": { "postcss": "^8.2.14" } }, ""],
"postcss-nested": ["postcss-nested@7.0.2", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.2.14" } }, ""],
"postcss-preset-mantine": ["postcss-preset-mantine@1.18.0", "", { "dependencies": { "postcss-mixins": "^12.0.0", "postcss-nested": "^7.0.2" }, "peerDependencies": { "postcss": ">=8.0.0" } }, ""],
"postcss-selector-parser": ["postcss-selector-parser@7.1.0", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, ""],
"postcss-simple-vars": ["postcss-simple-vars@7.0.1", "", { "peerDependencies": { "postcss": "^8.2.1" } }, ""],
"prisma": ["prisma@6.19.0", "", { "dependencies": { "@prisma/config": "6.19.0", "@prisma/engines": "6.19.0" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
"randomstring": ["randomstring@1.3.1", "", { "dependencies": { "randombytes": "2.1.0" }, "bin": { "randomstring": "bin/randomstring" } }, "sha512-lgXZa80MUkjWdE7g2+PZ1xDLzc7/RokXVEQOv5NN2UOTChW1I8A9gha5a9xYBOqgaSoI6uJikDmCU8PyRdArRQ=="],
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
"react": ["react@19.2.0", "", {}, ""],
"react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, ""],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-number-format": ["react-number-format@5.4.4", "", { "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, ""],
"react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, ""],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, ""],
"react-router": ["react-router@7.9.6", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA=="],
"react-router-dom": ["react-router-dom@7.9.6", "", { "dependencies": { "react-router": "7.9.6" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, ""],
"react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, ""],
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
"rx.mini": ["rx.mini@1.4.0", "", {}, "sha512-8w5cSc1mwNja7fl465DXOkVvIOkpvh2GW4jo31nAIvX4WTXCsRnKJGUfiDBzWtYRInEcHAUYIZfzusjIrea8gA=="],
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"scheduler": ["scheduler@0.27.0", "", {}, ""],
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, ""],
"source-map-js": ["source-map-js@1.2.1", "", {}, ""],
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
"sugarss": ["sugarss@5.0.1", "", { "peerDependencies": { "postcss": "^8.3.3" } }, ""],
"swr": ["swr@2.3.6", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw=="],
"tabbable": ["tabbable@6.2.0", "", {}, ""],
"thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="],
"tiktok-tts": ["tiktok-tts@1.1.17", "", { "dependencies": { "axios": "^1.3.4" } }, "sha512-crqAw+KRo+oDlPGX3lQsOZHp8tWYjbaK8neHpBRKTCBVowDdyu2kjzAjF1XImZaKYUZhJdBtbpcz2iM//L8Osg=="],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, ""],
"token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="],
"tslib": ["tslib@2.8.1", "", {}, ""],
"tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="],
"turbo-crc32": ["turbo-crc32@1.0.1", "", {}, "sha512-8yyRd1ZdNp+AQLGqi3lTaA2k81JjlIZOyFQEsi7GQWBgirnQOxjqVtDEbYHM2Z4yFdJ5AQw0fxBLLnDCl6RXoQ=="],
"tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="],
"type-fest": ["type-fest@4.41.0", "", {}, ""],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"undici-types": ["undici-types@7.14.0", "", {}, ""],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"unzipper": ["unzipper@0.12.3", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "^11.2.0", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, ""],
"use-composed-ref": ["use-composed-ref@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, ""],
"use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, ""],
"use-latest": ["use-latest@1.3.0", "", { "dependencies": { "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, ""],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, ""],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, ""],
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
"werift": ["werift@0.22.2", "", { "dependencies": { "@fidm/x509": "^1.2.1", "@minhducsun2002/leb128": "^1.0.0", "@noble/curves": "^1.8.1", "@peculiar/x509": "^1.12.3", "@shinyoshiaki/binary-data": "^0.6.1", "@shinyoshiaki/jspack": "^0.0.6", "aes-js": "^3.1.2", "buffer": "^6.0.3", "buffer-crc32": "^1.0.0", "date-fns": "^4.1.0", "debug": "^4.4.0", "int64-buffer": "1.1.0", "ip": "^2.0.1", "lodash": "^4.17.21", "mp4box": "^0.5.3", "multicast-dns": "^7.2.5", "nano-time": "^1.0.0", "turbo-crc32": "^1.0.1", "tweetnacl": "^1.0.3", "uuid": "^11.0.5", "werift-common": "*", "werift-dtls": "*", "werift-ice": "*", "werift-rtp": "*", "werift-sctp": "*" } }, "sha512-R+dfzOknUiGH8EcxGjWfN4404+Npj4tT1L5HpqZLjw0ARCO0B19i9gAQOo6ESzzTE+L8L1wxb1KIspOeoko+TQ=="],
"werift-common": ["werift-common@0.0.3", "", { "dependencies": { "@shinyoshiaki/jspack": "^0.0.6", "debug": "^4.4.0" } }, "sha512-ma3E4BqKTyZVLhrdfTVs2T1tg9seeUtKMRn5e64LwgrogWa62+3LAUoLBUSl1yPWhgSkXId7GmcHuWDen9IJeQ=="],
"werift-dtls": ["werift-dtls@0.5.7", "", { "dependencies": { "@fidm/x509": "^1.2.1", "@noble/curves": "^1.3.0", "@peculiar/x509": "^1.9.2", "@shinyoshiaki/binary-data": "^0.6.1", "date-fns": "^2.29.3", "lodash": "^4.17.21", "rx.mini": "^1.2.2", "tweetnacl": "^1.0.3" } }, "sha512-z2fjbP7fFUFmu/Ky4bCKXzdgPTtmSY1DYi0TUf3GG2zJT4jMQ3TQmGY8y7BSSNGetvL4h3pRZ5un0EcSOWpPog=="],
"werift-ice": ["werift-ice@0.2.2", "", { "dependencies": { "@shinyoshiaki/jspack": "^0.0.6", "buffer-crc32": "^1.0.0", "debug": "^4.3.4", "int64-buffer": "^1.0.1", "ip": "^2.0.1", "lodash": "^4.17.21", "multicast-dns": "^7.2.5", "p-cancelable": "^2.1.1", "rx.mini": "^1.2.2" } }, "sha512-td52pHp+JmFnUn5jfDr/SSNO0dMCbknhuPdN1tFp9cfRj5jaktN63qnAdUuZC20QCC3ETWdsOthcm+RalHpFCQ=="],
"werift-rtp": ["werift-rtp@0.8.8", "", { "dependencies": { "@minhducsun2002/leb128": "^1.0.0", "@shinyoshiaki/jspack": "^0.0.6", "aes-js": "^3.1.2", "buffer": "^6.0.3", "mp4box": "^0.5.3" } }, "sha512-GiYMSdvCyScQaw5bnEsraSoHUVZpjfokJAiLV4R1FsiB06t6XiebPYPpkqB9nYNNKiA8Z/cYWsym7wISq1sYSQ=="],
"werift-sctp": ["werift-sctp@0.0.6", "", { "dependencies": { "@shinyoshiaki/binary-data": "^0.6.1", "@shinyoshiaki/jspack": "^0.0.6", "lodash": "^4.17.21", "rx.mini": "^1.2.2", "turbo-crc32": "^1.0.1" } }, "sha512-SaGrPvkXIPGHyY58Y8TV6vee3vpYHNyvMTWdu+c6SokG3ob8tfofHLKWdO1Zu3ypNV5pL9bxBuQMzOPM3N34fg=="],
"zhead": ["zhead@2.2.4", "", {}, ""],
"zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
"@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, ""],
"c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"c12/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"nypm/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
"werift-dtls/date-fns": ["date-fns@2.30.0", "", { "dependencies": { "@babel/runtime": "^7.21.0" } }, "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw=="],
"@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, ""],
"@scalar/themes/@scalar/types/nanoid": ["nanoid@5.1.6", "", { "bin": "bin/nanoid.js" }, ""],
"@scalar/themes/@scalar/types/zod": ["zod@3.25.76", "", {}, ""],
}
}

5
bunfig.toml Normal file
View File

@@ -0,0 +1,5 @@
[serve.static]
env = "BUN_PUBLIC_*"
[plugin]
preload = ["bun-plugin-glob-import/register"]

View File

@@ -0,0 +1 @@
["e4b37b72-d515-4aa4-b750-f9ab3e37f390","a4185301-2d75-4414-9390-455be77f34a4"]

View File

@@ -0,0 +1 @@
["3390f845-837c-4c34-bfdb-7a3cab9d1cc7","d30efb95-1ad4-4c58-8660-a10e0ca44988","4f328d59-9786-493d-ac98-ba83a73ab946","2b5447b3-30c2-4573-8029-5e10f3d3bf36"]

BIN
dayu.wav Normal file

Binary file not shown.

BIN
hasilTTS_FINAL.mp3 Normal file

Binary file not shown.

BIN
malik.wav Normal file

Binary file not shown.

BIN
malik_output.wav Normal file

Binary file not shown.

BIN
malik_output10.wav Normal file

Binary file not shown.

BIN
output/merged.wav Normal file

Binary file not shown.

56
package.json Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "jenna-tools",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "NODE_ENV=development bun --hot src/index.tsx",
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'",
"start": "NODE_ENV=production bun src/index.tsx",
"seed": "bun prisma/seed.ts",
"generate:route": "bun bin/route.generate.ts",
"generate:env": "bun bin/env.generate.ts"
},
"dependencies": {
"@elysiajs/cors": "^1.4.0",
"@elysiajs/eden": "^1.4.5",
"@elysiajs/jwt": "^1.4.0",
"@elysiajs/swagger": "^1.3.1",
"@gradio/client": "^2.0.0",
"@mantine/core": "^8.3.8",
"@mantine/hooks": "^8.3.8",
"@mantine/modals": "^8.3.8",
"@mantine/notifications": "^8.3.8",
"@prisma/client": "^6.19.0",
"@prisma/extension-accelerate": "^3.0.0",
"@tabler/icons-react": "^3.35.0",
"@types/randomstring": "^1.3.0",
"dotenv": "^17.2.3",
"elysia": "^1.4.16",
"jwt-decode": "^4.0.0",
"lodash": "^4.17.21",
"node-av": "^5.0.2",
"randomstring": "^1.3.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6",
"swr": "^2.3.6",
"tiktok-tts": "^1.1.17",
"zod": "^4.1.13"
},
"devDependencies": {
"@babel/parser": "^7.28.5",
"@babel/traverse": "^7.28.5",
"@babel/types": "^7.28.5",
"@types/babel__traverse": "^7.28.0",
"@types/bun": "latest",
"@types/jwt-decode": "^3.1.0",
"@types/lodash": "^4.17.21",
"@types/react": "^19.2.6",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prisma": "^6.19.0"
}
}

16
postcss.config.js Normal file
View File

@@ -0,0 +1,16 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};

31
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,31 @@
generator client {
provider = "prisma-client-js"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ApiKey ApiKey[]
}
model ApiKey {
id String @id @default(cuid())
User User? @relation(fields: [userId], references: [id])
userId String
name String
key String @unique @db.Text
description String?
expiredAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

30
prisma/seed.ts Normal file
View File

@@ -0,0 +1,30 @@
import { prisma } from "@/server/lib/prisma";
const user = [
{
name: "Bip",
email: "wibu@bip.com",
password: "Production_123",
}
];
; (async () => {
for (const u of user) {
await prisma.user.upsert({
where: { email: u.email },
create: u,
update: u,
})
console.log(`✅ User ${u.email} seeded successfully`)
}
})().catch((e) => {
console.error(e)
process.exit(1)
}).finally(() => {
console.log("✅ Seeding completed successfully ")
process.exit(0)
})

138
public/READS.md Normal file
View File

@@ -0,0 +1,138 @@
# Bun React Template Starter
This template is a starting point for building modern full-stack web applications using Bun, React, ElysiaJS, and Prisma. This project is designed to provide a fast, efficient, and structured development experience with a cutting-edge technology stack.
## Key Features
- **Super-Fast Runtime**: Built on top of [Bun](https://bun.sh/), a high-performance JavaScript runtime.
- **End-to-End Typesafe Backend**: Utilizes [ElysiaJS](https://elysiajs.com/) for a type-safe API from the backend to the frontend.
- **Automatic API Documentation**: Comes with [Elysia Swagger](https://elysiajs.com/plugins/swagger) to automatically generate interactive API documentation.
- **Modern Frontend**: A feature-rich and customizable user interface using [React](https://react.dev/) and [Mantine UI](https://mantine.dev/).
- **Easy Database Access**: Integrated with [Prisma](https://www.prisma.io/) as an ORM for intuitive and secure database interactions.
- **Clear Project Structure**: Logical file and folder organization to facilitate easy navigation and development.
## Tech Stack
- **Runtime**: Bun
- **Backend**:
- **Framework**: ElysiaJS
- **ElysiaJS Modules**:
- `@elysiajs/cors`: Manages Cross-Origin Resource Sharing policies.
- `@elysiajs/jwt`: JSON Web Token-based authentication.
- `@elysiajs/swagger`: Creates API documentation (Swagger/OpenAPI).
- `@elysiajs/eden`: A typesafe RPC-like client to connect the frontend with the Elysia API.
- **Frontend**:
- **Library**: React
- **UI Framework**: Mantine
- **Routing**: React Router
- **Data Fetching**: SWR
- **Database**:
- **ORM**: Prisma
- **Supported Databases**: PostgreSQL (default), MySQL, SQLite, etc.
- **Language**: TypeScript
## Getting Started
### 1. Clone the Repository
```bash
git clone https://github.com/your-username/bun-react-template-starter.git
cd bun-react-template-starter
```
### 2. Install Dependencies
Ensure you have [Bun](https://bun.sh/docs/installation) installed. Then, run the following command:
```bash
bun install
```
### 3. Configure Environment Variables
Copy the `.env.example` file to `.env` and customize the values.
```bash
cp .env.example .env
```
Fill in your `.env` file similar to the example below:
```
DATABASE_URL="postgresql://user:password@host:port/database?schema=public"
JWT_SECRET=a_super_long_and_secure_secret
BUN_PUBLIC_BASE_URL=http://localhost:3000
PORT=3000
```
After that, create TypeScript type declarations for your environment variables with the provided script:
```bash
bun run generate:env
```
This command will generate a `types/env.d.ts` file based on your `.env`.
### 4. Database Preparation
Make sure your PostgreSQL database server is running. Then, apply the Prisma schema to your database:
```bash
bunx prisma db push
```
You can also seed the database with initial data using the following script:
```bash
bun run seed
```
### 5. Running the Development Server
```bash
bun run dev
```
The application will be running at `http://localhost:3000`. The server supports hot-reloading, so changes in the code will be reflected instantly without needing a manual restart.
### 6. Accessing API Documentation (Swagger)
Once the server is running, you can access the automatically generated API documentation at:
`http://localhost:3000/swagger`
## Available Scripts
- `bun run dev`: Runs the development server with hot-reloading.
- `bun run build`: Builds the frontend application for production into the `dist` directory.
- `bun run start`: Runs the application in production mode.
- `bun run seed`: Executes the database seeding script located in `prisma/seed.ts`.
- `bun run generate:route`: A utility to create new route files in the backend.
- `bun run generate:env`: Generates a type definition file (`.d.ts`) from the variables in `.env`.
## Project Structure
```
/
├── bin/ # Utility scripts (generators)
├── prisma/ # Database schema, migrations, and seed
├── src/ # Main source code
│ ├── App.tsx # Root application component
│ ├── clientRoutes.ts # Route definitions for the frontend
│ ├── frontend.tsx # Entry point for client-side rendering (React)
│ ├── index.css # Global CSS file
│ ├── index.html # Main HTML template
│ ├── index.tsx # Main entry point for the app (server and client)
│ ├── components/ # Reusable React components
│ ├── lib/ # Shared libraries/helpers (e.g., apiFetch)
│ ├── pages/ # React page components
│ └── server/ # Backend code (ElysiaJS)
│ ├── lib/ # Server-specific libraries (e.g., prisma client)
│ ├── middlewares/ # Middleware for the API
│ └── routes/ # API route files
└── types/ # TypeScript type definitions
```
## Contributing
Contributions are highly welcome! Please feel free to create a pull request to add features, fix bugs, or improve the documentation.

13
public/apa.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>APA Page</title>
</head>
<body>
<h1>APA Page</h1>
<p>This is a test page to verify static file serving.</p>
<img src="/public/kelinci.png" />
</body>
</html>

BIN
public/kelincix.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

7776
public/styles.css Normal file

File diff suppressed because it is too large Load Diff

456
py/main.py Normal file
View File

@@ -0,0 +1,456 @@
import os
import base64
import uuid
import asyncio
import time
import threading
from typing import Dict
from concurrent.futures import ThreadPoolExecutor
from fastapi import FastAPI, UploadFile, File, Form, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
from tts_util import TTSConfig, TTSEngine
# ===============================================
# CONFIG
# ===============================================
PROMPT_FOLDER = "prompt_source"
os.makedirs(PROMPT_FOLDER, exist_ok=True)
JOBS_FOLDER = "jobs"
os.makedirs(JOBS_FOLDER, exist_ok=True)
OUTPUT_FOLDER = "output"
os.makedirs(OUTPUT_FOLDER, exist_ok=True)
# Job store
job_store: Dict[str, dict] = {}
# Auto cleanup
JOB_EXPIRE_SECONDS = 600
# FIFO queue and turbo workers
TURBO_MODE = True
TURBO_WORKERS = 3
WORKER_THREADPOOL_MAX = 4
# Rate limiting
RATE_LIMIT_TOKENS = 10
RATE_LIMIT_WINDOW = 60
# Token buckets
token_buckets: Dict[str, dict] = {}
token_lock = threading.Lock()
# Executor and TTS engine
thread_pool = ThreadPoolExecutor(max_workers=WORKER_THREADPOOL_MAX)
config = TTSConfig()
tts_engine = TTSEngine(config, thread_pool)
app = FastAPI(title="Chatterbox TTS Server - Turbo + FIFO + RateLimit + WAV")
# ===============================================
# RATE LIMIT UTILITIES
# ===============================================
def get_client_ip(request: Request) -> str:
"""Get client IP from request"""
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
if request.client:
return request.client.host
return "unknown"
def allow_request_ip(ip: str) -> bool:
"""Check if request from IP is allowed (token bucket)"""
now = time.time()
with token_lock:
bucket = token_buckets.get(ip)
if bucket is None:
token_buckets[ip] = {"tokens": RATE_LIMIT_TOKENS - 1, "last": now}
return True
# Refill tokens
elapsed = now - bucket["last"]
refill = (elapsed / RATE_LIMIT_WINDOW) * RATE_LIMIT_TOKENS
if refill > 0:
bucket["tokens"] = min(RATE_LIMIT_TOKENS, bucket["tokens"] + refill)
bucket["last"] = now
if bucket["tokens"] >= 1:
bucket["tokens"] -= 1
return True
else:
return False
# ===============================================
# BACKGROUND WORKERS
# ===============================================
job_queue: asyncio.Queue = asyncio.Queue()
async def worker_loop(worker_id: int):
"""Background worker for processing TTS jobs"""
while True:
job_id = await job_queue.get()
job = job_store.get(job_id)
if job is None:
job_queue.task_done()
continue
# Mark as processing
job["status"] = "processing"
job["worker"] = worker_id
job["timestamp"] = time.time()
try:
prompt = job.get("prompt")
text = job.get("text")
prompt_path = os.path.join(PROMPT_FOLDER, f"{prompt}.wav")
if not os.path.exists(prompt_path):
job["status"] = "error"
job["error"] = "Prompt tidak ditemukan"
job["timestamp"] = time.time()
job_queue.task_done()
continue
# Generate audio
out_wav = os.path.join(OUTPUT_FOLDER, f"{job_id}.wav")
await tts_engine.generate_to_file(text, prompt_path, out_wav)
job["status"] = "done"
job["result"] = out_wav
job["timestamp"] = time.time()
except Exception as e:
job["status"] = "error"
job["error"] = str(e)
job["timestamp"] = time.time()
finally:
job_queue.task_done()
async def cleanup_worker():
"""Background cleanup worker for expired jobs"""
while True:
now = time.time()
expired = []
for jid, job in list(job_store.items()):
if (
job.get("status") == "done"
and now - job.get("timestamp", 0) > JOB_EXPIRE_SECONDS
):
f = job.get("result")
# if f and os.path.exists(f):
# try:
# os.remove(f)
# except Exception:
# pass
expired.append(jid)
for jid in expired:
job_store.pop(jid, None)
# Purge stale token buckets
with token_lock:
stale_ips = []
for ip, b in token_buckets.items():
if now - b.get("last", 0) > RATE_LIMIT_WINDOW * 10:
stale_ips.append(ip)
for ip in stale_ips:
token_buckets.pop(ip, None)
await asyncio.sleep(30)
# ===============================================
# STARTUP
# ===============================================
@app.on_event("startup")
async def startup():
"""Initialize TTS engine and start background workers"""
# Load TTS model
tts_engine.load_model()
# Start cleanup worker
asyncio.create_task(cleanup_worker())
# Start turbo workers
worker_count = TURBO_WORKERS if TURBO_MODE else 1
for i in range(worker_count):
asyncio.create_task(worker_loop(i + 1))
# ===============================================
# PYDANTIC MODELS
# ===============================================
class RegisterPromptBase64(BaseModel):
prompt_name: str
base64_audio: str
class DeletePrompt(BaseModel):
prompt_name: str
class RenamePrompt(BaseModel):
old_name: str
new_name: str
# ===============================================
# PROMPT MANAGEMENT ENDPOINTS
# ===============================================
@app.post("/register-prompt-base64")
async def register_prompt_base64(data: RegisterPromptBase64):
"""Register a voice prompt from base64 audio"""
filename = f"{data.prompt_name}.wav"
path = os.path.join(PROMPT_FOLDER, filename)
try:
raw = base64.b64decode(data.base64_audio)
with open(path, "wb") as f:
f.write(raw)
return {"status": "ok", "file": filename}
except Exception as e:
return JSONResponse(status_code=400, content={"error": str(e)})
@app.post("/register-prompt-file")
async def register_prompt_file(prompt: UploadFile = File(...), name: str = Form(None)):
"""Register a voice prompt from uploaded file"""
prompt_name = name or os.path.splitext(prompt.filename)[0]
save_path = os.path.join(PROMPT_FOLDER, f"{prompt_name}.wav")
try:
with open(save_path, "wb") as f:
f.write(await prompt.read())
return {"status": "ok", "file": f"{prompt_name}.wav"}
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
@app.get("/list-prompt")
async def list_prompt():
"""List all registered voice prompts"""
lst = [f for f in os.listdir(PROMPT_FOLDER) if f.lower().endswith(".wav")]
return {
"count": len(lst),
"prompts": lst,
"prompt_names": [os.path.splitext(f)[0] for f in lst],
}
@app.post("/delete-prompt")
async def delete_prompt(data: DeletePrompt):
"""Delete a voice prompt"""
path = os.path.join(PROMPT_FOLDER, f"{data.prompt_name}.wav")
if not os.path.exists(path):
return JSONResponse(
status_code=404, content={"error": "Prompt tidak ditemukan"}
)
try:
os.remove(path)
return {"status": "ok", "deleted": f"{data.prompt_name}.wav"}
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
@app.post("/rename-prompt")
async def rename_prompt(data: RenamePrompt):
"""Rename a voice prompt"""
old = os.path.join(PROMPT_FOLDER, f"{data.old_name}.wav")
new = os.path.join(PROMPT_FOLDER, f"{data.new_name}.wav")
if not os.path.exists(old):
return JSONResponse(
status_code=404, content={"error": "Prompt lama tidak ditemukan"}
)
if os.path.exists(new):
return JSONResponse(
status_code=400, content={"error": "Nama baru sudah digunakan"}
)
try:
os.rename(old, new)
return {"status": "ok", "from": data.old_name, "to": data.new_name}
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
@app.post("/tts-async")
async def tts_async(request: Request, text: str = Form(...), prompt: str = Form(...)):
"""Asynchronous TTS - enqueue job and return job_id"""
client_ip = get_client_ip(request)
if not allow_request_ip(client_ip):
return JSONResponse(status_code=429, content={"error": "rate limit exceeded"})
job_id = str(uuid.uuid4())
job_store[job_id] = {
"status": "pending",
"timestamp": time.time(),
"prompt": prompt,
"text": text,
"client_ip": client_ip,
}
# Enqueue (FIFO)
await job_queue.put(job_id)
return {"status": "queued", "job_id": job_id, "check": f"/result/{job_id}"}
@app.get("/result/{job_id}")
async def tts_result(job_id: str):
"""Get result of async TTS job"""
job = job_store.get(job_id)
if not job:
return JSONResponse(
status_code=404, content={"error": "Job ID tidak ditemukan"}
)
# Still processing
if job["status"] in ("pending", "processing"):
return {
"status": job["status"],
"job_id": job_id,
"worker": job.get("worker"),
"timestamp": job.get("timestamp"),
}
# Error
if job["status"] == "error":
return job
# Done - return file
result_path = job.get("result")
if not result_path or not os.path.exists(result_path):
return JSONResponse(
status_code=500,
content={"status": "error", "error": "File hasil tidak ditemukan"},
)
return JSONResponse(
status_code=200,
content={"status": "done", "job_id": job_id, "file": result_path},
)
@app.get("/list-file")
async def list_file():
"""List all files inside OUTPUT_FOLDER"""
try:
files = [
f for f in os.listdir(OUTPUT_FOLDER)
if os.path.isfile(os.path.join(OUTPUT_FOLDER, f))
]
# Hanya file WAV (sesuai output engine)
wav_files = [f for f in files if f.lower().endswith(".wav")]
# Include metadata timestamp dari job_store
detailed = []
for f in wav_files:
full_path = os.path.join(OUTPUT_FOLDER, f)
size = os.path.getsize(full_path)
# Cari job yang terkait (jika ada)
related_job = None
for jid, job in job_store.items():
if job.get("result") == full_path:
related_job = {
"job_id": jid,
"status": job.get("status"),
"timestamp": job.get("timestamp"),
"prompt": job.get("prompt"),
}
break
detailed.append(
{
"file": f,
"size_bytes": size,
"path": full_path,
"job": related_job,
}
)
return {
"count": len(wav_files),
"files": detailed,
}
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
async def iterfile(file_path):
with open(file_path, "rb") as f:
chunk = f.read(4096)
while chunk:
yield chunk
chunk = f.read(4096)
@app.get("/file/{file_name}")
async def get_output_file(file_name: str):
if not file_name.endswith(".wav"):
file_name = f"{file_name}.wav"
file_path = os.path.join(OUTPUT_FOLDER, file_name)
if not os.path.exists(file_path):
return JSONResponse(status_code=404, content={"error": "File tidak ditemukan"})
return StreamingResponse(
iterfile(file_path),
media_type="audio/wav",
headers={"Content-Disposition": f"attachment; filename={file_name}"},
)
# ===============================================
# FILE MANAGEMENT ENDPOINTS
# ===============================================
@app.delete("/rm/{filename}")
async def remove_file(filename: str):
"""Delete a single output file"""
if not filename.endswith(".wav"):
filename = f"{filename}.wav"
path = os.path.join(OUTPUT_FOLDER, filename)
if not os.path.exists(path):
return JSONResponse(status_code=404, content={"error": "File tidak ditemukan"})
try:
os.remove(path)
# Remove from job_store
for jid, job in list(job_store.items()):
if job.get("result") == path:
job_store.pop(jid, None)
return {"status": "ok", "deleted": filename}
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
@app.post("/cleanup")
async def manual_cleanup():
"""Manual cleanup - remove all output files and clear done/error jobs"""
removed = []
for f in os.listdir(OUTPUT_FOLDER):
fp = os.path.join(OUTPUT_FOLDER, f)
if os.path.isfile(fp):
try:
os.remove(fp)
removed.append(f)
except:
pass
# Clear done/error jobs
cleared = []
for jid in list(job_store.keys()):
if job_store[jid]["status"] in ("done", "error"):
job_store.pop(jid, None)
cleared.append(jid)
return {"status": "ok", "removed_files": removed, "jobs_cleared": cleared}

322
py/op.py Normal file
View File

@@ -0,0 +1,322 @@
import io
import os
import asyncio
from typing import Optional
from functools import lru_cache
import torch
import torchaudio as ta
import torchaudio.functional as F
from chatterbox.tts import ChatterboxTTS
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
from concurrent.futures import ThreadPoolExecutor
import multiprocessing
class TTSConfig:
"""Configuration for TTS model and processing"""
MODEL_REPO = "grandhigh/Chatterbox-TTS-Indonesian"
CHECKPOINT = "t3_cfg.safetensors"
DEVICE = "cpu"
# Optimized generation parameters for speed
TEMPERATURE = 0.7
TOP_P = 0.9
REPETITION_PENALTY = 1.1
# Audio processing
AUDIO_GAIN_DB = 0.8
# Performance settings
USE_QUANTIZATION = True
USE_TORCH_COMPILE = True
SIMPLIFY_AUDIO_ENHANCEMENT = True
ENABLE_CACHING = True
class AudioProcessor:
"""Audio enhancement utilities (optimized)"""
@staticmethod
def generate_pink_noise_fast(shape, device):
"""Generate pink noise for audio enhancement (vectorized)"""
white = torch.randn(shape, device=device)
# Fast approximation using multi-scale filtering
pink = white * 0.5
# Apply simple averaging for pink-ish spectrum
if white.dim() == 1:
white_2d = white.unsqueeze(0).unsqueeze(0)
else:
white_2d = white.unsqueeze(0) if white.dim() == 2 else white
# Quick low-pass filtering approximation
kernel_size = min(3, white_2d.shape[-1])
if kernel_size >= 2:
filtered = torch.nn.functional.avg_pool1d(
white_2d,
kernel_size=kernel_size,
stride=1,
padding=kernel_size//2
)
pink += filtered.squeeze(0) * 0.3 if white.dim() == 1 else filtered.squeeze(0)
return pink * 0.1
@staticmethod
def enhance_audio_fast(wav, sr):
"""Apply audio enhancements with optimized operations"""
with torch.no_grad():
# Normalize
peak = wav.abs().max()
if peak > 0:
wav = wav / (peak + 1e-8) * 0.95
# Apply filters in sequence (no-grad mode for speed)
wav = F.highpass_biquad(wav, sr, cutoff_freq=60)
wav = F.lowpass_biquad(wav, sr, cutoff_freq=10000)
wav = F.bass_biquad(wav, sr, gain=1.5, central_freq=200, Q=0.7)
wav = F.treble_biquad(wav, sr, gain=-1.2, central_freq=6000, Q=0.7)
# Vectorized compression (faster than loop)
threshold = 0.6
ratio = 2.5
abs_wav = wav.abs()
mask = abs_wav > threshold
wav = torch.where(
mask,
torch.sign(wav) * (threshold + (abs_wav - threshold) / ratio),
wav
)
wav = torch.tanh(wav * 1.08)
# Add pink noise (fast version)
wav = wav + AudioProcessor.generate_pink_noise_fast(wav.shape, wav.device) * 0.0003
wav = F.gain(wav, gain_db=TTSConfig.AUDIO_GAIN_DB)
# Final normalization
peak = wav.abs().max()
if peak > 0:
wav = wav / peak * 0.88
return wav
@staticmethod
def enhance_audio_simple(wav, sr):
"""Simplified audio enhancement for maximum speed"""
with torch.no_grad():
# Simple normalization and tanh saturation
peak = wav.abs().max()
if peak > 0:
wav = wav / (peak + 1e-8) * 0.95
# Basic filtering
wav = F.highpass_biquad(wav, sr, cutoff_freq=80)
wav = F.lowpass_biquad(wav, sr, cutoff_freq=8000)
# Soft clipping
wav = torch.tanh(wav * 1.1)
# Final normalization
peak = wav.abs().max()
if peak > 0:
wav = wav / peak * 0.9
return wav
@staticmethod
def save_tensor_to_wav(wav_tensor: torch.Tensor, sr: int, out_wav_path: str):
"""Save a torch tensor to WAV file"""
# Ensure float32 CPU tensor
if wav_tensor.device.type != "cpu":
wav_tensor = wav_tensor.cpu()
if wav_tensor.dtype != torch.float32:
wav_tensor = wav_tensor.type(torch.float32)
# torchaudio.save requires shape [channels, samples]
if wav_tensor.dim() == 1:
wav_out = wav_tensor.unsqueeze(0)
else:
wav_out = wav_tensor
# Save directly as WAV
ta.save(out_wav_path, wav_out, sr, format="wav")
@staticmethod
def tensor_to_wav_buffer(wav_tensor: torch.Tensor, sr: int) -> io.BytesIO:
"""Convert torch tensor to WAV buffer"""
buf = io.BytesIO()
if wav_tensor.dim() == 1:
wav_out = wav_tensor.unsqueeze(0)
else:
wav_out = wav_tensor
ta.save(buf, wav_out, sr, format="wav")
buf.seek(0)
return buf
class TTSEngine:
"""Main TTS engine with model management (optimized)"""
def __init__(self, config: TTSConfig, thread_pool: Optional[ThreadPoolExecutor] = None):
self.config = config
self.thread_pool = thread_pool or ThreadPoolExecutor(
max_workers=multiprocessing.cpu_count()
)
self.model = None
self.model_lock = asyncio.Lock()
self.sr = None
self.audio_prompt_cache = {} if config.ENABLE_CACHING else None
def load_model(self):
"""Load the TTS model and checkpoint with optimizations"""
print("Loading model...")
self.model = ChatterboxTTS.from_pretrained(device=self.config.DEVICE)
ckpt = hf_hub_download(repo_id=self.config.MODEL_REPO, filename=self.config.CHECKPOINT)
state = load_file(ckpt, device=self.config.DEVICE)
self.model.t3.to(self.config.DEVICE).load_state_dict(state)
self.model.t3.eval()
# Apply quantization for CPU speed
if self.config.USE_QUANTIZATION:
print("Applying dynamic quantization...")
self.model.t3 = torch.quantization.quantize_dynamic(
self.model.t3,
{torch.nn.Linear, torch.nn.LSTM, torch.nn.GRU},
dtype=torch.qint8
)
# Apply torch.compile if available (PyTorch 2.0+)
if self.config.USE_TORCH_COMPILE and hasattr(torch, 'compile'):
print("Compiling model with torch.compile...")
try:
self.model.t3 = torch.compile(self.model.t3, mode="reduce-overhead")
except Exception as e:
print(f"Torch compile failed: {e}, continuing without compilation")
# Disable dropout for inference
for m in self.model.t3.modules():
if hasattr(m, "training"):
m.training = False
if isinstance(m, torch.nn.Dropout):
m.p = 0
self.sr = self.model.sr
print("Model ready (optimized for CPU).")
def _load_audio_prompt(self, audio_prompt_path: str):
"""Load audio prompt with optional caching"""
if self.config.ENABLE_CACHING and audio_prompt_path in self.audio_prompt_cache:
return self.audio_prompt_cache[audio_prompt_path]
# Load normally
# Note: actual loading is done inside model.generate
if self.config.ENABLE_CACHING:
self.audio_prompt_cache[audio_prompt_path] = audio_prompt_path
return audio_prompt_path
async def generate(self, text: str, audio_prompt_path: str) -> torch.Tensor:
"""Generate audio from text with voice prompt"""
async with self.model_lock:
# Cache audio prompt path
cached_prompt = self._load_audio_prompt(audio_prompt_path)
def blocking_generate():
with torch.no_grad():
# Set number of threads for CPU inference
torch.set_num_threads(multiprocessing.cpu_count())
return self.model.generate(
text,
audio_prompt_path=cached_prompt,
temperature=self.config.TEMPERATURE,
top_p=self.config.TOP_P,
repetition_penalty=self.config.REPETITION_PENALTY,
)
wav = await asyncio.get_event_loop().run_in_executor(
self.thread_pool,
blocking_generate
)
return wav
async def generate_and_enhance(self, text: str, audio_prompt_path: str) -> torch.Tensor:
"""Generate and enhance audio"""
wav = await self.generate(text, audio_prompt_path)
# Choose enhancement method based on config
enhance_func = (
AudioProcessor.enhance_audio_simple
if self.config.SIMPLIFY_AUDIO_ENHANCEMENT
else AudioProcessor.enhance_audio_fast
)
# Enhance audio (CPU-bound)
wav = await asyncio.get_event_loop().run_in_executor(
self.thread_pool,
lambda: enhance_func(wav.cpu(), self.sr)
)
return wav
async def generate_to_file(self, text: str, audio_prompt_path: str, output_path: str):
"""Generate audio and save to file"""
wav = await self.generate_and_enhance(text, audio_prompt_path)
# Save to WAV
await asyncio.get_event_loop().run_in_executor(
self.thread_pool,
AudioProcessor.save_tensor_to_wav,
wav,
self.sr,
output_path
)
async def generate_to_buffer(self, text: str, audio_prompt_path: str) -> io.BytesIO:
"""Generate audio and return as WAV buffer"""
wav = await self.generate_and_enhance(text, audio_prompt_path)
# Convert to buffer
buffer = await asyncio.get_event_loop().run_in_executor(
self.thread_pool,
AudioProcessor.tensor_to_wav_buffer,
wav,
self.sr
)
return buffer
def clear_cache(self):
"""Clear audio prompt cache"""
if self.audio_prompt_cache:
self.audio_prompt_cache.clear()
# Example usage
async def main():
"""Example usage of optimized TTS engine"""
config = TTSConfig()
engine = TTSEngine(config)
# Load model once
engine.load_model()
# Generate audio
text = "Halo, ini adalah tes text to speech dalam bahasa Indonesia."
audio_prompt = "path/to/your/voice_sample.wav"
# Generate to file
await engine.generate_to_file(text, audio_prompt, "output.wav")
print("Audio generated successfully!")
# Or generate to buffer
buffer = await engine.generate_to_buffer(text, audio_prompt)
print(f"Audio buffer size: {len(buffer.getvalue())} bytes")
if __name__ == "__main__":
asyncio.run(main())

213
py/tts_util.py Normal file
View File

@@ -0,0 +1,213 @@
import io
import os
import asyncio
from typing import Optional
import torch
import torchaudio as ta
import torchaudio.functional as F
from chatterbox.tts import ChatterboxTTS
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
from concurrent.futures import ThreadPoolExecutor
class TTSConfig:
"""Configuration for TTS model and processing"""
MODEL_REPO = "grandhigh/Chatterbox-TTS-Indonesian"
CHECKPOINT = "t3_cfg.safetensors"
DEVICE = "cpu"
# Generation parameters
TEMPERATURE = 0.65
TOP_P = 0.88
REPETITION_PENALTY = 1.25
# Audio processing
AUDIO_GAIN_DB = 0.8
class AudioProcessor:
"""Audio enhancement utilities"""
@staticmethod
def generate_pink_noise(shape, device):
"""Generate pink noise for audio enhancement"""
white = torch.randn(shape, device=device)
pink = torch.zeros_like(white)
b = torch.zeros(7)
if len(shape) == 1:
for j in range(shape[0]):
w = white[j].item()
b[0] = 0.99886 * b[0] + w * 0.0555179
b[1] = 0.99332 * b[1] + w * 0.0750759
b[2] = 0.96900 * b[2] + w * 0.1538520
b[3] = 0.86650 * b[3] + w * 0.3104856
b[4] = 0.55000 * b[4] + w * 0.5329522
b[5] = -0.7616 * b[5] - w * 0.0168980
pink[j] = (b[0]+b[1]+b[2]+b[3]+b[4]+b[5]+b[6] + w*0.5362) * 0.11
b[6] = w * 0.115926
else:
for i in range(shape[0]):
b = torch.zeros(7)
for j in range(shape[1]):
w = white[i, j].item()
b[0] = 0.99886 * b[0] + w * 0.0555179
b[1] = 0.99332 * b[1] + w * 0.0750759
b[2] = 0.96900 * b[2] + w * 0.1538520
b[3] = 0.86650 * b[3] + w * 0.3104856
b[4] = 0.55000 * b[4] + w * 0.5329522
b[5] = -0.7616 * b[5] - w * 0.0168980
pink[i, j] = (b[0]+b[1]+b[2]+b[3]+b[4]+b[5]+b[6] + w*0.5362) * 0.11
b[6] = w * 0.115926
return pink * 0.1
@staticmethod
def enhance_audio(wav, sr):
"""Apply audio enhancements: normalization, filtering, compression"""
# Normalize
peak = wav.abs().max()
if peak > 0:
wav = wav / (peak + 1e-8) * 0.95
# Apply filters
wav = F.highpass_biquad(wav, sr, cutoff_freq=60)
wav = F.lowpass_biquad(wav, sr, cutoff_freq=10000)
wav = F.bass_biquad(wav, sr, gain=1.5, central_freq=200, Q=0.7)
wav = F.treble_biquad(wav, sr, gain=-1.2, central_freq=6000, Q=0.7)
# Compression
threshold = 0.6
ratio = 2.5
abs_wav = wav.abs()
compressed = wav.clone()
mask = abs_wav > threshold
compressed[mask] = torch.sign(wav[mask]) * (threshold + (abs_wav[mask] - threshold) / ratio)
wav = compressed
wav = torch.tanh(wav * 1.08)
# Add pink noise
wav = wav + AudioProcessor.generate_pink_noise(wav.shape, wav.device) * 0.0003
wav = F.gain(wav, gain_db=TTSConfig.AUDIO_GAIN_DB)
# Final normalization
peak = wav.abs().max()
if peak > 0:
wav = wav / peak * 0.88
return wav
@staticmethod
def save_tensor_to_wav(wav_tensor: torch.Tensor, sr: int, out_wav_path: str):
"""Save a torch tensor to WAV file"""
# Ensure float32 CPU tensor
if wav_tensor.device.type != "cpu":
wav_tensor = wav_tensor.cpu()
if wav_tensor.dtype != torch.float32:
wav_tensor = wav_tensor.type(torch.float32)
# torchaudio.save requires shape [channels, samples]
if wav_tensor.dim() == 1:
wav_out = wav_tensor.unsqueeze(0)
else:
wav_out = wav_tensor
# Save directly as WAV
ta.save(out_wav_path, wav_out, sr, format="wav")
@staticmethod
def tensor_to_wav_buffer(wav_tensor: torch.Tensor, sr: int) -> io.BytesIO:
"""Convert torch tensor to WAV buffer"""
buf = io.BytesIO()
if wav_tensor.dim() == 1:
wav_out = wav_tensor.unsqueeze(0)
else:
wav_out = wav_tensor
ta.save(buf, wav_out, sr, format="wav")
buf.seek(0)
return buf
class TTSEngine:
"""Main TTS engine with model management"""
def __init__(self, config: TTSConfig, thread_pool: ThreadPoolExecutor):
self.config = config
self.thread_pool = thread_pool
self.model = None
self.model_lock = asyncio.Lock()
self.sr = None
def load_model(self):
"""Load the TTS model and checkpoint"""
print("Loading model...")
self.model = ChatterboxTTS.from_pretrained(device=self.config.DEVICE)
ckpt = hf_hub_download(repo_id=self.config.MODEL_REPO, filename=self.config.CHECKPOINT)
state = load_file(ckpt, device=self.config.DEVICE)
self.model.t3.to(self.config.DEVICE).load_state_dict(state)
self.model.t3.eval()
# Disable dropout
for m in self.model.t3.modules():
if hasattr(m, "training"):
m.training = False
if isinstance(m, torch.nn.Dropout):
m.p = 0
self.sr = self.model.sr
print("Model ready.")
async def generate(self, text: str, audio_prompt_path: str) -> torch.Tensor:
"""Generate audio from text with voice prompt"""
async with self.model_lock:
def blocking_generate():
with torch.no_grad():
return self.model.generate(
text,
audio_prompt_path=audio_prompt_path,
temperature=self.config.TEMPERATURE,
top_p=self.config.TOP_P,
repetition_penalty=self.config.REPETITION_PENALTY,
)
wav = await asyncio.get_event_loop().run_in_executor(
self.thread_pool,
blocking_generate
)
return wav
async def generate_and_enhance(self, text: str, audio_prompt_path: str) -> torch.Tensor:
"""Generate and enhance audio"""
wav = await self.generate(text, audio_prompt_path)
# Enhance audio (CPU-bound)
wav = await asyncio.get_event_loop().run_in_executor(
self.thread_pool,
lambda: AudioProcessor.enhance_audio(wav.cpu(), self.sr)
)
return wav
async def generate_to_file(self, text: str, audio_prompt_path: str, output_path: str):
"""Generate audio and save to file"""
wav = await self.generate_and_enhance(text, audio_prompt_path)
# Save to WAV
await asyncio.get_event_loop().run_in_executor(
self.thread_pool,
AudioProcessor.save_tensor_to_wav,
wav,
self.sr,
output_path
)
async def generate_to_buffer(self, text: str, audio_prompt_path: str) -> io.BytesIO:
"""Generate audio and return as WAV buffer"""
wav = await self.generate_and_enhance(text, audio_prompt_path)
# Convert to buffer
return AudioProcessor.tensor_to_wav_buffer(wav, self.sr)

17
src/App.tsx Normal file
View File

@@ -0,0 +1,17 @@
import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css";
import { Notifications } from "@mantine/notifications";
import { ModalsProvider } from "@mantine/modals";
import { MantineProvider } from "@mantine/core";
import AppRoutes from "./AppRoutes";
export function App() {
return (
<MantineProvider defaultColorScheme="dark">
<Notifications />
<ModalsProvider>
<AppRoutes />
</ModalsProvider>
</MantineProvider>
);
}

226
src/AppRoutes.tsx Normal file
View File

@@ -0,0 +1,226 @@
// ⚡ AUTO-GENERATED — DO NOT EDIT
import React from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { Skeleton } from "@mantine/core";
const SkeletonLoading = () => {
return (
<div style={{ padding: "20px" }}>
{Array.from({ length: 5 }, (_, i) => (
<Skeleton key={i} height={70} radius="md" animate={true} mb="sm" />
))}
</div>
);
};
/**
* Prefetch lazy component:
* - Hover
* - Visible (viewport)
* - Browser idle
*/
export function attachPrefetch(el: HTMLElement | null, preload: () => void) {
if (!el) return;
let done = false;
const run = () => {
if (done) return;
done = true;
preload();
};
// 1) On hover
el.addEventListener("pointerenter", run, { once: true });
// 2) On visible (IntersectionObserver)
const io = new IntersectionObserver((entries) => {
if (entries && entries[0] && entries[0].isIntersecting) {
run();
io.disconnect();
}
});
io.observe(el);
// 3) On idle
if ("requestIdleCallback" in window) {
requestIdleCallback(() => run());
} else {
setTimeout(run, 200);
}
}
const Login = {
Component: React.lazy(() => import("./pages/Login")),
preload: () => import("./pages/Login"),
};
const Home = {
Component: React.lazy(() => import("./pages/Home")),
preload: () => import("./pages/Home"),
};
const Register = {
Component: React.lazy(() => import("./pages/Register")),
preload: () => import("./pages/Register"),
};
const ChatterboxTtsPage = {
Component: React.lazy(
() => import("./pages/dashboard/chatterbox-tts/chatterbox-tts-_page"),
),
preload: () =>
import("./pages/dashboard/chatterbox-tts/chatterbox-tts-_page"),
};
const ChatterboxTtsLayout = {
Component: React.lazy(
() => import("./pages/dashboard/chatterbox-tts/chatterbox-tts_layout"),
),
preload: () =>
import("./pages/dashboard/chatterbox-tts/chatterbox-tts_layout"),
};
const ApikeyPage = {
Component: React.lazy(() => import("./pages/dashboard/apikey/apikey_page")),
preload: () => import("./pages/dashboard/apikey/apikey_page"),
};
const DashboardPage = {
Component: React.lazy(() => import("./pages/dashboard/dashboard_page")),
preload: () => import("./pages/dashboard/dashboard_page"),
};
const TiktokTtsPage = {
Component: React.lazy(
() => import("./pages/dashboard/tiktok-tts/tiktok_tts_page"),
),
preload: () => import("./pages/dashboard/tiktok-tts/tiktok_tts_page"),
};
const TiktokTtsLayout = {
Component: React.lazy(
() => import("./pages/dashboard/tiktok-tts/tiktok_tts_layout"),
),
preload: () => import("./pages/dashboard/tiktok-tts/tiktok_tts_layout"),
};
const DashboardLayout = {
Component: React.lazy(() => import("./pages/dashboard/dashboard_layout")),
preload: () => import("./pages/dashboard/dashboard_layout"),
};
const NotFound = {
Component: React.lazy(() => import("./pages/NotFound")),
preload: () => import("./pages/NotFound"),
};
export default function AppRoutes() {
return (
<BrowserRouter>
<Routes>
<Route
path="/login"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<Login.Component />
</React.Suspense>
}
/>
<Route
path="/"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<Home.Component />
</React.Suspense>
}
/>
<Route
path="/register"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<Register.Component />
</React.Suspense>
}
/>
<Route path="/dashboard" element={<DashboardLayout.Component />}>
<Route index element={<DashboardPage.Component />} />
<Route
path="/dashboard/chatterbox-tts"
element={<ChatterboxTtsLayout.Component />}
>
<Route
index
element={
<Navigate
to="/dashboard/chatterbox-tts/chatterbox-tts-_page"
replace
/>
}
/>
<Route
path="/dashboard/chatterbox-tts/chatterbox-tts"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<ChatterboxTtsPage.Component />
</React.Suspense>
}
/>
</Route>
<Route
path="/dashboard/apikey/apikey"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<ApikeyPage.Component />
</React.Suspense>
}
/>
<Route
path="/dashboard/dashboard"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<DashboardPage.Component />
</React.Suspense>
}
/>
<Route
path="/dashboard/tiktok-tts"
element={<TiktokTtsLayout.Component />}
>
<Route
index
element={
<Navigate to="/dashboard/tiktok-tts/tiktok_tts_page" replace />
}
/>
<Route
path="/dashboard/tiktok-tts/tiktok-tts"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<TiktokTtsPage.Component />
</React.Suspense>
}
/>
</Route>
</Route>
<Route
path="/*"
element={
<React.Suspense fallback={<SkeletonLoading />}>
<NotFound.Component />
</React.Suspense>
}
/>
</Routes>
</BrowserRouter>
);
}

443
src/Landing.tsx Normal file
View File

@@ -0,0 +1,443 @@
import clientRoutes from "./clientRoutes";
export function LandingPage() {
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NexaFlow - Modern AI Solutions</title>
<style>{`
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
overflow-x: hidden;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
/* Navbar */
nav {
padding: 20px 0;
position: fixed;
width: 100%;
top: 0;
z-index: 1000;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.nav-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 24px;
font-weight: 700;
background: linear-gradient(45deg, #fff, #e0e0e0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav-links {
display: flex;
gap: 30px;
list-style: none;
}
.nav-links a {
color: #fff;
text-decoration: none;
font-weight: 500;
transition: opacity 0.3s ease;
}
.nav-links a:hover {
opacity: 0.7;
}
.cta-nav {
padding: 10px 24px;
background: #260c668a;
color: #667eea;
border-radius: 25px;
font-weight: 600;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.cta-nav:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
/* Hero Section */
.hero {
padding: 150px 0 100px;
text-align: center;
position: relative;
}
.hero h1 {
font-size: 64px;
font-weight: 800;
margin-bottom: 20px;
line-height: 1.2;
animation: fadeInUp 1s ease;
}
.hero p {
font-size: 20px;
margin-bottom: 40px;
opacity: 0.9;
max-width: 600px;
margin-left: auto;
margin-right: auto;
animation: fadeInUp 1s ease 0.2s backwards;
}
.hero-buttons {
display: flex;
gap: 20px;
justify-content: center;
animation: fadeInUp 1s ease 0.4s backwards;
}
.btn {
padding: 16px 40px;
border-radius: 30px;
font-size: 16px;
font-weight: 600;
text-decoration: none;
transition: all 0.3s ease;
cursor: pointer;
border: none;
}
.btn-primary {
background: #fff;
color: #667eea;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.btn-primary:hover {
transform: translateY(-3px);
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.3);
}
.btn-secondary {
background: transparent;
color: #fff;
border: 2px solid #fff;
}
.btn-secondary:hover {
background: #fff;
color: #667eea;
}
/* Features Section */
.features {
padding: 100px 0;
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
}
.features h2 {
text-align: center;
font-size: 48px;
margin-bottom: 60px;
font-weight: 700;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 40px;
}
.feature-card {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
padding: 40px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.2);
transition: transform 0.3s ease, box-shadow 0.3s ease;
cursor: pointer;
}
.feature-card:hover {
transform: translateY(-10px);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
}
.feature-icon {
font-size: 48px;
margin-bottom: 20px;
display: inline-block;
animation: float 3s ease-in-out infinite;
}
.feature-card:nth-child(2) .feature-icon {
animation-delay: 0.5s;
}
.feature-card:nth-child(3) .feature-icon {
animation-delay: 1s;
}
.feature-card h3 {
font-size: 24px;
margin-bottom: 15px;
}
.feature-card p {
opacity: 0.9;
line-height: 1.6;
}
/* Stats Section */
.stats {
padding: 80px 0;
text-align: center;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 40px;
}
.stat-item h3 {
font-size: 48px;
font-weight: 800;
margin-bottom: 10px;
background: linear-gradient(45deg, #fff, #f0f0f0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-item p {
opacity: 0.9;
font-size: 18px;
}
/* Footer */
footer {
padding: 60px 0;
text-align: center;
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
}
footer p {
opacity: 0.8;
margin-bottom: 20px;
}
.social-links {
display: flex;
gap: 20px;
justify-content: center;
margin-top: 30px;
}
.social-links a {
width: 50px;
height: 50px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
text-decoration: none;
font-size: 20px;
transition: all 0.3s ease;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.social-links a:hover {
background: #fff;
color: #667eea;
transform: translateY(-5px);
}
/* Animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-20px);
}
}
/* Responsive */
@media (max-width: 768px) {
.nav-links {
display: none;
}
.hero h1 {
font-size: 40px;
}
.hero p {
font-size: 18px;
}
.hero-buttons {
flex-direction: column;
align-items: center;
}
.features h2 {
font-size: 36px;
}
}
`}</style>
</head>
<body>
<nav>
<div className="container">
<div className="nav-content">
<div className="logo">NexaFlow</div>
<ul className="nav-links">
<li>
<a href="#features">Features</a>
</li>
<li>
<a href="#about">About</a>
</li>
<li>
<a href="#contact">Contact</a>
</li>
<li>
<a href={clientRoutes["/dashboard"]} className="cta-nav">
Get Started
</a>
</li>
</ul>
</div>
</div>
</nav>
<section className="hero">
<div className="container">
<h1>Transform Your Workflow with AI</h1>
<p>
Powerful automation and intelligent insights to boost your
productivity and streamline operations
</p>
<div className="hero-buttons">
<a href="#" className="btn btn-primary">
Start Free Trial
</a>
<a href="#" className="btn btn-secondary">
Watch Demo
</a>
</div>
</div>
</section>
<section className="features" id="features">
<div className="container">
<h2>Why Choose NexaFlow?</h2>
<div className="features-grid">
<div className="feature-card">
<div className="feature-icon"></div>
<h3>Lightning Fast</h3>
<p>
Experience blazing fast performance with our optimized
infrastructure and cutting-edge technology
</p>
</div>
<div className="feature-card">
<div className="feature-icon">🔒</div>
<h3>Secure & Reliable</h3>
<p>
Enterprise-grade security with 99.9% uptime guarantee to keep
your data safe and accessible
</p>
</div>
<div className="feature-card">
<div className="feature-icon">🎯</div>
<h3>Smart Analytics</h3>
<p>
Gain actionable insights with AI-powered analytics and make
data-driven decisions effortlessly
</p>
</div>
</div>
</div>
</section>
<section className="stats">
<div className="container">
<div className="stats-grid">
<div className="stat-item">
<h3>50K+</h3>
<p>Active Users</p>
</div>
<div className="stat-item">
<h3>99.9%</h3>
<p>Uptime</p>
</div>
<div className="stat-item">
<h3>24/7</h3>
<p>Support</p>
</div>
<div className="stat-item">
<h3>150+</h3>
<p>Integrations</p>
</div>
</div>
</div>
</section>
<footer>
<div className="container">
<div className="logo">NexaFlow</div>
<p>Empowering businesses with intelligent automation</p>
<div className="social-links">
<a href="#">𝕏</a>
<a href="#">in</a>
<a href="#">f</a>
</div>
<p style={{ marginTop: "30px", fontSize: "14px" }}>
© 2025 NexaFlow. All rights reserved.
</p>
</div>
</footer>
</body>
</html>
);
}

15
src/clientRoutes.ts Normal file
View File

@@ -0,0 +1,15 @@
// AUTO-GENERATED
const clientRoutes = {
"/login": "/login",
"/": "/",
"/register": "/register",
"/dashboard": "/dashboard",
"/dashboard/chatterbox-tts": "/dashboard/chatterbox-tts",
"/dashboard/chatterbox-tts/chatterbox-tts": "/dashboard/chatterbox-tts/chatterbox-tts",
"/dashboard/apikey/apikey": "/dashboard/apikey/apikey",
"/dashboard/dashboard": "/dashboard/dashboard",
"/dashboard/tiktok-tts": "/dashboard/tiktok-tts",
"/dashboard/tiktok-tts/tiktok-tts": "/dashboard/tiktok-tts/tiktok-tts",
"/*": "/*"
} as const;
export default clientRoutes;

View File

@@ -0,0 +1,25 @@
import { useEffect, useState } from "react";
import { Navigate, Outlet } from "react-router-dom";
import clientRoutes from "@/clientRoutes";
import apiFetch from "@/lib/apiFetch";
export default function ProtectedRoute() {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
useEffect(() => {
async function checkSession() {
try {
// backend otomatis baca cookie JWT dari request
const res = await apiFetch.api.user.find.get();
setIsAuthenticated(res.status === 200);
} catch {
setIsAuthenticated(false);
}
}
checkSession();
}, []);
if (isAuthenticated === null) return null;
if (!isAuthenticated) return <Navigate to={clientRoutes["/login"]} replace />;
return <Outlet />;
}

26
src/frontend.tsx Normal file
View File

@@ -0,0 +1,26 @@
/**
* This file is the entry point for the React app, it sets up the root
* element and renders the App component to the DOM.
*
* It is included in `src/index.html`.
*/
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
const elem = document.getElementById("root")!;
const app = (
<StrictMode>
<App />
</StrictMode>
);
if (import.meta.hot) {
// With hot module reloading, `import.meta.hot.data` is persisted.
const root = (import.meta.hot.data.root ??= createRoot(elem));
root.render(app);
} else {
// The hot module reloading API is not available in production.
createRoot(elem).render(app);
}

187
src/index.css Normal file
View File

@@ -0,0 +1,187 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
}
body {
margin: 0;
display: grid;
place-items: center;
min-width: 320px;
min-height: 100vh;
position: relative;
}
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
opacity: 0.05;
background: url("./logo.svg");
background-size: 256px;
transform: rotate(-12deg) scale(1.35);
animation: slide 30s linear infinite;
pointer-events: none;
}
@keyframes slide {
from {
background-position: 0 0;
}
to {
background-position: 256px 224px;
}
}
.app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
position: relative;
z-index: 1;
}
.logo-container {
display: flex;
justify-content: center;
align-items: center;
gap: 2rem;
margin-bottom: 2rem;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 0.3s;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.bun-logo {
transform: scale(1.2);
}
.bun-logo:hover {
filter: drop-shadow(0 0 2em #fbf0dfaa);
}
.react-logo {
animation: spin 20s linear infinite;
}
.react-logo:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes spin {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
code {
background-color: #1a1a1a;
padding: 0.2em 0.4em;
border-radius: 0.3em;
font-family: monospace;
}
.api-tester {
margin: 2rem auto 0;
width: 100%;
max-width: 600px;
text-align: left;
display: flex;
flex-direction: column;
gap: 1rem;
}
.endpoint-row {
display: flex;
align-items: center;
gap: 0.5rem;
background: #1a1a1a;
padding: 0.75rem;
border-radius: 12px;
font: monospace;
border: 2px solid #fbf0df;
transition: 0.3s;
width: 100%;
box-sizing: border-box;
}
.endpoint-row:focus-within {
border-color: #f3d5a3;
}
.method {
background: #fbf0df;
color: #1a1a1a;
padding: 0.3rem 0.7rem;
border-radius: 8px;
font-weight: 700;
font-size: 0.9em;
appearance: none;
margin: 0;
width: min-content;
display: block;
flex-shrink: 0;
border: none;
}
.method option {
text-align: left;
}
.url-input {
width: 100%;
flex: 1;
background: 0;
border: 0;
color: #fbf0df;
font: 1em monospace;
padding: 0.2rem;
outline: 0;
}
.url-input:focus {
color: #fff;
}
.url-input::placeholder {
color: rgba(251, 240, 223, 0.4);
}
.send-button {
background: #fbf0df;
color: #1a1a1a;
border: 0;
padding: 0.4rem 1.2rem;
border-radius: 8px;
font-weight: 700;
transition: 0.1s;
cursor: var(--bun-cursor);
}
.send-button:hover {
background: #f3d5a3;
transform: translateY(-1px);
cursor: pointer;
}
.response-area {
width: 100%;
min-height: 120px;
background: #1a1a1a;
border: 2px solid #fbf0df;
border-radius: 12px;
padding: 0.75rem;
color: #fbf0df;
font: monospace;
resize: vertical;
box-sizing: border-box;
}
.response-area:focus {
border-color: #f3d5a3;
}
.response-area::placeholder {
color: rgba(251, 240, 223, 0.4);
}
@media (prefers-reduced-motion) {
*,
::before,
::after {
animation: none !important;
}
}

13
src/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="./logo.svg" />
<title>Bun + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>

120
src/index.tsx Normal file
View File

@@ -0,0 +1,120 @@
import Elysia, { t } from "elysia";
import Swagger from "@elysiajs/swagger";
import html from "./index.html";
import { apiAuth } from "./server/middlewares/apiAuth";
import Auth from "./server/routes/auth_route";
import ApiKeyRoute from "./server/routes/apikey_route";
import type { User } from "generated/prisma";
import { renderToReadableStream } from "react-dom/server";
import { LandingPage } from "./Landing";
import cors from "@elysiajs/cors";
import packageJson from "../package.json";
import TTSTiktok from "./server/routes/tts_tiktok";
import ChatterboxTTS from "./server/routes/chatterbox_tts";
const PORT = process.env.PORT || 3000;
const Docs = new Elysia().use(
Swagger({
path: "/docs",
specPath: "/spec",
exclude: ["/docs", "/spec"],
documentation: {
info: {
title: packageJson.name,
version: packageJson.version,
description: `API documentation for ${packageJson.name} ${packageJson.version}`,
contact: {
name: "Jenna Support",
email: "support@jenna.com",
},
license: {
name: "MIT",
url: "https://github.com/jenna/jenna-tools/blob/main/LICENSE",
},
},
servers: [
{
url: process.env.BASE_URL || "http://localhost:3000",
description: process.env.BASE_URL
? "Production server"
: "Local development server",
},
],
},
}),
);
const ApiUser = new Elysia({
prefix: "/user",
}).get(
"/find",
(ctx) => {
const { user } = ctx as any;
return {
user: user as User,
};
},
{
detail: {
description: "Get the current user information",
summary: "Retrieve authenticated user details",
tags: ["User"],
},
},
);
const Api = new Elysia({
prefix: "/api",
})
.use(apiAuth)
.use(ApiKeyRoute)
.use(ApiUser)
.use(TTSTiktok)
.use(ChatterboxTTS);
const app = new Elysia()
.use(cors())
.use(Api)
.use(Docs)
.use(Auth)
.get(
"/assets/:name",
(ctx) => {
try {
const file = Bun.file(`public/${encodeURIComponent(ctx.params.name)}`);
return new Response(file);
} catch (error) {
return new Response("File not found", { status: 404 });
}
},
{
detail: {
description: "Serve static asset files",
summary: "Get a static asset by name",
tags: ["Static Assets"],
},
},
)
.get(
"/",
async () => {
const stream = await renderToReadableStream(<LandingPage />);
return new Response(stream, {
headers: { "Content-Type": "text/html" },
});
},
{
detail: {
description: "Landing page for Jenna Tools",
summary: "Get the main landing page",
tags: ["General"],
},
},
)
.get("/*", html)
.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
export type ServerApp = typeof app;

11
src/lib/apiFetch.ts Normal file
View File

@@ -0,0 +1,11 @@
import { treaty } from '@elysiajs/eden'
import type { ServerApp } from '..'
const URL = process.env.BUN_PUBLIC_BASE_URL
if (!URL) {
throw new Error('BUN_PUBLIC_BASE_URL is not defined')
}
const apiFetch = treaty<ServerApp>(URL)
export default apiFetch

1
src/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg id="Bun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 70"><title>Bun Logo</title><path id="Shadow" d="M71.09,20.74c-.16-.17-.33-.34-.5-.5s-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5A26.46,26.46,0,0,1,75.5,35.7c0,16.57-16.82,30.05-37.5,30.05-11.58,0-21.94-4.23-28.83-10.86l.5.5.5.5.5.5.5.5.5.5.5.5.5.5C19.55,65.3,30.14,69.75,42,69.75c20.68,0,37.5-13.48,37.5-30C79.5,32.69,76.46,26,71.09,20.74Z"/><g id="Body"><path id="Background" d="M73,35.7c0,15.21-15.67,27.54-35,27.54S3,50.91,3,35.7C3,26.27,9,17.94,18.22,13S33.18,3,38,3s8.94,4.13,19.78,10C67,17.94,73,26.27,73,35.7Z" style="fill:#fbf0df"/><path id="Bottom_Shadow" data-name="Bottom Shadow" d="M73,35.7a21.67,21.67,0,0,0-.8-5.78c-2.73,33.3-43.35,34.9-59.32,24.94A40,40,0,0,0,38,63.24C57.3,63.24,73,50.89,73,35.7Z" style="fill:#f6dece"/><path id="Light_Shine" data-name="Light Shine" d="M24.53,11.17C29,8.49,34.94,3.46,40.78,3.45A9.29,9.29,0,0,0,38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7c0,.4,0,.8,0,1.19C9.06,15.48,20.07,13.85,24.53,11.17Z" style="fill:#fffefc"/><path id="Top" d="M35.12,5.53A16.41,16.41,0,0,1,29.49,18c-.28.25-.06.73.3.59,3.37-1.31,7.92-5.23,6-13.14C35.71,5,35.12,5.12,35.12,5.53Zm2.27,0A16.24,16.24,0,0,1,39,19c-.12.35.31.65.55.36C41.74,16.56,43.65,11,37.93,5,37.64,4.74,37.19,5.14,37.39,5.49Zm2.76-.17A16.42,16.42,0,0,1,47,17.12a.33.33,0,0,0,.65.11c.92-3.49.4-9.44-7.17-12.53C40.08,4.54,39.82,5.08,40.15,5.32ZM21.69,15.76a16.94,16.94,0,0,0,10.47-9c.18-.36.75-.22.66.18-1.73,8-7.52,9.67-11.12,9.45C21.32,16.4,21.33,15.87,21.69,15.76Z" style="fill:#ccbea7;fill-rule:evenodd"/><path id="Outline" d="M38,65.75C17.32,65.75.5,52.27.5,35.7c0-10,6.18-19.33,16.53-24.92,3-1.6,5.57-3.21,7.86-4.62,1.26-.78,2.45-1.51,3.6-2.19C32,1.89,35,.5,38,.5s5.62,1.2,8.9,3.14c1,.57,2,1.19,3.07,1.87,2.49,1.54,5.3,3.28,9,5.27C69.32,16.37,75.5,25.69,75.5,35.7,75.5,52.27,58.68,65.75,38,65.75ZM38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7,3,50.89,18.7,63.25,38,63.25S73,50.89,73,35.7C73,26.62,67.31,18.13,57.78,13,54,11,51.05,9.12,48.66,7.64c-1.09-.67-2.09-1.29-3-1.84C42.63,4,40.42,3,38,3Z"/></g><g id="Mouth"><g id="Background-2" data-name="Background"><path d="M45.05,43a8.93,8.93,0,0,1-2.92,4.71,6.81,6.81,0,0,1-4,1.88A6.84,6.84,0,0,1,34,47.71,8.93,8.93,0,0,1,31.12,43a.72.72,0,0,1,.8-.81H44.26A.72.72,0,0,1,45.05,43Z" style="fill:#b71422"/></g><g id="Tongue"><path id="Background-3" data-name="Background" d="M34,47.79a6.91,6.91,0,0,0,4.12,1.9,6.91,6.91,0,0,0,4.11-1.9,10.63,10.63,0,0,0,1-1.07,6.83,6.83,0,0,0-4.9-2.31,6.15,6.15,0,0,0-5,2.78C33.56,47.4,33.76,47.6,34,47.79Z" style="fill:#ff6164"/><path id="Outline-2" data-name="Outline" d="M34.16,47a5.36,5.36,0,0,1,4.19-2.08,6,6,0,0,1,4,1.69c.23-.25.45-.51.66-.77a7,7,0,0,0-4.71-1.93,6.36,6.36,0,0,0-4.89,2.36A9.53,9.53,0,0,0,34.16,47Z"/></g><path id="Outline-3" data-name="Outline" d="M38.09,50.19a7.42,7.42,0,0,1-4.45-2,9.52,9.52,0,0,1-3.11-5.05,1.2,1.2,0,0,1,.26-1,1.41,1.41,0,0,1,1.13-.51H44.26a1.44,1.44,0,0,1,1.13.51,1.19,1.19,0,0,1,.25,1h0a9.52,9.52,0,0,1-3.11,5.05A7.42,7.42,0,0,1,38.09,50.19Zm-6.17-7.4c-.16,0-.2.07-.21.09a8.29,8.29,0,0,0,2.73,4.37A6.23,6.23,0,0,0,38.09,49a6.28,6.28,0,0,0,3.65-1.73,8.3,8.3,0,0,0,2.72-4.37.21.21,0,0,0-.2-.09Z"/></g><g id="Face"><ellipse id="Right_Blush" data-name="Right Blush" cx="53.22" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><ellipse id="Left_Bluch" data-name="Left Bluch" cx="22.95" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><path id="Eyes" d="M25.7,38.8a5.51,5.51,0,1,0-5.5-5.51A5.51,5.51,0,0,0,25.7,38.8Zm24.77,0A5.51,5.51,0,1,0,45,33.29,5.5,5.5,0,0,0,50.47,38.8Z" style="fill-rule:evenodd"/><path id="Iris" d="M24,33.64a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,24,33.64Zm24.77,0a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,48.75,33.64Z" style="fill:#fff;fill-rule:evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

31
src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,31 @@
import clientRoutes from "@/clientRoutes";
import { Button, Card, Container, Group, Stack, Title } from "@mantine/core";
export default function Home() {
return (
<Container size={420} py={80}>
<Card shadow="sm" padding="xl" radius="md">
<Stack gap="md">
<Title order={2} ta="center">
Home
</Title>
<Group grow>
<Button size="sm" component="a" href={clientRoutes["/dashboard"]}>
Dashboard
</Button>
<Button
size="sm"
component="a"
href={clientRoutes["/login"]}
variant="light"
>
Login
</Button>
</Group>
</Stack>
</Card>
</Container>
);
}

100
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,100 @@
import {
Button,
Card,
Container,
Group,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useEffect, useState } from "react";
import apiFetch from "../lib/apiFetch";
import clientRoutes from "@/clientRoutes";
import { Navigate } from "react-router-dom";
export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const handleSubmit = async () => {
setLoading(true);
try {
const response = await apiFetch.auth.login.post({
email,
password,
});
if (response.data?.token) {
localStorage.setItem("token", response.data.token);
window.location.href = "/dashboard";
return;
}
if (response.error) {
alert(JSON.stringify(response.error));
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
async function checkSession() {
try {
// backend otomatis baca cookie JWT dari request
const res = await apiFetch.api.user.find.get();
setIsAuthenticated(res.status === 200);
} catch {
setIsAuthenticated(false);
}
}
checkSession();
}, []);
if (isAuthenticated === null) return null;
if (isAuthenticated)
return <Navigate to={clientRoutes["/dashboard"]} replace />;
return (
<Container size={420} py={80}>
<Card shadow="sm" radius="md" padding="xl">
<Stack gap="md">
<Title order={2} ta="center">
Login
</Title>
<TextInput
label="Email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<PasswordInput
label="Password"
placeholder="********"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<Group justify="flex-end" mt="sm">
<Button onClick={handleSubmit} loading={loading} fullWidth>
Login
</Button>
</Group>
<Text ta="center" size="sm">
Don't have an account? <a href="/register">Register</a>
</Text>
</Stack>
</Card>
</Container>
);
}

19
src/pages/NotFound.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { Container, Text, Anchor } from "@mantine/core";
export default function NotFound() {
return (
<Container>
<Text size="xl" ta="center" mb="md">
404 Not Found
</Text>
<Text ta="center" mb="lg">
The page you are looking for does not exist.
</Text>
<Text ta="center">
<Anchor href="/" c="blue" underline="hover">
Go back home
</Anchor>
</Text>
</Container>
);
}

108
src/pages/Register.tsx Normal file
View File

@@ -0,0 +1,108 @@
import {
Button,
Card,
Container,
Group,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useEffect, useState } from "react";
import apiFetch from "../lib/apiFetch";
import clientRoutes from "@/clientRoutes";
import { Navigate } from "react-router-dom";
export default function Register() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const handleSubmit = async () => {
setLoading(true);
try {
const response = await apiFetch.auth.register.post({
name,
email,
password,
});
if (response.data?.success) {
window.location.href = clientRoutes["/login"];
return;
}
if (response.error) {
alert(JSON.stringify(response.error));
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
async function checkSession() {
try {
const res = await apiFetch.api.user.find.get();
setIsAuthenticated(res.status === 200);
} catch {
setIsAuthenticated(false);
}
}
checkSession();
}, []);
if (isAuthenticated === null) return null;
if (isAuthenticated)
return <Navigate to={clientRoutes["/dashboard"]} replace />;
return (
<Container size={420} py={80}>
<Card shadow="sm" radius="md" padding="xl">
<Stack gap="md">
<Title order={2} ta="center">
Register
</Title>
<TextInput
label="Name"
placeholder="Your full name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<TextInput
label="Email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<PasswordInput
label="Password"
placeholder="********"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<Group justify="flex-end" mt="sm">
<Button onClick={handleSubmit} loading={loading} fullWidth>
Register
</Button>
</Group>
<Text ta="center" size="sm">
Already have an account? <a href="/login">Login</a>
</Text>
</Stack>
</Card>
</Container>
);
}

View File

@@ -0,0 +1,232 @@
import {
Button,
Card,
Container,
Group,
Stack,
Table,
Text,
TextInput,
Title,
Divider,
Loader,
} from "@mantine/core";
import { useEffect, useState } from "react";
import apiFetch from "@/lib/apiFetch";
import { showNotification } from "@mantine/notifications";
import useSwr from "swr";
import { modals } from "@mantine/modals";
export default function ApiKeyPage() {
return (
<Container size="md" w="100%" py="lg">
<Stack gap="lg">
<Title order={2}>API Key Management</Title>
<CreateApiKey />
<ListApiKey />
</Stack>
</Container>
);
}
function CreateApiKey() {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [expiredAt, setExpiredAt] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
try {
setLoading(true);
if (!name || !description) {
showNotification({
title: "Error",
message: "All fields are required",
color: "red",
});
return;
}
const res = await apiFetch.api.apikey.create.post({
name,
description,
expiredAt: expiredAt
? new Date(expiredAt).toISOString()
: new Date().toISOString(),
});
if (res.status === 200) {
setName("");
setDescription("");
setExpiredAt("");
showNotification({
title: "Success",
message: "API key created successfully",
color: "green",
});
}
setLoading(false);
} catch (error) {
showNotification({
title: "Error",
message: "Failed to create API key " + JSON.stringify(error),
color: "red",
});
setLoading(false);
} finally {
setLoading(false);
}
};
return (
<Card shadow="sm" radius="md" padding="lg">
<Stack gap="md">
<Title order={4}>Create API Key</Title>
<TextInput
label="Name"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<TextInput
label="Description"
placeholder="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<TextInput
label="Expired At"
placeholder="Expired At"
type="date"
value={expiredAt}
onChange={(e) => setExpiredAt(e.target.value)}
/>
<Group justify="flex-end" mt="sm">
<Button
variant="outline"
onClick={() => {
setName("");
setDescription("");
setExpiredAt("");
}}
>
Cancel
</Button>
<Button onClick={handleSubmit} type="submit" loading={loading}>
Save
</Button>
</Group>
</Stack>
</Card>
);
}
function ListApiKey() {
const { data, error, isLoading, mutate } = useSwr(
"/",
() => apiFetch.api.apikey.list.get(),
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
refreshInterval: 3000,
},
);
const apiKeys = data?.data?.apiKeys || [];
useEffect(() => {
mutate();
}, []);
if (error) return <Text color="red">Error fetching API keys</Text>;
if (isLoading) return <Loader />;
return (
<Card shadow="sm" radius="md" padding="lg">
<Stack gap="md">
<Title order={4}>API Key List</Title>
<Divider />
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th>Expired At</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th style={{ width: 160 }}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{apiKeys.map((apiKey: any, index: number) => (
<Table.Tr key={index}>
<Table.Td>{apiKey.name}</Table.Td>
<Table.Td>{apiKey.description}</Table.Td>
<Table.Td>
{apiKey.expiredAt?.toISOString().split("T")[0]}
</Table.Td>
<Table.Td>
{apiKey.createdAt?.toISOString().split("T")[0]}
</Table.Td>
<Table.Td>
<Group gap="xs">
<Button
variant="light"
size="xs"
onClick={() => {
modals.openConfirmModal({
title: "Delete API Key",
children: (
<Text>
Are you sure you want to delete this API key?
</Text>
),
labels: { confirm: "Delete", cancel: "Cancel" },
onCancel: () => {},
onConfirm: async () => {
await apiFetch.api.apikey.delete.delete({
id: apiKey.id,
});
mutate();
},
});
}}
>
Delete
</Button>
<Button
variant="outline"
size="xs"
onClick={() => {
navigator.clipboard.writeText(apiKey.key);
showNotification({
title: "Copied",
message: "API key copied to clipboard",
color: "green",
});
}}
>
Copy
</Button>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,225 @@
import { useEffect, useState } from "react";
import {
Alert,
Button,
Card,
Group,
Loader,
Modal,
ScrollArea,
Stack,
Table,
Text,
ActionIcon,
Code
} from "@mantine/core";
import {
IconAlertTriangle,
IconRefresh,
IconDownload,
IconTrash,
IconPlayerPlay
} from "@tabler/icons-react";
import apiFetch from "@/lib/apiFetch";
export default function ChatterboxListFile() {
const [files, setFiles] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [loadingDelete, setLoadingDelete] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [playModal, setPlayModal] = useState(false);
const [currentUrl, setCurrentUrl] = useState<string | null>(null);
const [loadingPlay, setLoadingPlay] = useState(false)
const fetchFiles = async () => {
setLoading(true);
setError(null);
try {
const res = await apiFetch.api["chatterbox-tts"]["list-file"].get();
setFiles(res.data?.data?.files || []);
} catch (err) {
setError(err + "");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchFiles();
}, []);
const removeFile = async (name: string) => {
setLoadingDelete(name);
try {
await apiFetch.api["chatterbox-tts"]["rm"]({ filename: name }).delete();
fetchFiles();
} catch (err) {
alert("Gagal menghapus: " + err);
} finally {
setLoadingDelete(null);
}
};
const openPlay = async (filename: string) => {
setLoadingPlay(true)
try {
const urlFetch = await apiFetch.api["chatterbox-tts"].file({ filename }).get()
const res = await fetch(urlFetch.response.url);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setCurrentUrl(url);
setPlayModal(true);
console.log("BLOB: ",blob)
console.log("URL BLOB: ", url)
} catch (error) {
console.log("eh error")
} finally {
setLoadingPlay(false)
}
};
return (
<>
{/* Modal Player */}
<Modal
opened={playModal}
onClose={() => setPlayModal(false)}
title="Audio Player"
centered
size="md"
>
{currentUrl && (
<audio controls style={{ width: "100%" }}>
<source src={currentUrl} type="audio/mpeg" />
Browser kamu tidak mendukung pemutar audio.
</audio>
)}
</Modal>
{/* Main List Card */}
<Card shadow="md" radius="lg" p="xl" withBorder>
<Stack gap="lg">
<Group justify="space-between">
<Text size="xl" fw={700}>
🎧 Output Files
</Text>
<Button
leftSection={<IconRefresh size={16} />}
onClick={fetchFiles}
loading={loading}
>
Refresh
</Button>
</Group>
{error && (
<Alert
icon={<IconAlertTriangle />}
color="red"
radius="md"
title="Gagal memuat data"
>
{error}
</Alert>
)}
{loading && (
<Group justify="center" py="lg">
<Loader size="lg" />
</Group>
)}
{!loading && files.length === 0 && (
<Text ta="center" c="dimmed">
Belum ada file output.
</Text>
)}
{!loading && files.length > 0 && (
<ScrollArea h={350}>
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
>
<Table.Thead>
<Table.Tr>
<Table.Th>File Name</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th w={200}>Action</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{files.map((item, idx) => (
<Table.Tr key={idx}>
<Table.Td>
<Code>{item.file}</Code>
</Table.Td>
<Table.Td>
{(item.size_bytes / 1024).toFixed(1)} KB
</Table.Td>
<Table.Td>
<Group justify="flex-end">
{/* Play */}
<ActionIcon
loading={loadingPlay}
variant="light"
color="blue"
size="lg"
radius="md"
onClick={() => openPlay(item.file)}
>
<IconPlayerPlay size={18} />
</ActionIcon>
{/* Download */}
<ActionIcon
variant="light"
color="green"
size="lg"
radius="md"
component="a"
href={`/tts-output/${item.file}`}
download={item.file}
>
<IconDownload size={18} />
</ActionIcon>
{/* Delete */}
<ActionIcon
variant="light"
color="red"
size="lg"
radius="md"
onClick={() => removeFile(item.file)}
loading={loadingDelete === item.file}
>
<IconTrash size={18} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
)}
</Stack>
</Card>
</>
);
}

View File

@@ -0,0 +1,175 @@
import apiFetch from "@/lib/apiFetch";
import {
Alert,
Button,
Card,
Group,
Loader,
ScrollArea,
Stack,
Table,
Text,
Modal
} from "@mantine/core";
import { IconAlertTriangle, IconRefresh, IconTrash } from "@tabler/icons-react";
import { useEffect, useState } from "react";
export default function ChatterboxListPrompt() {
const [data, setData] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [loadingDelete, setLoadingDelete] = useState<string | null>(null); // file yang sedang dihapus
const [error, setError] = useState<string | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [targetFile, setTargetFile] = useState<string | null>(null);
const fetchList = async () => {
setLoading(true);
setError(null);
try {
const res = await apiFetch.api["chatterbox-tts"]["list-prompt"].get();
setData(res.data?.data.prompt_names || []);
} catch (err) {
setError(err + "");
} finally {
setLoading(false);
}
};
const removePrompt = async (filename: string) => {
setLoadingDelete(filename);
try {
await apiFetch.api["chatterbox-tts"]["delete-prompt"].post({
prompt_name: filename
});
// refresh list
fetchList();
} catch (err) {
alert("Failed to remove: " + err);
} finally {
setLoadingDelete(null);
}
};
useEffect(() => {
fetchList();
}, []);
const openRemoveDialog = (name: string) => {
setTargetFile(name);
setConfirmOpen(true);
};
return (
<>
{/* CONFIRM DELETE MODAL */}
<Modal
opened={confirmOpen}
onClose={() => setConfirmOpen(false)}
title="Confirm Remove"
centered
>
<Stack>
<Text>Hapus file <b>{targetFile}</b> ?</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setConfirmOpen(false)}>
Cancel
</Button>
<Button
color="red"
leftSection={<IconTrash size={16} />}
loading={loadingDelete === targetFile}
onClick={() => {
if (targetFile) removePrompt(targetFile);
setConfirmOpen(false);
}}
>
Remove
</Button>
</Group>
</Stack>
</Modal>
<Card shadow="md" radius="lg" p="xl" withBorder>
<Stack gap="lg">
<Group justify="space-between">
<Text size="xl" fw={700}>
📁 List Prompt Files
</Text>
<Button
leftSection={!loading && <IconRefresh size={16} />}
onClick={fetchList}
loading={loading}
radius="md"
>
Refresh
</Button>
</Group>
{error && (
<Alert
icon={<IconAlertTriangle size={16} />}
color="red"
radius="md"
title="Error fetching list"
>
{error}
</Alert>
)}
{loading && (
<Group justify="center" py="lg">
<Loader size="lg" />
</Group>
)}
{!loading && data.length === 0 && (
<Text ta="center" c="dimmed">
Belum ada prompt file yang terdaftar.
</Text>
)}
{!loading && data.length > 0 && (
<ScrollArea h={320}>
<Table highlightOnHover striped withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>File Name</Table.Th>
<Table.Th w={120}>Action</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.map((item, i) => (
<Table.Tr key={i}>
<Table.Td>{item}</Table.Td>
<Table.Td>
<Button
color="red"
size="xs"
radius="md"
leftSection={<IconTrash size={14} />}
loading={loadingDelete === item}
onClick={() => openRemoveDialog(item)}
>
Remove
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
)}
</Stack>
</Card>
</>
);
}

View File

@@ -0,0 +1,104 @@
import { useState } from "react";
import {
Button,
Card,
FileInput,
Stack,
Text,
Group,
Loader,
Alert,
Code,
} from "@mantine/core";
import { IconUpload, IconCheck, IconAlertTriangle } from "@tabler/icons-react";
import apiFetch from "@/lib/apiFetch";
export default function ChatterboxRegisterPromptFile() {
const [file, setFile] = useState<File | null>(null);
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
const uploadPrompt = async () => {
if (!file) return;
setLoading(true);
setResult(null);
try {
const res = await apiFetch.api["chatterbox-tts"]["register-prompt-file"].post({
file: file
});
setResult({ success: true, data: res.data?.data });
} catch (err) {
setResult({ success: false, error: err + "" });
} finally {
setLoading(false);
}
};
return (
<Card shadow="md" radius="lg" p="xl" withBorder>
<Stack gap="lg">
<Text size="xl" fw={700}>
🎤 Register Prompt File
</Text>
<FileInput
label="Upload Prompt (.wav)"
placeholder="Choose WAV file"
value={file}
onChange={setFile}
accept="audio/wav"
clearable
radius="md"
size="md"
description="File harus format .wav — biasanya sample dari suara Anda"
/>
<Button
onClick={uploadPrompt}
disabled={!file || loading}
loading={loading}
radius="md"
leftSection={!loading && <IconUpload size={18} />}
fullWidth
>
{loading ? "Uploading..." : "Upload Prompt"}
</Button>
{/* --- Response Section --- */}
{result && (
<>
{result.success ? (
<Alert
icon={<IconCheck size={16} />}
color="green"
title="Upload berhasil!"
radius="md"
>
Prompt file sudah terdaftar.
</Alert>
) : (
<Alert
icon={<IconAlertTriangle size={16} />}
color="red"
title="Gagal upload"
radius="md"
>
{result.error}
</Alert>
)}
<Card shadow="sm" p="md" radius="md" bg="#fafafa" withBorder>
<Text fw={600} mb={4}>Server Response:</Text>
<Code block>
{JSON.stringify(result, null, 2)}
</Code>
</Card>
</>
)}
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,162 @@
import { useEffect, useState } from "react";
import {
Button,
Card,
Textarea,
Select,
Stack,
Text,
Loader,
Alert,
Group,
Code,
TextInput
} from "@mantine/core";
import { IconAlertTriangle, IconPlayerPlay } from "@tabler/icons-react";
import apiFetch from "@/lib/apiFetch";
import { useLocalStorage } from "@mantine/hooks";
export default function ChatterboxTTSAsync() {
const [text, setText] = useState("");
const [prompt, setPrompt] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [promptList, setPromptList] = useState<string[]>([]);
const [loadingPrompt, setLoadingPrompt] = useState(false);
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Load available prompts
const loadPrompts = async () => {
setLoadingPrompt(true);
try {
const res = await apiFetch.api["chatterbox-tts"]["list-prompt"].get();
setPromptList(res.data?.data?.prompt_names || []);
} catch (err) {
console.error(err);
} finally {
setLoadingPrompt(false);
}
};
useEffect(() => {
loadPrompts();
}, []);
const sendTTS = async () => {
setLoading(true);
setError(null);
setResult(null);
if (!text) {
setError("Text is required");
return;
}
if (!prompt) {
setError("Prompt is required");
return;
}
if (!title) {
setError("Title is required");
return;
}
try {
const res = await apiFetch.api["chatterbox-tts"]["tts-async"].post({
text,
prompt: prompt || "",
title: title || ""
});
setResult(res.data);
} catch (err) {
setError(err + "");
} finally {
setLoading(false);
}
};
return (
<Card shadow="md" radius="lg" p="xl" withBorder>
<Stack gap="lg">
{/* Title */}
<Text fw={700} size="xl">
🔊 TTS Async Generator
</Text>
<TextInput
label="Title"
placeholder="Masukkan judul"
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
/>
{/* Input text */}
<Textarea
label="Text to Convert"
placeholder="Masukkan teks untuk diubah menjadi audio..."
minRows={3}
autosize
value={text}
onChange={(e) => setText(e.currentTarget.value)}
/>
{/* Select Prompt */}
<Select
label="Voice Prompt"
placeholder={loadingPrompt ? "Loading prompts..." : "Select voice prompt"}
data={promptList}
value={prompt}
onChange={setPrompt}
searchable
disabled={loadingPrompt}
nothingFoundMessage="No prompt found"
/>
{/* Generate Button */}
<Button
onClick={sendTTS}
loading={loading}
disabled={!text || !prompt}
leftSection={<IconPlayerPlay size={16} />}
radius="md"
>
Generate TTS
</Button>
{/* Error */}
{error && (
<Alert
icon={<IconAlertTriangle />}
title="Error"
color="red"
radius="md"
>
{error}
</Alert>
)}
{/* Result */}
{result && (
<Card shadow="sm" p="md" radius="md" bg="#fafafa" withBorder>
<Stack>
<Text fw={700}>Job Enqueued</Text>
<Text size="sm">
Job ID: <Code>{result?.job_id}</Code>
</Text>
<Text c="dimmed">
Use endpoint <Code>/result/{result?.job_id}</Code> to poll output.
</Text>
</Stack>
</Card>
)}
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,18 @@
import { Container, Stack } from "@mantine/core";
import ChatterboxRegisterPromptFile from "./ChatterboxRegisterPromptFile";
import ChatterboxListPrompt from "./ChatterboxListPrompt";
import ChatterboxTTSAsync from "./ChatterboxTTSAsync";
import ChatterboxListFile from "./ChatterboxListFile";
export default function ChatterboxTTSPage() {
return (
<Container size="md" w={"100%"}>
<Stack>
<ChatterboxRegisterPromptFile />
<ChatterboxListPrompt />
<ChatterboxTTSAsync />
<ChatterboxListFile />
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,5 @@
import { Outlet } from "react-router-dom";
export default function ChatterboxTTSLayout() {
return <Outlet />;
}

View File

@@ -0,0 +1,228 @@
import { useEffect, useState } from "react";
import {
ActionIcon,
AppShell,
Avatar,
Button,
Card,
Divider,
Flex,
Group,
NavLink,
Paper,
ScrollArea,
Stack,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import {
IconChevronLeft,
IconChevronRight,
IconDashboard,
IconKey,
IconTextCaption,
IconTextGrammar,
} from "@tabler/icons-react";
import type { User } from "generated/prisma";
import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
default as clientRoute,
default as clientRoutes,
} from "@/clientRoutes";
import apiFetch from "@/lib/apiFetch";
import ProtectedRoute from "@/components/ProtectedRoute";
import { modals } from "@mantine/modals";
/* ----------------------- Logout ----------------------- */
function Logout() {
return (
<Group justify="flex-end">
<Button
variant="light"
color="red"
size="xs"
onClick={async () => {
modals.openConfirmModal({
title: "Confirm Logout",
children: "Are you sure you want to logout?",
labels: { confirm: "Logout", cancel: "Cancel" },
confirmProps: { color: "red" },
onCancel: () => {},
onConfirm: async () => {
await apiFetch.auth.logout.delete();
localStorage.removeItem("token");
window.location.href = "/login";
},
});
}}
>
Logout
</Button>
</Group>
);
}
/* ----------------------- Layout ----------------------- */
export default function DashboardLayout() {
const [opened, setOpened] = useLocalStorage({
key: "nav_open",
defaultValue: true,
});
return (
<AppShell
padding="md"
navbar={{
width: 260,
breakpoint: "sm",
collapsed: { mobile: !opened, desktop: !opened },
}}
>
{/* NAVBAR */}
<AppShell.Navbar p="sm">
{/* Collapse toggle */}
<AppShell.Section>
<Group justify="flex-end">
<Tooltip
label={opened ? "Collapse navigation" : "Expand navigation"}
withArrow
>
<ActionIcon
variant="light"
color="gray"
onClick={() => setOpened((v) => !v)}
radius="xl"
>
{opened ? <IconChevronLeft /> : <IconChevronRight />}
</ActionIcon>
</Tooltip>
</Group>
</AppShell.Section>
{/* Navigation */}
<AppShell.Section grow component={ScrollArea} mt="sm">
<NavigationDashboard />
</AppShell.Section>
{/* User info */}
<AppShell.Section>
<HostView />
</AppShell.Section>
</AppShell.Navbar>
{/* MAIN CONTENT */}
<AppShell.Main>
<Stack gap={"0"}>
{!opened && (
<Tooltip label="Open navigation menu" withArrow m={"lg"}>
<ActionIcon
variant="light"
color="gray"
onClick={() => setOpened(true)}
radius="xl"
>
<IconChevronRight />
</ActionIcon>
</Tooltip>
)}
<ProtectedRoute />
</Stack>
</AppShell.Main>
</AppShell>
);
}
/* ----------------------- Host Info ----------------------- */
function HostView() {
const [host, setHost] = useState<User | null>(null);
useEffect(() => {
async function fetchHost() {
const { data } = await apiFetch.api.user.find.get();
setHost(data?.user ?? null);
}
fetchHost();
}, []);
return (
<Card radius="md" withBorder shadow="xs" p="md">
{host ? (
<Stack gap="sm">
<Flex gap="md" align="center">
<Avatar size="lg" radius="xl" color="blue">
{host.name?.[0]}
</Avatar>
<Stack gap={2}>
<Text fw={600} size="sm">
{host.name}
</Text>
<Text size="xs" c="dimmed">
{host.email}
</Text>
</Stack>
</Flex>
<Divider />
<Logout />
</Stack>
) : (
<Text size="sm" c="dimmed" ta="center">
No host information available
</Text>
)}
</Card>
);
}
/* ----------------------- Navigation ----------------------- */
function NavigationDashboard() {
const navigate = useNavigate();
const location = useLocation();
const isActive = (path: keyof typeof clientRoute) =>
location.pathname.startsWith(clientRoute[path]);
return (
<Stack gap="xs">
<NavLink
active={isActive("/dashboard/dashboard")}
leftSection={<IconDashboard size={18} />}
label="Dashboard Overview"
description="Quick summary and activity highlights"
onClick={() => navigate(clientRoutes["/dashboard/dashboard"])}
/>
<NavLink
active={isActive("/dashboard/apikey/apikey")}
leftSection={<IconKey size={18} />}
label="API Keys"
description="Manage your API credentials"
onClick={() => navigate(clientRoutes["/dashboard/apikey/apikey"])}
/>
<NavLink
active={isActive("/dashboard/tiktok-tts/tiktok-tts")}
leftSection={<IconTextCaption size={18} />}
label="Tiktok TTS"
description="Manage your Tiktok TTS"
onClick={() =>
navigate(clientRoutes["/dashboard/tiktok-tts/tiktok-tts"])
}
/>
<NavLink
active={isActive("/dashboard/chatterbox-tts/chatterbox-tts")}
leftSection={<IconTextGrammar size={18} />}
label="Chatterbox TTS"
description="Manage your Chatterbox TTS"
onClick={() =>
navigate(clientRoutes["/dashboard/chatterbox-tts/chatterbox-tts"])
}
/>
</Stack>
);
}

View File

@@ -0,0 +1,118 @@
import {
Button,
Card,
Container,
Divider,
Group,
SimpleGrid,
Stack,
Table,
Text,
Title,
} from "@mantine/core";
export default function Dashboard() {
return (
<Container>
<Stack gap="lg">
{/* -------- STATS SECTION -------- */}
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
<Card shadow="sm" padding="lg" radius="md">
<Text size="sm" c="dimmed">
Total Users
</Text>
<Title order={3}>1,234</Title>
</Card>
<Card shadow="sm" padding="lg" radius="md">
<Text size="sm" c="dimmed">
Active Sessions
</Text>
<Title order={3}>87</Title>
</Card>
<Card shadow="sm" padding="lg" radius="md">
<Text size="sm" c="dimmed">
API Calls today
</Text>
<Title order={3}>12,490</Title>
</Card>
<Card shadow="sm" padding="lg" radius="md">
<Text size="sm" c="dimmed">
Errors
</Text>
<Title order={3}>5</Title>
</Card>
</SimpleGrid>
{/* -------- QUICK ACTIONS -------- */}
<Card shadow="sm" radius="md" padding="lg">
<Group justify="space-between" mb="sm">
<Title order={4}>Quick Actions</Title>
</Group>
<Group>
<Button>Add API Key</Button>
<Button variant="outline">Manage Users</Button>
<Button variant="light">View Logs</Button>
</Group>
</Card>
{/* -------- ACTIVITY TABLE -------- */}
<Card shadow="sm" radius="md" padding="lg">
<Stack gap="md">
<Title order={4}>Recent Activity</Title>
<Divider />
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Action</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Table.Tr>
<Table.Td>John Doe</Table.Td>
<Table.Td>Generated new API key</Table.Td>
<Table.Td>2025-01-21</Table.Td>
<Table.Td>
<Button size="xs" variant="light" color="green">
Success
</Button>
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>Ana Smith</Table.Td>
<Table.Td>Deleted session</Table.Td>
<Table.Td>2025-01-20</Table.Td>
<Table.Td>
<Button size="xs" variant="light" color="blue">
Info
</Button>
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>Michael</Table.Td>
<Table.Td>Failed login attempt</Table.Td>
<Table.Td>2025-01-19</Table.Td>
<Table.Td>
<Button size="xs" variant="light" color="red">
Error
</Button>
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Stack>
</Card>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,5 @@
import { Outlet } from "react-router-dom";
export default function TiktokTtsLayout() {
return <Outlet />;
}

View File

@@ -0,0 +1,274 @@
import apiFetch from "@/lib/apiFetch";
import {
Button,
Card,
Container,
Group,
List,
Stack,
Textarea,
TextInput,
Title,
} from "@mantine/core";
import { useLocalStorage, useShallowEffect } from "@mantine/hooks";
import { showNotification } from "@mantine/notifications";
import { useState } from "react";
import useSWR from "swr";
export default function TiktokTtsPage() {
useShallowEffect(() => {
const chat = apiFetch.api["tts-tiktok"].ws.subscribe();
chat.subscribe((message) => {
console.log("got", message);
});
chat.on("open", () => {
chat.send("hello from client");
});
}, []);
return (
<Container size={"md"} w={"100%"}>
<Stack gap="lg">
<Title order={4}>Tiktok TTS</Title>
<GenerateTts />
<ListAudio />
</Stack>
</Container>
);
}
function GenerateTts() {
const [fileName, setFileName] = useLocalStorage({
key: "fileName",
defaultValue: "",
});
const [session_id, setSessionId] = useLocalStorage({
key: "session_id",
defaultValue: "",
});
const [text, setText] = useLocalStorage({
key: "text",
defaultValue: "",
});
const [loading, setLoading] = useState(false);
const generate = async () => {
try {
setLoading(true);
if (!session_id || !text || !fileName)
return showNotification({
title: "Error",
message: "Session ID atau Text tidak boleh kosong",
color: "red",
});
const { data } = await apiFetch.api["tts-tiktok"].generate.post({
text,
sessionId: session_id,
file_name: fileName,
});
console.log(data);
showNotification({
title: "Success",
message: "TTS berhasil di generate",
color: "green",
});
setText("");
} catch (error) {
showNotification({
title: "Error",
message: "Gagal generate TTS",
color: "red",
});
} finally {
setLoading(false);
}
};
return (
<Card shadow="sm" radius="md" padding="lg">
<Stack gap="md">
<Title order={4}>Generate TTS</Title>
<TextInput
placeholder="session_id"
value={session_id}
onChange={(e) => setSessionId(e.currentTarget.value)}
/>
<TextInput
placeholder="file_name"
value={fileName}
onChange={(e) => setFileName(e.currentTarget.value)}
/>
<Textarea
placeholder="text"
autosize
minRows={3}
maxRows={10}
value={text}
onChange={(e) => setText(e.currentTarget.value)}
/>
<Group>
<Button onClick={generate} loading={loading}>
Generate
</Button>
</Group>
</Stack>
</Card>
);
}
function ListAudio() {
const { data, error, isLoading, mutate } = useSWR(
"/x",
apiFetch.api["tts-tiktok"]["list-audio"].get,
{ refreshInterval: 3000, revalidateOnFocus: true },
);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const playAudio = async (filename: string) => {
const { error, response } = await apiFetch.api["tts-tiktok"]
["download"]({ filename })
.get();
if (error) {
showNotification({
title: "Error",
message: "Gagal download audio",
color: "red",
});
return;
}
const res = await fetch(response.url);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setAudioUrl(url);
};
const downloadAudio = async (filename: string) => {
const { error, response } = await apiFetch.api["tts-tiktok"]
["download"]({ filename })
.get();
if (error) {
showNotification({
title: "Error",
message: "Gagal download file",
color: "red",
});
return;
}
const res = await fetch(response.url);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
const deleteAudio = async (filename: string) => {
const { error } = await apiFetch.api["tts-tiktok"]
.remove({ filename })
.delete();
if (error) {
showNotification({
title: "Error",
message: "Gagal menghapus file",
color: "red",
});
return;
}
showNotification({
title: "Success",
message: `File ${filename} dihapus`,
color: "green",
});
mutate(); // refresh list
};
const deleteAll = async () => {
const { error } = await apiFetch.api["tts-tiktok"].clear.delete();
if (error) {
showNotification({
title: "Error",
message: "Gagal menghapus semua file",
color: "red",
});
return;
}
showNotification({
title: "Success",
message: "Semua file berhasil dihapus",
color: "green",
});
mutate();
setAudioUrl(null);
};
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<Card shadow="sm" radius="md" padding="lg">
<Stack gap="md">
<Group justify="space-between">
<Title order={4}>List Audio</Title>
<Button color="red" size="xs" variant="outline" onClick={deleteAll}>
Hapus Semua
</Button>
</Group>
{(data?.data?.data || []).map((audio) => (
<Group justify="space-between" key={audio}>
{audio}
<Group gap={8}>
<Button size="xs" onClick={() => playAudio(audio)}>
Play
</Button>
<Button
size="xs"
variant="light"
onClick={() => downloadAudio(audio)}
>
Download
</Button>
<Button
size="xs"
color="red"
variant="outline"
onClick={() => deleteAudio(audio)}
>
Delete
</Button>
</Group>
</Group>
))}
{audioUrl && (
<audio
src={audioUrl}
controls
autoPlay
style={{ width: "100%", marginTop: 10 }}
/>
)}
</Stack>
</Card>
);
}

8
src/react.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-11.5 -10.23174 23 20.46348">
<circle cx="0" cy="0" r="2.05" fill="#61dafb"/>
<g stroke="#61dafb" stroke-width="1" fill="none">
<ellipse rx="11" ry="4.2"/>
<ellipse rx="11" ry="4.2" transform="rotate(60)"/>
<ellipse rx="11" ry="4.2" transform="rotate(120)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 338 B

11
src/routeTypes.ts Normal file
View File

@@ -0,0 +1,11 @@
export type AppRoute = "/login" | "/" | "/register" | "/dashboard" | "/dashboard/chatterbox-tts" | "/dashboard/chatterbox-tts/chatterbox-tts" | "/dashboard/apikey/apikey" | "/dashboard/dashboard" | "/dashboard/tiktok-tts" | "/dashboard/tiktok-tts/tiktok-tts";
export function route(path: AppRoute, params?: Record<string,string|number>) {
if (!params) return path;
let final = path;
for (const k of Object.keys(params)) {
final = final.replace(":" + k, params[k] + "") as AppRoute;
}
return final;
}

View File

@@ -0,0 +1,27 @@
import fs from 'fs'
import path from 'path'
const TEMP_DIR = path.resolve("./chatterbox");
const FAILED_LOG = path.resolve(TEMP_DIR, "failed.log");
const HOST = "https://office4-chatterbox.wibudev.com";
const LIST_FILE_NAME = "wav-list.txt";
const JOBS_DIR = path.resolve(TEMP_DIR, "jobs");
const OUTPUT = path.resolve(TEMP_DIR, "output")
const PART = path.resolve(TEMP_DIR, "part")
if (!fs.existsSync(TEMP_DIR)) {
fs.mkdirSync(TEMP_DIR, { recursive: true });
}
if (!fs.existsSync(JOBS_DIR)) {
fs.mkdirSync(JOBS_DIR, { recursive: true });
}
if (!fs.existsSync(OUTPUT)) {
fs.mkdirSync(OUTPUT, { recursive: true });
}
if (!fs.existsSync(PART)) {
fs.mkdirSync(PART, { recursive: true });
}
export { TEMP_DIR, FAILED_LOG, HOST, JOBS_DIR, LIST_FILE_NAME, OUTPUT, PART };

11
src/server/lib/prisma.ts Normal file
View File

@@ -0,0 +1,11 @@
import { PrismaClient } from 'generated/prisma'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}

View File

@@ -0,0 +1,182 @@
import fs from "fs";
import { HOST, JOBS_DIR, TEMP_DIR } from "./chatterbox.env";
import path from "path";
// Pastikan folder temp ada
if (!fs.existsSync(TEMP_DIR)) {
fs.mkdirSync(TEMP_DIR, { recursive: true });
}
/* ============================================================
CLEAN TEXT (AMAN UNTUK UNICODE)
============================================================ */
function convertDateToText(text: string): string {
const months = [
"Januari", "Februari", "Maret", "April", "Mei", "Juni",
"Juli", "Agustus", "September", "Oktober", "November", "Desember"
];
return text.replace(/\(?(\d{1,2})\/(\d{1,2})\/(\d{4})\)?/g, (_, dd, mm, yyyy) => {
const monthIndex = parseInt(mm, 10) - 1;
if (monthIndex < 0 || monthIndex > 11) return _;
return `${parseInt(dd, 10)} ${months[monthIndex]} ${yyyy}`;
});
}
function cleanText(text: string): string {
// Ubah tanggal dulu biar lebih mudah dibaca TTS
text = convertDateToText(text);
return text
// izinkan: huruf, angka, spasi, dan . , ! ? ;
.replace(/[^\p{L}\p{N} .,!?;]/gu, "")
// normalkan banyak spasi menjadi 1 spasi
.replace(/\s+/g, " ")
// trim spasi kiri/kanan
.trim();
}
/* ============================================================
SPLIT TEXT (MAKSIMAL 200 CHAR + CARI TITIK/KOMA/!?)
============================================================ */
function splitText(text: string, max = 200): string[] {
const chunks: string[] = [];
text = text.trim();
const isDecimal = (str: string, idx: number) => {
// kasus angka.desimal → contoh: 1000.25 atau 1,234.56
const before = str[idx - 1];
const after = str[idx + 1];
return /\d/.test(before || '') && /\d/.test(after || '');
};
const isThousandsSeparator = (str: string, idx: number) => {
// angka ribuan 1.234 atau 2,500 dll
const before = str[idx - 1];
const after = str[idx + 1];
return /\d/.test(before || '') && /\d/.test(after || '');
};
while (text.length > 0) {
if (text.length <= max) {
chunks.push(text);
break;
}
const slice = text.slice(0, max);
let cutIndex = -1;
// Cari tanda baca yang aman untuk split
for (let i = slice.length - 1; i >= 0; i--) {
const ch = slice[i];
if (".,!?;".includes(ch || '')) {
// Abaikan jika itu bagian dari angka
if (isDecimal(slice, i)) continue;
if (isThousandsSeparator(slice, i)) continue;
cutIndex = i + 1;
break;
}
}
// Jika tidak ada tanda baca yang valid
if (cutIndex === -1) cutIndex = max;
const part = text.slice(0, cutIndex).trim();
if (part) chunks.push(part);
text = text.slice(cutIndex).trim();
}
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, prompt: string) {
const res = await fetchRetry(`${HOST}/tts-async`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ text, prompt }),
});
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;
}
/* ============================================================
MAIN
============================================================ */
export async function tts_chatterbox(rowText: string, prompt: string, jobs_name: string) {
const text = cleanText(rowText);
const parts = splitText(text, 260);
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, prompt);
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);
}
}
const jobs_file = path.join(JOBS_DIR, `${jobs_name}.json`);
console.log(`\n✅ All ${jobs.length} jobs submitted\n`);
fs.writeFileSync(jobs_file, JSON.stringify(jobs));
return jobs;
}

View File

@@ -0,0 +1,121 @@
import fs from "fs";
import path from "path";
import { FAILED_LOG, HOST, JOBS_DIR, PART } from "./chatterbox.env";
import randomstring from "randomstring";
// const sub_name = randomstring.generate({ length: 5, charset: "alphanumeric" });
/* ============================================================
DOWNLOAD
============================================================ */
// tidur
const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
// cek apakah file sudah ada
function fileExists(path: string) {
return fs.existsSync(path) && fs.statSync(path).size > 44;
}
// tulis log gagal
function logFail(msg: string) {
fs.appendFileSync(FAILED_LOG, msg + "\n");
}
async function downloadWithRetry(jobId: string, outFile: string) {
const MAX_RETRY = 5;
// jika sebelumnya sudah ada → skip
if (fileExists(outFile)) {
console.log(` 🔁 Resume: file sudah ada → ${outFile}`);
return true;
}
for (let attempt = 1; attempt <= MAX_RETRY; attempt++) {
try {
console.log(` ⏳ Download ${jobId} (try ${attempt}/${MAX_RETRY})...`);
const res = await fetch(`${HOST}/file/${jobId}`);
if (!res.ok) {
throw new Error(`Status ${res.status}`);
}
const buf = Buffer.from(await res.arrayBuffer());
// pastikan ukurannya wajar
if (buf.length < 44) {
throw new Error("File terlalu kecil (korup?)");
}
fs.writeFileSync(outFile, buf);
console.log(` ✓ Success → ${outFile}`);
return true;
} catch (err: any) {
console.error(` ❌ Error: ${err.message}`);
if (attempt < MAX_RETRY) {
console.log(" 🔄 Retry dalam 2s...");
await sleep(2000);
}
}
}
console.error(` ❌ Gagal total untuk ${jobId}`);
logFail(jobId);
return false;
}
/**
* Download all jobs
* @returns
*/
export async function download(jobs_name: string) {
const JOBS_FILE = path.join(JOBS_DIR, `${jobs_name}.json`);
if (!fs.existsSync(JOBS_FILE)) {
console.error("❌ jobs.json not found");
process.exit(1);
}
const jobs = JSON.parse(fs.readFileSync(JOBS_FILE, "utf-8"));
console.log(`📦 Total jobs: ${jobs.length}`);
const lastJobIndex = jobs.length - 1;
const lastJobId = jobs[lastJobIndex];
const resLastJob = await fetch(`${HOST}/file/${lastJobId}`);
if (!resLastJob.ok) {
return {
success: false,
message: `Failed to download last job: ${lastJobId} or on generate`,
data: null
}
}
const resultFiles: string[] = [];
await fs.promises.rm(PART, { recursive: true }).catch(() => {});
await fs.promises.mkdir(PART, { recursive: true }).catch(() => {});
for (let i = 0; i < jobs.length; i++) {
const jobId = jobs[i];
const outFile = `${PART}/${jobId}_${jobs_name}_${(i + 1)
.toString()
.padStart(4, "0")}.wav`;
console.log(`\n▶ Job ${i + 1}/${jobs.length}${jobId}`);
const success = await downloadWithRetry(jobId, outFile);
if (success) {
resultFiles.push(outFile);
}
}
console.log("\n🎉 DONE!");
console.log("Result files:", resultFiles);
return {
success: true,
message: "Download completed",
data: resultFiles
};
}

View File

@@ -0,0 +1,90 @@
import fs from "fs";
import { constants as fsConstants } from "node:fs";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { LIST_FILE_NAME, OUTPUT, TEMP_DIR } from "./chatterbox.env";
import path from "node:path";
const exec = promisify(execFile);
/**
* Menggabungkan daftar file WAV menggunakan ffmpeg concat demuxer.
* @param relativeFileNames Array NAMA file WAV yang berada di TEMP_DIR (misal: ['a.wav', 'b.wav']).
* @param output Path lengkap untuk menyimpan file WAV hasil gabungan.
*/
async function merge(relativeFileNames: string[], output: string): Promise<void> {
if (relativeFileNames.length === 0) {
console.warn("⚠️ Tidak ada file untuk digabungkan. Melewati proses.");
return;
}
console.log(`\n🔧 Menggabungkan ${relativeFileNames.length} file WAV menggunakan ffmpeg...`);
const listFilePath = `${TEMP_DIR}/${LIST_FILE_NAME}`;
// Perbaikan: Tulis nama file saja tanpa prefix TEMP_DIR agar path valid saat concat
const listContent = relativeFileNames
.map((f) => `file '${f.replace(/'/g, "'\\''")}'`)
.join("\n");
try {
await fs.promises.writeFile(listFilePath, listContent, "utf-8");
} catch (err) {
console.error(`❌ Gagal menulis file list ${listFilePath}:`, err);
throw new Error(`Gagal menyiapkan file list untuk ffmpeg.`);
}
try {
await exec("ffmpeg", [
"-f", "concat",
"-safe", "0",
"-i", listFilePath,
"-c", "copy", // Menggabungkan tanpa re-encoding
output,
]);
console.log(`✅ File berhasil digabungkan dan disimpan di: ${output}\n`);
} catch (err) {
console.error("❌ Kesalahan saat menjalankan ffmpeg:", err);
if (err instanceof Error && 'stdout' in err) {
console.error('ffmpeg stderr output:', (err as any).stderr);
}
throw new Error(`Penggabungan ffmpeg gagal.`);
} finally {
try {
await fs.promises.unlink(listFilePath);
} catch {
console.warn(`⚠️ Gagal menghapus file list ${listFilePath}.`);
}
}
}
// --- Fungsi Utama ---
export async function merge_wav(jobs_name: string) {
const output = path.resolve(OUTPUT, `${jobs_name}.wav`);
try {
await fs.promises.access(output, fsConstants.F_OK);
await fs.promises.unlink(output);
} catch (error) {}
// Baca daftar file WAV dalam TEMP_DIR
const allFiles = await fs.promises.readdir(TEMP_DIR);
const wavFileNames = allFiles
.filter((f) => f.endsWith(".wav"))
.sort((a, b) => {
const aNum = parseInt(a?.split("_")[2] || "0");
const bNum = parseInt(b?.split("_")[2] || "0");
return aNum - bNum;
});
try {
await merge(wavFileNames, output);
} catch (err) {
console.error("⛔ Proses penggabungan utama gagal.");
process.exit(1);
}
}

View File

@@ -0,0 +1,335 @@
import fs from "fs";
import path from "path";
import { v4 as uuid } from "uuid";
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;
}
export async function ensureOutputDir() {
const OUTPUT_DIR_BASE = "./tts_tiktok_output";
const OUTPUT_DIR_PARTS = path.resolve(OUTPUT_DIR_BASE, "./part");
const OUTPUT_DIR_FINAL = path.resolve(OUTPUT_DIR_BASE, "./final");
await fs.promises.mkdir(OUTPUT_DIR_BASE, { recursive: true }).catch(() => { });
await fs.promises.mkdir(OUTPUT_DIR_PARTS, { recursive: true }).catch(() => { });
await fs.promises.mkdir(OUTPUT_DIR_FINAL, { recursive: true }).catch(() => { });
return {
OUTPUT_DIR_BASE,
OUTPUT_DIR_PARTS,
OUTPUT_DIR_FINAL,
}
}
const { OUTPUT_DIR_BASE, OUTPUT_DIR_PARTS, OUTPUT_DIR_FINAL } = await ensureOutputDir();
/** ---- 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(OUTPUT_DIR_PARTS, 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
}
}
}
async function createAudioFromText(
params:
{
text: string,
fileName?: string,
speaker?: string
},
options?: { concurrency?: number; maxRetries?: number }
): Promise<string> {
const concurrency = options?.concurrency ?? 5;
const maxRetries = options?.maxRetries ?? 3;
const { text , speaker = "id_001" } = params;
const fileName = `${params.fileName}_${uuid()}`;
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;
} 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(OUTPUT_DIR_FINAL, `${fileName}.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;
}
export async function tts_tiktok(params: { text: string, sessionId: string, fileName?: string }, options?: { concurrency?: number; maxRetries?: number }) {
config(params.sessionId)
const create = await createAudioFromText({ text: params.text, fileName: params.fileName }, options);
return path.basename(create)
}
if (import.meta.main) {
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?
`
tts_tiktok({
text,
sessionId: "7b7a48e1313f9413825a8544e52b1481"
})
}

View File

@@ -0,0 +1,84 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import jwt, { type JWTPayloadSpec } from '@elysiajs/jwt'
import Elysia from 'elysia'
import { prisma } from '../lib/prisma'
const secret = process.env.JWT_SECRET
if (!secret) {
throw new Error('JWT_SECRET is not defined')
}
export function apiAuth(app: Elysia) {
if (!secret) {
throw new Error('JWT_SECRET is not defined')
}
return app
.use(
jwt({
name: 'jwt',
secret,
})
)
.derive(async ({ cookie, headers, jwt, request }) => {
let token: string | undefined
// 🔸 Ambil token dari Cookie
if (cookie?.token?.value) token = cookie.token.value as string
// 🔸 Ambil token dari Header (case-insensitive)
const possibleHeaders = [
'authorization',
'Authorization',
'x-token',
'X-Token',
]
for (const key of possibleHeaders) {
const value = headers[key]
if (typeof value === 'string') {
token = value.startsWith('Bearer ') ? value.slice(7) : value
break
}
}
// 🔸 Tidak ada token
if (!token) {
console.warn(`[AUTH] No token found for ${request.method} ${request.url}`)
return { user: null }
}
// 🔸 Verifikasi token
try {
const decoded = (await jwt.verify(token)) as JWTPayloadSpec
if (!decoded?.sub) {
console.warn('[AUTH] Token missing sub field:', decoded)
return { user: null }
}
const user = await prisma.user.findUnique({
where: { id: decoded.sub as string },
})
if (!user) {
console.warn('[AUTH] User not found for sub:', decoded.sub)
return { user: null }
}
return { user }
} catch (err) {
console.warn('[AUTH] Invalid JWT token:', err)
return { user: null }
}
})
.onBeforeHandle(({ user, set, request }) => {
if (!user) {
console.warn(
`[AUTH] Unauthorized access: ${request.method} ${request.url}`
)
set.status = 401
return { error: 'Unauthorized' }
}
})
}

View File

@@ -0,0 +1,105 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { type JWTPayloadSpec } from '@elysiajs/jwt'
import Elysia, { t } from 'elysia'
import { type User } from 'generated/prisma'
import { prisma } from '../lib/prisma'
const NINETY_YEARS = 60 * 60 * 24 * 365 * 90 // in seconds
type JWT = {
sign(data: Record<string, string | number> & JWTPayloadSpec): Promise<string>
verify(
jwt?: string
): Promise<false | (Record<string, string | number> & JWTPayloadSpec)>
}
const ApiKeyRoute = new Elysia({
prefix: '/apikey',
detail: { tags: ['apikey'] },
})
.post(
'/create',
async ctx => {
const { user }: { user: User } = ctx as any
const { name, description, expiredAt } = ctx.body
const { sign } = (ctx as any).jwt as JWT
// hitung expiredAt
const exp = expiredAt
? Math.floor(new Date(expiredAt).getTime() / 1000) // jika dikirim
: Math.floor(Date.now() / 1000) + NINETY_YEARS // default 90 tahun
const token = await sign({
sub: user.id,
aud: 'host',
exp,
payload: JSON.stringify({
name,
description,
expiredAt,
}),
})
const apiKey = await prisma.apiKey.create({
data: {
name,
description,
key: token,
userId: user.id,
expiredAt: new Date(exp * 1000), // simpan juga di DB biar gampang query
},
})
return { message: 'success', token, apiKey }
},
{
detail: {
summary: 'create api key',
},
body: t.Object({
name: t.String(),
description: t.String(),
expiredAt: t.Optional(t.String({ format: 'date-time' })), // ISO date string
}),
}
)
.get(
'/list',
async ctx => {
const { user }: { user: User } = ctx as any
const apiKeys = await prisma.apiKey.findMany({
where: {
userId: user.id,
},
})
return { message: 'success', apiKeys }
},
{
detail: {
summary: 'get api key list',
},
}
)
.delete(
'/delete',
async ctx => {
const { id } = ctx.body as { id: string }
const apiKey = await prisma.apiKey.delete({
where: {
id,
},
})
return { message: 'success', apiKey }
},
{
detail: {
summary: 'delete api key',
},
body: t.Object({
id: t.String(),
}),
}
)
export default ApiKeyRoute

View File

@@ -0,0 +1,237 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { jwt as jwtPlugin, type JWTPayloadSpec } from '@elysiajs/jwt'
import Elysia, { t, type Cookie, type HTTPHeaders, type StatusMap } from 'elysia'
import { type ElysiaCookie } from 'elysia/cookies'
import { prisma } from '@/server/lib/prisma'
const secret = process.env.JWT_SECRET
if (!secret) {
throw new Error('Missing JWT_SECRET in environment variables')
}
const isProd = process.env.NODE_ENV === 'production'
const NINETY_YEARS = 60 * 60 * 24 * 365 * 90
type JWT = {
sign(data: Record<string, string | number> & JWTPayloadSpec): Promise<string>
verify(
jwt?: string
): Promise<false | (Record<string, string | number> & JWTPayloadSpec)>
}
type COOKIE = Record<string, Cookie<string | undefined>>
type SET = {
headers: HTTPHeaders
status?: number | keyof StatusMap
redirect?: string
cookie?: Record<string, ElysiaCookie>
}
async function issueToken({
jwt,
cookie,
userId,
role,
expiresAt,
}: {
jwt: JWT
cookie: COOKIE
userId: string
role: 'host' | 'user'
expiresAt: number
}) {
const token = await jwt.sign({
sub: userId,
aud: role,
exp: expiresAt,
})
cookie.token?.set({
value: token,
httpOnly: true,
secure: isProd,
sameSite: 'strict',
maxAge: NINETY_YEARS,
path: '/',
})
return token
}
/* -----------------------
REGISTER FUNCTION
-------------------------*/
async function register({
body,
cookie,
set,
jwt,
}: {
body: { name: string; email: string; password: string }
cookie: COOKIE
set: SET
jwt: JWT
}) {
try {
const { name, email, password } = body
// cek existing user
const existing = await prisma.user.findUnique({ where: { email } })
if (existing) {
set.status = 400
return { message: 'Email already registered' }
}
// create user
const user = await prisma.user.create({
data: {
name,
email,
password, // plaintext bisa ditambah hash
},
})
return {
success: true,
message: "User registered successfully"
}
} catch (err) {
console.error('Error registering user:', err)
set.status = 500
return {
message: 'Register failed',
error:
err instanceof Error ? err.message : JSON.stringify(err ?? null),
}
}
}
/* -----------------------
LOGIN FUNCTION
-------------------------*/
async function login({
body,
cookie,
set,
jwt,
}: {
body: { email: string; password: string }
cookie: COOKIE
set: SET
jwt: JWT
}) {
try {
const { email, password } = body
const user = await prisma.user.findUnique({
where: { email },
})
if (!user) {
set.status = 401
return { message: 'User not found' }
}
if (user.password !== password) {
set.status = 401
return { message: 'Invalid password' }
}
const token = await issueToken({
jwt,
cookie,
userId: user.id,
role: 'user',
expiresAt: Math.floor(Date.now() / 1000) + NINETY_YEARS,
})
return { token }
} catch (error) {
console.error('Error logging in:', error)
return {
message: 'Login failed',
error:
error instanceof Error ? error.message : JSON.stringify(error ?? null),
}
}
}
/* -----------------------
AUTH ROUTES
-------------------------*/
const Auth = new Elysia({
prefix: '/auth',
detail: { description: 'Auth API', summary: 'Auth API', tags: ['auth'] },
})
.use(
jwtPlugin({
name: 'jwt',
secret,
})
)
/* REGISTER */
.post(
'/register',
async ({ jwt, body, cookie, set }) => {
return await register({
jwt: jwt as JWT,
body,
cookie: cookie as any,
set: set as any,
})
},
{
body: t.Object({
name: t.String(),
email: t.String(),
password: t.String(),
}),
detail: {
description: 'Register new account',
summary: 'register',
},
}
)
/* LOGIN */
.post(
'/login',
async ({ jwt, body, cookie, set }) => {
return await login({
jwt: jwt as JWT,
body,
cookie: cookie as any,
set: set as any,
})
},
{
body: t.Object({
email: t.String(),
password: t.String(),
}),
detail: {
description: 'Login with email + password',
summary: 'login',
},
}
)
/* LOGOUT */
.delete(
'/logout',
({ cookie }) => {
cookie.token?.remove()
return { message: 'Logout successful' }
},
{
detail: {
description: 'Logout (clear token cookie)',
summary: 'logout',
},
}
)
export default Auth

View File

@@ -0,0 +1,182 @@
import Elysia, { t } from "elysia";
import { tts_chatterbox } from "../lib/tts_chatterbox";
const HOST = "https://office4-chatterbox.wibudev.com";
const ChatterboxTTS = new Elysia({
prefix: '/chatterbox-tts',
detail: {
tags: ['chatterbox-tts']
},
}).get('/list-prompt', async () => {
const res = await fetch(`${HOST}/list-prompt`);
const data = await res.json();
return { data };
}, {
detail: {
summary: 'List voice prompts',
description: 'List voice prompts'
}
}).post(
'/register-prompt-file',
async (c) => {
const file = c.body.file;
const filename = file.name;
const buf = await file.arrayBuffer();
const blob = new Blob([buf], { type: file.type });
const form = new FormData();
form.append("prompt", blob, filename);
const res = await fetch(`${HOST}/register-prompt-file`, {
method: "POST",
body: form
});
const data = await res.json();
return { data };
},
{
body: t.Object({
file: t.File()
}),
detail: {
summary: 'Register voice prompt from file',
tags: ['chatterbox-tts']
}
}
).post(
'/register-prompt-base64',
async (c) => {
const res = await fetch(`${HOST}/register-prompt-base64`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(c.body)
});
return res.json();
},
{
body: t.Object({
prompt_name: t.String(),
base64_audio: t.String()
}),
detail: {
summary: 'Register voice prompt via base64'
}
}
).post(
'/delete-prompt',
async (c) => {
const res = await fetch(`${HOST}/delete-prompt`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(c.body)
});
return res.json();
},
{
body: t.Object({
prompt_name: t.String()
}),
detail: {
summary: 'Delete voice prompt',
description: 'Delete voice prompt',
}
}
).post(
'/rename-prompt',
async (c) => {
const res = await fetch(`${HOST}/rename-prompt`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(c.body)
});
return res.json();
},
{
body: t.Object({
old_name: t.String(),
new_name: t.String()
}),
detail: {
summary: 'Rename voice prompt',
description: 'Rename voice prompt'
}
}
).post(
'/tts-async',
async (c) => {
const { title, text, prompt } = c.body
const data = await tts_chatterbox(text, prompt, title)
return { title, data };
},
{
body: t.Object({
title: t.String(),
text: t.String(),
prompt: t.String()
}),
detail: {
summary: 'Enqueue TTS job',
description: 'Enqueue TTS job'
}
}
).get('/result/:job_id', async (c) => {
const res = await fetch(`${HOST}/result/${c.params.job_id}`);
return res.json();
}, {
detail: {
summary: 'Get result by job ID',
description: 'Get result by job ID'
}
}).get('/list-file', async () => {
const res = await fetch(`${HOST}/list-file`);
const data = await res.json();
return { data };
}, {
detail: {
summary: 'List output files',
description: 'List output files'
}
}).get('/file/:filename', async (c) => {
const res = await fetch(`${HOST}/file/${c.params.filename}`);
const blob = await res.blob();
return new Response(blob, {
headers: {
"Content-Type": "audio/mpeg",
"Content-Disposition": `attachment; filename="${c.params.filename}"`
}
});
}, {
detail: {
summary: 'Download file',
description: 'Download file'
}
}).delete('/rm/:filename', async (c) => {
const res = await fetch(`${HOST}/rm/${c.params.filename}`, {
method: "DELETE"
});
return res.json();
}, {
detail: {
summary: 'Delete output file',
description: 'Delete output file'
}
}).post('/cleanup', async () => {
const res = await fetch(`${HOST}/cleanup`, {
method: "POST"
});
return res.json();
}, {
detail: {
summary: 'Cleanup output folder + jobstore',
description: 'Cleanup output folder + jobstore'
}
});
export default ChatterboxTTS;

View File

@@ -0,0 +1,103 @@
import Elysia, { t } from "elysia";
import { ElysiaWS } from "elysia/ws";
import { tts_tiktok, ensureOutputDir } from "../lib/tts_tiktok";
import fs from "fs";
import path from "path";
import _ from "lodash";
const { OUTPUT_DIR_FINAL } = await ensureOutputDir();
const wsClients = new Set<ElysiaWS>();
const TTSTiktok = new Elysia({
prefix: '/tts-tiktok',
detail: { description: 'TTS TikTok API', summary: 'TTS TikTok API', tags: ['tts-tiktok'] },
}).post('/generate', async ({ body }) => {
const { file_name, text, sessionId } = body
const nameFile = _.snakeCase(file_name)
const audio = await tts_tiktok({ text, sessionId, fileName: nameFile })
return {
success: true,
data: audio
}
}, {
body: t.Object({
file_name: t.String(),
text: t.String(),
sessionId: t.String()
}),
detail: {
description: 'Generate TTS TikTok',
summary: 'Generate TTS TikTok'
}
})
.get("/list-audio", () => {
const files = fs.readdirSync(OUTPUT_DIR_FINAL)
return {
data: files
}
}, {
detail: {
description: 'List all audio files',
summary: 'List all audio files'
}
})
.get("/download/:filename", ({ params }) => {
console.log(params.filename)
const filePath = path.join(OUTPUT_DIR_FINAL, params.filename);
if (!fs.existsSync(filePath)) {
return new Response("Not found", { status: 404 });
}
const file = Bun.file(filePath);
return new Response(file, {
headers: {
"Content-Type": "audio/mpeg",
"Cache-Control": "no-cache",
},
});
})
.delete("/remove/:filename", ({ params }) => {
const { filename } = params
fs.unlinkSync(path.join(OUTPUT_DIR_FINAL, filename))
return {
success: true,
data: filename
}
}, {
params: t.Object({
filename: t.String()
}),
detail: {
description: 'Delete audio file',
summary: 'Delete audio file'
}
})
.delete("/clear", () => {
fs.promises.rmdir(OUTPUT_DIR_FINAL, { recursive: true }).catch(() => { })
return {
success: true,
data: "OK"
}
}, {
detail: {
description: 'Delete all audio files',
summary: 'Delete all audio files'
}
})
.ws("/ws", {
open: (ws) => {
console.log("Client connected");
wsClients.add(ws);
},
close: (ws) => {
console.log("Client disconnected");
wsClients.delete(ws);
},
message: (ws, msg) => {
ws.send("Echo: " + msg);
}
})
export default TTSTiktok

1
temp-tts/jobs.json Normal file
View File

@@ -0,0 +1 @@
["04282526-fe6a-4e0e-a9fd-9c94dcf70662","2e196685-b957-456c-9199-694e25e7b75f","f16b7522-af2d-4ac7-b269-154820eaa6b8","de80f011-5b75-4e09-adac-11e0186831bf"]

36
tsconfig.json Normal file
View File

@@ -0,0 +1,36 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"exclude": ["dist", "node_modules"]
}

8
types/env.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
declare namespace NodeJS {
interface ProcessEnv {
DATABASE_URL?: string;
JWT_SECRET?: string;
BUN_PUBLIC_BASE_URL?: string;
PORT?: string;
}
}

7
x.sh Normal file
View File

@@ -0,0 +1,7 @@
# curl -X POST "http://85.31.224.193:6007/api/predict" \
# -H "Accept: application/json" \
# -F "data=@src.wav" \
# -F "data=@ref.wav" \
# -F "data=0.7"
curl -X POST https://office4-chatterbox.wibudev.com/cleanup

16
xc.sh Normal file
View File

@@ -0,0 +1,16 @@
HOST="http://85.31.224.193:4000"
curl -X POST $HOST/register-prompt-file \
-F "prompt=@dayu.wav"
# TEXT=$(cat << 'EOF'
# Jika kamu menggunakan venv (virtual environment), maka PM2 tetap bisa menjalankan Uvicorn dengan sangat aman dan stabil.
# EOF
# )
# # curl -X POST $HOST/tts-async \
# # -F "text=$TEXT" \
# # -F "prompt=dayu" \
# # -o "hasil.wav"
# curl $HOST

245
xclone.py Normal file
View File

@@ -0,0 +1,245 @@
import io
import os
import sys
import base64
import argparse
import torch
import torchaudio as ta
import torchaudio.functional as F
from chatterbox.tts import ChatterboxTTS
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
# =========================
# KONFIGURASI MODEL - DISESUAIKAN UNTUK NATURALNESS
# =========================
MODEL_REPO = "grandhigh/Chatterbox-TTS-Indonesian"
CHECKPOINT = "t3_cfg.safetensors"
DEVICE = "cpu"
# Parameter dioptimasi untuk suara lebih natural dan mirip source
TEMPERATURE = 0.65
TOP_P = 0.88
REPETITION_PENALTY = 1.25
AUDIO_GAIN_DB = 0.8
PROMPT_FOLDER = "prompt_source"
os.makedirs(PROMPT_FOLDER, exist_ok=True)
# =========================
# Enhance audio dengan fokus pada naturalness
# =========================
def enhance_audio(wav, sr):
peak = wav.abs().max()
if peak > 0:
wav = wav / (peak + 1e-8) * 0.95
wav = F.highpass_biquad(wav, sr, cutoff_freq=60)
wav = F.lowpass_biquad(wav, sr, cutoff_freq=10000)
wav = F.bass_biquad(wav, sr, gain=1.5, central_freq=200, Q=0.7)
wav = F.treble_biquad(wav, sr, gain=-1.2, central_freq=6000, Q=0.7)
threshold = 0.6
ratio = 2.5
knee = 0.1
abs_wav = wav.abs()
mask_hard = abs_wav > (threshold + knee)
mask_knee = (abs_wav > (threshold - knee)) & (abs_wav <= (threshold + knee))
compressed = torch.where(
mask_hard,
torch.sign(wav) * (threshold + (abs_wav - threshold) / ratio),
wav
)
knee_factor = ((abs_wav - (threshold - knee)) / (2 * knee)) ** 2
knee_compressed = torch.sign(wav) * (
threshold - knee + knee_factor * (2 * knee) + (abs_wav - threshold) / ratio * knee_factor
)
compressed = torch.where(mask_knee, knee_compressed, compressed)
wav = compressed
saturation_amount = 0.08
wav = torch.tanh(wav * (1 + saturation_amount)) / (1 + saturation_amount)
pink_noise = generate_pink_noise(wav.shape, wav.device) * 0.0003
wav = wav + pink_noise
wav = torch.tanh(wav * 1.1) * 0.92
wav = F.gain(wav, gain_db=AUDIO_GAIN_DB)
peak = wav.abs().max().item()
if peak > 0:
wav = wav / peak * 0.88
return wav
def generate_pink_noise(shape, device):
white = torch.randn(shape, device=device)
if len(shape) > 1:
pink = torch.zeros_like(white)
for i in range(shape[0]):
b = torch.zeros(7)
for j in range(shape[1]):
white_val = white[i, j].item()
b[0] = 0.99886 * b[0] + white_val * 0.0555179
b[1] = 0.99332 * b[1] + white_val * 0.0750759
b[2] = 0.96900 * b[2] + white_val * 0.1538520
b[3] = 0.86650 * b[3] + white_val * 0.3104856
b[4] = 0.55000 * b[4] + white_val * 0.5329522
b[5] = -0.7616 * b[5] - white_val * 0.0168980
pink[i, j] = (b[0] + b[1] + b[2] + b[3] + b[4] + b[5] + b[6] + white_val * 0.5362) * 0.11
b[6] = white_val * 0.115926
else:
pink = torch.zeros_like(white)
b = torch.zeros(7)
for j in range(shape[0]):
white_val = white[j].item()
b[0] = 0.99886 * b[0] + white_val * 0.0555179
b[1] = 0.99332 * b[1] + white_val * 0.0750759
b[2] = 0.96900 * b[2] + white_val * 0.1538520
b[3] = 0.86650 * b[3] + white_val * 0.3104856
b[4] = 0.55000 * b[4] + white_val * 0.5329522
b[5] = -0.7616 * b[5] - white_val * 0.0168980
pink[j] = (b[0] + b[1] + b[2] + b[3] + b[4] + b[5] + b[6] + white_val * 0.5362) * 0.11
b[6] = white_val * 0.115926
return pink * 0.1
# Load model sekali
print("Loading model...")
model = ChatterboxTTS.from_pretrained(device=DEVICE)
ckpt = hf_hub_download(repo_id=MODEL_REPO, filename=CHECKPOINT)
state = load_file(ckpt, device=DEVICE)
model.t3.to(DEVICE).load_state_dict(state)
model.t3.eval()
for module in model.t3.modules():
if hasattr(module, "training"):
module.training = False
for module in model.t3.modules():
if isinstance(module, torch.nn.Dropout):
module.p = 0
print("Model ready with enhanced settings.")
# ======= Fungsi CLI =======
def register_prompt_base64(prompt_name, base64_audio):
filename = f"{prompt_name}.wav"
path = os.path.join(PROMPT_FOLDER, filename)
raw = base64.b64decode(base64_audio)
with open(path, "wb") as f:
f.write(raw)
print(f"Registered base64 prompt as {filename}")
def register_prompt_file(src_path, prompt_name=None):
if prompt_name is None:
prompt_name = os.path.splitext(os.path.basename(src_path))[0]
save_path = os.path.join(PROMPT_FOLDER, f"{prompt_name}.wav")
with open(src_path, "rb") as src_file:
data = src_file.read()
with open(save_path, "wb") as dst_file:
dst_file.write(data)
print(f"Registered prompt file as {prompt_name}.wav")
def list_prompt():
files = os.listdir(PROMPT_FOLDER)
wav_files = [f for f in files if f.lower().endswith(".wav")]
print(f"Total prompts: {len(wav_files)}")
for f in wav_files:
print(f"- {f}")
def delete_prompt(prompt_name):
file_path = os.path.join(PROMPT_FOLDER, f"{prompt_name}.wav")
if not os.path.exists(file_path):
print(f"Prompt '{prompt_name}' not found.", file=sys.stderr)
sys.exit(1)
os.remove(file_path)
print(f"Deleted prompt {prompt_name}.wav")
def rename_prompt(old_name, new_name):
old_path = os.path.join(PROMPT_FOLDER, f"{old_name}.wav")
new_path = os.path.join(PROMPT_FOLDER, f"{new_name}.wav")
if not os.path.exists(old_path):
print(f"Old prompt '{old_name}' not found.", file=sys.stderr)
sys.exit(1)
if os.path.exists(new_path):
print(f"New prompt name '{new_name}' already exists.", file=sys.stderr)
sys.exit(1)
os.rename(old_path, new_path)
print(f"Renamed prompt '{old_name}' to '{new_name}'")
def tts(text, prompt, output_path="output.wav", temperature=TEMPERATURE, top_p=TOP_P, repetition_penalty=REPETITION_PENALTY):
prompt_path = os.path.join(PROMPT_FOLDER, f"{prompt}.wav")
if not os.path.exists(prompt_path):
print(f"Prompt '{prompt}' not found.", file=sys.stderr)
sys.exit(1)
try:
wav = model.generate(
text,
audio_prompt_path=prompt_path,
temperature=temperature,
top_p=top_p,
repetition_penalty=repetition_penalty,
)
except Exception as e:
print(f"Failed to generate audio: {e}", file=sys.stderr)
sys.exit(1)
wav = enhance_audio(wav.cpu(), model.sr)
ta.save(output_path, wav, model.sr, format="wav")
print(f"TTS output saved to {output_path}")
def main():
parser = argparse.ArgumentParser(description="Chatterbox TTS CLI")
subparsers = parser.add_subparsers(dest="command")
# register base64
p_register_base64 = subparsers.add_parser("register-base64", help="Register prompt from base64 string")
p_register_base64.add_argument("prompt_name")
p_register_base64.add_argument("base64_audio")
# register file
p_register_file = subparsers.add_parser("register-file", help="Register prompt from wav file")
p_register_file.add_argument("src_path")
p_register_file.add_argument("--name", default=None)
# list prompt
p_list = subparsers.add_parser("list", help="List all prompt wav files")
# delete prompt
p_delete = subparsers.add_parser("delete", help="Delete prompt wav file")
p_delete.add_argument("prompt_name")
# rename prompt
p_rename = subparsers.add_parser("rename", help="Rename prompt wav file")
p_rename.add_argument("old_name")
p_rename.add_argument("new_name")
# tts generate
p_tts = subparsers.add_parser("tts", help="Generate TTS wav file")
p_tts.add_argument("text")
p_tts.add_argument("prompt")
p_tts.add_argument("--output", default="output.wav")
p_tts.add_argument("--temperature", type=float, default=TEMPERATURE)
p_tts.add_argument("--top_p", type=float, default=TOP_P)
p_tts.add_argument("--repetition_penalty", type=float, default=REPETITION_PENALTY)
args = parser.parse_args()
if args.command == "register-base64":
register_prompt_base64(args.prompt_name, args.base64_audio)
elif args.command == "register-file":
register_prompt_file(args.src_path, args.name)
elif args.command == "list":
list_prompt()
elif args.command == "delete":
delete_prompt(args.prompt_name)
elif args.command == "rename":
rename_prompt(args.old_name, args.new_name)
elif args.command == "tts":
tts(args.text, args.prompt, args.output, args.temperature, args.top_p, args.repetition_penalty)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()

267
xclonev2.py Normal file
View File

@@ -0,0 +1,267 @@
import io
import os
import base64
from fastapi import FastAPI, UploadFile, File, Form
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
import torch
import torchaudio as ta
import torchaudio.functional as F
from pydub import AudioSegment
from chatterbox.tts import ChatterboxTTS
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
import asyncio
import uuid
# =========================
# CONFIG
# =========================
MODEL_REPO = "grandhigh/Chatterbox-TTS-Indonesian"
CHECKPOINT = "t3_cfg.safetensors"
DEVICE = "cpu"
TEMPERATURE = 0.85
TOP_P = 0.92
REPETITION_PENALTY = 1.15
AUDIO_GAIN_DB = 1.5
PROMPT_FOLDER = "prompt_source"
TEMP_FOLDER = "temp_parts"
os.makedirs(PROMPT_FOLDER, exist_ok=True)
os.makedirs(TEMP_FOLDER, exist_ok=True)
# Executor parallel max 5
semaphore = asyncio.Semaphore(5)
app = FastAPI(title="Chatterbox TTS Server")
# =========================
# TEXT SPLITTER
# =========================
def split_text(text: str, max_len=100):
parts = []
text = text.strip()
while len(text) > max_len:
cut_index = text.rfind(" ", 0, max_len)
if cut_index == -1:
cut_index = max_len
parts.append(text[:cut_index].strip())
text = text[cut_index:].strip()
if text:
parts.append(text)
return parts
# =========================
# Enhance audio
# =========================
def enhance_audio(wav, sr):
wav = wav / (wav.abs().max() + 1e-8)
noise_level = 0.0008
wav = wav + torch.randn_like(wav) * noise_level
threshold = 0.7
ratio = 3.0
mask = wav.abs() > threshold
wav = torch.where(
mask, torch.sign(wav) * (threshold + (wav.abs() - threshold) / ratio), wav
)
wav = F.highpass_biquad(wav, sr, cutoff_freq=80)
wav = F.lowpass_biquad(wav, sr, cutoff_freq=8000)
wav = F.gain(wav, gain_db=AUDIO_GAIN_DB)
peak = wav.abs().max().item()
if peak > 0:
wav = wav / peak * 0.93
return wav
# =========================
# LOAD MODEL
# =========================
print("Loading model...")
model = ChatterboxTTS.from_pretrained(device=DEVICE)
ckpt = hf_hub_download(repo_id=MODEL_REPO, filename=CHECKPOINT)
state = load_file(ckpt, device=DEVICE)
model.t3.to(DEVICE).load_state_dict(state)
model.t3.eval()
for m in model.t3.modules():
if hasattr(m, "training"):
m.training = False
print("Model ready.")
# =========================
# Helper generate per-part
# =========================
async def generate_part(text, prompt_path, part_id):
async with semaphore:
loop = asyncio.get_running_loop()
wav = await loop.run_in_executor(
None,
lambda: model.generate(
text,
audio_prompt_path=prompt_path,
temperature=TEMPERATURE,
top_p=TOP_P,
repetition_penalty=REPETITION_PENALTY,
),
)
wav = enhance_audio(wav.cpu(), model.sr)
temp_path = os.path.join(TEMP_FOLDER, f"{part_id}.wav")
ta.save(temp_path, wav, model.sr)
return temp_path
# =========================
# Merge WAV
# =========================
def merge_wav(files):
combined = AudioSegment.empty()
for f in files:
combined += AudioSegment.from_wav(f)
return combined
# =====================================================
# 2. TTS MULTI-PART + QUEUE
# =====================================================
@app.post("/tts")
async def tts(text: str = Form(...), prompt: str = Form(...)):
prompt_path = os.path.join(PROMPT_FOLDER, f"{prompt}.wav")
if not os.path.exists(prompt_path):
return JSONResponse(status_code=404, content={"error": "Prompt tidak ditemukan"})
# split text
parts = split_text(text)
# generate unique prefix for temp file parts
uid = str(uuid.uuid4())
tasks = []
part_files = []
# create async tasks
for i, segment in enumerate(parts):
part_id = f"{uid}_{i}"
tasks.append(generate_part(segment, prompt_path, part_id))
# run queue (max 5 at once)
part_files = await asyncio.gather(*tasks)
# merge result
final_audio = merge_wav(part_files)
# convert to buffer
buf = io.BytesIO()
final_audio.export(buf, format="wav")
buf.seek(0)
# cleanup
for f in part_files:
if os.path.exists(f):
os.remove(f)
return StreamingResponse(
buf,
media_type="audio/wav",
headers={"Content-Disposition": "attachment; filename=final_output.wav"},
)
# =====================================================
# Prompt Management (tidak diubah)
# =====================================================
class RegisterPromptBase64(BaseModel):
prompt_name: str
base64_audio: str
@app.post("/register-prompt-base64")
async def register_prompt_base64(data: RegisterPromptBase64):
filename = f"{data.prompt_name}.wav"
path = os.path.join(PROMPT_FOLDER, filename)
try:
raw = base64.b64decode(data.base64_audio)
with open(path, "wb") as f:
f.write(raw)
return {"status": "ok", "file": filename}
except Exception as e:
return JSONResponse(status_code=400, content={"error": str(e)})
@app.post("/register-prompt-file")
async def register_prompt_file(prompt: UploadFile = File(...), name: str = Form(None)):
prompt_name = name if name else os.path.splitext(prompt.filename)[0]
save_path = os.path.join(PROMPT_FOLDER, f"{prompt_name}.wav")
try:
with open(save_path, "wb") as f:
f.write(await prompt.read())
return {"status": "ok", "file": f"{prompt_name}.wav"}
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
@app.get("/list-prompt")
async def list_prompt():
try:
files = os.listdir(PROMPT_FOLDER)
wav_files = [f for f in files if f.lower().endswith(".wav")]
return {
"count": len(wav_files),
"prompts": wav_files,
"prompt_names": [os.path.splitext(f)[0] for f in wav_files],
}
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
class DeletePrompt(BaseModel):
prompt_name: str
@app.post("/delete-prompt")
async def delete_prompt(data: DeletePrompt):
file_path = os.path.join(PROMPT_FOLDER, f"{data.prompt_name}.wav")
if not os.path.exists(file_path):
return JSONResponse(status_code=404, content={"error": "Prompt tidak ditemukan"})
os.remove(file_path)
return {"status": "ok"}
class RenamePrompt(BaseModel):
old_name: str
new_name: str
@app.post("/rename-prompt")
async def rename_prompt(data: RenamePrompt):
old_path = os.path.join(PROMPT_FOLDER, f"{data.old_name}.wav")
new_path = os.path.join(PROMPT_FOLDER, f"{data.new_name}.wav")
if not os.path.exists(old_path):
return JSONResponse(status_code=404, content={"error": "Prompt lama tidak ditemukan"})
if os.path.exists(new_path):
return JSONResponse(status_code=400, content={"error": "Nama baru sudah digunakan"})
os.rename(old_path, new_path)
return {"status": "ok"}
@app.get("/")
async def root():
return {"message": "Chatterbox TTS API ready with queue + multi-part!"}
if __name__ == "__main__":
import uvicorn
uvicorn.run("claude_clonev4:app", host="0.0.0.0", port=6007, reload=False)

427
xclonev3.py Normal file
View File

@@ -0,0 +1,427 @@
import io
import os
import base64
from fastapi import FastAPI, UploadFile, File, Form
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
import torch
import torchaudio as ta
import torchaudio.functional as F
from chatterbox.tts import ChatterboxTTS
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
# =========================
# KONFIGURASI MODEL - DISESUAIKAN UNTUK NATURALNESS
# =========================
MODEL_REPO = "grandhigh/Chatterbox-TTS-Indonesian"
CHECKPOINT = "t3_cfg.safetensors"
DEVICE = "cpu"
# Parameter dioptimasi untuk suara lebih natural dan mirip source
TEMPERATURE = 0.65 # Lebih rendah untuk lebih konsisten dengan prompt
TOP_P = 0.88 # Lebih fokus pada prediksi berkualitas tinggi
REPETITION_PENALTY = 1.25 # Lebih tinggi untuk menghindari pola repetitif robot
AUDIO_GAIN_DB = 0.8 # Gain lebih rendah untuk suara natural
# Parameter tambahan untuk kontrol kualitas
TOP_K = 50 # Batasi kandidat token
MIN_P = 0.05 # Filter probabilitas rendah
CFG_SCALE = 1.2 # Classifier-free guidance untuk adherence ke prompt
PROMPT_FOLDER = "prompt_source"
os.makedirs(PROMPT_FOLDER, exist_ok=True)
app = FastAPI(title="Chatterbox TTS Server - Enhanced")
# =========================
# Enhance audio dengan fokus pada naturalness
# =========================
def enhance_audio(wav, sr):
"""
Enhanced audio processing untuk suara lebih natural dan mirip source
"""
# 1. Normalisasi awal yang lembut
peak = wav.abs().max()
if peak > 0:
wav = wav / (peak + 1e-8) * 0.95
# 2. De-essing ringan (kurangi sibilance yang khas robot)
wav = F.highpass_biquad(wav, sr, cutoff_freq=60)
wav = F.lowpass_biquad(wav, sr, cutoff_freq=10000)
# 3. Tambahkan sedikit warmth dengan subtle low-shelf boost
# Simulasi resonansi natural voice
wav = F.bass_biquad(wav, sr, gain=1.5, central_freq=200, Q=0.7)
# 4. De-harsh treble (kurangi ketajaman digital)
wav = F.treble_biquad(wav, sr, gain=-1.2, central_freq=6000, Q=0.7)
# 5. Soft compression untuk dynamic range natural
# Kompresi multi-band untuk maintain naturalness
threshold = 0.6
ratio = 2.5
knee = 0.1
abs_wav = wav.abs()
mask_hard = abs_wav > (threshold + knee)
mask_knee = (abs_wav > (threshold - knee)) & (abs_wav <= (threshold + knee))
# Hard compression
compressed = torch.where(
mask_hard,
torch.sign(wav) * (threshold + (abs_wav - threshold) / ratio),
wav
)
# Soft knee
knee_factor = ((abs_wav - (threshold - knee)) / (2 * knee)) ** 2
knee_compressed = torch.sign(wav) * (
threshold - knee + knee_factor * (2 * knee) +
(abs_wav - threshold) / ratio * knee_factor
)
compressed = torch.where(mask_knee, knee_compressed, compressed)
wav = compressed
# 6. Subtle saturation untuk warmth (analog-like)
saturation_amount = 0.08
wav = torch.tanh(wav * (1 + saturation_amount)) / (1 + saturation_amount)
# 7. Tambahkan very subtle analog-style noise (bukan digital noise)
# Ini membantu mask artifacts digital dan menambah warmth
pink_noise = generate_pink_noise(wav.shape, wav.device) * 0.0003
wav = wav + pink_noise
# 8. Gentle limiting untuk prevent clipping tanpa harshness
wav = torch.tanh(wav * 1.1) * 0.92
# 9. Final gain adjustment
wav = F.gain(wav, gain_db=AUDIO_GAIN_DB)
# 10. Final normalization dengan headroom
peak = wav.abs().max().item()
if peak > 0:
wav = wav / peak * 0.88 # Lebih banyak headroom untuk suara natural
return wav
def generate_pink_noise(shape, device):
"""
Generate pink noise (1/f noise) untuk natural analog warmth
"""
white = torch.randn(shape, device=device)
# Simple pink noise approximation menggunakan running sum
# Pink noise memiliki karakteristik lebih natural daripada white noise
if len(shape) > 1:
# Handle stereo/multi-channel
pink = torch.zeros_like(white)
for i in range(shape[0]):
b = torch.zeros(7)
for j in range(shape[1]):
white_val = white[i, j].item()
b[0] = 0.99886 * b[0] + white_val * 0.0555179
b[1] = 0.99332 * b[1] + white_val * 0.0750759
b[2] = 0.96900 * b[2] + white_val * 0.1538520
b[3] = 0.86650 * b[3] + white_val * 0.3104856
b[4] = 0.55000 * b[4] + white_val * 0.5329522
b[5] = -0.7616 * b[5] - white_val * 0.0168980
pink[i, j] = (b[0] + b[1] + b[2] + b[3] + b[4] + b[5] + b[6] + white_val * 0.5362) * 0.11
b[6] = white_val * 0.115926
else:
# Mono
pink = torch.zeros_like(white)
b = torch.zeros(7)
for j in range(shape[0]):
white_val = white[j].item()
b[0] = 0.99886 * b[0] + white_val * 0.0555179
b[1] = 0.99332 * b[1] + white_val * 0.0750759
b[2] = 0.96900 * b[2] + white_val * 0.1538520
b[3] = 0.86650 * b[3] + white_val * 0.3104856
b[4] = 0.55000 * b[4] + white_val * 0.5329522
b[5] = -0.7616 * b[5] - white_val * 0.0168980
pink[j] = (b[0] + b[1] + b[2] + b[3] + b[4] + b[5] + b[6] + white_val * 0.5362) * 0.11
b[6] = white_val * 0.115926
return pink * 0.1 # Scale down
# =========================
# Load model sekali
# =========================
print("Loading model...")
model = ChatterboxTTS.from_pretrained(device=DEVICE)
ckpt = hf_hub_download(repo_id=MODEL_REPO, filename=CHECKPOINT)
state = load_file(ckpt, device=DEVICE)
model.t3.to(DEVICE).load_state_dict(state)
model.t3.eval()
# Pastikan model dalam mode eval penuh
for module in model.t3.modules():
if hasattr(module, "training"):
module.training = False
# Disable dropout untuk konsistensi maksimal
for module in model.t3.modules():
if isinstance(module, torch.nn.Dropout):
module.p = 0
print("Model ready with enhanced settings.")
# =====================================================
# 1A. REGISTER BASE64 -> WAV
# =====================================================
class RegisterPromptBase64(BaseModel):
prompt_name: str
base64_audio: str
@app.post("/register-prompt-base64")
async def register_prompt_base64(data: RegisterPromptBase64):
filename = f"{data.prompt_name}.wav"
path = os.path.join(PROMPT_FOLDER, filename)
try:
raw = base64.b64decode(data.base64_audio)
with open(path, "wb") as f:
f.write(raw)
return {"status": "ok", "file": filename}
except Exception as e:
return JSONResponse(
status_code=400, content={"error": f"Gagal decode / simpan audio: {e}"}
)
# =====================================================
# 1B. REGISTER FILE UPLOAD (form-data)
# =====================================================
@app.post("/register-prompt-file")
async def register_prompt_file(prompt: UploadFile = File(...), name: str = Form(None)):
prompt_name = name if name else os.path.splitext(prompt.filename)[0]
save_path = os.path.join(PROMPT_FOLDER, f"{prompt_name}.wav")
try:
with open(save_path, "wb") as f:
f.write(await prompt.read())
return {"status": "ok", "file": f"{prompt_name}.wav"}
except Exception as e:
return JSONResponse(
status_code=500, content={"error": f"Gagal menyimpan prompt: {e}"}
)
# =====================================================
# LIST PROMPT
# =====================================================
@app.get("/list-prompt")
async def list_prompt():
try:
files = os.listdir(PROMPT_FOLDER)
wav_files = [f for f in files if f.lower().endswith(".wav")]
return {
"count": len(wav_files),
"prompts": wav_files,
"prompt_names": [os.path.splitext(f)[0] for f in wav_files],
}
except Exception as e:
return JSONResponse(
status_code=500, content={"error": f"Gagal membaca folder prompt: {e}"}
)
# =====================================================
# DELETE PROMPT
# =====================================================
class DeletePrompt(BaseModel):
prompt_name: str
@app.post("/delete-prompt")
async def delete_prompt(data: DeletePrompt):
file_path = os.path.join(PROMPT_FOLDER, f"{data.prompt_name}.wav")
if not os.path.exists(file_path):
return JSONResponse(
status_code=404, content={"error": "Prompt tidak ditemukan"}
)
try:
os.remove(file_path)
return {"status": "ok", "deleted": f"{data.prompt_name}.wav"}
except Exception as e:
return JSONResponse(status_code=500, content={"error": f"Gagal menghapus: {e}"})
# =====================================================
# RENAME PROMPT
# =====================================================
class RenamePrompt(BaseModel):
old_name: str
new_name: str
@app.post("/rename-prompt")
async def rename_prompt(data: RenamePrompt):
old_path = os.path.join(PROMPT_FOLDER, f"{data.old_name}.wav")
new_path = os.path.join(PROMPT_FOLDER, f"{data.new_name}.wav")
if not os.path.exists(old_path):
return JSONResponse(
status_code=404, content={"error": "Prompt lama tidak ditemukan"}
)
if os.path.exists(new_path):
return JSONResponse(
status_code=400, content={"error": "Nama baru sudah digunakan"}
)
try:
os.rename(old_path, new_path)
return {"status": "ok", "from": data.old_name, "to": data.new_name}
except Exception as e:
return JSONResponse(status_code=500, content={"error": f"Gagal rename: {e}"})
# =====================================================
# 2. TTS - ENHANCED dengan parameter optimal
# =====================================================
@app.post("/tts")
async def tts(text: str = Form(...), prompt: str = Form(...)):
prompt_path = os.path.join(PROMPT_FOLDER, f"{prompt}.wav")
if not os.path.exists(prompt_path):
return JSONResponse(
status_code=404, content={"error": "Prompt tidak ditemukan"}
)
# Generate dengan parameter optimal untuk naturalness
try:
wav = model.generate(
text,
audio_prompt_path=prompt_path,
temperature=TEMPERATURE, # Lebih rendah = lebih konsisten dengan prompt
top_p=TOP_P, # Sampling lebih fokus
repetition_penalty=REPETITION_PENALTY, # Hindari pola robot
)
except Exception as e:
return JSONResponse(
status_code=500, content={"error": f"Gagal generate audio: {e}"}
)
# Enhanced audio processing
wav = enhance_audio(wav.cpu(), model.sr)
# Simpan ke buffer memori
buffer = io.BytesIO()
ta.save(buffer, wav, model.sr, format="wav")
buffer.seek(0)
return StreamingResponse(
buffer,
media_type="audio/wav",
headers={"Content-Disposition": "attachment; filename=output.wav"}
)
# =====================================================
# 3. TTS dengan parameter custom (advanced)
# =====================================================
class TTSCustomParams(BaseModel):
text: str
prompt: str
temperature: float = TEMPERATURE
top_p: float = TOP_P
repetition_penalty: float = REPETITION_PENALTY
@app.post("/tts-custom")
async def tts_custom(params: TTSCustomParams):
"""
Endpoint untuk experimentation dengan parameter berbeda
"""
prompt_path = os.path.join(PROMPT_FOLDER, f"{params.prompt}.wav")
if not os.path.exists(prompt_path):
return JSONResponse(
status_code=404, content={"error": "Prompt tidak ditemukan"}
)
try:
wav = model.generate(
params.text,
audio_prompt_path=prompt_path,
temperature=params.temperature,
top_p=params.top_p,
repetition_penalty=params.repetition_penalty,
)
except Exception as e:
return JSONResponse(
status_code=500, content={"error": f"Gagal generate audio: {e}"}
)
wav = enhance_audio(wav.cpu(), model.sr)
buffer = io.BytesIO()
ta.save(buffer, wav, model.sr, format="wav")
buffer.seek(0)
return StreamingResponse(
buffer,
media_type="audio/wav",
headers={"Content-Disposition": "attachment; filename=output_custom.wav"}
)
# =========================
# Root
# =========================
@app.get("/")
async def root():
return {
"message": "Chatterbox TTS API - Enhanced Voice Quality",
"version": "2.0",
"improvements": [
"Optimized temperature & sampling for source similarity",
"Enhanced audio processing for natural voice",
"Reduced robotic artifacts",
"Better emotion & intonation preservation",
"Analog-style warmth processing"
]
}
# =========================
# Health check
# =========================
@app.get("/health")
async def health():
return {
"status": "healthy",
"model_loaded": True,
"device": DEVICE,
"settings": {
"temperature": TEMPERATURE,
"top_p": TOP_P,
"repetition_penalty": REPETITION_PENALTY,
}
}
if __name__ == "__main__":
import uvicorn
uvicorn.run("claude_clonev4:app", host="0.0.0.0", port=6007, reload=False)

101
xdownload.ts Normal file
View File

@@ -0,0 +1,101 @@
import fs from "fs";
import randomstring from "randomstring";
const TEMP_DIR = "./temp-tts";
const FAILED_LOG = TEMP_DIR + "/failed.log";
const sub_name = randomstring.generate({ length: 5, charset: "alphanumeric" });
const HOST = "http://85.31.224.193:4000";
// tidur
const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
// cek apakah file sudah ada
function fileExists(path: string) {
return fs.existsSync(path) && fs.statSync(path).size > 44;
}
// tulis log gagal
function logFail(msg: string) {
fs.appendFileSync(FAILED_LOG, msg + "\n");
}
async function downloadWithRetry(jobId: string, outFile: string) {
const MAX_RETRY = 5;
// jika sebelumnya sudah ada → skip
if (fileExists(outFile)) {
console.log(` 🔁 Resume: file sudah ada → ${outFile}`);
return true;
}
for (let attempt = 1; attempt <= MAX_RETRY; attempt++) {
try {
console.log(` ⏳ Download ${jobId} (try ${attempt}/${MAX_RETRY})...`);
const res = await fetch(`${HOST}/file/${jobId}`);
if (!res.ok) {
throw new Error(`Status ${res.status}`);
}
const buf = Buffer.from(await res.arrayBuffer());
// pastikan ukurannya wajar
if (buf.length < 44) {
throw new Error("File terlalu kecil (korup?)");
}
fs.writeFileSync(outFile, buf);
console.log(` ✓ Success → ${outFile}`);
return true;
} catch (err: any) {
console.error(` ❌ Error: ${err.message}`);
if (attempt < MAX_RETRY) {
console.log(" 🔄 Retry dalam 2s...");
await sleep(2000);
}
}
}
console.error(` ❌ Gagal total untuk ${jobId}`);
logFail(jobId);
return false;
}
async function main() {
const jobsPath = TEMP_DIR + "/jobs.json";
if (!fs.existsSync(jobsPath)) {
console.error("❌ jobs.json not found");
process.exit(1);
}
const jobs = JSON.parse(fs.readFileSync(jobsPath, "utf-8"));
console.log(`📦 Total jobs: ${jobs.length}`);
const resultFiles: string[] = [];
for (let i = 0; i < jobs.length; i++) {
const jobId = jobs[i];
const outFile = `${TEMP_DIR}/${jobId}_${sub_name}_${(i + 1)
.toString()
.padStart(4, "0")}.wav`;
console.log(`\n▶ Job ${i + 1}/${jobs.length}${jobId}`);
const success = await downloadWithRetry(jobId, outFile);
if (success) {
resultFiles.push(outFile);
}
}
console.log("\n🎉 DONE!");
console.log("Result files:", resultFiles);
return resultFiles;
}
if (import.meta.main) {
main().then((files) => {
console.log("\n📁 Final:", files.length, "file tersimpan.");
});
}

209
xfetch.ts Normal file
View File

@@ -0,0 +1,209 @@
import fs from "fs";
const HOST = "http://85.31.224.193:4000/";
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 convertDateToText(text: string): string {
const months = [
"Januari", "Februari", "Maret", "April", "Mei", "Juni",
"Juli", "Agustus", "September", "Oktober", "November", "Desember"
];
return text.replace(/\(?(\d{1,2})\/(\d{1,2})\/(\d{4})\)?/g, (_, dd, mm, yyyy) => {
const monthIndex = parseInt(mm, 10) - 1;
if (monthIndex < 0 || monthIndex > 11) return _;
return `${parseInt(dd, 10)} ${months[monthIndex]} ${yyyy}`;
});
}
function cleanText(text: string): string {
// Ubah tanggal dulu biar lebih mudah dibaca TTS
text = convertDateToText(text);
return text
// izinkan: huruf, angka, spasi, dan . , ! ? ;
.replace(/[^\p{L}\p{N} .,!?;]/gu, "")
// normalkan banyak spasi menjadi 1 spasi
.replace(/\s+/g, " ")
// trim spasi kiri/kanan
.trim();
}
/* ============================================================
SPLIT TEXT (MAKSIMAL 200 CHAR + CARI TITIK/KOMA/!?)
============================================================ */
function splitText(text: string, max = 200): string[] {
const chunks: string[] = [];
text = text.trim();
const isDecimal = (str: string, idx: number) => {
// kasus angka.desimal → contoh: 1000.25 atau 1,234.56
const before = str[idx - 1];
const after = str[idx + 1];
return /\d/.test(before || '') && /\d/.test(after || '');
};
const isThousandsSeparator = (str: string, idx: number) => {
// angka ribuan 1.234 atau 2,500 dll
const before = str[idx - 1];
const after = str[idx + 1];
return /\d/.test(before || '') && /\d/.test(after || '');
};
while (text.length > 0) {
if (text.length <= max) {
chunks.push(text);
break;
}
const slice = text.slice(0, max);
let cutIndex = -1;
// Cari tanda baca yang aman untuk split
for (let i = slice.length - 1; i >= 0; i--) {
const ch = slice[i];
if (".,!?;".includes(ch || '')) {
// Abaikan jika itu bagian dari angka
if (isDecimal(slice, i)) continue;
if (isThousandsSeparator(slice, i)) continue;
cutIndex = i + 1;
break;
}
}
// Jika tidak ada tanda baca yang valid
if (cutIndex === -1) cutIndex = max;
const part = text.slice(0, cutIndex).trim();
if (part) chunks.push(part);
text = text.slice(cutIndex).trim();
}
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;
}
/* ============================================================
MAIN
============================================================ */
(async () => {
console.log("🎙️ TTS Async Client Starting...\n");
let text = `
Kalau soal **minimal durasi contoh suara untuk clone suara (voice cloning)** di model seperti Chatterbox TTS atau umumnya model voice cloning, biasanya durasi minimalnya tergantung dari kualitas dan metode cloning-nya.
Berikut gambaran umumnya:
* **Minimal durasi ideal untuk voice cloning berkualitas cukup baik:**
**sekitar 30 detik 1 menit** rekaman suara bersih, jernih, tanpa noise, dan dengan intonasi alami.
* **Jika kurang dari 30 detik:**
Model bisa saja clone, tapi hasilnya biasanya kurang natural dan suaranya bisa terdengar “robotik” atau kurang variatif.
* **Durasi lebih dari 1 menit:**
Biasanya semakin bagus hasil cloning karena model punya banyak data untuk belajar karakter suara, intonasi, ekspresi, dan variasi.
---
### Catatan:
* Kualitas rekaman sangat penting: noise rendah, microphone bagus, format lossless (WAV) lebih disarankan.
* Banyak model TTS/voice cloning modern bisa pakai data pendek, tapi kualitas output terbatas.
* Kalau kamu pakai Chatterbox TTS khusus, coba cek dokumentasi mereka apakah ada rekomendasi durasi input.
---
Kalau kamu butuh, aku bisa bantu cek dokumentasi spesifik Chatterbox TTS atau rekomendasi untuk voice cloning dari model lain juga. Mau?
`;
text = cleanText(text);
const parts = splitText(text, 360);
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`);
fs.writeFileSync(`${TEMP_DIR}/jobs.json`, JSON.stringify(jobs));
})();

416
xfetch.txt Normal file
View File

@@ -0,0 +1,416 @@
/**
* 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();
})();

106
xmerge.ts Normal file
View File

@@ -0,0 +1,106 @@
import * as fs from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
// --- Konfigurasi ---
const TEMP_DIR = "./temp-tts";
const LIST_FILE_NAME = "wav-list.txt";
const exec = promisify(execFile);
/**
* Menggabungkan daftar file WAV menggunakan ffmpeg concat demuxer.
* @param relativeFileNames Array NAMA file WAV yang berada di TEMP_DIR (misal: ['a.wav', 'b.wav']).
* @param output Path lengkap untuk menyimpan file WAV hasil gabungan.
*/
async function merge(relativeFileNames: string[], output: string): Promise<void> {
if (relativeFileNames.length === 0) {
console.warn("⚠️ Tidak ada file untuk digabungkan. Melewati proses.");
return;
}
console.log(`\n🔧 Menggabungkan ${relativeFileNames.length} file WAV menggunakan ffmpeg...`);
const listFilePath = `${TEMP_DIR}/${LIST_FILE_NAME}`;
// Perbaikan: Tulis nama file saja tanpa prefix TEMP_DIR agar path valid saat concat
const listContent = relativeFileNames
.map((f) => `file '${f.replace(/'/g, "'\\''")}'`)
.join("\n");
try {
await fs.writeFile(listFilePath, listContent, "utf-8");
} catch (err) {
console.error(`❌ Gagal menulis file list ${listFilePath}:`, err);
throw new Error(`Gagal menyiapkan file list untuk ffmpeg.`);
}
try {
await exec("ffmpeg", [
"-f", "concat",
"-safe", "0",
"-i", listFilePath,
"-c", "copy", // Menggabungkan tanpa re-encoding
output,
]);
console.log(`✅ File berhasil digabungkan dan disimpan di: ${output}\n`);
} catch (err) {
console.error("❌ Kesalahan saat menjalankan ffmpeg:", err);
if (err instanceof Error && 'stdout' in err) {
console.error('ffmpeg stderr output:', (err as any).stderr);
}
throw new Error(`Penggabungan ffmpeg gagal.`);
} finally {
try {
await fs.unlink(listFilePath);
} catch {
console.warn(`⚠️ Gagal menghapus file list ${listFilePath}.`);
}
}
}
// --- Fungsi Utama ---
export async function merge_wav() {
await fs.mkdir(`./output`, { recursive: true }).catch(() => {});
const output = `./output/merged.wav`;
try {
await fs.access(output, fsConstants.F_OK);
await fs.unlink(output);
} catch (error) {}
try {
// Cek apakah direktori TEMP_DIR ada
await fs.access(TEMP_DIR, fsConstants.F_OK);
} catch {
console.error(`❌ Direktori sementara ${TEMP_DIR} tidak ditemukan. Pastikan sudah membuat folder tersebut.`);
return;
}
// Baca daftar file WAV dalam TEMP_DIR
const allFiles = await fs.readdir(TEMP_DIR);
const wavFileNames = allFiles
.filter((f) => f.endsWith(".wav"))
.sort((a, b) => {
const aNum = parseInt(a?.split("_")[2] || "0");
const bNum = parseInt(b?.split("_")[2] || "0");
return aNum - bNum;
});
try {
await merge(wavFileNames, output);
} catch (err) {
console.error("⛔ Proses penggabungan utama gagal.");
process.exit(1);
}
}
if (import.meta.main) {
merge_wav();
}

124
xpooling.ts Normal file
View File

@@ -0,0 +1,124 @@
import fs from "fs";
import randomstring from "randomstring";
const TEMP_DIR = "./temp-tts";
const sub_name = randomstring.generate({ length: 5, charset: 'alphanumeric' });
const HOST = "https://office4-chatterbox.wibudev.com";
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)));
}
}
}
async function fetchResult(jobId: string, partIndex: number) {
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}/${jobId}_${sub_name}_${(partIndex + 1).toString().padStart(4, "0")}.wav`;
fs.writeFileSync(file, buf);
return { status: "done", filePath: file };
}
export async function main() {
const jobsPath = TEMP_DIR + "/jobs.json";
if (!fs.existsSync(jobsPath)) {
console.error("❌ jobs.json not found");
process.exit(1);
}
// POLLING SEMUA
const jobs = JSON.parse(fs.readFileSync(jobsPath, "utf-8"));
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 = 5000;
let attempts = 0;
while (status === "processing" || status === "pending") {
try {
const res = await fetchResult(jobId as string, i);
status = res.status;
if (status === "done" && res.filePath) {
resultFiles.push(res.filePath as string);
console.log(` ✓ Job ${i + 1}/${jobs.length} completed: ${jobId}`);
const resFile = await fetchRetry(`${HOST}/file/${jobId}`);
if (!resFile) {
throw new Error("Failed to fetch file");
}
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length < 44) {
throw new Error("Invalid WAV: too small");
}
const file = `${TEMP_DIR}/${jobId}_${sub_name}_${(i + 1).toString().padStart(4, "0")}.wav`;
fs.writeFileSync(file, buf);
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("🎉 DONE!");
return resultFiles;
}
if (import.meta.main) {
main().then((resultFiles) => {
console.log("Result files:", resultFiles);
});
}

6
xtest.sh Normal file
View File

@@ -0,0 +1,6 @@
curl http://localhost:3000/api/chatterbox-tts/register-prompt-file \
--request POST \
--header 'Content-Type: application/json' \
--data '{
"file": "@dayu.wav"
}'

325
xtiktok.ts Normal file
View File

@@ -0,0 +1,325 @@
#!/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();
}

1
xx.ts Normal file
View File

@@ -0,0 +1 @@
console.log(`Ah! Sekarang saya paham masalahnya! Model di-load 2x dan yang lebih parah, semua 5 worker memproses teks yang SAMA secara bersamaan (bukan per-part). `.length)