tambahannya
This commit is contained in:
4
.env.example
Normal file
4
.env.example
Normal 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
42
.gitignore
vendored
Normal 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
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"python.formatting.provider": "yapf"
|
||||||
|
}
|
||||||
138
README.md
Normal file
138
README.md
Normal 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
53
bin/env.generate.ts
Normal 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
416
bin/route.generate.ts
Normal 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
17
bun-env.d.ts
vendored
Normal 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
589
bun.lock
Normal 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
5
bunfig.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[serve.static]
|
||||||
|
env = "BUN_PUBLIC_*"
|
||||||
|
|
||||||
|
[plugin]
|
||||||
|
preload = ["bun-plugin-glob-import/register"]
|
||||||
1
chatterbox/jobs/baru.json
Normal file
1
chatterbox/jobs/baru.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
["e4b37b72-d515-4aa4-b750-f9ab3e37f390","a4185301-2d75-4414-9390-455be77f34a4"]
|
||||||
1
chatterbox/jobs/dewi.json
Normal file
1
chatterbox/jobs/dewi.json
Normal 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
hasilTTS_FINAL.mp3
Normal file
BIN
hasilTTS_FINAL.mp3
Normal file
Binary file not shown.
BIN
malik_output.wav
Normal file
BIN
malik_output.wav
Normal file
Binary file not shown.
BIN
malik_output10.wav
Normal file
BIN
malik_output10.wav
Normal file
Binary file not shown.
BIN
output/merged.wav
Normal file
BIN
output/merged.wav
Normal file
Binary file not shown.
56
package.json
Normal file
56
package.json
Normal 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
16
postcss.config.js
Normal 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
31
prisma/schema.prisma
Normal 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
30
prisma/seed.ts
Normal 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
138
public/READS.md
Normal 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
13
public/apa.html
Normal 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
BIN
public/kelincix.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
7776
public/styles.css
Normal file
7776
public/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
456
py/main.py
Normal file
456
py/main.py
Normal 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
322
py/op.py
Normal 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
213
py/tts_util.py
Normal 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
17
src/App.tsx
Normal 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
226
src/AppRoutes.tsx
Normal 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
443
src/Landing.tsx
Normal 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
15
src/clientRoutes.ts
Normal 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;
|
||||||
25
src/components/ProtectedRoute.tsx
Normal file
25
src/components/ProtectedRoute.tsx
Normal 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
26
src/frontend.tsx
Normal 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
187
src/index.css
Normal 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
13
src/index.html
Normal 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
120
src/index.tsx
Normal 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
11
src/lib/apiFetch.ts
Normal 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
1
src/logo.svg
Normal 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
31
src/pages/Home.tsx
Normal 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
100
src/pages/Login.tsx
Normal 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
19
src/pages/NotFound.tsx
Normal 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
108
src/pages/Register.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
src/pages/dashboard/apikey/apikey_page.tsx
Normal file
232
src/pages/dashboard/apikey/apikey_page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
225
src/pages/dashboard/chatterbox-tts/ChatterboxListFile.tsx
Normal file
225
src/pages/dashboard/chatterbox-tts/ChatterboxListFile.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
src/pages/dashboard/chatterbox-tts/ChatterboxListPrompt.tsx
Normal file
175
src/pages/dashboard/chatterbox-tts/ChatterboxListPrompt.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
src/pages/dashboard/chatterbox-tts/ChatterboxTTSAsync.tsx
Normal file
162
src/pages/dashboard/chatterbox-tts/ChatterboxTTSAsync.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/pages/dashboard/chatterbox-tts/chatterbox-tts-_page.tsx
Normal file
18
src/pages/dashboard/chatterbox-tts/chatterbox-tts-_page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function ChatterboxTTSLayout() {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
228
src/pages/dashboard/dashboard_layout.tsx
Normal file
228
src/pages/dashboard/dashboard_layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/pages/dashboard/dashboard_page.tsx
Normal file
118
src/pages/dashboard/dashboard_page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/pages/dashboard/tiktok-tts/tiktok_tts_layout.tsx
Normal file
5
src/pages/dashboard/tiktok-tts/tiktok_tts_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function TiktokTtsLayout() {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
274
src/pages/dashboard/tiktok-tts/tiktok_tts_page.tsx
Normal file
274
src/pages/dashboard/tiktok-tts/tiktok_tts_page.tsx
Normal 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
8
src/react.svg
Normal 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
11
src/routeTypes.ts
Normal 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;
|
||||||
|
}
|
||||||
27
src/server/lib/chatterbox.env.ts
Normal file
27
src/server/lib/chatterbox.env.ts
Normal 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
11
src/server/lib/prisma.ts
Normal 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
|
||||||
|
}
|
||||||
182
src/server/lib/tts_chatterbox.ts
Normal file
182
src/server/lib/tts_chatterbox.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
121
src/server/lib/tts_chatterbox_download.ts
Normal file
121
src/server/lib/tts_chatterbox_download.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
90
src/server/lib/tts_chatterbox_merge.ts
Normal file
90
src/server/lib/tts_chatterbox_merge.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
335
src/server/lib/tts_tiktok.ts
Normal file
335
src/server/lib/tts_tiktok.ts
Normal 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"
|
||||||
|
})
|
||||||
|
}
|
||||||
84
src/server/middlewares/apiAuth.ts
Normal file
84
src/server/middlewares/apiAuth.ts
Normal 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' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
105
src/server/routes/apikey_route.ts
Normal file
105
src/server/routes/apikey_route.ts
Normal 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
|
||||||
237
src/server/routes/auth_route.ts
Normal file
237
src/server/routes/auth_route.ts
Normal 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
|
||||||
182
src/server/routes/chatterbox_tts.ts
Normal file
182
src/server/routes/chatterbox_tts.ts
Normal 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;
|
||||||
103
src/server/routes/tts_tiktok.ts
Normal file
103
src/server/routes/tts_tiktok.ts
Normal 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
|
||||||
BIN
temp-tts/04282526-fe6a-4e0e-a9fd-9c94dcf70662_EBWzB_0001.wav
Normal file
BIN
temp-tts/04282526-fe6a-4e0e-a9fd-9c94dcf70662_EBWzB_0001.wav
Normal file
Binary file not shown.
BIN
temp-tts/2e196685-b957-456c-9199-694e25e7b75f_EBWzB_0002.wav
Normal file
BIN
temp-tts/2e196685-b957-456c-9199-694e25e7b75f_EBWzB_0002.wav
Normal file
Binary file not shown.
BIN
temp-tts/de80f011-5b75-4e09-adac-11e0186831bf_EBWzB_0004.wav
Normal file
BIN
temp-tts/de80f011-5b75-4e09-adac-11e0186831bf_EBWzB_0004.wav
Normal file
Binary file not shown.
BIN
temp-tts/f16b7522-af2d-4ac7-b269-154820eaa6b8_EBWzB_0003.wav
Normal file
BIN
temp-tts/f16b7522-af2d-4ac7-b269-154820eaa6b8_EBWzB_0003.wav
Normal file
Binary file not shown.
1
temp-tts/jobs.json
Normal file
1
temp-tts/jobs.json
Normal 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
36
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
BIN
tts_tiktok_output/final/07dfc3c3-aec3-403d-a314-af3f356c95e5.mp3
Normal file
BIN
tts_tiktok_output/final/07dfc3c3-aec3-403d-a314-af3f356c95e5.mp3
Normal file
Binary file not shown.
Binary file not shown.
8
types/env.d.ts
vendored
Normal file
8
types/env.d.ts
vendored
Normal 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
7
x.sh
Normal 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
16
xc.sh
Normal 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
245
xclone.py
Normal 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
267
xclonev2.py
Normal 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
427
xclonev3.py
Normal 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
101
xdownload.ts
Normal 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
209
xfetch.ts
Normal 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
416
xfetch.txt
Normal 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
106
xmerge.ts
Normal 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
124
xpooling.ts
Normal 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
6
xtest.sh
Normal 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
325
xtiktok.ts
Normal 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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user