feat: refactor and optimize frontend with Next.js App Router, TypeScript, and Tailwind CSS
- Complete refactoring of old frontend into Next.js App Router workspace - Redesigned sidebar collapsing animation with absolute toggle positioning - Resolved visual canvas bleed transitions between light/dark themes - Added custom dark theme variant for toggle switch buttons - Implemented full localization across Indonesian, English, Spanish, Japanese, and Chinese - Synchronized HTML document themes to apply dark mode styles to portals/overlays
@@ -29,3 +29,8 @@ Thumbs.db
|
||||
*.index
|
||||
weights/
|
||||
pretrained/
|
||||
|
||||
# Next.js workspace exclusions
|
||||
frontend-next/node_modules/
|
||||
frontend-next/.next/
|
||||
frontend-next/out/
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,50 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const srcDir = path.join(__dirname, 'out');
|
||||
const destDir = path.join(__dirname, '..', 'frontend');
|
||||
|
||||
function copyDirSync(src, dest) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
const entries = fs.readdirSync(src, { withFileTypes: true });
|
||||
|
||||
for (let entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
copyDirSync(srcPath, destPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanDirSync(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (let entry of entries) {
|
||||
if (entry.name === '.git' || entry.name === '.antigravitycli') continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
fs.rmSync(fullPath, { recursive: true, force: true });
|
||||
} else {
|
||||
fs.unlinkSync(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Wiping old static files in frontend/ ...');
|
||||
cleanDirSync(destDir);
|
||||
|
||||
console.log('Copying static Next.js assets to frontend/ ...');
|
||||
if (fs.existsSync(srcDir)) {
|
||||
copyDirSync(srcDir, destDir);
|
||||
console.log('Build output synchronization successful!');
|
||||
} else {
|
||||
console.error('Error: "out/" directory not found. Compile the Next.js build first.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Synchronization failed:', err);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'export',
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "frontend-next",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build && node copy-build.js",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.40.0",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,82 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
:root {
|
||||
--track-bg: #e4e4e7;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--track-bg: #27272a;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--color-background: #fafafa;
|
||||
--color-foreground: #18181b;
|
||||
|
||||
--color-primary: #84cc16; /* Lime-500 */
|
||||
--color-hover: #65a30d; /* Lime-600 */
|
||||
--color-soft-accent: #d9f99d; /* Lime-200 */
|
||||
--color-success: #10b981; /* Emerald-500 */
|
||||
|
||||
--color-text-primary: #18181b;
|
||||
--color-text-secondary: #52525b;
|
||||
|
||||
--radius-lg: 1rem; /* rounded-2xl */
|
||||
--radius-md: 0.75rem; /* rounded-xl */
|
||||
--radius-sm: 0.5rem; /* rounded-lg */
|
||||
}
|
||||
|
||||
/* Custom styling presets */
|
||||
body {
|
||||
background-color: #fafafa;
|
||||
color: #18181b;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Glowing Aura Background */
|
||||
.glow-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: -10;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 20%, rgba(132, 204, 22, 0.04) 0%, transparent 40%),
|
||||
radial-gradient(circle at 90% 80%, rgba(16, 185, 129, 0.04) 0%, transparent 40%);
|
||||
}
|
||||
|
||||
/* Glassmorphism panel overlay */
|
||||
.glass-panel {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(24, 24, 27, 0.05);
|
||||
}
|
||||
|
||||
/* Custom pulse animations for recording signals */
|
||||
@keyframes signal-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(132, 204, 22, 0.4);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 10px 4px rgba(132, 204, 22, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.pulse-indicator {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 9999px;
|
||||
background-color: #84cc16;
|
||||
}
|
||||
|
||||
.pulse-indicator.active {
|
||||
animation: signal-pulse 2s infinite ease-in-out;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "🎙️ ONNX VC - Real-Time AI Voice Changer",
|
||||
description: "ONNX VC - Pengubah suara real-time berbasis AI berlatensi ultra-rendah dengan ONNX Runtime.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'danger' | 'info';
|
||||
}
|
||||
|
||||
const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
|
||||
({ className, variant = "default", ...props }, ref) => {
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold select-none border tracking-wide uppercase",
|
||||
variant === 'default' && "bg-[var(--accent-color)] text-white border-transparent",
|
||||
variant === 'secondary' && "bg-[var(--accent-soft)] text-[var(--accent-text)] border-transparent",
|
||||
variant === 'success' && "bg-[#10b981]/10 text-[#059669] border-[#10b981]/20",
|
||||
variant === 'warning' && "bg-amber-500/10 text-amber-700 border-amber-500/20",
|
||||
variant === 'danger' && "bg-red-500/10 text-red-700 border-red-500/20",
|
||||
variant === 'info' && "bg-sky-500/10 text-sky-700 border-sky-500/20",
|
||||
variant === 'outline' && "text-zinc-600 dark:text-zinc-400 border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Badge.displayName = "Badge";
|
||||
|
||||
export { Badge };
|
||||
export default Badge;
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as React from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'accent' | 'success' | 'danger';
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "primary", size = "default", ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-lime-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98] cursor-pointer",
|
||||
// Sizing
|
||||
size === 'default' && "h-11 px-5 py-2.5 text-sm",
|
||||
size === 'sm' && "h-9 px-3.5 text-xs rounded-lg",
|
||||
size === 'lg' && "h-12 px-7 py-3 text-base rounded-2xl",
|
||||
size === 'icon' && "h-10 w-10 rounded-xl",
|
||||
// Variants
|
||||
variant === 'primary' && "bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white shadow-sm shadow-lime-500/10 font-semibold",
|
||||
variant === 'secondary' && "bg-[var(--accent-soft)] hover:bg-[var(--accent-soft)]/80 text-[var(--accent-text)] font-semibold",
|
||||
variant === 'accent' && "bg-zinc-900 dark:bg-[var(--accent-color)] hover:bg-zinc-850 dark:hover:bg-[var(--accent-hover)] text-white dark:text-zinc-950 shadow-sm font-semibold",
|
||||
variant === 'outline' && "border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-zinc-700 dark:text-zinc-300",
|
||||
variant === 'ghost' && "hover:bg-zinc-50 dark:hover:bg-zinc-800 text-zinc-700 dark:text-zinc-300",
|
||||
variant === 'success' && "bg-[#10b981] hover:bg-[#059669] text-white shadow-sm font-semibold",
|
||||
variant === 'danger' && "bg-red-500 hover:bg-red-600 text-white shadow-sm font-semibold",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button };
|
||||
export default Button;
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as React from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export interface DialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Dialog: React.FC<DialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
className
|
||||
}) => {
|
||||
// Close dialog on escape key press
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop Overlay */}
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-zinc-950/40 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal content */}
|
||||
<motion.div
|
||||
className={twMerge(
|
||||
"relative bg-white dark:bg-zinc-900 w-full max-w-lg rounded-2xl p-6 shadow-xl border border-zinc-200/50 dark:border-zinc-800/80 z-10 overflow-hidden flex flex-col max-h-[85vh] text-zinc-800 dark:text-zinc-100",
|
||||
className
|
||||
)}
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-4 pb-2 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-zinc-100 leading-none">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-zinc-400 dark:text-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent-color)] cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto pr-1 flex-1">
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
export default Dialog;
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
options: { value: string | number; label: string }[];
|
||||
}
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, options, ...props }, ref) => {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<select
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"w-full h-11 px-4 text-sm bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl focus:outline-none focus:ring-2 focus:ring-[var(--accent-color)] focus:border-[var(--accent-color)] transition-all cursor-pointer appearance-none text-zinc-800 dark:text-zinc-100 pr-10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Custom Arrow Icon */}
|
||||
<div className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 flex items-center justify-center text-zinc-400">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Select.displayName = "Select";
|
||||
|
||||
export { Select };
|
||||
export default Select;
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export interface SliderProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
onValueChange?: (val: number) => void;
|
||||
}
|
||||
|
||||
const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
|
||||
({ className, value, min, max, step = 1, onValueChange, ...props }, ref) => {
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
|
||||
return (
|
||||
<div className="relative w-full flex items-center select-none group">
|
||||
<input
|
||||
type="range"
|
||||
ref={ref}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onValueChange?.(parseFloat(e.target.value))}
|
||||
className={twMerge(
|
||||
"w-full h-2 rounded-lg bg-zinc-200 dark:bg-zinc-800 appearance-none cursor-pointer outline-none focus:outline-none",
|
||||
// custom thumb styles
|
||||
"[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-[var(--accent-color)] [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:transition-all [&::-webkit-slider-thumb]:active:scale-110",
|
||||
"[&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-[var(--accent-color)] [&::-moz-range-thumb]:shadow-md [&::-moz-range-thumb]:transition-all [&::-moz-range-thumb]:active:scale-110",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
background: `linear-gradient(to right, var(--accent-color) 0%, var(--accent-color) ${percentage}%, var(--track-bg, #e4e4e7) ${percentage}%, var(--track-bg, #e4e4e7) 100%)`
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Slider.displayName = "Slider";
|
||||
|
||||
export { Slider };
|
||||
export default Slider;
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export interface SwitchProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
checked: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
label?: string;
|
||||
variant?: 'default' | 'dark';
|
||||
}
|
||||
|
||||
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
({ className, checked, onCheckedChange, label, variant = 'default', ...props }, ref) => {
|
||||
return (
|
||||
<label className="flex items-center gap-3 cursor-pointer select-none group">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
ref={ref}
|
||||
checked={checked}
|
||||
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
||||
className="sr-only"
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-10 h-6 bg-zinc-200 dark:bg-zinc-800 rounded-full transition-all duration-200 group-focus-within:ring-2 group-focus-within:ring-offset-2",
|
||||
variant === 'dark' ? "group-focus-within:ring-zinc-500" : "group-focus-within:ring-[var(--accent-color)]",
|
||||
checked && (variant === 'dark' ? "bg-zinc-800 dark:bg-zinc-700 border border-zinc-700/50 dark:border-zinc-650/80" : "bg-[var(--accent-color)]"),
|
||||
className
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={twMerge(
|
||||
"absolute left-0.5 top-0.5 w-5 h-5 bg-white dark:bg-zinc-900 rounded-full transition-all duration-200 shadow-sm border border-zinc-200/50 dark:border-zinc-800/80",
|
||||
checked && "translate-x-4",
|
||||
checked && (variant === 'dark' ? "border-zinc-500" : "border-[var(--accent-color)]")
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300 select-none">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
);
|
||||
Switch.displayName = "Switch";
|
||||
|
||||
export { Switch };
|
||||
export default Switch;
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useWaveformCanvas } from '../../../hooks/useWaveformCanvas';
|
||||
import { usePictureInPicture } from '../../../hooks/usePictureInPicture';
|
||||
import { Button } from '../../../components/ui/button';
|
||||
import { Maximize2, MonitorOff, Activity } from 'lucide-react';
|
||||
import { translations, Language } from '../../../utils/translations';
|
||||
|
||||
interface WaveformPanelProps {
|
||||
title: string;
|
||||
buffer: React.MutableRefObject<Float32Array>;
|
||||
strokeColor: string;
|
||||
isTalking?: boolean;
|
||||
lineWidth?: number;
|
||||
traceFade?: number;
|
||||
isDark?: boolean;
|
||||
lang?: Language;
|
||||
}
|
||||
|
||||
export const WaveformPanel: React.FC<WaveformPanelProps> = ({
|
||||
title,
|
||||
buffer,
|
||||
strokeColor,
|
||||
isTalking = false,
|
||||
lineWidth = 2,
|
||||
traceFade = 0.4,
|
||||
isDark = false,
|
||||
lang = 'en',
|
||||
}) => {
|
||||
const t = translations[lang];
|
||||
// Background clear color: white for light mode, dark zinc-950 for dark mode
|
||||
const canvasBgColor = isDark ? `rgba(9, 9, 11, ${traceFade})` : `rgba(255, 255, 255, ${traceFade})`;
|
||||
|
||||
const { canvasRef, updateData } = useWaveformCanvas({
|
||||
strokeColor,
|
||||
fillColor: canvasBgColor, // dynamic trail alpha blending
|
||||
scaleAmplitude: 2.0,
|
||||
lineWidth,
|
||||
});
|
||||
|
||||
const { togglePip, isPipActive, isSupported } = usePictureInPicture();
|
||||
|
||||
// Draw buffer updates
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const loop = () => {
|
||||
if (!active) return;
|
||||
updateData(buffer.current);
|
||||
requestAnimationFrame(loop);
|
||||
};
|
||||
loop();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [buffer, updateData]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-white dark:bg-zinc-900 border border-zinc-200/50 dark:border-zinc-800/80 shadow-sm rounded-2xl p-5 relative overflow-hidden flex flex-col h-full transition-colors"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{isTalking && (
|
||||
<motion.span
|
||||
className="w-2.5 h-2.5 rounded-full bg-[var(--accent-color)]"
|
||||
animate={{ scale: [1, 1.4, 1], opacity: [1, 0.4, 1] }}
|
||||
transition={{ repeat: Infinity, duration: 1.2 }}
|
||||
/>
|
||||
)}
|
||||
<h3 className="font-bold text-zinc-800 dark:text-zinc-200 text-xs tracking-wider uppercase flex items-center gap-1.5">
|
||||
<Activity className="w-4 h-4 text-[var(--accent-color)]" />
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{isSupported && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => togglePip(canvasRef.current)}
|
||||
className="text-[10px] h-8 px-2.5 flex items-center gap-1 border-zinc-200 dark:border-zinc-800 hover:bg-[var(--accent-soft)] dark:hover:bg-[var(--accent-soft)]/20 hover:text-[var(--accent-text)] dark:hover:text-[var(--accent-color)] text-zinc-700 dark:text-zinc-300 transition-colors"
|
||||
>
|
||||
{isPipActive ? (
|
||||
<>
|
||||
<MonitorOff className="w-3 h-3" />
|
||||
{t.pipClose}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Maximize2 className="w-3 h-3" />
|
||||
{t.pipStream}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1 min-h-[140px] bg-zinc-50 dark:bg-zinc-950 border border-zinc-100 dark:border-zinc-900 rounded-xl overflow-hidden shadow-inner transition-colors">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full block cursor-pointer"
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{isTalking && (
|
||||
<motion.div
|
||||
className="absolute top-2.5 right-2.5 bg-[var(--accent-color)]/90 text-white text-[9px] font-bold px-2 py-0.5 rounded-full shadow-sm uppercase tracking-wider backdrop-blur-sm"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{t.activeSignal}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
export default WaveformPanel;
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { AudioConfig, ConnectionStatus, HardwareDevice } from '../types/audio';
|
||||
|
||||
export const useAudioPipeline = (
|
||||
wsUrl: string,
|
||||
config: AudioConfig,
|
||||
onConfigSync: (sr: number, list: HardwareDevice[]) => void
|
||||
) => {
|
||||
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
|
||||
const [rtt, setRtt] = useState<number | null>(null);
|
||||
const [processingTime, setProcessingTime] = useState<number | null>(null);
|
||||
const [isTalking, setIsTalking] = useState<boolean>(false);
|
||||
const [isStreaming, setIsStreaming] = useState<boolean>(false);
|
||||
const [playOutput, setPlayOutput] = useState<boolean>(true);
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const micStreamRef = useRef<MediaStream | null>(null);
|
||||
const micSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
const processorRef = useRef<ScriptProcessorNode | null>(null);
|
||||
const sampleRateRef = useRef<number>(40000);
|
||||
|
||||
// High-performance canvas rolling buffers
|
||||
const inputDisplayBuf = useRef<Float32Array>(new Float32Array(4096));
|
||||
const outputDisplayBuf = useRef<Float32Array>(new Float32Array(4096));
|
||||
const micAccumulator = useRef<Float32Array>(new Float32Array(0));
|
||||
|
||||
// Playback scheduling & timing
|
||||
const sentTimestamps = useRef<{ id: number; sent: number }[]>([]);
|
||||
const nextPlaybackTime = useRef<number>(0);
|
||||
const outputChunkQueue = useRef<{ data: Float32Array; startTime: number }[]>([]);
|
||||
|
||||
// Function to stringify and sync configs
|
||||
const sendConfig = useCallback(() => {
|
||||
const socket = socketRef.current;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
type: 'config',
|
||||
model_name: config.model_name,
|
||||
device: config.device,
|
||||
f0_method: config.f0_method,
|
||||
f0_up_key: config.f0_up_key,
|
||||
noise_gate: config.noise_gate,
|
||||
input_gain: config.input_gain,
|
||||
output_gain: config.output_gain,
|
||||
input_sr: audioCtxRef.current ? audioCtxRef.current.sampleRate : 44100,
|
||||
routing_mode: config.routing_mode,
|
||||
input_device: config.input_device,
|
||||
output_device: config.output_device,
|
||||
chunk_size: config.chunk_size
|
||||
}));
|
||||
}, [config]);
|
||||
|
||||
// Decodes array buffers from Python server
|
||||
const handleServerAudio = useCallback((arrayBuffer: ArrayBuffer) => {
|
||||
if (!audioCtxRef.current) return;
|
||||
|
||||
const now = performance.now();
|
||||
if (sentTimestamps.current.length > 0) {
|
||||
const oldest = sentTimestamps.current.shift();
|
||||
if (oldest) {
|
||||
setRtt(Math.round(now - oldest.sent));
|
||||
}
|
||||
}
|
||||
|
||||
const payload = new Float32Array(arrayBuffer);
|
||||
const procTime = payload[0];
|
||||
const pcmData = payload.subarray(1);
|
||||
|
||||
setProcessingTime(Math.max(0, Math.round(procTime)));
|
||||
|
||||
const ctx = audioCtxRef.current;
|
||||
const audioBuf = ctx.createBuffer(1, pcmData.length, sampleRateRef.current);
|
||||
audioBuf.getChannelData(0).set(pcmData);
|
||||
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = audioBuf;
|
||||
|
||||
// Only route node to speaker output if user didn't mute local listening
|
||||
if (playOutput) {
|
||||
source.connect(ctx.destination);
|
||||
}
|
||||
|
||||
// Precise schedule timelines
|
||||
const currentTime = ctx.currentTime;
|
||||
const duration = audioBuf.duration;
|
||||
const adaptiveBuf = Math.min(duration * 2.5, 0.50);
|
||||
|
||||
if (nextPlaybackTime.current < currentTime) {
|
||||
nextPlaybackTime.current = currentTime + adaptiveBuf;
|
||||
} else if (nextPlaybackTime.current > currentTime + duration * 5.0) {
|
||||
nextPlaybackTime.current = currentTime + adaptiveBuf; // Latency Buster
|
||||
}
|
||||
|
||||
const startSchedule = nextPlaybackTime.current;
|
||||
source.start(startSchedule);
|
||||
nextPlaybackTime.current += duration;
|
||||
|
||||
// Queue for syncing waveform outputs
|
||||
outputChunkQueue.current.push({ data: pcmData, startTime: startSchedule });
|
||||
while (outputChunkQueue.current.length > 0) {
|
||||
const c = outputChunkQueue.current[0];
|
||||
if (c.startTime + c.data.length / sampleRateRef.current < ctx.currentTime - 2.0) {
|
||||
outputChunkQueue.current.shift();
|
||||
} else break;
|
||||
}
|
||||
|
||||
// Push output PCM samples to rolling display buffers
|
||||
const size = 4096;
|
||||
const display = outputDisplayBuf.current;
|
||||
if (pcmData.length >= size) {
|
||||
display.set(pcmData.slice(pcmData.length - size));
|
||||
} else {
|
||||
display.copyWithin(0, pcmData.length);
|
||||
display.set(pcmData, size - pcmData.length);
|
||||
}
|
||||
}, [playOutput]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (socketRef.current) {
|
||||
try {
|
||||
socketRef.current.close();
|
||||
} catch (e) {}
|
||||
socketRef.current = null;
|
||||
}
|
||||
setStatus('disconnected');
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
disconnect();
|
||||
setStatus('connecting');
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus('connected');
|
||||
socketRef.current = ws;
|
||||
sendConfig();
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setStatus('disconnected');
|
||||
socketRef.current = null;
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setStatus('disconnected');
|
||||
socketRef.current = null;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'config_success') {
|
||||
sampleRateRef.current = data.target_sr;
|
||||
} else if (data.type === 'init_devices') {
|
||||
onConfigSync(data.target_sr || 40000, data.devices || []);
|
||||
} else if (data.type === 'visualizer') {
|
||||
// Hardware mode visualizer data stream
|
||||
inputDisplayBuf.current.set(new Float32Array(data.input));
|
||||
outputDisplayBuf.current.set(new Float32Array(data.output));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('WS JSON parse error:', e);
|
||||
}
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
handleServerAudio(event.data);
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('WS Connection failed:', e);
|
||||
setStatus('disconnected');
|
||||
}
|
||||
}, [wsUrl, sendConfig, handleServerAudio, onConfigSync, disconnect]);
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
setIsStreaming(false);
|
||||
setIsTalking(false);
|
||||
|
||||
if (config.routing_mode === 'hardware') {
|
||||
const socket = socketRef.current;
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'config',
|
||||
routing_mode: 'browser' // tells server hardware stream to stop
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (micStreamRef.current) {
|
||||
micStreamRef.current.getTracks().forEach(t => t.stop());
|
||||
micStreamRef.current = null;
|
||||
}
|
||||
if (micSourceRef.current) {
|
||||
micSourceRef.current.disconnect();
|
||||
micSourceRef.current = null;
|
||||
}
|
||||
if (processorRef.current) {
|
||||
processorRef.current.disconnect();
|
||||
processorRef.current = null;
|
||||
}
|
||||
|
||||
micAccumulator.current = new Float32Array(0);
|
||||
setRtt(null);
|
||||
setProcessingTime(null);
|
||||
}, [config.routing_mode]);
|
||||
|
||||
const startStream = useCallback(async () => {
|
||||
if (config.routing_mode === 'hardware') {
|
||||
setIsStreaming(true);
|
||||
sendConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!audioCtxRef.current) {
|
||||
audioCtxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({
|
||||
latencyHint: 'interactive',
|
||||
});
|
||||
}
|
||||
|
||||
const ctx = audioCtxRef.current;
|
||||
if (ctx.state === 'suspended') {
|
||||
await ctx.resume();
|
||||
}
|
||||
|
||||
try {
|
||||
micStreamRef.current = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
|
||||
micSourceRef.current = ctx.createMediaStreamSource(micStreamRef.current);
|
||||
processorRef.current = ctx.createScriptProcessor(4096, 1, 1);
|
||||
|
||||
processorRef.current.onaudioprocess = (e) => {
|
||||
const inputData = e.inputBuffer.getChannelData(0);
|
||||
|
||||
// Update input waveform display buffer
|
||||
const display = inputDisplayBuf.current;
|
||||
display.copyWithin(0, inputData.length);
|
||||
display.set(inputData, display.length - inputData.length);
|
||||
|
||||
// Append to local accumulator
|
||||
const nextAcc = new Float32Array(micAccumulator.current.length + inputData.length);
|
||||
nextAcc.set(micAccumulator.current);
|
||||
nextAcc.set(inputData, micAccumulator.current.length);
|
||||
micAccumulator.current = nextAcc;
|
||||
|
||||
const size = config.chunk_size;
|
||||
while (micAccumulator.current.length >= size) {
|
||||
const chunk = micAccumulator.current.slice(0, size);
|
||||
micAccumulator.current = micAccumulator.current.slice(size);
|
||||
|
||||
// Simple RMS for Voice Activity Badge
|
||||
let sum = 0;
|
||||
for (let i = 0; i < chunk.length; i++) sum += chunk[i] * chunk[i];
|
||||
const rms = Math.sqrt(sum / chunk.length);
|
||||
setIsTalking(rms > 0.005);
|
||||
|
||||
// Stream raw float PCM bytes
|
||||
const ws = socketRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
const time = performance.now();
|
||||
sentTimestamps.current.push({ id: time, sent: time });
|
||||
ws.send(chunk.buffer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
micSourceRef.current.connect(processorRef.current);
|
||||
processorRef.current.connect(ctx.destination);
|
||||
nextPlaybackTime.current = 0;
|
||||
setIsStreaming(true);
|
||||
} catch (e) {
|
||||
console.error('Failed to start microphone streaming:', e);
|
||||
alert('Microphone access failed: ' + (e instanceof Error ? e.message : String(e)));
|
||||
stopStream();
|
||||
}
|
||||
}, [config.routing_mode, config.chunk_size, sendConfig, stopStream]);
|
||||
|
||||
// Sync config whenever React props config changes
|
||||
useEffect(() => {
|
||||
sendConfig();
|
||||
}, [config, sendConfig]);
|
||||
|
||||
// Lifecycle cleanups
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disconnect();
|
||||
stopStream();
|
||||
if (audioCtxRef.current) {
|
||||
audioCtxRef.current.close().catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [disconnect, stopStream]);
|
||||
|
||||
return {
|
||||
status,
|
||||
rtt,
|
||||
processingTime,
|
||||
isTalking,
|
||||
isStreaming,
|
||||
playOutput,
|
||||
setPlayOutput,
|
||||
connect,
|
||||
disconnect,
|
||||
startStream,
|
||||
stopStream,
|
||||
inputBuffer: inputDisplayBuf,
|
||||
outputBuffer: outputDisplayBuf
|
||||
};
|
||||
};
|
||||
export default useAudioPipeline;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
export interface ShortcutBinding {
|
||||
keys: string; // e.g. "Control+k", " ", "m", "alt+1"
|
||||
description: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
export const useKeyboardShortcuts = (bindings: ShortcutBinding[], enabled: boolean = true) => {
|
||||
const bindingsRef = useRef<ShortcutBinding[]>(bindings);
|
||||
|
||||
useEffect(() => {
|
||||
bindingsRef.current = bindings;
|
||||
}, [bindings]);
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (!enabled) return;
|
||||
|
||||
// Avoid hijacking keystrokes when editing inputs
|
||||
const active = document.activeElement;
|
||||
if (active) {
|
||||
const name = active.tagName.toLowerCase();
|
||||
if (name === 'input' || name === 'textarea' || active.getAttribute('contenteditable') === 'true') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const pressedKey = e.key.toLowerCase();
|
||||
const isCtrl = e.ctrlKey || e.metaKey;
|
||||
const isAlt = e.altKey;
|
||||
const isShift = e.shiftKey;
|
||||
|
||||
for (const binding of bindingsRef.current) {
|
||||
const keys = binding.keys.toLowerCase().split('+');
|
||||
|
||||
const requiresCtrl = keys.includes('control') || keys.includes('ctrl');
|
||||
const requiresAlt = keys.includes('alt');
|
||||
const requiresShift = keys.includes('shift');
|
||||
|
||||
const baseKey = keys.filter(k => !['control', 'ctrl', 'alt', 'shift'].includes(k))[0];
|
||||
|
||||
const matchesCtrl = requiresCtrl ? isCtrl : !isCtrl;
|
||||
const matchesAlt = requiresAlt ? isAlt : !isAlt;
|
||||
const matchesShift = requiresShift ? isShift : !isShift;
|
||||
|
||||
const normalizedBaseKey = baseKey === 'space' ? ' ' : baseKey;
|
||||
const matchesBase = pressedKey === normalizedBaseKey;
|
||||
|
||||
if (matchesCtrl && matchesAlt && matchesShift && matchesBase) {
|
||||
e.preventDefault();
|
||||
binding.action();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
};
|
||||
export default useKeyboardShortcuts;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
export const usePictureInPicture = () => {
|
||||
const [isPipActive, setIsPipActive] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
videoRef.current = video;
|
||||
|
||||
const handleLeavePip = () => {
|
||||
setIsPipActive(false);
|
||||
};
|
||||
|
||||
video.addEventListener('leavepictureinpicture', handleLeavePip);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('leavepictureinpicture', handleLeavePip);
|
||||
if (document.pictureInPictureElement === video) {
|
||||
document.exitPictureInPicture().catch(() => {});
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const togglePip = useCallback(async (canvas: HTMLCanvasElement | null) => {
|
||||
if (!canvas || !videoRef.current) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
|
||||
try {
|
||||
if (document.pictureInPictureElement === video) {
|
||||
await document.exitPictureInPicture();
|
||||
setIsPipActive(false);
|
||||
} else {
|
||||
// Capture a stream of the Canvas at 30 fps
|
||||
const stream = canvas.captureStream
|
||||
? canvas.captureStream(30)
|
||||
: (canvas as any).mozCaptureStream
|
||||
? (canvas as any).mozCaptureStream(30)
|
||||
: null;
|
||||
|
||||
if (!stream) {
|
||||
throw new Error("Canvas.captureStream() is not supported on this browser.");
|
||||
}
|
||||
|
||||
video.srcObject = stream;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
video.onloadedmetadata = () => {
|
||||
video.play().then(() => resolve());
|
||||
};
|
||||
});
|
||||
|
||||
await video.requestPictureInPicture();
|
||||
setIsPipActive(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Picture-in-Picture failed:", error);
|
||||
alert("Picture-in-Picture error: " + (error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isSupported = typeof window !== 'undefined' && 'pictureInPictureEnabled' in document;
|
||||
|
||||
return { togglePip, isPipActive, isSupported };
|
||||
};
|
||||
export default usePictureInPicture;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
interface UseWaveformCanvasOptions {
|
||||
strokeColor: string;
|
||||
fillColor?: string;
|
||||
scaleAmplitude?: number;
|
||||
lineWidth?: number;
|
||||
}
|
||||
|
||||
export const useWaveformCanvas = (options: UseWaveformCanvasOptions) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const bufferRef = useRef<Float32Array | null>(null);
|
||||
|
||||
const updateData = useCallback((data: Float32Array) => {
|
||||
bufferRef.current = data;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const handleResize = () => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * window.devicePixelRatio;
|
||||
canvas.height = rect.height * window.devicePixelRatio;
|
||||
|
||||
const baseColor = (options.fillColor || 'rgba(10, 10, 10, 0.4)').replace(/[\d.]+\)$/, '1)');
|
||||
ctx.fillStyle = baseColor;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
};
|
||||
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Initial canvas clear with solid color
|
||||
const baseColor = (options.fillColor || 'rgba(10, 10, 10, 0.4)').replace(/[\d.]+\)$/, '1)');
|
||||
ctx.fillStyle = baseColor;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const draw = () => {
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
const dataArray = bufferRef.current;
|
||||
|
||||
// Dark transparent fill for trace/fade visual trails
|
||||
ctx.fillStyle = options.fillColor || 'rgba(10, 10, 10, 0.4)';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
if (dataArray && dataArray.length > 0) {
|
||||
ctx.lineWidth = (options.lineWidth ?? 2) * window.devicePixelRatio;
|
||||
ctx.strokeStyle = options.strokeColor;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
|
||||
const sliceWidth = width / dataArray.length;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
const v = dataArray[i] * (options.scaleAmplitude ?? 1.5);
|
||||
const y = (v * (height / 2)) + (height / 2);
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
x += sliceWidth;
|
||||
}
|
||||
|
||||
ctx.lineTo(width, height / 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw subtle zero amplitude baseline
|
||||
ctx.strokeStyle = options.fillColor?.includes('255') ? 'rgba(0, 0, 0, 0.06)' : 'rgba(255, 255, 255, 0.06)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, height / 2);
|
||||
ctx.lineTo(width, height / 2);
|
||||
ctx.stroke();
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [options.strokeColor, options.fillColor, options.scaleAmplitude, options.lineWidth]);
|
||||
|
||||
return { canvasRef, updateData };
|
||||
};
|
||||
export default useWaveformCanvas;
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface AudioConfig {
|
||||
model_name: string;
|
||||
device: 'cpu' | 'cuda' | 'dml';
|
||||
f0_method: 'pm' | 'dio' | 'harvest' | 'rmvpe';
|
||||
f0_up_key: number;
|
||||
noise_gate: number;
|
||||
input_gain: number;
|
||||
output_gain: number;
|
||||
input_sr: number;
|
||||
routing_mode: 'browser' | 'hardware';
|
||||
input_device: number | null;
|
||||
output_device: number | null;
|
||||
chunk_size: number;
|
||||
}
|
||||
|
||||
export interface HardwareDevice {
|
||||
id: number;
|
||||
name: string;
|
||||
max_input_channels: number;
|
||||
max_output_channels: number;
|
||||
}
|
||||
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected';
|
||||
@@ -0,0 +1,592 @@
|
||||
export type Language = 'en' | 'ja' | 'zh' | 'es' | 'id';
|
||||
|
||||
export const languages: { code: Language; label: string; flag: string }[] = [
|
||||
{ code: 'en', label: 'English', flag: '🇺🇸' },
|
||||
{ code: 'id', label: 'Bahasa Indonesia', flag: '🇮🇩' },
|
||||
{ code: 'ja', label: '日本語', flag: '🇯🇵' },
|
||||
{ code: 'zh', label: '简体中文', flag: '🇨🇳' },
|
||||
{ code: 'es', label: 'Español', flag: '🇪🇸' }
|
||||
];
|
||||
|
||||
export const translations = {
|
||||
en: {
|
||||
appTitle: "🎙️ ONNX VC",
|
||||
appSubtitle: "Low-latency real-time AI voice conversion powered by ONNX Runtime acceleration.",
|
||||
wsServerUrl: "WebSocket Server URL",
|
||||
wsPlaceholder: "ws://localhost:8765",
|
||||
connectionStatus: "Connection Status",
|
||||
disconnected: "Disconnected",
|
||||
connecting: "Connecting",
|
||||
connected: "Connected",
|
||||
connect: "Connect Server",
|
||||
disconnect: "Disconnect Server",
|
||||
startChanger: "Start Voice Changer",
|
||||
stopChanger: "Stop Voice Changer",
|
||||
listeningActive: "Listening: ACTIVE",
|
||||
listeningMute: "Listening: MUTED",
|
||||
|
||||
// Tabs
|
||||
tabDashboard: "Workspace",
|
||||
tabModel: "Model Settings",
|
||||
tabDsp: "Audio DSP",
|
||||
tabShortcuts: "Shortcuts",
|
||||
|
||||
// Model Config
|
||||
modelConfigTitle: "Model & Device Configuration",
|
||||
quickPresets: "Quick Presets (Performance Profile)",
|
||||
latencyPreset: "⚡ Instant Response (PM)",
|
||||
qualityPreset: "🎙️ High Fidelity (RMVPE)",
|
||||
selectModel: "Select Character Model (RVC ONNX)",
|
||||
executionProvider: "Execution Provider (GPU Acceleration)",
|
||||
routingMode: "Audio Routing Mode",
|
||||
clientMode: "Client Mode (Browser Streaming)",
|
||||
serverMode: "Server Mode (Direct Sounddevice)",
|
||||
serverInput: "Server Input Microphone",
|
||||
serverOutput: "Server Output Speaker",
|
||||
pitchMethod: "Pitch Extraction Method",
|
||||
transpose: "Transpose (Pitch Modifier)",
|
||||
transposeMale: "-24 (Male Pitch)",
|
||||
transposeNormal: "0 (Original)",
|
||||
transposeFemale: "+24 (Female/Anime Pitch)",
|
||||
|
||||
// DSP
|
||||
dspTitle: "Audio Processing Settings (DSP)",
|
||||
noiseGate: "Noise Gate (Threshold)",
|
||||
noiseGateSens: "-60 dB (Sensitive)",
|
||||
noiseGateDefault: "-40 dB (Default)",
|
||||
noiseGateStrict: "-10 dB (Strict)",
|
||||
inputGain: "Input Gain (Microphone)",
|
||||
outputGain: "Output Gain (AI Volume)",
|
||||
noiseCancel: "Noise Cancellation (Filter)",
|
||||
noiseCancelDesc: "Filters browser echo & background hum",
|
||||
bufferSize: "Buffer Size (Chunk Size - Latency vs Stability)",
|
||||
|
||||
// Visualizers
|
||||
visualizerTitle: "Real-Time Audio Visualizer",
|
||||
micSignal: "Microphone Input Signal",
|
||||
aiSignal: "AI Voice Output Signal",
|
||||
activeSignal: "Active Signal",
|
||||
pipStream: "PiP Waveform",
|
||||
pipClose: "Close PiP",
|
||||
|
||||
// HUD
|
||||
hudLatency: "RTT Latency",
|
||||
hudInference: "Inference Speed",
|
||||
hudDetector: "Voice Detector",
|
||||
hudTalking: "Speaking",
|
||||
hudSilent: "Silent",
|
||||
hudSr: "Model Frequency",
|
||||
hudHelp: "Press ? to view hotkeys menu",
|
||||
|
||||
// Shortcuts Dialog
|
||||
shortcutsTitle: "Keyboard Shortcuts Guide",
|
||||
shortcutsDesc: "Use these keyboard shortcuts to navigate the dashboard without a mouse:",
|
||||
shortcutsClose: "Close",
|
||||
shortcutConnect: "Connect / Disconnect WebSocket Server",
|
||||
shortcutStream: "Start / Stop AI Voice Changer",
|
||||
shortcutMute: "Mute / Unmute Output Audio Local Listening",
|
||||
shortcutPreset1: "Apply Preset: Instant Response (PM)",
|
||||
shortcutPreset2: "Apply Preset: High Fidelity (RMVPE)",
|
||||
shortcutHelp: "Open / Close Shortcuts Help Dialog",
|
||||
|
||||
// Premium layouts
|
||||
characterCardTitle: "Active Voice Character",
|
||||
characterAvatarDesc: "Currently loaded voice weight profile.",
|
||||
welcomeBack: "Real-Time Audio Control Center",
|
||||
currentLang: "Language",
|
||||
themeSettings: "Interface Theme & Accent",
|
||||
themeMode: "Theme Mode",
|
||||
themeDark: "Dark Mode",
|
||||
themeLight: "Light Mode",
|
||||
accentColorLabel: "Global Accent Color",
|
||||
tabCredits: "Credits",
|
||||
creditsTitle: "💖 Open Source Credits",
|
||||
creditsDescription: "ONNX VC is made possible thanks to the following incredible open-source projects and libraries:",
|
||||
liveTuningTitle: "Live Settings Tuning",
|
||||
customCanvasTitle: "Custom Canvas Visualizer",
|
||||
showMicInput: "Show Mic Input",
|
||||
showAiOutput: "Show AI Output",
|
||||
lineWidthLabel: "Line Width",
|
||||
traceDecayLabel: "Trace Decay (Fading)",
|
||||
inputLineColorLabel: "Input Line Color",
|
||||
outputLineColorLabel: "Output Line Color",
|
||||
creditCreatorTitle: "Creator & Integrator",
|
||||
creditNeuralTitle: "Neural Conversion",
|
||||
creditEngineTitle: "Inference Engine",
|
||||
creditPitchTitle: "Pitch Extraction",
|
||||
creditPipelineTitle: "Streaming Pipeline",
|
||||
creditFrameworkTitle: "Frontend Framework",
|
||||
creditDesignTitle: "Design & Animation",
|
||||
creditCreatorDesc: "Creators of the ONNX VC client interface and low-latency audio control workspace integration layer.",
|
||||
creditNeuralDesc: "The core neural network architecture for real-time voice feature retrieval and vocal conversion.",
|
||||
creditEngineDesc: "Cross-platform accelerator for machine learning models running on CPU, NVIDIA CUDA, and DirectML GPU backends.",
|
||||
creditPitchDesc: "Robust Minimum Vocal Pitch Estimation model providing highly accurate vocals pitch tracking under ambient noise.",
|
||||
creditPipelineDesc: "High-speed binary data transfer loops passing raw PCM float32 frames between the client browser and backend.",
|
||||
creditFrameworkDesc: "Modern web framework compiling React client-side components to statically optimized static exports.",
|
||||
creditDesignDesc: "Utility-first styling utility and fluid declarative animation libraries for interactive visual user interfaces."
|
||||
},
|
||||
id: {
|
||||
appTitle: "🎙️ ONNX VC",
|
||||
appSubtitle: "Pengubah suara real-time berbasis AI berlatensi ultra-rendah dengan akselerasi ONNX Runtime.",
|
||||
wsServerUrl: "URL Server WebSocket",
|
||||
wsPlaceholder: "ws://localhost:8765",
|
||||
connectionStatus: "Status Koneksi",
|
||||
disconnected: "Terputus",
|
||||
connecting: "Menghubungkan",
|
||||
connected: "Terhubung",
|
||||
connect: "Hubungkan Server",
|
||||
disconnect: "Putuskan Server",
|
||||
startChanger: "Mulai Mengubah Suara",
|
||||
stopChanger: "Hentikan Mengubah",
|
||||
listeningActive: "Mendengarkan: AKTIF",
|
||||
listeningMute: "Mendengarkan: SENYAP",
|
||||
|
||||
// Tabs
|
||||
tabDashboard: "Ruang Kerja",
|
||||
tabModel: "Setelan Model",
|
||||
tabDsp: "Audio DSP",
|
||||
tabShortcuts: "Shortcut",
|
||||
|
||||
// Model Config
|
||||
modelConfigTitle: "Konfigurasi Model & Perangkat",
|
||||
quickPresets: "Quick Presets (Profil Performa)",
|
||||
latencyPreset: "⚡ Respon Kilat (PM)",
|
||||
qualityPreset: "🎙️ Kualitas Tinggi (RMVPE)",
|
||||
selectModel: "Pilih Model Suara (RVC ONNX)",
|
||||
executionProvider: "Execution Provider (Akselerasi GPU)",
|
||||
routingMode: "Mode Routing Audio",
|
||||
clientMode: "Client Mode (Streaming Browser)",
|
||||
serverMode: "Server Mode (Direct Sounddevice)",
|
||||
serverInput: "Input Mikrofon Server",
|
||||
serverOutput: "Output Speaker Server",
|
||||
pitchMethod: "Metode Deteksi Nada (Pitch Extraction)",
|
||||
transpose: "Transpose (Pengubah Nada)",
|
||||
transposeMale: "-24 (Pria Berat)",
|
||||
transposeNormal: "0 (Asli)",
|
||||
transposeFemale: "+24 (Wanita/Anime)",
|
||||
|
||||
// DSP
|
||||
dspTitle: "Pemrosesan Audio (DSP)",
|
||||
noiseGate: "Noise Gate (Threshold)",
|
||||
noiseGateSens: "-60 dB (Sensitif)",
|
||||
noiseGateDefault: "-40 dB (Default)",
|
||||
noiseGateStrict: "-10 dB (Ketat)",
|
||||
inputGain: "Input Gain (Microphone)",
|
||||
outputGain: "Output Gain (Volume AI)",
|
||||
noiseCancel: "Peredam Bising (Noise Cancel)",
|
||||
noiseCancelDesc: "Filter gema & desah di browser",
|
||||
bufferSize: "Ukuran Buffer (Chunk Size - Latensi vs Stabilitas)",
|
||||
|
||||
// Visualizers
|
||||
visualizerTitle: "Visualisasi Waveform Live",
|
||||
micSignal: "Sinyal Mikrofon (Input)",
|
||||
aiSignal: "Hasil AI Voice (Output)",
|
||||
activeSignal: "Signal Aktif",
|
||||
pipStream: "PiP Waveform",
|
||||
pipClose: "Batal PiP",
|
||||
|
||||
// HUD
|
||||
hudLatency: "Latensi Bulat (RTT)",
|
||||
hudInference: "Kecepatan Inference",
|
||||
hudDetector: "Detektor Suara",
|
||||
hudTalking: "Bicara",
|
||||
hudSilent: "Berdiam",
|
||||
hudSr: "Frekuensi Model",
|
||||
hudHelp: "Tekan ? untuk melihat menu hotkey",
|
||||
|
||||
// Shortcuts Dialog
|
||||
shortcutsTitle: "Panduan Keyboard Shortcut",
|
||||
shortcutsDesc: "Gunakan keyboard shortcuts berikut untuk navigasi dashboard tanpa mouse:",
|
||||
shortcutsClose: "Tutup",
|
||||
shortcutConnect: "Hubungkan / Putuskan Server WebSocket",
|
||||
shortcutStream: "Mulai / Hentikan Pengubah Suara AI",
|
||||
shortcutMute: "Bungkam / Dengarkan Audio Output Lokal",
|
||||
shortcutPreset1: "Terapkan Profil: Respon Kilat (PM)",
|
||||
shortcutPreset2: "Terapkan Profil: Kualitas Tinggi (RMVPE)",
|
||||
shortcutHelp: "Buka / Tutup Dialog Panduan Shortcut",
|
||||
|
||||
// Premium layouts
|
||||
characterCardTitle: "Karakter Suara Aktif",
|
||||
characterAvatarDesc: "Profil bobot suara yang sedang dimuat saat ini.",
|
||||
welcomeBack: "Pusat Kontrol Audio Real-Time",
|
||||
currentLang: "Bahasa",
|
||||
themeSettings: "Tema Antarmuka & Aksen",
|
||||
themeMode: "Mode Tema",
|
||||
themeDark: "Mode Gelap",
|
||||
themeLight: "Mode Terang",
|
||||
accentColorLabel: "Warna Aksen Global",
|
||||
tabCredits: "Kredit Open Source",
|
||||
creditsTitle: "💖 Kredit Lisensi & Open Source",
|
||||
creditsDescription: "ONNX VC dimungkinkan berkat proyek dan pustaka open source luar biasa berikut:",
|
||||
liveTuningTitle: "Setelan Cepat Pemrosesan",
|
||||
customCanvasTitle: "Kustomisasi Canvas Visualizer",
|
||||
showMicInput: "Tampilkan Input Mic",
|
||||
showAiOutput: "Tampilkan Output AI",
|
||||
lineWidthLabel: "Ketebalan Garis",
|
||||
traceDecayLabel: "Intensitas Ekor (Trail Fading)",
|
||||
inputLineColorLabel: "Warna Garis Input",
|
||||
outputLineColorLabel: "Warna Garis Output",
|
||||
creditCreatorTitle: "Pencipta & Integrator",
|
||||
creditNeuralTitle: "Konversi Neural",
|
||||
creditEngineTitle: "Mesin Inferensi",
|
||||
creditPitchTitle: "Ekstraksi Nada Vokal",
|
||||
creditPipelineTitle: "Streaming Pipeline",
|
||||
creditFrameworkTitle: "Framework Frontend",
|
||||
creditDesignTitle: "Desain & Animasi",
|
||||
creditCreatorDesc: "Pengembang antarmuka audio ONNX VC dan pengintegrasi workspace kontrol audio real-time berlatensi ultra-rendah.",
|
||||
creditNeuralDesc: "Kerangka kerja pengubah suara berbasis AI yang menggunakan fitur retrieval untuk transfer karakter suara berlatensi rendah.",
|
||||
creditEngineDesc: "Mesin akselerasi inferensi model lintas platform untuk CPU, CUDA GPU, dan Windows DirectML GPU.",
|
||||
creditPitchDesc: "Model deteksi pitch vokal berkinerja tinggi yang presisi terhadap desau latar belakang.",
|
||||
creditPipelineDesc: "Pipa transfer data audio biner mentah PCM float32 yang berjalan lancar antara peramban dan server python.",
|
||||
creditFrameworkDesc: "Kerangka kerja aplikasi web terstruktur yang dikompilasi ke statik HTML ekspor.",
|
||||
creditDesignDesc: "Mesin animasi layout deklaratif dan utilitas CSS presisi untuk tampilan premium."
|
||||
},
|
||||
ja: {
|
||||
appTitle: "🎙️ ONNX VC",
|
||||
appSubtitle: "ONNX Runtime高速化による低遅延リアルタイムAI音声変換システム。",
|
||||
wsServerUrl: "WebSocketサーバーURL",
|
||||
wsPlaceholder: "ws://localhost:8765",
|
||||
connectionStatus: "接続状態",
|
||||
disconnected: "切断",
|
||||
connecting: "接続中...",
|
||||
connected: "接続完了",
|
||||
connect: "サーバー接続",
|
||||
disconnect: "接続解除",
|
||||
startChanger: "音声変換開始",
|
||||
stopChanger: "音声変換停止",
|
||||
listeningActive: "モニター音:ON",
|
||||
listeningMute: "モニター音:OFF",
|
||||
|
||||
// Tabs
|
||||
tabDashboard: "ワークスペース",
|
||||
tabModel: "モデル設定",
|
||||
tabDsp: "オーディオDSP",
|
||||
tabShortcuts: "ショートカット",
|
||||
|
||||
// Model Config
|
||||
modelConfigTitle: "モデルとデバイスの構成",
|
||||
quickPresets: "クイックプリセット (パフォーマンス)",
|
||||
latencyPreset: "⚡ 低遅延優先 (PM)",
|
||||
qualityPreset: "🎙️ 高音質優先 (RMVPE)",
|
||||
selectModel: "キャラクターモデルの選択 (RVC ONNX)",
|
||||
executionProvider: "実行プロバイダー (GPUアクセラレーション)",
|
||||
routingMode: "音声ルーティングモード",
|
||||
clientMode: "クライアントモード (ブラウザ再生)",
|
||||
serverMode: "サーバーモード (ハードウェア直結)",
|
||||
serverInput: "サーバー入力マイク",
|
||||
serverOutput: "サーバー出力スピーカー",
|
||||
pitchMethod: "ピッチ検出アルゴリズム",
|
||||
transpose: "ピッチ変換 (トランスポーズ)",
|
||||
transposeMale: "-24 (男声向け)",
|
||||
transposeNormal: "0 (原音)",
|
||||
transposeFemale: "+24 (女声/アニメ声)",
|
||||
|
||||
// DSP
|
||||
dspTitle: "オーディオ処理設定 (DSP)",
|
||||
noiseGate: "ノイズゲート (閾値)",
|
||||
noiseGateSens: "-60 dB (高感度)",
|
||||
noiseGateDefault: "-40 dB (推奨)",
|
||||
noiseGateStrict: "-10 dB (厳格)",
|
||||
inputGain: "入力ゲイン (マイク)",
|
||||
outputGain: "出力ゲイン (AI音量)",
|
||||
noiseCancel: "ノイズキャンセリング",
|
||||
noiseCancelDesc: "ブラウザのエコーと環境音を除去します",
|
||||
bufferSize: "バッファサイズ (遅延時間 vs 安定性)",
|
||||
|
||||
// Visualizers
|
||||
visualizerTitle: "リアルタイム波形表示",
|
||||
micSignal: "マイク入力信号",
|
||||
aiSignal: "AI音声出力信号",
|
||||
activeSignal: "音声検出中",
|
||||
pipStream: "PiP波形ウィンドウ",
|
||||
pipClose: "PiPを閉じる",
|
||||
|
||||
// HUD
|
||||
hudLatency: "応答速度 (RTT)",
|
||||
hudInference: "推論速度",
|
||||
hudDetector: "音声検出",
|
||||
hudTalking: "発話中",
|
||||
hudSilent: "無音",
|
||||
hudSr: "モデルサンプリングレート",
|
||||
hudHelp: "?キーでショートカットヘルプを表示",
|
||||
|
||||
// Shortcuts Dialog
|
||||
shortcutsTitle: "キーボードショートカット一覧",
|
||||
shortcutsDesc: "キーボードを使ってマウスなしで素早く操作できます:",
|
||||
shortcutsClose: "閉じる",
|
||||
shortcutConnect: "WebSocketサーバーの接続 / 切断",
|
||||
shortcutStream: "AI音声変換の開始 / 停止",
|
||||
shortcutMute: "ローカル出力のミュート / 解除",
|
||||
shortcutPreset1: "プリセット適用:低遅延優先 (PM)",
|
||||
shortcutPreset2: "プリセット適用:高音質優先 (RMVPE)",
|
||||
shortcutHelp: "ショートカット一覧の表示 / 非表示",
|
||||
|
||||
// Premium layouts
|
||||
characterCardTitle: "現在のボイスモデル",
|
||||
characterAvatarDesc: "現在ロードされている音声のキャラクタープロファイルです。",
|
||||
welcomeBack: "リアルタイムオーディオコントロールセンター",
|
||||
currentLang: "言語",
|
||||
themeSettings: "テーマとアクセント",
|
||||
themeMode: "テーマモード",
|
||||
themeDark: "ダークモード",
|
||||
themeLight: "ライトモード",
|
||||
accentColorLabel: "グローバルアクセントカラー",
|
||||
tabCredits: "オープンソース",
|
||||
creditsTitle: "💖 オープンソースクレジット",
|
||||
creditsDescription: "ONNX VCは、以下の素晴らしいオープンソースプロジェクトとライブラリのおかげで実現しました。",
|
||||
liveTuningTitle: "常用パラメータ微調整",
|
||||
customCanvasTitle: "カスタムビジュアライザ",
|
||||
showMicInput: "マイク入力を表示",
|
||||
showAiOutput: "AI出力を表示",
|
||||
lineWidthLabel: "線の太さ",
|
||||
traceDecayLabel: "残像フェード率",
|
||||
inputLineColorLabel: "入力線の色",
|
||||
outputLineColorLabel: "出力線の色",
|
||||
creditCreatorTitle: "開発・統合元",
|
||||
creditNeuralTitle: "ニューラル音声変換",
|
||||
creditEngineTitle: "推推論エンジン",
|
||||
creditPitchTitle: "ピッチ検出",
|
||||
creditPipelineTitle: "ストリーミング・パイプライン",
|
||||
creditFrameworkTitle: "フロントエンドフレームワーク",
|
||||
creditDesignTitle: "デザインとアニメーション",
|
||||
creditCreatorDesc: "ONNX VCクライアントインターフェースおよび超低遅延リアルタイムオーディオ制御ワークスペースの統合開発チーム。",
|
||||
creditNeuralDesc: "リアルタイムの音声特徴抽出および声質変換のためのコアニューラルネットワークアーキテクチャ。",
|
||||
creditEngineDesc: "CPU、NVIDIA CUDA、およびWindows DirectML GPUバックエンド上で動作する、クロスプラットフォームの推論高速化エンジン。",
|
||||
creditPitchDesc: "周囲のノイズ下でも高精度にボーカルのピッチ追跡を行うことができる高性能ピッチ推定モデル。",
|
||||
creditPipelineDesc: "ブラウザクライアントとPythonサーバー間で生のPCM float32フレームを高速に送受信するバイナリデータパイプライン。",
|
||||
creditFrameworkDesc: "Reactクライアントコンポーネントを静的に最適化されたHTMLにエクスポートするモダンウェブフレームワーク。",
|
||||
creditDesignDesc: "インタラクティブで高品質なUIデザインのための、ユーティリティ優先CSSおよび宣言的アニメーションライブラリ。"
|
||||
},
|
||||
zh: {
|
||||
appTitle: "🎙️ ONNX VC",
|
||||
appSubtitle: "基于 ONNX 运行时加速的低延迟实时 AI 变声器系统。",
|
||||
wsServerUrl: "WebSocket 服务器地址",
|
||||
wsPlaceholder: "ws://localhost:8765",
|
||||
connectionStatus: "连接状态",
|
||||
disconnected: "已断开",
|
||||
connecting: "连接中...",
|
||||
connected: "已连接",
|
||||
connect: "连接服务器",
|
||||
disconnect: "断开连接",
|
||||
startChanger: "开启变声",
|
||||
stopChanger: "停止变声",
|
||||
listeningActive: "声音监听:开启",
|
||||
listeningMute: "声音监听:静音",
|
||||
|
||||
// Tabs
|
||||
tabDashboard: "控制工作台",
|
||||
tabModel: "模型设置",
|
||||
tabDsp: "音频 DSP",
|
||||
tabShortcuts: "快捷键",
|
||||
|
||||
// Model Config
|
||||
modelConfigTitle: "变声模型与硬件设备配置",
|
||||
quickPresets: "快速预设 (性能配置)",
|
||||
latencyPreset: "⚡ 极速响应 (PM)",
|
||||
qualityPreset: "🎙️ 高清音质 (RMVPE)",
|
||||
selectModel: "选择声音模型 (RVC ONNX)",
|
||||
executionProvider: "运行加速提供商 (GPU 加速)",
|
||||
routingMode: "音频路由模式",
|
||||
clientMode: "客户端模式 (浏览器音频流转换)",
|
||||
serverMode: "服务器模式 (直连服务端硬件)",
|
||||
serverInput: "服务器输入麦克风",
|
||||
serverOutput: "服务器输出扬声器",
|
||||
pitchMethod: "基频检测算法 (Pitch)",
|
||||
transpose: "变调参数 (Transpose)",
|
||||
transposeMale: "-24 (男声声调)",
|
||||
transposeNormal: "0 (原音)",
|
||||
transposeFemale: "+24 (女声/动漫声调)",
|
||||
|
||||
// DSP
|
||||
dspTitle: "音频效果器配置 (DSP)",
|
||||
noiseGate: "噪声门限阈值 (Noise Gate)",
|
||||
noiseGateSens: "-60 dB (灵敏)",
|
||||
noiseGateDefault: "-40 dB (默认)",
|
||||
noiseGateStrict: "-10 dB (严格)",
|
||||
inputGain: "输入增益 (麦克风音量)",
|
||||
outputGain: "输出增益 (变声后音量)",
|
||||
noiseCancel: "回声抑噪过滤",
|
||||
noiseCancelDesc: "过滤浏览器的回声和杂音",
|
||||
bufferSize: "缓冲区大小 (延迟时间 vs 稳定性)",
|
||||
|
||||
// Visualizers
|
||||
visualizerTitle: "实时音频波形图",
|
||||
micSignal: "麦克风输入波形",
|
||||
aiSignal: "AI变声输出波形",
|
||||
activeSignal: "正在输入",
|
||||
pipStream: "画中画波形图",
|
||||
pipClose: "关闭画中画",
|
||||
|
||||
// HUD
|
||||
hudLatency: "双向延迟 (RTT)",
|
||||
hudInference: "推理用时",
|
||||
hudDetector: "声控指示器",
|
||||
hudTalking: "检测到讲话",
|
||||
hudSilent: "静音中",
|
||||
hudSr: "模型音频采样率",
|
||||
hudHelp: "按 ? 键打开快捷键指南",
|
||||
|
||||
// Shortcuts Dialog
|
||||
shortcutsTitle: "键盘快捷键指南",
|
||||
shortcutsDesc: "使用键盘快捷键可以在没有鼠标的情况下极速控制工作台:",
|
||||
shortcutsClose: "关闭",
|
||||
shortcutConnect: "连接 / 断开 WebSocket 服务器",
|
||||
shortcutStream: "开启 / 停止 AI 变声器",
|
||||
shortcutMute: "静音 / 开启本地输出监听",
|
||||
shortcutPreset1: "加载预设:极速响应 (PM)",
|
||||
shortcutPreset2: "加载预设:高清音质 (RMVPE)",
|
||||
shortcutHelp: "打开 / 关闭快捷键帮助面板",
|
||||
|
||||
// Premium layouts
|
||||
characterCardTitle: "当前声音人物",
|
||||
characterAvatarDesc: "当前正在承载的音频权重包与神经网络特征。",
|
||||
welcomeBack: "实时音频变声控制台",
|
||||
currentLang: "语言",
|
||||
themeSettings: "界面主题与强调色",
|
||||
themeMode: "主题模式",
|
||||
themeDark: "深色模式",
|
||||
themeLight: "浅色模式",
|
||||
accentColorLabel: "全局强调颜色",
|
||||
tabCredits: "开源鸣谢",
|
||||
creditsTitle: "💖 开源软件鸣谢",
|
||||
creditsDescription: "ONNX VC 的诞生离不开以下优秀的开源项目与函数库的支持:",
|
||||
liveTuningTitle: "常用变声微调",
|
||||
customCanvasTitle: "画布自定设置",
|
||||
showMicInput: "显示麦克风输入",
|
||||
showAiOutput: "显示AI变声输出",
|
||||
lineWidthLabel: "线条宽度",
|
||||
traceDecayLabel: "余晖消退率 (渐变)",
|
||||
inputLineColorLabel: "输入线颜色",
|
||||
outputLineColorLabel: "输出线颜色",
|
||||
creditCreatorTitle: "核心集成开发商",
|
||||
creditNeuralTitle: "声线转换算法",
|
||||
creditEngineTitle: "深度学习推理引擎",
|
||||
creditPitchTitle: "基频音高提取",
|
||||
creditPipelineTitle: "数据流通通道",
|
||||
creditFrameworkTitle: "前端应用框架",
|
||||
creditDesignTitle: "界面设计与动效",
|
||||
creditCreatorDesc: "ONNX VC 客户端界面设计与超低延迟音频控制工作台的集成开发者。",
|
||||
creditNeuralDesc: "基于检索的神经网络架构,用于实现低延迟的实时声音特征提取与音色转换。",
|
||||
creditEngineDesc: "跨平台的机器学习模型推理加速引擎,支持 CPU、NVIDIA CUDA 以及 Windows DirectML GPU 后端。",
|
||||
creditPitchDesc: "高性能人声基频检测模型,在背景嘈杂的环境下仍能提供极高精度的音高跟踪。",
|
||||
creditPipelineDesc: "在浏览器客户端与 Python 服务端之间高速传输原始 PCM Float32 音频帧的双向二进制数据通道。",
|
||||
creditFrameworkDesc: "现代网页开发框架,支持将 React 客户端组件编译并打包为高度优化的静态资源导出。",
|
||||
creditDesignDesc: "功能类优先 CSS 框架与流式声明式动画库,用以打造流畅的高级交互式视觉界面。"
|
||||
},
|
||||
es: {
|
||||
appTitle: "🎙️ ONNX VC",
|
||||
appSubtitle: "Modulador de voz por IA en tiempo real y baja latencia acelerado por ONNX Runtime.",
|
||||
wsServerUrl: "URL del Servidor WebSocket",
|
||||
wsPlaceholder: "ws://localhost:8765",
|
||||
connectionStatus: "Estado de la Conexión",
|
||||
disconnected: "Desconectado",
|
||||
connecting: "Conectando...",
|
||||
connected: "Conectado",
|
||||
connect: "Conectar Servidor",
|
||||
disconnect: "Desconectar Servidor",
|
||||
startChanger: "Iniciar Modulador",
|
||||
stopChanger: "Detener Modulador",
|
||||
listeningActive: "Escucha: ACTIVA",
|
||||
listeningMute: "Escucha: SILENCIADO",
|
||||
|
||||
// Tabs
|
||||
tabDashboard: "Espacio Trabajo",
|
||||
tabModel: "Ajustes Modelo",
|
||||
tabDsp: "Audio DSP",
|
||||
tabShortcuts: "Atajos Teclado",
|
||||
|
||||
// Model Config
|
||||
modelConfigTitle: "Configuración de Modelo y Dispositivo",
|
||||
quickPresets: "Ajustes Rápidos (Perfil de Rendimiento)",
|
||||
latencyPreset: "⚡ Respuesta Instantánea (PM)",
|
||||
qualityPreset: "🎙️ Alta Fidelidad (RMVPE)",
|
||||
selectModel: "Seleccionar Modelo de Voz (RVC ONNX)",
|
||||
executionProvider: "Proveedor de Ejecución (Aceleración GPU)",
|
||||
routingMode: "Modo de Ruta de Audio",
|
||||
clientMode: "Modo Cliente (Streaming en Navegador)",
|
||||
serverMode: "Modo Servidor (Sounddevice Directo)",
|
||||
serverInput: "Micrófono de Entrada del Servidor",
|
||||
serverOutput: "Altavoz de Salida del Servidor",
|
||||
pitchMethod: "Método de Extracción de Tono",
|
||||
transpose: "Transposición (Modificador de Tono)",
|
||||
transposeMale: "-24 (Tono Grave Masculino)",
|
||||
transposeNormal: "0 (Original)",
|
||||
transposeFemale: "+24 (Tono Agudo/Anime)",
|
||||
|
||||
// DSP
|
||||
dspTitle: "Configuración de Procesamiento de Audio (DSP)",
|
||||
noiseGate: "Puerta de Ruido (Umbral)",
|
||||
noiseGateSens: "-60 dB (Sensible)",
|
||||
noiseGateDefault: "-40 dB (Predeterminado)",
|
||||
noiseGateStrict: "-10 dB (Estricto)",
|
||||
inputGain: "Ganancia de Entrada (Micrófono)",
|
||||
outputGain: "Ganancia de Salida (Volumen IA)",
|
||||
noiseCancel: "Cancelación de Ruido (Filtro)",
|
||||
noiseCancelDesc: "Filtra el eco y el zumbido de fondo",
|
||||
bufferSize: "Tamaño de Búfer (Tamaño de Chunk - Latencia vs Estabilidad)",
|
||||
|
||||
// Visualizers
|
||||
visualizerTitle: "Visualizador de Ondas de Audio",
|
||||
micSignal: "Señal de Entrada del Micrófono",
|
||||
aiSignal: "Señal de Salida de Voz IA",
|
||||
activeSignal: "Señal Activa",
|
||||
pipStream: "Forma de Onda PiP",
|
||||
pipClose: "Cerrar PiP",
|
||||
|
||||
// HUD
|
||||
hudLatency: "Latencia RTT",
|
||||
hudInference: "Velocidad de Inferencia",
|
||||
hudDetector: "Detector de Voz",
|
||||
hudTalking: "Hablando",
|
||||
hudSilent: "Silencio",
|
||||
hudSr: "Frecuencia del Modelo",
|
||||
hudHelp: "Presione ? para ver el menú de atajos",
|
||||
|
||||
// Shortcuts Dialog
|
||||
shortcutsTitle: "Guía de Atajos de Teclado",
|
||||
shortcutsDesc: "Utilice los siguientes atajos para controlar el panel de control sin el mouse:",
|
||||
shortcutsClose: "Cerrar",
|
||||
shortcutConnect: "Conectar / Desconectar Servidor WebSocket",
|
||||
shortcutStream: "Iniciar / Detener Modulador de Voz IA",
|
||||
shortcutMute: "Silenciar / Activar Escucha Local de Salida",
|
||||
shortcutPreset1: "Cargar Ajuste: Respuesta Instantánea (PM)",
|
||||
shortcutPreset2: "Cargar Ajuste: Alta Fidelidad (RMVPE)",
|
||||
shortcutHelp: "Abrir / Cerrar Diálogo de Ayuda de Atajos",
|
||||
|
||||
// Premium layouts
|
||||
characterCardTitle: "Voz del Personaje Activo",
|
||||
characterAvatarDesc: "Perfil de pesos de voz cargado actualmente.",
|
||||
welcomeBack: "Centro de Control de Audio en Tiempo Real",
|
||||
currentLang: "Idioma",
|
||||
themeSettings: "Tema de Interfaz y Acento",
|
||||
themeMode: "Modo de Tema",
|
||||
themeDark: "Modo Oscuro",
|
||||
themeLight: "Modo Claro",
|
||||
accentColorLabel: "Color de Acento Global",
|
||||
tabCredits: "Créditos",
|
||||
creditsTitle: "💖 Créditos de Código Abierto",
|
||||
creditsDescription: "ONNX VC es posible gracias a los siguientes increíbles proyectos y bibliotecas de código abierto:",
|
||||
liveTuningTitle: "Ajustes en Vivo",
|
||||
customCanvasTitle: "Ajustes de Canvas",
|
||||
showMicInput: "Mostrar Entrada Mic",
|
||||
showAiOutput: "Mostrar Salida IA",
|
||||
lineWidthLabel: "Grosor de Línea",
|
||||
traceDecayLabel: "Decaimiento del Trazo",
|
||||
inputLineColorLabel: "Color de Línea de Entrada",
|
||||
outputLineColorLabel: "Color de Línea de Salida",
|
||||
creditCreatorTitle: "Creador e Integrador",
|
||||
creditNeuralTitle: "Conversión Neuronal",
|
||||
creditEngineTitle: "Motor de Inferencia",
|
||||
creditPitchTitle: "Extracción de Tono",
|
||||
creditPipelineTitle: "Línea de Transmisión",
|
||||
creditFrameworkTitle: "Marco Frontend",
|
||||
creditDesignTitle: "Diseño y Animación",
|
||||
creditCreatorDesc: "Creadores de la interfaz de cliente ONNX VC e integradores del entorno de control de audio en tiempo real.",
|
||||
creditNeuralDesc: "Arquitectura central de red neuronal para la extracción de características de voz y conversión vocal.",
|
||||
creditEngineDesc: "Acelerador multiplataforma de inferencia de modelos de IA para CPU, GPU CUDA y GPU DirectML de Windows.",
|
||||
creditPitchDesc: "Modelo robusto de estimación de tono mínimo para un seguimiento de tono vocal de alta precisión.",
|
||||
creditPipelineDesc: "Tubería binaria de alta velocidad para la transferencia de tramas PCM float32 nativas entre el cliente y el servidor.",
|
||||
creditFrameworkDesc: "Marco de desarrollo web moderno que compila componentes de React para exportaciones estáticas optimizadas.",
|
||||
creditDesignDesc: "Utilidad de estilos CSS y librerías de animación declarativa para interfaces de usuario interactivas de primera calidad."
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
1:"$Sreact.fragment"
|
||||
2:I[47257,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"ClientPageRoot"]
|
||||
3:I[52683,["/_next/static/chunks/0dbhjjzl8qfwv.js","/_next/static/chunks/01yjdu1pd4sew.js"],"default"]
|
||||
6:I[97367,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"OutletBoundary"]
|
||||
7:"$Sreact.suspense"
|
||||
0:{"rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","script","script-0",{"src":"/_next/static/chunks/01yjdu1pd4sew.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"ncF6Dp_0OCMhTp1KUVnS4"}
|
||||
4:{}
|
||||
5:"$0:rsc:props:children:0:props:serverProvidedParams:params"
|
||||
8:null
|
||||
@@ -0,0 +1,20 @@
|
||||
1:"$Sreact.fragment"
|
||||
2:I[39756,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"default"]
|
||||
3:I[37457,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"default"]
|
||||
4:I[47257,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"ClientPageRoot"]
|
||||
5:I[52683,["/_next/static/chunks/0dbhjjzl8qfwv.js","/_next/static/chunks/01yjdu1pd4sew.js"],"default"]
|
||||
8:I[97367,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"OutletBoundary"]
|
||||
9:"$Sreact.suspense"
|
||||
b:I[97367,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"ViewportBoundary"]
|
||||
d:I[97367,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"MetadataBoundary"]
|
||||
f:I[68027,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"default",1]
|
||||
:HL["/_next/static/chunks/0~n~ndg8g34g9.css","style"]
|
||||
:HL["/_next/static/media/797e433ab948586e-s.p.09zddjkbdep5a.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
||||
:HL["/_next/static/media/caa3a2e1cccd8315-s.p.09~u27dqhyhd6.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
||||
0:{"P":null,"c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0~n~ndg8g34g9.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0dbhjjzl8qfwv.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","className":"geist_a71539c9-module__T19VSG__variable geist_mono_8d43a2aa-module__8Li5zG__variable h-full antialiased","children":["$","body",null,{"className":"min-h-full flex flex-col","children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":[["$","$1","c",{"children":[["$","$L4",null,{"Component":"$5","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@6","$@7"]}}],[["$","script","script-0",{"src":"/_next/static/chunks/01yjdu1pd4sew.js","async":true,"nonce":"$undefined"}]],["$","$L8",null,{"children":["$","$9",null,{"name":"Next.MetadataOutlet","children":"$@a"}]}]]}],{},null,false,null]},null,false,null],["$","$1","h",{"children":[null,["$","$Lb",null,{"children":"$Lc"}],["$","div",null,{"hidden":true,"children":["$","$Ld",null,{"children":["$","$9",null,{"name":"Next.Metadata","children":"$Le"}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],false]],"m":"$undefined","G":["$f",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0~n~ndg8g34g9.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"ncF6Dp_0OCMhTp1KUVnS4"}
|
||||
6:{}
|
||||
7:"$0:f:0:1:1:children:0:props:children:0:props:serverProvidedParams:params"
|
||||
c:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
||||
10:I[27201,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"IconMark"]
|
||||
a:null
|
||||
e:[["$","title","0",{"children":"🎙️ ONNX VC - Real-Time AI Voice Changer"}],["$","meta","1",{"name":"description","content":"ONNX VC - Pengubah suara real-time berbasis AI berlatensi ultra-rendah dengan ONNX Runtime."}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0x3dzn~oxb6tn.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L10","3",{}]]
|
||||
@@ -0,0 +1,6 @@
|
||||
1:"$Sreact.fragment"
|
||||
2:I[97367,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"ViewportBoundary"]
|
||||
3:I[97367,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"MetadataBoundary"]
|
||||
4:"$Sreact.suspense"
|
||||
5:I[27201,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"IconMark"]
|
||||
0:{"rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"🎙️ ONNX VC - Real-Time AI Voice Changer"}],["$","meta","1",{"name":"description","content":"ONNX VC - Pengubah suara real-time berbasis AI berlatensi ultra-rendah dengan ONNX Runtime."}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0x3dzn~oxb6tn.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L5","3",{}]]}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"ncF6Dp_0OCMhTp1KUVnS4"}
|
||||
@@ -0,0 +1,5 @@
|
||||
1:"$Sreact.fragment"
|
||||
2:I[39756,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"default"]
|
||||
3:I[37457,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"default"]
|
||||
:HL["/_next/static/chunks/0~n~ndg8g34g9.css","style"]
|
||||
0:{"rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0~n~ndg8g34g9.css","precedence":"next"}],["$","script","script-0",{"src":"/_next/static/chunks/0dbhjjzl8qfwv.js","async":true}]],["$","html",null,{"lang":"en","className":"geist_a71539c9-module__T19VSG__variable geist_mono_8d43a2aa-module__8Li5zG__variable h-full antialiased","children":["$","body",null,{"className":"min-h-full flex flex-col","children":["$","$L2",null,{"parallelRouterKey":"children","template":["$","$L3",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"ncF6Dp_0OCMhTp1KUVnS4"}
|
||||
@@ -0,0 +1,4 @@
|
||||
:HL["/_next/static/chunks/0~n~ndg8g34g9.css","style"]
|
||||
:HL["/_next/static/media/797e433ab948586e-s.p.09zddjkbdep5a.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
||||
:HL["/_next/static/media/caa3a2e1cccd8315-s.p.09~u27dqhyhd6.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
||||
0:{"tree":{"name":"","param":null,"prefetchHints":16,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":0,"slots":null}}},"staleTime":300,"buildId":"ncF6Dp_0OCMhTp1KUVnS4"}
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,11 @@
|
||||
self.__BUILD_MANIFEST = {
|
||||
"__rewrites": {
|
||||
"afterFiles": [],
|
||||
"beforeFiles": [],
|
||||
"fallback": []
|
||||
},
|
||||
"sortedPages": [
|
||||
"/_app",
|
||||
"/_error"
|
||||
]
|
||||
};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
|
||||
@@ -0,0 +1 @@
|
||||
self.__MIDDLEWARE_MATCHERS = [];self.__MIDDLEWARE_MATCHERS_CB && self.__MIDDLEWARE_MATCHERS_CB()
|
||||
@@ -0,0 +1 @@
|
||||
self.__SSG_MANIFEST=new Set([]);self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
|
||||
@@ -0,0 +1,16 @@
|
||||
1:"$Sreact.fragment"
|
||||
2:I[39756,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"default"]
|
||||
3:I[37457,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"default"]
|
||||
4:I[97367,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"OutletBoundary"]
|
||||
5:"$Sreact.suspense"
|
||||
8:I[97367,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"ViewportBoundary"]
|
||||
a:I[97367,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"MetadataBoundary"]
|
||||
c:I[68027,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"default",1]
|
||||
:HL["/_next/static/chunks/0~n~ndg8g34g9.css","style"]
|
||||
0:{"P":null,"c":["","_not-found"],"q":"","i":false,"f":[[["",{"children":["/_not-found",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0~n~ndg8g34g9.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0dbhjjzl8qfwv.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","className":"geist_a71539c9-module__T19VSG__variable geist_mono_8d43a2aa-module__8Li5zG__variable h-full antialiased","children":["$","body",null,{"className":"min-h-full flex flex-col","children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],null,["$","$L4",null,{"children":["$","$5",null,{"name":"Next.MetadataOutlet","children":"$@6"}]}]]}],{},null,false,null]},null,false,"$@7"]},null,false,null],["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L8",null,{"children":"$L9"}],["$","div",null,{"hidden":true,"children":["$","$La",null,{"children":["$","$5",null,{"name":"Next.Metadata","children":"$Lb"}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],false]],"m":"$undefined","G":["$c",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0~n~ndg8g34g9.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"ncF6Dp_0OCMhTp1KUVnS4"}
|
||||
d:[]
|
||||
7:"$Wd"
|
||||
9:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
||||
e:I[27201,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"IconMark"]
|
||||
6:null
|
||||
b:[["$","title","0",{"children":"🎙️ ONNX VC - Real-Time AI Voice Changer"}],["$","meta","1",{"name":"description","content":"ONNX VC - Pengubah suara real-time berbasis AI berlatensi ultra-rendah dengan ONNX Runtime."}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0x3dzn~oxb6tn.ico","sizes":"256x256","type":"image/x-icon"}],["$","$Le","3",{}]]
|
||||
@@ -0,0 +1,16 @@
|
||||
1:"$Sreact.fragment"
|
||||
2:I[39756,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"default"]
|
||||
3:I[37457,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"default"]
|
||||
4:I[97367,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"OutletBoundary"]
|
||||
5:"$Sreact.suspense"
|
||||
8:I[97367,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"ViewportBoundary"]
|
||||
a:I[97367,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"MetadataBoundary"]
|
||||
c:I[68027,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"default",1]
|
||||
:HL["/_next/static/chunks/0~n~ndg8g34g9.css","style"]
|
||||
0:{"P":null,"c":["","_not-found"],"q":"","i":false,"f":[[["",{"children":["/_not-found",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0~n~ndg8g34g9.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0dbhjjzl8qfwv.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","className":"geist_a71539c9-module__T19VSG__variable geist_mono_8d43a2aa-module__8Li5zG__variable h-full antialiased","children":["$","body",null,{"className":"min-h-full flex flex-col","children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],null,["$","$L4",null,{"children":["$","$5",null,{"name":"Next.MetadataOutlet","children":"$@6"}]}]]}],{},null,false,null]},null,false,"$@7"]},null,false,null],["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L8",null,{"children":"$L9"}],["$","div",null,{"hidden":true,"children":["$","$La",null,{"children":["$","$5",null,{"name":"Next.Metadata","children":"$Lb"}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],false]],"m":"$undefined","G":["$c",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0~n~ndg8g34g9.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"ncF6Dp_0OCMhTp1KUVnS4"}
|
||||
d:[]
|
||||
7:"$Wd"
|
||||
9:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
||||
e:I[27201,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"IconMark"]
|
||||
6:null
|
||||
b:[["$","title","0",{"children":"🎙️ ONNX VC - Real-Time AI Voice Changer"}],["$","meta","1",{"name":"description","content":"ONNX VC - Pengubah suara real-time berbasis AI berlatensi ultra-rendah dengan ONNX Runtime."}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0x3dzn~oxb6tn.ico","sizes":"256x256","type":"image/x-icon"}],["$","$Le","3",{}]]
|
||||
@@ -0,0 +1,6 @@
|
||||
1:"$Sreact.fragment"
|
||||
2:I[97367,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"ViewportBoundary"]
|
||||
3:I[97367,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"MetadataBoundary"]
|
||||
4:"$Sreact.suspense"
|
||||
5:I[27201,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"IconMark"]
|
||||
0:{"rsc":["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"🎙️ ONNX VC - Real-Time AI Voice Changer"}],["$","meta","1",{"name":"description","content":"ONNX VC - Pengubah suara real-time berbasis AI berlatensi ultra-rendah dengan ONNX Runtime."}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0x3dzn~oxb6tn.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L5","3",{}]]}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"ncF6Dp_0OCMhTp1KUVnS4"}
|
||||
@@ -0,0 +1,5 @@
|
||||
1:"$Sreact.fragment"
|
||||
2:I[39756,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"default"]
|
||||
3:I[37457,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"default"]
|
||||
:HL["/_next/static/chunks/0~n~ndg8g34g9.css","style"]
|
||||
0:{"rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0~n~ndg8g34g9.css","precedence":"next"}],["$","script","script-0",{"src":"/_next/static/chunks/0dbhjjzl8qfwv.js","async":true}]],["$","html",null,{"lang":"en","className":"geist_a71539c9-module__T19VSG__variable geist_mono_8d43a2aa-module__8Li5zG__variable h-full antialiased","children":["$","body",null,{"className":"min-h-full flex flex-col","children":["$","$L2",null,{"parallelRouterKey":"children","template":["$","$L3",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"ncF6Dp_0OCMhTp1KUVnS4"}
|
||||
@@ -0,0 +1,5 @@
|
||||
1:"$Sreact.fragment"
|
||||
2:I[39756,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"default"]
|
||||
3:I[37457,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"default"]
|
||||
4:[]
|
||||
0:{"rsc":["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","template":["$","$L3",null,{}]}]]}],"isPartial":false,"staleTime":300,"varyParams":"$W4","buildId":"ncF6Dp_0OCMhTp1KUVnS4"}
|
||||
@@ -0,0 +1,5 @@
|
||||
1:"$Sreact.fragment"
|
||||
2:I[97367,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"OutletBoundary"]
|
||||
3:"$Sreact.suspense"
|
||||
0:{"rsc":["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],null,["$","$L2",null,{"children":["$","$3",null,{"name":"Next.MetadataOutlet","children":"$@4"}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"ncF6Dp_0OCMhTp1KUVnS4"}
|
||||
4:null
|
||||
@@ -0,0 +1,2 @@
|
||||
:HL["/_next/static/chunks/0~n~ndg8g34g9.css","style"]
|
||||
0:{"tree":{"name":"","param":null,"prefetchHints":16,"slots":{"children":{"name":"/_not-found","param":null,"prefetchHints":0,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":0,"slots":null}}}}},"staleTime":300,"buildId":"ncF6Dp_0OCMhTp1KUVnS4"}
|
||||
@@ -1,744 +0,0 @@
|
||||
/**
|
||||
* Omni Real-Time Voice Changer - Client App
|
||||
* High-performance browser-based mic streaming and RVC playback.
|
||||
*/
|
||||
|
||||
// UI Elements
|
||||
const wsUrlInput = document.getElementById('ws_url');
|
||||
const connectionStatus = document.getElementById('connection_status');
|
||||
const connectBtn = document.getElementById('connect_btn');
|
||||
const streamBtn = document.getElementById('stream_btn');
|
||||
const playToggleBtn = document.getElementById('play_toggle_btn');
|
||||
|
||||
const modelSelect = document.getElementById('model_select');
|
||||
const deviceSelect = document.getElementById('device_select');
|
||||
const transposeSlider = document.getElementById('transpose_slider');
|
||||
const transposeVal = document.getElementById('transpose_val');
|
||||
const gateSlider = document.getElementById('gate_slider');
|
||||
const gateVal = document.getElementById('gate_val');
|
||||
const inputGainSlider = document.getElementById('input_gain_slider');
|
||||
const inputGainVal = document.getElementById('input_gain_val');
|
||||
const outputGainSlider = document.getElementById('output_gain_slider');
|
||||
const outputGainVal = document.getElementById('output_gain_val');
|
||||
const chunkSelect = document.getElementById('chunk_select');
|
||||
const noiseCancelCheckbox = document.getElementById('noise_cancel_checkbox');
|
||||
const routingModeSelect = document.getElementById('routing_mode_select');
|
||||
const hardwareDevicesPanel = document.getElementById('hardware_devices_panel');
|
||||
const serverInputSelect = document.getElementById('server_input_select');
|
||||
const serverOutputSelect = document.getElementById('server_output_select');
|
||||
const browserNoiseCancelGroup = document.getElementById('browser_noise_cancel_group');
|
||||
|
||||
const presetLatencyBtn = document.getElementById('preset_latency_btn');
|
||||
const presetQualityBtn = document.getElementById('preset_quality_btn');
|
||||
|
||||
const inputCanvas = document.getElementById('input_canvas');
|
||||
const outputCanvas = document.getElementById('output_canvas');
|
||||
|
||||
const hudLatency = document.getElementById('hud_latency');
|
||||
const hudTime = document.getElementById('hud_time');
|
||||
const hudGateStatus = document.getElementById('hud_gate_status');
|
||||
const hudSr = document.getElementById('hud_sr');
|
||||
|
||||
// Audio Visualizer Contexts
|
||||
const inputCtx = inputCanvas.getContext('2d');
|
||||
const outputCtx = outputCanvas.getContext('2d');
|
||||
|
||||
// Web Audio State
|
||||
let audioContext = null;
|
||||
let micStream = null;
|
||||
let micSourceNode = null;
|
||||
let scriptProcessorNode = null;
|
||||
let micAccumulator = new Float32Array(0); // Accumulates audio for large/custom chunk sizes
|
||||
|
||||
// WebSocket State
|
||||
let socket = null;
|
||||
let isStreaming = false;
|
||||
let playOutput = true;
|
||||
let targetSampleRate = 40000; // RVC Model default, updated dynamically
|
||||
|
||||
// Playback Sync State
|
||||
let nextPlaybackTime = 0;
|
||||
const safetyDelay = 0.10; // 100ms buffer to absorb network/websocket jitter (increased for perfect smoothness!)
|
||||
|
||||
// Latency Tracking Queues
|
||||
let sentTimestamps = [];
|
||||
const maxSentLogs = 50;
|
||||
|
||||
// --- SMOOTH VISUALIZER (Rolling Display Buffers + RAF loop) ---
|
||||
// Fixed display buffer size: ~85ms window looks great at all chunk sizes.
|
||||
const VIS_DISPLAY_SIZE = 4096;
|
||||
let inputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE); // rolling input (updated ~85ms)
|
||||
let outputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE); // fallback for hardware mode
|
||||
let rafHandle = null;
|
||||
|
||||
// Time-synced output queue: each entry = { data: Float32Array, startTime: number (audioCtx seconds) }
|
||||
let outputChunkQueue = [];
|
||||
|
||||
function pushToDisplayBuf(displayBuf, newSamples) {
|
||||
if (newSamples.length >= VIS_DISPLAY_SIZE) {
|
||||
displayBuf.set(newSamples.slice(newSamples.length - VIS_DISPLAY_SIZE));
|
||||
} else {
|
||||
displayBuf.copyWithin(0, newSamples.length);
|
||||
displayBuf.set(newSamples, VIS_DISPLAY_SIZE - newSamples.length);
|
||||
}
|
||||
}
|
||||
|
||||
// Build a VIS_DISPLAY_SIZE window of output samples ending at audioContext.currentTime
|
||||
function buildTimeSyncedOutputBuf() {
|
||||
if (!audioContext || outputChunkQueue.length === 0) return outputDisplayBuf;
|
||||
|
||||
const now = audioContext.currentTime;
|
||||
const windowDuration = VIS_DISPLAY_SIZE / targetSampleRate;
|
||||
const windowStart = now - windowDuration;
|
||||
|
||||
// Drop chunks that ended before our window start
|
||||
while (outputChunkQueue.length > 0) {
|
||||
const c = outputChunkQueue[0];
|
||||
if (c.startTime + c.data.length / targetSampleRate < windowStart) {
|
||||
outputChunkQueue.shift();
|
||||
} else break;
|
||||
}
|
||||
|
||||
const out = new Float32Array(VIS_DISPLAY_SIZE);
|
||||
for (const chunk of outputChunkQueue) {
|
||||
const chunkEnd = chunk.startTime + chunk.data.length / targetSampleRate;
|
||||
// Overlap between [windowStart, now] and [chunk.startTime, chunkEnd]
|
||||
const overlapStart = Math.max(windowStart, chunk.startTime);
|
||||
const overlapEnd = Math.min(now, chunkEnd);
|
||||
if (overlapStart >= overlapEnd) continue;
|
||||
|
||||
const srcOffset = Math.floor((overlapStart - chunk.startTime) * targetSampleRate);
|
||||
const destOffset = Math.floor((overlapStart - windowStart) * targetSampleRate);
|
||||
const count = Math.floor((overlapEnd - overlapStart) * targetSampleRate);
|
||||
const safeCount = Math.min(count,
|
||||
chunk.data.length - srcOffset,
|
||||
VIS_DISPLAY_SIZE - destOffset);
|
||||
if (safeCount > 0) out.set(chunk.data.subarray(srcOffset, srcOffset + safeCount), destOffset);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function startVisualizerLoop() {
|
||||
if (rafHandle) return;
|
||||
function frame() {
|
||||
drawWaveform(inputDisplayBuf, inputCanvas, '#a3e635');
|
||||
// Time-synced output: scrub through queued chunks using audioContext clock
|
||||
drawWaveform(buildTimeSyncedOutputBuf(), outputCanvas, '#22d3ee');
|
||||
rafHandle = requestAnimationFrame(frame);
|
||||
}
|
||||
rafHandle = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function stopVisualizerLoop() {
|
||||
if (rafHandle) {
|
||||
cancelAnimationFrame(rafHandle);
|
||||
rafHandle = null;
|
||||
}
|
||||
outputChunkQueue = [];
|
||||
}
|
||||
|
||||
// Setup Canvas Sizes dynamically
|
||||
function resizeCanvases() {
|
||||
inputCanvas.width = inputCanvas.clientWidth * window.devicePixelRatio;
|
||||
inputCanvas.height = inputCanvas.clientHeight * window.devicePixelRatio;
|
||||
outputCanvas.width = outputCanvas.clientWidth * window.devicePixelRatio;
|
||||
outputCanvas.height = outputCanvas.clientHeight * window.devicePixelRatio;
|
||||
}
|
||||
resizeCanvases();
|
||||
window.addEventListener('resize', resizeCanvases);
|
||||
|
||||
// Connect / Disconnect WebSocket
|
||||
connectBtn.addEventListener('click', () => {
|
||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||
disconnectServer();
|
||||
} else {
|
||||
connectServer();
|
||||
}
|
||||
});
|
||||
|
||||
function connectServer() {
|
||||
const url = wsUrlInput.value.trim();
|
||||
updateConnectionStatus('connecting');
|
||||
|
||||
try {
|
||||
socket = new WebSocket(url);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log('Connected to RVC Server');
|
||||
updateConnectionStatus('connected');
|
||||
sendConfigToServer(); // Send initial configurations
|
||||
streamBtn.disabled = false;
|
||||
playToggleBtn.disabled = false;
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
console.log('WebSocket Connection Closed');
|
||||
disconnectServer();
|
||||
};
|
||||
|
||||
socket.onerror = (err) => {
|
||||
console.error('WebSocket Error:', err);
|
||||
disconnectServer();
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
// Config or control response
|
||||
try {
|
||||
const response = JSON.parse(event.data);
|
||||
if (response.type === 'config_success') {
|
||||
targetSampleRate = response.target_sr;
|
||||
console.log('Server configuration synced successfully:', response);
|
||||
} else if (response.type === 'init_devices') {
|
||||
populateServerDevices(response.devices, response.default_input, response.default_output);
|
||||
} else if (response.type === 'visualizer') {
|
||||
// Feed rolling display buffers — RAF loop handles drawing at 60fps
|
||||
pushToDisplayBuf(inputDisplayBuf, new Float32Array(response.input));
|
||||
pushToDisplayBuf(outputDisplayBuf, new Float32Array(response.output));
|
||||
if (!rafHandle) startVisualizerLoop();
|
||||
} else if (response.type === 'error') {
|
||||
alert('Server Error: ' + response.message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing text message:', e);
|
||||
}
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
// Binary processed PCM audio chunk returned from server (Browser Mode only)
|
||||
handleServerAudioChunk(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
console.error('Connection failed:', e);
|
||||
disconnectServer();
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectServer() {
|
||||
if (isStreaming) {
|
||||
stopStreaming();
|
||||
}
|
||||
|
||||
if (socket) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (e) {}
|
||||
socket = null;
|
||||
}
|
||||
|
||||
updateConnectionStatus('disconnected');
|
||||
streamBtn.disabled = true;
|
||||
playToggleBtn.disabled = true;
|
||||
}
|
||||
|
||||
function updateConnectionStatus(status) {
|
||||
connectionStatus.className = 'status-badge ' + status;
|
||||
if (status === 'connected') {
|
||||
connectionStatus.textContent = 'Terhubung';
|
||||
connectBtn.textContent = 'Putuskan Server';
|
||||
connectBtn.className = 'btn btn-primary';
|
||||
} else if (status === 'connecting') {
|
||||
connectionStatus.textContent = 'Menghubungkan';
|
||||
connectBtn.textContent = 'Batal';
|
||||
} else {
|
||||
connectionStatus.textContent = 'Terputus';
|
||||
connectBtn.textContent = 'Hubungkan Server';
|
||||
connectBtn.className = 'btn btn-primary';
|
||||
}
|
||||
}
|
||||
|
||||
// Config synchronization
|
||||
function sendConfigToServer() {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const activeF0 = document.querySelector('input[name="f0_method"]:checked').value;
|
||||
|
||||
const config = {
|
||||
type: 'config',
|
||||
model_name: modelSelect.value,
|
||||
device: deviceSelect.value,
|
||||
f0_method: activeF0,
|
||||
f0_up_key: parseInt(transposeSlider.value),
|
||||
noise_gate: parseFloat(gateSlider.value),
|
||||
input_gain: parseFloat(inputGainSlider.value),
|
||||
output_gain: parseFloat(outputGainSlider.value),
|
||||
input_sr: audioContext ? audioContext.sampleRate : 44100,
|
||||
routing_mode: routingModeSelect.value,
|
||||
input_device: serverInputSelect.value ? parseInt(serverInputSelect.value) : null,
|
||||
output_device: serverOutputSelect.value ? parseInt(serverOutputSelect.value) : null,
|
||||
chunk_size: parseInt(chunkSelect.value)
|
||||
};
|
||||
|
||||
socket.send(jsonEncode(config));
|
||||
console.log('Sent configuration change:', config);
|
||||
}
|
||||
|
||||
// Populate Server Audio Devices dropdowns
|
||||
function populateServerDevices(devices, defaultInput, defaultOutput) {
|
||||
serverInputSelect.innerHTML = '';
|
||||
serverOutputSelect.innerHTML = '';
|
||||
|
||||
if (devices.length === 0) {
|
||||
const optIn = document.createElement('option');
|
||||
optIn.textContent = 'Tidak ada mic terdeteksi di server';
|
||||
serverInputSelect.appendChild(optIn);
|
||||
|
||||
const optOut = document.createElement('option');
|
||||
optOut.textContent = 'Tidak ada output terdeteksi di server';
|
||||
serverOutputSelect.appendChild(optOut);
|
||||
return;
|
||||
}
|
||||
|
||||
devices.forEach(device => {
|
||||
if (device.max_input_channels > 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = device.id;
|
||||
opt.textContent = `[ID ${device.id}] ${device.name}`;
|
||||
if (device.id === defaultInput) opt.selected = true;
|
||||
serverInputSelect.appendChild(opt);
|
||||
}
|
||||
|
||||
if (device.max_output_channels > 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = device.id;
|
||||
opt.textContent = `[ID ${device.id}] ${device.name}`;
|
||||
if (device.id === defaultOutput) opt.selected = true;
|
||||
serverOutputSelect.appendChild(opt);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Successfully populated server hardware devices in UI.');
|
||||
}
|
||||
|
||||
// UI Event Listeners to trigger instant sync
|
||||
modelSelect.addEventListener('change', sendConfigToServer);
|
||||
deviceSelect.addEventListener('change', sendConfigToServer);
|
||||
document.querySelectorAll('input[name="f0_method"]').forEach(radio => {
|
||||
radio.addEventListener('change', sendConfigToServer);
|
||||
});
|
||||
|
||||
transposeSlider.addEventListener('input', () => {
|
||||
transposeVal.textContent = (transposeSlider.value >= 0 ? '+' : '') + transposeSlider.value + ' semitone';
|
||||
});
|
||||
transposeSlider.addEventListener('change', sendConfigToServer);
|
||||
|
||||
gateSlider.addEventListener('input', () => {
|
||||
gateVal.textContent = gateSlider.value + ' dB';
|
||||
});
|
||||
gateSlider.addEventListener('change', sendConfigToServer);
|
||||
|
||||
inputGainSlider.addEventListener('input', () => {
|
||||
inputGainVal.textContent = parseFloat(inputGainSlider.value).toFixed(1) + 'x';
|
||||
});
|
||||
inputGainSlider.addEventListener('change', sendConfigToServer);
|
||||
|
||||
outputGainSlider.addEventListener('input', () => {
|
||||
outputGainVal.textContent = parseFloat(outputGainSlider.value).toFixed(1) + 'x';
|
||||
});
|
||||
outputGainSlider.addEventListener('change', sendConfigToServer);
|
||||
|
||||
chunkSelect.addEventListener('change', () => {
|
||||
// Reinitialize stream if buffer size is changed during active streaming
|
||||
if (isStreaming) {
|
||||
stopStreaming();
|
||||
startStreaming();
|
||||
}
|
||||
});
|
||||
|
||||
noiseCancelCheckbox.addEventListener('change', () => {
|
||||
// Reinitialize microphone with new noise cancellation constraints if streaming
|
||||
if (isStreaming) {
|
||||
stopStreaming();
|
||||
startStreaming();
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to dynamically adjust UI layout based on Routing Mode
|
||||
function applyAudioRoutingUI() {
|
||||
if (routingModeSelect.value === 'hardware') {
|
||||
hardwareDevicesPanel.style.display = 'block';
|
||||
playToggleBtn.style.display = 'none'; // Hide browser-only "Mendengarkan" button
|
||||
browserNoiseCancelGroup.style.display = 'none'; // Hide browser-only Noise Cancel checkbox
|
||||
} else {
|
||||
hardwareDevicesPanel.style.display = 'none';
|
||||
playToggleBtn.style.display = 'inline-block'; // Show browser-only "Mendengarkan" button
|
||||
browserNoiseCancelGroup.style.display = 'block'; // Show browser-only Noise Cancel checkbox
|
||||
}
|
||||
}
|
||||
|
||||
// Routing Mode Event Listeners
|
||||
routingModeSelect.addEventListener('change', () => {
|
||||
applyAudioRoutingUI();
|
||||
sendConfigToServer();
|
||||
|
||||
if (isStreaming) {
|
||||
stopStreaming();
|
||||
startStreaming();
|
||||
}
|
||||
});
|
||||
|
||||
serverInputSelect.addEventListener('change', sendConfigToServer);
|
||||
serverOutputSelect.addEventListener('change', sendConfigToServer);
|
||||
|
||||
// Quick Presets Event Listeners
|
||||
presetLatencyBtn.addEventListener('click', () => {
|
||||
const radioPM = document.querySelector('input[name="f0_method"][value="pm"]');
|
||||
if (radioPM) radioPM.checked = true;
|
||||
chunkSelect.value = "8192";
|
||||
|
||||
console.log("Preset loaded: Latency (PM + 8192)");
|
||||
sendConfigToServer();
|
||||
|
||||
if (isStreaming) {
|
||||
stopStreaming();
|
||||
startStreaming();
|
||||
}
|
||||
});
|
||||
|
||||
presetQualityBtn.addEventListener('click', () => {
|
||||
const radioRMVPE = document.querySelector('input[name="f0_method"][value="rmvpe"]');
|
||||
if (radioRMVPE) radioRMVPE.checked = true;
|
||||
chunkSelect.value = "16384";
|
||||
|
||||
console.log("Preset loaded: Quality (RMVPE + 16384)");
|
||||
sendConfigToServer();
|
||||
|
||||
if (isStreaming) {
|
||||
stopStreaming();
|
||||
startStreaming();
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions for UI JSON safely
|
||||
function jsonEncode(obj) {
|
||||
return JSON.stringify(obj);
|
||||
}
|
||||
|
||||
playToggleBtn.addEventListener('click', () => {
|
||||
playOutput = !playOutput;
|
||||
if (playOutput) {
|
||||
playToggleBtn.textContent = '🔊 Mendengarkan: AKTIF';
|
||||
playToggleBtn.className = 'btn btn-primary';
|
||||
} else {
|
||||
playToggleBtn.textContent = '🔇 Mendengarkan: SENYAP';
|
||||
playToggleBtn.className = 'btn btn-accent';
|
||||
}
|
||||
});
|
||||
|
||||
// Stream Toggle
|
||||
streamBtn.addEventListener('click', () => {
|
||||
if (isStreaming) {
|
||||
stopStreaming();
|
||||
} else {
|
||||
startStreaming();
|
||||
}
|
||||
});
|
||||
|
||||
async function startStreaming() {
|
||||
isStreaming = true;
|
||||
streamBtn.textContent = 'Hentikan Pengubah Suara';
|
||||
streamBtn.className = 'btn btn-primary';
|
||||
|
||||
const isHardwareMode = (routingModeSelect.value === 'hardware');
|
||||
|
||||
if (isHardwareMode) {
|
||||
// --- SERVER HARDWARE ROUTING MODE ---
|
||||
inputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE);
|
||||
outputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE);
|
||||
startVisualizerLoop();
|
||||
sendConfigToServer(); // Sends config with routing_mode: 'hardware' which triggers stream start on server
|
||||
console.log('Server Hardware Mode initialized.');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- CLIENT BROWSER MODE ---
|
||||
// 1. Create AudioContext if not active
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||
latencyHint: 'interactive'
|
||||
});
|
||||
}
|
||||
|
||||
if (audioContext.state === 'suspended') {
|
||||
await audioContext.resume();
|
||||
}
|
||||
|
||||
hudSr.textContent = audioContext.sampleRate + ' Hz';
|
||||
sendConfigToServer(); // sync actual input sample rate
|
||||
|
||||
// 2. Request user microphone with high-fidelity, lowest possible latency constraints
|
||||
try {
|
||||
const useNoiseCancel = noiseCancelCheckbox.checked;
|
||||
micStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: useNoiseCancel,
|
||||
noiseSuppression: useNoiseCancel,
|
||||
autoGainControl: useNoiseCancel
|
||||
}
|
||||
});
|
||||
|
||||
micSourceNode = audioContext.createMediaStreamSource(micStream);
|
||||
|
||||
// 3. Create Audio Processing Loop Node (ScriptProcessorNode)
|
||||
// BaseAudioContext's createScriptProcessor buffer size MUST be a power of two between 256 and 16384.
|
||||
// We use a fixed, highly supported buffer size of 4096 for recording, and accumulate samples in-memory
|
||||
// to support ANY arbitrary or extremely large chunk size (like 12288, 24576, 32768) selected by the user!
|
||||
const recordBufferSize = 4096;
|
||||
scriptProcessorNode = audioContext.createScriptProcessor(recordBufferSize, 1, 1);
|
||||
|
||||
scriptProcessorNode.onaudioprocess = (event) => {
|
||||
if (!isStreaming) return;
|
||||
|
||||
const inputBuffer = event.inputBuffer;
|
||||
const inputData = inputBuffer.getChannelData(0); // 4096 samples
|
||||
|
||||
// Push latest mic samples into the rolling display buffer every callback (~85ms)
|
||||
pushToDisplayBuf(inputDisplayBuf, inputData);
|
||||
|
||||
// Append incoming recorded samples to our accumulator
|
||||
const temp = new Float32Array(micAccumulator.length + inputData.length);
|
||||
temp.set(micAccumulator);
|
||||
temp.set(inputData, micAccumulator.length);
|
||||
micAccumulator = temp;
|
||||
|
||||
const targetChunkSize = parseInt(chunkSelect.value);
|
||||
|
||||
// Process and send chunks of the user's selected target size
|
||||
while (micAccumulator.length >= targetChunkSize) {
|
||||
const chunkToSend = micAccumulator.slice(0, targetChunkSize);
|
||||
micAccumulator = micAccumulator.slice(targetChunkSize); // Keep remainder
|
||||
|
||||
// Voice Activity Detection for gate status badge
|
||||
let maxVal = 0;
|
||||
for (let i = 0; i < chunkToSend.length; i++) maxVal = Math.max(maxVal, Math.abs(chunkToSend[i]));
|
||||
if (maxVal > 0.005) {
|
||||
hudGateStatus.textContent = 'Bicara';
|
||||
hudGateStatus.className = 'hud-value active-badge';
|
||||
} else {
|
||||
hudGateStatus.textContent = 'Berdiam';
|
||||
hudGateStatus.className = 'hud-value text-muted';
|
||||
}
|
||||
|
||||
// Send binary PCM Float32 audio chunk of target size to Python Server
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
const packetTime = performance.now();
|
||||
sentTimestamps.push({ id: packetTime, sent: packetTime });
|
||||
if (sentTimestamps.length > maxSentLogs) {
|
||||
sentTimestamps.shift();
|
||||
}
|
||||
|
||||
socket.send(chunkToSend.buffer); // Send direct array buffer
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
micSourceNode.connect(scriptProcessorNode);
|
||||
scriptProcessorNode.connect(audioContext.destination); // Required to trigger onaudioprocess
|
||||
|
||||
// Reset playback sync clock
|
||||
nextPlaybackTime = 0;
|
||||
micAccumulator = new Float32Array(0); // Reset accumulator
|
||||
inputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE);
|
||||
outputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE);
|
||||
startVisualizerLoop();
|
||||
|
||||
console.log('Browser Streaming active. Recording buffer size: 4096 | Target chunk size:', chunkSelect.value);
|
||||
} catch (e) {
|
||||
console.error('Failed to access microphone:', e);
|
||||
alert('Gagal mengakses mikrofon Anda: ' + e.message);
|
||||
stopStreaming();
|
||||
}
|
||||
}
|
||||
|
||||
function stopStreaming() {
|
||||
isStreaming = false;
|
||||
streamBtn.textContent = 'Mulai Mengubah Suara';
|
||||
streamBtn.className = 'btn btn-accent';
|
||||
|
||||
playOutput = true;
|
||||
playToggleBtn.textContent = '🔊 Mendengarkan: AKTIF';
|
||||
playToggleBtn.className = 'btn btn-primary';
|
||||
|
||||
const isHardwareMode = (routingModeSelect.value === 'hardware');
|
||||
|
||||
if (isHardwareMode) {
|
||||
// --- SERVER HARDWARE ROUTING MODE ---
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
const config = {
|
||||
type: 'config',
|
||||
routing_mode: 'browser' // Tells server to stop local hardware stream
|
||||
};
|
||||
socket.send(jsonEncode(config));
|
||||
}
|
||||
console.log('Server Hardware Mode stopped.');
|
||||
|
||||
hudGateStatus.textContent = 'Berdiam';
|
||||
hudGateStatus.className = 'hud-value text-muted';
|
||||
hudLatency.textContent = '-- ms';
|
||||
hudTime.textContent = '-- ms';
|
||||
|
||||
stopVisualizerLoop();
|
||||
inputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE);
|
||||
outputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE);
|
||||
clearCanvas(inputCanvas);
|
||||
clearCanvas(outputCanvas);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- CLIENT BROWSER MODE ---
|
||||
// Stop microphone stream tracks
|
||||
if (micStream) {
|
||||
micStream.getTracks().forEach(track => track.stop());
|
||||
micStream = null;
|
||||
}
|
||||
|
||||
// Disconnect Web Audio nodes
|
||||
if (micSourceNode) {
|
||||
micSourceNode.disconnect();
|
||||
micSourceNode = null;
|
||||
}
|
||||
if (scriptProcessorNode) {
|
||||
scriptProcessorNode.disconnect();
|
||||
scriptProcessorNode = null;
|
||||
}
|
||||
|
||||
micAccumulator = new Float32Array(0); // Reset accumulator
|
||||
|
||||
stopVisualizerLoop();
|
||||
inputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE);
|
||||
outputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE);
|
||||
|
||||
hudGateStatus.textContent = 'Berdiam';
|
||||
hudGateStatus.className = 'hud-value text-muted';
|
||||
hudLatency.textContent = '-- ms';
|
||||
hudTime.textContent = '-- ms';
|
||||
|
||||
clearCanvas(inputCanvas);
|
||||
clearCanvas(outputCanvas);
|
||||
}
|
||||
|
||||
// Seamless Audio Playback Scheduler (Absorbs WebSocket & processing jitter)
|
||||
function handleServerAudioChunk(arrayBuffer) {
|
||||
if (!isStreaming) return;
|
||||
|
||||
// 1. Measure Round-Trip Time Latency (RTT)
|
||||
const now = performance.now();
|
||||
let rtt = 0;
|
||||
if (sentTimestamps.length > 0) {
|
||||
const oldestSent = sentTimestamps.shift();
|
||||
rtt = now - oldestSent.sent;
|
||||
hudLatency.textContent = Math.round(rtt) + ' ms';
|
||||
}
|
||||
|
||||
// Convert arrayBuffer to Float32 samples
|
||||
const payload = new Float32Array(arrayBuffer);
|
||||
const processingTime = payload[0]; // first float32 is the server processing time in ms
|
||||
const pcmData = payload.subarray(1); // the rest is the audio
|
||||
|
||||
// 2. Schedule chunk smoothly inside the AudioContext timeline
|
||||
const audioBuf = audioContext.createBuffer(1, pcmData.length, targetSampleRate);
|
||||
audioBuf.getChannelData(0).set(pcmData);
|
||||
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = audioBuf;
|
||||
|
||||
if (playOutput) {
|
||||
source.connect(audioContext.destination);
|
||||
}
|
||||
|
||||
// Calculate precise playback clock scheduling
|
||||
const currentTime = audioContext.currentTime;
|
||||
const chunkDuration = audioBuf.duration; // actual chunk duration in seconds
|
||||
// Adaptive buffer: enough headroom so next chunk always arrives before this one ends.
|
||||
// 2.5× chunk or 500ms cap — absorbs even 300ms+ processing spikes.
|
||||
const adaptiveBuf = Math.min(chunkDuration * 2.5, 0.50);
|
||||
|
||||
if (nextPlaybackTime < currentTime) {
|
||||
// Clock behind — first chunk or dropout recovery.
|
||||
// Use full adaptiveBuf on BOTH cases so recovery fully rebuilds headroom.
|
||||
// (0.5× recovery was causing cascading dropouts: one late chunk → the next also late)
|
||||
nextPlaybackTime = currentTime + adaptiveBuf;
|
||||
} else if (nextPlaybackTime > currentTime + chunkDuration * 5.0) {
|
||||
// --- ADAPTIVE LATENCY BUSTER ---
|
||||
// Only snap when queue is >5 chunk-durations ahead (genuine backlog, not normal look-ahead).
|
||||
// At 8192 (170ms): threshold = 850ms
|
||||
// At 65536 (1.6s): threshold = 8s
|
||||
const snapTarget = currentTime + adaptiveBuf;
|
||||
console.log(`Latency Buster: ${Math.round((nextPlaybackTime-currentTime)*1000)}ms → ${Math.round(adaptiveBuf*1000)}ms`);
|
||||
nextPlaybackTime = snapTarget;
|
||||
}
|
||||
|
||||
// Record schedule start time BEFORE advancing the clock (for time-synced visualizer)
|
||||
const scheduleStartTime = nextPlaybackTime;
|
||||
|
||||
// Schedule play
|
||||
source.start(nextPlaybackTime);
|
||||
|
||||
hudTime.textContent = Math.max(0, Math.round(processingTime)) + ' ms';
|
||||
|
||||
// Advance playback sync clock
|
||||
nextPlaybackTime += audioBuf.duration;
|
||||
|
||||
// Push to time-synced output queue for visualizer (keyed by when audio actually plays)
|
||||
outputChunkQueue.push({ data: pcmData, startTime: scheduleStartTime });
|
||||
// Keep queue bounded to ~10 seconds of audio max
|
||||
while (outputChunkQueue.length > 0) {
|
||||
const c = outputChunkQueue[0];
|
||||
if (c.startTime + c.data.length / targetSampleRate < audioContext.currentTime - 2.0) {
|
||||
outputChunkQueue.shift();
|
||||
} else break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- VISUALIZATION / DRAWING ROUTINES ---
|
||||
function drawWaveform(dataArray, canvas, strokeColor) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
// Dark transparent redraw for trace/motion-blur effect
|
||||
ctx.fillStyle = 'rgba(5, 7, 4, 0.4)';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
ctx.lineWidth = 2 * window.devicePixelRatio;
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.beginPath();
|
||||
|
||||
const sliceWidth = width / dataArray.length;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
// Center the wave around half-height and scale scale amplitude
|
||||
const v = dataArray[i] * 1.5;
|
||||
const y = (v * (height / 2)) + (height / 2);
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
x += sliceWidth;
|
||||
}
|
||||
|
||||
ctx.lineTo(width, height / 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw a subtle baseline center glowing path
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, height / 2);
|
||||
ctx.lineTo(width, height / 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function clearCanvas(canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#050704';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
// Apply initial UI layout on startup
|
||||
applyAudioRoutingUI();
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,20 @@
|
||||
1:"$Sreact.fragment"
|
||||
2:I[39756,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"default"]
|
||||
3:I[37457,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"default"]
|
||||
4:I[47257,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"ClientPageRoot"]
|
||||
5:I[52683,["/_next/static/chunks/0dbhjjzl8qfwv.js","/_next/static/chunks/01yjdu1pd4sew.js"],"default"]
|
||||
8:I[97367,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"OutletBoundary"]
|
||||
9:"$Sreact.suspense"
|
||||
b:I[97367,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"ViewportBoundary"]
|
||||
d:I[97367,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"MetadataBoundary"]
|
||||
f:I[68027,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"default",1]
|
||||
:HL["/_next/static/chunks/0~n~ndg8g34g9.css","style"]
|
||||
:HL["/_next/static/media/797e433ab948586e-s.p.09zddjkbdep5a.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
||||
:HL["/_next/static/media/caa3a2e1cccd8315-s.p.09~u27dqhyhd6.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
||||
0:{"P":null,"c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0~n~ndg8g34g9.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0dbhjjzl8qfwv.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","className":"geist_a71539c9-module__T19VSG__variable geist_mono_8d43a2aa-module__8Li5zG__variable h-full antialiased","children":["$","body",null,{"className":"min-h-full flex flex-col","children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":[["$","$1","c",{"children":[["$","$L4",null,{"Component":"$5","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@6","$@7"]}}],[["$","script","script-0",{"src":"/_next/static/chunks/01yjdu1pd4sew.js","async":true,"nonce":"$undefined"}]],["$","$L8",null,{"children":["$","$9",null,{"name":"Next.MetadataOutlet","children":"$@a"}]}]]}],{},null,false,null]},null,false,null],["$","$1","h",{"children":[null,["$","$Lb",null,{"children":"$Lc"}],["$","div",null,{"hidden":true,"children":["$","$Ld",null,{"children":["$","$9",null,{"name":"Next.Metadata","children":"$Le"}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],false]],"m":"$undefined","G":["$f",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0~n~ndg8g34g9.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"ncF6Dp_0OCMhTp1KUVnS4"}
|
||||
6:{}
|
||||
7:"$0:f:0:1:1:children:0:props:children:0:props:serverProvidedParams:params"
|
||||
c:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
||||
10:I[27201,["/_next/static/chunks/0dbhjjzl8qfwv.js"],"IconMark"]
|
||||
a:null
|
||||
e:[["$","title","0",{"children":"🎙️ ONNX VC - Real-Time AI Voice Changer"}],["$","meta","1",{"name":"description","content":"ONNX VC - Pengubah suara real-time berbasis AI berlatensi ultra-rendah dengan ONNX Runtime."}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0x3dzn~oxb6tn.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L10","3",{}]]
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,596 +0,0 @@
|
||||
/* ==========================================================================
|
||||
CSS GLOBAL TOKENS & RESET (LIME LIGHT THEME)
|
||||
========================================================================== */
|
||||
:root {
|
||||
--bg-dark: #0a0d08;
|
||||
--bg-card: rgba(15, 20, 13, 0.7);
|
||||
--border-color: rgba(163, 230, 53, 0.18);
|
||||
|
||||
--primary: #a3e635;
|
||||
--primary-glow: rgba(163, 230, 53, 0.4);
|
||||
--accent: #22d3ee;
|
||||
--accent-glow: rgba(34, 211, 238, 0.45);
|
||||
--emerald: #10b981;
|
||||
--rose: #f43f5e;
|
||||
|
||||
--text-main: #f8fafc;
|
||||
--text-muted: #94a3b8;
|
||||
--font-header: 'Outfit', 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
|
||||
--transition-smooth: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-body);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
DYNAMIC GLOWING BACKGROUND
|
||||
========================================================================== */
|
||||
.glow-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: -1;
|
||||
background:
|
||||
radial-gradient(circle at 10% 20%, rgba(163, 230, 53, 0.08) 0%, transparent 40%),
|
||||
radial-gradient(circle at 90% 80%, rgba(34, 211, 238, 0.09) 0%, transparent 45%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
LAYOUT CONTAINER & CARDS
|
||||
========================================================================== */
|
||||
.dashboard-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.glassmorphism {
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.glassmorphism:hover {
|
||||
border-color: rgba(163, 230, 53, 0.3);
|
||||
box-shadow: 0 10px 40px 0 rgba(163, 230, 53, 0.1);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1.75rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--font-header);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.25rem;
|
||||
background: linear-gradient(135deg, #fff 0%, var(--text-muted) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
APP HEADER
|
||||
========================================================================== */
|
||||
.app-header {
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.logo-area {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.logo-area h1 {
|
||||
font-family: var(--font-header);
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-shadow: 0 0 40px rgba(163, 230, 53, 0.2);
|
||||
}
|
||||
|
||||
.pulse-indicator {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--rose);
|
||||
box-shadow: 0 0 10px var(--rose);
|
||||
}
|
||||
|
||||
.pulse-indicator.active {
|
||||
background-color: var(--emerald);
|
||||
box-shadow: 0 0 10px var(--emerald);
|
||||
animation: pulse 1.8s infinite;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 400;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
DASHBOARD GRID LAYOUT
|
||||
========================================================================== */
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.col-span-2 {
|
||||
grid-column: span 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.col-span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
INPUTS & CONTROLS
|
||||
========================================================================== */
|
||||
.control-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.control-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.custom-select {
|
||||
width: 100%;
|
||||
padding: 0.8rem 1rem;
|
||||
background-color: rgba(14, 20, 13, 0.8);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-main);
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--font-body);
|
||||
outline: none;
|
||||
transition: var(--transition-smooth);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 1rem center;
|
||||
background-size: 1.2rem;
|
||||
}
|
||||
|
||||
.custom-select:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 8px var(--primary-glow);
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
background-color: rgba(14, 20, 13, 0.8);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-main);
|
||||
padding: 0.8rem 1rem;
|
||||
width: 100%;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.input-group input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 8px var(--primary-glow);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
SLIDERS STYLING
|
||||
========================================================================== */
|
||||
.slider-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
font-family: var(--font-header);
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 8px var(--accent-glow);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.custom-slider {
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(163, 230, 53, 0.15);
|
||||
outline: none;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.custom-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 10px var(--primary-glow);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.custom-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.slider-ticks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
BUTTONS
|
||||
========================================================================== */
|
||||
.btn {
|
||||
padding: 0.8rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-header);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
transition: var(--transition-smooth);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary) 0%, #65a30d 100%);
|
||||
color: #0c0f0a;
|
||||
box-shadow: 0 4px 14px 0 var(--primary-glow);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px 0 rgba(163, 230, 53, 0.6);
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #0891b2 100%);
|
||||
color: #0c0f0a;
|
||||
box-shadow: 0 4px 14px 0 var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-accent:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px 0 rgba(34, 211, 238, 0.65);
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
CONNECTION BAR
|
||||
========================================================================== */
|
||||
.connection-bar {
|
||||
padding: 1rem 1.5rem !important;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-row .input-group {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.connection-status-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.status-badge::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-badge.connected {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
color: var(--emerald);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.status-badge.connected::before {
|
||||
background-color: var(--emerald);
|
||||
box-shadow: 0 0 6px var(--emerald);
|
||||
}
|
||||
|
||||
.status-badge.disconnected {
|
||||
background-color: rgba(254, 63, 94, 0.15);
|
||||
color: var(--rose);
|
||||
border: 1px solid rgba(254, 63, 94, 0.3);
|
||||
}
|
||||
|
||||
.status-badge.disconnected::before {
|
||||
background-color: var(--rose);
|
||||
box-shadow: 0 0 6px var(--rose);
|
||||
}
|
||||
|
||||
.status-badge.connecting {
|
||||
background-color: rgba(34, 211, 238, 0.15);
|
||||
color: var(--accent);
|
||||
border: 1px solid rgba(34, 211, 238, 0.3);
|
||||
}
|
||||
|
||||
.status-badge.connecting::before {
|
||||
background-color: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent);
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
.btn-group-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
MODERN RADIO TILES
|
||||
========================================================================== */
|
||||
.radio-group-modern {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.radio-tile {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.radio-tile input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tile-label {
|
||||
display: block;
|
||||
padding: 0.6rem;
|
||||
background-color: rgba(15, 20, 13, 0.5);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.radio-tile input:checked + .tile-label {
|
||||
background-color: rgba(163, 230, 53, 0.12);
|
||||
border-color: var(--primary);
|
||||
color: var(--text-main);
|
||||
box-shadow: 0 0 10px rgba(163, 230, 53, 0.2);
|
||||
}
|
||||
|
||||
.radio-tile:hover .tile-label {
|
||||
border-color: rgba(163, 230, 53, 0.4);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
OSCILLOSCOPE WAVEFORM CANVASES
|
||||
========================================================================== */
|
||||
.visualizer-row {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.visualizer-container {
|
||||
flex: 1;
|
||||
min-width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.vis-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.input-dot {
|
||||
background-color: var(--primary);
|
||||
box-shadow: 0 0 6px var(--primary);
|
||||
}
|
||||
|
||||
.output-dot {
|
||||
background-color: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent);
|
||||
}
|
||||
|
||||
.waveform-canvas {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
background-color: #050704;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
PERFORMANCE HUD
|
||||
========================================================================== */
|
||||
.performance-hud {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.85rem 1.75rem !important;
|
||||
}
|
||||
|
||||
.hud-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.hud-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hud-value {
|
||||
font-family: var(--font-header);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.hud-separator {
|
||||
width: 1px;
|
||||
height: 30px;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.hud-value.text-accent {
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 8px var(--accent-glow);
|
||||
}
|
||||
|
||||
.active-badge {
|
||||
color: var(--emerald);
|
||||
text-shadow: 0 0 6px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.performance-hud {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.hud-separator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
KEYFRAME ANIMATIONS
|
||||
========================================================================== */
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(0.9);
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.9);
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |