Initial release

This commit is contained in:
civ
2026-08-16 18:24:52 +07:00
commit 876886a39a
13244 changed files with 2353959 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+80
View File
@@ -0,0 +1,80 @@
/**
* generate_translations.mjs — Translate en.json to target locales
* Uses Google Translate free API via fetch (no package needed).
* Run: node generate_translations.mjs
*/
import { readFileSync, writeFileSync } from 'fs';
const LOCALES = ['ru', 'zh-CN', 'ja', 'ko'];
const LOCALE_FILES = { 'ru': 'ru', 'zh-CN': 'zh', 'ja': 'ja', 'ko': 'ko' };
// Technical terms that should NOT be translated
const KEEP_ENGLISH = new Set([
'DiT', 'VAE', 'LM', 'LoRA', 'LoKR', 'GGUF', 'BPM', 'APG', 'CFG', 'CFG++',
'DPM++', 'DDIM', 'SGM', 'ODE', 'NFE', 'RK4', 'CoT', 'HSLAT', 'PP-VAE',
'VST', 'VST3', 'VRAM', 'Opus', 'FLAC', 'WAV', 'MP3', 'RMS', 'DCW',
'Euler', 'Heun', 'HOT-Step', 'HOT-Step CPP', 'Genius', 'OpenAI', 'Gemini',
'Anthropic', 'Claude', 'GPT', 'Ollama', 'LM Studio', 'Unsloth',
'safetensors', '.safetensors', '.latent', 'HuggingFace',
'NoFSQ', 'FSQ', 'SNR-t', 'Log-SNR',
]);
async function translate(text, targetLang) {
// Skip empty or very short strings
if (!text || text.length < 2) return text;
// Skip if it's a technical placeholder
if (text.startsWith('{{') || text.startsWith('NO USER INPUT')) return text;
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=en&tl=${targetLang}&dt=t&q=${encodeURIComponent(text)}`;
try {
const res = await fetch(url);
const data = await res.json();
// Response is nested arrays: [[["translated","original",null,null,x],...]]
let result = '';
if (Array.isArray(data) && Array.isArray(data[0])) {
for (const segment of data[0]) {
if (segment[0]) result += segment[0];
}
}
return result || text;
} catch (err) {
console.error(` ⚠ Failed to translate "${text.substring(0, 40)}..." to ${targetLang}:`, err.message);
return text; // fallback to English
}
}
// Rate limiter — Google blocks if we go too fast
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
async function main() {
const en = JSON.parse(readFileSync('./src/i18n/locales/en.json', 'utf-8'));
const entries = Object.entries(en.translation);
for (const locale of LOCALES) {
const outFile = LOCALE_FILES[locale];
console.log(`\n🌍 Translating to ${locale} (${entries.length} keys)...`);
const translated = {};
let done = 0;
for (const [key, value] of entries) {
done++;
if (done % 50 === 0) console.log(` ${done}/${entries.length}...`);
const result = await translate(value, locale);
translated[key] = result;
// Small delay to avoid rate limiting (50ms between requests)
await sleep(50);
}
const output = { translation: translated };
writeFileSync(`./src/i18n/locales/${outFile}.json`, JSON.stringify(output, null, 2) + '\n', 'utf-8');
console.log(`✅ Wrote ${outFile}.json (${Object.keys(translated).length} keys)`);
}
console.log('\n🎉 All translations complete!');
}
main().catch(console.error);
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="HOT-Step 9000 CPP — High-performance AI music generation powered by acestep.cpp" />
<meta name="theme-color" id="theme-color-meta" content="#ec4899" />
<!-- Prevent flash of wrong theme: apply dark class before any CSS is loaded -->
<script>
(function() {
try {
var theme = localStorage.getItem('hs-theme');
if (theme === 'light') return; // stay light
document.documentElement.classList.add('dark'); // default to dark
} catch(e) { document.documentElement.classList.add('dark'); }
})();
</script>
<title>HOT-Step 9000 ⚡ CPP</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>" />
</head>
<body class="bg-white dark:bg-suno text-zinc-900 dark:text-white overflow-hidden transition-colors duration-300">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4052
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "ui",
"private": true,
"version": "1.0.2",
"engines": {
"node": ">=18.0.0 <24.0.0"
},
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"audiomotion-analyzer": "^4.5.4",
"i18next": "^26.0.10",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.8.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^17.0.7",
"wavesurfer.js": "^7.12.6",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"postcss": "^8.5.10",
"tailwindcss": "^3.4.19",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 905 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 979 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 895 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 751 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+1481
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,642 @@
/* AssistantPanel.css — AI Assistant sidebar styling */
.assistant-panel {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg-primary, #0d0d0f);
font-family: var(--font-sans, system-ui, sans-serif);
}
/* ── Header ─────────────────────────────────────────────────────────── */
.assistant-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(24, 24, 27, 0.8);
flex-shrink: 0;
}
.assistant-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.assistant-title {
font-size: 0.75rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
}
.assistant-header-actions {
display: flex;
align-items: center;
gap: 4px;
}
.assistant-header-btn {
padding: 4px;
border-radius: 4px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
}
.assistant-header-btn:hover {
color: white;
background: rgba(255, 255, 255, 0.05);
}
/* ── Provider selector bar ──────────────────────────────────────────── */
.assistant-provider-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(24, 24, 27, 0.5);
flex-shrink: 0;
}
/* Provider selects use Tailwind classes matching the Models bar — see AssistantPanel.tsx */
/* ── Messages area ──────────────────────────────────────────────────── */
.assistant-messages {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
.assistant-messages::-webkit-scrollbar {
width: 4px;
}
.assistant-messages::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
/* ── Message bubbles ────────────────────────────────────────────────── */
.assistant-msg {
max-width: 92%;
padding: 10px 14px;
border-radius: 12px;
font-size: 0.8125rem;
line-height: 1.55;
word-wrap: break-word;
animation: msgFadeIn 0.2s ease;
}
@keyframes msgFadeIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.assistant-msg--user {
align-self: flex-end;
background: rgba(236, 72, 153, 0.12);
border: 1px solid rgba(236, 72, 153, 0.15);
color: rgba(255, 255, 255, 0.9);
border-bottom-right-radius: 4px;
}
.assistant-msg--assistant {
align-self: flex-start;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.85);
border-bottom-left-radius: 4px;
}
.assistant-msg--streaming {
border-color: rgba(139, 92, 246, 0.2);
}
.assistant-msg--error {
align-self: center;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #fca5a5;
font-size: 0.75rem;
}
/* ── Simple Markdown rendering ──────────────────────────────────────── */
.smd-root {
display: flex;
flex-direction: column;
gap: 2px;
}
.smd-spacer {
height: 6px;
}
.smd-p {
margin: 0;
line-height: 1.55;
}
.smd-h1 {
font-size: 1rem;
font-weight: 700;
margin: 8px 0 4px;
color: rgba(255, 255, 255, 0.95);
}
.smd-h2 {
font-size: 0.875rem;
font-weight: 700;
margin: 8px 0 2px;
color: rgba(255, 255, 255, 0.9);
}
.smd-h3 {
font-size: 0.8125rem;
font-weight: 600;
margin: 6px 0 2px;
color: rgba(255, 255, 255, 0.85);
}
.smd-bold {
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
}
.smd-italic {
font-style: italic;
color: rgba(255, 255, 255, 0.7);
}
.smd-inline-code {
padding: 1px 5px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 0.75rem;
color: rgba(236, 72, 153, 0.9);
}
.smd-code-block {
margin: 4px 0;
padding: 8px 10px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.05);
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 0.7rem;
line-height: 1.5;
overflow-x: auto;
white-space: pre;
color: rgba(255, 255, 255, 0.75);
position: relative;
}
.smd-code-lang {
position: absolute;
top: 4px;
right: 8px;
font-size: 0.6rem;
color: rgba(255, 255, 255, 0.2);
text-transform: uppercase;
}
.smd-ul, .smd-ol {
margin: 2px 0;
padding-left: 18px;
}
.smd-li {
margin: 1px 0;
line-height: 1.5;
}
.smd-ul .smd-li {
list-style-type: ' ';
}
.smd-ul .smd-li::marker {
color: rgba(139, 92, 246, 0.5);
}
.smd-ol .smd-li::marker {
color: rgba(139, 92, 246, 0.5);
font-weight: 600;
font-size: 0.75rem;
}
.smd-hr {
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin: 6px 0;
}
/* Light mode overrides */
[data-theme="light"] .smd-h1,
[data-theme="light"] .smd-h2,
[data-theme="light"] .smd-h3 { color: rgba(0, 0, 0, 0.85); }
[data-theme="light"] .smd-bold { color: rgba(0, 0, 0, 0.9); }
[data-theme="light"] .smd-inline-code { background: rgba(0, 0, 0, 0.06); color: rgba(190, 40, 100, 0.9); }
[data-theme="light"] .smd-code-block { background: rgba(0, 0, 0, 0.04); border-color: rgba(0, 0, 0, 0.08); color: rgba(0, 0, 0, 0.7); }
/* Streaming cursor */
.assistant-cursor {
display: inline-block;
width: 2px;
height: 14px;
background: rgba(139, 92, 246, 0.7);
margin-left: 2px;
vertical-align: text-bottom;
animation: cursorBlink 0.6s ease infinite;
}
@keyframes cursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* ── Thinking block (collapsible) ───────────────────────────────────── */
.assistant-thinking {
margin-bottom: 6px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.04);
background: rgba(255, 255, 255, 0.02);
overflow: hidden;
}
.assistant-thinking[open] {
border-color: rgba(139, 92, 246, 0.1);
}
.assistant-thinking-summary {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 10px;
font-size: 0.6875rem;
font-weight: 500;
color: rgba(139, 92, 246, 0.6);
cursor: pointer;
user-select: none;
list-style: none;
transition: color 0.15s ease;
}
.assistant-thinking-summary::-webkit-details-marker {
display: none;
}
.assistant-thinking-summary:hover {
color: rgba(139, 92, 246, 0.9);
}
.assistant-thinking-summary .thinking-chevron {
transition: transform 0.2s ease;
flex-shrink: 0;
}
.assistant-thinking[open] .thinking-chevron {
transform: rotate(90deg);
}
.assistant-thinking-content {
padding: 6px 10px 10px;
font-size: 0.75rem;
line-height: 1.5;
color: rgba(255, 255, 255, 0.35);
font-style: italic;
white-space: pre-wrap;
word-wrap: break-word;
border-top: 1px solid rgba(255, 255, 255, 0.03);
max-height: 300px;
overflow-y: auto;
}
.assistant-thinking-label {
animation: thinkingPulse 1.5s ease infinite;
}
@keyframes thinkingPulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
/* ── Action cards ───────────────────────────────────────────────────── */
.assistant-action-card {
margin-top: 8px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(139, 92, 246, 0.06);
border: 1px solid rgba(139, 92, 246, 0.15);
}
.assistant-action-card-title {
font-size: 0.6875rem;
font-weight: 600;
color: rgba(139, 92, 246, 0.8);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.assistant-action-diff {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.assistant-action-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.75rem;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
}
.assistant-action-label {
color: rgba(255, 255, 255, 0.5);
min-width: 100px;
flex-shrink: 0;
}
.assistant-action-from {
color: rgba(239, 68, 68, 0.7);
text-decoration: line-through;
}
.assistant-action-arrow {
color: rgba(255, 255, 255, 0.2);
flex-shrink: 0;
}
.assistant-action-to {
color: rgba(52, 211, 153, 0.9);
font-weight: 500;
}
.assistant-action-row-btn-area {
margin-left: auto;
flex-shrink: 0;
}
.assistant-action-apply-one {
padding: 2px 8px;
border-radius: 5px;
border: 1px solid rgba(139, 92, 246, 0.25);
background: rgba(139, 92, 246, 0.1);
color: rgba(139, 92, 246, 0.8);
font-size: 0.625rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.assistant-action-apply-one:hover {
background: rgba(139, 92, 246, 0.2);
border-color: rgba(139, 92, 246, 0.4);
}
.assistant-action-applied-badge {
color: rgba(52, 211, 153, 0.6);
display: flex;
align-items: center;
}
.assistant-action-row--applied {
opacity: 0.45;
}
.assistant-action-buttons {
display: flex;
gap: 8px;
}
.assistant-apply-btn {
flex: 1;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid rgba(139, 92, 246, 0.3);
background: rgba(139, 92, 246, 0.15);
color: rgba(139, 92, 246, 0.9);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.assistant-apply-btn:hover {
background: rgba(139, 92, 246, 0.25);
border-color: rgba(139, 92, 246, 0.5);
}
.assistant-apply-btn--applied {
border-color: rgba(52, 211, 153, 0.3);
background: rgba(52, 211, 153, 0.1);
color: rgba(52, 211, 153, 0.8);
cursor: default;
}
.assistant-dismiss-btn {
padding: 6px 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.4);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s ease;
}
.assistant-dismiss-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.6);
}
/* ── Welcome state ──────────────────────────────────────────────────── */
.assistant-welcome {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
text-align: center;
gap: 12px;
}
.assistant-welcome-icon {
font-size: 2rem;
opacity: 0.6;
}
.assistant-welcome-title {
font-size: 0.9375rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
}
.assistant-welcome-subtitle {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.35);
line-height: 1.5;
max-width: 280px;
}
.assistant-welcome-suggestions {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
width: 100%;
max-width: 280px;
}
.assistant-suggestion-btn {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.5);
font-size: 0.75rem;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
}
.assistant-suggestion-btn:hover {
background: rgba(139, 92, 246, 0.08);
border-color: rgba(139, 92, 246, 0.15);
color: rgba(255, 255, 255, 0.7);
}
/* ── Input bar ──────────────────────────────────────────────────────── */
.assistant-input-bar {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(24, 24, 27, 0.8);
backdrop-filter: blur(8px);
flex-shrink: 0;
}
.assistant-input {
flex: 1;
min-height: 36px;
max-height: 120px;
padding: 8px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.9);
font-size: 0.8125rem;
line-height: 1.4;
resize: none;
outline: none;
transition: border-color 0.15s ease;
font-family: inherit;
}
.assistant-input::placeholder {
color: rgba(255, 255, 255, 0.25);
}
.assistant-input:focus {
border-color: rgba(139, 92, 246, 0.4);
}
.assistant-send-btn {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 10px;
border: none;
background: rgba(139, 92, 246, 0.2);
color: rgba(139, 92, 246, 0.8);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.assistant-send-btn:hover:not(:disabled) {
background: rgba(139, 92, 246, 0.35);
color: rgba(139, 92, 246, 1);
}
.assistant-send-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* ── Light mode ─────────────────────────────────────────────────────── */
[data-theme="light"] .assistant-panel {
background: #fafafa;
}
[data-theme="light"] .assistant-header {
background: rgba(240, 240, 245, 0.9);
border-color: rgba(0, 0, 0, 0.06);
}
[data-theme="light"] .assistant-title {
color: rgba(0, 0, 0, 0.5);
}
[data-theme="light"] .assistant-msg--user {
background: rgba(236, 72, 153, 0.08);
border-color: rgba(236, 72, 153, 0.12);
color: rgba(0, 0, 0, 0.8);
}
[data-theme="light"] .assistant-msg--assistant {
background: rgba(0, 0, 0, 0.03);
border-color: rgba(0, 0, 0, 0.06);
color: rgba(0, 0, 0, 0.8);
}
[data-theme="light"] .assistant-input {
background: white;
border-color: rgba(0, 0, 0, 0.1);
color: rgba(0, 0, 0, 0.85);
}
[data-theme="light"] .assistant-input-bar {
background: rgba(240, 240, 245, 0.9);
border-color: rgba(0, 0, 0, 0.06);
}
@@ -0,0 +1,501 @@
// AssistantPanel.tsx — AI Assistant sidebar with streaming chat
//
// Follows the same sidebar pattern as TerminalPanel.tsx. Streams LLM
// responses via SSE, parses action blocks, and applies settings changes
// through GlobalParamsContext setters.
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { X, Send, Trash2, Sparkles, ArrowRight, Check } from 'lucide-react';
import { usePersistedState } from '../../hooks/usePersistedState';
import { useGlobalParams } from '../../context/GlobalParamsContext';
import { useAssistantActions, type ActionDiff } from '../../hooks/useAssistantActions';
import { SimpleMarkdown } from './SimpleMarkdown';
import {
chatStream,
parseActions,
stripActionBlocks,
stripThinkingBlocks,
extractThinkingAndResponse,
getProviders,
type ChatMessage,
type AssistantAction,
type AssistantProvider,
} from '../../services/assistantApi';
import './AssistantPanel.css';
interface AssistantPanelProps {
onClose: () => void;
activeView?: string;
}
interface DisplayMessage {
id: number;
role: 'user' | 'assistant' | 'error';
content: string;
rawContent?: string; // original LLM output (with thinking tags) for re-parsing
actions?: AssistantAction[];
appliedKeys?: Set<string>; // tracks which action keys have been applied
}
let messageIdCounter = 0;
export const AssistantPanel: React.FC<AssistantPanelProps> = ({ onClose, activeView }) => {
// ── Provider state ──
const [providers, setProviders] = useState<AssistantProvider[]>([]);
const [selectedProvider, setSelectedProvider] = usePersistedState('hs-assistant-provider', '');
const [selectedModel, setSelectedModel] = usePersistedState('hs-assistant-model', '');
// ── Chat state ──
const [messages, setMessages] = useState<DisplayMessage[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingText, setStreamingText] = useState('');
// ── Refs ──
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(null);
// ── Hooks ──
const globalParams = useGlobalParams();
const { applyActions, previewActions } = useAssistantActions();
// ── Load providers on mount ──
useEffect(() => {
getProviders()
.then((p) => {
setProviders(p);
// Auto-select first available provider if none persisted
if (!selectedProvider) {
const first = p.find(pr => pr.available);
if (first) {
setSelectedProvider(first.id);
setSelectedModel(first.default_model);
}
}
})
.catch(err => console.error('[Assistant] Failed to load providers:', err));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// ── Auto-scroll ──
useEffect(() => {
requestAnimationFrame(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
});
}, [messages, streamingText]);
// ── Get current provider's models ──
const currentProvider = providers.find(p => p.id === selectedProvider);
const availableModels = currentProvider?.models || [];
// When provider changes, auto-select its default model
const handleProviderChange = useCallback((providerId: string) => {
setSelectedProvider(providerId);
const p = providers.find(pr => pr.id === providerId);
if (p) setSelectedModel(p.default_model);
}, [providers, setSelectedProvider, setSelectedModel]);
// ── Build history for context ──
const buildHistory = useCallback((): ChatMessage[] => {
return messages
.filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => ({
role: m.role as 'user' | 'assistant',
content: m.content,
}));
}, [messages]);
// ── Send message ──
const handleSend = useCallback(() => {
const text = input.trim();
if (!text || isStreaming || !selectedProvider) return;
// Add user message
const userMsg: DisplayMessage = {
id: ++messageIdCounter,
role: 'user',
content: text,
};
setMessages(prev => [...prev, userMsg]);
setInput('');
setIsStreaming(true);
setStreamingText('');
// Get current settings snapshot (engine params + content fields from localStorage)
const readLS = <T,>(key: string, fallback: T): T => {
try { const v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; } catch { return fallback; }
};
const currentSettings = {
...globalParams.getGlobalParams(),
// Active mode/view
_activeView: activeView || 'create',
// Content fields (stored in localStorage by CreatePanel)
caption: readLS('hs-caption', ''),
lyrics: readLS('hs-lyrics', ''),
instrumental: readLS('hs-instrumental', false),
bpm: readLS('hs-bpm', 0),
duration: readLS('hs-duration', -1),
keyScale: readLS('hs-keyScale', ''),
timeSignature: readLS('hs-timeSignature', ''),
vocalLanguage: readLS('hs-vocalLanguage', 'en'),
};
// Start streaming
const abort = chatStream(
{
message: text,
history: buildHistory(),
currentSettings,
provider: selectedProvider,
model: selectedModel || undefined,
},
// onChunk
(chunk) => {
setStreamingText(prev => prev + chunk);
},
// onComplete
(fullText) => {
// Parse actions from the clean (non-thinking) text
const cleanText = stripThinkingBlocks(fullText);
const actions = parseActions(cleanText);
const displayContent = stripActionBlocks(cleanText);
const assistantMsg: DisplayMessage = {
id: ++messageIdCounter,
role: 'assistant',
content: displayContent,
rawContent: fullText, // keep raw for thinking extraction
actions: actions.length > 0 ? actions : undefined,
};
setMessages(prev => [...prev, assistantMsg]);
setStreamingText('');
setIsStreaming(false);
},
// onError
(error) => {
const errorMsg: DisplayMessage = {
id: ++messageIdCounter,
role: 'error',
content: error,
};
setMessages(prev => [...prev, errorMsg]);
setStreamingText('');
setIsStreaming(false);
},
);
abortRef.current = abort;
}, [input, isStreaming, selectedProvider, selectedModel, globalParams, buildHistory]);
// ── Handle Enter key ──
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}, [handleSend]);
// ── Apply actions from a message ──
const handleApply = useCallback((msgId: number, actions: AssistantAction[]) => {
const count = applyActions(actions);
console.log(`[Assistant] Applied ${count} setting changes`);
const newKeys = new Set(actions.map(a => a.set));
setMessages(prev => prev.map(m =>
m.id === msgId
? { ...m, appliedKeys: new Set([...(m.appliedKeys || []), ...newKeys]) }
: m
));
}, [applyActions]);
// ── Clear chat ──
const handleClear = useCallback(() => {
if (isStreaming && abortRef.current) {
abortRef.current.abort();
}
setMessages([]);
setStreamingText('');
setIsStreaming(false);
}, [isStreaming]);
// ── Quick suggestion ──
const handleSuggestion = useCallback((text: string) => {
setInput(text);
// Focus the input after setting
requestAnimationFrame(() => inputRef.current?.focus());
}, []);
// ── Auto-resize textarea ──
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value);
const el = e.target;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
}, []);
// ── Render ──
return (
<div className="assistant-panel">
{/* Header */}
<div className="assistant-header">
<div className="assistant-header-left">
<Sparkles size={14} style={{ color: 'rgba(139, 92, 246, 0.7)' }} />
<span className="assistant-title">Assistant</span>
</div>
<div className="assistant-header-actions">
<button onClick={handleClear} className="assistant-header-btn" title="Clear chat">
<Trash2 size={12} />
</button>
<button onClick={onClose} className="assistant-header-btn" title="Close assistant">
<X size={12} />
</button>
</div>
</div>
{/* Provider & model selectors */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/80 dark:bg-zinc-900/50">
<div className="flex-1 min-w-0">
<label className="block text-[10px] font-medium text-zinc-500 uppercase tracking-wider mb-1">Provider</label>
<select
className="w-full px-3 py-1.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/20 outline-none transition-colors cursor-pointer"
value={selectedProvider}
onChange={(e) => handleProviderChange(e.target.value)}
>
{providers.length === 0 && <option value="">Loading...</option>}
{providers.map(p => (
<option key={p.id} value={p.id} disabled={!p.available}>
{p.name}{!p.available ? ' (unavailable)' : ''}
</option>
))}
</select>
</div>
<div className="flex-1 min-w-0">
<label className="block text-[10px] font-medium text-zinc-500 uppercase tracking-wider mb-1">Model</label>
<select
className="w-full px-3 py-1.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/20 outline-none transition-colors cursor-pointer"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
>
{availableModels.map(m => (
<option key={m} value={m}>{m}</option>
))}
{availableModels.length === 0 && (
<option value="">Default</option>
)}
</select>
</div>
</div>
{/* Messages or welcome screen */}
{messages.length === 0 && !isStreaming ? (
<div className="assistant-welcome">
<div className="assistant-welcome-icon"></div>
<div className="assistant-welcome-title">HOT-Step Assistant</div>
<div className="assistant-welcome-subtitle">
I can help you configure settings, recommend presets, troubleshoot issues, and adjust parameters directly.
</div>
<div className="assistant-welcome-suggestions">
<button className="assistant-suggestion-btn" onClick={() => handleSuggestion('Set me up for lo-fi hip hop')}>
🎵 Set me up for lo-fi hip hop
</button>
<button className="assistant-suggestion-btn" onClick={() => handleSuggestion('What solver should I use for clean vocals?')}>
🎤 Best solver for clean vocals?
</button>
<button className="assistant-suggestion-btn" onClick={() => handleSuggestion('Review my current settings and suggest improvements')}>
🔧 Review my current settings
</button>
<button className="assistant-suggestion-btn" onClick={() => handleSuggestion('How do I reduce metallic artifacts?')}>
🔇 Fix metallic artifacts
</button>
</div>
</div>
) : (
<div className="assistant-messages" ref={scrollRef}>
{messages.map((msg) => {
// Extract thinking for assistant messages
const parsed = msg.role === 'assistant' && msg.rawContent
? extractThinkingAndResponse(msg.rawContent)
: null;
return (
<React.Fragment key={msg.id}>
{/* Thinking block (collapsible) */}
{parsed?.thinking && (
<ThinkingBlock thinking={parsed.thinking} isStreaming={false} />
)}
{/* Message bubble */}
<div className={`assistant-msg assistant-msg--${msg.role}`}>
{msg.role === 'assistant'
? <SimpleMarkdown content={msg.content} />
: msg.content
}
</div>
{/* Action card */}
{msg.role === 'assistant' && msg.actions && msg.actions.length > 0 && (
<ActionCard
actions={msg.actions}
appliedKeys={msg.appliedKeys || new Set()}
onApplySelected={(selectedActions) => handleApply(msg.id, selectedActions)}
previewActions={previewActions}
/>
)}
</React.Fragment>
);
})}
{/* Streaming message */}
{isStreaming && streamingText && (() => {
const parsed = extractThinkingAndResponse(streamingText);
const isStillThinking = parsed.thinking && !parsed.response;
return (
<>
{parsed.thinking && (
<ThinkingBlock thinking={parsed.thinking} isStreaming={isStillThinking || false} />
)}
{parsed.response && (
<div className="assistant-msg assistant-msg--assistant assistant-msg--streaming">
<SimpleMarkdown content={parsed.response} />
<span className="assistant-cursor" />
</div>
)}
{!parsed.response && !parsed.thinking && (
<div className="assistant-msg assistant-msg--assistant assistant-msg--streaming">
<span className="assistant-cursor" />
</div>
)}
</>
);
})()}
{/* Streaming but no text yet */}
{isStreaming && !streamingText && (
<div className="assistant-msg assistant-msg--assistant assistant-msg--streaming">
<span className="assistant-cursor" />
</div>
)}
</div>
)}
{/* Input bar */}
<div className="assistant-input-bar">
<textarea
ref={inputRef}
className="assistant-input"
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={isStreaming ? 'Waiting for response...' : 'Ask about settings, request a preset...'}
disabled={isStreaming}
rows={1}
/>
<button
className="assistant-send-btn"
onClick={handleSend}
disabled={isStreaming || !input.trim() || !selectedProvider}
title="Send message"
>
<Send size={16} />
</button>
</div>
</div>
);
};
// ── Action Card sub-component ─────────────────────────────────────────────────
interface ActionCardProps {
actions: AssistantAction[];
appliedKeys: Set<string>;
onApplySelected: (actions: AssistantAction[]) => void;
previewActions: (actions: AssistantAction[]) => ActionDiff[];
}
const ActionCard: React.FC<ActionCardProps> = ({ actions, appliedKeys, onApplySelected, previewActions }) => {
const diffs = previewActions(actions);
const allApplied = actions.every(a => appliedKeys.has(a.set));
const unapplied = actions.filter(a => !appliedKeys.has(a.set));
// Format a value for display
const fmt = (v: any): string => {
if (v === undefined || v === null) return '—';
if (typeof v === 'boolean') return v ? 'ON' : 'OFF';
if (typeof v === 'number') return String(v);
return String(v);
};
return (
<div className="assistant-action-card">
<div className="assistant-action-card-title">
<Sparkles size={11} />
Suggested Changes
</div>
<div className="assistant-action-diff">
{diffs.map((d) => {
const isApplied = appliedKeys.has(d.key);
const action = actions.find(a => a.set === d.key);
return (
<div key={d.key} className={`assistant-action-row ${isApplied ? 'assistant-action-row--applied' : ''}`}>
<span className="assistant-action-label">{d.label}</span>
<span className="assistant-action-from">{fmt(d.from)}</span>
<ArrowRight size={10} className="assistant-action-arrow" />
<span className="assistant-action-to">{fmt(d.to)}</span>
<span className="assistant-action-row-btn-area">
{isApplied ? (
<span className="assistant-action-applied-badge"><Check size={10} /></span>
) : action ? (
<button
className="assistant-action-apply-one"
onClick={() => onApplySelected([action])}
title={`Apply ${d.label}`}
>
Apply
</button>
) : null}
</span>
</div>
);
})}
</div>
<div className="assistant-action-buttons">
{allApplied ? (
<button className="assistant-apply-btn assistant-apply-btn--applied" disabled>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px', justifyContent: 'center' }}>
<Check size={12} /> All Applied
</span>
</button>
) : (
<button className="assistant-apply-btn" onClick={() => onApplySelected(unapplied)}>
Apply All ({unapplied.length})
</button>
)}
</div>
</div>
);
};
// ── Thinking Block sub-component ──────────────────────────────────────────────
interface ThinkingBlockProps {
thinking: string;
isStreaming: boolean;
}
const ThinkingBlock: React.FC<ThinkingBlockProps> = ({ thinking, isStreaming }) => (
<details className="assistant-thinking" open={isStreaming}>
<summary className="assistant-thinking-summary">
<svg className="thinking-chevron" width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<path d="M3 1.5L7 5L3 8.5" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" />
</svg>
<span className={isStreaming ? 'assistant-thinking-label' : ''}>
💭 {isStreaming ? 'Thinking...' : 'Thought process'}
</span>
</summary>
<div className="assistant-thinking-content">
{thinking}
{isStreaming && <span className="assistant-cursor" />}
</div>
</details>
);
@@ -0,0 +1,172 @@
// SimpleMarkdown.tsx — Lightweight markdown renderer for assistant chat
//
// Handles the subset of markdown that LLMs commonly output:
// - Headings (###, ##, #)
// - Bold (**text**)
// - Italic (*text*)
// - Inline code (`code`)
// - Fenced code blocks (```...```)
// - Unordered lists (- item, * item)
// - Ordered lists (1. item)
// - Horizontal rules (---, ***)
// - Line breaks
//
// No external dependencies. Intentionally simple — this isn't a full
// markdown parser, just enough to make LLM responses readable.
import React from 'react';
/** Parse inline markdown (bold, italic, code) within a text string */
function parseInline(text: string): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
// Regex: inline code, bold, italic (in priority order)
const re = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)/g;
let lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
// Text before the match
if (match.index > lastIndex) {
nodes.push(text.slice(lastIndex, match.index));
}
if (match[1]) {
// Inline code
const code = match[1].slice(1, -1);
nodes.push(
<code key={match.index} className="smd-inline-code">{code}</code>
);
} else if (match[2]) {
// Bold
const bold = match[2].slice(2, -2);
nodes.push(
<strong key={match.index} className="smd-bold">{bold}</strong>
);
} else if (match[3]) {
// Italic
const italic = match[3].slice(1, -1);
nodes.push(
<em key={match.index} className="smd-italic">{italic}</em>
);
}
lastIndex = match.index + match[0].length;
}
// Remaining text
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes.length > 0 ? nodes : [text];
}
interface SimpleMarkdownProps {
content: string;
}
export const SimpleMarkdown: React.FC<SimpleMarkdownProps> = ({ content }) => {
const lines = content.split('\n');
const elements: React.ReactNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
// Empty line → spacer
if (!trimmed) {
elements.push(<div key={i} className="smd-spacer" />);
i++;
continue;
}
// Fenced code block
if (trimmed.startsWith('```')) {
const lang = trimmed.slice(3).trim();
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].trim().startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
i++; // skip closing ```
elements.push(
<pre key={`code-${i}`} className="smd-code-block">
{lang && <span className="smd-code-lang">{lang}</span>}
<code>{codeLines.join('\n')}</code>
</pre>
);
continue;
}
// Horizontal rule
if (/^[-*_]{3,}$/.test(trimmed)) {
elements.push(<hr key={i} className="smd-hr" />);
i++;
continue;
}
// Headings
if (trimmed.startsWith('### ')) {
elements.push(
<h4 key={i} className="smd-h3">{parseInline(trimmed.slice(4))}</h4>
);
i++;
continue;
}
if (trimmed.startsWith('## ')) {
elements.push(
<h3 key={i} className="smd-h2">{parseInline(trimmed.slice(3))}</h3>
);
i++;
continue;
}
if (trimmed.startsWith('# ')) {
elements.push(
<h2 key={i} className="smd-h1">{parseInline(trimmed.slice(2))}</h2>
);
i++;
continue;
}
// Unordered list items (collect consecutive)
if (/^[-*•]\s/.test(trimmed)) {
const items: { key: number; content: React.ReactNode[] }[] = [];
while (i < lines.length && /^[-*•]\s/.test(lines[i].trim())) {
items.push({ key: i, content: parseInline(lines[i].trim().slice(2)) });
i++;
}
elements.push(
<ul key={`ul-${items[0].key}`} className="smd-ul">
{items.map(it => <li key={it.key} className="smd-li">{it.content}</li>)}
</ul>
);
continue;
}
// Ordered list items (collect consecutive)
if (/^\d+[.)]\s/.test(trimmed)) {
const items: { key: number; content: React.ReactNode[] }[] = [];
while (i < lines.length && /^\d+[.)]\s/.test(lines[i].trim())) {
const text = lines[i].trim().replace(/^\d+[.)]\s/, '');
items.push({ key: i, content: parseInline(text) });
i++;
}
elements.push(
<ol key={`ol-${items[0].key}`} className="smd-ol">
{items.map(it => <li key={it.key} className="smd-li">{it.content}</li>)}
</ol>
);
continue;
}
// Regular paragraph
elements.push(
<p key={i} className="smd-p">{parseInline(trimmed)}</p>
);
i++;
}
return <div className="smd-root">{elements}</div>;
};
@@ -0,0 +1,315 @@
// ArtistSettingsPanel.tsx — Right panel: artist selector + cover settings + generate
import React from 'react';
import { Guitar, Disc3, Zap, Music, ChevronDown, Loader2, Type, X, Mic, RefreshCw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { EditableSlider } from './EditableSlider';
import { transposeKey, type AudioAnalysis } from './coverStudioUtils';
import type { Artist, AlbumPreset } from '../../services/lireekApi';
interface ArtistSettingsPanelProps {
artists: Artist[];
isLoadingArtists: boolean;
selectedArtistId: number | null;
onSelectArtist: (a: Artist) => void;
onClearArtist: () => void;
artistPresets: { lsId: number; album: string; preset: AlbumPreset | null }[];
selectedPreset: AlbumPreset | null;
onSelectPreset: (p: AlbumPreset | null) => void;
audioCoverStrength: number;
onAudioCoverStrength: (v: number) => void;
coverNoiseStrength: number;
onCoverNoiseStrength: (v: number) => void;
coverNoiseMethod: string;
onCoverNoiseMethodChange: (v: string) => void;
noFsq: boolean;
onNoFsqChange: (v: boolean) => void;
instrumental: boolean;
onInstrumentalChange: (v: boolean) => void;
tempoScale: number;
onTempoScale: (v: number) => void;
pitchShift: number;
onPitchShift: (v: number) => void;
analysis: AudioAnalysis | null;
bpmCorrection: number;
keyOverride: string | null;
artistCaption: string;
onArtistCaptionChange: (v: string) => void;
canGenerate: boolean;
isGenerating: boolean;
genProgress: number;
genStage: string;
onGenerate: () => void;
onCancel: () => void;
isGeneratingCaption: boolean;
onRegenerateCaption: () => void;
}
export const ArtistSettingsPanel: React.FC<ArtistSettingsPanelProps> = (props) => {
const {
artists, isLoadingArtists, selectedArtistId, onSelectArtist, onClearArtist,
artistPresets, selectedPreset, onSelectPreset,
audioCoverStrength, onAudioCoverStrength, coverNoiseStrength, onCoverNoiseStrength,
coverNoiseMethod, onCoverNoiseMethodChange,
noFsq, onNoFsqChange, instrumental, onInstrumentalChange,
tempoScale, onTempoScale, pitchShift, onPitchShift, analysis, bpmCorrection, keyOverride,
artistCaption, onArtistCaptionChange,
canGenerate, isGenerating, genProgress, genStage, onGenerate, onCancel,
isGeneratingCaption, onRegenerateCaption,
} = props;
const { t } = useTranslation();
const presetsWithAdapters = artistPresets.filter(p => p.preset?.adapter_path);
const effectiveKey = keyOverride || analysis?.key || null;
return (
<div className="w-[540px] flex-shrink-0 overflow-y-auto scrollbar-hide p-4 space-y-4">
{/* Target Artist */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-300">
<Guitar className="w-4 h-4 text-cyan-400" />
{t('cover.targetArtist')}
<span className="text-[10px] font-normal text-zinc-500">(optional)</span>
{selectedArtistId && (
<button
onClick={onClearArtist}
className="ml-auto flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium text-zinc-400 hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<X className="w-3 h-3" />
{t('cover.clearArtist')}
</button>
)}
</div>
{isLoadingArtists ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 text-zinc-500 animate-spin" />
</div>
) : artists.length === 0 ? (
<div className="text-center py-4">
<p className="text-xs text-zinc-500">{t('cover.noArtistsAvailable')}</p>
<p className="text-[10px] text-zinc-600 dark:text-zinc-400 mt-1">Describe the target style below, or add artists in Lyric Studio for adapter-powered covers.</p>
</div>
) : (
<div className="grid grid-cols-5 gap-2 max-h-[240px] overflow-y-auto scrollbar-hide">
{artists.map(artist => (
<button
key={artist.id}
onClick={() => onSelectArtist(artist)}
className={`
flex flex-col items-center gap-1.5 p-2 rounded-xl transition-all duration-200
${selectedArtistId === artist.id
? 'bg-cyan-500/20 ring-2 ring-cyan-400 shadow-lg shadow-cyan-500/10'
: 'bg-black/5 dark:bg-white/5 hover:bg-cyan-500/10 hover:ring-1 hover:ring-cyan-400/50'}
`}
>
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-cyan-500 to-teal-600 flex items-center justify-center overflow-hidden flex-shrink-0">
{artist.image_url ? (
<img src={artist.image_url} alt={artist.name} className="w-full h-full object-cover" />
) : (
<span className="text-white text-sm font-bold">{artist.name.charAt(0)}</span>
)}
</div>
<span className="text-[10px] font-medium text-zinc-700 dark:text-zinc-300 truncate w-full text-center">
{artist.name}
</span>
</button>
))}
</div>
)}
{/* Album selector */}
{presetsWithAdapters.length > 1 && (
<div className="flex items-center gap-2">
<label className="text-[10px] font-medium text-zinc-500 uppercase whitespace-nowrap">Album</label>
<div className="relative flex-1">
<select
value={artistPresets.findIndex(p => p.preset === selectedPreset)}
onChange={e => {
const chosen = artistPresets[parseInt(e.target.value)];
if (chosen?.preset) {
onSelectPreset(chosen.preset);
}
}}
className="w-full appearance-none rounded-lg bg-black/5 dark:bg-white/5 border border-zinc-200 dark:border-zinc-700 px-3 py-1.5 pr-8 text-xs text-zinc-700 dark:text-zinc-300 cursor-pointer focus:ring-2 focus:ring-cyan-500/50 focus:outline-none"
>
{presetsWithAdapters.map(p => {
const idx = artistPresets.indexOf(p);
return <option key={p.lsId} value={idx}>{p.album}</option>;
})}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-zinc-600 dark:text-zinc-400 pointer-events-none" />
</div>
</div>
)}
{/* Selected preset info */}
{selectedPreset && (
<div className="rounded-lg bg-cyan-500/5 border border-cyan-500/20 px-3 py-2 space-y-1">
{selectedPreset.adapter_path && (
<div className="flex items-center gap-2">
<Zap className="w-3 h-3 text-pink-400" />
<span className="text-[10px] text-zinc-500 truncate flex-1">
{selectedPreset.adapter_path.split(/[\\/]/).pop()}
</span>
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-pink-900/30 text-pink-400">ADAPTER</span>
</div>
)}
{selectedPreset.reference_track_path && (
<div className="flex items-center gap-2">
<Music className="w-3 h-3 text-amber-400" />
<span className="text-[10px] text-zinc-500 truncate flex-1">
{selectedPreset.reference_track_path.split(/[\\/]/).pop()}
</span>
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-amber-900/30 text-amber-400">REF</span>
</div>
)}
</div>
)}
</div>
{/* Style Description */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-300">
<Type className="w-4 h-4 text-purple-400" />
Style Description
{selectedArtistId && (
<button
onClick={onRegenerateCaption}
disabled={isGeneratingCaption}
className="ml-auto flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium text-purple-400 hover:text-purple-300 hover:bg-purple-500/10 transition-colors disabled:opacity-50"
title="Regenerate style caption using LLM"
>
{isGeneratingCaption ? <Loader2 className="w-3 h-3 animate-spin" /> : <RefreshCw className="w-3 h-3" />}
{isGeneratingCaption ? 'Generating...' : 'Generate'}
</button>
)}
</div>
<textarea
value={artistCaption}
onChange={e => onArtistCaptionChange(e.target.value)}
placeholder="Describe the target style, e.g. 'indie rock, breathy female vocal, lo-fi production, dreamy reverb, 2010s alternative'"
className="w-full h-24 resize-none rounded-xl bg-white dark:bg-black/20 border border-zinc-200 dark:border-white/10 px-3 py-2 text-xs text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-purple-500 transition-colors leading-relaxed"
/>
<p className="text-[10px] text-zinc-500 leading-tight">
{selectedArtistId
? 'Auto-filled from artist. Edit freely to refine the style.'
: 'Describe genre, instruments, vocal style, production, mood — used as the generation caption.'}
</p>
</div>
<div className="border-t border-zinc-200 dark:border-white/5" />
{/* Instrumental Toggle */}
<div className="flex items-center justify-between px-1 py-1">
<div className="flex items-center gap-2">
<Mic className="w-4 h-4 text-amber-400" />
<div>
<span className="text-xs font-medium text-zinc-700 dark:text-zinc-300">{t('cover.instrumental')}</span>
<p className="text-[10px] text-zinc-500 leading-tight mt-0.5">{t('cover.instrumentalHelp')}</p>
</div>
</div>
<button
onClick={() => onInstrumentalChange(!instrumental)}
className={`relative w-9 h-5 rounded-full transition-colors duration-200 flex-shrink-0 ${
instrumental ? 'bg-amber-500' : 'bg-zinc-300 dark:bg-zinc-700'
}`}
>
<div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform duration-200 ${
instrumental ? 'translate-x-[18px]' : 'translate-x-0.5'
}`} />
</button>
</div>
<div className="border-t border-zinc-200 dark:border-white/5" />
{/* Cover Settings */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-300">
<Disc3 className="w-4 h-4 text-teal-400" />
{t('cover.coverSettings')}
</div>
<EditableSlider label="Structure Fidelity" value={audioCoverStrength} min={0} max={1} step={0.05}
onChange={onAudioCoverStrength} formatDisplay={v => v.toFixed(2)}
helpText="How closely the output follows the source's arrangement" />
<EditableSlider label={t('cover.sourcePreservation')} value={coverNoiseStrength} min={0} max={1} step={0.05}
onChange={onCoverNoiseStrength} formatDisplay={v => v.toFixed(2)}
helpText={t('cover.sourcePreservationHelp')} />
{coverNoiseStrength > 0 && (
<div className="flex items-center justify-between px-1 py-1">
<span className="text-xs font-medium text-zinc-700 dark:text-zinc-300">Noise Method</span>
<select
className="appearance-none rounded-lg bg-black/5 dark:bg-white/5 border border-zinc-200 dark:border-zinc-700 px-3 py-1.5 pr-8 text-xs text-zinc-700 dark:text-zinc-300 cursor-pointer focus:ring-2 focus:ring-cyan-500/50 focus:outline-none"
value={coverNoiseMethod}
onChange={(e) => onCoverNoiseMethodChange(e.target.value)}
>
<option value="">Classic (Truncate)</option>
<option value="rescale">Full Denoise (Rescale)</option>
</select>
</div>
)}
{/* NoFSQ toggle */}
<div className="flex items-center justify-between px-1 py-1">
<div>
<span className="text-xs font-medium text-zinc-700 dark:text-zinc-300">NoFSQ Mode</span>
<p className="text-[10px] text-zinc-500 leading-tight mt-0.5">Skip quantization more faithful to source</p>
</div>
<button
onClick={() => onNoFsqChange(!noFsq)}
className={`relative w-9 h-5 rounded-full transition-colors duration-200 flex-shrink-0 ${
noFsq ? 'bg-cyan-500' : 'bg-zinc-300 dark:bg-zinc-700'
}`}
>
<div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform duration-200 ${
noFsq ? 'translate-x-[18px]' : 'translate-x-0.5'
}`} />
</button>
</div>
<EditableSlider label="Tempo Scale" value={tempoScale} min={0.5} max={2.0} step={0.05}
onChange={onTempoScale}
formatDisplay={v => {
const bpm = analysis?.bpm ? Math.round(analysis.bpm * bpmCorrection) : null;
return bpm ? `${v.toFixed(2)}x (${Math.round(bpm * v)} BPM)` : `${v.toFixed(2)}x`;
}}
helpText={`1.0 = original tempo${analysis?.bpm ? ` (${Math.round(analysis.bpm * bpmCorrection)} BPM)` : ''}`} />
<EditableSlider label="Pitch Shift" value={pitchShift} min={-12} max={12} step={1}
onChange={onPitchShift}
formatDisplay={v => {
const shifted = effectiveKey ? transposeKey(effectiveKey, v) : null;
const sign = v > 0 ? '+' : '';
return shifted && v !== 0 ? `${sign}${v} st → ${shifted}` : `${sign}${v} st`;
}}
helpText={`Semitones (-12 to +12)${effectiveKey ? `. Source: ${effectiveKey}` : ''}`} />
</div>
<div className="border-t border-zinc-200 dark:border-white/5" />
{/* Generate — progress for the running cover (if any) + an always-available
queue button so the user can stack several covers (#62). */}
<div className="space-y-2">
{isGenerating && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-cyan-400 font-medium">{genStage || 'Generating...'}</span>
<span className="text-zinc-500 font-mono">{genProgress}%</span>
</div>
<div className="w-full h-2 bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-cyan-500 to-teal-500 transition-all duration-500 rounded-full"
style={{ width: `${genProgress}%` }} />
</div>
<button onClick={onCancel}
className="w-full py-1.5 rounded-xl text-xs font-medium text-red-400 hover:bg-red-500/10 transition-colors">
{t('common.cancel')}
</button>
</div>
)}
<button onClick={onGenerate} disabled={!canGenerate}
className={`w-full flex items-center justify-center gap-2 px-6 py-3.5 rounded-xl text-sm font-bold transition-all duration-300 shadow-lg
${canGenerate
? 'bg-gradient-to-r from-cyan-500 to-teal-500 hover:from-cyan-400 hover:to-teal-400 text-white shadow-cyan-500/20 hover:shadow-cyan-400/30 hover:scale-[1.02]'
: 'bg-zinc-200 dark:bg-white/5 text-zinc-600 dark:text-zinc-400 cursor-not-allowed shadow-none'}`}>
<Disc3 className="w-4 h-4" />
{isGenerating ? t('cover.addToQueue', 'Add to Queue') : t('cover.generateCover')}
</button>
</div>
</div>
);
};
@@ -0,0 +1,231 @@
/**
* CoverRecentSongs.tsx — Shows recently generated covers in Cover Studio.
*
* Fetches songs with source=cover-studio, provides play/download/delete.
* Download uses cover-specific filename: Target Artist - Track Name (Source Artist Cover)
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Play, Loader2, Music, Download, Trash2, ListPlus, Check } from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
import { songApi } from '../../services/api';
import type { Song } from '../../types';
import { downloadTrack } from '../../utils/downloadTrack';
import { usePlaylist } from '../lyric-studio/playlistStore';
import { playFromList, songToTrack, usePlaybackSelector } from '../../stores/playbackStore';
import { useDisguiseMode } from '../../hooks/useDisguiseMode';
interface CoverRecentSongsProps {
showToast: (msg: string, type?: 'success' | 'error') => void;
refreshKey?: number;
}
// ── Module-level cache ───────────────────────────────────────────────────────
let _cachedCovers: Song[] = [];
let _cachedRefreshKey = -1;
let _fetchInFlight = false;
// ── Component ────────────────────────────────────────────────────────────────
export const CoverRecentSongs: React.FC<CoverRecentSongsProps> = ({ showToast, refreshKey = 0 }) => {
const { token } = useAuth();
const currentTrackId = usePlaybackSelector(s => s.currentTrack?.id ?? null);
const [songs, setSongs] = useState<Song[]>(_cachedCovers);
const [loading, setLoading] = useState(_cachedCovers.length === 0);
const mountedRef = useRef(true);
const { disguiseArtist, disguiseTitle } = useDisguiseMode();
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
_cachedRefreshKey = -1;
};
}, []);
useEffect(() => {
if (!token) { setLoading(false); return; }
if (_cachedRefreshKey === refreshKey && _cachedCovers.length > 0) return;
if (_fetchInFlight) return;
if (_cachedCovers.length === 0) setLoading(true);
_fetchInFlight = true;
fetch('/api/songs?source=cover-studio', {
headers: { Authorization: `Bearer ${token}` },
})
.then(res => res.json())
.then(data => {
const allSongs: Song[] = (data.songs || [])
.filter((s: any) => !!s.audio_url)
.map((s: any): Song => ({
id: s.id,
title: s.title || 'Untitled Cover',
lyrics: s.lyrics || '',
style: s.style || '',
caption: s.caption || '',
audioUrl: s.audio_url || '',
masteredAudioUrl: s.mastered_audio_url || '',
mastered_audio_url: s.mastered_audio_url || '',
latentUrl: s.latent_url || '',
latent_url: s.latent_url || '',
duration: s.duration || 0,
tags: s.tags || [],
createdAt: new Date(s.created_at),
artistName: s.artist || '',
generationParams: typeof s.generation_params === 'string'
? JSON.parse(s.generation_params || '{}')
: (s.generation_params || {}),
}))
.sort((a: Song, b: Song) => {
const aTime = a.createdAt instanceof Date ? a.createdAt.getTime() : 0;
const bTime = b.createdAt instanceof Date ? b.createdAt.getTime() : 0;
return bTime - aTime;
})
.slice(0, 50);
_cachedCovers = allSongs;
_cachedRefreshKey = refreshKey;
_fetchInFlight = false;
if (mountedRef.current) { setSongs(allSongs); setLoading(false); }
})
.catch(() => {
_fetchInFlight = false;
if (mountedRef.current) setLoading(false);
});
}, [refreshKey, token]);
const handlePlay = useCallback((song: Song) => {
playFromList(songToTrack(song), songs.map(songToTrack), 'cover-studio');
}, [songs]);
const handleDelete = useCallback(async (e: React.MouseEvent, song: Song) => {
e.stopPropagation();
if (!token) return;
try {
await songApi.delete(song.id, token);
setSongs(prev => {
const updated = prev.filter(s => s.id !== song.id);
_cachedCovers = updated;
return updated;
});
showToast('Cover deleted');
} catch {
showToast('Failed to delete', 'error');
}
}, [token, showToast]);
const handleDownloadClick = useCallback((e: React.MouseEvent, song: Song) => {
e.stopPropagation();
const gp = song.generationParams as any;
const targetArtist = gp?.artistName || song.artistName || '';
downloadTrack(song, { artistName: targetArtist });
}, []);
if (loading && songs.length === 0) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-4 h-4 text-zinc-500 animate-spin" />
</div>
);
}
if (songs.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8 text-center px-4">
<Music className="w-5 h-5 text-zinc-600 mb-2" />
<p className="text-xs text-zinc-500">No covers yet</p>
</div>
);
}
return (
<>
<div className="grid grid-cols-1 auto-rows-[4.5rem] gap-1 px-2 py-1.5 overflow-y-auto scrollbar-hide" style={{ maxHeight: '100%' }}>
{songs.map((song) => {
const dur = typeof song.duration === 'number' ? song.duration : 0;
const mins = Math.floor(dur / 60);
const secs = String(Math.floor(dur % 60)).padStart(2, '0');
const isCurrent = currentTrackId === song.id;
const gp = song.generationParams as any;
const targetArtist = gp?.artistName || song.artistName || '';
return (
<div key={song.id}
className={`flex items-center gap-2.5 rounded-lg hover:bg-white/[0.06] transition-colors text-left group px-2 overflow-hidden relative cursor-pointer ${isCurrent ? 'bg-cyan-500/10 ring-1 ring-cyan-500/30' : ''}`}
onClick={() => handlePlay(song)}>
<div className="w-14 h-14 rounded-md flex-shrink-0 overflow-hidden bg-zinc-100 dark:bg-zinc-800 relative">
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-cyan-900/40 to-purple-900/40">
<Music className="w-5 h-5 text-cyan-500/60" />
</div>
<div className="absolute inset-0 bg-black/20 dark:bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Play className="w-4 h-4 text-white ml-0.5" />
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-zinc-800 dark:text-zinc-200 truncate leading-snug">
{disguiseTitle(song.title || 'Untitled Cover')}
</p>
{targetArtist && (
<p className="text-[10px] text-zinc-500 truncate leading-snug">{disguiseArtist(targetArtist)}</p>
)}
{dur > 0 && (
<p className="text-[10px] text-zinc-600 font-mono mt-0.5">{mins}:{secs}</p>
)}
</div>
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<CoverAddToPlaylistBtn song={song} />
<button onClick={(e) => handleDownloadClick(e, song)}
className="p-1.5 rounded-md bg-zinc-100/80 dark:bg-zinc-800/80 hover:bg-zinc-300 dark:hover:bg-zinc-700 text-zinc-600 dark:text-zinc-400 hover:text-white transition-colors"
title="Download">
<Download className="w-3 h-3" />
</button>
<button onClick={(e) => handleDelete(e, song)}
className="p-1.5 rounded-md bg-zinc-100/80 dark:bg-zinc-800/80 hover:bg-red-100 dark:hover:bg-red-900/60 text-zinc-600 dark:text-zinc-400 hover:text-red-400 transition-colors"
title="Delete">
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
);
})}
</div>
</>
);
};
// ── Add-to-playlist helper ───────────────────────────────────────────────────
const CoverAddToPlaylistBtn: React.FC<{ song: Song }> = ({ song }) => {
const playlist = usePlaylist();
const inPlaylist = playlist.isIn(song.id);
const gp = song.generationParams as any;
const targetArtist = gp?.artistName || song.artistName || '';
const toggle = (e: React.MouseEvent) => {
e.stopPropagation();
if (inPlaylist) {
playlist.remove(song.id);
} else {
playlist.add({
id: song.id,
title: song.title || 'Untitled Cover',
audioUrl: song.audioUrl || '',
masteredAudioUrl: song.masteredAudioUrl || '',
artistName: targetArtist,
coverUrl: '',
duration: typeof song.duration === 'number' ? song.duration : 0,
});
}
};
return (
<button onClick={toggle}
className={`p-1.5 rounded-md transition-colors ${
inPlaylist ? 'bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30'
: 'bg-zinc-100/80 dark:bg-zinc-800/80 hover:bg-zinc-300 dark:hover:bg-zinc-700 text-zinc-600 dark:text-zinc-400 hover:text-cyan-400'
}`}
title={inPlaylist ? 'Remove from playlist' : 'Add to playlist'}>
{inPlaylist ? <Check className="w-3 h-3" /> : <ListPlus className="w-3 h-3" />}
</button>
);
};
@@ -0,0 +1,825 @@
// CoverStudio.tsx — Main Cover Studio orchestrator
// Composes: SourcePanel, ArtistSettingsPanel, ActivitySidebar
import React, { useState, useEffect, useCallback } from 'react';
import type { Song } from '../../types';
import { Search, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import { useGlobalParamsStore } from '../../context/GlobalParamsContext';
import { usePersistedState } from '../../hooks/usePersistedState';
import { DEFAULT_SETTINGS, type AppSettings } from '../settings/SettingsPanel';
import { generateApi } from '../../services/api';
import { createGenerationTimer, getGenerationTimeoutMinutes } from '../../utils/generationTimer';
import { lireekApi, type Artist, type AlbumPreset } from '../../services/lireekApi';
import {
startSeparation, waitForCompletion, recombineStems, getStemAudioUrl,
type SeparationLevel,
} from '../../services/supersepApi';
import { SourcePanel } from './SourcePanel';
import { ArtistSettingsPanel } from './ArtistSettingsPanel';
import { ActivitySidebar } from '../shared/ActivitySidebar';
import { StemMixer, type StemControl, type MixerStemInfo } from '../shared/StemMixer';
import {
addManualQueueItem, updateManualQueueItem,
completeManualQueueItem, failManualQueueItem,
useAudioGenQueueSelector,
} from '../../stores/audioGenQueueStore';
import {
persist, restore, getTrackCache, saveTrackCacheEntry, transposeKey,
type AudioMetadata, type AudioAnalysis,
} from './coverStudioUtils';
import type { LatentMetadata } from '../shared/LatentImport';
import { loadSelections, saveSelections } from '../lyric-studio/ProviderSelector';
// ── Serial cover-generation queue ────────────────────────────────────────────
// Lets the user stack multiple cover generations (different settings) without
// waiting — same pattern as InstaGen's queue. Jobs run one at a time; the engine
// serializes anyway, this keeps the client-side recombine→submit→poll ordered.
const _coverQueue: Array<() => Promise<void>> = [];
let _coverRunning = false;
// Survives Cover Studio remounts (it unmounts when you leave the tab) so a
// previously-sent library track isn't re-applied every time you revisit (#61).
let _lastConsumedCoverTs = 0;
function enqueueCoverJob(fn: () => Promise<void>) {
_coverQueue.push(fn);
if (!_coverRunning) _drainCoverQueue();
}
async function _drainCoverQueue() {
_coverRunning = true;
while (_coverQueue.length > 0) {
const job = _coverQueue.shift()!;
try { await job(); } catch { /* each job handles its own errors */ }
}
_coverRunning = false;
}
interface CoverStudioProps {
/** A library track to load as the cover source (from "Send to Cover Studio", #61). */
coverSource?: { song: Song; timestamp: number } | null;
}
export const CoverStudio: React.FC<CoverStudioProps> = ({ coverSource }) => {
const { t } = useTranslation();
const { token } = useAuth();
const gp = useGlobalParamsStore();
const [settings] = usePersistedState<AppSettings>('ace-settings', DEFAULT_SETTINGS);
// ── Source audio state ──
const [sourceFileName, setSourceFileName] = useState(() => restore<string>('sourceFileName', ''));
const [sourceAudioUrl, setSourceAudioUrl] = useState(() => restore<string>('sourceAudioUrl', ''));
const [metadata, setMetadata] = useState<AudioMetadata | null>(() => restore('metadata', null));
const [analysis, setAnalysis] = useState<AudioAnalysis | null>(() => restore('analysis', null));
const [isUploading, setIsUploading] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
// ── Song details ──
const [songArtist, setSongArtist] = useState(() => restore<string>('songArtist', ''));
const [songTitle, setSongTitle] = useState(() => restore<string>('songTitle', ''));
const [lyrics, setLyrics] = useState(() => restore<string>('lyrics', ''));
const [isSearchingLyrics, setIsSearchingLyrics] = useState(false);
// ── Target artist ──
const [artists, setArtists] = useState<Artist[]>([]);
const [selectedArtistId, setSelectedArtistId] = useState<number | null>(() => restore('selectedArtistId', null));
const [selectedPreset, setSelectedPreset] = useState<AlbumPreset | null>(() => restore('selectedPreset', null));
const [artistCaption, setArtistCaption] = useState(() => restore<string>('artistCaption', ''));
const [artistPresets, setArtistPresets] = useState<{ lsId: number; album: string; preset: AlbumPreset | null }[]>([]);
const [isLoadingArtists, setIsLoadingArtists] = useState(false);
// ── Cover caption LLM ──
const [coverCaptionProvider, setCoverCaptionProvider] = useState(() => loadSelections().coverCaption.provider);
const [coverCaptionModel, setCoverCaptionModel] = useState(() => loadSelections().coverCaption.model);
const [isGeneratingCaption, setIsGeneratingCaption] = useState(false);
// ── Cover settings ──
const [audioCoverStrength, setAudioCoverStrength] = useState(() => restore<number>('audioCoverStrength', 0.5));
const [coverNoiseStrength, setCoverNoiseStrength] = useState(() => restore<number>('coverNoiseStrength', 0));
const [coverNoiseMethod, setCoverNoiseMethod] = useState(() => restore<string>('coverNoiseMethod', ''));
const [tempoScale, setTempoScale] = useState(() => restore<number>('tempoScale', 1.0));
const [pitchShift, setPitchShift] = useState(() => restore<number>('pitchShift', 0));
const [bpmCorrection, setBpmCorrection] = useState(() => restore<number>('bpmCorrection', 1));
const [bpmOverride, setBpmOverride] = useState<number | null>(() => restore<number | null>('bpmOverride', null));
const [keyOverride, setKeyOverride] = useState<string | null>(() => restore<string | null>('keyOverride', null));
const [noFsq, setNoFsq] = useState(() => restore<boolean>('noFsq', false));
const [instrumental, setInstrumental] = useState(() => restore<boolean>('coverInstrumental', false));
const [sourceLatentUrl, setSourceLatentUrl] = useState(() => restore<string>('sourceLatentUrl', ''));
const [vocalLanguage, setVocalLanguage] = useState(() => restore<string>('coverVocalLanguage', 'en'));
const [timbreOverridePath, setTimbreOverridePath] = useState(() => restore<string>('coverTimbreOverride', ''));
// ── Generation ──
const [isGenerating, setIsGenerating] = useState(false);
const [genProgress, setGenProgress] = useState(0);
const [genStage, setGenStage] = useState('');
const [activeJobId, setActiveJobId] = useState<string | null>(null);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [toast, setToast] = useState('');
const [queueItemId, setQueueItemId] = useState<string | null>(null);
const completionCounter = useAudioGenQueueSelector(s => s.completionCounter);
// ── Sidebar resize ──
const [sidebarWidth, setSidebarWidth] = usePersistedState('hs-activitySidebarWidth', 320);
const handleSidebarResize = useCallback((e: React.MouseEvent) => {
e.preventDefault();
const startX = e.clientX;
const startW = sidebarWidth;
const onMove = (ev: MouseEvent) => {
const newW = Math.min(700, Math.max(240, startW + startX - ev.clientX));
setSidebarWidth(newW);
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}, [sidebarWidth, setSidebarWidth]);
// ── Advanced Mode (SuperSep) ──
const [advancedMode, setAdvancedMode] = useState(false);
const [sepLevel, setSepLevel] = useState<SeparationLevel>(() => restore('sepLevel', 1) as SeparationLevel);
const [isSeparating, setIsSeparating] = useState(false);
const [sepProgress, setSepProgress] = useState(0);
const [sepMessage, setSepMessage] = useState('');
const [sepJobId, setSepJobId] = useState<string | null>(null);
const [sepStems, setSepStems] = useState<MixerStemInfo[] | null>(null);
const [stemControls, setStemControls] = useState<StemControl[]>([]);
const [showMixer, setShowMixer] = useState(false);
// ── Persist ──
useEffect(() => { persist('sourceFileName', sourceFileName); }, [sourceFileName]);
useEffect(() => { persist('sourceAudioUrl', sourceAudioUrl); }, [sourceAudioUrl]);
useEffect(() => { persist('metadata', metadata); }, [metadata]);
useEffect(() => { persist('analysis', analysis); }, [analysis]);
useEffect(() => { persist('songArtist', songArtist); }, [songArtist]);
useEffect(() => { persist('songTitle', songTitle); }, [songTitle]);
useEffect(() => { persist('lyrics', lyrics); }, [lyrics]);
useEffect(() => { persist('selectedArtistId', selectedArtistId); }, [selectedArtistId]);
useEffect(() => { persist('selectedPreset', selectedPreset); }, [selectedPreset]);
useEffect(() => { persist('artistCaption', artistCaption); }, [artistCaption]);
useEffect(() => { persist('audioCoverStrength', audioCoverStrength); }, [audioCoverStrength]);
useEffect(() => { persist('coverNoiseStrength', coverNoiseStrength); }, [coverNoiseStrength]);
useEffect(() => { persist('coverNoiseMethod', coverNoiseMethod); }, [coverNoiseMethod]);
useEffect(() => { persist('tempoScale', tempoScale); }, [tempoScale]);
useEffect(() => { persist('pitchShift', pitchShift); }, [pitchShift]);
useEffect(() => { persist('bpmCorrection', bpmCorrection); }, [bpmCorrection]);
useEffect(() => { persist('bpmOverride', bpmOverride); }, [bpmOverride]);
useEffect(() => { persist('keyOverride', keyOverride); }, [keyOverride]);
useEffect(() => { persist('noFsq', noFsq); }, [noFsq]);
useEffect(() => { persist('coverInstrumental', instrumental); }, [instrumental]);
useEffect(() => { persist('sourceLatentUrl', sourceLatentUrl); }, [sourceLatentUrl]);
useEffect(() => { persist('coverVocalLanguage', vocalLanguage); }, [vocalLanguage]);
useEffect(() => { persist('coverTimbreOverride', timbreOverridePath); }, [timbreOverridePath]);
useEffect(() => { persist('sepLevel', sepLevel); }, [sepLevel]);
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 4000); };
// ── "Send to Cover Studio" — load a library track as the cover source (#61) ──
useEffect(() => {
if (!coverSource || coverSource.timestamp === _lastConsumedCoverTs) return;
_lastConsumedCoverTs = coverSource.timestamp;
const s = coverSource.song;
const gpData: any = s.generationParams || s.generation_params || {};
const audioUrl = s.audioUrl || s.audio_url || '';
// Source audio — reuse the track's server URL directly (loadSourceAudio
// resolves /audio/ paths server-side), so no re-upload is needed.
setSourceAudioUrl(audioUrl);
setSourceFileName(s.title || 'Library track');
setMetadata({
artist: s.artistName || '', title: s.title || '', album: '',
duration: typeof s.duration === 'number' ? s.duration : null,
});
// Text + style descriptions
setSongTitle(s.title || '');
if (s.artistName) setSongArtist(s.artistName);
setLyrics(s.lyrics || gpData.lyrics || '');
setArtistCaption(s.style || s.caption || gpData.caption || '');
// Instrumental / vocal intent
const lyr = (s.lyrics || gpData.lyrics || '').trim().toLowerCase();
setInstrumental(gpData.instrumental === true || lyr === '' || lyr === '[instrumental]');
// Reset transforms/overrides from any previous source
setBpmCorrection(1); setKeyOverride(null); setBpmOverride(null);
setTempoScale(1.0); setPitchShift(0);
// BPM/key — (A) use the track's stored metadata, else (B) analyze the source.
const storedBpm = s.bpm ?? gpData.bpm;
const storedKey = s.key_scale || gpData.keyScale;
if (storedBpm && storedKey) {
setAnalysis({ bpm: Number(storedBpm), key: String(storedKey), scale: String(storedKey).split(' ')[1] });
} else if (audioUrl) {
setIsAnalyzing(true);
fetch('/api/analyze', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audioUrl }),
})
.then(r => (r.ok ? r.json() : null))
.then(d => { if (d) setAnalysis({ bpm: d.bpm || 120, key: `${d.key || 'C'} ${d.scale || 'major'}`, scale: d.scale }); })
.catch(() => { /* leave defaults; user can override BPM/key manually */ })
.finally(() => setIsAnalyzing(false));
}
showToast(t('cover.loadedFromLibrary', 'Loaded source from library'));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [coverSource?.timestamp]);
// ── Load artists on mount ──
useEffect(() => {
setIsLoadingArtists(true);
lireekApi.listArtists()
.then(res => {
setArtists(res.artists);
if (selectedArtistId && !selectedPreset) {
const a = res.artists.find(x => x.id === selectedArtistId);
if (a) loadArtistPresets(a);
}
})
.catch(() => showToast(t('cover.failedToLoadArtists')))
.finally(() => setIsLoadingArtists(false));
}, []);
// ── File upload + analysis pipeline ──
const handleFileSelected = async (file: File) => {
if (!token) { showToast(t('cover.signInFirst')); return; }
setSourceFileName(file.name);
setBpmCorrection(1);
setKeyOverride(null);
// Check track cache
const cached = getTrackCache()[file.name];
if (cached) {
showToast(t('cover.loadedFromCache'));
if (cached.artist) setSongArtist(cached.artist);
if (cached.title) setSongTitle(cached.title);
if (cached.lyrics) setLyrics(cached.lyrics);
setMetadata({ artist: cached.artist || '', title: cached.title || '', album: cached.album || '', duration: cached.duration });
setAnalysis({ bpm: cached.bpm, key: cached.key, scale: cached.scale });
// Still upload the file
setIsUploading(true);
try {
const fd = new FormData(); fd.append('audio', file);
const r = await fetch('/api/upload/audio', { method: 'POST', body: fd });
if (r.ok) { const d = await r.json(); setSourceAudioUrl(d.audio_url || ''); }
} catch {} finally { setIsUploading(false); }
return;
}
// Full pipeline
setIsUploading(true);
let extractedArtist = '', extractedTitle = '', extractedAlbum = '';
let extractedDuration: number | null = null;
try {
// 1. Metadata
const metaFd = new FormData(); metaFd.append('audio', file);
const metaRes = await fetch('/api/analyze/metadata', { method: 'POST', body: metaFd });
if (metaRes.ok) {
const meta = await metaRes.json();
setMetadata(meta);
extractedArtist = meta.artist || '';
extractedTitle = meta.title || '';
extractedAlbum = meta.album || '';
extractedDuration = meta.duration;
if (meta.artist) setSongArtist(meta.artist);
if (meta.title) setSongTitle(meta.title);
}
// 2. Upload
const upFd = new FormData(); upFd.append('audio', file);
const upRes = await fetch('/api/upload/audio', { method: 'POST', body: upFd });
if (!upRes.ok) throw new Error('Upload failed');
const upData = await upRes.json();
const audioUrl = upData.audio_url || '';
setSourceAudioUrl(audioUrl);
// 3. Essentia analysis
setIsUploading(false); setIsAnalyzing(true);
let bpm = 120, key = 'C major', scale: string | undefined;
const anRes = await fetch('/api/analyze', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audioUrl }),
});
if (anRes.ok) {
const d = await anRes.json();
bpm = d.bpm || 120; key = `${d.key || 'C'} ${d.scale || 'major'}`; scale = d.scale;
setAnalysis({ bpm, key, scale });
}
// 4. Cache
saveTrackCacheEntry(file.name, { artist: extractedArtist, title: extractedTitle, album: extractedAlbum, duration: extractedDuration, bpm, key, scale });
} catch (err: any) {
showToast(`Error: ${err.message}`);
} finally { setIsUploading(false); setIsAnalyzing(false); }
};
// ── Lyrics search ──
const handleSearchLyrics = async () => {
if (!songArtist.trim() || !songTitle.trim()) { showToast(t('cover.enterArtistTitle')); return; }
setIsSearchingLyrics(true);
try {
const result = await lireekApi.searchSongLyrics(songArtist.trim(), songTitle.trim());
setLyrics(result.lyrics);
if (result.title) setSongTitle(result.title);
showToast(t('cover.lyricsFound'));
if (sourceFileName) saveTrackCacheEntry(sourceFileName, { lyrics: result.lyrics, artist: songArtist.trim(), title: result.title || songTitle.trim() });
} catch (err: any) { showToast(err.message || t('cover.noLyricsFound')); }
finally { setIsSearchingLyrics(false); }
};
// ── Apply preset to global UI (adapter bar + mastering reference) ──
const applyPresetToGlobal = (preset: AlbumPreset | null) => {
// Only sync the adapter path — never override user's manual scale/group-scale settings
if (preset?.adapter_path) {
gp.setAdapter(preset.adapter_path);
}
if (preset?.reference_track_path) {
gp.setMasteringReference(preset.reference_track_path);
}
};
// ── Artist preset loading ──
const loadArtistPresets = async (artist: Artist) => {
try {
const { lyrics_sets } = await lireekApi.listLyricsSets(artist.id);
const results: typeof artistPresets = [];
for (const ls of lyrics_sets) {
try {
const { preset } = await lireekApi.getPreset(ls.id);
results.push({ lsId: ls.id, album: ls.album || ls.id.toString(), preset });
} catch { results.push({ lsId: ls.id, album: ls.album || ls.id.toString(), preset: null }); }
}
setArtistPresets(results);
// Find caption — waterfall: generations → profiles → LLM
let caption = '';
// Step 1: Try generation captions
for (const ls of lyrics_sets) {
try {
const { generations } = await lireekApi.listGenerations(undefined, ls.id);
const wc = generations.find(g => g.caption?.trim());
if (wc?.caption) { caption = wc.caption; break; }
} catch {}
}
// Step 2: Try profile style_caption
if (!caption) {
for (const ls of lyrics_sets) {
try {
const profiles = await lireekApi.listProfiles(ls.id);
for (const p of profiles.profiles) {
const full = await lireekApi.getProfile(p.id);
if (full.profile_data?.style_caption) {
caption = full.profile_data.style_caption;
break;
}
}
if (caption) break;
} catch {}
}
}
setArtistCaption(caption);
// Step 3: On-demand LLM generation (async, non-blocking)
if (!caption) {
const sel = loadSelections();
const { provider, model } = sel.coverCaption;
if (provider) {
setIsGeneratingCaption(true);
lireekApi.generateCaption(artist.id, { provider, model: model || undefined })
.then(res => { if (res.caption) setArtistCaption(res.caption); })
.catch(err => console.warn('[CoverStudio] Caption generation failed:', err))
.finally(() => setIsGeneratingCaption(false));
}
}
// Pick adapter preset — use first album with adapter (don't override cover settings)
const withAdapter = results.find(p => p.preset?.adapter_path);
if (withAdapter?.preset) {
setSelectedPreset(withAdapter.preset);
applyPresetToGlobal(withAdapter.preset);
} else { setSelectedPreset(results[0]?.preset || null); }
} catch { setSelectedPreset(null); setArtistPresets([]); setArtistCaption(''); }
};
const handleSelectArtist = async (artist: Artist) => {
setSelectedArtistId(artist.id);
await loadArtistPresets(artist);
};
// ── Cover caption LLM handlers ──
const handleCoverProviderChange = (provider: string) => {
setCoverCaptionProvider(provider);
const sel = loadSelections();
sel.coverCaption = { ...sel.coverCaption, provider };
saveSelections(sel);
};
const handleCoverModelChange = (model: string) => {
setCoverCaptionModel(model);
const sel = loadSelections();
sel.coverCaption = { ...sel.coverCaption, model };
saveSelections(sel);
};
const handleRegenerateCaption = async () => {
if (!selectedArtistId) return;
const sel = loadSelections();
const { provider, model } = sel.coverCaption;
if (!provider) { showToast('Select a Caption LLM provider first'); return; }
setIsGeneratingCaption(true);
try {
const res = await lireekApi.generateCaption(selectedArtistId, { provider, model: model || undefined, force: true });
if (res.caption) setArtistCaption(res.caption);
} catch (err: any) { showToast(`Caption generation failed: ${err.message}`); }
finally { setIsGeneratingCaption(false); }
};
// ── Generation ──
const handleGenerate = () => {
if (!token || !sourceAudioUrl) { showToast(t('cover.missingSrcOrLyrics')); return; }
if (!instrumental && !lyrics.trim()) { showToast('Enter lyrics or enable Instrumental mode'); return; }
// Show a queue item immediately ("Queued…" if a cover is already running),
// then enqueue the work so the user can stack more without waiting (#62).
const coverTitle = songArtist
? `${songTitle || 'Cover'} (${songArtist} Cover)`
: (songTitle || 'Cover');
const qId = addManualQueueItem({
title: coverTitle,
artistName: artists.find(a => a.id === selectedArtistId)?.name || '',
caption: artistCaption || '',
});
updateManualQueueItem(qId, { stage: _coverRunning ? 'Queued…' : 'Preparing…' });
setIsGenerating(true);
showToast(t('cover.genStarted'));
// The closure snapshots the current settings, so each queued cover keeps
// the params it was submitted with.
enqueueCoverJob(async () => {
try {
// Step 0: If advanced mode with stems, auto-recombine before generation
let effectiveSourceUrl = sourceAudioUrl;
if (advancedMode && sepStems && sepStems.length > 0 && sepJobId) {
setGenStage(t('cover.recombiningStems'));
setGenProgress(2);
try {
// Build effective controls — log them for diagnostics
const effectiveControls = stemControls.map(c => ({
index: c.index,
volume: c.muted ? 0 : c.volume,
muted: c.muted,
}));
console.log('[CoverStudio] Auto-recombine controls:', JSON.stringify(effectiveControls));
console.log('[CoverStudio] Stem names:', sepStems.map(s => `[${s.index}] ${s.name}`).join(', '));
const blob = await recombineStems(sepJobId, effectiveControls);
// Upload recombined WAV to get a server-side URL
const fd = new FormData();
fd.append('audio', blob, 'recombined-stems.wav');
const upRes = await fetch('/api/upload/audio', { method: 'POST', body: fd });
if (upRes.ok) {
const { audio_url } = await upRes.json();
effectiveSourceUrl = audio_url;
console.log('[CoverStudio] Using recombined stems:', audio_url);
}
} catch (err: any) {
console.warn('[CoverStudio] Stem recombine failed, using original:', err.message);
showToast(`Stem recombine failed: ${err.message}. Using original audio.`);
}
}
const selectedArtist = artists.find(a => a.id === selectedArtistId);
const sourceBpm = bpmOverride != null ? bpmOverride : ((analysis?.bpm || 120) * bpmCorrection);
const sourceKey = keyOverride || analysis?.key || 'C major';
const targetBpm = Math.round(sourceBpm * tempoScale);
const targetKey = pitchShift !== 0 ? transposeKey(sourceKey, pitchShift) : sourceKey;
// Start from global engine params
const engineParams = gp.getGlobalParams();
// Override with cover-specific params
const params: Record<string, any> = {
...engineParams,
customMode: true,
lyrics: instrumental ? '[Instrumental]' : lyrics,
style: artistCaption || engineParams.style || '',
title: songArtist
? `${songTitle || 'Cover'} (${songArtist} Cover)`
: (songTitle || 'Cover'),
taskType: noFsq ? 'cover-nofsq' : 'cover',
sourceAudioUrl: effectiveSourceUrl,
audioCoverStrength,
coverNoiseStrength,
...(coverNoiseMethod ? { coverNoiseMethod } : {}),
bpm: targetBpm,
keyScale: targetKey,
duration: 0,
instrumental: instrumental,
vocalLanguage,
source: 'cover-studio',
artistName: selectedArtist?.name || songArtist || '',
sourceArtist: songArtist || '',
...(sourceLatentUrl ? { sourceLatentUrl } : {}),
};
if (tempoScale !== 1.0) params.tempoScale = tempoScale;
if (pitchShift !== 0) params.pitchShift = pitchShift;
// Apply album preset adapter (overrides global adapter)
if (selectedPreset?.adapter_path) {
params.loraPath = selectedPreset.adapter_path;
// IMPORTANT: always use the user's manual scale from the adapters dropdown,
// NOT the preset's stored scale — user's manual overrides take priority.
// params.loraScale and params.adapterGroupScales already come from
// engineParams (spread on line 257) and must not be overridden here.
// Override trigger word to match the preset's adapter, not the global one
if (settings.triggerUseFilename) {
const presetFilename = selectedPreset.adapter_path.split(/[\\/]/).pop() || '';
const presetTrigger = presetFilename.replace(/\.safetensors$/i, '');
if (presetTrigger) {
params.triggerWord = presetTrigger;
params.triggerPlacement = settings.triggerPlacement || 'prepend';
}
}
}
// Reference track + matchering from album preset
if (selectedPreset?.reference_track_path) {
params.referenceAudioUrl = selectedPreset.reference_track_path;
params.masteringEnabled = true;
params.masteringReference = selectedPreset.reference_track_path;
// Default: use preset reference as timbre (can be overridden below)
params.timbreReference = true;
}
// Timbre conditioning — user override takes priority over preset reference
if (timbreOverridePath) {
params.timbreReference = timbreOverridePath;
} else if (typeof engineParams.timbreReference === 'string' && engineParams.timbreReference) {
params.timbreReference = engineParams.timbreReference;
}
const res = await generateApi.submit(params as any, token);
updateManualQueueItem(qId, { jobId: res.jobId });
await pollJobAsync(res.jobId, qId);
} catch (err: any) {
failManualQueueItem(qId, err.message || 'Generation failed');
} finally {
// Reset the inline progress only once the whole queue has drained.
if (_coverQueue.length === 0) {
setIsGenerating(false);
setActiveJobId(null);
setGenProgress(0);
setGenStage('');
}
}
});
};
// Poll a single cover job to completion. Resolves on any terminal state so
// the serial queue can advance to the next cover. Updates the inline progress
// (safe — jobs run one at a time) and the per-job queue item.
const pollJobAsync = (jobId: string, qId: string): Promise<void> => new Promise<void>((resolve) => {
setActiveJobId(jobId); setQueueItemId(qId);
setGenProgress(0); setGenStage('Queued...');
// Clock ignores server-queue wait — only real generation time counts.
const timer = createGenerationTimer();
const iv = setInterval(async () => {
try {
const s = await generateApi.status(jobId);
const tk = timer.tick(s.status);
// Server sends 0-100; normalise to 0-100 for display
const rawProg = s.progress;
const pct = rawProg != null
? Math.min(100, Math.max(0, Math.round(rawProg > 1 ? rawProg : rawProg * 100)))
: undefined;
if (pct != null) setGenProgress(pct);
if (s.stage) setGenStage(s.stage);
// Update shared queue item
updateManualQueueItem(qId, {
progress: pct,
stage: s.stage || 'Generating...',
elapsed: tk.elapsed,
});
if (tk.timedOut) {
clearInterval(iv);
showToast(`Generation timed out after ${getGenerationTimeoutMinutes()} minutes`);
failManualQueueItem(qId, 'Generation timed out');
resolve();
return;
}
if (s.status === 'succeeded') {
clearInterval(iv); setGenProgress(100); setGenStage('Complete!');
setRefreshTrigger(p => p + 1); showToast(t('cover.coverGenerated'));
// Complete queue item with audio data
completeManualQueueItem(qId, {
audioUrl: s.result?.audioUrls?.[0] || '',
songId: s.result?.songIds?.[0],
masteredAudioUrl: s.result?.masteredAudioUrl,
audioDuration: s.result?.duration,
});
resolve();
} else if (s.status === 'failed' || s.status === 'cancelled') {
clearInterval(iv);
showToast(`Failed: ${s.error || 'Unknown error'}`);
failManualQueueItem(qId, s.error || (s.status === 'cancelled' ? 'Cancelled' : 'Unknown error'));
resolve();
}
} catch { /* transient poll error — keep polling */ }
}, 2000);
// Absolute backstop so a wedged job can't block the queue forever. Generous
// so it never pre-empts the generation-start timer above.
setTimeout(() => { clearInterval(iv); resolve(); }, (getGenerationTimeoutMinutes() + 30) * 60_000);
});
// Cancel the currently-running cover. The poll sees the cancelled status,
// resolves, and the queue advances to the next item.
const handleCancel = async () => {
if (activeJobId) { try { await generateApi.cancel(activeJobId); } catch {} }
if (queueItemId) failManualQueueItem(queueItemId, 'Cancelled by user');
};
const handleClearSource = () => {
setSourceFileName(''); setSourceAudioUrl('');
setMetadata(null); setAnalysis(null);
setSongArtist(''); setSongTitle(''); setLyrics('');
setBpmCorrection(1); setKeyOverride(null);
// Clear stems too
setSepStems(null); setStemControls([]); setSepJobId(null); setShowMixer(false);
};
const handleClearArtist = () => {
setSelectedArtistId(null);
setSelectedPreset(null);
setArtistPresets([]);
setArtistCaption('');
};
// Always allow queuing another cover — covers stack and run one at a time (#62).
const canGenerate = !!sourceAudioUrl && (!!lyrics.trim() || instrumental);
// ── SuperSep handlers ──
const handleSeparate = useCallback(async () => {
if (!sourceAudioUrl) { showToast(t('cover.uploadAudioFirst')); return; }
setIsSeparating(true);
setSepProgress(0);
setSepMessage(t('cover.startingSeparation'));
setSepStems(null);
try {
// Start separation — pass server URL directly (no need to download/re-upload)
const jobId = await startSeparation(sourceAudioUrl, sepLevel);
setSepJobId(jobId);
// Wait for completion with progress updates
const result = await waitForCompletion(jobId, (progress, message) => {
setSepProgress(progress);
setSepMessage(message);
});
// Map SuperSep stems to shared MixerStemInfo (add audioUrl)
const mixerStems: MixerStemInfo[] = result.stems.map(s => ({
name: s.name,
category: s.category,
audioUrl: getStemAudioUrl(jobId, s.index),
index: s.index,
stage: s.stage,
}));
setSepStems(mixerStems);
// Initialize stem controls (all at 100%, unmuted)
setStemControls(mixerStems.map(s => ({ index: s.index, volume: 1.0, muted: false })));
setShowMixer(true);
showToast(t('cover.separatedIntoStems', { count: result.stems.length }));
} catch (err: any) {
showToast(`Separation failed: ${err.message}`);
} finally {
setIsSeparating(false);
}
}, [sourceAudioUrl, sepLevel]);
// ── Render ──
return (
<div className="flex flex-col w-full h-full bg-zinc-50 dark:bg-suno overflow-hidden">
{/* Toast */}
{toast && (
<div className="absolute top-16 right-6 z-50 px-4 py-2 rounded-xl bg-white dark:bg-zinc-900 text-white text-sm shadow-xl border border-zinc-300 dark:border-white/10 animate-in fade-in slide-in-from-top-2">
{toast}
</div>
)}
{/* Main workspace */}
<div className="flex-1 flex overflow-hidden">
{/* Left: Source Audio */}
<SourcePanel
sourceFileName={sourceFileName} metadata={metadata} analysis={analysis}
isUploading={isUploading} isAnalyzing={isAnalyzing}
onFileSelected={handleFileSelected} onClear={handleClearSource}
bpmCorrection={bpmCorrection} onBpmCorrectionChange={setBpmCorrection}
bpmOverride={bpmOverride} onBpmOverrideChange={setBpmOverride}
keyOverride={keyOverride} onKeyOverrideChange={setKeyOverride}
vocalLanguage={vocalLanguage} onVocalLanguageChange={setVocalLanguage}
advancedMode={advancedMode} onAdvancedModeChange={setAdvancedMode}
sepLevel={sepLevel} onSepLevelChange={(v) => setSepLevel(v as SeparationLevel)}
isSeparating={isSeparating} sepProgress={sepProgress} sepMessage={sepMessage}
sourceAudioUrl={sourceAudioUrl} onSeparate={handleSeparate}
hasStems={!!(sepStems && sepStems.length > 0 && sepJobId)}
onConfigureStems={() => setShowMixer(true)}
sourceLatentUrl={sourceLatentUrl}
onLatentLoaded={(url: string, meta: LatentMetadata) => {
setSourceLatentUrl(url);
// Auto-populate fields from HSLAT metadata
if (meta.lyrics) setLyrics(meta.lyrics);
if (meta.caption) setArtistCaption(meta.caption);
if (meta.bpm && meta.bpm > 0) {
setAnalysis(prev => prev ? { ...prev, bpm: meta.bpm! } : { bpm: meta.bpm!, key: meta.key || '', scale: undefined });
}
if (meta.key) {
setKeyOverride(meta.key);
}
}}
onLatentClear={() => setSourceLatentUrl('')}
timbreOverridePath={timbreOverridePath}
onTimbreOverridePathChange={setTimbreOverridePath}
token={token}
coverCaptionProvider={coverCaptionProvider}
coverCaptionModel={coverCaptionModel}
onCoverCaptionProviderChange={handleCoverProviderChange}
onCoverCaptionModelChange={handleCoverModelChange}
/>
{/* Center: Lyrics */}
<div className="flex-1 flex flex-col overflow-hidden border-r border-zinc-200 dark:border-white/5">
<div className="flex-shrink-0 px-4 py-3 border-b border-zinc-200 dark:border-white/5">
<div className="flex items-center justify-between gap-2">
{/* Artist + Title inputs */}
<div className="flex-1 flex gap-2">
<input value={songArtist} onChange={e => setSongArtist(e.target.value)}
placeholder={t('cover.artistPlaceholder')} className="flex-1 px-3 py-1.5 text-xs rounded-lg bg-white dark:bg-black/20 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:border-cyan-500" />
<input value={songTitle} onChange={e => setSongTitle(e.target.value)}
placeholder={t('cover.songTitlePlaceholder')} className="flex-1 px-3 py-1.5 text-xs rounded-lg bg-white dark:bg-black/20 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:border-cyan-500" />
</div>
<button onClick={handleSearchLyrics} disabled={isSearchingLyrics || !songArtist.trim() || !songTitle.trim()}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 text-xs font-medium transition-colors disabled:opacity-50">
{isSearchingLyrics ? <Loader2 className="w-3 h-3 animate-spin" /> : <Search className="w-3 h-3" />}
Genius
</button>
</div>
</div>
<div className="flex-1 p-4">
<textarea value={lyrics} onChange={e => setLyrics(e.target.value)}
placeholder={t('cover.lyricsPlaceholder')}
className="w-full h-full resize-none bg-white dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-xl px-4 py-3 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-cyan-500 transition-colors font-mono leading-relaxed" />
</div>
{showMixer && sepStems && sepJobId && (
<StemMixer jobId={sepJobId} stems={sepStems}
controls={stemControls} onControlsChange={setStemControls}
onClose={() => setShowMixer(false)} />
)}
</div>
{/* Right: Artist + Settings */}
<ArtistSettingsPanel
artists={artists} isLoadingArtists={isLoadingArtists}
selectedArtistId={selectedArtistId} onSelectArtist={handleSelectArtist}
onClearArtist={handleClearArtist}
artistPresets={artistPresets} selectedPreset={selectedPreset}
onSelectPreset={(p) => { setSelectedPreset(p); applyPresetToGlobal(p); }}
audioCoverStrength={audioCoverStrength} onAudioCoverStrength={setAudioCoverStrength}
coverNoiseStrength={coverNoiseStrength} onCoverNoiseStrength={setCoverNoiseStrength}
coverNoiseMethod={coverNoiseMethod} onCoverNoiseMethodChange={setCoverNoiseMethod}
noFsq={noFsq} onNoFsqChange={setNoFsq}
instrumental={instrumental} onInstrumentalChange={setInstrumental}
tempoScale={tempoScale} onTempoScale={setTempoScale}
pitchShift={pitchShift} onPitchShift={setPitchShift}
analysis={analysis}
bpmCorrection={bpmCorrection}
keyOverride={keyOverride}
artistCaption={artistCaption} onArtistCaptionChange={setArtistCaption}
canGenerate={canGenerate}
isGenerating={isGenerating} genProgress={genProgress} genStage={genStage}
onGenerate={handleGenerate} onCancel={handleCancel}
isGeneratingCaption={isGeneratingCaption}
onRegenerateCaption={handleRegenerateCaption}
/>
{/* Resize handle */}
<div
className="flex-shrink-0 w-1.5 h-full cursor-col-resize group z-20 flex items-center hover:bg-pink-500/20 active:bg-pink-500/30 transition-colors"
onMouseDown={handleSidebarResize}
>
<div className="w-0.5 h-8 rounded-full bg-zinc-600 group-hover:bg-pink-400 transition-colors" />
</div>
{/* Right: Recent Covers + Queue */}
<div className="h-full flex-shrink-0 border-l border-zinc-200 dark:border-white/5 overflow-hidden" style={{ width: sidebarWidth }}>
<ActivitySidebar
showToast={showToast}
source="cover-studio"
refreshKey={refreshTrigger + completionCounter}
queueCountColor="bg-cyan-500/20 text-cyan-300"
compact={sidebarWidth < 380}
/>
</div>
</div>
</div>
);
};
@@ -0,0 +1,55 @@
// EditableSlider.tsx — Slider with inline editable value display
import React, { useState } from 'react';
interface EditableSliderProps {
label: string;
value: number;
min: number;
max: number;
step: number;
onChange: (v: number) => void;
formatDisplay?: (v: number) => string;
helpText?: string;
}
export const EditableSlider: React.FC<EditableSliderProps> = ({
label, value, min, max, step, onChange, formatDisplay, helpText,
}) => {
const [editing, setEditing] = useState(false);
const [editVal, setEditVal] = useState('');
const display = formatDisplay ? formatDisplay(value) : value.toString();
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-[10px] font-medium text-zinc-500 uppercase tracking-wider">{label}</label>
{editing ? (
<input
autoFocus
type="text"
value={editVal}
onChange={e => setEditVal(e.target.value)}
onBlur={() => {
const n = parseFloat(editVal);
if (!isNaN(n)) onChange(Math.min(max, Math.max(min, n)));
setEditing(false);
}}
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
className="w-20 px-1.5 py-0.5 text-[10px] text-right bg-zinc-200 dark:bg-black/20 border border-cyan-500/50 rounded text-white outline-none font-mono"
/>
) : (
<span
className="text-[10px] text-zinc-600 dark:text-zinc-400 font-mono cursor-pointer hover:text-cyan-400 transition-colors"
onClick={() => { setEditing(true); setEditVal(String(value)); }}
title="Click to edit"
>{display}</span>
)}
</div>
<input
type="range" value={value} onChange={e => onChange(parseFloat(e.target.value))}
min={min} max={max} step={step} className="w-full h-1.5 accent-cyan-500"
/>
{helpText && <p className="text-[9px] text-zinc-600">{helpText}</p>}
</div>
);
};
@@ -0,0 +1,135 @@
// RecentCovers.tsx — Recent covers sub-component for Cover Studio
import React, { useState, useEffect } from 'react';
import { Music, Play, Pause, Loader2, Trash2 } from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
import { songApi } from '../../services/api';
import type { Song } from '../../types';
import { playFromList, songToTrack, usePlaybackSelector } from '../../stores/playbackStore';
interface RecentCoversProps {
refreshTrigger: number;
}
export const RecentCovers: React.FC<RecentCoversProps> = ({ refreshTrigger }) => {
const { token } = useAuth();
const currentTrackId = usePlaybackSelector(s => s.currentTrack?.id ?? null);
const isPlaying = usePlaybackSelector(s => s.isPlaying);
const [covers, setCovers] = useState<Song[]>([]);
const [loading, setLoading] = useState(false);
const [clearing, setClearing] = useState(false);
useEffect(() => {
if (!token) return;
setLoading(true);
fetch('/api/songs?source=cover-studio', {
headers: { Authorization: `Bearer ${token}` },
})
.then(res => res.json())
.then(data => {
const allSongs = data.songs || [];
setCovers(
allSongs
.map((s: any): Song => ({
id: s.id,
title: s.title || 'Untitled Cover',
lyrics: s.lyrics || '',
style: s.style || '',
caption: s.caption || '',
audioUrl: s.audio_url || '',
masteredAudioUrl: s.mastered_audio_url || '',
mastered_audio_url: s.mastered_audio_url || '',
duration: s.duration && s.duration > 0
? `${Math.floor(s.duration / 60)}:${String(Math.floor(s.duration % 60)).padStart(2, '0')}`
: '0:00',
tags: s.tags || [],
createdAt: new Date(s.created_at),
generationParams: typeof s.generation_params === 'string'
? JSON.parse(s.generation_params || '{}')
: (s.generation_params || {}),
}))
.sort((a: Song, b: Song) => {
const aTime = a.createdAt instanceof Date ? a.createdAt.getTime() : 0;
const bTime = b.createdAt instanceof Date ? b.createdAt.getTime() : 0;
return bTime - aTime;
})
.slice(0, 20)
);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [token, refreshTrigger]);
const handleClearAll = async () => {
if (!token || covers.length === 0) return;
if (!window.confirm(`Delete all ${covers.length} covers?`)) return;
setClearing(true);
try {
await songApi.bulkDelete(covers.map(c => c.id), token);
setCovers([]);
} finally {
setClearing(false);
}
};
const handlePlay = (cover: Song) => {
playFromList(songToTrack(cover), covers.map(songToTrack), 'cover-studio');
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-300">
<Music className="w-4 h-4 text-cyan-400" />
Recent Covers
{covers.length > 0 && <span className="text-[10px] text-zinc-500 font-normal">({covers.length})</span>}
</div>
{covers.length > 0 && (
<button
onClick={handleClearAll}
disabled={clearing}
className="p-1 rounded-md hover:bg-red-900/30 text-zinc-500 hover:text-red-400 transition-colors"
title="Clear all covers"
>
{clearing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
</button>
)}
</div>
{loading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-4 h-4 text-zinc-500 animate-spin" />
</div>
) : covers.length === 0 ? (
<p className="text-[10px] text-zinc-500 text-center py-4">No covers yet. Generate your first one!</p>
) : (
<div className="space-y-1">
{covers.map(cover => {
const isCurrent = currentTrackId === cover.id;
return (
<div
key={cover.id}
onClick={() => handlePlay(cover)}
className={`
w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200 text-left cursor-pointer group relative
${isCurrent ? 'bg-cyan-500/20 ring-1 ring-cyan-400/50' : 'hover:bg-white/5'}
`}
>
<div className="w-6 h-6 rounded-full bg-cyan-500/20 flex items-center justify-center flex-shrink-0">
{isCurrent && isPlaying ? (
<Pause className="w-3 h-3 text-cyan-400" />
) : (
<Play className="w-3 h-3 text-cyan-400" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-zinc-700 dark:text-zinc-300 truncate">{cover.title}</div>
<div className="text-[10px] text-zinc-500">{cover.duration}</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
};
@@ -0,0 +1,523 @@
// SourcePanel.tsx — Left panel: source audio upload + metadata + analysis
import React, { useCallback, useState, useEffect } from 'react';
import { Upload, Music, Loader2, X, Layers, Volume2, FolderOpen } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { AudioMetadata, AudioAnalysis } from './coverStudioUtils';
import { ALL_KEYS } from './coverStudioUtils';
import { SEPARATION_LEVELS } from '../../services/supersepApi';
import { LatentImport, type LatentMetadata } from '../shared/LatentImport';
import { masteringApi } from '../../services/api';
import { VOCAL_LANGUAGES } from '../../constants/languages';
import { ProviderSelector } from '../lyric-studio/ProviderSelector';
interface SourcePanelProps {
sourceFileName: string;
metadata: AudioMetadata | null;
analysis: AudioAnalysis | null;
isUploading: boolean;
isAnalyzing: boolean;
onFileSelected: (file: File) => void;
onClear: () => void;
bpmCorrection: number;
onBpmCorrectionChange: (v: number) => void;
bpmOverride: number | null;
onBpmOverrideChange: (v: number | null) => void;
keyOverride: string | null;
onKeyOverrideChange: (v: string | null) => void;
vocalLanguage: string;
onVocalLanguageChange: (v: string) => void;
advancedMode: boolean;
onAdvancedModeChange: (v: boolean) => void;
sepLevel: number;
onSepLevelChange: (v: number) => void;
isSeparating: boolean;
sepProgress: number;
sepMessage: string;
sourceAudioUrl: string;
onSeparate: () => void;
hasStems: boolean;
onConfigureStems: () => void;
// Latent import
sourceLatentUrl: string;
onLatentLoaded: (url: string, meta: LatentMetadata) => void;
onLatentClear: () => void;
// Timbre reference override
timbreOverridePath: string;
onTimbreOverridePathChange: (v: string) => void;
token: string | null;
// Caption LLM selector
coverCaptionProvider: string;
coverCaptionModel: string;
onCoverCaptionProviderChange: (provider: string) => void;
onCoverCaptionModelChange: (model: string) => void;
}
export const SourcePanel: React.FC<SourcePanelProps> = ({
sourceFileName, metadata, analysis, isUploading, isAnalyzing,
onFileSelected, onClear,
bpmCorrection, onBpmCorrectionChange,
bpmOverride, onBpmOverrideChange,
keyOverride, onKeyOverrideChange,
vocalLanguage, onVocalLanguageChange,
advancedMode, onAdvancedModeChange,
sepLevel, onSepLevelChange,
isSeparating, sepProgress, sepMessage,
sourceAudioUrl, onSeparate,
hasStems, onConfigureStems,
sourceLatentUrl, onLatentLoaded, onLatentClear,
timbreOverridePath, onTimbreOverridePathChange, token,
coverCaptionProvider, coverCaptionModel,
onCoverCaptionProviderChange, onCoverCaptionModelChange,
}) => {
const { t } = useTranslation();
const [isDragging, setIsDragging] = useState(false);
const [isTimbreDragging, setIsTimbreDragging] = useState(false);
const [isTimbreUploading, setIsTimbreUploading] = useState(false);
const [showTimbreBrowser, setShowTimbreBrowser] = useState(false);
interface ReferenceTrack { name: string; size: number; url: string; }
const [timbreRefs, setTimbreRefs] = useState<ReferenceTrack[]>([]);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const timbreFileRef = React.useRef<HTMLInputElement>(null);
// Load references when browser opens
useEffect(() => {
if (showTimbreBrowser) {
masteringApi.listReferences()
.then(data => setTimbreRefs(data.references))
.catch(() => {});
}
}, [showTimbreBrowser]);
const handleTimbreUpload = useCallback(async (file: File) => {
if (!token) return;
try {
setIsTimbreUploading(true);
const result = await masteringApi.uploadReference(file, token);
onTimbreOverridePathChange(result.name);
// Refresh list if browser is open
if (showTimbreBrowser) {
const data = await masteringApi.listReferences();
setTimbreRefs(data.references);
}
} catch (err) {
console.error('[Timbre] Upload failed:', err);
} finally {
setIsTimbreUploading(false);
}
}, [token, onTimbreOverridePathChange, showTimbreBrowser]);
const handleTimbreDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsTimbreDragging(false);
const file = e.dataTransfer.files[0];
if (file && /\.(mp3|wav|flac|ogg|m4a|opus|aac)$/i.test(file.name)) {
handleTimbreUpload(file);
}
}, [handleTimbreUpload]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file && /\.(mp3|wav|flac|ogg|m4a|opus|aac)$/i.test(file.name)) {
onFileSelected(file);
}
}, [onFileSelected]);
const correctedBpm = bpmOverride != null ? bpmOverride : (analysis?.bpm ? Math.round(analysis.bpm * bpmCorrection) : null);
const effectiveKey = keyOverride || analysis?.key || null;
const bpmIsOverridden = bpmOverride != null;
return (
<div className="w-[320px] flex-shrink-0 overflow-y-auto scrollbar-hide border-r border-zinc-200 dark:border-white/5 p-4 space-y-4">
{/* Upload zone */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-300">
<Upload className="w-4 h-4 text-cyan-400" />
{t('cover.sourceAudio')}
</div>
<div
onDrop={handleDrop}
onDragOver={e => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onClick={() => fileInputRef.current?.click()}
className={`
relative border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-300
${isDragging
? 'border-cyan-400 bg-cyan-500/10 scale-[1.02]'
: sourceFileName
? 'border-cyan-500/30 bg-cyan-500/5'
: 'border-zinc-300 dark:border-zinc-700 hover:border-cyan-400/50 hover:bg-cyan-500/5'}
`}
>
<input
ref={fileInputRef}
type="file"
accept=".mp3,.wav,.flac,.ogg,.m4a,.opus,.aac"
className="hidden"
onChange={e => { if (e.target.files?.[0]) onFileSelected(e.target.files[0]); }}
/>
{isUploading ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="w-8 h-8 text-cyan-400 animate-spin" />
<span className="text-xs text-cyan-400">{t('cover.uploading')}</span>
</div>
) : isAnalyzing ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="w-8 h-8 text-teal-400 animate-spin" />
<span className="text-xs text-teal-400">{t('cover.analyzingBpmKey')}</span>
</div>
) : sourceFileName ? (
<div className="flex flex-col items-center gap-2">
<Music className="w-8 h-8 text-cyan-400" />
<span className="text-xs text-zinc-600 dark:text-zinc-400 truncate max-w-full">{sourceFileName}</span>
<span className="text-[10px] text-zinc-500">{t('cover.clickOrDropReplace')}</span>
</div>
) : (
<div className="flex flex-col items-center gap-2">
<Upload className="w-8 h-8 text-zinc-600 dark:text-zinc-400" />
<span className="text-xs text-zinc-500">{t('cover.dropAudioOrBrowse')}</span>
<span className="text-[10px] text-zinc-600">MP3, WAV, FLAC, OGG, M4A</span>
</div>
)}
</div>
{/* Latent import (alternative to audio) */}
<LatentImport
latentUrl={sourceLatentUrl}
onLatentLoaded={onLatentLoaded}
onClear={onLatentClear}
/>
</div>
{/* Timbre Reference Override */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-300">
<Volume2 className="w-4 h-4 text-teal-400" />
{t('cover.timbreRef')}
<span className="text-[10px] font-normal text-zinc-500">(optional)</span>
</div>
{/* Drag-and-drop / click zone */}
<div
onDrop={handleTimbreDrop}
onDragOver={e => { e.preventDefault(); setIsTimbreDragging(true); }}
onDragLeave={() => setIsTimbreDragging(false)}
onClick={() => timbreFileRef.current?.click()}
className={`
relative border-2 border-dashed rounded-xl p-4 text-center cursor-pointer transition-all duration-300
${isTimbreDragging
? 'border-teal-400 bg-teal-500/10 scale-[1.02]'
: timbreOverridePath
? 'border-teal-500/30 bg-teal-500/5'
: 'border-zinc-300 dark:border-zinc-700 hover:border-teal-400/50 hover:bg-teal-500/5'}
`}
>
<input
ref={timbreFileRef}
type="file"
accept=".mp3,.wav,.flac,.ogg,.m4a,.opus,.aac"
className="hidden"
onChange={e => {
if (e.target.files?.[0]) handleTimbreUpload(e.target.files[0]);
e.target.value = '';
}}
/>
{isTimbreUploading ? (
<div className="flex flex-col items-center gap-1">
<Loader2 className="w-6 h-6 text-teal-400 animate-spin" />
<span className="text-[10px] text-teal-400">Uploading...</span>
</div>
) : timbreOverridePath ? (
<div className="flex flex-col items-center gap-1">
<Volume2 className="w-6 h-6 text-teal-400" />
<span className="text-[10px] text-zinc-600 dark:text-zinc-400 truncate max-w-full">{timbreOverridePath}</span>
<span className="text-[9px] text-zinc-500">{t('cover.clickOrDropReplace')}</span>
</div>
) : (
<div className="flex flex-col items-center gap-1">
<Upload className="w-6 h-6 text-zinc-600 dark:text-zinc-400" />
<span className="text-[10px] text-zinc-500">Drop timbre reference or click to upload</span>
<span className="text-[9px] text-zinc-600">MP3, WAV, FLAC, OGG</span>
</div>
)}
</div>
{/* Action row: Browse + Clear */}
<div className="flex items-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); setShowTimbreBrowser(!showTimbreBrowser); }}
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg bg-teal-500/10 hover:bg-teal-500/20 text-teal-400 text-[10px] font-medium transition-colors border border-teal-500/20"
>
<FolderOpen className="w-3 h-3" />
Browse References
</button>
{timbreOverridePath && (
<button
onClick={() => onTimbreOverridePathChange('')}
className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-[10px] font-medium text-zinc-400 hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<X className="w-3 h-3" />
{t('cover.clearTimbreRef')}
</button>
)}
</div>
{/* Reference browser modal */}
{showTimbreBrowser && (
<div className="rounded-xl bg-black/5 dark:bg-white/5 border border-zinc-200 dark:border-white/10 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-200 dark:border-white/5">
<span className="text-[10px] font-semibold text-zinc-500 uppercase">Reference Tracks</span>
<button onClick={() => setShowTimbreBrowser(false)} className="text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300">
<X className="w-3 h-3" />
</button>
</div>
<div className="max-h-[160px] overflow-y-auto scrollbar-hide">
{timbreRefs.length === 0 ? (
<div className="px-3 py-4 text-center text-[10px] text-zinc-500">
No references uploaded yet. Drop an audio file above to add one.
</div>
) : (
timbreRefs.map(ref => (
<button
key={ref.name}
onClick={() => { onTimbreOverridePathChange(ref.name); setShowTimbreBrowser(false); }}
className={`w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-teal-500/10 transition-colors ${
timbreOverridePath === ref.name ? 'bg-teal-500/15 border-l-2 border-teal-400' : ''
}`}
>
<Volume2 className="w-3 h-3 text-teal-400 flex-shrink-0" />
<span className="text-xs text-zinc-700 dark:text-zinc-300 truncate flex-1">{ref.name}</span>
<span className="text-[9px] text-zinc-500 flex-shrink-0">
{ref.size < 1024 * 1024 ? `${(ref.size / 1024).toFixed(0)} KB` : `${(ref.size / (1024 * 1024)).toFixed(1)} MB`}
</span>
</button>
))
)}
</div>
</div>
)}
{/* Selected timbre info badge */}
{timbreOverridePath && (
<div className="rounded-lg bg-teal-500/5 border border-teal-500/20 px-3 py-1.5 flex items-center gap-2">
<Volume2 className="w-3 h-3 text-teal-400 flex-shrink-0" />
<span className="text-[10px] text-zinc-500 truncate flex-1">{timbreOverridePath}</span>
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-teal-900/30 text-teal-400">TIMBRE</span>
</div>
)}
{!timbreOverridePath && (
<p className="text-[10px] text-zinc-500 leading-tight">
{t('cover.timbreRefHelp')}
</p>
)}
</div>
{/* Metadata display */}
{metadata && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium text-zinc-500 uppercase">{t('cover.metadata')}</span>
<button onClick={onClear} className="text-zinc-500 hover:text-red-400 transition-colors" title="Clear">
<X className="w-3 h-3" />
</button>
</div>
<div className="rounded-lg bg-black/5 dark:bg-white/5 p-3 space-y-1">
{metadata.artist && <MetaRow label="Artist" value={metadata.artist} />}
{metadata.title && <MetaRow label="Title" value={metadata.title} />}
{metadata.album && <MetaRow label="Album" value={metadata.album} />}
{metadata.duration != null && (
<MetaRow label="Duration" value={`${Math.floor(metadata.duration / 60)}:${String(Math.floor(metadata.duration % 60)).padStart(2, '0')}`} />
)}
</div>
</div>
)}
{/* Analysis display */}
{analysis && (
<div className="space-y-2">
<span className="text-[10px] font-medium text-zinc-500 uppercase">{t('cover.analysis')}</span>
<div className="grid grid-cols-2 gap-2">
<div className="rounded-lg bg-gradient-to-br from-cyan-500/10 to-teal-500/10 border border-cyan-500/20 p-3 text-center">
<span className="text-[10px] text-zinc-500 block">BPM</span>
<span className={`text-lg font-bold ${bpmIsOverridden ? 'text-amber-400' : 'text-cyan-400'}`}>{correctedBpm ?? analysis.bpm}</span>
{(bpmIsOverridden || bpmCorrection !== 1) && (
<span className="text-[9px] text-zinc-500 block">
(detected: {analysis.bpm})
</span>
)}
</div>
<div className="rounded-lg bg-gradient-to-br from-teal-500/10 to-emerald-500/10 border border-teal-500/20 p-3 text-center">
<span className="text-[10px] text-zinc-500 block">Key</span>
<span className="text-lg font-bold text-teal-400">{effectiveKey || analysis.key}</span>
{keyOverride && (
<span className="text-[9px] text-zinc-500 block">
(detected: {analysis.key})
</span>
)}
</div>
</div>
{/* BPM correction — Essentia sometimes halves or doubles the tempo */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-zinc-500 whitespace-nowrap">{t('cover.tempoFix')}</span>
<div className="flex gap-1 flex-1">
{([
{ label: '÷2', value: 0.5 },
{ label: 'Detected', value: 1 },
{ label: '×2', value: 2 },
] as const).map(opt => (
<button
key={opt.value}
onClick={() => { onBpmCorrectionChange(opt.value); onBpmOverrideChange(null); }}
className={`flex-1 px-2 py-1 rounded-md text-[10px] font-medium transition-all ${
bpmCorrection === opt.value && !bpmIsOverridden
? 'bg-cyan-500/20 text-cyan-300 ring-1 ring-cyan-500/40'
: 'bg-white/5 text-zinc-500 hover:bg-white/10 hover:text-zinc-700 dark:text-zinc-300'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Free-text BPM override */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-zinc-500 whitespace-nowrap">{t('cover.custom')}</span>
<input
type="number"
min={20}
max={300}
placeholder={String(analysis?.bpm ? Math.round(analysis.bpm * bpmCorrection) : 120)}
value={bpmOverride ?? ''}
onChange={e => {
const v = e.target.value.trim();
onBpmOverrideChange(v ? parseInt(v, 10) || null : null);
}}
className={`flex-1 px-2 py-1 rounded-xl bg-zinc-100 dark:bg-zinc-800 border text-xs outline-none transition-colors tabular-nums ${
bpmIsOverridden
? 'border-amber-500/40 text-amber-300 focus:border-amber-500/60 focus:ring-1 focus:ring-amber-500/20'
: 'border-zinc-300 dark:border-white/10 text-zinc-700 dark:text-zinc-300 focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20'
}`}
/>
{bpmIsOverridden && (
<button onClick={() => onBpmOverrideChange(null)} className="text-zinc-500 hover:text-red-400 transition-colors" title="Clear override">
<X className="w-3 h-3" />
</button>
)}
</div>
{/* Key override — Essentia sometimes gets the wrong key */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-zinc-500 whitespace-nowrap">{t('cover.keyFix')}</span>
<select
value={keyOverride || ''}
onChange={e => onKeyOverrideChange(e.target.value || null)}
className={`flex-1 px-2 py-1 rounded-xl bg-zinc-100 dark:bg-zinc-800 border text-xs outline-none transition-colors cursor-pointer ${
keyOverride
? 'border-teal-500/40 text-teal-300 focus:border-teal-500/60 focus:ring-1 focus:ring-teal-500/20'
: 'border-zinc-300 dark:border-white/10 text-zinc-700 dark:text-zinc-300 focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/20'
}`}
>
<option value="">Detected{analysis?.key ? ` (${analysis.key})` : ''}</option>
{ALL_KEYS.map(k => (
<option key={k} value={k}>{k}</option>
))}
</select>
</div>
{/* Vocal language */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-zinc-500 whitespace-nowrap">{t('cover.language')}</span>
<select
value={vocalLanguage}
onChange={e => onVocalLanguageChange(e.target.value)}
className="flex-1 px-2 py-1 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-xs text-zinc-700 dark:text-zinc-300 outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors cursor-pointer"
>
{VOCAL_LANGUAGES.map(l => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
</select>
</div>
</div>
)}
{/* Advanced Mode */}
<div className="border-t border-zinc-200 dark:border-white/5 pt-4 space-y-3">
<button
onClick={() => onAdvancedModeChange(!advancedMode)}
className={`w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-xs font-medium transition-all ${
advancedMode
? 'bg-purple-500/20 text-purple-300 border border-purple-500/30'
: 'bg-white/5 text-zinc-600 dark:text-zinc-400 border border-zinc-300 dark:border-white/10 hover:border-zinc-400 dark:hover:border-white/20'
}`}
>
<Layers className="w-3.5 h-3.5" />
{t('cover.advancedMode')}{advancedMode ? ' (On)' : ''}
</button>
{advancedMode && (
<div className="space-y-2">
<select
value={sepLevel}
onChange={(e) => onSepLevelChange(parseInt(e.target.value))}
className="w-full px-2 py-1.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-xs text-zinc-700 dark:text-zinc-300 focus:outline-none focus:border-purple-500/50 focus:ring-1 focus:ring-purple-500/20 transition-colors cursor-pointer"
>
{SEPARATION_LEVELS.map(l => (
<option key={l.value} value={l.value}>{l.label} {l.description}</option>
))}
</select>
{hasStems ? (
<button
onClick={onConfigureStems}
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg bg-gradient-to-r from-purple-500 to-cyan-500 text-white text-xs font-semibold shadow-lg hover:shadow-cyan-500/25 transition-all"
>
🎛 Configure Stems
</button>
) : (
<button
onClick={onSeparate}
disabled={isSeparating || !sourceAudioUrl}
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg bg-purple-500/20 hover:bg-purple-500/30 text-purple-300 text-xs font-medium transition-colors disabled:opacity-50"
>
{isSeparating ? (
<>
<Loader2 className="w-3 h-3 animate-spin" />
{Math.round(sepProgress * 100)}% {sepMessage}
</>
) : (
'✂ Split Stems'
)}
</button>
)}
</div>
)}
</div>
{/* Caption LLM */}
<div className="border-t border-zinc-200 dark:border-white/5 pt-4 space-y-2">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-300">
<span className="text-base"></span>
Caption LLM
</div>
<ProviderSelector
selectedProvider={coverCaptionProvider}
selectedModel={coverCaptionModel}
onProviderChange={onCoverCaptionProviderChange}
onModelChange={onCoverCaptionModelChange}
label="Caption LLM"
compact
/>
<p className="text-[10px] text-zinc-500 leading-tight">
Used to auto-generate a style description when no caption is found for the selected artist.
</p>
</div>
</div>
);
};
const MetaRow: React.FC<{ label: string; value: string }> = ({ label, value }) => (
<div className="flex items-center gap-2">
<span className="text-[10px] text-zinc-500 w-14 flex-shrink-0">{label}</span>
<span className="text-xs text-zinc-700 dark:text-zinc-300 truncate">{value}</span>
</div>
);
@@ -0,0 +1,77 @@
// coverStudioUtils.ts — Shared helpers for Cover Studio
// ── Persistence ─────────────────────────────────────────────────────────
export const STORAGE_PREFIX = 'cover-studio-';
export const TRACK_CACHE_KEY = 'cover-studio-trackCache';
export function persist(key: string, value: any) {
try { localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value)); } catch {}
}
export function restore<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(STORAGE_PREFIX + key);
return raw !== null ? JSON.parse(raw) : fallback;
} catch { return fallback; }
}
// ── Track cache ─────────────────────────────────────────────────────────
export interface TrackCacheEntry {
artist: string;
title: string;
lyrics: string;
bpm: number;
key: string;
scale?: string;
duration: number | null;
album?: string;
}
export function getTrackCache(): Record<string, TrackCacheEntry> {
try { return JSON.parse(localStorage.getItem(TRACK_CACHE_KEY) || '{}'); } catch { return {}; }
}
export function saveTrackCacheEntry(filename: string, entry: Partial<TrackCacheEntry>) {
try {
const cache = getTrackCache();
cache[filename] = { ...(cache[filename] || {}), ...entry } as TrackCacheEntry;
localStorage.setItem(TRACK_CACHE_KEY, JSON.stringify(cache));
} catch {}
}
// ── Types ───────────────────────────────────────────────────────────────
export interface AudioMetadata {
artist: string;
title: string;
album: string;
duration: number | null;
}
export interface AudioAnalysis {
bpm: number;
key: string;
scale?: string;
}
// ── Music theory helpers ────────────────────────────────────────────────
export const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
/** All 24 standard musical keys (12 major + 12 minor). */
export const ALL_KEYS = [
...NOTE_NAMES.map(n => `${n} major`),
...NOTE_NAMES.map(n => `${n} minor`),
];
const NOTE_ALIASES: Record<string, number> = {
'C': 0, 'C#': 1, 'Db': 1, 'D': 2, 'D#': 3, 'Eb': 3, 'E': 4, 'Fb': 4,
'F': 5, 'F#': 6, 'Gb': 6, 'G': 7, 'G#': 8, 'Ab': 8, 'A': 9, 'A#': 10, 'Bb': 10, 'B': 11, 'Cb': 11,
};
export function transposeKey(keyStr: string, semitones: number): string {
if (!keyStr || semitones === 0) return keyStr;
const parts = keyStr.trim().split(/\s+/);
const noteIndex = NOTE_ALIASES[parts[0]];
if (noteIndex === undefined) return keyStr;
const newIndex = ((noteIndex + semitones) % 12 + 12) % 12;
const quality = parts.slice(1).join(' ');
return quality ? `${NOTE_NAMES[newIndex]} ${quality}` : NOTE_NAMES[newIndex];
}
@@ -0,0 +1,528 @@
// AdaptersAccordion.tsx — Adapter selection and configuration
//
// Self-contained accordion with Simple and Advanced modes.
// Simple Mode: path input + browse + scale + loading mode
// Advanced Mode: folder scan + file list + group scales
//
// Replaces the adapter section that was previously inside ModelSelector.
import React, { useState, useCallback } from 'react';
import { ChevronDown, FolderOpen, X, Tag, Search, Circle } from 'lucide-react';
import { adapterApi } from '../../services/api';
import { FileBrowserModal } from '../shared/FileBrowserModal';
import type { AdapterFile } from '../../types';
interface AdapterGroupScales {
self_attn: number;
cross_attn: number;
mlp: number;
cond_embed: number;
time_embed: number;
proj_in: number;
}
interface AdaptersAccordionProps {
// Accordion state
isOpen: boolean;
onToggle: () => void;
// Mode
advancedAdapters: boolean;
onAdvancedAdaptersChange: (val: boolean) => void;
// Adapter selection (shared between modes)
adapter: string;
onAdapterChange: (path: string) => void;
adapterScale: number;
onAdapterScaleChange: (val: number) => void;
adapterMode: string;
onAdapterModeChange: (val: string) => void;
// Group scales (advanced only)
adapterGroupScales: AdapterGroupScales;
onAdapterGroupScalesChange: (v: AdapterGroupScales) => void;
// Folder scanning (advanced)
adapterFolder: string;
onAdapterFolderChange: (val: string) => void;
// Trigger word display (read from settings)
triggerUseFilename: boolean;
triggerPlacement: 'prepend' | 'append' | 'replace';
}
const GROUP_INFO = [
{ key: 'self_attn' as const, label: 'Self-Attn', help: 'How audio frames relate to each other over time', defaultVal: 1.0 },
{ key: 'cross_attn' as const, label: 'Cross-Attn', help: 'How strongly your text prompt shapes the output', defaultVal: 1.0 },
{ key: 'mlp' as const, label: 'MLP', help: 'Timbre, tonal texture, and sonic character', defaultVal: 1.0 },
{ key: 'cond_embed' as const, label: 'Conditioning', help: 'How the adapter reshapes text/style interpretation', defaultVal: 1.0 },
{ key: 'time_embed' as const, label: 'Timestep', help: 'How the adapter modifies noise-schedule understanding (0 = skip)', defaultVal: 0.0 },
{ key: 'proj_in' as const, label: 'Proj-In', help: 'Input patchification layer — how latent tokens enter the model (0 = skip)', defaultVal: 0.0 },
];
/** Extract trigger word from adapter path — filename without extension, underscores kept */
function deriveTriggerWord(adapterPath: string): string {
if (!adapterPath) return '';
const filename = adapterPath.split(/[\\/]/).pop() || '';
return filename.replace(/\.safetensors$/i, '');
}
/**
* What the server will actually inject for this adapter.
*
* An adapter trained by HOT-Step carries its trigger — and the position it was
* trained at — in its own safetensors metadata, and that always wins over a
* guess made from the filename. The filename fallback only fires for adapters
* that carry nothing (which is every adapter trained before this feature, until
* the stamper runs over them).
* docs/plans/2026-07-28-adapter-trigger-embedding.md T6
*/
function resolveTrigger(
adapterPath: string,
files: AdapterFile[],
useFilename: boolean,
globalPlacement: 'prepend' | 'append' | 'replace',
): { word: string; placement: string; source: 'embedded' | 'filename' | 'none' } {
const hit = files.find(f => f.path === adapterPath);
if (hit?.trigger) {
return { word: hit.trigger, placement: hit.triggerPosition || 'prepend', source: 'embedded' };
}
const word = useFilename ? deriveTriggerWord(adapterPath) : '';
return word ? { word, placement: globalPlacement, source: 'filename' } : { word: '', placement: '', source: 'none' };
}
/** Format byte count */
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export const AdaptersAccordion: React.FC<AdaptersAccordionProps> = ({
isOpen, onToggle,
advancedAdapters, onAdvancedAdaptersChange,
adapter, onAdapterChange,
adapterScale, onAdapterScaleChange,
adapterMode, onAdapterModeChange,
adapterGroupScales, onAdapterGroupScalesChange,
adapterFolder, onAdapterFolderChange,
triggerUseFilename, triggerPlacement,
}) => {
// Internal state
const [adapterFiles, setAdapterFiles] = useState<AdapterFile[]>([]);
const [showGroupScales, setShowGroupScales] = useState(false);
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
const [scanning, setScanning] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
// The FileBrowserModal mode depends on the adapter mode
// Simple: select a file
// Advanced: select a folder
const fileBrowserMode = advancedAdapters ? 'folder' as const : 'file' as const;
const resolvedTrigger = resolveTrigger(adapter, adapterFiles, triggerUseFilename, triggerPlacement);
const adapterFilename = adapter ? adapter.split(/[\\/]/).pop() || '' : '';
const handleGroupScaleChange = (key: keyof AdapterGroupScales, value: number) => {
onAdapterGroupScalesChange({ ...adapterGroupScales, [key]: value });
};
const allDefault = GROUP_INFO.every(g => adapterGroupScales[g.key] === g.defaultVal);
// Scan folder for adapter files (Advanced mode)
const handleScan = useCallback(async (folder?: string) => {
const dir = folder || adapterFolder;
if (!dir) return;
setScanning(true);
setScanError(null);
try {
const result = await adapterApi.scan(dir);
setAdapterFiles(result.files);
if (result.files.length === 0) {
setScanError('No .safetensors files found in this folder');
}
} catch (err: any) {
setScanError(err?.message || 'Failed to scan folder');
} finally {
setScanning(false);
}
}, [adapterFolder]);
// Handle file browser selection
const handleBrowseSelect = (path: string) => {
setFileBrowserOpen(false);
if (advancedAdapters) {
// Folder mode — set folder path and auto-scan
onAdapterFolderChange(path);
handleScan(path);
} else {
// File mode — set adapter path directly
onAdapterChange(path);
}
};
return (
<div className="space-y-1 pt-3 border-t border-zinc-200 dark:border-white/5">
{/* Accordion header */}
<button
onClick={onToggle}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-400 uppercase tracking-wider">Adapters</span>
{adapter && (
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" title="Adapter active" />
)}
</div>
<ChevronDown size={14} className={`text-zinc-500 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div className="px-3 pb-3 space-y-3">
{/* Simple / Advanced toggle */}
<div className="flex rounded-xl overflow-hidden border border-zinc-300 dark:border-white/10">
<button
onClick={() => onAdvancedAdaptersChange(false)}
className={`flex-1 px-3 py-1.5 text-xs font-medium transition-colors ${
!advancedAdapters ? 'bg-zinc-200 dark:bg-zinc-700 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
Simple
</button>
<button
onClick={() => onAdvancedAdaptersChange(true)}
className={`flex-1 px-3 py-1.5 text-xs font-medium transition-colors ${
advancedAdapters ? 'bg-zinc-200 dark:bg-zinc-700 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
Advanced
</button>
</div>
{/* ═══ SIMPLE MODE ═══ */}
{!advancedAdapters && (
<>
{/* Path input + Browse */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">Adapter Path</label>
<div className="flex gap-2">
<input
type="text"
value={adapter}
onChange={(e) => onAdapterChange(e.target.value)}
placeholder="Path to .safetensors file..."
className="flex-1 px-3 py-2 rounded-xl bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 placeholder-zinc-400 dark:placeholder-zinc-600 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none transition-colors font-mono text-xs"
/>
<button
onClick={() => setFileBrowserOpen(true)}
className="px-3 py-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:hover:text-zinc-200 hover:bg-zinc-300 dark:hover:bg-zinc-700 transition-colors"
title="Browse for adapter file"
>
<FolderOpen size={14} />
</button>
</div>
</div>
{/* Selected adapter indicator */}
{adapter && (
<div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<Circle size={8} fill="#10b981" className="text-emerald-500 flex-shrink-0" />
<span className="text-xs text-emerald-400 font-medium truncate flex-1" title={adapter}>
{adapterFilename}
</span>
<button
onClick={() => onAdapterChange('')}
className="text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors flex-shrink-0"
title="Clear adapter"
>
<X size={12} />
</button>
</div>
)}
{/* Trigger word tag — embedded triggers show regardless of the
"use filename" setting, because they are what the adapter was
trained with rather than a guess. */}
{adapter && resolvedTrigger.word && (
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-pink-500/10 border border-pink-500/20">
<Tag size={10} className="text-pink-400 flex-shrink-0" />
<span className="text-[10px] text-pink-400 font-medium">{resolvedTrigger.word}</span>
<span className="text-[10px] text-zinc-500">
({resolvedTrigger.placement}
{resolvedTrigger.source === 'embedded' ? ' · from adapter' : ' · from filename'})
</span>
</div>
)}
{/* Adapter Scale */}
{adapter && (
<>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs font-medium text-zinc-500 uppercase tracking-wider">Adapter Scale</label>
<span className="text-xs text-zinc-600 dark:text-zinc-400 font-mono">{adapterScale.toFixed(2)}</span>
</div>
<input type="range" value={adapterScale}
onChange={e => onAdapterScaleChange(parseFloat(e.target.value))}
min={0} max={4} step={0.05} className="w-full" />
</div>
{/* Loading Mode */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">Loading Mode</label>
<div className="flex rounded-xl overflow-hidden border border-zinc-300 dark:border-white/10">
<button
type="button"
onClick={() => onAdapterModeChange('merge')}
className={`flex-1 px-2.5 py-1.5 text-xs font-medium transition-colors ${
adapterMode === 'merge' ? 'bg-amber-600 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
Merge
</button>
<button
type="button"
onClick={() => onAdapterModeChange('runtime')}
className={`flex-1 px-2.5 py-1.5 text-xs font-medium transition-colors ${
adapterMode === 'runtime' ? 'bg-pink-600 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
Runtime
</button>
<button
type="button"
onClick={() => onAdapterModeChange('runtime_lowrank')}
className={`flex-1 px-2.5 py-1.5 text-xs font-medium transition-colors ${
adapterMode === 'runtime_lowrank' ? 'bg-violet-600 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
Low-Rank 🪶
</button>
</div>
<p className="text-[10px] text-zinc-600 mt-1">
{adapterMode === 'runtime_lowrank'
? 'Applies raw adapter factors per-step — lowest VRAM (LoRA & LoKr; DoRA needs Merge).'
: adapterMode === 'runtime'
? 'Keeps base weights intact, applies adapter per-step. Same quality, slower inference, saves VRAM.'
: 'Merges adapter at F32 precision. Best quality, fast inference, but uses more VRAM during synthesis.'}
</p>
</div>
</>
)}
</>
)}
{/* ═══ ADVANCED MODE ═══ */}
{advancedAdapters && (
<>
{/* Folder path + Scan + Browse */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">Adapter Folder</label>
<div className="flex gap-2">
<input
type="text"
value={adapterFolder}
onChange={(e) => onAdapterFolderChange(e.target.value)}
placeholder="Path to folder with adapters..."
className="flex-1 px-3 py-2 rounded-xl bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 placeholder-zinc-400 dark:placeholder-zinc-600 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none transition-colors font-mono text-xs"
/>
<button
onClick={() => handleScan()}
disabled={!adapterFolder || scanning}
className="px-3 py-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:hover:text-zinc-200 hover:bg-zinc-300 dark:hover:bg-zinc-700 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
title="Scan folder"
>
<Search size={14} className={scanning ? 'animate-spin' : ''} />
</button>
<button
onClick={() => setFileBrowserOpen(true)}
className="px-3 py-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:hover:text-zinc-200 hover:bg-zinc-300 dark:hover:bg-zinc-700 transition-colors"
title="Browse for folder"
>
<FolderOpen size={14} />
</button>
</div>
</div>
{/* Scan error */}
{scanError && (
<div className="text-xs text-amber-400/70 px-1">
{scanError}
</div>
)}
{/* Available adapters list */}
{adapterFiles.length > 0 && (
<div className="rounded-xl bg-zinc-50/80 dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/5 overflow-hidden" style={{ maxHeight: '200px', overflowY: 'auto' }}>
{adapterFiles.map((file) => {
const isActive = adapter === file.path;
return (
<button
key={file.path}
onClick={() => onAdapterChange(isActive ? '' : file.path)}
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-colors ${
isActive ? 'bg-emerald-500/10 border-l-2 border-emerald-500' : 'hover:bg-white/5 border-l-2 border-transparent'
}`}
>
{isActive ? (
<Circle size={8} fill="#10b981" className="text-emerald-500 flex-shrink-0" />
) : (
<Circle size={8} className="text-zinc-600 flex-shrink-0" />
)}
<span className={`text-xs truncate flex-1 ${isActive ? 'text-emerald-400 font-medium' : 'text-zinc-600 dark:text-zinc-400'}`}>
{file.name}
</span>
<span className="text-zinc-600 flex-shrink-0" style={{ fontSize: '10px' }}>
{formatSize(file.size)}
</span>
<span
className={`text-[10px] font-semibold flex-shrink-0 ${isActive ? 'text-emerald-500' : 'text-zinc-600'}`}
>
{isActive ? 'Active' : 'Select'}
</span>
</button>
);
})}
</div>
)}
{/* Selected adapter details (Advanced) */}
{adapter && (
<>
{/* Selected indicator */}
<div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<Circle size={8} fill="#10b981" className="text-emerald-500 flex-shrink-0" />
<span className="text-xs text-emerald-400 font-medium truncate flex-1" title={adapter}>
{adapterFilename}
</span>
<button
onClick={() => onAdapterChange('')}
className="text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors flex-shrink-0"
title="Deselect adapter"
>
<X size={12} />
</button>
</div>
{/* Trigger word tag */}
{resolvedTrigger.word && (
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-pink-500/10 border border-pink-500/20">
<Tag size={10} className="text-pink-400 flex-shrink-0" />
<span className="text-[10px] text-pink-400 font-medium">{resolvedTrigger.word}</span>
<span className="text-[10px] text-zinc-500">
({resolvedTrigger.placement}
{resolvedTrigger.source === 'embedded' ? ' · from adapter' : ' · from filename'})
</span>
</div>
)}
{/* Adapter Scale */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs font-medium text-zinc-500 uppercase tracking-wider">Adapter Scale</label>
<span className="text-xs text-zinc-600 dark:text-zinc-400 font-mono">{adapterScale.toFixed(2)}</span>
</div>
<input type="range" value={adapterScale}
onChange={e => onAdapterScaleChange(parseFloat(e.target.value))}
min={0} max={4} step={0.05} className="w-full" />
</div>
{/* Loading Mode */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">Loading Mode</label>
<div className="flex rounded-xl overflow-hidden border border-zinc-300 dark:border-white/10">
<button
type="button"
onClick={() => onAdapterModeChange('merge')}
className={`flex-1 px-2.5 py-1.5 text-xs font-medium transition-colors ${
adapterMode === 'merge' ? 'bg-amber-600 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
Merge
</button>
<button
type="button"
onClick={() => onAdapterModeChange('runtime')}
className={`flex-1 px-2.5 py-1.5 text-xs font-medium transition-colors ${
adapterMode === 'runtime' ? 'bg-pink-600 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
Runtime
</button>
<button
type="button"
onClick={() => onAdapterModeChange('runtime_lowrank')}
className={`flex-1 px-2.5 py-1.5 text-xs font-medium transition-colors ${
adapterMode === 'runtime_lowrank' ? 'bg-violet-600 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
Low-Rank 🪶
</button>
</div>
<p className="text-[10px] text-zinc-600 mt-1">
{adapterMode === 'runtime_lowrank'
? 'Applies raw adapter factors per-step — lowest VRAM (LoRA & LoKr; DoRA needs Merge).'
: adapterMode === 'runtime'
? 'Keeps base weights intact, applies adapter per-step. Same quality, slower inference, saves VRAM.'
: 'Merges adapter at F32 precision. Best quality, fast inference, but uses more VRAM during synthesis.'}
</p>
</div>
{/* Group Scales Toggle (Advanced only) */}
<button
onClick={() => setShowGroupScales(!showGroupScales)}
className="flex items-center gap-2 text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors"
>
<ChevronDown size={12} className={`transition-transform duration-200 ${showGroupScales ? 'rotate-180' : ''}`} />
Group Scales
{!allDefault && (
<span className="w-1.5 h-1.5 rounded-full bg-pink-500" title="Group scales modified" />
)}
</button>
{showGroupScales && (
<div className="rounded-xl bg-zinc-50/80 dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/5 p-3 space-y-3">
{GROUP_INFO.map(({ key, label, help, defaultVal }) => (
<div key={key}>
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-zinc-500" title={help}>{label}</label>
<span className={`text-xs font-mono ${
adapterGroupScales[key] === defaultVal ? 'text-zinc-600' : 'text-pink-400'
}`}>
{adapterGroupScales[key].toFixed(2)}
</span>
</div>
<input
type="range"
value={adapterGroupScales[key]}
onChange={e => handleGroupScaleChange(key, parseFloat(e.target.value))}
min={0} max={4} step={0.05}
className="w-full"
/>
</div>
))}
<div className="text-center text-[10px] text-zinc-600 mt-1">
Scale changes apply on next generation (DiT reload)
</div>
</div>
)}
</>
)}
</>
)}
</div>
)}
{/* File Browser Modal */}
<FileBrowserModal
open={fileBrowserOpen}
onClose={() => setFileBrowserOpen(false)}
onSelect={handleBrowseSelect}
mode={fileBrowserMode}
startPath={advancedAdapters ? adapterFolder : undefined}
filter="adapters"
title={advancedAdapters ? 'Select Adapter Folder' : 'Select Adapter File'}
/>
</div>
);
};
@@ -0,0 +1,394 @@
// AiGenerateModal.tsx — Modal for generating song content via external LLM
//
// Lets the user pick an LLM provider/model, enter a subject and genre/style,
// and generates lyrics + caption + metadata in one shot. Results populate
// the CreatePanel form fields.
//
// Uses the same /api/inspire/llm endpoint that InstaGen uses, so zero
// backend changes required.
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { X, Sparkles, Dice5, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import {
runLlmInspire,
fetchInspireProviders,
generateRandomSubject,
type InspireProvider,
} from '../../services/inspireApi';
import { VOCAL_LANGUAGES } from '../../constants/languages';
// ── Result type for the parent callback ──────────────────────────────────
export interface AiGenerateResult {
caption: string;
lyrics: string;
title: string;
subject: string;
bpm: number;
keyScale: string;
timeSignature: string;
duration: number;
vocalLanguage: string;
}
// ── Props ────────────────────────────────────────────────────────────────
interface AiGenerateModalProps {
isOpen: boolean;
onClose: () => void;
onResult: (result: AiGenerateResult) => void;
}
// ── localStorage keys for persisting provider/model selection ─────────
const STORAGE_KEY_PROVIDER = 'hs-customgen-ai-provider';
const STORAGE_KEY_MODEL = 'hs-customgen-ai-model';
function loadPersisted(key: string): string {
try { return JSON.parse(localStorage.getItem(key) || '""'); } catch { return ''; }
}
function savePersisted(key: string, value: string) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* ignore */ }
}
// ── Component ────────────────────────────────────────────────────────────
export const AiGenerateModal: React.FC<AiGenerateModalProps> = ({ isOpen, onClose, onResult }) => {
const { } = useTranslation();
const { token } = useAuth();
// ── Provider state ──
const [providers, setProviders] = useState<InspireProvider[]>([]);
const [providersLoading, setProvidersLoading] = useState(true);
const [selectedProvider, setSelectedProvider] = useState(() => loadPersisted(STORAGE_KEY_PROVIDER));
const [selectedModel, setSelectedModel] = useState(() => loadPersisted(STORAGE_KEY_MODEL));
// ── Form state ──
const [subject, setSubject] = useState('');
const [genreText, setGenreText] = useState('');
const [language, setLanguage] = useState('en');
// ── Submission state ──
const [loading, setLoading] = useState(false);
const [loadingStage, setLoadingStage] = useState('');
const [error, setError] = useState('');
const [randomLoading, setRandomLoading] = useState(false);
// ── Load providers on open ──
useEffect(() => {
if (!isOpen) return;
setProvidersLoading(true);
fetchInspireProviders()
.then(list => {
const available = list.filter(p => p.available);
setProviders(available);
// Auto-select first if none persisted or persisted one is unavailable
if (available.length > 0 && !available.find(p => p.id === selectedProvider)) {
const first = available[0];
setSelectedProvider(first.id);
setSelectedModel(first.default_model);
savePersisted(STORAGE_KEY_PROVIDER, first.id);
savePersisted(STORAGE_KEY_MODEL, first.default_model);
}
})
.catch(() => { /* leave empty */ })
.finally(() => setProvidersLoading(false));
}, [isOpen]);
// ── Current provider's models ──
const currentProvider = useMemo(
() => providers.find(p => p.id === selectedProvider),
[providers, selectedProvider],
);
const models = currentProvider?.models || [];
// ── Provider change handler ──
const handleProviderChange = useCallback((id: string) => {
setSelectedProvider(id);
savePersisted(STORAGE_KEY_PROVIDER, id);
const prov = providers.find(p => p.id === id);
if (prov) {
setSelectedModel(prov.default_model);
savePersisted(STORAGE_KEY_MODEL, prov.default_model);
}
}, [providers]);
const handleModelChange = useCallback((model: string) => {
setSelectedModel(model);
savePersisted(STORAGE_KEY_MODEL, model);
}, []);
// ── Parse genre text into array ──
const parseGenres = useCallback((text: string): string[] => {
return text
.split(/[,;]+/)
.map(s => s.trim())
.filter(s => s.length > 0);
}, []);
// ── Validation ──
const canSubmit = useMemo(() => {
if (!selectedProvider) return false;
if (!subject.trim() && !genreText.trim()) return false;
return true;
}, [selectedProvider, subject, genreText]);
// ── Random subject ──
const handleRandomSubject = useCallback(async () => {
if (!selectedProvider) return;
setRandomLoading(true);
try {
const genres = parseGenres(genreText);
const result = await generateRandomSubject(
{ provider: selectedProvider, model: selectedModel || undefined, genres: genres.length > 0 ? genres : undefined },
token || undefined,
);
setSubject(result);
} catch (err: any) {
setError(err.message || 'Failed to generate subject');
} finally {
setRandomLoading(false);
}
}, [selectedProvider, selectedModel, genreText, parseGenres, token]);
// ── Submit ──
const handleSubmit = useCallback(async () => {
if (!canSubmit || loading) return;
setError('');
setLoading(true);
setLoadingStage('Generating song with AI…');
try {
const genres = parseGenres(genreText);
// If no genres provided, use a neutral default so the LLM has context
const effectiveGenres = genres.length > 0 ? genres : ['any genre'];
const effectiveSubject = subject.trim() || 'a creative and interesting topic of your choice';
const result = await runLlmInspire(
{
provider: selectedProvider,
model: selectedModel || undefined,
genres: effectiveGenres,
subject: effectiveSubject,
language,
},
token || undefined,
);
// Build the result for the parent
const aiResult: AiGenerateResult = {
caption: result.caption || effectiveGenres.join(', '),
lyrics: result.lyrics || '',
title: result.title || '',
subject: effectiveSubject,
bpm: result.bpm || 0,
keyScale: result.key || '',
timeSignature: result.timeSignature || '',
duration: result.duration || 0,
vocalLanguage: language,
};
onResult(aiResult);
onClose();
// Reset form for next use (keep provider/model persisted)
setSubject('');
setGenreText('');
setError('');
} catch (err: any) {
setError(err.message || 'AI generation failed');
} finally {
setLoading(false);
setLoadingStage('');
}
}, [canSubmit, loading, parseGenres, genreText, subject, selectedProvider, selectedModel, language, token, onResult, onClose]);
// ── Keyboard shortcuts ──
useEffect(() => {
if (!isOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !loading) onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [isOpen, loading, onClose]);
if (!isOpen) return null;
// ── Shared input classes ──
const inputClass = 'w-full px-3 py-2.5 rounded-xl bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none transition-colors';
const selectClass = 'w-full px-3 py-2.5 rounded-xl bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none transition-colors cursor-pointer appearance-none';
const labelClass = 'block text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5';
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
onClick={loading ? undefined : onClose}
/>
{/* Modal */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<div
className="w-full max-w-lg bg-zinc-50 dark:bg-zinc-900/95 rounded-2xl border border-zinc-200 dark:border-white/10 shadow-2xl pointer-events-auto overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-200 dark:border-white/5">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500 to-pink-500 flex items-center justify-center">
<Sparkles size={16} className="text-white" />
</div>
<div>
<h3 className="text-base font-bold text-zinc-900 dark:text-white">Generate with AI</h3>
<p className="text-[11px] text-zinc-500">Describe your song, let AI fill in the rest</p>
</div>
</div>
<button
onClick={onClose}
disabled={loading}
className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors disabled:opacity-50"
>
<X size={18} />
</button>
</div>
{/* Body */}
<div className="px-5 py-4 space-y-4 max-h-[60vh] overflow-y-auto">
{/* Provider / Model */}
<div>
<label className={labelClass}>LLM Provider</label>
{providersLoading ? (
<div className="flex items-center gap-2 text-xs text-zinc-500 py-2">
<Loader2 size={14} className="animate-spin" />
Loading providers
</div>
) : providers.length === 0 ? (
<div className="text-xs text-amber-400 py-2">
No LLM providers configured. Set up LM Studio, Ollama, or another OpenAI-compatible API in Settings.
</div>
) : (
<div className="grid grid-cols-2 gap-2">
<select
className={selectClass}
value={selectedProvider}
onChange={e => handleProviderChange(e.target.value)}
disabled={loading}
title="LLM Provider"
>
{providers.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<select
className={selectClass}
value={selectedModel}
onChange={e => handleModelChange(e.target.value)}
disabled={loading}
title="Model"
>
{models.map(m => (
<option key={m} value={m}>{m}</option>
))}
{models.length === 0 && <option value="">No models</option>}
</select>
</div>
)}
</div>
{/* Genre / Style */}
<div>
<label className={labelClass}>Genre / Style</label>
<input
type="text"
className={inputClass}
placeholder="e.g. indie folk, acoustic, dreamy female vocals"
value={genreText}
onChange={e => setGenreText(e.target.value)}
disabled={loading}
/>
<p className="mt-1 text-[10px] text-zinc-500">
Comma-separated genres and style descriptors. Used to generate a rich caption.
</p>
</div>
{/* Subject */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
Song Subject
</label>
<button
onClick={handleRandomSubject}
disabled={loading || randomLoading || !selectedProvider}
className="flex items-center gap-1 px-2 py-0.5 rounded-lg text-[10px] font-medium text-violet-400 hover:text-violet-300 hover:bg-violet-400/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Generate a random subject"
>
{randomLoading ? (
<Loader2 size={10} className="animate-spin" />
) : (
<Dice5 size={10} />
)}
Random
</button>
</div>
<textarea
className={`${inputClass} resize-none`}
placeholder="e.g. A night drive through empty streets, thinking about someone who left"
value={subject}
onChange={e => setSubject(e.target.value)}
disabled={loading}
rows={2}
/>
<p className="mt-1 text-[10px] text-zinc-500">
What the song should be about. Leave empty for AI to choose.
</p>
</div>
{/* Language */}
<div>
<label className={labelClass}>Vocal Language</label>
<select
className={selectClass}
value={language}
onChange={e => setLanguage(e.target.value)}
disabled={loading}
>
{VOCAL_LANGUAGES.map(l => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
</select>
</div>
{/* Error */}
{error && (
<div className="px-3 py-2 rounded-xl bg-red-500/10 border border-red-500/20 text-xs text-red-400">
{error}
</div>
)}
</div>
{/* Footer */}
<div className="px-5 py-4 border-t border-zinc-200 dark:border-white/5">
<button
onClick={handleSubmit}
disabled={!canSubmit || loading || providers.length === 0}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-gradient-to-r from-violet-600 to-pink-600 hover:from-violet-500 hover:to-pink-500 text-white font-semibold text-sm transition-all duration-200 hover:shadow-lg hover:shadow-violet-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<>
<Loader2 size={16} className="animate-spin" />
{loadingStage}
</>
) : (
<>
<Sparkles size={16} />
Generate Song
</>
)}
</button>
</div>
</div>
</div>
</>
);
};
+284
View File
@@ -0,0 +1,284 @@
// ContentSection.tsx — Caption + Lyrics input area with optional metadata fields
// Ported to Tailwind styling matching hot-step-9000.
import React from 'react';
import { Music, ChevronDown, ChevronRight, Plug, Drum } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { hasWildcards, expandInPlace, randomWildcardSeed } from '../../utils/wildcardUtils';
interface ContentSectionProps {
caption: string;
onCaptionChange: (v: string) => void;
lyrics: string;
onLyricsChange: (v: string) => void;
instrumental: boolean;
onInstrumentalChange: (v: boolean) => void;
// Optional metadata fields (auto-populated from Lyric Studio Send to Create)
title: string;
onTitleChange: (v: string) => void;
artist: string;
onArtistChange: (v: string) => void;
subject: string;
onSubjectChange: (v: string) => void;
negativePrompt: string;
onNegativePromptChange: (v: string) => void;
// Compose-time caption helpers (MDMAchine)
loraTrigger: string;
onLoraTriggerChange: (v: string) => void;
beatIntro: boolean;
onBeatIntroChange: (v: boolean) => void;
introBars: number;
onIntroBarsChange: (v: number) => void;
autoExpand: boolean;
onAutoExpandChange: (v: boolean) => void;
/** Seed for manual wildcard expansion; undefined = random per click */
wildcardSeed?: number;
}
export const ContentSection: React.FC<ContentSectionProps> = ({
caption, onCaptionChange, lyrics, onLyricsChange,
instrumental, onInstrumentalChange,
title, onTitleChange, artist, onArtistChange, subject, onSubjectChange,
negativePrompt, onNegativePromptChange,
loraTrigger, onLoraTriggerChange,
beatIntro, onBeatIntroChange,
introBars, onIntroBarsChange,
autoExpand, onAutoExpandChange,
wildcardSeed,
}) => {
const { t } = useTranslation();
const hasMetadata = !!(title || artist || subject);
const [showMetadata, setShowMetadata] = React.useState(hasMetadata);
const styleRef = React.useRef<HTMLTextAreaElement>(null);
const lyricsRef = React.useRef<HTMLTextAreaElement>(null);
// Expand {a|b|c} wildcards in place, preserving the caret position
const expandField = (
ref: React.RefObject<HTMLTextAreaElement | null>,
onChange: (v: string) => void,
) => {
if (!ref.current) return;
const seed = wildcardSeed ?? randomWildcardSeed();
const { value, selectionStart, selectionEnd } = expandInPlace(ref.current, seed, 0);
onChange(value);
requestAnimationFrame(() => {
ref.current?.setSelectionRange(selectionStart, selectionEnd);
ref.current?.focus();
});
};
const autoExpandBtn = (
<button
onClick={() => onAutoExpandChange(!autoExpand)}
title={autoExpand ? t('contentSection.autoExpandOn') : t('contentSection.autoExpandOff')}
className={`text-[9px] px-1.5 py-0.5 rounded transition-colors ${
autoExpand
? 'bg-amber-500/20 text-amber-600 dark:bg-amber-600/30 dark:text-amber-300 hover:bg-amber-500/30 dark:hover:bg-amber-600/50'
: 'text-zinc-400 dark:text-zinc-600 hover:text-zinc-600 dark:hover:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-800'
}`}
>
{t('contentSection.autoExpand')}
</button>
);
// Auto-expand when metadata is populated (e.g. from Send to Create)
React.useEffect(() => {
if (hasMetadata && !showMetadata) setShowMetadata(true);
}, [hasMetadata]);
return (
<div className="space-y-3">
{/* Style / Caption */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs font-medium text-zinc-500 uppercase tracking-wider">
{t('contentSection.styleDescription')}
</label>
<div className="flex items-center gap-1.5">
{hasWildcards(caption) && (
<button
onClick={() => expandField(styleRef, onCaptionChange)}
className="text-[9px] px-1.5 py-0.5 rounded font-mono bg-pink-500/10 text-pink-600 dark:bg-pink-900/40 dark:text-pink-300 hover:bg-pink-500/20 dark:hover:bg-pink-700/60 transition-colors"
>
{'{·}'} {t('contentSection.expand')}
</button>
)}
{autoExpandBtn}
</div>
</div>
<textarea
ref={styleRef}
className="w-full px-3 py-2.5 rounded-xl bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 dark:text-zinc-400 dark:placeholder:text-zinc-600 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none resize-none transition-colors"
placeholder="Dreamy indie folk, warm acoustic guitar, soft female vocals, intricate fingerpicking..."
value={caption}
onChange={e => onCaptionChange(e.target.value)}
rows={3}
/>
</div>
{/* LoRA trigger word + Beat intro/outro — compose-time caption helpers */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="flex items-center gap-1 text-[10px] text-zinc-500 shrink-0 w-20">
<Plug size={11} /> {t('contentSection.loraTrigger')}
</span>
<input
type="text"
value={loraTrigger}
onChange={e => onLoraTriggerChange(e.target.value)}
placeholder={t('contentSection.loraTriggerPlaceholder')}
className="flex-1 px-2 py-1 rounded-lg bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 text-xs text-zinc-700 dark:text-zinc-300 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 outline-none focus:border-pink-500/30 transition-colors"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onBeatIntroChange(!beatIntro)}
title={t('contentSection.beatIOTooltip')}
className={`flex items-center gap-1 text-[10px] px-2 py-0.5 rounded font-medium transition-colors shrink-0 border ${
beatIntro
? 'bg-orange-500/15 text-orange-600 border-orange-500/40 dark:bg-orange-600/30 dark:text-orange-300 dark:border-orange-600/40'
: 'text-zinc-500 border-zinc-300 dark:border-zinc-700 hover:bg-zinc-200 dark:hover:bg-zinc-800 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
<Drum size={11} /> {t('contentSection.beatIO')}
</button>
{beatIntro && (
<div className="flex items-center gap-1">
<span className="text-[9px] text-zinc-500 dark:text-zinc-600">{t('contentSection.bars')}:</span>
{([1, 2, 4, 8] as const).map(n => (
<button
key={n}
onClick={() => onIntroBarsChange(n)}
className={`text-[9px] w-5 h-4 rounded font-medium transition-colors ${
introBars === n
? 'bg-orange-500 text-white'
: 'text-zinc-500 dark:text-zinc-600 hover:bg-zinc-200 dark:hover:bg-zinc-800'
}`}
>
{n}
</button>
))}
</div>
)}
</div>
</div>
{/* Song Metadata (Title / Artist / Subject) — collapsible */}
<div>
<button
onClick={() => setShowMetadata(!showMetadata)}
className="flex items-center gap-1.5 text-xs font-medium text-zinc-500 uppercase tracking-wider hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors mb-1.5"
>
{showMetadata
? <ChevronDown size={12} className="text-zinc-500" />
: <ChevronRight size={12} className="text-zinc-500" />}
{t('contentSection.songInfo')}
{hasMetadata && (
<span className="text-[9px] text-pink-400/80 font-normal normal-case ml-1">{t('contentSection.populated')}</span>
)}
</button>
{showMetadata && (
<div className="space-y-2 pl-0.5">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-zinc-600 mb-0.5">{t('contentSection.artist')}</label>
<input
type="text"
className="w-full px-2.5 py-1.5 rounded-lg bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 dark:text-zinc-400 dark:placeholder:text-zinc-600 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none transition-colors"
placeholder={t('contentSection.artistPlaceholder')}
value={artist}
onChange={e => onArtistChange(e.target.value)}
/>
</div>
<div>
<label className="block text-[10px] text-zinc-600 mb-0.5">{t('contentSection.title')}</label>
<input
type="text"
className="w-full px-2.5 py-1.5 rounded-lg bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 dark:text-zinc-400 dark:placeholder:text-zinc-600 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none transition-colors"
placeholder={t('contentSection.titlePlaceholder')}
value={title}
onChange={e => onTitleChange(e.target.value)}
/>
</div>
</div>
<div>
<label className="block text-[10px] text-zinc-600 mb-0.5">{t('contentSection.subject')}</label>
<input
type="text"
className="w-full px-2.5 py-1.5 rounded-lg bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 dark:text-zinc-400 dark:placeholder:text-zinc-600 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none transition-colors"
placeholder={t('contentSection.subjectPlaceholder')}
value={subject}
onChange={e => onSubjectChange(e.target.value)}
/>
</div>
</div>
)}
</div>
{/* Instrumental toggle */}
<label className="flex items-center gap-2.5 cursor-pointer group">
<div className="relative">
<input
type="checkbox"
checked={instrumental}
onChange={e => onInstrumentalChange(e.target.checked)}
className="sr-only peer"
/>
<div className="w-8 h-4.5 bg-zinc-200 dark:bg-zinc-700 rounded-full peer-checked:bg-pink-500 transition-colors" />
<div className="absolute top-0.5 left-0.5 w-3.5 h-3.5 bg-white rounded-full transition-transform peer-checked:translate-x-3.5" />
</div>
<div className="flex items-center gap-1.5">
<Music size={14} className="text-zinc-500" />
<span className="text-sm text-zinc-600 dark:text-zinc-400 group-hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors">
{t('contentSection.instrumental')}
</span>
</div>
</label>
{/* Lyrics */}
{!instrumental && (
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs font-medium text-zinc-500 uppercase tracking-wider">
{t('contentSection.lyrics')}
</label>
<div className="flex items-center gap-1.5">
{hasWildcards(lyrics) && (
<button
onClick={() => expandField(lyricsRef, onLyricsChange)}
className="text-[9px] px-1.5 py-0.5 rounded font-mono bg-purple-500/10 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300 hover:bg-purple-500/20 dark:hover:bg-purple-700/60 transition-colors"
>
{'{·}'} {t('contentSection.expand')}
</button>
)}
{autoExpandBtn}
</div>
</div>
<textarea
ref={lyricsRef}
className="w-full px-3 py-2.5 rounded-xl bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 dark:text-zinc-400 dark:placeholder:text-zinc-600 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none resize-vertical transition-colors font-mono leading-relaxed"
placeholder={`[Verse 1]\nWalking through the morning light\nEvery shadow fading bright\n\n[Chorus]\nWe're alive, we're alive tonight...`}
value={lyrics}
onChange={e => onLyricsChange(e.target.value)}
rows={8}
/>
</div>
)}
{/* Negative Prompt */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">
Negative Prompt
</label>
<textarea
className="w-full px-3 py-2.5 rounded-xl bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 focus:border-red-500/50 focus:ring-1 focus:ring-red-500/20 outline-none resize-none transition-colors"
placeholder="jazz, acoustic, slow, ambient, piano, soft, classical..."
value={negativePrompt}
onChange={e => onNegativePromptChange(e.target.value)}
rows={2}
/>
</div>
</div>
);
};
+304
View File
@@ -0,0 +1,304 @@
// CreatePanel.tsx — The composition panel (Content + Metadata only)
//
// Global engine parameters (Models, Adapters, Generation Settings, LM, Mastering)
// have been moved to the GlobalParamBar. This panel now only handles
// per-song content and metadata.
import React, { useState, useEffect, useCallback } from 'react';
import { Zap, ListPlus, Sparkles, Radio } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePersistedState } from '../../hooks/usePersistedState';
import { useGlobalParams } from '../../context/GlobalParamsContext';
import { ContentSection } from './ContentSection';
import { MetadataSection } from './MetadataSection';
import { LatentImport } from '../shared/LatentImport';
import { CoverArtSubjectSection } from '../shared/CoverArtSubjectSection';
import { AiGenerateModal, type AiGenerateResult } from './AiGenerateModal';
import { useStreamGeneration } from '../../hooks/useStreamGeneration';
import { StreamPlayer } from '../player/StreamPlayer';
import { expandWildcards, hasWildcards, randomWildcardSeed } from '../../utils/wildcardUtils';
import type { GenerationParams, Song } from '../../types';
interface CreatePanelProps {
onGenerate: (params: Partial<GenerationParams>) => void;
activeJobCount: number;
reuseData?: { song: Song; timestamp: number } | null;
/** Currently active streaming job ID (for SSE connection) */
streamJobId?: string | null;
}
export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, activeJobCount, reuseData, streamJobId }) => {
const { t } = useTranslation();
// ── Stream mode ──
const [streamMode, setStreamMode] = usePersistedState('hs-streamMode', false);
const stream = useStreamGeneration(streamJobId || null);
// ── AI Generate modal ──
const [aiModalOpen, setAiModalOpen] = useState(false);
// ── Content (per-song) ──
const [caption, setCaption] = usePersistedState('hs-caption', '');
const [lyrics, setLyrics] = usePersistedState('hs-lyrics', '');
const [negativePrompt, setNegativePrompt] = usePersistedState('hs-negative-prompt', '');
const [instrumental, setInstrumental] = usePersistedState('hs-instrumental', false);
// ── Compose-time caption helpers (MDMAchine) ──
const [loraTrigger, setLoraTrigger] = usePersistedState('hs-lora-trigger', '');
const [beatIntro, setBeatIntro] = usePersistedState('hs-beat-intro', false);
const [introBars, setIntroBars] = usePersistedState('hs-intro-bars', 2);
const [autoExpand, setAutoExpand] = usePersistedState('hs-main-auto-expand', false);
// LoRA trigger word prepended, beat intro/outro request appended
const buildCaption = useCallback((base: string) => {
const loraText = loraTrigger.trim() ? `${loraTrigger.trim()}, ` : '';
const beatText = beatIntro ? `, with a clean ${introBars}-bar percussive intro and outro for DJ mixing` : '';
return `${loraText}${base}${beatText}`;
}, [loraTrigger, beatIntro, introBars]);
// ── Song Info (optional, auto-populated from Lyric Studio Send to Create) ──
const [title, setTitle] = usePersistedState('hs-title', '');
const [artist, setArtist] = usePersistedState('hs-artist', '');
const [subject, setSubject] = usePersistedState('hs-subject', '');
// ── Metadata (per-song) ──
const [bpm, setBpm] = usePersistedState('hs-bpm', 0);
const [keyScale, setKeyScale] = usePersistedState('hs-keyScale', '');
const [timeSignature, setTimeSignature] = usePersistedState('hs-timeSignature', '');
const [duration, setDuration] = usePersistedState('hs-duration', -1);
const [vocalLanguage, setVocalLanguage] = usePersistedState('hs-vocalLanguage', 'en');
const [sourceLatentUrl, setSourceLatentUrl] = usePersistedState('hs-sourceLatentUrl', '');
// Global params context — for reuse data
const gp = useGlobalParams();
// ── Reuse data (Edit) — restores ALL generation params for full reproducibility ──
useEffect(() => {
if (!reuseData) return;
const gpData = reuseData.song.generationParams;
if (!gpData) return;
// Style Description field ← user's original style input from generation_params
// Priority: gpData.caption (original user input) → song.style (DB column)
setCaption(gpData.caption || reuseData.song.style || '');
// Lyrics
setLyrics(gpData.lyrics || reuseData.song.lyrics || '');
// Song info metadata
if (gpData.title || reuseData.song.title) setTitle(gpData.title || reuseData.song.title || '');
if (gpData.artist) setArtist(gpData.artist);
if (gpData.subject) setSubject(gpData.subject);
// Metadata
if (gpData.bpm) setBpm(gpData.bpm);
if (gpData.keyScale) setKeyScale(gpData.keyScale);
if (gpData.timeSignature) setTimeSignature(gpData.timeSignature);
if (gpData.duration) setDuration(typeof gpData.duration === 'string' ? parseFloat(gpData.duration) : gpData.duration);
if (gpData.vocalLanguage) setVocalLanguage(gpData.vocalLanguage);
// Engine params — full reproducibility
if (gpData.inferenceSteps) gp.setInferenceSteps(gpData.inferenceSteps);
if (gpData.guidanceScale !== undefined) gp.setGuidanceScale(gpData.guidanceScale);
if (gpData.cfgCutoffRatio !== undefined) gp.setCfgCutoffRatio(gpData.cfgCutoffRatio);
if (gpData.lmCfgCutoffRatio !== undefined) gp.setLmCfgCutoffRatio(gpData.lmCfgCutoffRatio);
if (gpData.cacheRatio !== undefined) gp.setCacheRatio(gpData.cacheRatio);
if (gpData.seed !== undefined) gp.setSeed(gpData.seed);
if (gpData.randomSeed !== undefined) gp.setRandomSeed(gpData.randomSeed);
if (gpData.lmSeed !== undefined) gp.setLmSeed(gpData.lmSeed);
if (gpData.lmSeedFollowsDit !== undefined) gp.setLmSeedFollowsDit(gpData.lmSeedFollowsDit);
if (gpData.shift !== undefined) gp.setShift(gpData.shift);
if (gpData.inferMethod) gp.setInferMethod(gpData.inferMethod);
if (gpData.scheduler) gp.setScheduler(gpData.scheduler);
if (gpData.guidanceMode) gp.setGuidanceMode(gpData.guidanceMode);
if (gpData.batchSize) gp.setBatchSize(gpData.batchSize);
if (gpData.useCotCaption !== undefined) gp.setUseCotCaption(gpData.useCotCaption);
if (gpData.skipLm !== undefined) gp.setSkipLm(gpData.skipLm);
// Adapter
if (gpData.adapter || gpData.loraPath) gp.setAdapter(gpData.adapter || gpData.loraPath);
if (gpData.adapterScale ?? gpData.loraScale) gp.setAdapterScale(gpData.adapterScale ?? gpData.loraScale);
if (gpData.adapterGroupScales) gp.setAdapterGroupScales(gpData.adapterGroupScales);
if (gpData.adapterMode) gp.setAdapterMode(gpData.adapterMode);
// Model selection
if (gpData.ditModel) gp.setDitModel(gpData.ditModel);
if (gpData.lmModel) gp.setLmModel(gpData.lmModel);
if (gpData.vaeModel) gp.setVaeModel(gpData.vaeModel);
// DCW
if (gpData.dcwEnabled !== undefined) gp.setDcwEnabled(gpData.dcwEnabled);
if (gpData.dcwMode) gp.setDcwMode(gpData.dcwMode);
if (gpData.dcwLowScaler !== undefined) gp.setDcwLowScaler(gpData.dcwLowScaler);
if (gpData.dcwHighScaler !== undefined) gp.setDcwHighScaler(gpData.dcwHighScaler);
// Post-processing
if (gpData.postProcessingEnabled !== undefined) gp.setPostProcessingEnabled(gpData.postProcessingEnabled);
if (gpData.masteringEnabled !== undefined) gp.setMasteringEnabled(gpData.masteringEnabled);
if (gpData.masteringReference !== undefined) gp.setMasteringReference(gpData.masteringReference);
}, [reuseData?.timestamp]);
// ── AI generation result handler ──
const handleAiResult = useCallback((result: AiGenerateResult) => {
if (result.caption) setCaption(result.caption);
if (result.lyrics) setLyrics(result.lyrics);
if (result.title) setTitle(result.title);
if (result.subject) setSubject(result.subject);
if (result.bpm) setBpm(result.bpm);
if (result.keyScale) setKeyScale(result.keyScale);
if (result.timeSignature) setTimeSignature(result.timeSignature);
if (result.duration) setDuration(result.duration);
if (result.vocalLanguage) setVocalLanguage(result.vocalLanguage);
// Disable instrumental mode since AI generated lyrics
setInstrumental(false);
}, [setCaption, setLyrics, setTitle, setSubject, setBpm, setKeyScale, setTimeSignature, setDuration, setVocalLanguage, setInstrumental]);
const handleGenerate = () => {
// Wildcard auto-expand: reproducible from the DiT seed when it's locked,
// fresh randomness when the seed is random anyway
const wcSeed = gp.randomSeed ? randomWildcardSeed() : gp.seed;
const resolvedCaption = autoExpand && hasWildcards(caption)
? expandWildcards(caption, wcSeed, 0) : caption;
const resolvedLyrics = autoExpand && hasWildcards(lyrics)
? expandWildcards(lyrics, wcSeed, 0) : lyrics;
const params: Partial<GenerationParams> = {
caption: buildCaption(resolvedCaption),
lyrics: instrumental ? '[Instrumental]' : resolvedLyrics,
...(negativePrompt.trim() ? { negative_prompt: negativePrompt.trim() } : {}),
instrumental,
bpm, duration, keyScale, timeSignature, vocalLanguage,
taskType: 'text2music',
};
// Optional song info fields — only include if populated
if (title.trim()) params.title = title.trim();
if (artist.trim()) params.artist = artist.trim();
if (subject.trim()) params.subject = subject.trim();
if (sourceLatentUrl) params.sourceLatentUrl = sourceLatentUrl;
// Stream mode — SHELVED
// if (streamMode) {
// (params as any).streamMode = true;
// }
onGenerate(params);
};
return (
<div className="h-full flex flex-col bg-zinc-50 dark:bg-suno">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-200 dark:border-white/5">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">{t('createPanel.title')}</h2>
<div className="flex items-center gap-2">
<button
onClick={() => setAiModalOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-violet-400 hover:text-violet-300 bg-violet-500/10 hover:bg-violet-500/20 border border-violet-500/20 hover:border-violet-500/30 transition-all duration-200"
title="Generate all fields using an external AI model"
>
<Sparkles size={13} />
Generate with AI
</button>
</div>
</div>
{/* Scrollable body — now much slimmer */}
<div className="flex-1 overflow-y-auto hide-scrollbar px-4 py-3 space-y-1">
<ContentSection
caption={caption} onCaptionChange={setCaption}
lyrics={lyrics} onLyricsChange={setLyrics}
instrumental={instrumental} onInstrumentalChange={setInstrumental}
title={title} onTitleChange={setTitle}
artist={artist} onArtistChange={setArtist}
subject={subject} onSubjectChange={setSubject}
negativePrompt={negativePrompt} onNegativePromptChange={setNegativePrompt}
loraTrigger={loraTrigger} onLoraTriggerChange={setLoraTrigger}
beatIntro={beatIntro} onBeatIntroChange={setBeatIntro}
introBars={introBars} onIntroBarsChange={setIntroBars}
autoExpand={autoExpand} onAutoExpandChange={setAutoExpand}
wildcardSeed={gp.randomSeed ? undefined : gp.seed}
/>
<MetadataSection
bpm={bpm} onBpmChange={setBpm}
keyScale={keyScale} onKeyScaleChange={setKeyScale}
timeSignature={timeSignature} onTimeSignatureChange={setTimeSignature}
duration={duration} onDurationChange={setDuration}
vocalLanguage={vocalLanguage} onVocalLanguageChange={setVocalLanguage}
/>
{/* Latent import */}
<LatentImport
latentUrl={sourceLatentUrl}
onLatentLoaded={(url, meta) => {
setSourceLatentUrl(url);
if (meta.bpm && meta.bpm > 0) setBpm(meta.bpm);
if (meta.key) setKeyScale(meta.key);
if (meta.lyrics) setLyrics(meta.lyrics);
if (meta.caption) setCaption(meta.caption);
}}
onClear={() => setSourceLatentUrl('')}
/>
{/* Cover Art prompt override (only when enabled) */}
<CoverArtSubjectSection />
</div>
{/* Stream Player — SHELVED: streaming not yet production-ready */}
{false && streamJobId && (
<div className="px-4 py-2 border-t border-zinc-200 dark:border-white/5">
<StreamPlayer
connected={stream.connected}
status={stream.status}
previews={stream.previews}
playing={stream.playing}
done={stream.done}
error={stream.error}
onPlay={stream.play}
onPause={stream.pause}
onStop={stream.stop}
/>
</div>
)}
{/* Generate button + Stream toggle */}
<div className="px-4 py-3 border-t border-zinc-200 dark:border-white/5 space-y-2">
{/* Stream mode toggle — SHELVED: streaming not yet production-ready */}
{false && <div className="flex items-center justify-between">
<button
onClick={() => setStreamMode(!streamMode)}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-all ${
streamMode
? 'text-emerald-400 bg-emerald-500/10 border border-emerald-500/20'
: 'text-zinc-500 hover:text-zinc-300 bg-zinc-800/50 border border-zinc-700/50'
}`}
title="Enable streaming preview — hear audio as it generates"
>
<Radio size={12} />
Stream
</button>
{streamMode && (
<span className="text-[10px] text-zinc-600 italic">Preview audio during generation</span>
)}
</div>}
<button
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl font-semibold text-sm transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed bg-gradient-to-r from-pink-600 to-purple-600 hover:from-pink-500 hover:to-purple-500 hover:shadow-lg hover:shadow-pink-500/20 text-white"
onClick={handleGenerate}
disabled={!caption.trim() && !lyrics.trim() && !instrumental}
>
{activeJobCount > 0 ? (
<>
<ListPlus size={18} />
{t('createPanel.queueGeneration')}
<span className="ml-1 inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full bg-white/20 text-xs font-bold tabular-nums">
{activeJobCount}
</span>
</>
) : (
<>
<Zap size={18} />
{t('createPanel.generate')}
</>
)}
</button>
</div>
{/* AI Generate Modal */}
<AiGenerateModal
isOpen={aiModalOpen}
onClose={() => setAiModalOpen(false)}
onResult={handleAiResult}
/>
</div>
);
};
@@ -0,0 +1,222 @@
// MasteringSection.tsx — Reference-based mastering controls
//
// Collapsible accordion section with:
// - Toggle: enable/disable reference mastering
// - Reference track selector with upload support
import React, { useState, useEffect, useCallback } from 'react';
import { ChevronDown, Upload, Trash2, Sparkles, Music2 } from 'lucide-react';
import { masteringApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
interface MasteringSectionProps {
masteringEnabled: boolean;
onMasteringEnabledChange: (v: boolean) => void;
masteringReference: string;
onMasteringReferenceChange: (v: string) => void;
timbreReference: boolean;
onTimbreReferenceChange: (v: boolean) => void;
timbreAudioPath?: string;
}
interface ReferenceTrack {
name: string;
size: number;
url: string;
}
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
export const MasteringSection: React.FC<MasteringSectionProps> = ({
masteringEnabled,
onMasteringEnabledChange,
masteringReference,
onMasteringReferenceChange,
timbreReference,
onTimbreReferenceChange,
timbreAudioPath = '',
}) => {
const { token } = useAuth();
const [open, setOpen] = useState(false);
const [references, setReferences] = useState<ReferenceTrack[]>([]);
const [uploading, setUploading] = useState(false);
// Load references on mount
useEffect(() => {
masteringApi.listReferences()
.then(data => setReferences(data.references))
.catch(() => {});
}, []);
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !token) return;
try {
setUploading(true);
const result = await masteringApi.uploadReference(file, token);
onMasteringReferenceChange(result.name);
// Refresh list
const data = await masteringApi.listReferences();
setReferences(data.references);
} catch (err) {
console.error('[Mastering] Upload failed:', err);
} finally {
setUploading(false);
e.target.value = '';
}
}, [token, onMasteringReferenceChange]);
const handleDelete = useCallback(async (name: string) => {
if (!token) return;
try {
await masteringApi.deleteReference(name, token);
if (masteringReference === name) onMasteringReferenceChange('');
const data = await masteringApi.listReferences();
setReferences(data.references);
} catch (err) {
console.error('[Mastering] Delete failed:', err);
}
}, [token, masteringReference, onMasteringReferenceChange]);
return (
<div className="space-y-1 pt-3 border-t border-zinc-200 dark:border-white/5">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-400 uppercase tracking-wider">Mastering</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
masteringEnabled ? 'bg-amber-500/20 text-amber-400' : 'bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400'
}`}>
{masteringEnabled ? 'ON' : 'OFF'}
</span>
</div>
<ChevronDown size={14} className={`text-zinc-500 transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="px-3 pb-3 space-y-3">
{/* Enable toggle */}
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={masteringEnabled}
onChange={e => onMasteringEnabledChange(e.target.checked)}
className="rounded border-zinc-600 bg-zinc-100 dark:bg-zinc-800 text-amber-500 focus:ring-amber-500/20"
/>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Apply Reference Mastering</span>
<Sparkles size={14} className="text-amber-400 ml-auto" />
</label>
{masteringEnabled && (
<>
{/* Reference selector */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">
Reference Track
</label>
{references.length > 0 ? (
<select
className="w-full px-3 py-2 rounded-xl bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 focus:border-amber-500/50 focus:ring-1 focus:ring-amber-500/20 outline-none transition-colors cursor-pointer"
value={masteringReference}
onChange={e => onMasteringReferenceChange(e.target.value)}
>
<option value="">Select a reference...</option>
{references.map(r => (
<option key={r.name} value={r.name}>
{r.name} ({formatFileSize(r.size)})
</option>
))}
</select>
) : (
<div className="text-xs text-zinc-500 italic px-1">
No reference tracks uploaded yet
</div>
)}
</div>
{/* Selected reference info + delete */}
{masteringReference && (
<div className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-amber-500/5 border border-amber-500/10">
<Music2 size={14} className="text-amber-400 flex-shrink-0" />
<span className="text-xs text-amber-300 truncate flex-1">{masteringReference}</span>
<button
onClick={() => handleDelete(masteringReference)}
className="p-1 rounded hover:bg-red-500/10 text-zinc-500 hover:text-red-400 transition-colors flex-shrink-0"
title="Delete reference"
>
<Trash2 size={12} />
</button>
</div>
)}
{/* Upload button */}
<div className="flex items-center gap-2">
<input
type="file"
accept="audio/*"
id="mastering-ref-upload"
className="hidden"
onChange={handleUpload}
/>
<label
htmlFor="mastering-ref-upload"
className={`flex items-center gap-2 px-3 py-2 text-xs font-semibold rounded-xl border cursor-pointer transition-all ${
uploading
? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500 border-zinc-200 dark:border-white/5 cursor-wait'
: 'bg-white dark:bg-zinc-900 text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-white/10 hover:border-amber-500/30 hover:text-amber-400'
}`}
>
{uploading ? (
<><span className="w-3 h-3 border-2 border-zinc-500 border-t-transparent rounded-full animate-spin" /> Uploading...</>
) : (
<><Upload size={14} /> Upload Reference</>
)}
</label>
</div>
{/* Timbre reference toggle */}
{masteringReference && (
timbreAudioPath ? (
<div className="flex items-center gap-1.5 mt-1 px-2 py-1.5 rounded-lg bg-teal-500/5 border border-teal-500/10">
<Music2 size={14} className="text-teal-400" />
<span className="text-[10px] text-teal-400">Timbre: using dedicated reference ({timbreAudioPath.split(/[\\/]/).pop()})</span>
</div>
) : (
<label className="flex items-center gap-2.5 cursor-pointer mt-1">
<input
type="checkbox"
checked={timbreReference}
onChange={e => onTimbreReferenceChange(e.target.checked)}
className="rounded border-zinc-600 bg-zinc-100 dark:bg-zinc-800 text-teal-500 focus:ring-teal-500/20"
/>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Also use as timbre reference</span>
<Music2 size={14} className="text-teal-400 ml-auto" />
</label>
)
)}
{timbreReference && masteringReference && !timbreAudioPath && (
<p className="text-[10px] text-zinc-600 leading-relaxed">
The reference track will be VAE-encoded and fed into the timbre conditioning pipeline,
guiding the generation&apos;s tone and texture to match the reference.
</p>
)}
{/* Info */}
<p className="text-[10px] text-zinc-600 leading-relaxed">
The generated audio will be mastered to match the RMS level, frequency spectrum,
and dynamic characteristics of the reference track.
</p>
</>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,96 @@
// MetadataSection.tsx — BPM, Key, Time Signature, Duration, Language
// Ported to Tailwind styling matching hot-step-9000's grid layout.
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Slider } from '../shared/Slider';
import { VOCAL_LANGUAGES } from '../../constants/languages';
const KEY_SIGNATURES = [
'', 'C major', 'C minor', 'C# major', 'C# minor',
'D major', 'D minor', 'D# major', 'D# minor',
'E major', 'E minor', 'F major', 'F minor',
'F# major', 'F# minor', 'G major', 'G minor',
'G# major', 'G# minor', 'A major', 'A minor',
'A# major', 'A# minor', 'B major', 'B minor',
];
const TIME_SIGNATURES = ['', '4/4', '3/4', '6/8', '2/4'];
interface MetadataSectionProps {
bpm: number;
onBpmChange: (v: number) => void;
keyScale: string;
onKeyScaleChange: (v: string) => void;
timeSignature: string;
onTimeSignatureChange: (v: string) => void;
duration: number;
onDurationChange: (v: number) => void;
vocalLanguage: string;
onVocalLanguageChange: (v: string) => void;
}
const selectClasses = "w-full px-3 py-2 rounded-xl bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none transition-colors cursor-pointer";
export const MetadataSection: React.FC<MetadataSectionProps> = ({
bpm, onBpmChange, keyScale, onKeyScaleChange,
timeSignature, onTimeSignatureChange,
duration, onDurationChange,
vocalLanguage, onVocalLanguageChange,
}) => {
const { t } = useTranslation();
return (
<div className="space-y-3 pt-3 border-t border-zinc-200 dark:border-white/5">
<h4 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">{t('metadataSection.musicParameters')}</h4>
<div className="grid grid-cols-2 gap-3">
{/* BPM */}
<div>
<Slider label={t('metadataSection.bpm')} value={bpm} onChange={onBpmChange}
min={0} max={240} step={1} showInput suffix="" />
{bpm === 0 && <span className="text-[10px] text-zinc-600">{t('metadataSection.auto')}</span>}
</div>
{/* Duration */}
<div>
<Slider label={t('metadataSection.duration')} value={duration} onChange={onDurationChange}
min={-1} max={240} step={1} suffix="s" showInput />
{duration <= 0 && <span className="text-[10px] text-zinc-600">{t('metadataSection.auto')}</span>}
</div>
{/* Key */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">{t('metadataSection.key')}</label>
<select className={selectClasses} value={keyScale}
onChange={e => onKeyScaleChange(e.target.value)}>
{KEY_SIGNATURES.map(k => (
<option key={k} value={k}>{k || t('metadataSection.auto')}</option>
))}
</select>
</div>
{/* Time Signature */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">{t('metadataSection.timeSig')}</label>
<select className={selectClasses} value={timeSignature}
onChange={e => onTimeSignatureChange(e.target.value)}>
{TIME_SIGNATURES.map(tSig => (
<option key={tSig} value={tSig}>{tSig || t('metadataSection.auto')}</option>
))}
</select>
</div>
{/* Language */}
<div className="col-span-2">
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">{t('metadataSection.vocalLanguage')}</label>
<select className={selectClasses} value={vocalLanguage}
onChange={e => onVocalLanguageChange(e.target.value)}>
{VOCAL_LANGUAGES.map(l => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
</select>
</div>
</div>
</div>
);
};
@@ -0,0 +1,194 @@
// MetadataEditorModal.tsx — edit a track's embed metadata + cover (#60).
//
// Shows the auto-populated metadata and lets the user change it. Title / genre /
// bpm / key / lyrics update the song columns (so the library reflects them);
// artist / album / year / comment are stored as verbatim embed overrides. The
// cover can be replaced with an uploaded image. All of it is embedded into
// exported audio files on download (handled server-side by gatherSongMetadata).
import React, { useState, useRef } from 'react';
import ReactDOM from 'react-dom';
import { X, Image as ImageIcon, Loader2, Save } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { Song } from '../../types';
import { songApi } from '../../services/api';
interface MetadataEditorModalProps {
song: Song;
token: string;
onClose: () => void;
onSaved: (song: Song) => void;
}
const Field: React.FC<{ label: string; value: string; onChange: (v: string) => void; type?: string }> = ({
label, value, onChange, type = 'text',
}) => (
<div>
<label className="block text-xs font-medium text-zinc-500 mb-1">{label}</label>
<input
type={type}
value={value}
onChange={e => onChange(e.target.value)}
className="w-full text-sm rounded-lg bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-white/10 px-3 py-2 text-zinc-800 dark:text-zinc-200 outline-none focus:border-cyan-500"
/>
</div>
);
export const MetadataEditorModal: React.FC<MetadataEditorModalProps> = ({ song, token, onClose, onSaved }) => {
const { t } = useTranslation();
const gp: any = song.generationParams || {};
const overrides: any = (() => {
try { return song.metadata_overrides ? JSON.parse(song.metadata_overrides) : {}; } catch { return {}; }
})();
const initialYear = (() => {
try { return song.created_at ? String(new Date(song.created_at).getFullYear()) : ''; } catch { return ''; }
})();
const [title, setTitle] = useState(song.title || '');
const [artist, setArtist] = useState<string>(overrides.artist ?? gp.artist ?? gp.artistName ?? '');
const [album, setAlbum] = useState<string>(overrides.album ?? gp.album ?? '');
const [genre, setGenre] = useState(song.caption || gp.caption || '');
const [year, setYear] = useState<string>(overrides.year ?? initialYear);
const [comment, setComment] = useState<string>(overrides.comment ?? '');
const [bpm, setBpm] = useState(song.bpm ? String(song.bpm) : (gp.bpm ? String(gp.bpm) : ''));
const [key, setKey] = useState(song.key_scale || gp.keyScale || '');
const [lyrics, setLyrics] = useState(song.lyrics || '');
const [coverUrl, setCoverUrl] = useState(song.coverUrl || song.cover_url || '');
const [uploading, setUploading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const fileRef = useRef<HTMLInputElement>(null);
const handleCoverFile = async (file: File) => {
setUploading(true); setError('');
try {
const form = new FormData();
form.append('image', file);
const res = await fetch('/api/upload/cover-image', { method: 'POST', body: form });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Upload failed');
const d = await res.json();
if (d.cover_url) setCoverUrl(d.cover_url);
} catch (e: any) {
setError(e.message || 'Cover upload failed');
} finally { setUploading(false); }
};
const handleSave = async () => {
setSaving(true); setError('');
try {
const payload: any = {
title,
caption: genre,
bpm: bpm ? (parseInt(bpm, 10) || 0) : 0,
key_scale: key,
lyrics,
cover_url: coverUrl,
metadata_overrides: {
artist: artist.trim() || undefined,
album: album.trim() || undefined,
year: year.trim() || undefined,
comment: comment.trim() || undefined,
},
};
const { song: updated } = await songApi.update(song.id, payload, token);
onSaved(updated);
onClose();
} catch (e: any) {
setError(e.message || 'Save failed');
} finally { setSaving(false); }
};
return ReactDOM.createPortal(
<div
className="fixed inset-0 z-[150] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onClick={onClose}
>
<div
className="w-full max-w-lg max-h-[88vh] overflow-y-auto rounded-2xl bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 shadow-2xl"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-200 dark:border-white/10">
<h2 className="text-base font-bold text-zinc-900 dark:text-white">{t('metadata.editTitle', 'Edit Metadata')}</h2>
<button onClick={onClose} className="p-1 rounded-lg text-zinc-500 hover:text-zinc-800 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/10">
<X size={18} />
</button>
</div>
<div className="p-5 space-y-4">
{/* Cover */}
<div className="flex items-center gap-4">
<div className="relative w-24 h-24 rounded-xl overflow-hidden bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center flex-shrink-0">
{coverUrl
? <img src={coverUrl} alt="" className="w-full h-full object-cover" />
: <ImageIcon size={28} className="text-zinc-400" />}
{uploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
<Loader2 size={20} className="animate-spin text-white" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) handleCoverFile(f); e.currentTarget.value = ''; }}
/>
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-cyan-500/15 text-cyan-400 hover:bg-cyan-500/25 transition-colors disabled:opacity-50"
>
{t('metadata.replaceCover', 'Replace cover image')}
</button>
<p className="text-[11px] text-zinc-500 mt-1.5 leading-relaxed">
{t('metadata.coverHint', 'PNG/JPG. Embedded into MP3/FLAC downloads.')}
</p>
</div>
</div>
<Field label={t('metadata.title', 'Title')} value={title} onChange={setTitle} />
<Field label={t('metadata.artist', 'Artist')} value={artist} onChange={setArtist} />
<div className="grid grid-cols-2 gap-3">
<Field label={t('metadata.album', 'Album')} value={album} onChange={setAlbum} />
<Field label={t('metadata.year', 'Year')} value={year} onChange={setYear} />
</div>
<Field label={t('metadata.genre', 'Genre / Style')} value={genre} onChange={setGenre} />
<div className="grid grid-cols-2 gap-3">
<Field label={t('metadata.bpm', 'BPM')} value={bpm} onChange={setBpm} type="number" />
<Field label={t('metadata.key', 'Key')} value={key} onChange={setKey} />
</div>
<Field label={t('metadata.comment', 'Comment')} value={comment} onChange={setComment} />
<div>
<label className="block text-xs font-medium text-zinc-500 mb-1">{t('metadata.lyrics', 'Lyrics')}</label>
<textarea
value={lyrics}
onChange={e => setLyrics(e.target.value)}
rows={5}
className="w-full text-sm rounded-lg bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-white/10 px-3 py-2 text-zinc-800 dark:text-zinc-200 outline-none focus:border-cyan-500 resize-y"
/>
</div>
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-zinc-200 dark:border-white/10">
<button onClick={onClose} className="px-4 py-2 rounded-lg text-sm text-zinc-600 dark:text-zinc-300 hover:bg-black/5 dark:hover:bg-white/5 transition-colors">
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={handleSave}
disabled={saving || uploading}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold bg-cyan-500 hover:bg-cyan-400 text-white transition-colors disabled:opacity-50"
>
{saving ? <Loader2 size={15} className="animate-spin" /> : <Save size={15} />}
{t('common.save', 'Save')}
</button>
</div>
</div>
</div>,
document.body,
);
};
+440
View File
@@ -0,0 +1,440 @@
// RightSidebar.tsx — Selected song details panel
// Ported from hot-step-9000's RightSidebar, simplified for current feature set.
import React from 'react';
import { X, Play, Pause, RotateCcw, Trash2, Music, Clock, Hash, Gauge, Download, Upload, Cpu, Terminal, Settings2, Zap, Radio, Activity, Layers, Sparkles, SlidersHorizontal, Pencil, Disc3, Tags, Image as ImageIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { Song } from '../../types';
import { HoverFullText } from '../shared/HoverFullText';
import { openCoverArtPrompt } from '../library/CoverArtPromptModal';
import { formatDitModel, formatLmModel } from '../global-bar/modelLabels';
interface RightSidebarProps {
song: Song;
onClose: () => void;
onReuse: (song: Song) => void;
onDelete: (song: Song) => void;
onPlay: (song: Song) => void;
isPlaying: boolean;
onDownload?: (song: Song) => void;
onRename?: (song: Song, newTitle: string) => void;
onSendToCover?: (song: Song) => void;
onEditMetadata?: (song: Song) => void;
}
export const RightSidebar: React.FC<RightSidebarProps> = ({
song,
onClose,
onReuse,
onDelete,
onPlay,
isPlaying,
onDownload,
onRename,
onSendToCover,
onEditMetadata,
}) => {
const { t } = useTranslation();
const gp = song.generationParams;
// Inline rename state
const [editing, setEditing] = React.useState(false);
const [editTitle, setEditTitle] = React.useState(song.title || '');
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
// Reset edit state when song changes
React.useEffect(() => {
setEditing(false);
setEditTitle(song.title || '');
}, [song.id]);
const commitRename = () => {
const trimmed = editTitle.trim();
if (trimmed && trimmed !== (song.title || '')) {
onRename?.(song, trimmed);
}
setEditing(false);
};
return (
<div className="h-full flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-200 dark:border-white/5">
<h3 className="text-sm font-semibold text-zinc-700 dark:text-zinc-300 truncate">{t('details.songDetails')}</h3>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-zinc-600 dark:text-zinc-400 hover:text-white hover:bg-white/5 transition-colors"
>
<X size={16} />
</button>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto hide-scrollbar p-4 space-y-4">
{/* Cover Art Placeholder */}
<div className="aspect-square w-full rounded-xl bg-gradient-to-br from-pink-500/20 to-purple-600/20 border border-zinc-200 dark:border-white/5 flex items-center justify-center">
{song.coverUrl ? (
<img src={song.coverUrl} alt={song.title} className="w-full h-full object-cover rounded-xl" />
) : (
<Music size={48} className="text-zinc-600" />
)}
</div>
{/* Title & Style */}
<div>
{editing ? (
<input
ref={inputRef}
className="w-full text-lg font-bold bg-zinc-800 border border-pink-500/40 rounded-lg px-2 py-0.5 text-white outline-none focus:border-pink-500"
value={editTitle}
onChange={e => setEditTitle(e.target.value)}
onBlur={commitRename}
onKeyDown={e => {
if (e.key === 'Enter') commitRename();
if (e.key === 'Escape') { setEditTitle(song.title || ''); setEditing(false); }
}}
/>
) : (
<div className="flex items-center gap-1.5 group/title">
<h2 className="text-lg font-bold text-white leading-tight truncate">{song.title || 'Untitled'}</h2>
{onRename && (
<button
onClick={() => { setEditTitle(song.title || ''); setEditing(true); }}
className="flex-shrink-0 p-1 rounded-lg text-zinc-600 hover:text-zinc-300 opacity-0 group-hover/title:opacity-100 transition-opacity"
title={t('library.rename')}
>
<Pencil size={13} />
</button>
)}
</div>
)}
{song.style && (
<HoverFullText
as="p"
text={song.style}
className="mt-1 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 cursor-help"
/>
)}
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
<button
onClick={() => onPlay(song)}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl bg-pink-600 hover:bg-pink-500 text-white font-semibold transition-colors"
>
{isPlaying ? <Pause size={16} /> : <Play size={16} />}
{isPlaying ? t('details.pause') : t('details.play')}
</button>
<button
onClick={() => onReuse(song)}
className="p-2.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300 transition-colors"
title={t('details.edit')}
>
<RotateCcw size={16} />
</button>
<button
onClick={() => onDelete(song)}
className="p-2.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 hover:bg-red-100 dark:hover:bg-red-900/50 text-zinc-700 dark:text-zinc-300 hover:text-red-400 transition-colors"
title={t('details.delete')}
>
<Trash2 size={16} />
</button>
{onDownload && (
<button
onClick={() => onDownload(song)}
className="p-2.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 hover:bg-emerald-100 dark:hover:bg-emerald-900/50 text-zinc-700 dark:text-zinc-300 hover:text-emerald-400 transition-colors"
title={t('details.download')}
>
<Download size={16} />
</button>
)}
<button
onClick={() => openCoverArtPrompt(song)}
className="p-2.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 hover:bg-pink-100 dark:hover:bg-pink-900/50 text-zinc-700 dark:text-zinc-300 hover:text-pink-400 transition-colors"
title={(song.coverUrl || song.cover_url)
? t('coverArt.regenerateTitle', 'Regenerate Cover Art')
: t('coverArt.generateTitle', 'Generate Cover Art')}
>
<ImageIcon size={16} />
</button>
{onSendToCover && (
<button
onClick={() => onSendToCover(song)}
className="p-2.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 hover:bg-cyan-100 dark:hover:bg-cyan-900/50 text-zinc-700 dark:text-zinc-300 hover:text-cyan-400 transition-colors"
title={t('library.sendToCover', 'Send to Cover Studio')}
>
<Disc3 size={16} />
</button>
)}
{onEditMetadata && (
<button
onClick={() => onEditMetadata(song)}
className="p-2.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 hover:bg-amber-100 dark:hover:bg-amber-900/50 text-zinc-700 dark:text-zinc-300 hover:text-amber-400 transition-colors"
title={t('metadata.editTitle', 'Edit Metadata')}
>
<Tags size={16} />
</button>
)}
{gp && (
<button
onClick={() => {
const params = song.generationParams || song.generation_params || {};
const exportData = { _format: 'hot-step-preset', _version: 1, ...params, title: song.title || '', caption: (params as any).caption || song.style || '', lyrics: (params as any).lyrics || song.lyrics || '' };
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${(song.title || 'song').slice(0, 40).replace(/[^a-zA-Z0-9]/g, '_').toLowerCase()}_params.json`;
a.click();
URL.revokeObjectURL(url);
}}
className="p-2.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 hover:bg-sky-100 dark:hover:bg-sky-900/50 text-zinc-700 dark:text-zinc-300 hover:text-sky-400 transition-colors"
title={t('details.exportParams')}
>
<Upload size={16} />
</button>
)}
</div>
{/* Metadata Badges */}
<div className="grid grid-cols-2 gap-2">
{song.duration && (
<MetaBadge icon={<Clock size={14} />} label={t('details.duration')} value={String(song.duration)} gradient="from-amber-500/10 to-orange-500/10 border-amber-200 dark:border-amber-500/30" iconColor="text-amber-600 dark:text-amber-400" />
)}
{(song.bpm || gp?.bpm) ? (
<MetaBadge icon={<Gauge size={14} />} label={t('details.bpm')} value={String(song.bpm || gp?.bpm)} gradient="from-rose-500/10 to-pink-500/10 border-rose-200 dark:border-rose-500/30" iconColor="text-rose-600 dark:text-rose-400" />
) : null}
{gp?.keyScale && (
<MetaBadge icon={<Hash size={14} />} label={t('details.key')} value={gp.keyScale} gradient="from-emerald-500/10 to-teal-500/10 border-emerald-200 dark:border-emerald-500/30" iconColor="text-emerald-600 dark:text-emerald-400" />
)}
{gp?.timeSignature && (
<MetaBadge icon={<Music size={14} />} label={t('details.timeSig')} value={gp.timeSignature} gradient="from-violet-500/10 to-purple-500/10 border-violet-200 dark:border-violet-500/30" iconColor="text-violet-600 dark:text-violet-400" />
)}
</div>
{/* Generation Parameters Grid — HOT-Step 9000 style */}
{gp && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-lg bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-indigo-200 dark:border-indigo-500/30 flex items-center justify-center">
<SlidersHorizontal size={12} className="text-indigo-600 dark:text-indigo-400" />
</div>
<h4 className="text-xs font-bold text-zinc-800 dark:text-zinc-200 uppercase tracking-wider">{t('details.generationInfo')}</h4>
</div>
<div className="grid grid-cols-2 gap-2">
{/* Models — blue accent */}
{gp.ditModel && (
<ParamCell
label="DiT Model"
value={formatDitModel(gp.ditModel)}
title={gp.ditModel}
gradient="from-blue-500/10 to-cyan-500/10 border-blue-200 dark:border-blue-500/30"
iconColor="text-blue-600 dark:text-blue-400"
icon={<Cpu size={12} />}
/>
)}
{gp.lmModel && (
<ParamCell
label="LM Model"
value={formatLmModel(gp.lmModel)}
title={gp.lmModel}
gradient="from-blue-500/10 to-cyan-500/10 border-blue-200 dark:border-blue-500/30"
iconColor="text-blue-600 dark:text-blue-400"
icon={<Terminal size={12} />}
/>
)}
{/* Engine — tech accent */}
{gp.inferenceSteps && (
<ParamCell
label="Steps"
value={String(gp.inferenceSteps)}
gradient="from-slate-500/10 to-zinc-500/10 border-slate-200 dark:border-slate-500/30"
iconColor="text-slate-600 dark:text-slate-400"
icon={<Gauge size={12} />}
/>
)}
{gp.guidanceScale !== undefined && (
<ParamCell
label="CFG Scale"
value={String(gp.guidanceScale)}
gradient="from-slate-500/10 to-zinc-500/10 border-slate-200 dark:border-slate-500/30"
iconColor="text-slate-600 dark:text-slate-400"
icon={<Settings2 size={12} />}
/>
)}
{/* Solver + Scheduler — violet accent */}
{gp.inferMethod && (
<ParamCell
label="Solver"
value={gp.inferMethod.toUpperCase()}
gradient="from-violet-500/10 to-purple-500/10 border-violet-200 dark:border-violet-500/30"
iconColor="text-violet-600 dark:text-violet-400"
icon={<Zap size={12} />}
/>
)}
{gp.scheduler && gp.scheduler !== 'linear' && (
<ParamCell
label="Schedule"
value={gp.scheduler.split(':')[0].replace(/_/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase())}
gradient="from-violet-500/10 to-purple-500/10 border-violet-200 dark:border-violet-500/30"
iconColor="text-violet-600 dark:text-violet-400"
icon={<Clock size={12} />}
/>
)}
{/* Guidance — emerald accent */}
{gp.guidanceMode && (
<ParamCell
label="Guidance"
value={gp.guidanceMode.toUpperCase()}
gradient="from-emerald-500/10 to-teal-500/10 border-emerald-200 dark:border-emerald-500/30"
iconColor="text-emerald-600 dark:text-emerald-400"
icon={<Radio size={12} />}
/>
)}
{/* Shift */}
{gp.shift !== undefined && (
<ParamCell
label="Shift"
value={gp.shift < 0 ? 'Auto' : String(gp.shift)}
gradient="from-amber-500/10 to-orange-500/10 border-amber-200 dark:border-amber-500/30"
iconColor="text-amber-600 dark:text-amber-400"
icon={<Activity size={12} />}
/>
)}
{/* Seed — mono */}
{gp.seed !== undefined && (
<ParamCell
label="Seed"
value={String(gp.seed).substring(0, 12) + (String(gp.seed).length > 12 ? '…' : '')}
gradient="from-slate-500/10 to-zinc-500/10 border-slate-200 dark:border-slate-500/30"
iconColor="text-slate-600 dark:text-slate-400"
icon={<Hash size={12} />}
mono
/>
)}
{/* LM Seed — mono */}
{gp.lmSeed !== undefined && (
<ParamCell
label="LM Seed"
value={String(gp.lmSeed).substring(0, 12) + (String(gp.lmSeed).length > 12 ? '…' : '')}
gradient="from-slate-500/10 to-zinc-500/10 border-slate-200 dark:border-slate-500/30"
iconColor="text-slate-600 dark:text-slate-400"
icon={<Hash size={12} />}
mono
/>
)}
{/* Batch Size */}
{gp.batchSize && gp.batchSize > 1 && (
<ParamCell
label="Batch"
value={String(gp.batchSize)}
gradient="from-slate-500/10 to-zinc-500/10 border-slate-200 dark:border-slate-500/30"
iconColor="text-slate-600 dark:text-slate-400"
icon={<Layers size={12} />}
/>
)}
{/* Adapter — pink accent */}
{(gp.adapter || gp.loraPath) && (
<ParamCell
label="Adapter"
value={getModelShortName(gp.adapter || gp.loraPath || '')}
title={gp.adapter || gp.loraPath || ''}
gradient="from-pink-500/10 to-rose-500/10 border-pink-200 dark:border-pink-500/30"
iconColor="text-pink-600 dark:text-pink-400"
icon={<Sparkles size={12} />}
span2
/>
)}
{gp.loraScale !== undefined && gp.loraScale !== 1 && (gp.adapter || gp.loraPath) && (
<ParamCell
label="Adapter Scale"
value={String(gp.loraScale)}
gradient="from-pink-500/10 to-rose-500/10 border-pink-200 dark:border-pink-500/30"
iconColor="text-pink-600 dark:text-pink-400"
icon={<SlidersHorizontal size={12} />}
/>
)}
{/* Thinking */}
{gp.useCotCaption !== undefined && (
<ParamCell
label="Thinking"
value={gp.useCotCaption ? 'ON' : 'OFF'}
gradient={gp.useCotCaption
? "from-emerald-500/10 to-green-500/10 border-emerald-200 dark:border-emerald-500/30"
: "from-slate-500/10 to-zinc-500/10 border-slate-200 dark:border-slate-500/30"}
iconColor={gp.useCotCaption ? "text-emerald-600 dark:text-emerald-400" : "text-slate-600 dark:text-slate-400"}
icon={<Zap size={12} />}
/>
)}
</div>
</div>
)}
{/* Lyrics */}
{song.lyrics && (
<div className="space-y-2">
<h4 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">{t('details.lyrics')}</h4>
<pre className="text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-wrap font-sans leading-relaxed bg-zinc-50/80 dark:bg-zinc-900/50 rounded-xl p-3 border border-zinc-200 dark:border-white/5 max-h-64 overflow-y-auto">
{song.lyrics}
</pre>
</div>
)}
</div>
</div>
);
};
/** Extract basename from a full model path */
const getModelShortName = (modelId: string): string => {
const base = modelId.split(/[\\/]/).filter(Boolean).pop() || modelId;
return base.replace(/^acestep-/, '');
};
/** Color-coded metadata badge (top section) */
const MetaBadge: React.FC<{ icon: React.ReactNode; label: string; value: string; gradient: string; iconColor: string }> = ({ icon, label, value, gradient, iconColor }) => (
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg bg-gradient-to-r ${gradient} border`}>
<div className={iconColor}>{icon}</div>
<div className="min-w-0">
<div className="text-[10px] text-zinc-500 uppercase tracking-wider">{label}</div>
<div className="text-sm text-zinc-800 dark:text-zinc-200 font-medium truncate">{value}</div>
</div>
</div>
);
/** Color-coded generation parameter cell (2-column grid) */
const ParamCell: React.FC<{
label: string;
value: string;
gradient: string;
iconColor: string;
icon: React.ReactNode;
mono?: boolean;
span2?: boolean;
title?: string; // full text shown on hover (the cell value is truncated)
}> = ({ label, value, gradient, iconColor, icon, mono, span2, title }) => (
<div className={`flex items-center gap-2 px-2.5 py-2 rounded-lg bg-gradient-to-r ${gradient} border ${span2 ? 'col-span-2' : ''}`}>
<div className={`${iconColor} flex-shrink-0`}>{icon}</div>
<div className="min-w-0 flex-1">
<div className="text-[9px] text-zinc-500 uppercase tracking-wider leading-none mb-0.5">{label}</div>
<div className={`text-xs text-zinc-800 dark:text-zinc-200 font-semibold truncate ${mono ? 'font-mono' : ''}`} title={title ?? value}>{value}</div>
</div>
</div>
);
@@ -0,0 +1,766 @@
// AdaptersDropdown.tsx — Adapter configuration UI for the global param bar
//
// Adapted from create/AdaptersAccordion.tsx to read from GlobalParamsContext.
// Self-contained with Simple and Advanced modes, file browser, group scales.
import React, { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { FolderOpen, X, Tag, Search, Circle, ChevronDown, RotateCcw } from 'lucide-react';
import { useGlobalParams } from '../../context/GlobalParamsContext';
import { usePersistedState } from '../../hooks/usePersistedState';
import { adapterApi, modelApi } from '../../services/api';
import { FileBrowserModal } from '../shared/FileBrowserModal';
import { Slider } from '../shared/Slider';
import { ModelSelect, getModelFormat } from './ModelSelect';
import { formatDitModel } from './modelLabels';
import { DEFAULT_SETTINGS, type AppSettings } from '../settings/SettingsPanel';
import type { AdapterFile } from '../../types';
// Select styling applied inline where needed
const GROUP_INFO = [
{ key: 'self_attn' as const, label: 'Self-Attn', help: 'How audio frames relate to each other over time' },
{ key: 'cross_attn' as const, label: 'Cross-Attn', help: 'How strongly your text prompt shapes the output' },
{ key: 'mlp' as const, label: 'MLP', help: 'Timbre, tonal texture, and sonic character' },
{ key: 'cond_embed' as const, label: 'Conditioning', help: 'How the adapter reshapes text/style interpretation' },
{ key: 'time_embed' as const, label: 'Timestep', help: 'How the adapter modifies noise-schedule understanding (0 = skip)' },
{ key: 'proj_in' as const, label: 'Proj-In', help: 'Input patchification layer — how latent tokens enter the model (0 = skip)' },
];
function deriveTriggerWord(adapterPath: string): string {
if (!adapterPath) return '';
const filename = adapterPath.split(/[\\/]/).pop() || '';
return filename.replace(/\.safetensors$/i, '');
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export const AdaptersDropdown: React.FC = () => {
const gp = useGlobalParams();
const { t } = useTranslation();
const [settings] = usePersistedState<AppSettings>('ace-settings', DEFAULT_SETTINGS);
// Internal state
const [adapterFiles, setAdapterFiles] = useState<AdapterFile[]>([]);
const [showGroupScales, setShowGroupScales] = usePersistedState('hs-adapterAccordion-groupScales', false);
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
const [scanning, setScanning] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
const [ditModels, setDitModels] = useState<string[]>([]);
// DiT model list for the basin re-base "home base" selector.
useEffect(() => {
modelApi.list().then(m => setDitModels(m?.models?.dit || [])).catch(() => {});
}, []);
// Planner-LM adapters (runtime LoRA on the 5Hz LM). Filesystem-scanned via
// the Node route so freshly trained adapters appear WITHOUT an engine
// restart — selection sends the absolute path, which the engine's
// path-fallback resolver loads directly.
const [lmAdapters, setLmAdapters] = useState<{ name: string; path: string; kind: string; size: number; lmSize?: string; run?: string }[]>([]);
const refreshLmAdapters = useCallback(() => {
adapterApi.lmList(gp.lmAdapterFolder || undefined).then(r => setLmAdapters(r?.adapters || [])).catch(() => {});
}, [gp.lmAdapterFolder]);
useEffect(() => { refreshLmAdapters(); }, [refreshLmAdapters]);
const fileBrowserMode = gp.advancedAdapters ? 'folder' as const : 'file' as const;
// In advanced mode the stack drives everything; in simple mode the single adapter does.
const stack: { path: string; scale: number; stepStart?: number; stepEnd?: number }[] = gp.adapterStack || [];
// Any stack entry with a timestep window forces runtime mode server-side —
// several knobs below change visibility/meaning when this is true.
const stackHasWindows = stack.some(e => e.stepStart !== undefined || e.stepEnd !== undefined);
// Timestep window helpers. Store fields stepStart/stepEnd are flow-matching t
// (1 = noise, 0 = clean); the UI shows "% of denoising" (0% = first step),
// so display = (1 t) flipped: startPct derives from stepEnd and vice versa.
const winStartPct = (e: { stepEnd?: number }) => Math.round((1 - (e.stepEnd ?? 1)) * 100);
const winEndPct = (e: { stepStart?: number }) => Math.round((1 - (e.stepStart ?? 0)) * 100);
const setWindowPct = (path: string, sPct: number, ePct: number) => {
const lo = Math.max(0, Math.min(100, Math.min(sPct, ePct)));
const hi = Math.max(0, Math.min(100, Math.max(sPct, ePct)));
gp.setAdapterStackWindow(path, 1 - hi / 100, 1 - lo / 100);
};
const primaryPath = gp.advancedAdapters ? (stack[0]?.path || '') : gp.adapter;
const hasAdapter = gp.advancedAdapters ? stack.length > 0 : !!gp.adapter;
const triggerWord = deriveTriggerWord(primaryPath);
// Every stacked adapter contributes its trigger word (matches what is injected
// into the caption server-side).
const stackTriggerWords = stack.map(e => deriveTriggerWord(e.path)).filter(Boolean).join(', ');
const adapterFilename = gp.adapter ? gp.adapter.split(/[\\/]/).pop() || '' : '';
const fileLabel = (p: string) => p.split(/[\\/]/).pop() || p;
// Blend mode: per-adapter sliders are relative weights, normalised so the
// effective scales sum to the combined-strength budget. effectiveScale maps a
// row's weight to the scale actually sent to the engine (mirrors the store).
const isBlend = gp.adapterStackMode === 'blend';
// Sum/Blend distinction only matters with 2+ adapters; a single adapter just
// has a "Strength".
const multiStack = stack.length >= 2;
const stackWeightSum = stack.reduce((acc, e) => acc + (e.scale || 0), 0);
const effectiveScale = (weight: number) => {
if (!isBlend || !multiStack) return weight;
const budget = gp.adapterStackBudget ?? 0.75;
return stackWeightSum > 0 ? (budget * (weight || 0)) / stackWeightSum : budget / Math.max(1, stack.length);
};
const GROUP_DEFAULTS: Record<string, number> = { self_attn: 1.0, cross_attn: 1.0, mlp: 1.0, cond_embed: 1.0, time_embed: 0.0, proj_in: 0.0 };
const allDefault = GROUP_INFO.every(g => gp.adapterGroupScales[g.key] === (GROUP_DEFAULTS[g.key] ?? 1.0));
const handleGroupScaleChange = (key: keyof typeof gp.adapterGroupScales, value: number) => {
gp.setAdapterGroupScales({ ...gp.adapterGroupScales, [key]: value });
};
const handleScan = useCallback(async (folder?: string) => {
const dir = folder || gp.adapterFolder;
if (!dir) return;
setScanning(true);
setScanError(null);
try {
const result = await adapterApi.scan(dir);
setAdapterFiles(result.files);
if (result.files.length === 0) {
setScanError('No .safetensors files found in this folder');
}
} catch (err: any) {
setScanError(err?.message || 'Failed to scan folder');
} finally {
setScanning(false);
}
}, [gp.adapterFolder]);
const handleBrowseSelect = (path: string) => {
setFileBrowserOpen(false);
if (gp.advancedAdapters) {
gp.setAdapterFolder(path);
handleScan(path);
} else {
gp.setAdapter(path);
}
};
return (
<div className="space-y-3">
{/* Simple / Advanced toggle */}
<div className="flex rounded-xl overflow-hidden border border-zinc-300 dark:border-white/10">
<button
onClick={() => gp.setAdvancedAdapters(false)}
className={`flex-1 px-3 py-1.5 text-xs font-medium transition-colors ${
!gp.advancedAdapters ? 'bg-zinc-200 dark:bg-zinc-700 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
{t('common.simple')}
</button>
<button
onClick={() => gp.setAdvancedAdapters(true)}
className={`flex-1 px-3 py-1.5 text-xs font-medium transition-colors ${
gp.advancedAdapters ? 'bg-zinc-200 dark:bg-zinc-700 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
{t('common.advanced')}
</button>
</div>
{/* ═══ SIMPLE MODE ═══ */}
{!gp.advancedAdapters && (
<>
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">{t('adapter.adapterPath')}</label>
<div className="flex gap-2">
<input
type="text"
value={gp.adapter}
onChange={(e) => gp.setAdapter(e.target.value)}
placeholder="Path to .safetensors file..."
className="flex-1 px-3 py-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 placeholder-zinc-400 dark:placeholder-zinc-600 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none transition-colors font-mono text-xs"
/>
<button
onClick={() => setFileBrowserOpen(true)}
className="px-3 py-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:hover:text-zinc-200 hover:bg-zinc-300 dark:hover:bg-zinc-700 transition-colors"
title={t('adapter.browseFile')}
>
<FolderOpen size={14} />
</button>
</div>
</div>
{gp.adapter && (
<div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<Circle size={8} fill="#10b981" className="text-emerald-500 flex-shrink-0" />
<span className="text-xs text-emerald-400 font-medium truncate flex-1" title={gp.adapter}>
{adapterFilename}
</span>
<button
onClick={() => gp.setAdapter('')}
className="text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors flex-shrink-0"
title={t('adapter.clearAdapter')}
>
<X size={12} />
</button>
</div>
)}
{gp.adapter && settings.triggerUseFilename && triggerWord && (
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-pink-500/10 border border-pink-500/20">
<Tag size={10} className="text-pink-400 flex-shrink-0" />
<span className="text-[10px] text-pink-400 font-medium">{triggerWord}</span>
<span className="text-[10px] text-zinc-500">({settings.triggerPlacement})</span>
</div>
)}
</>
)}
{/* ═══ ADVANCED MODE ═══ */}
{gp.advancedAdapters && (
<>
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">{t('adapter.adapterFolder')}</label>
<div className="flex gap-2">
<input
type="text"
value={gp.adapterFolder}
onChange={(e) => gp.setAdapterFolder(e.target.value)}
placeholder="Path to folder with adapters..."
className="flex-1 px-3 py-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 placeholder-zinc-400 dark:placeholder-zinc-600 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none transition-colors font-mono text-xs"
/>
<button
onClick={() => handleScan()}
disabled={!gp.adapterFolder || scanning}
className="px-3 py-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:hover:text-zinc-200 hover:bg-zinc-300 dark:hover:bg-zinc-700 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
title={t('adapter.scanFolder')}
>
<Search size={14} className={scanning ? 'animate-spin' : ''} />
</button>
<button
onClick={() => setFileBrowserOpen(true)}
className="px-3 py-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:hover:text-zinc-200 hover:bg-zinc-300 dark:hover:bg-zinc-700 transition-colors"
title={t('adapter.browseFolder')}
>
<FolderOpen size={14} />
</button>
</div>
</div>
{scanError && (
<div className="text-xs text-amber-400/70 px-1">{scanError}</div>
)}
{adapterFiles.length > 0 && (
<div className="rounded-xl bg-zinc-50/80 dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/5 overflow-hidden" style={{ maxHeight: '200px', overflowY: 'auto' }}>
{adapterFiles.map((file) => {
const isActive = stack.some(a => a.path === file.path);
return (
<button
key={file.path}
onClick={() => gp.toggleAdapterInStack(file.path, 1.0)}
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-colors ${
isActive ? 'bg-emerald-500/10 border-l-2 border-emerald-500' : 'hover:bg-white/5 border-l-2 border-transparent'
}`}
>
{isActive ? (
<Circle size={8} fill="#10b981" className="text-emerald-500 flex-shrink-0" />
) : (
<Circle size={8} className="text-zinc-600 flex-shrink-0" />
)}
<span className={`text-xs truncate flex-1 ${isActive ? 'text-emerald-400 font-medium' : 'text-zinc-600 dark:text-zinc-400'}`}>
{file.name}
</span>
<span className="text-zinc-600 flex-shrink-0" style={{ fontSize: '10px' }}>
{formatSize(file.size)}
</span>
</button>
);
})}
</div>
)}
{/* Selected stack: one row per adapter with its own scale + remove. */}
{stack.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-zinc-600 dark:text-zinc-400 uppercase tracking-wider">
{t('adapter.adapterStack', 'Adapter Stack')} ({stack.length})
</span>
<button type="button" onClick={() => gp.setAdapterStack([])}
className="text-[10px] text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors">
{t('adapter.clearAll', 'Clear all')}
</button>
</div>
{/* Sum / Blend toggle — only meaningful with 2+ adapters */}
{multiStack && (
<>
<div className="flex rounded-xl overflow-hidden border border-zinc-300 dark:border-white/10">
<button
type="button"
onClick={() => gp.setAdapterStackMode('blend')}
className={`flex-1 px-2.5 py-1.5 text-xs font-medium transition-colors ${
isBlend ? 'bg-emerald-600 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
Blend
</button>
<button
type="button"
onClick={() => gp.setAdapterStackMode('sum')}
className={`flex-1 px-2.5 py-1.5 text-xs font-medium transition-colors ${
!isBlend ? 'bg-amber-600 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
Sum
</button>
</div>
<p className="text-[10px] text-zinc-600 -mt-1">
{isBlend
? 'Per-adapter sliders are relative weights; effective scales are normalised so they sum to the combined strength below. Keeps total strength constant as you add adapters.'
: 'Per-adapter sliders are absolute scales, summed directly. Σ can exceed 1 to deliberately over-drive the stack.'}
</p>
{/* Combined strength budget (blend mode only) */}
{isBlend && (
<Slider label={t('adapter.combinedStrength', 'Combined Strength (Σ)')} value={gp.adapterStackBudget}
onChange={gp.setAdapterStackBudget} min={0} max={4} step={0.05} showInput />
)}
</>
)}
{stack.map((entry, i) => (
<div key={entry.path} className="px-3 py-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20 space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-[10px] text-zinc-500 flex-shrink-0">{i + 1}.</span>
<span className="text-xs text-emerald-400 font-medium truncate flex-1" title={entry.path}>
{fileLabel(entry.path)}
</span>
{multiStack && isBlend && (
<span className="text-[10px] text-zinc-500 font-mono flex-shrink-0" title="Effective scale sent to the engine">
{effectiveScale(entry.scale).toFixed(3)}
</span>
)}
<button
onClick={() => gp.toggleAdapterInStack(entry.path)}
className="text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors flex-shrink-0"
title={t('adapter.deselectAdapter')}
>
<X size={12} />
</button>
</div>
<Slider
label={!multiStack
? t('adapter.strength', 'Strength')
: isBlend ? t('adapter.weight', 'Weight') : t('adapter.adapterScale', 'Adapter Scale')}
value={entry.scale}
onChange={v => gp.setAdapterStackScale(entry.path, v)} min={0} max={4} step={0.05} showInput />
{/* Timestep window (interval experts): which slice of denoising this
adapter is active in. 0% = first step (structure), 100% = last
(texture/detail). Any non-full window forces runtime mode. */}
<div className="flex items-center gap-1.5"
title={t('adapter.timestepWindowHint',
'Active slice of the denoising process. Early steps shape structure/rhythm, late steps shape timbre/detail. Windows crossfade where adapters meet. Forces runtime mode.')}>
<span className="text-[10px] text-zinc-500 flex-shrink-0">
{t('adapter.timestepWindow', 'Active phase')}
</span>
<input type="number" min={0} max={100} step={5} value={winStartPct(entry)}
onChange={e => setWindowPct(entry.path, Number(e.target.value), winEndPct(entry))}
className="w-12 px-1 py-0.5 text-[10px] text-right rounded bg-white dark:bg-black/30 border border-zinc-200 dark:border-white/10 text-zinc-700 dark:text-zinc-300" />
<span className="text-[10px] text-zinc-500"></span>
<input type="number" min={0} max={100} step={5} value={winEndPct(entry)}
onChange={e => setWindowPct(entry.path, winStartPct(entry), Number(e.target.value))}
className="w-12 px-1 py-0.5 text-[10px] text-right rounded bg-white dark:bg-black/30 border border-zinc-200 dark:border-white/10 text-zinc-700 dark:text-zinc-300" />
<span className="text-[10px] text-zinc-500">%</span>
{(entry.stepStart !== undefined || entry.stepEnd !== undefined) && (
<button type="button"
onClick={() => gp.setAdapterStackWindow(entry.path, 0, 1)}
className="text-[10px] text-amber-400/80 hover:text-amber-300 flex-shrink-0"
title={t('adapter.timestepWindowReset', 'Reset to always active')}>
{t('adapter.timestepWindowClear', 'clear')}
</button>
)}
</div>
</div>
))}
{stackHasWindows && (
<div className="px-2.5 py-1.5 rounded-lg bg-amber-500/10 border border-amber-500/20">
<p className="text-[10px] text-amber-400/90 leading-relaxed m-0">
{t('adapter.timestepWindowVram',
'Timestep windows force Runtime mode: each adapter holds its own full-size deltas in VRAM. Set Adapter VRAM below to Q8 ½ or Q4 ¼ to keep this affordable.')}
</p>
</div>
)}
{settings.triggerUseFilename && stackTriggerWords && (
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-pink-500/10 border border-pink-500/20">
<Tag size={10} className="text-pink-400 flex-shrink-0" />
<span className="text-[10px] text-pink-400 font-medium">{stackTriggerWords}</span>
<span className="text-[10px] text-zinc-500">({settings.triggerPlacement})</span>
</div>
)}
{/* Per-section masking hint (2+ adapters) */}
{multiStack && (
<div className="px-2.5 py-2 rounded-lg bg-sky-500/5 border border-sky-500/15 space-y-1">
<div className="text-[10px] font-semibold text-sky-300/80 uppercase tracking-wider">Per-section influence</div>
<p className="text-[10px] text-zinc-500 leading-relaxed">
Vary each adapter by lyric section add a directive after a section header,
keyed by trigger word. Forces runtime mode.
</p>
<pre className="text-[9px] text-zinc-400 font-mono whitespace-pre-wrap leading-snug bg-black/20 rounded p-1.5 m-0">{`[Verse]{${stackTriggerWords.split(', ').map((w, i) => `${w}=${i === 0 ? '1' : '0'}`).join('; ')}}
[Chorus]{${stackTriggerWords.split(', ').map((w, i) => `${w}=${i === 0 ? '0' : '1'}`).join('; ')}}`}</pre>
<div className="pt-1 space-y-2">
<Slider label="Alignment Timing" value={gp.adapterSectionAlignAt}
onChange={gp.setAdapterSectionAlignAt} min={0.2} max={0.85} step={0.05} showInput />
<p className="text-[9px] text-zinc-500 leading-relaxed -mt-1">
When section boundaries snap to the model's real timing. Earlier locks section
identity sooner (less first-adapter bias); too early = fuzzier boundaries.
</p>
</div>
</div>
)}
</div>
)}
</>
)}
{/* ═══ PLANNER ADAPTER (LM) — song-structure LoRA on the 5Hz planner ═══ */}
<div className="space-y-2 pt-2 border-t border-zinc-200 dark:border-white/5">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-violet-400 uppercase tracking-wider">
Planner Adapter (LM)
</span>
<button type="button" onClick={refreshLmAdapters}
className="text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors"
title="Rescan adapters/lm for new planner adapters">
<RotateCcw size={12} />
</button>
</div>
<p className="text-[10px] text-zinc-500 leading-relaxed -mt-1">
Artist-trained song-structure adapter applied to the planner LM at runtime.
Pairs with the matching DiT adapter (timbre) — same trigger word.
</p>
<input
type="text"
value={gp.lmAdapterFolder}
onChange={(e) => gp.setLmAdapterFolder(e.target.value)}
placeholder="Scan folder (empty = adapters/lm)"
className="w-full px-3 py-1.5 rounded-lg bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-[11px] text-zinc-700 dark:text-zinc-300 placeholder-zinc-500 focus:border-violet-500/50 outline-none transition-colors"
/>
{lmAdapters.length === 0 ? (
<p className="text-[10px] text-zinc-600 px-1">
None found in adapters/lm — train one with Side-Step's lm-train.
</p>
) : (
<div className="rounded-xl bg-zinc-50/80 dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/5 overflow-hidden" style={{ maxHeight: '160px', overflowY: 'auto' }}>
{lmAdapters.map((a) => {
const isActive = gp.lmAdapter === a.path;
return (
<button
key={a.path}
onClick={() => { gp.setLmAdapter(isActive ? '' : a.path); }}
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-colors ${
isActive ? 'bg-violet-500/10 border-l-2 border-violet-500' : 'hover:bg-white/5 border-l-2 border-transparent'
}`}
>
{isActive ? (
<Circle size={8} fill="#8b5cf6" className="text-violet-500 flex-shrink-0" />
) : (
<Circle size={8} className="text-zinc-600 flex-shrink-0" />
)}
<span className={`text-xs truncate flex-1 ${isActive ? 'text-violet-400 font-medium' : 'text-zinc-600 dark:text-zinc-400'}`}>
{a.name}
</span>
{/* Per-base + per-run layout: names are unsuffixed, the
parent lm-* folder carries the size and each training run
sits in a stamped subfolder — show both so same-artist
entries stay distinguishable. */}
{a.run && (
<span className="text-zinc-500 flex-shrink-0 font-mono" style={{ fontSize: '9px' }} title={a.run}>
{a.run.slice(0, 10)}
</span>
)}
{a.lmSize && (
<span className="text-violet-400/70 flex-shrink-0 font-mono" style={{ fontSize: '10px' }}>
{a.lmSize}
</span>
)}
<span className="text-zinc-600 flex-shrink-0" style={{ fontSize: '10px' }}>
{formatSize(a.size)}
</span>
</button>
);
})}
</div>
)}
{gp.lmAdapter && (
<div className="px-3 py-2 rounded-xl bg-violet-500/10 border border-violet-500/20">
<div className="flex items-center gap-2">
<span className="text-xs text-violet-400 font-medium truncate flex-1" title={gp.lmAdapter}>
{lmAdapters.find(a => a.path === gp.lmAdapter)?.name || gp.lmAdapter}
</span>
<button
onClick={() => gp.setLmAdapter('')}
className="text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors flex-shrink-0"
title="Unload planner adapter"
>
<X size={12} />
</button>
</div>
</div>
)}
{/* Global planner strength — ALWAYS visible: like the DiT Adapter Scale,
it also governs planner adapters supplied by Album Presets. */}
<Slider label="Planner Strength" value={gp.lmAdapterScale}
onChange={gp.setLmAdapterScale} min={0} max={2} step={0.05} showInput />
<p className="text-[9px] text-zinc-500 leading-relaxed -mt-1">
Applies to the adapter above AND album-preset planner adapters.
1.0 = as trained; above ~1.4 risks repetitive planning prefer more
training epochs over slider overdrive.
</p>
</div>
{/* ═══ SHARED CONTROLS (when adapter selected) ═══ */}
{hasAdapter && (
<>
{/* Adapter Scale — simple mode only; the advanced stack has per-row scales */}
{!gp.advancedAdapters && (
<Slider label="Adapter Scale" value={gp.adapterScale}
onChange={gp.setAdapterScale} min={0} max={4} step={0.05} showInput />
)}
{/* Loading Mode */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">{t('adapter.loadingMode')}</label>
<div className="flex rounded-xl overflow-hidden border border-zinc-300 dark:border-white/10">
<button
type="button"
onClick={() => gp.setAdapterMode('merge')}
className={`flex-1 px-2.5 py-1.5 text-xs font-medium transition-colors ${
gp.adapterMode === 'merge' ? 'bg-amber-600 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
Merge
</button>
<button
type="button"
onClick={() => gp.setAdapterMode('runtime')}
className={`flex-1 px-2.5 py-1.5 text-xs font-medium transition-colors ${
gp.adapterMode === 'runtime' ? 'bg-pink-600 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
Runtime
</button>
<button
type="button"
onClick={() => gp.setAdapterMode('runtime_lowrank')}
className={`flex-1 px-2.5 py-1.5 text-xs font-medium transition-colors ${
gp.adapterMode === 'runtime_lowrank' ? 'bg-violet-600 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
Low-Rank 🪶
</button>
</div>
<p className="text-[10px] text-zinc-600 mt-1">
{gp.adapterMode === 'runtime_lowrank'
? 'Applies raw adapter factors per-step, never materializing full deltas — lowest VRAM (LoRA & LoKr; DoRA needs Merge). Basin re-base still works.'
: gp.adapterMode === 'runtime'
? 'Keeps base weights intact, applies adapter per-step. Same quality, slower inference, saves VRAM.'
: 'Merges adapter at F32 precision. Best quality, fast inference, but uses more VRAM during synthesis.'}
</p>
</div>
{/* Merge VRAM — merge mode only (storage precision of the merged weights) */}
{gp.adapterMode === 'merge' && (
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">
{t('adapter.mergeVram', 'Merge VRAM')}
</label>
<div className="flex rounded-xl overflow-hidden border border-zinc-300 dark:border-white/10">
{([
{ v: false, label: 'HQ', sub: 'Merged weights stored as F32 (best quality, ~4× VRAM on a Q8 base)' },
{ v: true, label: 'Low ¼', sub: 'Merged weights re-encoded to the base\'s native quant' },
] as const).map(opt => (
<button
key={String(opt.v)}
type="button"
onClick={() => gp.setAdapterMergeLowVram(opt.v)}
title={opt.sub}
className={`flex-1 px-2.5 py-1.5 text-xs font-medium transition-colors ${
gp.adapterMergeLowVram === opt.v
? 'bg-sky-600 text-white'
: 'bg-white dark:bg-zinc-900 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
{opt.label}
</button>
))}
</div>
<p className="text-[10px] text-zinc-600 mt-1">
HQ keeps merged weights at F32 (a Q8 base grows ~4× in VRAM). Low re-encodes them
back to the base&apos;s native quant base-model VRAM, one extra quantization
round-trip. FP4 bases always use the low path.
</p>
</div>
)}
{/* Adapter Quantization — runtime modes (quantizes the in-VRAM full-size
deltas; in Low-Rank mode that's the re-base correction + Conv1d fallbacks).
Also shown when timestep windows are set: windows force runtime mode
server-side, so this knob governs VRAM even from Merge/Low-Rank. */}
{(gp.adapterMode === 'runtime' || gp.adapterMode === 'runtime_lowrank' || stackHasWindows) && (
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">
{t('adapter.runtimeQuant', 'Adapter VRAM')}
</label>
<div className="flex rounded-xl overflow-hidden border border-zinc-300 dark:border-white/10">
{([
{ v: 'bf16', label: 'Full', sub: 'BF16' },
{ v: 'q8_0', label: 'Q8 ½', sub: 'Q8_0' },
{ v: 'q4_0', label: 'Q4 ¼', sub: 'Q4_0' },
] as const).map(opt => (
<button
key={opt.v}
type="button"
onClick={() => gp.setAdapterRuntimeQuant(opt.v)}
title={opt.sub}
className={`flex-1 px-2.5 py-1.5 text-xs font-medium transition-colors ${
gp.adapterRuntimeQuant === opt.v
? 'bg-sky-600 text-white'
: 'bg-white dark:bg-zinc-900 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
{opt.label}
</button>
))}
</div>
<p className="text-[10px] text-zinc-600 mt-1">
Quantizes the runtime adapter deltas in VRAM (nothing written to disk). Q8 halves /
Q4 quarters VRAM per adapter lets more stacked adapters fit. Small quality cost;
safe when the base model is already 4-bit (NVFP4).
</p>
</div>
)}
{/* Basin re-base (cross-base adapter support) — merge AND runtime modes
(runtime folds the nudge into the delta sum; per-section masking skips it).
Home base must be a SafeTensors model (nudge reads F32 weights), so
the selector is filtered to safetensors DiT models only. */}
<div className="rounded-xl bg-zinc-100/50 dark:bg-zinc-800/50 border border-zinc-200 dark:border-white/5 p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[10px] font-semibold text-zinc-600 dark:text-zinc-400 uppercase tracking-wider">Basin Re-base</span>
{gp.rebaseSource && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400" title="Re-base active" />}
</div>
{gp.rebaseSource && (
<button type="button" onClick={() => gp.setRebaseSource('')}
className="text-[10px] text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors">
Off
</button>
)}
</div>
<label className="block text-[10px] text-zinc-500">Adapter trained on (home base)</label>
<ModelSelect
id="rebase-source-select"
value={gp.rebaseSource}
onChange={gp.setRebaseSource}
options={ditModels.filter(m => getModelFormat(m) === 'safetensors')}
formatLabel={formatDitModel}
placeholder="Off — apply adapter as-is"
/>
{gp.rebaseSource && (
<>
<Slider label="Re-base Strength (β)" value={gp.rebaseBeta}
onChange={gp.setRebaseBeta} min={0} max={1} step={0.05} showInput />
<p className="text-[10px] text-zinc-600">
Nudges the loaded base toward the adapter's home base so a heavy cross-base adapter
stays coherent at full strength. β=1 = home-base behavior; lower keeps more of the
loaded base's character.
</p>
</>
)}
</div>
{/* Group Scales */}
<button
onClick={() => setShowGroupScales(!showGroupScales)}
className="flex items-center gap-2 text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors"
>
<ChevronDown size={12} className={`transition-transform duration-200 ${showGroupScales ? 'rotate-180' : ''}`} />
{t('adapter.groupScales')}
{!allDefault && (
<span className="w-1.5 h-1.5 rounded-full bg-pink-500" title="Group scales modified" />
)}
</button>
{showGroupScales && (
<div className="rounded-xl bg-zinc-100/50 dark:bg-zinc-800/50 border border-zinc-200 dark:border-white/5 p-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-zinc-600 dark:text-zinc-400 uppercase tracking-wider">{t('adapter.layerScales')}</span>
<button type="button" onClick={() => gp.setAdapterGroupScales({ self_attn: 1.0, cross_attn: 1.0, mlp: 1.0, cond_embed: 1.0, time_embed: 0.0, proj_in: 0.0 })}
className="flex items-center gap-1 text-[10px] text-zinc-600 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors">
<RotateCcw size={10} /> Reset
</button>
</div>
{GROUP_INFO.map(({ key, label, help }) => (
<div key={key}>
<Slider label={label} value={gp.adapterGroupScales[key]}
onChange={v => handleGroupScaleChange(key, v)} min={0} max={4} step={0.05} showInput />
<p className="text-[10px] text-zinc-600 mt-0.5 -mb-1">{help}</p>
</div>
))}
</div>
)}
</>
)}
{/* File Browser Modal */}
<FileBrowserModal
open={fileBrowserOpen}
onClose={() => setFileBrowserOpen(false)}
onSelect={handleBrowseSelect}
mode={fileBrowserMode}
startPath={gp.advancedAdapters ? gp.adapterFolder : undefined}
filter="adapters"
title={gp.advancedAdapters ? t('adapter.selectAdapterFolder') : t('adapter.selectAdapterFile')}
/>
</div>
);
};
/** Summary badge for the Adapters section */
export const AdaptersBadge: React.FC = () => {
const { adapter, adapterScale, adapterStack, advancedAdapters } = useGlobalParams();
const stack = adapterStack || [];
const useStack = advancedAdapters && stack.length > 0;
const shortName = (p: string) => p.split(/[\\/]/).pop()?.replace(/\.safetensors$/i, '') || '';
if (useStack) {
const first = shortName(stack[0].path);
return (
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
<span className="text-[10px] text-emerald-400 font-mono truncate max-w-[120px]" title={stack.map(a => a.path).join('\n')}>
{first}
</span>
{stack.length > 1 && <span className="text-[10px] text-zinc-600">+{stack.length - 1}</span>}
</div>
);
}
const filename = adapter ? shortName(adapter) : '';
return (
<div className="flex items-center gap-1.5">
{adapter ? (
<>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
<span className="text-[10px] text-emerald-400 font-mono">{filename}</span>
<span className="text-[10px] text-zinc-600">×{adapterScale.toFixed(2)}</span>
</>
) : (
<span className="text-[10px] text-zinc-600">None</span>
)}
</div>
);
};
+184
View File
@@ -0,0 +1,184 @@
// BarSection.tsx — Reusable hover-to-expand section for the global param bar
//
// Shows a compact header with label + summary badge.
// On hover (or click), expands a floating dropdown panel below.
// Each section has a unique accent tint that is always visible as its background.
// Optionally shows a toggle switch in the header (for LM / Mastering).
import React, { useRef, useCallback, useEffect } from 'react';
// ── Accent color lookup ────────────────────────────────────────────────────
// Tailwind JIT can't compile dynamic class names like `bg-${color}-500/10`,
// so we map accent names to concrete classes.
const ACCENT_STYLES: Record<string, {
bg: string; // resting background tint
bgHover: string; // hover/active background tint (stronger)
border: string; // active bottom border
iconColor: string; // icon color when active
}> = {
pink: { bg: 'bg-pink-500/5', bgHover: 'bg-pink-500/10', border: 'border-pink-500', iconColor: 'text-pink-400' },
emerald: { bg: 'bg-emerald-500/5', bgHover: 'bg-emerald-500/10', border: 'border-emerald-500', iconColor: 'text-emerald-400' },
sky: { bg: 'bg-sky-500/5', bgHover: 'bg-sky-500/10', border: 'border-sky-500', iconColor: 'text-sky-400' },
purple: { bg: 'bg-purple-500/5', bgHover: 'bg-purple-500/10', border: 'border-purple-500', iconColor: 'text-purple-400' },
amber: { bg: 'bg-amber-500/5', bgHover: 'bg-amber-500/10', border: 'border-amber-500', iconColor: 'text-amber-400' },
violet: { bg: 'bg-violet-500/5', bgHover: 'bg-violet-500/10', border: 'border-violet-500', iconColor: 'text-violet-400' },
};
interface BarSectionProps {
id: string;
label: string;
icon: React.ReactNode;
badge: React.ReactNode;
accentColor?: string;
children: React.ReactNode;
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
/** Optional toggle rendered in the header bar (e.g. LM on/off, Mastering on/off).
* The element handles its own onClick and should call e.stopPropagation(). */
headerToggle?: React.ReactNode;
}
const HOVER_CLOSE_DELAY = 400; // ms
export const BarSection: React.FC<BarSectionProps> = ({
id, label, icon, badge, accentColor = 'pink', children,
isOpen, onOpen, onClose, headerToggle,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const accent = ACCENT_STYLES[accentColor] || ACCENT_STYLES.pink;
const cancelClose = useCallback(() => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
}, []);
const scheduleClose = useCallback(() => {
cancelClose();
closeTimer.current = setTimeout(() => {
onClose();
}, HOVER_CLOSE_DELAY);
}, [onClose, cancelClose]);
const handleMouseEnter = useCallback(() => {
cancelClose();
onOpen();
}, [onOpen, cancelClose]);
const handleMouseLeave = useCallback(() => {
scheduleClose();
}, [scheduleClose]);
const handleClick = useCallback(() => {
if (isOpen) {
onClose();
} else {
onOpen();
}
}, [isOpen, onOpen, onClose]);
// Clean up timer on unmount
useEffect(() => {
return () => {
if (closeTimer.current) clearTimeout(closeTimer.current);
};
}, []);
return (
<div
ref={containerRef}
className="relative flex-1 min-w-0"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Header */}
<button
id={`global-bar-${id}`}
onClick={handleClick}
className={`
absolute inset-0 w-full px-3 pt-1 flex items-center gap-2 transition-all duration-150 cursor-pointer
border-b-2 ${isOpen ? `${accent.bgHover} ${accent.border}` : `${accent.bg} border-transparent hover:${accent.bgHover}`}
`}
>
<span className={`flex-shrink-0 transition-colors duration-150 ${isOpen ? accent.iconColor : 'text-zinc-500'}`}>
{icon}
</span>
<span className={`text-[11px] font-semibold uppercase tracking-wider flex-shrink-0 hidden xl:inline transition-colors duration-150 ${
isOpen ? 'text-zinc-800 dark:text-zinc-200' : 'text-zinc-600 dark:text-zinc-400'
}`}>
{label}
</span>
{/* Optional inline toggle */}
{headerToggle && (
<div className="flex-shrink-0" onClick={e => e.stopPropagation()}>
{headerToggle}
</div>
)}
<div className="flex-1 min-w-0 flex justify-end">
{badge}
</div>
</button>
{/* Dropdown — matches section width */}
{isOpen && (
<div
className="absolute top-full left-0 z-50 w-full min-w-[300px] max-h-[calc(100vh-120px)] overflow-y-auto
bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 border-t-0 rounded-b-xl shadow-2xl shadow-black/30 dark:shadow-black/60
global-bar-dropdown-enter hide-scrollbar"
>
<div className="p-4 space-y-3">
{children}
</div>
</div>
)}
</div>
);
};
// ── Inline Toggle Switch ─────────────────────────────────────────────────────
interface ToggleSwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
accentColor?: 'pink' | 'emerald' | 'sky' | 'purple' | 'amber' | 'teal';
}
const TOGGLE_COLORS: Record<string, string> = {
pink: 'bg-pink-500',
emerald: 'bg-emerald-500',
sky: 'bg-sky-500',
purple: 'bg-purple-500',
amber: 'bg-amber-500',
teal: 'bg-teal-500',
};
export const ToggleSwitch: React.FC<ToggleSwitchProps> = ({ checked, onChange, accentColor = 'pink' }) => {
const activeColor = TOGGLE_COLORS[accentColor] || TOGGLE_COLORS.pink;
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={(e) => {
e.stopPropagation();
onChange(!checked);
}}
className={`
relative inline-flex h-4 w-8 items-center rounded-full transition-colors duration-200 flex-shrink-0
${checked ? activeColor : 'bg-zinc-200 dark:bg-zinc-700'}
`}
>
<span
className={`
inline-block h-3 w-3 rounded-full bg-white shadow-sm transform transition-transform duration-200
${checked ? 'translate-x-[17px]' : 'translate-x-[3px]'}
`}
/>
</button>
);
};
@@ -0,0 +1,233 @@
// CoverArtDropdown.tsx — AI cover art toggle + download status
//
// Renders as an accordion section inside PostProcessingDropdown.
// Shows: toggle, installation status, download progress, model info.
import React, { useState, useEffect, useCallback } from 'react';
import { Image, Download, X, Check, Loader2 } from 'lucide-react';
import { useGlobalParams } from '../../context/GlobalParamsContext';
import { useAuth } from '../../context/AuthContext';
import { ToggleSwitch } from './BarSection';
// ── Types ────────────────────────────────────────────────────────
interface FileProgress {
filename: string;
description: string;
status: string;
bytesDownloaded: number;
totalBytes: number;
speed: number;
}
interface CoverArtStatusResponse {
installed: boolean;
missingFiles: string[];
download: {
phase: string;
files: FileProgress[];
totalBytes: number;
downloadedBytes: number;
overallProgress: number;
};
}
// ── Helpers ──────────────────────────────────────────────────────
const formatBytes = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
};
const formatSpeed = (bytesPerSec: number): string => {
if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(0)} KB/s`;
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
};
// ── Component ───────────────────────────────────────────────────
export const CoverArtContent: React.FC = () => {
const gp = useGlobalParams();
const { token } = useAuth();
const [status, setStatus] = useState<CoverArtStatusResponse | null>(null);
const [polling, setPolling] = useState(false);
// Poll status
const fetchStatus = useCallback(async () => {
try {
const res = await fetch('/api/cover-art/status');
if (res.ok) {
const data = await res.json();
setStatus(data);
return data;
}
} catch {}
return null;
}, []);
// Initial fetch + polling during download
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
useEffect(() => {
if (!polling) return;
const interval = setInterval(async () => {
const data = await fetchStatus();
if (data && data.download.phase !== 'downloading') {
setPolling(false);
}
}, 1500);
return () => clearInterval(interval);
}, [polling, fetchStatus]);
// Start download
const handleDownload = useCallback(async () => {
if (!token) return;
try {
await fetch('/api/cover-art/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
});
setPolling(true);
fetchStatus();
} catch (err) {
console.error('[CoverArt] Download failed:', err);
}
}, [token, fetchStatus]);
// Cancel download
const handleCancel = useCallback(async () => {
if (!token) return;
try {
await fetch('/api/cover-art/download/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
});
setPolling(false);
fetchStatus();
} catch {}
}, [token, fetchStatus]);
const isDownloading = status?.download?.phase === 'downloading';
const isInstalled = status?.installed ?? false;
return (
<div className="space-y-3 mt-2">
{/* Auto-generate toggle */}
<div className="flex items-center justify-between">
<span className="text-sm text-zinc-600 dark:text-zinc-400">Auto-generate after creation</span>
<ToggleSwitch
checked={gp.coverArtEnabled}
onChange={gp.setCoverArtEnabled}
accentColor="pink"
/>
</div>
{/* Status indicator */}
{!status ? (
<div className="text-xs text-zinc-500 italic text-center py-2">
Checking status...
</div>
) : isInstalled ? (
<div className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-emerald-500/5 border border-emerald-500/10">
<Check size={14} className="text-emerald-400 flex-shrink-0" />
<span className="text-xs text-emerald-400">Ready FLUX.2-klein-4B</span>
</div>
) : isDownloading ? (
/* Download progress */
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Loader2 size={14} className="text-pink-400 animate-spin" />
<span className="text-xs text-zinc-400">
Downloading... {status.download.overallProgress}%
</span>
</div>
<button
onClick={handleCancel}
className="p-1 rounded hover:bg-red-500/10 text-zinc-500 hover:text-red-400 transition-colors"
title="Cancel download"
>
<X size={12} />
</button>
</div>
{/* Overall progress bar */}
<div className="w-full bg-zinc-200 dark:bg-zinc-800 rounded-full h-1.5">
<div
className="bg-gradient-to-r from-pink-500 to-purple-500 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${status.download.overallProgress}%` }}
/>
</div>
{/* Per-file progress */}
<div className="space-y-1">
{status.download.files.map(f => (
<div key={f.filename} className="flex items-center gap-2 text-[10px]">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${
f.status === 'completed' ? 'bg-emerald-400' :
f.status === 'downloading' ? 'bg-pink-400 animate-pulse' :
f.status === 'failed' ? 'bg-red-400' :
'bg-zinc-600'
}`} />
<span className="text-zinc-500 truncate flex-1">{f.description}</span>
{f.status === 'downloading' && f.speed > 0 && (
<span className="text-zinc-600 font-mono flex-shrink-0">
{formatSpeed(f.speed)}
</span>
)}
<span className="text-zinc-600 font-mono flex-shrink-0">
{f.status === 'completed' ? '✓' :
f.status === 'downloading' ? `${formatBytes(f.bytesDownloaded)} / ${formatBytes(f.totalBytes)}` :
f.status === 'failed' ? '✗' :
formatBytes(f.totalBytes)}
</span>
</div>
))}
</div>
</div>
) : (
/* Not installed — show download button */
<div className="space-y-2">
<div className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-300 dark:border-white/5">
<Image size={14} className="text-zinc-500 flex-shrink-0" />
<span className="text-xs text-zinc-500">
Not installed one-click download (~5.9 GB)
</span>
</div>
<button
onClick={handleDownload}
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs font-semibold rounded-xl
bg-gradient-to-r from-pink-500/10 to-purple-500/10
border border-pink-500/20 text-pink-400
hover:from-pink-500/20 hover:to-purple-500/20 hover:border-pink-500/30
transition-all"
>
<Download size={14} />
Download Cover Art Models + Engine
</button>
</div>
)}
<p className="text-[10px] text-zinc-600 leading-relaxed">
Generate 1024×1024 album cover art using FLUX.2-klein-4B.
Uses the song&apos;s subject or lyrics to create relevant artwork.
Runs after audio generation completes.
</p>
</div>
);
};
// ── Badge ────────────────────────────────────────────────────────
export const CoverArtBadge: React.FC = () => {
const { coverArtEnabled } = useGlobalParams();
if (!coverArtEnabled) return null;
return (
<span className="text-[10px] text-pink-400/60 font-mono">Cover Art</span>
);
};
@@ -0,0 +1,868 @@
// GenerationDropdown.tsx — DiT generation settings for the global param bar
//
// Adapted from the DiT section of create/GenerationSettings.tsx.
// Reads from GlobalParamsContext instead of props.
import React, { useState, useEffect, useCallback, useMemo } from 'react';
// Seed input uses local string state to avoid parseInt("-") → NaN → -1 snap-back
import { useTranslation } from 'react-i18next';
import { RotateCcw, ChevronDown, Music2, Upload, Trash2, Zap, Save } from 'lucide-react';
import { useGlobalParams, useGlobalParamsStore } from '../../context/GlobalParamsContext';
import { Slider } from '../shared/Slider';
import { ToggleSwitch } from './BarSection';
import { formatScheduler, formatReferenceName } from './modelLabels';
import { usePersistedState } from '../../hooks/usePersistedState';
import { masteringApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import { usePluginRegistry } from '../../hooks/usePluginRegistry';
import { PluginControls } from './PluginControls';
import { SeedManagerDrawer } from './SeedManagerDrawer';
const selectClasses = "w-full px-3 py-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none transition-colors cursor-pointer";
const inputClasses = "w-full px-3 py-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none transition-colors";
export const GenerationDropdown: React.FC = () => {
const gp = useGlobalParams();
const { t } = useTranslation();
const { registry, findSolver, findScheduler, findGuidance } = usePluginRegistry();
const [compositeOpen, setCompositeOpen] = usePersistedState('hs-genAccordion-composite', false);
const [dcwOpen, setDcwOpen] = usePersistedState('hs-genAccordion-dcw', false);
const [latentOpen, setLatentOpen] = usePersistedState('hs-genAccordion-latent', false);
const [denoiserOpen, setDenoiserOpen] = usePersistedState('hs-genAccordion-denoiser', false);
const [lssOpen, setLssOpen] = usePersistedState('hs-genAccordion-lss', false);
// LSS params — new fields use direct store selectors (GlobalParamsContext is a legacy shim)
const lssStrength = useGlobalParamsStore((s: any) => s.lssStrength);
const lssVarThresh = useGlobalParamsStore((s: any) => s.lssVarThresh);
const lssDcRemove = useGlobalParamsStore((s: any) => s.lssDcRemove);
const setLssStrength = useGlobalParamsStore((s: any) => s.setLssStrength);
const setLssVarThresh = useGlobalParamsStore((s: any) => s.setLssVarThresh);
const setLssDcRemove = useGlobalParamsStore((s: any) => s.setLssDcRemove);
const [autoTrimOpen, setAutoTrimOpen] = usePersistedState('hs-genAccordion-autotrim', false);
const [perfOpen, setPerfOpen] = usePersistedState('hs-genAccordion-perf', false);
const [timbreOpen, setTimbreOpen] = usePersistedState('hs-genAccordion-timbre', false);
const { token } = useAuth();
// ── Timbre reference file management ──
interface ReferenceTrack { name: string; size: number; url: string; }
const [timbreRefs, setTimbreRefs] = useState<ReferenceTrack[]>([]);
const [timbreUploading, setTimbreUploading] = useState(false);
const [seedDrawerOpen, setSeedDrawerOpen] = useState(false);
useEffect(() => {
masteringApi.listReferences()
.then(data => setTimbreRefs(data.references))
.catch(() => {});
}, []);
const handleTimbreUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !token) return;
try {
setTimbreUploading(true);
const result = await masteringApi.uploadReference(file, token);
gp.setTimbreAudioPath(result.name);
const data = await masteringApi.listReferences();
setTimbreRefs(data.references);
} catch (err) {
console.error('[Timbre] Upload failed:', err);
} finally {
setTimbreUploading(false);
e.target.value = '';
}
}, [token, gp]);
// Resolve scheduler dropdown value from the composite string representation
const schedulerKey = gp.scheduler.startsWith('composite') ? 'composite'
: gp.scheduler.startsWith('beta:') ? 'beta'
: gp.scheduler.startsWith('power:') ? 'power'
: gp.scheduler;
return (
<div className="space-y-3">
<Slider label="Inference Steps" value={gp.inferenceSteps}
onChange={gp.setInferenceSteps} min={1} max={300} step={1} showInput />
<Slider label="Guidance Scale" value={gp.guidanceScale}
onChange={gp.setGuidanceScale} min={0} max={20} step={0.1} showInput />
{/* ── Performance / Speed Boosts (Accordion, closed by default) ── */}
<div className={`rounded-xl border transition-all overflow-hidden ${
(gp.cfgCutoffRatio < 1 || gp.lmCfgCutoffRatio < 1 || gp.cacheRatio > 0)
? 'border-amber-500/20 bg-amber-500/5'
: 'border-zinc-200 dark:border-white/10 bg-zinc-100/30 dark:bg-zinc-800/30'
}`}>
<button
type="button"
onClick={() => setPerfOpen(!perfOpen)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-amber-500/5 transition-colors"
>
<div className="flex items-center gap-2">
<ChevronDown size={12} className={`text-amber-400 transition-transform duration-200 ${perfOpen ? 'rotate-180' : ''}`} />
<Zap size={14} className={(gp.cfgCutoffRatio < 1 || gp.lmCfgCutoffRatio < 1 || gp.cacheRatio > 0) ? 'text-amber-400' : 'text-zinc-500'} />
<span className="text-[10px] font-semibold text-amber-400 uppercase tracking-wider">Performance</span>
</div>
{(gp.cfgCutoffRatio < 1 || gp.lmCfgCutoffRatio < 1 || gp.cacheRatio > 0) && (
<span className="text-[10px] text-amber-400/60 font-mono">
{gp.cfgCutoffRatio < 1 ? `CFG ${Math.round(gp.cfgCutoffRatio * 100)}%` : ''}
{gp.cfgCutoffRatio < 1 && gp.lmCfgCutoffRatio < 1 ? ' · ' : ''}
{gp.lmCfgCutoffRatio < 1 ? `LM ${Math.round(gp.lmCfgCutoffRatio * 100)}%` : ''}
{(gp.cfgCutoffRatio < 1 || gp.lmCfgCutoffRatio < 1) && gp.cacheRatio > 0 ? ' · ' : ''}
{gp.cacheRatio > 0 ? `Cache ${Math.round(gp.cacheRatio * 100)}%` : ''}
</span>
)}
</button>
{perfOpen && (
<div className="px-3 pb-3 space-y-3 border-t border-zinc-200 dark:border-white/5">
<Slider label="CFG Cutoff" value={gp.cfgCutoffRatio}
onChange={gp.setCfgCutoffRatio} min={0} max={1} step={0.05} showInput />
<p className="text-[10px] text-zinc-500 mt-1 leading-relaxed">
Ratio of DiT steps using full guidance. Lower = faster but may reduce prompt adherence. 0.5 20% speedup.
</p>
<Slider label="LM CFG Cutoff" value={gp.lmCfgCutoffRatio}
onChange={gp.setLmCfgCutoffRatio} min={0.3} max={1} step={0.05} showInput />
<p className="text-[10px] text-zinc-500 mt-1 leading-relaxed">
Fraction of LM audio code tokens using guidance. Lower = faster but may reduce prompt adherence. 0.7 = ~15% LM speedup.
</p>
<Slider label="Step Cache" value={gp.cacheRatio}
onChange={gp.setCacheRatio} min={0} max={0.7} step={0.05} showInput />
<p className="text-[10px] text-zinc-500 mt-1 leading-relaxed">
Skip redundant forward passes by reusing velocity. Higher = faster but may reduce quality. Try 0.30.5.
</p>
<button type="button" onClick={() => { gp.setCfgCutoffRatio(1); gp.setLmCfgCutoffRatio(1); gp.setCacheRatio(0); }}
className="flex items-center gap-1 text-[10px] text-amber-400 hover:text-amber-300 transition-colors">
<RotateCcw size={10} /> Reset to defaults
</button>
</div>
)}
</div>
{/* Shift with Auto toggle */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs font-medium text-zinc-500 uppercase tracking-wider">Shift</label>
<button
onClick={() => {
if (gp.shift === -1) {
gp.setShift(3.0);
} else {
gp.setShift(-1);
}
}}
className={`text-[10px] font-bold px-2 py-0.5 rounded-full transition-all ${
gp.shift === -1
? 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/30'
: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500 border border-zinc-200 dark:border-white/5 hover:text-zinc-700 dark:text-zinc-300 hover:border-zinc-300 dark:border-white/10'
}`}
>
Auto
</button>
</div>
{gp.shift === -1 ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-cyan-500/5 border border-cyan-500/10 text-xs text-cyan-400/80">
<span>Adaptive shift based on duration &amp; step count</span>
</div>
) : (
<Slider label="" value={gp.shift}
onChange={gp.setShift} min={0} max={10} step={0.1} showInput />
)}
</div>
{/* Solver */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">{t('gen.solver')}</label>
<select className={selectClasses} value={gp.inferMethod}
onChange={e => gp.setInferMethod(e.target.value)}>
{registry.solvers.length > 0 ? (
<>
<optgroup label="── Single Evaluation (1 NFE) ──">
{registry.solvers.filter(s => (s.nfe ?? 1) === 1).map(s => (
<option key={s.name} value={s.name}>{s.display}</option>
))}
</optgroup>
<optgroup label="── Multi Evaluation ──">
{registry.solvers.filter(s => (s.nfe ?? 1) > 1).map(s => (
<option key={s.name} value={s.name}>{s.display} ({s.nfe} NFE)</option>
))}
</optgroup>
{registry.solvers.some(s => (s.nfe ?? 1) === 0) && (
<optgroup label="── Adaptive (Variable NFE) ──">
{registry.solvers.filter(s => (s.nfe ?? 1) === 0).map(s => (
<option key={s.name} value={s.name}>{s.display}</option>
))}
</optgroup>
)}
</>
) : (
<>
{/* Fallback while registry is loading */}
<option value="euler">Euler (ODE)</option>
<option value="heun">Heun (2 NFE)</option>
<option value="dpm2m">DPM++ 2M</option>
<option value="rk4">RK4 (4 NFE)</option>
</>
)}
</select>
{/* Solver description from Lua plugin metadata */}
{(() => {
const solver = findSolver(gp.inferMethod);
return solver?.description ? (
<p className="text-[10px] text-zinc-500 mt-1.5 leading-relaxed">{solver.description}</p>
) : null;
})()}
</div>
{/* ── Dynamic Solver Controls ── */}
{(() => {
const solver = findSolver(gp.inferMethod);
if (!solver || solver.params.length === 0) return null;
return (
<PluginControls
pluginName={solver.name}
displayName={solver.display}
accent={solver.accent}
params={solver.params}
values={gp.pluginParams}
onChange={gp.setPluginParam}
onReset={() => gp.resetPluginParams(solver.name)}
/>
);
})()}
{/* Scheduler */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">{t('gen.schedule')}</label>
<select className={selectClasses} value={schedulerKey}
onChange={e => {
const v = e.target.value;
if (v === 'beta') gp.setScheduler('beta:0.50:0.70');
else if (v === 'power') gp.setScheduler('power:2.00');
else if (v === 'composite') gp.setScheduler('composite:bong_tangent+linear:0.50:0.50');
else gp.setScheduler(v);
}}>
{registry.schedulers.length > 0 ? (
<>
{registry.schedulers.map(s => (
<option key={s.name} value={s.name}>{s.display}</option>
))}
{/* Synthetic entries: parameterized schedules handled by the UI */}
<option value="beta">Beta (Custom)</option>
<option value="power">Power</option>
<option value="composite">Composite (2-Stage)</option>
</>
) : (
<>
{/* Fallback while registry is loading */}
<option value="linear">Linear (Default)</option>
<option value="cosine">Cosine</option>
<option value="ddim_uniform">DDIM Uniform</option>
<option value="sgm_uniform">SGM / Karras</option>
<option value="bong_tangent">Tangent</option>
<option value="linear_quadratic">Linear-Quadratic</option>
<option value="composite">Composite (2-Stage)</option>
</>
)}
</select>
{/* Scheduler description from Lua plugin metadata */}
{(() => {
const sched = registry.schedulers.find(s => s.name === schedulerKey);
return sched?.description ? (
<p className="text-[10px] text-zinc-500 mt-1.5 leading-relaxed">{sched.description}</p>
) : null;
})()}
</div>
{/* ── Dynamic Scheduler Controls ── */}
{(() => {
const sched = findScheduler(schedulerKey);
if (!sched || !sched.params || sched.params.length === 0) return null;
return (
<PluginControls
pluginName={sched.name}
displayName={sched.display}
accent={sched.accent}
params={sched.params}
values={gp.pluginParams}
onChange={gp.setPluginParam}
onReset={() => gp.resetPluginParams(sched.name)}
/>
);
})()}
{/* ── Beta (Custom) Sub-Controls ── */}
{gp.scheduler.startsWith('beta:') && (() => {
const parts = gp.scheduler.split(':');
const alpha = parseFloat(parts[1] || '0.5');
const betaParam = parseFloat(parts[2] || '0.7');
const updateBeta = (a: number, b: number) => {
gp.setScheduler(`beta:${a.toFixed(2)}:${b.toFixed(2)}`);
};
return (
<div className="rounded-xl border border-teal-500/20 bg-teal-500/5 p-3 space-y-3 transition-all">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-teal-400 uppercase tracking-wider">Beta Distribution</span>
<button type="button" onClick={() => updateBeta(0.5, 0.7)}
className="flex items-center gap-1 text-[10px] text-teal-400 hover:text-teal-300 transition-colors">
<RotateCcw size={10} /> Reset
</button>
</div>
<Slider label="Alpha (α)" value={alpha}
onChange={v => updateBeta(v, betaParam)} min={0.1} max={2.0} step={0.05} showInput />
<Slider label="Beta (β)" value={betaParam}
onChange={v => updateBeta(alpha, v)} min={0.1} max={2.0} step={0.05} showInput />
<p className="text-[10px] text-zinc-500">Lower α = more density at edges. Lower β = front-loaded (structural focus).</p>
</div>
);
})()}
{/* ── Power Sub-Controls ── */}
{gp.scheduler.startsWith('power:') && (() => {
const exponent = parseFloat(gp.scheduler.split(':')[1] || '2.0');
return (
<div className="rounded-xl border border-orange-500/20 bg-orange-500/5 p-3 space-y-3 transition-all">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-orange-400 uppercase tracking-wider">Power Law</span>
<button type="button" onClick={() => gp.setScheduler('power:2.00')}
className="flex items-center gap-1 text-[10px] text-orange-400 hover:text-orange-300 transition-colors">
<RotateCcw size={10} /> Reset
</button>
</div>
<Slider label="Exponent" value={exponent}
onChange={v => gp.setScheduler(`power:${v.toFixed(2)}`)} min={0.25} max={4.0} step={0.05} showInput />
<p className="text-[10px] text-zinc-500">p&gt;1 = front-loaded (structure), p=1 = linear, p&lt;1 = back-loaded (detail).</p>
</div>
);
})()}
{/* ── Composite Sub-Controls (Accordion) ── */}
{gp.scheduler.startsWith('composite') && (() => {
const parts = gp.scheduler.split(':');
const schedulerPair = (parts[1] || 'bong_tangent+linear').split('+');
const stageA = schedulerPair[0] || 'bong_tangent';
const stageB = schedulerPair[1] || 'linear';
const crossover = parseFloat(parts[2] || '0.5');
const split = parseFloat(parts[3] || '0.5');
const update = (a: string, b: string, c: number, s: number) => {
gp.setScheduler(`composite:${a}+${b}:${c.toFixed(2)}:${s.toFixed(2)}`);
};
return (
<div className="rounded-xl border border-purple-500/20 bg-purple-500/5 transition-all overflow-hidden">
<button
type="button"
onClick={() => setCompositeOpen(!compositeOpen)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-purple-500/5 transition-colors"
>
<div className="flex items-center gap-2">
<ChevronDown size={12} className={`text-purple-400 transition-transform duration-200 ${compositeOpen ? 'rotate-180' : ''}`} />
<span className="text-[10px] font-semibold text-purple-400 uppercase tracking-wider">Composite (2-Stage)</span>
</div>
<button type="button" onClick={(e) => { e.stopPropagation(); update('bong_tangent', 'linear', 0.5, 0.5); }}
className="flex items-center gap-1 text-[10px] text-purple-400 hover:text-purple-300 transition-colors">
<RotateCcw size={10} /> Reset
</button>
</button>
{compositeOpen && (
<div className="px-3 pb-3 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] text-purple-400 mb-1">Stage A</label>
<select className={selectClasses} value={stageA}
onChange={e => update(e.target.value, stageB, crossover, split)}>
{registry.schedulers.length > 0 ? (
registry.schedulers.map(s => (
<option key={s.name} value={s.name}>{s.display}</option>
))
) : (
<>
<option value="linear">Linear</option>
<option value="cosine">Cosine</option>
<option value="ddim_uniform">DDIM</option>
<option value="sgm_uniform">SGM</option>
<option value="bong_tangent">Tangent</option>
<option value="linear_quadratic">Lin-Quad</option>
</>
)}
</select>
</div>
<div>
<label className="block text-[10px] text-purple-400 mb-1">Stage B</label>
<select className={selectClasses} value={stageB}
onChange={e => update(stageA, e.target.value, crossover, split)}>
{registry.schedulers.length > 0 ? (
registry.schedulers.map(s => (
<option key={s.name} value={s.name}>{s.display}</option>
))
) : (
<>
<option value="linear">Linear</option>
<option value="cosine">Cosine</option>
<option value="ddim_uniform">DDIM</option>
<option value="sgm_uniform">SGM</option>
<option value="bong_tangent">Tangent</option>
<option value="linear_quadratic">Lin-Quad</option>
</>
)}
</select>
</div>
</div>
<Slider label="Crossover" value={crossover}
onChange={v => update(stageA, stageB, v, split)} min={0.1} max={0.9} step={0.05} showInput />
<Slider label="Split" value={split}
onChange={v => update(stageA, stageB, crossover, v)} min={0.1} max={0.9} step={0.05} showInput />
</div>
)}
</div>
);
})()}
{/* Guidance Mode */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">{t('gen.guidance')}</label>
<select className={selectClasses} value={gp.guidanceMode}
onChange={e => gp.setGuidanceMode(e.target.value)}>
{registry.guidance.length > 0 ? (
registry.guidance.map(g => (
<option key={g.name} value={g.name}>{g.display}</option>
))
) : (
<>
<option value="apg">APG (Default)</option>
<option value="cfg_pp">CFG++</option>
<option value="dynamic_cfg">Dynamic CFG</option>
<option value="rescaled_cfg">Rescaled CFG</option>
</>
)}
</select>
{/* Guidance description from Lua plugin metadata */}
{(() => {
const guide = findGuidance(gp.guidanceMode);
return guide?.description ? (
<p className="text-[10px] text-zinc-500 mt-1.5 leading-relaxed">{guide.description}</p>
) : null;
})()}
</div>
{/* ── APG Sub-Controls (native C++ path — always show for APG) ── */}
{gp.guidanceMode === 'apg' && (
<div className="rounded-xl border border-blue-500/20 bg-blue-500/5 p-3 space-y-3 transition-all">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-blue-400 uppercase tracking-wider">APG Parameters</span>
<button type="button" onClick={() => {
gp.setApgMomentum(0.75);
gp.setApgNormThreshold(2.5);
}} className="flex items-center gap-1 text-[10px] text-blue-400 hover:text-blue-300 transition-colors">
<RotateCcw size={10} /> Reset
</button>
</div>
<Slider label="Momentum" value={gp.apgMomentum}
onChange={gp.setApgMomentum} min={0} max={1} step={0.01} showInput />
<Slider label="Norm Threshold" value={gp.apgNormThreshold}
onChange={gp.setApgNormThreshold} min={0} max={10} step={0.1} showInput />
<p className="text-[10px] text-zinc-500">Momentum smooths guidance across steps. Norm threshold clips gradient magnitude per channel.</p>
</div>
)}
{/* ── Dynamic Guidance Controls (non-APG) ── */}
{gp.guidanceMode !== 'apg' && (() => {
const guide = findGuidance(gp.guidanceMode);
if (!guide || guide.params.length === 0) return null;
return (
<PluginControls
pluginName={guide.name}
displayName={guide.display}
accent={guide.accent}
params={guide.params}
values={gp.pluginParams}
onChange={gp.setPluginParam}
onReset={() => gp.resetPluginParams(guide.name)}
/>
);
})()}
{/* ── Timbre Conditioning (Accordion with file picker) ── */}
<div className={`rounded-xl border transition-all overflow-hidden ${gp.timbreAudioPath ? 'border-teal-500/20 bg-teal-500/5' : 'border-zinc-200 dark:border-white/10 bg-zinc-100/30 dark:bg-zinc-800/30'}`}>
<button
type="button"
onClick={() => setTimbreOpen(!timbreOpen)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-teal-500/5 transition-colors"
>
<div className="flex items-center gap-2">
<ChevronDown size={12} className={`text-teal-400 transition-transform duration-200 ${timbreOpen ? 'rotate-180' : ''}`} />
<Music2 size={14} className={gp.timbreAudioPath ? 'text-teal-400' : 'text-zinc-500'} />
<span className="text-[10px] font-semibold text-teal-400 uppercase tracking-wider">Timbre Reference</span>
</div>
{gp.timbreAudioPath ? (
<span className="text-[10px] text-teal-400/60 font-mono truncate max-w-[120px]">
{formatReferenceName(gp.timbreAudioPath)}
</span>
) : gp.timbreReference && gp.masteringReference ? (
<span className="text-[10px] text-zinc-500 font-mono">Mastering ref</span>
) : (
<span className="text-[10px] text-zinc-600 font-mono">None</span>
)}
</button>
{timbreOpen && (
<div className="px-3 pb-3 space-y-3 border-t border-zinc-200 dark:border-white/5">
<p className="text-[10px] text-zinc-500 leading-relaxed mt-2">
Set a dedicated audio track for timbre conditioning. The reference is VAE-encoded
and fed into the DiT during synthesis, guiding tone and texture.
If not set, the mastering reference is used when the timbre toggle is enabled.
</p>
{/* Reference selector */}
{timbreRefs.length > 0 ? (
<select
className="w-full px-3 py-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/20 outline-none transition-colors cursor-pointer"
value={gp.timbreAudioPath}
onChange={e => gp.setTimbreAudioPath(e.target.value)}
>
<option value="">None (use mastering ref if enabled)</option>
{timbreRefs.map(r => (
<option key={r.name} value={r.name}>
{r.name} ({r.size < 1024 * 1024 ? `${(r.size / 1024).toFixed(1)} KB` : `${(r.size / (1024 * 1024)).toFixed(1)} MB`})
</option>
))}
</select>
) : (
<div className="text-xs text-zinc-500 italic px-1">
No reference tracks uploaded yet
</div>
)}
{/* Selected file info + clear */}
{gp.timbreAudioPath && (
<div className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-teal-500/5 border border-teal-500/10">
<Music2 size={14} className="text-teal-400 flex-shrink-0" />
<span className="text-xs text-teal-300 truncate flex-1">{gp.timbreAudioPath}</span>
<button
onClick={() => gp.setTimbreAudioPath('')}
className="p-1 rounded hover:bg-red-500/10 text-zinc-500 hover:text-red-400 transition-colors flex-shrink-0"
title={t('gen.clearTimbreRef')}
>
<Trash2 size={12} />
</button>
</div>
)}
{/* Upload button */}
<div className="flex items-center gap-2">
<input
type="file"
accept="audio/*"
id="timbre-ref-upload-gen"
className="hidden"
onChange={handleTimbreUpload}
/>
<label
htmlFor="timbre-ref-upload-gen"
className={`flex items-center gap-2 px-3 py-2 text-xs font-semibold rounded-xl border cursor-pointer transition-all ${
timbreUploading
? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500 border-zinc-200 dark:border-white/5 cursor-wait'
: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-white/10 hover:border-teal-500/30 hover:text-teal-400'
}`}
>
{timbreUploading ? (
<><span className="w-3 h-3 border-2 border-zinc-500 border-t-transparent rounded-full animate-spin" /> Uploading...</>
) : (
<><Upload size={14} /> {t('gen.uploadReference')}</>
)}
</label>
</div>
</div>
)}
</div>
{/* ── DCW Correction (Accordion with checkbox in title) ── */}
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 transition-all overflow-hidden">
<button
type="button"
onClick={() => setDcwOpen(!dcwOpen)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-emerald-500/5 transition-colors"
>
<div className="flex items-center gap-2">
<ChevronDown size={12} className={`text-emerald-400 transition-transform duration-200 ${dcwOpen ? 'rotate-180' : ''}`} />
<div className="flex items-center gap-1.5" onClick={e => e.stopPropagation()}>
<ToggleSwitch checked={gp.dcwEnabled} onChange={gp.setDcwEnabled} accentColor="emerald" />
<span className="text-[10px] font-semibold text-emerald-400 uppercase tracking-wider">DCW Correction</span>
</div>
</div>
{gp.dcwEnabled && (
<span onClick={(e) => {
e.stopPropagation();
gp.setDcwMode('double');
gp.setDcwLowScaler(0.2);
gp.setDcwHighScaler(0.2);
}} className="flex items-center gap-1 text-[10px] text-emerald-400 hover:text-emerald-300 transition-colors cursor-pointer">
<RotateCcw size={10} /> Reset
</span>
)}
</button>
{dcwOpen && gp.dcwEnabled && (
<div className="px-3 pb-3 space-y-3">
<div>
<label className="block text-[10px] text-emerald-400 mb-1">Correction Mode</label>
<div className="relative group/dcw">
<select className={selectClasses} value={gp.dcwMode}
onChange={e => gp.setDcwMode(e.target.value)}>
<option value="low">Low-Frequency</option>
<option value="high">High-Frequency</option>
<option value="double">Both (Low + High)</option>
<option value="pix">Pixel-Space (No Wavelets)</option>
</select>
<div className="mt-1.5 text-[10px] text-zinc-500 leading-relaxed">
{gp.dcwMode === 'low' && '🎵 Corrects low-frequency wavelet bands — tightens bass, kick and rhythm without touching treble.'}
{gp.dcwMode === 'high' && '✨ Corrects high-frequency wavelet bands — sharpens hi-hats, vocals and presence.'}
{gp.dcwMode === 'double' && '🎛️ Independent correction on both low and high bands with separate scalers.'}
{gp.dcwMode === 'pix' && '📐 Applies correction directly in latent space, bypassing wavelet decomposition. More uniform but less targeted.'}
</div>
</div>
</div>
{(gp.dcwMode === 'low' || gp.dcwMode === 'double' || gp.dcwMode === 'pix') && (
<Slider label={gp.dcwMode === 'double' ? 'Low-Freq Scaler' : 'Scaler'} value={gp.dcwLowScaler}
onChange={gp.setDcwLowScaler} min={0} max={1} step={0.01} showInput />
)}
{(gp.dcwMode === 'high' || gp.dcwMode === 'double') && (
<Slider label={gp.dcwMode === 'double' ? 'High-Freq Scaler' : 'Scaler'} value={gp.dcwHighScaler}
onChange={gp.setDcwHighScaler} min={0} max={1} step={0.01} showInput />
)}
<p className="text-[10px] text-zinc-500">
Wavelet-domain SNR-t bias correction (CVPR 2026). Scaler is dynamically modulated by timestep.
</p>
</div>
)}
</div>
{/* ── Duration Buffer / Auto-Trim (Accordion with toggle) ── */}
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 transition-all overflow-hidden">
<button
type="button"
onClick={() => setAutoTrimOpen(!autoTrimOpen)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-amber-500/5 transition-colors"
>
<div className="flex items-center gap-2">
<ChevronDown size={12} className={`text-amber-400 transition-transform duration-200 ${autoTrimOpen ? 'rotate-180' : ''}`} />
<div className="flex items-center gap-1.5" onClick={e => e.stopPropagation()}>
<ToggleSwitch checked={gp.autoTrimEnabled} onChange={gp.setAutoTrimEnabled} accentColor="amber" />
<span className="text-[10px] font-semibold text-amber-400 uppercase tracking-wider">Auto-Trim Endings</span>
</div>
</div>
{gp.autoTrimEnabled && (
<span onClick={(e) => {
e.stopPropagation();
gp.setDurationBuffer(15);
gp.setAutoTrimFadeMs(2000);
}} className="flex items-center gap-1 text-[10px] text-amber-400 hover:text-amber-300 transition-colors cursor-pointer">
<RotateCcw size={10} /> Reset
</span>
)}
</button>
{autoTrimOpen && gp.autoTrimEnabled && (
<div className="px-3 pb-3 space-y-3">
<Slider label="Duration Buffer (seconds)" value={gp.durationBuffer}
onChange={gp.setDurationBuffer} min={5} max={30} step={1} showInput />
<Slider label="Fade-Out (seconds)" value={gp.autoTrimFadeMs / 1000}
onChange={(v: number) => gp.setAutoTrimFadeMs(Math.round(v * 1000))} min={0.5} max={5} step={0.1} showInput />
<p className="text-[10px] text-zinc-500">
Generates extra audio beyond the requested duration, then trims at the natural song ending.
Fade-out only applies when no clean ending is detected (forced trim at original duration).
</p>
</div>
)}
</div>
{/* ── Latent Post-Processing (Accordion) ── */}
<div className="rounded-xl border border-indigo-500/20 bg-indigo-500/5 transition-all overflow-hidden">
<button
type="button"
onClick={() => setLatentOpen(!latentOpen)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-indigo-500/5 transition-colors"
>
<div className="flex items-center gap-2">
<ChevronDown size={12} className={`text-indigo-400 transition-transform duration-200 ${latentOpen ? 'rotate-180' : ''}`} />
<span className="text-[10px] font-semibold text-indigo-400 uppercase tracking-wider">Latent Post-Processing</span>
</div>
<span onClick={(e) => {
e.stopPropagation();
gp.setLatentShift(0);
gp.setLatentRescale(1);
gp.setCustomTimesteps('');
}} className="flex items-center gap-1 text-[10px] text-indigo-400 hover:text-indigo-300 transition-colors cursor-pointer">
<RotateCcw size={10} /> Reset
</span>
</button>
{latentOpen && (
<div className="px-3 pb-3 space-y-3">
<Slider label="Latent Shift" value={gp.latentShift}
onChange={gp.setLatentShift} min={-2} max={2} step={0.01} showInput />
<Slider label="Latent Rescale" value={gp.latentRescale}
onChange={gp.setLatentRescale} min={0.1} max={3} step={0.01} showInput />
<div>
<label className="block text-[10px] text-indigo-400 mb-1">Custom Timesteps</label>
<input className={inputClasses} value={gp.customTimesteps}
onChange={e => gp.setCustomTimesteps(e.target.value)}
placeholder="0.97,0.76,0.615,0.5,0.395,0.28,0.18,0.085,0" />
<p className="text-[10px] text-zinc-500 mt-1">CSV of descending floats. Overrides schedule + step count when set.</p>
</div>
</div>
)}
</div>
{/* ── Post-VAE Spectral Denoiser (Accordion with toggle) ── */}
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 transition-all overflow-hidden">
<button
type="button"
onClick={() => setDenoiserOpen(!denoiserOpen)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-amber-500/5 transition-colors"
>
<div className="flex items-center gap-2">
<ChevronDown size={12} className={`text-amber-400 transition-transform duration-200 ${denoiserOpen ? 'rotate-180' : ''}`} />
<div className="flex items-center gap-1.5" onClick={e => e.stopPropagation()}>
<ToggleSwitch checked={gp.denoiseStrength > 0} onChange={(on) => gp.setDenoiseStrength(on ? 0.5 : 0)} accentColor="amber" />
<span className="text-[10px] font-semibold text-amber-400 uppercase tracking-wider">Denoiser</span>
</div>
</div>
{gp.denoiseStrength > 0 && (
<span onClick={(e) => {
e.stopPropagation();
gp.setDenoiseStrength(0.0);
gp.setDenoiseSmoothing(0.7);
gp.setDenoiseMix(0.25);
}} className="flex items-center gap-1 text-[10px] text-amber-400 hover:text-amber-300 transition-colors cursor-pointer">
<RotateCcw size={10} /> Reset
</span>
)}
</button>
{denoiserOpen && gp.denoiseStrength > 0 && (
<div className="px-3 pb-3 space-y-3">
<Slider label="Strength" value={gp.denoiseStrength}
onChange={gp.setDenoiseStrength} min={0.01} max={1} step={0.01} showInput />
<Slider label="Smoothing" value={gp.denoiseSmoothing}
onChange={gp.setDenoiseSmoothing} min={0} max={1} step={0.01} showInput />
<Slider label="Mix" value={gp.denoiseMix}
onChange={gp.setDenoiseMix} min={0} max={1} step={0.01} showInput />
<p className="text-[10px] text-zinc-500">
Spectral gate removes VAE fuzz after decode. Higher strength = more aggressive noise suppression.
</p>
</div>
)}
</div>
{/* ── LSS: Latent Spectral Suppressor (Accordion with toggle) ── */}
<div className="rounded-xl border border-teal-500/20 bg-teal-500/5 transition-all overflow-hidden">
<button
type="button"
onClick={() => setLssOpen(!lssOpen)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-teal-500/5 transition-colors"
>
<div className="flex items-center gap-2">
<ChevronDown size={12} className={`text-teal-400 transition-transform duration-200 ${lssOpen ? 'rotate-180' : ''}`} />
<div className="flex items-center gap-1.5" onClick={e => e.stopPropagation()}>
<ToggleSwitch checked={lssStrength > 0} onChange={(on) => setLssStrength(on ? 0.65 : 0)} accentColor="teal" />
<span className="text-[10px] font-semibold text-teal-400 uppercase tracking-wider">LSS</span>
</div>
</div>
{lssStrength > 0 && (
<span onClick={(e) => {
e.stopPropagation();
setLssStrength(0.0);
setLssVarThresh(0.15);
setLssDcRemove(true);
}} className="flex items-center gap-1 text-[10px] text-teal-400 hover:text-teal-300 transition-colors cursor-pointer">
<RotateCcw size={10} /> Reset
</span>
)}
</button>
{lssOpen && lssStrength > 0 && (
<div className="px-3 pb-3 space-y-3">
<Slider label="Strength" value={lssStrength}
onChange={setLssStrength} min={0.01} max={1} step={0.01} showInput />
<Slider label="Var Threshold" value={lssVarThresh}
onChange={setLssVarThresh} min={0.01} max={0.5} step={0.01} showInput />
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-500">DC Remove</span>
<ToggleSwitch checked={lssDcRemove} onChange={setLssDcRemove} accentColor="teal" />
</div>
<p className="text-[10px] text-zinc-500">
Latent Spectral Suppressor (MDMAchine): gates quiet latent channels before VAE decode.
Channels below the variance threshold are attenuated toward 1&minus;strength.
</p>
</div>
)}
</div>
{/* Seed */}
<div className="relative">
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5">
<label className="text-xs font-medium text-zinc-500 uppercase tracking-wider">Generation Seed</label>
<button onClick={() => setSeedDrawerOpen(true)} title="Seed Manager"
className="text-zinc-500 hover:text-amber-400 transition-colors">
<Save size={12} />
</button>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-zinc-500">Random</span>
<ToggleSwitch checked={gp.randomSeed} onChange={gp.setRandomSeed} accentColor="sky" />
</div>
</div>
{!gp.randomSeed && (
<SeedInput value={gp.seed} onChange={gp.setSeed} className={inputClasses} />
)}
<p className="text-[10px] text-zinc-500 mt-1">
Drives audio synthesis (DiT). Varies per track during batch generation. See LM Seed for caption/lyrics/code sampling.
</p>
<SeedManagerDrawer
isOpen={seedDrawerOpen}
onClose={() => setSeedDrawerOpen(false)}
currentSeed={gp.seed}
onLoad={(seed) => { gp.setSeed(seed); gp.setRandomSeed(false); setSeedDrawerOpen(false); }}
onLoadRandom={(seed) => { gp.setSeed(seed); gp.setRandomSeed(false); }}
/>
</div>
{/* Batch */}
<Slider label="Batch Size" value={gp.batchSize}
onChange={gp.setBatchSize} min={1} max={9} step={1} />
</div>
);
};
/** Seed input with local string buffer — prevents parseInt("-") snap-back */
const SeedInput: React.FC<{ value: number; onChange: (v: number) => void; className: string }> = ({ value, onChange, className }) => {
const [local, setLocal] = useState(String(value));
useEffect(() => { setLocal(String(value)); }, [value]);
const commit = () => { onChange(parseInt(local) || 42); };
return (
<input type="number" className={className} value={local}
onChange={e => setLocal(e.target.value)}
onBlur={commit}
onKeyDown={e => { if (e.key === 'Enter') commit(); }}
/>
);
};
/** Summary badge for the Generation section */
export const GenerationBadge: React.FC = () => {
const gp = useGlobalParams();
const { registry } = usePluginRegistry();
const solver = useMemo(() => {
const s = registry.solvers.find(p => p.name === gp.inferMethod);
return s?.display || gp.inferMethod;
}, [registry.solvers, gp.inferMethod]);
const guidance = useMemo(() => {
const g = registry.guidance.find(p => p.name === gp.guidanceMode);
return g?.display || gp.guidanceMode;
}, [registry.guidance, gp.guidanceMode]);
const schedule = formatScheduler(gp.scheduler);
const shiftLabel = gp.shift === -1 ? 'Auto' : gp.shift.toFixed(1);
const seedLabel = gp.randomSeed ? 'Rnd' : 'Fix';
return (
<span className="text-[10px] text-zinc-500 font-mono truncate">
{gp.inferenceSteps}s · {solver} · {schedule} · {guidance} {gp.guidanceScale.toFixed(1)} · σ{shiftLabel} · Seed {seedLabel}
</span>
);
};
@@ -0,0 +1,253 @@
// GlobalParamBar.tsx — Horizontal top bar with hover-to-expand engine config sections
//
// Renders 5 sections: Models, Adapters, Generation, LM/Thinking, Post-Processing.
// Each section shows a summary badge and expands on hover to reveal controls.
// Sits full-width at the top of the entire window (above sidebar).
import React, { useState, useCallback, useEffect } from 'react';
import { Cpu, Plug, Sliders, Brain, AudioWaveform, Bookmark } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { BarSection, ToggleSwitch } from './BarSection';
import { useGlobalParams } from '../../context/GlobalParamsContext';
import { modelApi } from '../../services/api';
import { ModelManagerModal } from '../model-manager/ModelManagerModal';
import { ModelsDropdown, ModelsBadge } from './ModelsDropdown';
import { AdaptersDropdown, AdaptersBadge } from './AdaptersDropdown';
import { GenerationDropdown, GenerationBadge } from './GenerationDropdown';
import { LmThinkingDropdown, LmThinkingBadge } from './LmThinkingDropdown';
import { PostProcessingDropdown, PostProcessingBadge } from './PostProcessingDropdown';
import { VramIndicator } from '../shared/VramIndicator';
import { DiscoPulseWrapper } from '../shared/DiscoPulseWrapper';
import { MonitorBar } from './MonitorBar';
import { useVstChainStore } from '../../stores/vstChainStore';
import { ProfilesModal } from './ProfilesModal';
type SectionId = 'models' | 'adapters' | 'generation' | 'lm' | 'postprocessing' | null;
export const GlobalParamBar: React.FC = () => {
const { t } = useTranslation();
const [openSection, setOpenSection] = useState<SectionId>(null);
const gp = useGlobalParams();
const monitoring = useVstChainStore(s => s.monitoring);
// ── Auto-select models when engine becomes ready ────────────────
// Polls the engine until it returns a model list, then auto-selects
// the first available model for any empty slot. Runs independently
// of the Model Manager modal state.
const [showModelManager, setShowModelManager] = useState(false);
const [showProfiles, setShowProfiles] = useState(false);
useEffect(() => {
let cancelled = false;
let retries = 0;
const MAX_RETRIES = 20; // ~60 seconds of polling
const tryAutoSelect = () => {
if (cancelled) return;
modelApi.list()
.then((data) => {
if (cancelled) return;
const dit = data?.models?.dit || [];
const lm = data?.models?.lm || [];
const vae = data?.models?.vae || [];
const emb = data?.models?.embedding || [];
// Auto-select first available model for any empty slot
if (dit.length > 0 && !gp.ditModel) gp.setDitModel(dit[0]);
if (lm.length > 0 && !gp.lmModel) gp.setLmModel(lm[0]);
if (vae.length > 0 && !gp.vaeModel) gp.setVaeModel(vae[0]);
if (emb.length > 0 && !gp.embeddingModel) gp.setEmbeddingModel(emb[0]);
// If we got models, we're done. If empty, keep polling
// (user might be downloading via Model Manager right now)
if (dit.length === 0 && retries < MAX_RETRIES) {
retries++;
setTimeout(tryAutoSelect, 3000);
}
})
.catch(() => {
// Engine not ready yet — retry
if (!cancelled && retries < MAX_RETRIES) {
retries++;
setTimeout(tryAutoSelect, 3000);
}
});
};
// Initial check after a short delay (let engine boot)
setTimeout(tryAutoSelect, 1500);
return () => { cancelled = true; };
}, []);
// ── Auto-open Model Manager on first launch ──────────────────────
// Separate from auto-select — only opens the modal if, after giving
// the engine time to start, there are genuinely no models available.
useEffect(() => {
if (sessionStorage.getItem('mm-auto-dismissed')) return;
const timer = setTimeout(() => {
modelApi.list()
.then((data) => {
const allModels = [
...(data?.models?.dit || []),
...(data?.models?.lm || []),
...(data?.models?.vae || []),
];
if (allModels.length === 0) {
setShowModelManager(true);
}
})
.catch(() => {
// Engine still not running after 8s — likely no models at all
setShowModelManager(true);
});
}, 8000); // 8s delay: engine needs time to scan models + cuBLAS download
return () => clearTimeout(timer);
}, []);
const handleOpen = useCallback((id: SectionId) => {
setOpenSection(id);
}, []);
// Only close if the requesting section is still the one that's open.
// Prevents the leaving section's delayed close from killing a newly-opened neighbour.
const handleClose = useCallback((id: SectionId) => {
setOpenSection(prev => prev === id ? null : prev);
}, []);
return (
<div className="flex-shrink-0 relative z-40 bg-white/95 dark:bg-zinc-900/95 border-b border-zinc-200 dark:border-white/5"
style={{ backdropFilter: 'blur(20px)' }}>
<div className="flex items-stretch">
{/* Logo */}
<div className="flex items-center justify-center flex-shrink-0 border-r border-zinc-200 dark:border-white/5" style={{ width: '199px', backgroundColor: '#000' }}>
<img src="/logo.webp" alt="HOT-Step" style={{ width: '140px' }} className="h-auto object-contain" draggable={false} />
</div>
{/* Sections — separated by dividers */}
<div className="flex-1 flex items-stretch divide-x divide-white/5">
<DiscoPulseWrapper hue={0} stem="snare" className="flex-1 min-w-0">
<BarSection
id="models"
label={t('globalBar.models')}
icon={<Cpu size={14} />}
badge={<ModelsBadge />}
accentColor="pink"
isOpen={openSection === 'models'}
onOpen={() => handleOpen('models')}
onClose={() => handleClose('models')}
>
<ModelsDropdown />
</BarSection>
</DiscoPulseWrapper>
<DiscoPulseWrapper hue={72} stem="snare" className="flex-1 min-w-0">
<BarSection
id="adapters"
label={t('globalBar.adapters')}
icon={<Plug size={14} />}
badge={<AdaptersBadge />}
accentColor="emerald"
isOpen={openSection === 'adapters'}
onOpen={() => handleOpen('adapters')}
onClose={() => handleClose('adapters')}
>
<AdaptersDropdown />
</BarSection>
</DiscoPulseWrapper>
<DiscoPulseWrapper hue={144} stem="snare" className="flex-1 min-w-0">
<BarSection
id="generation"
label={t('globalBar.generation')}
icon={<Sliders size={14} />}
badge={<GenerationBadge />}
accentColor="sky"
isOpen={openSection === 'generation'}
onOpen={() => handleOpen('generation')}
onClose={() => handleClose('generation')}
>
<GenerationDropdown />
</BarSection>
</DiscoPulseWrapper>
<DiscoPulseWrapper hue={216} stem="snare" className="flex-1 min-w-0">
<BarSection
id="lm"
label={t('globalBar.lm')}
icon={<Brain size={14} />}
badge={<LmThinkingBadge />}
accentColor="purple"
isOpen={openSection === 'lm'}
onOpen={() => handleOpen('lm')}
onClose={() => handleClose('lm')}
headerToggle={
<ToggleSwitch
checked={!gp.skipLm}
onChange={(on) => gp.setSkipLm(!on)}
accentColor="purple"
/>
}
>
<LmThinkingDropdown />
</BarSection>
</DiscoPulseWrapper>
<DiscoPulseWrapper hue={288} stem="snare" className="flex-1 min-w-0">
<BarSection
id="postprocessing"
label={t('globalBar.postProcessing')}
icon={<AudioWaveform size={14} />}
badge={<PostProcessingBadge />}
accentColor="amber"
isOpen={openSection === 'postprocessing'}
onOpen={() => handleOpen('postprocessing')}
onClose={() => handleClose('postprocessing')}
headerToggle={
<ToggleSwitch
checked={gp.postProcessingEnabled}
onChange={(on) => gp.setPostProcessingEnabled(on)}
accentColor="amber"
/>
}
>
<PostProcessingDropdown />
</BarSection>
</DiscoPulseWrapper>
</div>
{/* Right — MonitorBar when active, otherwise Export/Import + VRAM */}
<div className={`flex items-center gap-2 flex-shrink-0 px-3 border-l border-zinc-200 dark:border-white/5 transition-all overflow-hidden ${monitoring ? 'w-[300px]' : 'w-[240px]'}`}>
{monitoring ? (
<MonitorBar />
) : (
<>
{/* Mini version of the BarSection tabs to the left */}
<button onClick={() => setShowProfiles(true)} title={t('globalBar.profiles')}
className="group flex items-center gap-1.5 px-2 py-1.5 rounded-lg hover:bg-pink-500/10 transition-colors duration-150">
<Bookmark size={13} className="flex-shrink-0 text-zinc-500 group-hover:text-pink-400 transition-colors duration-150" />
<span className="text-[10px] font-semibold uppercase tracking-wider text-zinc-600 dark:text-zinc-400 group-hover:text-zinc-800 dark:group-hover:text-zinc-200 transition-colors duration-150">
{t('globalBar.profiles')}
</span>
</button>
<div className="w-px h-4 bg-white/5" />
<VramIndicator compact />
</>
)}
</div>
</div>
{/* Parameter Profiles Modal */}
{showProfiles && <ProfilesModal onClose={() => setShowProfiles(false)} />}
{/* Model Manager Modal — rendered here (always mounted) so auto-open works */}
{showModelManager && (
<ModelManagerModal onClose={() => {
setShowModelManager(false);
sessionStorage.setItem('mm-auto-dismissed', '1');
}} />
)}
</div>
);
};
@@ -0,0 +1,134 @@
// LmThinkingDropdown.tsx — LM / Thinking settings for the global param bar
//
// The on/off toggle is in the bar header (ToggleSwitch).
// This dropdown only shows the detailed LM parameters when LM is enabled.
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Save } from 'lucide-react';
import { useGlobalParams } from '../../context/GlobalParamsContext';
import { Slider } from '../shared/Slider';
import { ToggleSwitch } from './BarSection';
import { SeedManagerDrawer } from './SeedManagerDrawer';
const inputClasses = "w-full px-3 py-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none transition-colors";
/** Seed input with local string buffer — prevents parseInt("-") snap-back */
const SeedInput: React.FC<{ value: number; onChange: (v: number) => void; className: string }> = ({ value, onChange, className }) => {
const [local, setLocal] = useState(String(value));
useEffect(() => { setLocal(String(value)); }, [value]);
const commit = () => { onChange(parseInt(local) || 42); };
return (
<input type="number" className={className} value={local}
onChange={e => setLocal(e.target.value)}
onBlur={commit}
onKeyDown={e => { if (e.key === 'Enter') commit(); }}
/>
);
};
export const LmThinkingDropdown: React.FC = () => {
const gp = useGlobalParams();
const { t } = useTranslation();
const [seedDrawerOpen, setSeedDrawerOpen] = useState(false);
if (gp.skipLm) {
return (
<div className="text-xs text-zinc-500 italic text-center py-2">
{t('lm.disabled')}
</div>
);
}
return (
<div className="space-y-3">
{/* CoT Caption */}
<div className="flex items-center justify-between">
<span className="text-sm text-zinc-600 dark:text-zinc-400">{t('lm.cotCaption')}</span>
<ToggleSwitch checked={gp.useCotCaption} onChange={gp.setUseCotCaption} accentColor="purple" />
</div>
<Slider label="Temperature" value={gp.lmTemperature}
onChange={gp.setLmTemperature} min={0} max={2} step={0.01} showInput />
<Slider label="CFG Scale" value={gp.lmCfgScale}
onChange={gp.setLmCfgScale} min={0} max={10} step={0.1} showInput />
<Slider label="Top-K" value={gp.lmTopK}
onChange={gp.setLmTopK} min={0} max={200} step={1} showInput />
<Slider label="Top-P" value={gp.lmTopP}
onChange={gp.setLmTopP} min={0} max={1} step={0.01} showInput />
{/* Anti-loop: windowed repetition penalty on audio-code sampling.
1.0 = off. Breaks the stuck-loop failure mode (planner adapters
sharpen the code distribution into repetition attractors). */}
<Slider label="Repetition Penalty" value={gp.lmRepPenalty}
onChange={gp.setLmRepPenalty} min={1.0} max={1.5} step={0.01} showInput />
{gp.lmRepPenalty > 1.0 && (
<Slider label="Rep. Window (codes)" value={gp.lmRepWindow}
onChange={gp.setLmRepWindow} min={8} max={256} step={8} showInput />
)}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">{t('lm.negativePrompt')}</label>
<input className={inputClasses} value={gp.lmNegativePrompt}
onChange={e => gp.setLmNegativePrompt(e.target.value)}
placeholder="NO USER INPUT" />
</div>
<Slider label="LM Codes Strength" value={gp.lmCodesStrength}
onChange={gp.setLmCodesStrength} min={0} max={1} step={0.05} showInput />
{/* LM Seed — independent from the Generation (DiT) seed by default,
unless "Use DiT Seed" is on, which ties lm_seed to the DiT seed
(the original engine behavior: locked seed -> both deterministic,
random -> both random). */}
<div className="relative">
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5">
<label className="text-xs font-medium text-zinc-500 uppercase tracking-wider">LM Seed</label>
<button onClick={() => setSeedDrawerOpen(true)} title="Seed Manager"
className="text-zinc-500 hover:text-amber-400 transition-colors">
<Save size={12} />
</button>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-zinc-500">Use DiT Seed</span>
<ToggleSwitch checked={gp.lmSeedFollowsDit} onChange={gp.setLmSeedFollowsDit} accentColor="sky" />
</div>
</div>
{!gp.lmSeedFollowsDit && (
<SeedInput value={gp.lmSeed} onChange={gp.setLmSeed} className={inputClasses} />
)}
<p className="text-[10px] text-zinc-500 mt-1">
{gp.lmSeedFollowsDit
? 'Tied to the Generation seed — locked seed means both are deterministic, random means both are random.'
: 'Drives caption/lyrics/audio-code sampling independently of the Generation seed.'}
</p>
<SeedManagerDrawer
isOpen={seedDrawerOpen}
onClose={() => setSeedDrawerOpen(false)}
currentSeed={gp.lmSeed}
onLoad={(seed) => { gp.setLmSeed(seed); gp.setLmSeedFollowsDit(false); setSeedDrawerOpen(false); }}
onLoadRandom={(seed) => { gp.setLmSeed(seed); gp.setLmSeedFollowsDit(false); }}
/>
</div>
</div>
);
};
/** Summary badge for the LM / Thinking section */
export const LmThinkingBadge: React.FC = () => {
const { skipLm, useCotCaption, lmTemperature, lmCfgScale, lmCodesStrength, lmSeedFollowsDit } = useGlobalParams();
if (skipLm) return null;
const seedLabel = lmSeedFollowsDit ? 'DiT' : 'Fix';
return (
<span className="text-[10px] text-zinc-500 font-mono truncate">
{useCotCaption ? 'CoT · ' : ''}T{lmTemperature.toFixed(2)} · CFG {lmCfgScale.toFixed(1)}{lmCodesStrength < 1.0 ? ` · CS ${lmCodesStrength.toFixed(2)}` : ''} · Seed {seedLabel}
</span>
);
};
@@ -0,0 +1,187 @@
// MasteringDropdown.tsx — Mastering config for the global param bar
//
// The on/off toggle is in the bar header (ToggleSwitch).
// This dropdown shows reference track selection and options when mastering is enabled.
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Upload, Trash2, Music2 } from 'lucide-react';
import { useGlobalParams } from '../../context/GlobalParamsContext';
import { masteringApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import { ToggleSwitch } from './BarSection';
import { formatReferenceName } from './modelLabels';
interface ReferenceTrack {
name: string;
size: number;
url: string;
}
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
export const MasteringDropdown: React.FC = () => {
const gp = useGlobalParams();
const { t } = useTranslation();
const { token } = useAuth();
const [references, setReferences] = useState<ReferenceTrack[]>([]);
const [uploading, setUploading] = useState(false);
useEffect(() => {
masteringApi.listReferences()
.then(data => setReferences(data.references))
.catch(() => {});
}, []);
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !token) return;
try {
setUploading(true);
const result = await masteringApi.uploadReference(file, token);
gp.setMasteringReference(result.name);
const data = await masteringApi.listReferences();
setReferences(data.references);
} catch (err) {
console.error('[Mastering] Upload failed:', err);
} finally {
setUploading(false);
e.target.value = '';
}
}, [token, gp]);
const handleDelete = useCallback(async (name: string) => {
if (!token) return;
try {
await masteringApi.deleteReference(name, token);
if (gp.masteringReference === name) gp.setMasteringReference('');
const data = await masteringApi.listReferences();
setReferences(data.references);
} catch (err) {
console.error('[Mastering] Delete failed:', err);
}
}, [token, gp]);
if (!gp.masteringEnabled) {
return (
<div className="text-xs text-zinc-500 italic text-center py-2">
{t('mastering.disabled')}
</div>
);
}
return (
<div className="space-y-3">
{/* Reference selector */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">
{t('mastering.referenceTrack')}
</label>
{references.length > 0 ? (
<select
className="w-full px-3 py-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 focus:border-amber-500/50 focus:ring-1 focus:ring-amber-500/20 outline-none transition-colors cursor-pointer"
value={gp.masteringReference}
onChange={e => gp.setMasteringReference(e.target.value)}
>
<option value="">{t('mastering.selectReference')}</option>
{references.map(r => (
<option key={r.name} value={r.name}>
{r.name} ({formatFileSize(r.size)})
</option>
))}
</select>
) : (
<div className="text-xs text-zinc-500 italic px-1">
{t('mastering.noReferencesYet')}
</div>
)}
</div>
{/* Selected reference info + delete */}
{gp.masteringReference && (
<div className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-amber-500/5 border border-amber-500/10">
<Music2 size={14} className="text-amber-400 flex-shrink-0" />
<span className="text-xs text-amber-300 truncate flex-1">{gp.masteringReference}</span>
<button
onClick={() => handleDelete(gp.masteringReference)}
className="p-1 rounded hover:bg-red-500/10 text-zinc-500 hover:text-red-400 transition-colors flex-shrink-0"
title={t('mastering.deleteReference')}
>
<Trash2 size={12} />
</button>
</div>
)}
{/* Upload button */}
<div className="flex items-center gap-2">
<input
type="file"
accept="audio/*"
id="mastering-ref-upload-bar"
className="hidden"
onChange={handleUpload}
/>
<label
htmlFor="mastering-ref-upload-bar"
className={`flex items-center gap-2 px-3 py-2 text-xs font-semibold rounded-xl border cursor-pointer transition-all ${
uploading
? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500 border-zinc-200 dark:border-white/5 cursor-wait'
: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-white/10 hover:border-amber-500/30 hover:text-amber-400'
}`}
>
{uploading ? (
<><span className="w-3 h-3 border-2 border-zinc-500 border-t-transparent rounded-full animate-spin" /> Uploading...</>
) : (
<><Upload size={14} /> {t('mastering.uploadReference')}</>
)}
</label>
</div>
{/* Timbre reference toggle */}
{gp.masteringReference && (
gp.timbreAudioPath ? (
<div className="flex items-center gap-1.5 mt-1 px-2 py-1.5 rounded-lg bg-teal-500/5 border border-teal-500/10">
<Music2 size={14} className="text-teal-400" />
<span className="text-[10px] text-teal-400">Timbre: using dedicated reference ({gp.timbreAudioPath.split(/[\\/]/).pop()})</span>
</div>
) : (
<div className="flex items-center justify-between mt-1">
<div className="flex items-center gap-1.5">
<Music2 size={14} className="text-teal-400" />
<span className="text-sm text-zinc-600 dark:text-zinc-400">{t('mastering.alsoTimbreRef')}</span>
</div>
<ToggleSwitch checked={gp.timbreReference} onChange={gp.setTimbreReference} accentColor="amber" />
</div>
)
)}
{gp.timbreReference && gp.masteringReference && !gp.timbreAudioPath && (
<p className="text-[10px] text-zinc-600 leading-relaxed">
The reference track will be VAE-encoded and fed into the timbre conditioning pipeline,
guiding the generation&apos;s tone and texture to match the reference.
</p>
)}
{/* Info */}
<p className="text-[10px] text-zinc-600 leading-relaxed">
The generated audio will be mastered to match the RMS level, frequency spectrum,
and dynamic characteristics of the reference track.
</p>
</div>
);
};
/** Summary badge for the Mastering section */
export const MasteringBadge: React.FC = () => {
const { masteringEnabled, masteringReference } = useGlobalParams();
if (!masteringEnabled) return null;
const refName = formatReferenceName(masteringReference);
return (
<span className="text-[10px] text-amber-400/60 font-mono truncate">
{refName || 'No ref'}
</span>
);
};
@@ -0,0 +1,266 @@
// ModelSelect.tsx — Custom dropdown for model selection with format badges
//
// Replaces native <select> to allow rich rendering of options with
// GGUF/SafeTensors format indicators. Uses click-outside and keyboard
// navigation for accessibility.
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { ChevronDown, Check, Search } from 'lucide-react';
/** Detect model format from the raw model name/path.
* ONNX detection: .onnx extension OR known ONNX directory name patterns
* (the C++ registry registers ONNX subdirectories by directory name). */
export function getModelFormat(name: string): 'gguf' | 'safetensors' | 'onnx' {
if (/\.onnx$/i.test(name)) return 'onnx';
if (/\.gguf$/i.test(name)) return 'gguf';
// ONNX model directories: names like 'lm-4B', 'dit-xl', etc.
// that don't have .gguf or .safetensors extensions
// and don't match safetensors naming patterns (acestep-*, Qwen3-*, vae*, scragvae*)
const lower = name.toLowerCase();
if (/^lm-\d/i.test(name)) return 'onnx';
if (/^dit-/i.test(name) && !lower.includes('acestep')) return 'onnx';
if (/^vae-/i.test(name) && !lower.endsWith('.safetensors')) return 'onnx';
if (/^text[_-]enc/i.test(name)) return 'onnx';
return 'safetensors';
}
/** Middle-truncate a long label so BOTH ends stay visible (the tail often holds
* the distinguishing suffix, e.g. "…-xlremap-s0.3"). CSS truncates only the
* right, which hides exactly that. Returns the string unchanged if short. */
export function middleEllipsis(s: string, max = 40): string {
if (s.length <= max) return s;
const keep = max - 1; // room for the ellipsis
const head = Math.ceil(keep * 0.55);
const tail = keep - head;
return `${s.slice(0, head)}${s.slice(s.length - tail)}`;
}
interface FormatBadgeProps {
format: 'gguf' | 'safetensors' | 'onnx';
compact?: boolean;
}
/** Tiny pill showing GGUF, ST, or ONNX format */
export const FormatBadge: React.FC<FormatBadgeProps> = ({ format, compact }) => {
const colorClass = format === 'gguf'
? 'bg-sky-500/15 text-sky-400 ring-1 ring-sky-500/20'
: format === 'onnx'
? 'bg-emerald-500/15 text-emerald-400 ring-1 ring-emerald-500/20'
: 'bg-amber-500/15 text-amber-400 ring-1 ring-amber-500/20';
const icon = format === 'gguf' ? '◆' : format === 'onnx' ? '⬡' : '◈';
const label = format === 'gguf' ? (compact ? 'GG' : 'GGUF')
: format === 'onnx' ? (compact ? 'OX' : 'ONNX')
: (compact ? 'ST' : 'ST');
const title = format === 'gguf' ? 'GGUF quantized format'
: format === 'onnx' ? 'ONNX TensorRT-accelerated format'
: 'SafeTensors native format';
return (
<span
className={`inline-flex items-center gap-0.5 shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold leading-none tracking-wide uppercase ${colorClass}`}
title={title}
>
<span className="text-[9px]">{icon}</span>
{label}
</span>
);
};
interface ModelSelectProps {
value: string;
onChange: (v: string) => void;
options: string[];
formatLabel?: (name: string) => string;
placeholder?: string;
id?: string;
}
export const ModelSelect: React.FC<ModelSelectProps> = ({
value,
onChange,
options,
formatLabel = (n) => n,
placeholder = 'Select model…',
id,
}) => {
const [open, setOpen] = useState(false);
const [focusIdx, setFocusIdx] = useState(-1);
const [query, setQuery] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Text-filtered options: case-insensitive substring match against the raw
// model name and its formatted label.
const q = query.trim().toLowerCase();
const filtered = q
? options.filter((o) => o.toLowerCase().includes(q) || formatLabel(o).toLowerCase().includes(q))
: options;
// Close on click outside
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// On open: reset the query and focus the filter input. On close: clear query.
useEffect(() => {
if (open) {
setQuery('');
setFocusIdx(Math.max(0, options.indexOf(value)));
const t = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(t);
}
setQuery('');
}, [open]);
// Scroll focused item into view
useEffect(() => {
if (!open || focusIdx < 0 || !listRef.current) return;
const el = listRef.current.children[focusIdx] as HTMLElement | undefined;
el?.scrollIntoView({ block: 'nearest' });
}, [focusIdx, open]);
// Trigger-button keys: only used to open the dropdown.
const handleTriggerKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!open && (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
setOpen(true);
}
},
[open]
);
// Filter-input keys: navigate + select within the filtered list.
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setFocusIdx((i) => Math.min(i + 1, filtered.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setFocusIdx((i) => Math.max(i - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (focusIdx >= 0 && focusIdx < filtered.length) {
onChange(filtered[focusIdx]);
setOpen(false);
}
break;
case 'Escape':
e.preventDefault();
setOpen(false);
break;
}
},
[focusIdx, filtered, onChange]
);
const selectedFormat = value ? getModelFormat(value) : null;
return (
<div ref={containerRef} className="relative" id={id}>
{/* Trigger button */}
<button
type="button"
onClick={() => setOpen(!open)}
onKeyDown={handleTriggerKeyDown}
title={value || undefined}
className="w-full flex items-center gap-2 px-3 py-2 rounded-xl
bg-zinc-100 dark:bg-zinc-800
border border-zinc-300 dark:border-white/10
text-sm text-zinc-800 dark:text-zinc-200
hover:border-zinc-400 dark:hover:border-white/20
focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20
outline-none transition-colors cursor-pointer"
>
{value ? (
<>
{selectedFormat && <FormatBadge format={selectedFormat} compact />}
<span className="truncate flex-1 text-left">{middleEllipsis(formatLabel(value))}</span>
</>
) : (
<span className="truncate flex-1 text-left text-zinc-400">{placeholder}</span>
)}
<ChevronDown
size={14}
className={`shrink-0 text-zinc-400 transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
/>
</button>
{/* Dropdown panel: filter box + scrollable list */}
{open && (
<div
className="absolute z-50 mt-1 w-full rounded-xl
bg-white dark:bg-zinc-800
border border-zinc-200 dark:border-white/10
shadow-lg shadow-black/20"
>
{/* Text filter */}
<div className="p-1.5 border-b border-zinc-200 dark:border-white/10">
<div className="relative">
<Search size={13} className="absolute left-2 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => { setQuery(e.target.value); setFocusIdx(0); }}
onKeyDown={handleInputKeyDown}
placeholder="Filter models…"
className="w-full pl-7 pr-2 py-1.5 rounded-lg
bg-zinc-100 dark:bg-zinc-900
border border-zinc-200 dark:border-white/10
text-sm text-zinc-800 dark:text-zinc-200 placeholder-zinc-400
outline-none focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20"
/>
</div>
</div>
{/* Filtered list */}
<div ref={listRef} className="max-h-56 overflow-auto py-1" role="listbox">
{filtered.length === 0 ? (
<div className="px-3 py-2 text-sm text-zinc-400">No models match.</div>
) : (
filtered.map((opt, i) => {
const fmt = getModelFormat(opt);
const selected = opt === value;
const focused = i === focusIdx;
return (
<button
key={opt}
type="button"
role="option"
aria-selected={selected}
onClick={() => {
onChange(opt);
setOpen(false);
}}
onMouseEnter={() => setFocusIdx(i)}
title={opt}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors
${focused ? 'bg-pink-500/10 dark:bg-pink-500/15' : ''}
${selected ? 'text-pink-400' : 'text-zinc-700 dark:text-zinc-200'}
hover:bg-pink-500/10 dark:hover:bg-pink-500/15`}
>
<FormatBadge format={fmt} />
<span className="truncate flex-1">{middleEllipsis(formatLabel(opt), 48)}</span>
{selected && <Check size={14} className="shrink-0 text-pink-400" />}
</button>
);
})
)}
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,221 @@
// ModelsDropdown.tsx — Model selection UI for the global param bar
//
// Uses custom ModelSelect dropdown to show GGUF/SafeTensors format badges.
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Download } from 'lucide-react';
import { useGlobalParams } from '../../context/GlobalParamsContext';
import { modelApi } from '../../services/api';
import { formatDitModel, formatLmModel, formatVaeModel, formatEmbeddingModel, getDitModelDescription, getLmModelDescription, getVaeModelDescription } from './modelLabels';
import { ModelManagerModal } from '../model-manager/ModelManagerModal';
import { ModelSelect, getModelFormat } from './ModelSelect';
import type { AceModels } from '../../types';
type ModelFormat = 'gguf' | 'safetensors' | 'onnx';
const FORMAT_ORDER: ModelFormat[] = ['gguf', 'safetensors', 'onnx'];
const FORMAT_LABELS: Record<ModelFormat, string> = { gguf: 'GGUF', safetensors: 'SafeTensors', onnx: 'ONNX' };
const FORMAT_FILTER_KEY = 'model-format-filter';
function loadFormatFilter(): Record<ModelFormat, boolean> {
const allOn = { gguf: true, safetensors: true, onnx: true };
try {
const raw = localStorage.getItem(FORMAT_FILTER_KEY);
if (!raw) return allOn;
const parsed = JSON.parse(raw);
return {
gguf: parsed.gguf !== false,
safetensors: parsed.safetensors !== false,
onnx: parsed.onnx !== false,
};
} catch {
return allOn;
}
}
export const ModelsDropdown: React.FC = () => {
const gp = useGlobalParams();
const { t } = useTranslation();
const [models, setModels] = useState<AceModels | null>(null);
const [showModelManager, setShowModelManager] = useState(false);
useEffect(() => {
modelApi.list()
.then(setModels)
.catch(() => {});
}, []);
// Auto-select first available model when list loads and nothing is selected
useEffect(() => {
if (!models?.models) return;
const dit = models.models.dit || [];
const lm = models.models.lm || [];
const vae = models.models.vae || [];
const emb = models.models.embedding || [];
if (dit.length > 0 && (!gp.ditModel || !dit.includes(gp.ditModel))) {
gp.setDitModel(dit[0]);
}
if (lm.length > 0 && (!gp.lmModel || !lm.includes(gp.lmModel))) {
gp.setLmModel(lm[0]);
}
if (vae.length > 0 && (!gp.vaeModel || !vae.includes(gp.vaeModel))) {
gp.setVaeModel(vae[0]);
}
if (emb.length > 0 && (!gp.embeddingModel || !emb.includes(gp.embeddingModel))) {
gp.setEmbeddingModel(emb[0]);
}
}, [models]);
const ditModels = models?.models?.dit || [];
const lmModels = models?.models?.lm || [];
const vaeModels = models?.models?.vae || [];
const embeddingModels = models?.models?.embedding || [];
// Format-type filter (gguf / safetensors / onnx). Persisted across sessions.
// Only affects which options the dropdowns show — not auto-selection, so a
// selected model stays selected even when its format is filtered out.
const [formatFilter, setFormatFilter] = useState<Record<ModelFormat, boolean>>(loadFormatFilter);
useEffect(() => {
try { localStorage.setItem(FORMAT_FILTER_KEY, JSON.stringify(formatFilter)); } catch { /* ignore */ }
}, [formatFilter]);
// Only surface chips for formats that actually exist across the available models.
const presentFormats = new Set<ModelFormat>(
[...ditModels, ...lmModels, ...vaeModels, ...embeddingModels].map(getModelFormat)
);
const chipFormats = FORMAT_ORDER.filter((f) => presentFormats.has(f));
const byFormat = (list: string[]) => list.filter((m) => formatFilter[getModelFormat(m)]);
const toggleFormat = (f: ModelFormat) =>
setFormatFilter((prev) => ({ ...prev, [f]: !prev[f] }));
return (
<div className="space-y-3">
{/* Format-type filter — toggles which model formats appear in the dropdowns */}
{chipFormats.length > 1 && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[10px] font-medium text-zinc-500 uppercase tracking-wider mr-0.5">Format</span>
{chipFormats.map((f) => {
const active = formatFilter[f];
return (
<button
key={f}
type="button"
onClick={() => toggleFormat(f)}
aria-pressed={active}
title={active ? `Hide ${FORMAT_LABELS[f]} models` : `Show ${FORMAT_LABELS[f]} models`}
className={`px-2 py-0.5 rounded-md text-[10px] font-semibold uppercase tracking-wide border transition-colors
${active
? 'bg-pink-500/15 text-pink-400 border-pink-500/30'
: 'bg-transparent text-zinc-500 border-zinc-300 dark:border-white/10 hover:text-zinc-400'}`}
>
{FORMAT_LABELS[f]}
</button>
);
})}
</div>
)}
{/* DiT Model */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">{t('models.ditModel')}</label>
<ModelSelect
id="dit-model-select"
value={gp.ditModel}
onChange={gp.setDitModel}
options={byFormat(ditModels)}
formatLabel={formatDitModel}
placeholder={t('common.loading')}
/>
{getDitModelDescription(gp.ditModel) && (
<p className="text-[10px] text-zinc-500 mt-1.5 leading-relaxed">{getDitModelDescription(gp.ditModel)}</p>
)}
</div>
{/* LM Model */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">{t('models.lmModel')}</label>
<ModelSelect
id="lm-model-select"
value={gp.lmModel}
onChange={gp.setLmModel}
options={byFormat(lmModels)}
formatLabel={formatLmModel}
placeholder={t('common.loading')}
/>
{getLmModelDescription(gp.lmModel) && (
<p className="text-[10px] text-zinc-500 mt-1.5 leading-relaxed">{getLmModelDescription(gp.lmModel)}</p>
)}
</div>
{/* Planner Adapter (LM) moved to the Adapters dropdown, alongside the
DiT adapters it pairs with. */}
{/* VAE Model — only show when multiple VAEs are available */}
{vaeModels.length > 1 && (
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">{t('models.vaeDecoder')}</label>
<ModelSelect
id="vae-model-select"
value={gp.vaeModel}
onChange={gp.setVaeModel}
options={byFormat(vaeModels)}
formatLabel={formatVaeModel}
placeholder={t('common.loading')}
/>
{getVaeModelDescription(gp.vaeModel) && (
<p className="text-[10px] text-zinc-500 mt-1.5 leading-relaxed">{getVaeModelDescription(gp.vaeModel)}</p>
)}
</div>
)}
{/* Text Encoder — only show when multiple are available */}
{embeddingModels.length > 1 && (
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">{t('models.textEncoder')}</label>
<ModelSelect
id="embedding-model-select"
value={gp.embeddingModel}
onChange={gp.setEmbeddingModel}
options={byFormat(embeddingModels)}
formatLabel={formatEmbeddingModel}
placeholder={t('common.loading')}
/>
</div>
)}
{/* Get More Models */}
<div className="border-t border-zinc-200 dark:border-white/5 pt-3 mt-1">
<button
onClick={() => setShowModelManager(true)}
className="w-full px-3 py-2 rounded-xl bg-pink-500/10 border border-pink-500/20
text-sm text-pink-400 hover:bg-pink-500/20 hover:text-pink-300
transition-colors flex items-center justify-center gap-2"
>
<Download size={14} />
{t('models.getMoreModels')}
</button>
</div>
{/* Model Manager Modal */}
{showModelManager && (
<ModelManagerModal onClose={() => {
setShowModelManager(false);
sessionStorage.setItem('mm-auto-dismissed', '1');
}} />
)}
</div>
);
};
/** Summary badge for the Models section */
export const ModelsBadge: React.FC = () => {
const { ditModel, lmModel, vaeModel } = useGlobalParams();
return (
<span className="text-[10px] text-zinc-500 font-mono truncate">
{formatDitModel(ditModel)} · {formatLmModel(lmModel)} · {formatVaeModel(vaeModel)}
</span>
);
};
+100
View File
@@ -0,0 +1,100 @@
// MonitorBar.tsx Persistent VST monitor status strip
//
// Always polls /api/vst/monitor/status (slow when idle, fast when active)
// so it self-activates after page reload even if monitor was already running.
import React, { useEffect } from 'react';
import { Square, Pause, Play } from 'lucide-react';
import { useVstChainStore } from '../../stores/vstChainStore';
import { usePlaybackSelector } from '../../stores/playbackStore';
function formatTime(s: number): string {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
export const MonitorBar: React.FC = () => {
const {
monitoring, monitorPaused,
monitorPosition, monitorDuration,
stopMonitor, pauseMonitor, resumeMonitor,
seekMonitor, pollMonitorStatus,
} = useVstChainStore();
const currentTrack = usePlaybackSelector(s => s.currentTrack);
// Always poll — discovers monitor running after page reload.
// Slow (2s) when idle to save requests, fast (300ms) when active.
useEffect(() => {
// Fire once immediately so we don't wait for first interval tick
pollMonitorStatus();
const id = setInterval(() => pollMonitorStatus(), monitoring ? 300 : 2000);
return () => clearInterval(id);
}, [monitoring, pollMonitorStatus]);
if (!monitoring) return null;
const progress = monitorDuration > 0
? Math.min(100, (monitorPosition / monitorDuration) * 100)
: 0;
return (
<div className="flex items-center gap-2 w-full min-w-0 overflow-hidden px-2.5 py-1.5 rounded-xl bg-violet-500/10 border border-violet-500/25">
{/* Live / paused indicator */}
{monitorPaused ? (
<span className="h-2 w-2 rounded-full bg-violet-500/40 flex-shrink-0" />
) : (
<span className="relative flex h-2 w-2 flex-shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-violet-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-violet-500" />
</span>
)}
{/* Track name */}
<span className="text-[10px] text-violet-300 font-mono truncate max-w-[72px]">
{currentTrack?.title || 'monitor'}
</span>
{/* Seek slider */}
{monitorDuration > 0 && (
<input
type="range"
min={0}
max={monitorDuration}
step={0.5}
value={monitorPosition}
onChange={e => seekMonitor(parseFloat(e.target.value))}
className="flex-1 min-w-0 h-1 rounded-full appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, rgb(139 92 246) ${progress}%, rgb(63 63 70) ${progress}%)`,
}}
/>
)}
{/* Time */}
<span className="text-[10px] text-violet-300/60 font-mono flex-shrink-0 tabular-nums">
{formatTime(monitorPosition)}
{monitorDuration > 0 && <span className="text-violet-500/40">/{formatTime(monitorDuration)}</span>}
</span>
{/* Pause / Resume */}
<button
onClick={monitorPaused ? resumeMonitor : pauseMonitor}
title={monitorPaused ? 'Resume' : 'Pause'}
className="p-1 rounded hover:bg-violet-500/20 text-violet-400 hover:text-violet-200 transition-colors flex-shrink-0"
>
{monitorPaused ? <Play size={11} /> : <Pause size={11} />}
</button>
{/* Stop */}
<button
onClick={stopMonitor}
title="Stop monitor"
className="p-1 rounded hover:bg-red-500/10 text-violet-400 hover:text-red-400 transition-colors flex-shrink-0"
>
<Square size={11} />
</button>
</div>
);
};
@@ -0,0 +1,182 @@
// PluginControls.tsx — Dynamic UI controls rendered from Lua plugin param schemas
//
// Takes a plugin's `params` array and renders the appropriate controls
// (sliders, selects, toggles, text inputs) with a Reset button.
// Values are stored in a flat { "pluginName:key": value } map.
//
// Renders as a collapsible accordion, collapsed by default.
// Open/closed state is persisted per-plugin via localStorage.
import React from 'react';
import { RotateCcw, ChevronDown } from 'lucide-react';
import { Slider } from '../shared/Slider';
import { usePersistedState } from '../../hooks/usePersistedState';
import type { PluginParamSchema } from '../../types/pluginTypes';
const selectClasses = "w-full px-3 py-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none transition-colors cursor-pointer";
const inputClasses = "w-full px-3 py-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none transition-colors";
// Accent color mapping — plugins declare an accent name, we map to Tailwind
const accentMap: Record<string, { border: string; bg: string; text: string; hover: string }> = {
amber: { border: 'border-amber-500/20', bg: 'bg-amber-500/5', text: 'text-amber-400', hover: 'hover:text-amber-300' },
cyan: { border: 'border-cyan-500/20', bg: 'bg-cyan-500/5', text: 'text-cyan-400', hover: 'hover:text-cyan-300' },
blue: { border: 'border-blue-500/20', bg: 'bg-blue-500/5', text: 'text-blue-400', hover: 'hover:text-blue-300' },
teal: { border: 'border-teal-500/20', bg: 'bg-teal-500/5', text: 'text-teal-400', hover: 'hover:text-teal-300' },
green: { border: 'border-green-500/20', bg: 'bg-green-500/5', text: 'text-green-400', hover: 'hover:text-green-300' },
emerald: { border: 'border-emerald-500/20', bg: 'bg-emerald-500/5', text: 'text-emerald-400', hover: 'hover:text-emerald-300' },
purple: { border: 'border-purple-500/20', bg: 'bg-purple-500/5', text: 'text-purple-400', hover: 'hover:text-purple-300' },
indigo: { border: 'border-indigo-500/20', bg: 'bg-indigo-500/5', text: 'text-indigo-400', hover: 'hover:text-indigo-300' },
orange: { border: 'border-orange-500/20', bg: 'bg-orange-500/5', text: 'text-orange-400', hover: 'hover:text-orange-300' },
pink: { border: 'border-pink-500/20', bg: 'bg-pink-500/5', text: 'text-pink-400', hover: 'hover:text-pink-300' },
rose: { border: 'border-rose-500/20', bg: 'bg-rose-500/5', text: 'text-rose-400', hover: 'hover:text-rose-300' },
sky: { border: 'border-sky-500/20', bg: 'bg-sky-500/5', text: 'text-sky-400', hover: 'hover:text-sky-300' },
violet: { border: 'border-violet-500/20', bg: 'bg-violet-500/5', text: 'text-violet-400', hover: 'hover:text-violet-300' },
};
const defaultAccent = accentMap.cyan;
interface PluginControlsProps {
pluginName: string;
displayName: string;
accent?: string;
params: PluginParamSchema[];
values: Record<string, string>;
onChange: (key: string, value: string) => void;
onReset: () => void;
}
export const PluginControls: React.FC<PluginControlsProps> = ({
pluginName,
displayName,
accent,
params,
values,
onChange,
onReset,
}) => {
const [isOpen, setIsOpen] = usePersistedState(`hs-pluginAccordion-${pluginName}`, false);
if (!params || params.length === 0) return null;
const a = (accent && accentMap[accent]) || defaultAccent;
// Get value for a param, falling back to its declared default
const getVal = (p: PluginParamSchema): string => {
const k = `${pluginName}:${p.key}`;
if (values[k] !== undefined) return values[k];
if (p.default !== undefined) return String(p.default);
if (p.type === 'slider') return String(p.min ?? 0);
if (p.type === 'toggle') return 'false';
return '';
};
// Check visibility condition
const isVisible = (p: PluginParamSchema): boolean => {
if (!p.visible_when) return true;
const depVal = getVal(params.find(pp => pp.key === p.visible_when!.key) || p);
return depVal === p.visible_when.equals;
};
const visibleParams = params.filter(isVisible);
if (visibleParams.length === 0) return null;
return (
<div className={`rounded-xl border ${a.border} ${a.bg} transition-all overflow-hidden`}>
{/* Accordion header */}
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={`w-full flex items-center justify-between px-3 py-2 ${a.hover.replace('hover:text-', 'hover:bg-').replace('300', '500/5')} transition-colors`}
>
<div className="flex items-center gap-2">
<ChevronDown size={12} className={`${a.text} transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} />
<span className={`text-[10px] font-semibold ${a.text} uppercase tracking-wider`}>
{displayName} Controls
</span>
</div>
<button type="button" onClick={(e) => { e.stopPropagation(); onReset(); }}
className={`flex items-center gap-1 text-[10px] ${a.text} ${a.hover} transition-colors`}>
<RotateCcw size={10} /> Reset
</button>
</button>
{/* Collapsible param content */}
{isOpen && (
<div className="px-3 pb-3 space-y-3">
{visibleParams.map(p => {
const val = getVal(p);
const fullKey = `${pluginName}:${p.key}`;
switch (p.type) {
case 'slider':
return (
<div key={p.key}>
<Slider
label={p.label}
value={parseFloat(val) || 0}
onChange={v => onChange(fullKey, String(v))}
min={p.min ?? 0}
max={p.max ?? 1}
step={p.step ?? 0.01}
showInput
/>
{p.hint && <p className="text-[10px] text-zinc-500 mt-0.5">{p.hint}</p>}
</div>
);
case 'select':
return (
<div key={p.key}>
<label className={`block text-[10px] ${a.text} mb-1`}>{p.label}</label>
<select
className={selectClasses}
value={val}
onChange={e => onChange(fullKey, e.target.value)}
>
{(p.options || []).map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{p.hint && <p className="text-[10px] text-zinc-500 mt-0.5">{p.hint}</p>}
</div>
);
case 'toggle':
return (
<div key={p.key} className="flex items-center justify-between">
<span className="text-xs text-zinc-400">{p.label}</span>
<button
type="button"
onClick={() => onChange(fullKey, val === 'true' ? 'false' : 'true')}
className={`w-9 h-5 rounded-full transition-colors ${
val === 'true' ? 'bg-pink-500' : 'bg-zinc-600'
} relative`}
>
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform ${
val === 'true' ? 'left-[18px]' : 'left-0.5'
}`} />
</button>
</div>
);
case 'text':
return (
<div key={p.key}>
<label className={`block text-[10px] ${a.text} mb-1`}>{p.label}</label>
<input
className={inputClasses}
value={val}
onChange={e => onChange(fullKey, e.target.value)}
placeholder={p.hint || ''}
/>
</div>
);
default:
return null;
}
})}
</div>
)}
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,306 @@
// ProfilesModal.tsx — named parameter-profile manager
//
// Lists server-stored profiles (every generation parameter as a raw
// snapshot — see utils/paramProfiles.ts) and lets the user save the
// current config under a name, apply, overwrite, or delete. Applying is
// live: no page reload, CreatePanel content included.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Bookmark, Check, ChevronDown, ChevronRight, Download, Pencil, RefreshCw, Save, Trash2, Upload, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { profileApi, type ParamProfile } from '../../services/api';
import { applyProfileData, collectProfileData, describeProfileGroups, summarizeProfile } from '../../utils/paramProfiles';
import { ConfirmDialog } from '../shared/ConfirmDialog';
interface ProfilesModalProps {
onClose: () => void;
}
export const ProfilesModal: React.FC<ProfilesModalProps> = ({ onClose }) => {
const { t } = useTranslation();
const [profiles, setProfiles] = useState<ParamProfile[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [newName, setNewName] = useState('');
const [appliedName, setAppliedName] = useState('');
const [confirmAction, setConfirmAction] = useState<{ kind: 'delete' | 'overwrite' | 'import'; name: string; data?: Record<string, unknown> } | null>(null);
const [expandedName, setExpandedName] = useState('');
const [renamingName, setRenamingName] = useState('');
const [renameValue, setRenameValue] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const refresh = useCallback(() => {
profileApi.list()
.then(r => { setProfiles(r.profiles); setError(''); })
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, []);
useEffect(() => { refresh(); }, [refresh]);
const saveAs = useCallback((name: string) => {
profileApi.save(name, collectProfileData())
.then(() => { setNewName(''); refresh(); })
.catch(e => setError(e.message));
}, [refresh]);
const handleSaveNew = useCallback(() => {
const name = newName.trim();
if (!name) return;
if (profiles.some(p => p.name.toLowerCase() === name.toLowerCase())) {
setConfirmAction({ kind: 'overwrite', name });
} else {
saveAs(name);
}
}, [newName, profiles, saveAs]);
const handleApply = useCallback((p: ParamProfile) => {
applyProfileData(p.data);
setAppliedName(p.name);
}, []);
const handleDelete = useCallback((name: string) => {
profileApi.remove(name)
.then(() => refresh())
.catch(e => setError(e.message));
}, [refresh]);
const startRename = useCallback((p: ParamProfile) => {
setRenamingName(p.name);
setRenameValue(p.name);
setError('');
}, []);
const commitRename = useCallback(() => {
const from = renamingName;
const to = renameValue.trim();
if (!to || to === from) { setRenamingName(''); return; }
if (profiles.some(p => p.name.toLowerCase() === to.toLowerCase() && p.name !== from)) {
setError(t('profiles.renameCollision', { name: to }));
return;
}
profileApi.rename(from, to)
.then(() => {
if (appliedName === from) setAppliedName(to);
if (expandedName === from) setExpandedName(to);
setRenamingName('');
refresh();
})
.catch(e => setError(e.message));
}, [renamingName, renameValue, profiles, appliedName, expandedName, refresh, t]);
// ── JSON export/import (same preset format as saved profiles) ──
const handleExportProfile = useCallback((p: ParamProfile) => {
const blob = new Blob([JSON.stringify(p.data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${p.name.replace(/[^a-zA-Z0-9 _-]/g, '_')}.json`;
a.click();
URL.revokeObjectURL(url);
}, []);
const importAs = useCallback((name: string, data: Record<string, unknown>) => {
profileApi.save(name, data)
.then(() => refresh())
.catch(e => setError(e.message));
}, [refresh]);
const handleImportFile = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
let parsed = JSON.parse(reader.result as string);
// Tolerate a full profile wrapper ({ name, saved_at, data }) as well as bare preset JSON
if (parsed && typeof parsed === 'object' && parsed._format === undefined && parsed.data?._format === 'hot-step-preset') {
parsed = parsed.data;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
setError(t('profiles.importInvalid'));
return;
}
const name = file.name.replace(/\.json$/i, '').trim() || 'imported';
if (profiles.some(p => p.name.toLowerCase() === name.toLowerCase())) {
setConfirmAction({ kind: 'import', name, data: parsed });
} else {
importAs(name, parsed);
}
} catch {
setError(t('profiles.importInvalid'));
}
};
reader.readAsText(file);
e.target.value = '';
}, [profiles, importAs, t]);
// Portal to body: the global bar's backdrop-filter makes it the containing
// block for fixed descendants, which would pin this overlay to the bar.
return createPortal(
<div className="fixed inset-0 z-[150] flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={onClose}>
<div className="w-full max-w-xl max-h-[80vh] flex flex-col bg-zinc-50 dark:bg-zinc-900/95 rounded-2xl border border-zinc-200 dark:border-white/10 shadow-2xl overflow-hidden"
onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-200 dark:border-white/5">
<div className="flex items-center gap-2">
<Bookmark size={16} className="text-pink-500" />
<h2 className="text-sm font-semibold text-zinc-800 dark:text-zinc-200">{t('profiles.title')}</h2>
</div>
<div className="flex items-center gap-1">
<button onClick={() => fileInputRef.current?.click()} title={t('profiles.import')}
className="p-1.5 rounded-lg text-zinc-400 hover:text-sky-400 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors">
<Download size={14} />
</button>
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImportFile} />
<button onClick={onClose}
className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors">
<X size={16} />
</button>
</div>
</div>
{/* Save current */}
<div className="px-5 py-3 border-b border-zinc-200 dark:border-white/5 flex items-center gap-2">
<input
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveNew(); }}
placeholder={t('profiles.namePlaceholder')}
className="flex-1 px-3 py-2 rounded-xl bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none transition-colors"
/>
<button onClick={handleSaveNew} disabled={!newName.trim()}
className="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-pink-600 hover:bg-pink-500 text-white text-sm font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
<Save size={13} />
{t('profiles.saveCurrent')}
</button>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto px-3 py-2">
{loading && (
<div className="py-8 text-center text-sm text-zinc-500">{t('profiles.loading')}</div>
)}
{!loading && profiles.length === 0 && (
<div className="py-8 text-center text-sm text-zinc-500">{t('profiles.empty')}</div>
)}
{profiles.map(p => {
const isExpanded = expandedName === p.name;
const isRenaming = renamingName === p.name;
return (
<div key={p.name} className="rounded-xl overflow-hidden">
<div className="group flex items-center gap-3 px-2 py-2 rounded-xl hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors">
{isRenaming ? (
<input
autoFocus
value={renameValue}
onChange={e => setRenameValue(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') commitRename();
else if (e.key === 'Escape') setRenamingName('');
}}
onBlur={commitRename}
className="flex-1 min-w-0 px-2 py-1 rounded-lg bg-white dark:bg-zinc-900 border border-pink-500/50 text-sm text-zinc-800 dark:text-zinc-200 focus:ring-1 focus:ring-pink-500/20 outline-none"
/>
) : (
<button onClick={() => setExpandedName(isExpanded ? '' : p.name)}
title={t('profiles.inspect')}
className="flex-1 min-w-0 flex items-center gap-1.5 text-left">
<span className="flex-shrink-0 text-zinc-400">
{isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
</span>
<span className="min-w-0">
<span className="flex items-center gap-2">
<span className="text-sm font-medium text-zinc-800 dark:text-zinc-200 truncate">{p.name}</span>
{appliedName === p.name && (
<span className="flex items-center gap-1 text-[10px] text-emerald-500 flex-shrink-0">
<Check size={11} /> {t('profiles.applied')}
</span>
)}
</span>
<span className="block text-[10px] text-zinc-500 truncate">
{summarizeProfile(p.data)}
{p.saved_at && <> · {new Date(p.saved_at).toLocaleString()}</>}
</span>
</span>
</button>
)}
<button onClick={() => handleApply(p)} title={t('profiles.apply')}
className="flex-shrink-0 px-2.5 py-1.5 rounded-lg text-xs font-medium bg-zinc-200 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 hover:bg-pink-600 hover:text-white dark:hover:bg-pink-600 transition-colors">
{t('profiles.apply')}
</button>
<button onClick={() => startRename(p)} title={t('profiles.rename')}
className="p-1.5 rounded-lg text-zinc-400 hover:text-amber-400 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors">
<Pencil size={13} />
</button>
<button onClick={() => setConfirmAction({ kind: 'overwrite', name: p.name })} title={t('profiles.update')}
className="p-1.5 rounded-lg text-zinc-400 hover:text-sky-400 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors">
<RefreshCw size={13} />
</button>
<button onClick={() => handleExportProfile(p)} title={t('profiles.export')}
className="p-1.5 rounded-lg text-zinc-400 hover:text-emerald-400 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors">
<Upload size={13} />
</button>
<button onClick={() => setConfirmAction({ kind: 'delete', name: p.name })} title={t('profiles.delete')}
className="p-1.5 rounded-lg text-zinc-400 hover:text-red-400 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors">
<Trash2 size={13} />
</button>
</div>
{/* Inspector — grouped parameter list */}
{isExpanded && (
<div className="mx-2 mb-2 mt-0.5 rounded-xl bg-zinc-100/70 dark:bg-black/20 border border-zinc-200 dark:border-white/5 px-3 py-3 space-y-3">
{describeProfileGroups(p.data).map(g => (
<div key={g.title}>
<div className="text-[10px] font-semibold uppercase tracking-wider text-pink-500/80 mb-1">{g.title}</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5">
{g.rows.map(r => (
<div key={r.key} className="flex items-baseline justify-between gap-2 min-w-0">
<span className="text-[11px] text-zinc-500 truncate">{r.label}</span>
<span className="text-[11px] font-mono text-zinc-700 dark:text-zinc-300 truncate text-right">{r.value}</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
);
})}
{error && (
<div className="px-2 py-2 text-xs text-red-400">{error}</div>
)}
</div>
{/* Nested inside the stopPropagation panel so backdrop clicks don't
bubble to the overlay's onClose; renders fixed/fullscreen anyway. */}
<ConfirmDialog
isOpen={confirmAction !== null}
title={confirmAction?.kind === 'delete' ? t('profiles.deleteTitle') : t('profiles.overwriteTitle')}
message={confirmAction?.kind === 'delete'
? t('profiles.deleteMessage', { name: confirmAction?.name })
: confirmAction?.kind === 'import'
? t('profiles.importOverwriteMessage', { name: confirmAction?.name })
: t('profiles.overwriteMessage', { name: confirmAction?.name })}
danger={confirmAction?.kind === 'delete'}
onConfirm={() => {
if (!confirmAction) return;
if (confirmAction.kind === 'delete') handleDelete(confirmAction.name);
else if (confirmAction.kind === 'import' && confirmAction.data) importAs(confirmAction.name, confirmAction.data);
else saveAs(confirmAction.name);
setConfirmAction(null);
}}
onCancel={() => setConfirmAction(null)}
/>
</div>
</div>,
document.body
);
};
export default ProfilesModal;
@@ -0,0 +1,344 @@
// SeedManagerDrawer.tsx — Seed save/load popover for STORM Live Controls
// MDMAchine / A&E Concepts 2026
// GPL v3 — safe for public repo
//
// Renders as a floating panel anchored to the seed row.
// Triggered by a 💾 button added next to the existing 🎲 / 🔒 buttons.
//
// Features:
// - List all saved seeds with search filter
// - Click a seed → fires onLoad(seed) immediately (no confirm)
// - Star toggle for favorites, shown first
// - Save current seed with optional name + description
// - Delete with single-click (pill turns red, second click confirms)
// - Imports existing ComfyUI SeedSaver files with zero conversion
import React from 'react';
import { X, Star, Trash2, Save, Shuffle, Loader2 } from 'lucide-react';
// ─── Types ────────────────────────────────────────────────────────────────────
interface SavedSeed {
name: string;
seed: number;
saved_at: string | null;
description: string;
tags: string[];
favorite: boolean;
}
interface SeedManagerDrawerProps {
isOpen: boolean;
onClose: () => void;
currentSeed: number;
/** Called when user clicks a saved seed — apply it immediately */
onLoad: (seed: number) => void;
/** Called when user hits the random-saved-seed button */
onLoadRandom: (seed: number) => void;
}
// ─── API helpers ──────────────────────────────────────────────────────────────
async function apiList(): Promise<SavedSeed[]> {
const res = await fetch('/api/seeds');
if (!res.ok) throw new Error('list failed');
const data = await res.json();
return data.seeds ?? [];
}
async function apiSave(name: string, seed: number, description: string): Promise<void> {
const res = await fetch('/api/seeds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, seed, description }),
});
if (!res.ok) throw new Error('save failed');
}
async function apiDelete(name: string): Promise<void> {
const res = await fetch(`/api/seeds/${encodeURIComponent(name)}`, { method: 'DELETE' });
if (!res.ok) throw new Error('delete failed');
}
async function apiToggleFavorite(name: string): Promise<boolean> {
const res = await fetch(`/api/seeds/${encodeURIComponent(name)}/favorite`, { method: 'POST' });
if (!res.ok) throw new Error('favorite toggle failed');
const data = await res.json();
return data.favorite;
}
async function apiRandom(): Promise<{ name: string; seed: number } | null> {
const res = await fetch('/api/seeds/random');
if (!res.ok) return null;
return res.json();
}
// ─── Subcomponents ────────────────────────────────────────────────────────────
function fmtDate(iso: string | null): string {
if (!iso) return '';
try {
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} catch { return ''; }
}
// ─── Main component ───────────────────────────────────────────────────────────
export const SeedManagerDrawer: React.FC<SeedManagerDrawerProps> = ({
isOpen, onClose, currentSeed, onLoad, onLoadRandom,
}) => {
const [seeds, setSeeds] = React.useState<SavedSeed[]>([]);
const [loading, setLoading] = React.useState(false);
const [filter, setFilter] = React.useState('');
const [saveName, setSaveName] = React.useState('');
const [saveDesc, setSaveDesc] = React.useState('');
const [saving, setSaving] = React.useState(false);
const [deleteConfirm, setDeleteConfirm] = React.useState<string | null>(null);
const [flashLoaded, setFlashLoaded] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
// Load list when drawer opens
React.useEffect(() => {
if (!isOpen) return;
setLoading(true);
setError(null);
apiList()
.then(setSeeds)
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [isOpen]);
// Clear delete confirm when filter changes
React.useEffect(() => { setDeleteConfirm(null); }, [filter]);
if (!isOpen) return null;
// Sort: favorites first, then alpha
const filtered = seeds
.filter(s =>
!filter ||
s.name.toLowerCase().includes(filter.toLowerCase()) ||
s.description.toLowerCase().includes(filter.toLowerCase()) ||
s.tags.some(t => t.toLowerCase().includes(filter.toLowerCase()))
)
.sort((a, b) => {
if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
return a.name.localeCompare(b.name);
});
const handleLoad = (s: SavedSeed) => {
onLoad(s.seed);
setFlashLoaded(s.name);
setTimeout(() => setFlashLoaded(null), 1200);
};
const handleSave = async () => {
const name = saveName.trim() || `seed_${currentSeed}`;
setSaving(true);
setError(null);
try {
await apiSave(name, currentSeed, saveDesc.trim());
const updated = await apiList();
setSeeds(updated);
setSaveName('');
setSaveDesc('');
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'save failed');
} finally { setSaving(false); }
};
const handleDelete = async (name: string) => {
if (deleteConfirm !== name) { setDeleteConfirm(name); return; }
setDeleteConfirm(null);
try {
await apiDelete(name);
setSeeds(s => s.filter(x => x.name !== name));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'delete failed');
}
};
const handleFavorite = async (name: string) => {
try {
const nowFav = await apiToggleFavorite(name);
setSeeds(s => s.map(x => x.name === name ? { ...x, favorite: nowFav } : x));
} catch {}
};
const handleRandom = async () => {
try {
const r = await apiRandom();
if (r) {
onLoadRandom(r.seed);
setFlashLoaded(r.name);
setTimeout(() => setFlashLoaded(null), 1200);
}
} catch {}
};
return (
<>
{/* Backdrop — click to close */}
<div className="fixed inset-0 z-40 bg-black/40" onClick={onClose} />
{/* Drawer panel */}
<div
className="fixed z-50 w-72 bg-zinc-900 rounded-xl border border-zinc-700 shadow-2xl overflow-hidden"
style={{ bottom: '3.5rem', right: '1rem' }} // anchors above seed row; adjust if needed
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800">
<span className="text-[11px] font-bold text-zinc-200">🎲 Seed Manager</span>
<div className="flex items-center gap-1">
<button onClick={handleRandom} title="Load a random saved seed"
className="text-[9px] px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-400 hover:text-white hover:bg-zinc-700 transition-colors flex items-center gap-0.5">
<Shuffle size={9} /> random
</button>
<button onClick={onClose}
className="p-0.5 rounded text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800 transition-colors">
<X size={12} />
</button>
</div>
</div>
{/* Save current seed */}
<div className="px-3 py-2 border-b border-zinc-800 space-y-1.5">
<div className="text-[9px] text-zinc-600 uppercase tracking-wider">Save current seed</div>
<div className="flex items-center gap-1">
<div className="flex-1 text-[9px] text-amber-400 tabular-nums font-mono bg-zinc-800 rounded px-1.5 py-0.5 border border-zinc-700">
{currentSeed}
</div>
</div>
<input
type="text"
value={saveName}
onChange={e => setSaveName(e.target.value)}
placeholder={`name (default: seed_${currentSeed})`}
onKeyDown={e => { if (e.key === 'Enter') handleSave(); }}
className="w-full px-1.5 py-0.5 rounded bg-zinc-800 border border-zinc-700 text-[9px] text-zinc-200 placeholder:text-zinc-600 outline-none focus:border-amber-500/40 transition-colors"
/>
<input
type="text"
value={saveDesc}
onChange={e => setSaveDesc(e.target.value)}
placeholder="description (optional)"
onKeyDown={e => { if (e.key === 'Enter') handleSave(); }}
className="w-full px-1.5 py-0.5 rounded bg-zinc-800 border border-zinc-700 text-[9px] text-zinc-200 placeholder:text-zinc-600 outline-none focus:border-amber-500/40 transition-colors"
/>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-1 text-[9px] px-2 py-0.5 rounded bg-amber-600 hover:bg-amber-500 text-white font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 size={9} className="animate-spin" /> : <Save size={9} />}
Save
</button>
</div>
{/* Error */}
{error && (
<div className="px-3 py-1.5 text-[9px] text-red-400 bg-red-900/20 border-b border-zinc-800">
{error}
</div>
)}
{/* Search */}
<div className="px-3 py-2 border-b border-zinc-800">
<input
type="text"
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="search seeds…"
className="w-full px-1.5 py-0.5 rounded bg-zinc-800 border border-zinc-700 text-[9px] text-zinc-300 placeholder:text-zinc-600 outline-none focus:border-zinc-500 transition-colors"
/>
</div>
{/* Seed list */}
<div className="max-h-48 overflow-y-auto hide-scrollbar">
{loading && (
<div className="flex items-center justify-center py-4 gap-1.5 text-zinc-600">
<Loader2 size={12} className="animate-spin" />
<span className="text-[9px]">loading</span>
</div>
)}
{!loading && filtered.length === 0 && (
<div className="text-center py-4 text-[9px] text-zinc-700">
{seeds.length === 0 ? 'no seeds saved yet' : 'no matches'}
</div>
)}
{!loading && filtered.map(s => (
<div
key={s.name}
className={`group flex items-center gap-1.5 px-3 py-1.5 hover:bg-zinc-800/60 transition-colors border-b border-zinc-800/40 last:border-0 ${
flashLoaded === s.name ? 'bg-green-900/20' : ''
}`}
>
{/* Favorite star */}
<button
onClick={() => handleFavorite(s.name)}
className={`flex-shrink-0 transition-colors ${
s.favorite ? 'text-amber-400 hover:text-amber-300' : 'text-zinc-700 hover:text-zinc-500'
}`}
title={s.favorite ? 'Remove from favorites' : 'Add to favorites'}
>
<Star size={9} fill={s.favorite ? 'currentColor' : 'none'} />
</button>
{/* Load button — name + seed value */}
<button
onClick={() => handleLoad(s)}
className="flex-1 text-left min-w-0"
title={`Load seed ${s.seed}${s.description ? `${s.description}` : ''}`}
>
<div className="flex items-baseline gap-1.5 min-w-0">
<span className={`text-[9px] font-medium truncate ${
flashLoaded === s.name ? 'text-green-400' : 'text-zinc-300 group-hover:text-white'
}`}>
{flashLoaded === s.name ? '✓ loaded' : s.name}
</span>
<span className="text-[8px] text-zinc-600 tabular-nums shrink-0 font-mono">
{s.seed}
</span>
</div>
{(s.description || s.saved_at) && (
<div className="flex items-center gap-1 mt-0.5">
{s.description && (
<span className="text-[7px] text-zinc-600 truncate">{s.description}</span>
)}
{s.saved_at && (
<span className="text-[7px] text-zinc-700 shrink-0">{fmtDate(s.saved_at)}</span>
)}
</div>
)}
</button>
{/* Delete */}
<button
onClick={() => handleDelete(s.name)}
className={`flex-shrink-0 transition-all rounded px-1 py-0.5 ${
deleteConfirm === s.name
? 'text-red-300 bg-red-900/40 text-[8px] font-medium'
: 'text-zinc-700 hover:text-red-400 opacity-0 group-hover:opacity-100'
}`}
title={deleteConfirm === s.name ? 'Click again to confirm delete' : 'Delete seed'}
>
{deleteConfirm === s.name ? '✕ sure?' : <Trash2 size={9} />}
</button>
</div>
))}
</div>
{/* Footer count */}
<div className="px-3 py-1.5 border-t border-zinc-800">
<span className="text-[8px] text-zinc-700">
{seeds.length} seed{seeds.length !== 1 ? 's' : ''} saved
{seeds.some(s => s.favorite) ? ` · ${seeds.filter(s => s.favorite).length}` : ''}
</span>
</div>
</div>
</>
);
};
@@ -0,0 +1,488 @@
// VstChainDropdown.tsx — VST3 Post-Processing chain panel for the global bar
//
// Shows the plugin chain, lets you add/remove/reorder plugins,
// toggle enable/disable, and launch native plugin GUIs.
//
// Changes over stock:
// - PluginSearch stays open after add; shows added count; Done button
// - ChainRow: green dot when GUI open, restart-to-apply hint while monitoring
// - Pending changes banner when monitor is running and a GUI was opened
// - Preset section: save / load / delete named chain snapshots
// - Status poll lives in MonitorBar now (no own interval here)
import React, { useEffect, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Plus, Trash2, ExternalLink, Search,
ChevronUp, ChevronDown, Power, Headphones, Square,
BookmarkPlus, Check, RefreshCw, AlertTriangle,
} from 'lucide-react';
import { useVstChainStore } from '../../stores/vstChainStore';
import { usePlaybackSelector, togglePlay } from '../../stores/playbackStore';
// Format seconds as mm:ss
function formatTime(s: number): string {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
// ── Plugin Search Dropdown ──────────────────────────────────
// Stays open after adds. Done button closes.
const PluginSearch: React.FC<{ onClose: () => void }> = ({ onClose }) => {
const { plugins, scanning, scanPlugins, addToChain, chain } = useVstChainStore();
const { t } = useTranslation();
const [filter, setFilter] = useState('');
const [addedUids, setAddedUids] = useState<Set<string>>(new Set());
useEffect(() => {
if (plugins.length === 0 && !scanning) scanPlugins();
}, [plugins.length, scanning, scanPlugins]);
const filtered = useMemo(() => {
const q = filter.toLowerCase();
return (plugins || []).filter(p =>
!q ||
p.name.toLowerCase().includes(q) ||
p.vendor.toLowerCase().includes(q) ||
p.subcategories.toLowerCase().includes(q)
);
}, [plugins, filter]);
const inChain = useMemo(() => new Set((chain || []).map(p => p.uid)), [chain]);
const handleAdd = (plugin: typeof plugins[number]) => {
addToChain(plugin);
setAddedUids(s => new Set([...s, plugin.uid]));
};
return (
<div className="space-y-2">
{/* Search input */}
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
type="text"
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="Search plugins..."
autoFocus
className="w-full pl-8 pr-3 py-1.5 rounded-lg bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 placeholder-zinc-400 dark:placeholder-zinc-600 focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/20 outline-none transition-colors"
/>
</div>
{/* Plugin list */}
<div className="max-h-48 overflow-y-auto space-y-0.5 scrollbar-thin">
{scanning ? (
<div className="flex items-center gap-2 justify-center py-4 text-zinc-500">
<span className="w-3 h-3 border-2 border-zinc-500 border-t-transparent rounded-full animate-spin" />
<span className="text-xs">{t('vst.scanningPlugins')}</span>
</div>
) : filtered.length === 0 ? (
<div className="text-xs text-zinc-600 text-center py-3 italic">
{plugins.length === 0 ? t('vst.noPluginsFound') : t('vst.noMatches')}
</div>
) : (
filtered.map(plugin => {
const already = inChain.has(plugin.uid);
const justAdded = addedUids.has(plugin.uid);
return (
<button
key={plugin.uid}
disabled={already}
onClick={() => !already && handleAdd(plugin)}
className={`w-full flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-left transition-colors ${
already ? 'opacity-40 cursor-not-allowed' : 'hover:bg-violet-500/10 cursor-pointer'
}`}
>
{justAdded
? <Check size={12} className="flex-shrink-0 text-emerald-400" />
: <Plus size={12} className={`flex-shrink-0 ${already ? 'text-zinc-600' : 'text-violet-400'}`} />
}
<div className="min-w-0 flex-1">
<div className="text-xs text-zinc-800 dark:text-zinc-200 truncate">{plugin.name}</div>
<div className="text-[10px] text-zinc-500 truncate">{plugin.vendor} · {plugin.subcategories}</div>
</div>
</button>
);
})
)}
</div>
{/* Rescan + Done */}
<div className="flex items-center justify-between pt-1">
<button
onClick={scanPlugins}
disabled={scanning}
className="text-[10px] text-zinc-600 hover:text-violet-400 transition-colors"
>
{scanning ? t('vst.scanning') : `Rescan (${plugins.length} found)`}
</button>
<button
onClick={onClose}
className="text-[10px] font-semibold text-violet-400 hover:text-violet-300 transition-colors px-2 py-0.5 rounded-lg hover:bg-violet-500/10"
>
{addedUids.size > 0 ? `Done (+${addedUids.size})` : 'Done'}
</button>
</div>
</div>
);
};
// ── Preset Manager ──────────────────────────────────────────
// Save / load / delete named chain snapshots (localStorage).
const PresetManager: React.FC = () => {
const { presets, savePreset, loadPreset, deletePreset, chain } = useVstChainStore();
const [saving, setSaving] = useState(false);
const [newName, setNewName] = useState('');
const names = Object.keys(presets);
const handleSave = () => {
const name = newName.trim();
if (!name) return;
savePreset(name);
setNewName('');
setSaving(false);
};
if (names.length === 0 && !saving && chain.length === 0) return null;
return (
<div className="flex items-center gap-1.5 flex-wrap">
{names.length > 0 && (
<select
className="flex-1 min-w-0 px-2 py-1 rounded-lg bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-white/10 text-[10px] text-zinc-600 dark:text-zinc-400 cursor-pointer focus:border-violet-500/50 outline-none transition-colors"
defaultValue=""
onChange={e => { if (e.target.value) { loadPreset(e.target.value); e.target.value = ''; } }}
>
<option value="" disabled>Load preset</option>
{names.map(n => <option key={n} value={n}>{n}</option>)}
</select>
)}
{names.length > 0 && (
<select
className="px-2 py-1 rounded-lg bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-white/10 text-[10px] text-zinc-600 dark:text-zinc-400 cursor-pointer focus:border-red-500/50 outline-none transition-colors"
defaultValue=""
onChange={e => { if (e.target.value) { deletePreset(e.target.value); e.target.value = ''; } }}
>
<option value="" disabled>Delete</option>
{names.map(n => <option key={n} value={n}> {n}</option>)}
</select>
)}
{chain.length > 0 && (
saving ? (
<div className="flex items-center gap-1 flex-1 min-w-0">
<input
type="text"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') setSaving(false); }}
placeholder="Preset name…"
autoFocus
className="flex-1 min-w-0 px-2 py-1 rounded-lg bg-zinc-100 dark:bg-zinc-800 border border-violet-500/40 text-[10px] text-zinc-800 dark:text-zinc-200 placeholder-zinc-500 outline-none"
/>
<button
onClick={handleSave}
disabled={!newName.trim()}
className="p-1 rounded text-emerald-400 hover:text-emerald-300 disabled:opacity-30 transition-colors"
>
<Check size={12} />
</button>
</div>
) : (
<button
onClick={() => setSaving(true)}
title="Save current chain as preset"
className="p-1.5 rounded-lg hover:bg-violet-500/10 text-zinc-500 hover:text-violet-400 transition-colors flex-shrink-0"
>
<BookmarkPlus size={12} />
</button>
)
)}
</div>
);
};
// ── Chain Entry Row ─────────────────────────────────────────
const ChainRow: React.FC<{
entry: { uid: string; name: string; vendor: string; path: string; enabled: boolean; statePath: string };
index: number;
total: number;
}> = ({ entry, index, total }) => {
const { toggleEnabled, removeFromChain, reorderChain, openGui, openGuiUids, monitoring } = useVstChainStore();
const { t } = useTranslation();
const guiOpen = openGuiUids.includes(entry.uid);
return (
<div
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg border transition-all ${
entry.enabled
? 'bg-violet-500/5 border-violet-500/20'
: 'bg-zinc-100/50 dark:bg-zinc-800/50 border-zinc-200 dark:border-white/5 opacity-50'
}`}
>
{/* Drag handle placeholder + reorder buttons */}
<div className="flex flex-col gap-0 flex-shrink-0">
<button
onClick={() => reorderChain(index, index - 1)}
disabled={index === 0}
className="p-0 text-zinc-600 hover:text-zinc-700 dark:text-zinc-300 disabled:opacity-20 transition-colors"
>
<ChevronUp size={10} />
</button>
<button
onClick={() => reorderChain(index, index + 1)}
disabled={index >= total - 1}
className="p-0 text-zinc-600 hover:text-zinc-700 dark:text-zinc-300 disabled:opacity-20 transition-colors"
>
<ChevronDown size={10} />
</button>
</div>
{/* Order number */}
<span className="text-[10px] text-zinc-600 font-mono w-3 text-center flex-shrink-0">
{index + 1}
</span>
{/* Plugin name (green dot when its GUI window is open) */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs text-zinc-800 dark:text-zinc-200 truncate">{entry.name}</span>
{guiOpen && <span title="GUI window open" className="w-1.5 h-1.5 rounded-full bg-emerald-400 flex-shrink-0" />}
</div>
<div className="text-[10px] text-zinc-500 truncate">{entry.vendor}</div>
</div>
{/* Actions */}
<button
onClick={() => openGui(entry)}
title={monitoring ? 'Open GUI — restart monitor to apply changes' : (guiOpen ? 'Bring GUI to front' : t('vst.openPluginUi'))}
className={`p-1 rounded transition-colors flex-shrink-0 ${
guiOpen ? 'text-emerald-400 hover:bg-emerald-500/10' : 'text-zinc-500 hover:text-violet-400 hover:bg-violet-500/10'
}`}
>
<ExternalLink size={12} />
</button>
<button
onClick={() => toggleEnabled(entry.uid)}
title={entry.enabled ? 'Disable' : 'Enable'}
className={`p-1 rounded transition-colors flex-shrink-0 ${
entry.enabled
? 'hover:bg-amber-500/10 text-emerald-400 hover:text-amber-400'
: 'hover:bg-emerald-500/10 text-zinc-600 hover:text-emerald-400'
}`}
>
<Power size={12} />
</button>
<button
onClick={() => removeFromChain(entry.uid)}
title={t('vst.removeFromChain')}
className="p-1 rounded hover:bg-red-500/10 text-zinc-500 hover:text-red-400 transition-colors flex-shrink-0"
>
<Trash2 size={11} />
</button>
</div>
);
};
// ── Main Dropdown ───────────────────────────────────────────
export const VstChainDropdown: React.FC = () => {
const {
chain, chainLoaded, loadChain, monitoring,
startMonitor, stopMonitor, restartMonitor,
monitorPosition, monitorDuration, seekMonitor,
pendingGuiChanges,
} = useVstChainStore();
const currentTrack = usePlaybackSelector(s => s.currentTrack);
const isPlaying = usePlaybackSelector(s => s.isPlaying);
const presets = useVstChainStore(s => s.presets);
const [showSearch, setShowSearch] = useState(false);
const [restarting, setRestarting] = useState(false);
const { t } = useTranslation();
useEffect(() => {
if (!chainLoaded) loadChain();
}, [chainLoaded, loadChain]);
const safeChain = chain || [];
const enabledCount = safeChain.filter(p => p.enabled).length;
const hasTrack = !!currentTrack?.audioUrl;
const handleMonitorToggle = async () => {
if (monitoring) {
await stopMonitor();
} else if (hasTrack) {
// Pause browser playback to avoid double audio
if (isPlaying) togglePlay();
await startMonitor(currentTrack!.audioUrl);
}
};
const handleRestart = async () => {
setRestarting(true);
await restartMonitor();
setRestarting(false);
};
return (
<div className="space-y-3">
{/* Presets */}
{(safeChain.length > 0 || Object.keys(presets).length > 0) && (
<div className="space-y-1">
<div className="text-[10px] text-zinc-500 uppercase tracking-wider font-medium">Presets</div>
<PresetManager />
</div>
)}
{/* Pending changes banner — GUI was opened while monitoring */}
{monitoring && pendingGuiChanges && (
<div className="flex items-center gap-2 px-2.5 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle size={12} className="text-amber-400 flex-shrink-0" />
<span className="text-[10px] text-amber-300 flex-1 leading-relaxed">
GUI changes won't be heard until the monitor restarts — it loads state files at startup.
</span>
<button
onClick={handleRestart}
disabled={restarting}
className="flex items-center gap-1 text-[10px] font-semibold text-amber-300 hover:text-amber-200 transition-colors flex-shrink-0 disabled:opacity-50"
>
<RefreshCw size={11} className={restarting ? 'animate-spin' : ''} />
{restarting ? 'Restarting' : 'Restart'}
</button>
</div>
)}
{/* Current chain */}
{safeChain.length > 0 ? (
<div className="space-y-1">
<div className="flex items-center justify-between">
<div className="text-[10px] text-zinc-500 uppercase tracking-wider font-medium">
Plugin Chain ({enabledCount}/{safeChain.length} active)
</div>
{monitoring && !pendingGuiChanges && (
<button
onClick={handleRestart}
disabled={restarting}
title="Restart monitor to reload plugin state"
className="flex items-center gap-1 text-[10px] text-zinc-500 hover:text-violet-400 transition-colors disabled:opacity-50"
>
<RefreshCw size={10} className={restarting ? 'animate-spin' : ''} />
reload
</button>
)}
</div>
{safeChain.map((entry, i) => (
<ChainRow key={entry.uid} entry={entry} index={i} total={safeChain.length} />
))}
</div>
) : (
<div className="text-xs text-zinc-500 italic text-center py-2">
{t('vst.noPluginsInChain')}
</div>
)}
{/* Monitor button */}
{enabledCount > 0 && (
<button
onClick={handleMonitorToggle}
disabled={!monitoring && !hasTrack}
className={`w-full flex items-center justify-center gap-2 px-3 py-2.5 text-xs font-semibold rounded-xl border transition-all ${
monitoring
? 'bg-violet-500/15 border-violet-500/40 text-violet-300 hover:bg-violet-500/25'
: hasTrack
? 'bg-zinc-100 dark:bg-zinc-800 border-zinc-300 dark:border-white/10 text-zinc-600 dark:text-zinc-400 hover:border-violet-500/30 hover:text-violet-400'
: 'bg-zinc-100/50 dark:bg-zinc-800/50 border-zinc-200 dark:border-white/5 text-zinc-600 cursor-not-allowed'
}`}
>
{monitoring ? (
<>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-violet-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-violet-500" />
</span>
<Square size={12} />
{t('vst.stopMonitor')}
</>
) : (
<>
<Headphones size={14} />
{hasTrack ? t('vst.monitorWithVst') : t('vst.playTrackFirst')}
</>
)}
</button>
)}
{/* Transport bar during monitoring (MonitorBar is the persistent version) */}
{monitoring && (monitorDuration ?? 0) > 0 && (
<div className="space-y-1">
<input
type="range"
min={0}
max={monitorDuration}
step={0.5}
value={monitorPosition ?? 0}
onChange={e => seekMonitor(parseFloat(e.target.value))}
className="w-full h-1.5 rounded-full appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, rgb(139 92 246) ${((monitorPosition ?? 0) / monitorDuration) * 100}%, rgb(63 63 70) ${((monitorPosition ?? 0) / monitorDuration) * 100}%)`,
}}
/>
<div className="flex justify-between text-[10px] text-zinc-500 font-mono">
<span>{formatTime(monitorPosition ?? 0)}</span>
<span>{formatTime(monitorDuration)}</span>
</div>
</div>
)}
{/* Add plugin */}
{showSearch ? (
<div className="border-t border-zinc-200 dark:border-white/5 pt-2">
<PluginSearch onClose={() => setShowSearch(false)} />
</div>
) : (
<button
onClick={() => setShowSearch(true)}
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-semibold rounded-xl bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border border-zinc-300 dark:border-white/10 hover:border-violet-500/30 hover:text-violet-400 transition-all"
>
<Plus size={14} /> {t('vst.addPlugin')}
</button>
)}
{/* Info */}
<p className="text-[10px] text-zinc-600 leading-relaxed">
VST3 plugins are applied to generated audio in chain order.
Click <ExternalLink size={9} className="inline" /> to open the plugin's native UI
and configure settings they're saved automatically when you close the window.
</p>
</div>
);
};
// ── Badge ───────────────────────────────────────────────────
export const VstChainBadge: React.FC = () => {
const { chain, monitoring } = useVstChainStore();
const enabled = (chain || []).filter(p => p.enabled);
if (enabled.length === 0 && !monitoring) return null;
return (
<span className="flex items-center gap-1.5 text-[10px] text-violet-400/60 font-mono truncate">
{monitoring && (
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-violet-400 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-violet-500" />
</span>
)}
{monitoring ? 'monitoring' : `${enabled.length} plugin${enabled.length !== 1 ? 's' : ''}`}
</span>
);
};
+186
View File
@@ -0,0 +1,186 @@
// modelLabels.ts — Human-friendly labels for model filenames
//
// Maps raw model names (GGUF filenames or safetensors directory names)
// to short, readable labels. Format badges are handled by ModelSelect.
/** Parse a DiT model filename into a nice label like "Base/Turbo XL-BF16" */
export function formatDitModel(filename: string): string {
if (!filename) return '—';
// ONNX models: special labels
if (filename === 'dit_fp8.onnx') return 'DiT XL FP8 (TensorRT)';
if (filename === 'dit_bf16.onnx') return 'DiT XL BF16 (TensorRT)';
const name = filename.replace(/\.gguf$/i, '');
// Extract quant suffix (last segment after final dash, e.g. BF16, Q8_0, Q4_K_M)
const quantMatch = name.match(/-(BF16|MXFP4|NVFP4|Q\d+_?\w*|IQ\d+_?\w*)$/);
const quant = quantMatch ? quantMatch[1] : '';
const base = quant ? name.slice(0, -(quant.length + 1)) : name;
// Map known base patterns to friendly names
const labelMap: [RegExp, string][] = [
[/^acestep-v15-merge-base-turbo-xl-ta-[\d.]+$/, 'Merge Base/Turbo XL'],
[/^acestep-v15-merge-sft-turbo-xl-ta-([\d.]+)$/, 'Merge SFT/Turbo XL $1'],
[/^acestep-v15-xl-sftturbo50$/, 'XL SFT+Turbo50'],
[/^acestep-v15-xl-turbo$/, 'XL Turbo'],
[/^acestep-v15-xl-base$/, 'XL Base'],
[/^acestep-v15-xl-sft$/, 'XL SFT'],
[/^acestep-v15-sftturbo50$/, 'SFT+Turbo50'],
[/^acestep-v15-turbo-continuous$/, 'Turbo Continuous'],
[/^acestep-v15-turbo-shift(\d)$/, 'Turbo Shift$1'],
[/^acestep-v15-turbo$/, 'Turbo'],
[/^acestep-v15-base$/, 'Base'],
[/^acestep-v15-sft$/, 'SFT'],
];
let label = base;
for (const [pattern, replacement] of labelMap) {
if (pattern.test(base)) {
label = base.replace(pattern, replacement);
break;
}
}
return quant ? `${label}-${quant}` : label;
}
/** Parse an LM model filename into a nice label like "LM 4B-BF16" */
export function formatLmModel(filename: string): string {
if (!filename) return '—';
const name = filename.replace(/\.gguf$/i, '');
// LM pattern: acestep-5Hz-lm-{size}-{quant} (GGUF)
const lmMatch = name.match(/^acestep-5Hz-lm-([\d.]+B)-([\w_]+)$/);
if (lmMatch) return `LM ${lmMatch[1]}-${lmMatch[2]}`;
// LM safetensors dir: acestep-5Hz-lm-{size} (no quant)
const lmStMatch = name.match(/^acestep-5Hz-lm-([\d.]+B)$/);
if (lmStMatch) return `LM ${lmStMatch[1]}`;
// ONNX LM directory: lm-{size} (e.g. "lm-4B", "lm-0.6B", "lm-1.7B")
const lmOnnxMatch = name.match(/^lm-([\d.]+B)$/i);
if (lmOnnxMatch) return `LM ${lmOnnxMatch[1]}`;
// Qwen embedding
const qwenMatch = name.match(/^Qwen3-Embedding-([\d.]+B)-([\w_]+)$/);
if (qwenMatch) return `Qwen ${qwenMatch[1]}-${qwenMatch[2]}`;
return name;
}
/** Parse a VAE model filename into a nice label like "VAE" or "ScragVAE" */
export function formatVaeModel(filename: string): string {
if (!filename) return '—';
const name = filename
.replace(/\.(gguf|safetensors|onnx)$/i, '')
.replace(/-(BF16|F16|F32)$/i, '');
// Format badge in ModelSelect handles GGUF/ST indication — no suffix needed
if (name === 'vae') return 'VAE';
if (name === 'scragvae') return 'ScragVAE';
if (name === 'vae-DreamVAE') return 'DreamVAE';
// Regrind family: vae-Regrind-V9b, vae-Regrind-V10b-Blend50, ... (any version)
const regrindMatch = name.match(/^vae-Regrind-(V\w+?)(-Blend50)?$/i);
if (regrindMatch) return `Regrind ${regrindMatch[1]}${regrindMatch[2] ? ' Blend50' : ''}`;
return name;
}
/** Parse an embedding model filename into a nice label like "Qwen3 0.6B-Q8" */
export function formatEmbeddingModel(filename: string): string {
if (!filename) return '—';
const name = filename.replace(/\.gguf$/i, '');
// GGUF: Qwen3-Embedding-0.6B-Q8_0
const qwenMatch = name.match(/^Qwen3-Embedding-([\d.]+B)-([\w_]+)$/);
if (qwenMatch) return `Qwen3 ${qwenMatch[1]}-${qwenMatch[2]}`;
// Safetensors dir: Qwen3-Embedding-0.6B
const qwenStMatch = name.match(/^Qwen3-Embedding-([\d.]+B)$/);
if (qwenStMatch) return `Qwen3 ${qwenStMatch[1]}`;
return name;
}
/** Strip path and extension from a mastering reference filename */
export function formatReferenceName(filename: string): string {
if (!filename) return '';
// Strip any path prefix (forward or backslash)
const basename = filename.replace(/^.*[/\\]/, '');
// Strip file extension
return basename.replace(/\.[^.]+$/, '');
}
/**
* Format a scheduler string into a short display name for badge display.
* NOTE: The dropdown now gets primary display names from the plugin registry
* (registry.schedulers[].display). This function is the fallback used by
* GenerationBadge and other non-dropdown contexts. Unknown scheduler names
* fall through to `|| scheduler` which returns the raw plugin name.
*/
export function formatScheduler(scheduler: string): string {
if (scheduler.startsWith('composite')) return 'Composite';
if (scheduler.startsWith('beta:')) return 'Beta';
if (scheduler.startsWith('power:')) return 'Power';
const names: Record<string, string> = {
linear: 'Linear', cosine: 'Cosine', beta57: 'Beta57',
ddim_uniform: 'DDIM', sgm_uniform: 'SGM',
bong_tangent: 'Tangent', linear_quadratic: 'Lin-Quad',
};
return names[scheduler] || scheduler;
}
/** Get a contextual description for a DiT model based on its filename */
export function getDitModelDescription(filename: string): string {
if (!filename) return '';
// Strip both extensions — works for GGUF filenames and safetensors dirs
const name = filename.replace(/\.gguf$/i, '').toLowerCase();
// ONNX / TRT models
if (name.includes('fp8')) return 'FP8 quantized DiT — fastest inference with FP8 tensor cores. LoRA adapters not yet supported.';
if (name === 'dit_bf16.onnx') return 'BF16 DiT via TensorRT. Equivalent quality to GGUF BF16 with TRT acceleration.';
if (name.includes('turbo') && name.includes('xl')) return 'Extended architecture with turbo training. Fast inference at 815 steps with enriched audio quality.';
if (name.includes('merge') && name.includes('xl')) return 'Merged XL checkpoint combining base stability with turbo speed. Best of both worlds.';
if (name.includes('sftturbo') || (name.includes('sft') && name.includes('turbo'))) return 'Supervised fine-tuned + turbo-distilled. Consistent quality at reduced step counts.';
if (name.includes('xl') && name.includes('sft')) return 'Extended architecture, supervised fine-tuned. Maximum consistency for complex prompts.';
if (name.includes('xl') && name.includes('base')) return 'Extended architecture baseline. Full quality with 3050 steps recommended.';
if (name.includes('turbo-continuous')) return 'Turbo variant trained for continuous flow. Works well across all step ranges.';
if (name.includes('turbo-shift')) return 'Turbo variant with pre-baked shift calibration.';
// Merge checkpoints must be matched BEFORE the bare turbo/sft/base substrings,
// else a "merge-base-turbo" blend wrongly gets the pure-Turbo description (#68).
if (name.includes('merge')) {
if (name.includes('sft') && name.includes('turbo')) return 'Merged SFT+Turbo checkpoint — fine-tuned consistency with turbo speed. ~820 steps.';
if (name.includes('base') && name.includes('turbo')) return 'Merged Base+Turbo checkpoint — baseline quality blended with turbo speed. ~820 steps.';
if (name.includes('base') && name.includes('sft')) return 'Merged Base+SFT checkpoint — baseline quality with fine-tuned consistency.';
return 'Merged checkpoint blending multiple training paradigms.';
}
if (name.includes('turbo')) return 'Distilled for fast inference. Best at 815 steps. The speed workhorse.';
if (name.includes('sft')) return 'Supervised fine-tuned for consistent, prompt-adherent output.';
if (name.includes('base')) return 'Full quality baseline model. Best with 3050 steps.';
if (name.includes('merge')) return 'Merged checkpoint blending multiple training paradigms.';
return '';
}
/** Get a contextual description for an LM model based on its filename */
export function getLmModelDescription(filename: string): string {
if (!filename) return '';
const name = filename.toLowerCase();
if (name.includes('4b')) return 'Large language model (4B params). Richer lyric generation and metadata planning.';
if (name.includes('1.5b') || name.includes('1b')) return 'Compact language model. Faster inference, lighter VRAM usage.';
return 'Language model for lyric and metadata generation.';
}
/** Get a contextual description for a VAE model */
export function getVaeModelDescription(filename: string): string {
if (!filename) return '';
const name = filename.replace(/\.(gguf|safetensors|onnx)$/i, '').toLowerCase();
if (name.includes('dreamvae')) return 'DreamVAE — alternative decoder architecture with smoother output characteristics.';
if (name.includes('regrind') && name.includes('blend50')) {
if (name.includes('v10b')) return 'Regrind V10b Blend50 — 50/50 blend of V10b with stock weights. Recommended starting point for most material.';
return 'Regrind Blend50 — 50/50 blend of a Regrind decoder with stock weights for balanced clarity.';
}
if (name.includes('regrind-v10b') || name.includes('regrind_v10b')) return 'Regrind V10b — latest iteration with more aggressive artifact suppression.';
if (name.includes('regrind-v9b') || name.includes('regrind_v9b')) return 'Regrind V9b — refined high-frequency response and reduced artifacts.';
if (name.includes('regrind-v7') || name.includes('regrind_v7')) return 'Regrind V7 — retrained decoder for improved spectral fidelity.';
if (name.includes('regrind')) return 'Regrind VAE — retrained decoder for improved audio clarity.';
if (name.includes('scragvae') || name.includes('scrag')) return 'ScragVAE — custom-trained for reduced artifacts and improved clarity.';
if (name.startsWith('vae')) return 'Stock ACE-Step VAE. Standard latent-to-audio decoding.';
return 'Variational autoencoder for latent-to-audio decoding.';
}
@@ -0,0 +1,190 @@
// GenreSelector.tsx — Multi-select genre dropdown with categorised groups
//
// Combobox-style: selected genres appear as removable chips above a
// searchable, categorised dropdown list.
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { ChevronDown, X, Search } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { GENRE_TAXONOMY, ALL_GENRES } from '../../data/genres';
interface GenreSelectorProps {
selected: string[];
onChange: (genres: string[]) => void;
}
export const GenreSelector: React.FC<GenreSelectorProps> = ({ selected, onChange }) => {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
// Close on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
// Focus search on open
useEffect(() => {
if (isOpen) {
setTimeout(() => searchRef.current?.focus(), 50);
} else {
setSearch('');
}
}, [isOpen]);
const toggle = useCallback((genre: string) => {
if (selected.includes(genre)) {
onChange(selected.filter(g => g !== genre));
} else {
onChange([...selected, genre]);
}
}, [selected, onChange]);
const clearAll = useCallback(() => {
onChange([]);
}, [onChange]);
const randomize = useCallback(() => {
// Pick 2-4 random genres from the full list
const count = 2 + Math.floor(Math.random() * 3); // 2, 3, or 4
const shuffled = [...ALL_GENRES].sort(() => Math.random() - 0.5);
onChange(shuffled.slice(0, count));
}, [onChange]);
// Filtered taxonomy based on search
const filteredTaxonomy = useMemo(() => {
if (!search.trim()) return GENRE_TAXONOMY;
const q = search.toLowerCase();
return GENRE_TAXONOMY
.map(cat => ({
...cat,
genres: cat.genres.filter(g => g.toLowerCase().includes(q)),
}))
.filter(cat => cat.genres.length > 0);
}, [search]);
return (
<div ref={containerRef} className="relative">
{/* Label */}
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
{t('instaGen.genreLabel')}
</label>
{/* Selected chips + trigger + Random button */}
<div className="flex items-stretch gap-2">
<div
className="flex-1 min-h-[42px] rounded-xl border border-zinc-300 dark:border-white/10 bg-zinc-50 dark:bg-white/5 px-3 py-2 cursor-pointer hover:border-pink-400/50 transition-colors flex flex-wrap items-center gap-1.5"
onClick={() => setIsOpen(!isOpen)}
>
{selected.length === 0 && (
<span className="text-zinc-400 dark:text-zinc-500 text-sm select-none">
{t('instaGen.genrePlaceholder')}
</span>
)}
{selected.map(genre => (
<span
key={genre}
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-pink-500/15 text-pink-400 border border-pink-500/20 hover:bg-pink-500/25 transition-colors"
>
{genre}
<button
onClick={(e) => { e.stopPropagation(); toggle(genre); }}
className="hover:text-pink-200 transition-colors"
>
<X size={12} />
</button>
</span>
))}
<div className="ml-auto flex items-center gap-1.5 flex-shrink-0">
{selected.length > 0 && (
<button
onClick={(e) => { e.stopPropagation(); clearAll(); }}
className="text-xs text-zinc-400 hover:text-red-400 transition-colors"
title={t('instaGen.clearAll')}
>
{t('instaGen.clearAll')}
</button>
)}
<ChevronDown
size={16}
className={`text-zinc-400 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
/>
</div>
</div>
<button
onClick={randomize}
className="px-3 rounded-xl text-xs font-semibold text-white bg-gradient-to-r from-violet-600 to-purple-600 hover:from-violet-500 hover:to-purple-500 shadow-md shadow-violet-500/20 hover:shadow-violet-500/30 transition-all duration-200 flex-shrink-0"
>
Random
</button>
</div>
{/* Dropdown */}
{isOpen && (
<div className="absolute z-50 mt-1 w-full max-h-[380px] rounded-xl border border-zinc-300 dark:border-white/10 bg-white dark:bg-zinc-900 shadow-2xl overflow-hidden flex flex-col animate-in fade-in slide-in-from-top-1 duration-150">
{/* Search input */}
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-zinc-200 dark:border-white/5">
<Search size={14} className="text-zinc-400 flex-shrink-0" />
<input
ref={searchRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('instaGen.genrePlaceholder')}
className="flex-1 bg-transparent text-sm text-zinc-900 dark:text-white placeholder:text-zinc-400 dark:placeholder:text-zinc-500 outline-none"
onKeyDown={(e) => {
if (e.key === 'Escape') setIsOpen(false);
}}
/>
</div>
{/* Category list */}
<div className="flex-1 overflow-y-auto py-1">
{filteredTaxonomy.length === 0 && (
<div className="px-4 py-6 text-center text-sm text-zinc-400">
No genres match &ldquo;{search}&rdquo;
</div>
)}
{filteredTaxonomy.map(category => (
<div key={category.name}>
{/* Category header */}
<div className="px-3 py-1.5 text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider sticky top-0 bg-white dark:bg-zinc-900">
{category.icon} {category.name}
</div>
{/* Genre items */}
<div className="px-2 pb-1 flex flex-wrap gap-1">
{category.genres.map(genre => {
const isSelected = selected.includes(genre);
return (
<button
key={genre}
onClick={() => toggle(genre)}
className={`
px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-150
${isSelected
? 'bg-pink-500/20 text-pink-400 border border-pink-500/30 shadow-sm shadow-pink-500/10'
: 'bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 border border-transparent hover:bg-zinc-200 dark:hover:bg-white/10 hover:text-zinc-900 dark:hover:text-white'
}
`}
>
{genre}
</button>
);
})}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,128 @@
// InspirePreview.tsx — Preview/edit panel for inspire results
//
// Shows generated lyrics + metadata after the inspire step.
// User can edit lyrics and caption before committing to full generation.
import React from 'react';
import { Music, ArrowLeft, Pencil } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { InspireResult } from '../../services/inspireApi';
interface InspirePreviewProps {
result: InspireResult;
editedLyrics: string;
editedCaption: string;
onLyricsChange: (lyrics: string) => void;
onCaptionChange: (caption: string) => void;
onGenerate: () => void;
onBack: () => void;
onRefine?: () => void;
isGenerating: boolean;
}
export const InspirePreview: React.FC<InspirePreviewProps> = ({
result,
editedLyrics,
editedCaption,
onLyricsChange,
onCaptionChange,
onGenerate,
onBack,
onRefine,
isGenerating,
}) => {
const { t } = useTranslation();
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center gap-3 px-4 pt-3 pb-2">
<button
onClick={onBack}
disabled={isGenerating}
className="flex items-center gap-1.5 text-sm text-zinc-400 hover:text-white transition-colors disabled:opacity-40"
>
<ArrowLeft size={16} />
{t('instaGen.preview.back')}
</button>
<h2 className="text-sm font-semibold text-zinc-300">
{t('instaGen.preview.title')}
</h2>
</div>
{/* Metadata badges */}
<div className="px-4 py-2 flex flex-wrap gap-2">
<MetaBadge label="BPM" value={String(result.bpm)} color="amber" />
<MetaBadge label="Key" value={result.keyScale} color="emerald" />
<MetaBadge label="Time" value={`${result.timeSignature}/4`} color="sky" />
<MetaBadge label="Duration" value={`${result.duration}s`} color="purple" />
</div>
{/* Caption editor */}
<div className="px-4 py-2">
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1">
{t('instaGen.preview.editCaption')}
</label>
<textarea
value={editedCaption}
onChange={(e) => onCaptionChange(e.target.value)}
disabled={isGenerating}
rows={2}
className="w-full rounded-lg border border-zinc-300 dark:border-white/10 bg-zinc-50 dark:bg-white/5 px-3 py-2 text-sm text-zinc-900 dark:text-white placeholder:text-zinc-400 resize-none outline-none focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 transition-all disabled:opacity-50"
/>
</div>
{/* Lyrics editor */}
<div className="flex-1 px-4 py-2 min-h-0 flex flex-col">
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1">
{t('instaGen.preview.lyrics')}
</label>
<textarea
value={editedLyrics}
onChange={(e) => onLyricsChange(e.target.value)}
disabled={isGenerating}
className="flex-1 w-full rounded-lg border border-zinc-300 dark:border-white/10 bg-zinc-50 dark:bg-white/5 px-3 py-2 text-sm text-zinc-900 dark:text-white font-mono leading-relaxed placeholder:text-zinc-400 resize-none outline-none focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 transition-all disabled:opacity-50"
placeholder="[Verse 1]&#10;..."
/>
</div>
{/* Action buttons */}
<div className="px-4 py-3 space-y-2">
<button
onClick={onGenerate}
disabled={isGenerating}
className="w-full py-3 rounded-xl text-sm font-semibold text-white bg-gradient-to-r from-pink-600 to-violet-600 hover:from-pink-500 hover:to-violet-500 shadow-lg shadow-pink-500/20 hover:shadow-pink-500/30 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<Music size={16} />
{isGenerating ? t('instaGen.inspireLoading') : t('instaGen.preview.generate')}
</button>
{onRefine && (
<button
onClick={onRefine}
disabled={isGenerating}
className="w-full py-2.5 rounded-xl text-sm font-medium text-zinc-300 border border-white/10 bg-white/5 hover:bg-white/10 hover:text-white transition-all duration-200 disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<Pencil size={14} />
{t('instaGen.preview.refine')}
</button>
)}
</div>
</div>
);
};
/** Coloured metadata badge */
const MetaBadge: React.FC<{ label: string; value: string; color: string }> = ({ label, value, color }) => {
const colorClasses: Record<string, string> = {
amber: 'bg-amber-500/15 text-amber-400 border-amber-500/20',
emerald: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20',
sky: 'bg-sky-500/15 text-sky-400 border-sky-500/20',
purple: 'bg-purple-500/15 text-purple-400 border-purple-500/20',
};
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium border ${colorClasses[color] || colorClasses.amber}`}>
<span className="text-zinc-500 dark:text-zinc-400">{label}:</span>
{value}
</span>
);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,241 @@
// CoverArtPromptModal.tsx — per-track cover art with a custom prompt (#67).
//
// Manual "Generate / Regenerate Cover Art" from a track's dropdown menu opens
// this modal instead of firing immediately. It pre-fills the textarea with the
// exact prompt the engine would auto-build (fetched from /prompt-preview), lets
// the user edit it freely, then POSTs the verbatim prompt to /cover-art/generate
// and polls to completion — dispatching `cover-art-updated` so App.tsx refreshes
// the song. Auto-generate-after-creation is unaffected (it never opens this).
//
// Mounted once at the SongList root; driven by the `open-cover-art-prompt`
// window CustomEvent dispatched from the context-menu items.
import React, { useState, useEffect, useRef, useCallback } from 'react';
import ReactDOM from 'react-dom';
import { X, Image as ImageIcon, Loader2, Sparkles, RotateCcw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { Song } from '../../types';
/** Detail payload for the `open-cover-art-prompt` window event. */
export interface OpenCoverArtPromptDetail {
song: Song;
}
/** Open the prompt modal for a song from anywhere in the tree. */
export function openCoverArtPrompt(song: Song): void {
window.dispatchEvent(new CustomEvent<OpenCoverArtPromptDetail>('open-cover-art-prompt', {
detail: { song },
}));
}
/** Build the metadata payload used for both preview and generation. */
function songPromptInputs(song: Song) {
const params: any = song.generationParams || (song as any).generation_params || {};
return {
title: song.title || '',
style: song.style || params?.style || '',
lyrics: song.lyrics || params?.lyrics || '',
subject: (song as any).cover_art_subject || params?.coverArtSubject || params?.subject || '',
};
}
export const CoverArtPromptModal: React.FC = () => {
const { t } = useTranslation();
const [song, setSong] = useState<Song | null>(null);
const [prompt, setPrompt] = useState('');
const [defaultPrompt, setDefaultPrompt] = useState('');
const [loadingPreview, setLoadingPreview] = useState(false);
const [generating, setGenerating] = useState(false);
const [error, setError] = useState('');
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const close = useCallback(() => {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
setSong(null);
setPrompt('');
setDefaultPrompt('');
setError('');
setGenerating(false);
}, []);
// Listen for open requests
useEffect(() => {
const onOpen = (e: Event) => {
const detail = (e as CustomEvent<OpenCoverArtPromptDetail>).detail;
if (!detail?.song) return;
setSong(detail.song);
setError('');
setGenerating(false);
// Fetch the auto-assembled prompt to pre-fill
setLoadingPreview(true);
setPrompt('');
fetch('/api/cover-art/prompt-preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(songPromptInputs(detail.song)),
})
.then(r => (r.ok ? r.json() : Promise.reject()))
.then(d => { setPrompt(d.prompt || ''); setDefaultPrompt(d.prompt || ''); })
.catch(() => { setPrompt(''); setDefaultPrompt(''); })
.finally(() => setLoadingPreview(false));
};
window.addEventListener('open-cover-art-prompt', onOpen);
return () => window.removeEventListener('open-cover-art-prompt', onOpen);
}, []);
// Cleanup poll on unmount
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []);
const generate = useCallback(() => {
if (!song || !prompt.trim()) return;
setGenerating(true);
setError('');
fetch('/api/cover-art/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
songId: song.id,
...songPromptInputs(song),
prompt: prompt.trim(),
}),
})
.then(async r => {
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.error || 'Failed to start generation');
}
const { jobId } = await r.json();
pollRef.current = setInterval(async () => {
try {
const jr = await fetch(`/api/cover-art/generate/${jobId}`);
if (!jr.ok) return;
const job = await jr.json();
if (job.status === 'succeeded') {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
window.dispatchEvent(new CustomEvent('cover-art-updated', {
detail: { songId: song.id, coverUrl: job.result?.coverUrl },
}));
close();
} else if (job.status === 'failed') {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
setError(job.error || 'Generation failed');
setGenerating(false);
}
} catch { /* network blip — keep polling */ }
}, 2000);
// Safety stop after 5 minutes
setTimeout(() => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; setGenerating(false); } }, 300_000);
})
.catch(err => { setError(err.message); setGenerating(false); });
}, [song, prompt, close]);
if (!song) return null;
const hasCover = !!(song.coverUrl || (song as any).cover_url);
return ReactDOM.createPortal(
<div
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
onClick={generating ? undefined : close}
>
<div
className="w-full max-w-lg rounded-2xl bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 shadow-2xl overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center gap-2 px-5 py-4 border-b border-zinc-200 dark:border-white/10">
<ImageIcon size={16} className="text-pink-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
{hasCover
? t('coverArt.regenerateTitle', 'Regenerate Cover Art')
: t('coverArt.generateTitle', 'Generate Cover Art')}
</h3>
<p className="text-xs text-zinc-500 truncate">{song.title || t('library.untitled', 'Untitled')}</p>
</div>
<button
onClick={close}
disabled={generating}
className="p-1 rounded text-zinc-500 hover:text-zinc-800 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/10 transition-colors disabled:opacity-40"
>
<X size={16} />
</button>
</div>
{/* Body */}
<div className="px-5 py-4 space-y-3">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-zinc-500">
{t('coverArt.promptLabel', 'Image prompt')}
</label>
{prompt !== defaultPrompt && !!defaultPrompt && (
<button
onClick={() => setPrompt(defaultPrompt)}
disabled={generating}
className="flex items-center gap-1 text-[10px] text-zinc-500 hover:text-pink-400 transition-colors disabled:opacity-40"
title={t('coverArt.promptReset', 'Reset to auto-generated prompt')}
>
<RotateCcw size={10} />
{t('coverArt.promptReset', 'Reset')}
</button>
)}
</div>
{loadingPreview ? (
<div className="flex items-center gap-2 text-xs text-zinc-500 py-6 justify-center">
<Loader2 size={14} className="animate-spin" />
{t('coverArt.promptLoading', 'Preparing prompt…')}
</div>
) : (
<textarea
value={prompt}
onChange={e => setPrompt(e.target.value)}
disabled={generating}
rows={6}
autoFocus
placeholder={t('coverArt.promptPlaceholder', 'Describe the artwork you want…')}
className="w-full px-3 py-2 rounded-lg bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-white/10
text-sm text-zinc-800 dark:text-zinc-200 placeholder-zinc-500
focus:border-pink-500/40 focus:ring-1 focus:ring-pink-500/20
outline-none transition-colors resize-none disabled:opacity-60"
/>
)}
<p className="text-[10px] text-zinc-500 leading-relaxed">
{t('coverArt.promptHelp', 'Edit the prompt to steer the artwork. 1024×1024 via FLUX.2-klein-4B. Text/lettering is automatically suppressed.')}
</p>
{error && (
<div className="text-xs text-red-400 bg-red-500/5 border border-red-500/20 rounded-lg px-3 py-2">
{error}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-zinc-200 dark:border-white/10">
<button
onClick={close}
disabled={generating}
className="px-3 py-2 text-xs font-medium rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-40"
>
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={generate}
disabled={generating || loadingPreview || !prompt.trim()}
className="flex items-center gap-2 px-4 py-2 text-xs font-semibold rounded-lg
bg-gradient-to-r from-pink-500 to-purple-500 text-white
hover:from-pink-400 hover:to-purple-400 transition-all
disabled:opacity-40 disabled:cursor-not-allowed"
>
{generating
? <><Loader2 size={14} className="animate-spin" /> {t('coverArt.generating', 'Generating…')}</>
: <><Sparkles size={14} /> {t('coverArt.generate', 'Generate')}</>}
</button>
</div>
</div>
</div>,
document.body,
);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,86 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Disc3, Link2 } from 'lucide-react';
interface AddAlbumModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (albumName: string | undefined, imageUrl?: string) => Promise<void>;
artistName: string;
}
export const AddAlbumModal: React.FC<AddAlbumModalProps> = ({ isOpen, onClose, onSubmit, artistName }) => {
const [albumName, setAlbumName] = useState('');
const { t } = useTranslation();
const [imageUrl, setImageUrl] = useState('');
const [submitting, setSubmitting] = useState(false);
React.useEffect(() => {
if (isOpen) { setAlbumName(''); setImageUrl(''); }
}, [isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
await onSubmit(albumName.trim() || undefined, imageUrl.trim() || undefined);
onClose();
} finally { setSubmitting(false); }
};
return (
<div className="fixed inset-0 bg-black/30 dark:bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 rounded-2xl shadow-2xl w-full max-w-md overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-white/5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 flex items-center justify-center">
<Disc3 className="w-4 h-4 text-indigo-400" />
</div>
<div>
<h2 className="text-lg font-bold text-white">{t('lyric.addAlbum')}</h2>
<p className="text-xs text-zinc-500">{artistName}</p>
</div>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/5 text-zinc-600 dark:text-zinc-400 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5">
<span className="flex items-center gap-1.5">
<Disc3 className="w-3.5 h-3.5 text-zinc-600 dark:text-zinc-400" />
Album Name <span className="text-zinc-500 font-normal">{t('lyric.optional')}</span>
</span>
</label>
<input type="text" value={albumName} onChange={(e) => setAlbumName(e.target.value)}
placeholder="Leave blank for loose lyrics collection"
className="w-full px-4 py-2.5 rounded-xl bg-white/5 border border-zinc-300 dark:border-white/10 text-white placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/20 transition-all"
autoFocus
/>
<p className="text-xs text-zinc-500 mt-1">If left blank, songs will be stored as a loose lyrics collection</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5">
<span className="flex items-center gap-1.5">
<Link2 className="w-3.5 h-3.5 text-zinc-600 dark:text-zinc-400" />
Cover Art URL <span className="text-zinc-500 font-normal">{t('lyric.optional')}</span>
</span>
</label>
<input type="text" value={imageUrl} onChange={(e) => setImageUrl(e.target.value)}
placeholder="https://example.com/cover.jpg"
className="w-full px-4 py-2.5 rounded-xl bg-white/5 border border-zinc-300 dark:border-white/10 text-white placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/20 transition-all"
/>
</div>
<button type="submit" disabled={submitting}
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl bg-indigo-600 hover:bg-indigo-500 disabled:bg-zinc-200 dark:disabled:bg-zinc-200 dark:bg-zinc-700 disabled:text-zinc-500 text-white font-semibold transition-all"
>
{submitting ? t('lyric.creating') : t('lyric.createAlbum')}
</button>
</form>
</div>
</div>
);
};
@@ -0,0 +1,88 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { X, User, Link2 } from 'lucide-react';
interface AddArtistModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (name: string, imageUrl?: string) => Promise<void>;
}
export const AddArtistModal: React.FC<AddArtistModalProps> = ({ isOpen, onClose, onSubmit }) => {
const [name, setName] = useState('');
const { t } = useTranslation();
const [imageUrl, setImageUrl] = useState('');
const [submitting, setSubmitting] = useState(false);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
setSubmitting(true);
try {
await onSubmit(name.trim(), imageUrl.trim() || undefined);
setName('');
setImageUrl('');
onClose();
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 bg-black/30 dark:bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4" onClick={onClose}>
<div
className="bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 rounded-2xl shadow-2xl w-full max-w-md overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-white/5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-emerald-500/10 flex items-center justify-center">
<User className="w-4 h-4 text-emerald-400" />
</div>
<h2 className="text-lg font-bold text-white">{t('lyric.addArtist')}</h2>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/5 text-zinc-600 dark:text-zinc-400 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5">
<span className="flex items-center gap-1.5">
<User className="w-3.5 h-3.5 text-zinc-600 dark:text-zinc-400" />
{t('lyric.artistName')}
</span>
</label>
<input
type="text" value={name} onChange={(e) => setName(e.target.value)}
placeholder="e.g. My Custom Artist"
className="w-full px-4 py-2.5 rounded-xl bg-white/5 border border-zinc-300 dark:border-white/10 text-white placeholder:text-zinc-500 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/20 transition-all"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5">
<span className="flex items-center gap-1.5">
<Link2 className="w-3.5 h-3.5 text-zinc-600 dark:text-zinc-400" />
{t('lyric.imageUrl')} <span className="text-zinc-500 font-normal">{t('lyric.optional')}</span>
</span>
</label>
<input
type="text" value={imageUrl} onChange={(e) => setImageUrl(e.target.value)}
placeholder="https://example.com/artist.jpg"
className="w-full px-4 py-2.5 rounded-xl bg-white/5 border border-zinc-300 dark:border-white/10 text-white placeholder:text-zinc-500 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/20 transition-all"
/>
</div>
<button type="submit" disabled={!name.trim() || submitting}
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 disabled:bg-zinc-200 dark:disabled:bg-zinc-200 dark:bg-zinc-700 disabled:text-zinc-500 text-white font-semibold transition-all"
>
{submitting ? t('lyric.adding') : t('lyric.addArtist')}
</button>
</form>
</div>
</div>
);
};
@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { X, FileText, Type } from 'lucide-react';
interface AddSongModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (title: string, lyrics: string) => Promise<void>;
albumName?: string;
}
export const AddSongModal: React.FC<AddSongModalProps> = ({ isOpen, onClose, onSubmit, albumName }) => {
const [title, setTitle] = useState('');
const { t } = useTranslation();
const [lyrics, setLyrics] = useState('');
const [submitting, setSubmitting] = useState(false);
React.useEffect(() => {
if (isOpen) { setTitle(''); setLyrics(''); }
}, [isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || !lyrics.trim()) return;
setSubmitting(true);
try {
await onSubmit(title.trim(), lyrics);
onClose();
} finally { setSubmitting(false); }
};
return (
<div className="fixed inset-0 bg-black/30 dark:bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-white/5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-amber-500/10 flex items-center justify-center">
<FileText className="w-4 h-4 text-amber-400" />
</div>
<div>
<h2 className="text-lg font-bold text-white">{t('lyric.addSong')}</h2>
{albumName && <p className="text-xs text-zinc-500">{albumName}</p>}
</div>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/5 text-zinc-600 dark:text-zinc-400 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5">
<span className="flex items-center gap-1.5">
<Type className="w-3.5 h-3.5 text-zinc-600 dark:text-zinc-400" />
{t('lyric.songTitle')}
</span>
</label>
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. My Song Title"
className="w-full px-4 py-2.5 rounded-xl bg-white/5 border border-zinc-300 dark:border-white/10 text-white placeholder:text-zinc-500 focus:outline-none focus:border-amber-500/50 focus:ring-1 focus:ring-amber-500/20 transition-all"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5">
<span className="flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5 text-zinc-600 dark:text-zinc-400" />
{t('lyric.lyrics')}
</span>
</label>
<textarea value={lyrics} onChange={(e) => setLyrics(e.target.value)}
placeholder={"[Verse 1]\nYour lyrics here...\n\n[Chorus]\nChorus lyrics here..."}
className="w-full h-80 px-4 py-3 rounded-xl bg-white/5 border border-zinc-300 dark:border-white/10 text-white placeholder:text-zinc-500 focus:outline-none focus:border-amber-500/50 focus:ring-1 focus:ring-amber-500/20 transition-all resize-y font-mono text-sm leading-relaxed"
/>
<p className="text-xs text-zinc-500 mt-1">
Use [Verse], [Chorus], [Bridge] section headers for best profiling results
</p>
</div>
<button type="submit" disabled={!title.trim() || !lyrics.trim() || submitting}
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl bg-amber-600 hover:bg-amber-500 disabled:bg-zinc-200 dark:disabled:bg-zinc-200 dark:bg-zinc-700 disabled:text-zinc-500 text-white font-semibold transition-all"
>
{submitting ? t('lyric.adding') : t('lyric.addSong')}
</button>
</form>
</div>
</div>
);
};
@@ -0,0 +1,264 @@
import React, { useState, useEffect, useRef } from 'react';
import { Disc3, Plus, FileText, MoreVertical, RefreshCw, Link2, Trash2, Search, PenLine, ChevronDown, Sparkles } from 'lucide-react';
import type { LyricsSet, SongLyric } from '../../services/lireekApi';
import { useDisguiseMode } from '../../hooks/useDisguiseMode';
function parseSongs(songs: SongLyric[] | string): SongLyric[] {
if (typeof songs === 'string') {
try { return JSON.parse(songs); } catch { return []; }
}
return songs || [];
}
interface AlbumGridProps {
albums: LyricsSet[];
loading: boolean;
artistName: string;
onSelectAlbum: (album: LyricsSet) => void;
onAddAlbum: () => void;
onAddManual: () => void;
onDeleteAlbum: (album: LyricsSet) => void;
onRefreshImage?: (album: LyricsSet) => void;
onSetImage?: (album: LyricsSet, url: string) => void;
onCuratedProfile?: () => void;
}
export const AlbumGrid: React.FC<AlbumGridProps> = ({
albums, loading, artistName, onSelectAlbum, onAddAlbum, onAddManual, onDeleteAlbum, onRefreshImage, onSetImage, onCuratedProfile,
}) => {
const { disguiseArtist, disguiseAlbum, disguiseImageUrl } = useDisguiseMode();
const [imageErrors, setImageErrors] = useState<Set<number>>(new Set());
const [menuOpenId, setMenuOpenId] = useState<number | null>(null);
const [addMenuOpen, setAddMenuOpen] = useState(false);
const gridRef = useRef<HTMLDivElement>(null);
const addBtnRef = useRef<HTMLDivElement>(null);
// Close context menus on click outside
useEffect(() => {
if (menuOpenId === null && !addMenuOpen) return;
const handler = (e: MouseEvent) => {
if (gridRef.current && !gridRef.current.contains(e.target as Node)) {
setMenuOpenId(null);
}
if (addBtnRef.current && !addBtnRef.current.contains(e.target as Node)) {
setAddMenuOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [menuOpenId, addMenuOpen]);
const gradient = (name: string) => {
const hash = (name || 'album').split('').reduce((a, c) => ((a << 5) - a + c.charCodeAt(0)) | 0, 0);
const h1 = Math.abs(hash) % 360;
const h2 = (h1 + 30) % 360;
return `linear-gradient(135deg, hsl(${h1}, 50%, 25%), hsl(${h2}, 40%, 18%))`;
};
if (loading) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 p-6">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="aspect-square rounded-2xl bg-white/5 animate-pulse" />
))}
</div>
);
}
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-white">Albums</h2>
<p className="text-sm text-zinc-600 dark:text-zinc-400 mt-0.5">{disguiseArtist(artistName)}</p>
</div>
<div className="flex items-center gap-3">
{onCuratedProfile && albums.length >= 2 && (
<button
onClick={onCuratedProfile}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-purple-600/80 hover:bg-purple-500 text-white text-sm font-semibold transition-all hover:scale-105 shadow-lg shadow-purple-600/20"
>
<Sparkles className="w-4 h-4" />
Curated Profile
</button>
)}
<div className="relative" ref={addBtnRef}>
<button
onClick={() => setAddMenuOpen(!addMenuOpen)}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-pink-600 hover:bg-pink-500 text-white text-sm font-semibold transition-all hover:scale-105 shadow-lg shadow-pink-600/20"
>
<Plus className="w-4 h-4" />
Add Album
<ChevronDown className="w-3.5 h-3.5 opacity-70" />
</button>
{addMenuOpen && (
<div className="absolute right-0 top-full mt-1 z-30 min-w-[180px] rounded-xl bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 shadow-2xl py-1 animate-in fade-in slide-in-from-top-1">
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-white/5 hover:text-white transition-colors"
onClick={() => { setAddMenuOpen(false); onAddAlbum(); }}
>
<Search className="w-3.5 h-3.5" />
Fetch from Genius
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-white/5 hover:text-white transition-colors"
onClick={() => { setAddMenuOpen(false); onAddManual(); }}
>
<PenLine className="w-3.5 h-3.5" />
Add Manually
</button>
</div>
)}
</div>
</div>
</div>
{albums.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 rounded-full bg-white/5 flex items-center justify-center mb-4">
<Disc3 className="w-8 h-8 text-zinc-600" />
</div>
<h3 className="text-base font-semibold text-zinc-600 dark:text-zinc-400 mb-2">No albums yet</h3>
<p className="text-sm text-zinc-500 max-w-xs mb-4">
Fetch lyrics for an album or add one manually.
</p>
<div className="flex items-center gap-3">
<button
onClick={onAddAlbum}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-pink-600 hover:bg-pink-500 text-white text-sm font-semibold transition-all"
>
<Search className="w-4 h-4" />
Fetch from Genius
</button>
<button
onClick={onAddManual}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-zinc-300 dark:border-white/10 text-zinc-700 dark:text-zinc-300 hover:text-white text-sm font-semibold transition-all"
>
<PenLine className="w-4 h-4" />
Add Manually
</button>
</div>
</div>
) : (
<div ref={gridRef} className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{albums.map((album, idx) => {
const songCount = album.total_songs ?? (album.songs ? parseSongs(album.songs).length : 0);
return (
<div
key={album.id}
className={`group relative aspect-square rounded-2xl overflow-hidden cursor-pointer transition-all duration-300 hover:scale-[1.03] hover:shadow-2xl hover:shadow-indigo-500/10 ls2-card-in ls2-stagger-${Math.min(idx + 1, 11)}`}
onClick={() => onSelectAlbum(album)}
>
{(() => {
const dAlbumName = disguiseAlbum(album.album || '');
const dUrl = disguiseImageUrl(album.image_url, album.album || String(album.id));
return dUrl && !imageErrors.has(album.id) ? (
<img
src={dUrl}
alt={dAlbumName || 'Album'}
className="absolute inset-0 w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
onError={() => setImageErrors(prev => new Set(prev).add(album.id))}
/>
) : (
<>
<div
className="absolute inset-0"
style={{ background: gradient(dAlbumName || String(album.id)) }}
/>
{/* Decorative vinyl record */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[60%] h-[60%] rounded-full border border-zinc-200 dark:border-white/5 opacity-20">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[30%] h-[30%] rounded-full border border-zinc-300 dark:border-white/10" />
</div>
</>
);
})()}
{/* Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
{/* Content */}
<div className="absolute inset-x-0 bottom-0 p-4">
<h3 className="text-sm font-bold text-white truncate mb-2 drop-shadow-lg">
{disguiseAlbum(album.album || '') || 'Top Songs'}
</h3>
<div className="flex items-center gap-3 text-xs text-zinc-700 dark:text-zinc-300/70">
<span className="flex items-center gap-1">
<FileText className="w-3 h-3" />
{songCount} songs
</span>
</div>
</div>
{/* Hover ring */}
<div className="absolute inset-0 rounded-2xl ring-1 ring-white/10 group-hover:ring-indigo-500/40 transition-all duration-300" />
{/* Context menu button */}
<button
className="absolute top-2 right-2 p-1.5 rounded-lg bg-black/20 dark:bg-black/50 text-white/60 hover:text-white hover:bg-black/20 dark:bg-black/40 dark:bg-black/70 opacity-0 group-hover:opacity-100 transition-all z-10"
onClick={(e) => {
e.stopPropagation();
setMenuOpenId(menuOpenId === album.id ? null : album.id);
}}
>
<MoreVertical className="w-4 h-4" />
</button>
{/* Context menu */}
{menuOpenId === album.id && (
<div
className="absolute top-10 right-2 z-20 min-w-[160px] rounded-xl bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 shadow-2xl py-1 animate-in fade-in slide-in-from-top-1"
onClick={(e) => e.stopPropagation()}
>
{onRefreshImage && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-white/5 hover:text-white transition-colors"
onClick={() => { onRefreshImage(album); setMenuOpenId(null); }}
>
<RefreshCw className="w-3.5 h-3.5" />
Re-fetch from Genius
</button>
)}
{onSetImage && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-white/5 hover:text-white transition-colors"
onClick={() => {
setMenuOpenId(null);
const url = prompt(`Paste an image URL for "${album.album || 'Album'}":`, album.image_url || '');
if (url && url.trim()) onSetImage(album, url.trim());
}}
>
<Link2 className="w-3.5 h-3.5" />
Set Custom Image
</button>
)}
<div className="border-t border-zinc-200 dark:border-white/5 my-1" />
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors"
onClick={() => { onDeleteAlbum(album); setMenuOpenId(null); }}
>
<Trash2 className="w-3.5 h-3.5" />
Delete Album
</button>
</div>
)}
</div>
);
})}
{/* Add new album card — dropdown */}
<div
className="relative aspect-square rounded-2xl border-2 border-dashed border-zinc-300 dark:border-white/10 hover:border-pink-500/30 flex flex-col items-center justify-center cursor-pointer transition-all duration-300 hover:bg-white/[0.02] group"
onClick={() => setAddMenuOpen(!addMenuOpen)}
>
<div className="w-10 h-10 rounded-full bg-white/5 group-hover:bg-pink-500/10 flex items-center justify-center mb-2 transition-colors">
<Plus className="w-5 h-5 text-zinc-500 group-hover:text-pink-400 transition-colors" />
</div>
<span className="text-xs text-zinc-500 group-hover:text-zinc-700 dark:text-zinc-300 font-medium transition-colors">
Add Album
</span>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,144 @@
import React from 'react';
import { ChevronLeft, ChevronDown, ChevronRight, Settings2, FileText, Users, Music2, Headphones } from 'lucide-react';
import type { Artist, LyricsSet, SongLyric } from '../../services/lireekApi';
import { TripleProviderSelector, type ModelSelections, loadSelections, saveSelections } from './ProviderSelector';
import { useDisguiseMode } from '../../hooks/useDisguiseMode';
function parseSongs(songs: SongLyric[] | string): SongLyric[] {
if (typeof songs === 'string') {
try { return JSON.parse(songs); } catch { return []; }
}
return songs || [];
}
interface AlbumHeaderProps {
artist: Artist;
album: LyricsSet;
onBack: () => void;
onOpenPreset: () => void;
profileCount?: number;
generationCount?: number;
songCount?: number;
}
export const AlbumHeader: React.FC<AlbumHeaderProps> = ({
artist, album, onBack, onOpenPreset, profileCount = 0, generationCount = 0, songCount = 0,
}) => {
const [imageError, setImageError] = React.useState(false);
const [modelSelections, setModelSelections] = React.useState<ModelSelections>(loadSelections);
const [llmExpanded, setLlmExpanded] = React.useState(false);
const songs = parseSongs(album.songs);
const { disguiseArtist, disguiseAlbum, disguiseImageUrl } = useDisguiseMode();
const gradient = (name: string) => {
const hash = name.split('').reduce((a, c) => ((a << 5) - a + c.charCodeAt(0)) | 0, 0);
const h1 = Math.abs(hash) % 360;
const h2 = (h1 + 40) % 360;
return `linear-gradient(180deg, hsl(${h1}, 50%, 20%) 0%, hsl(${h2}, 40%, 12%) 100%)`;
};
return (
<div className="flex flex-col h-full bg-zinc-50 dark:bg-zinc-950/50">
{/* Back button */}
<button
onClick={onBack}
className="flex items-center gap-2 px-4 py-3 text-sm text-zinc-600 dark:text-zinc-400 hover:text-white hover:bg-white/5 border-b border-zinc-200 dark:border-white/5 transition-colors"
>
<ChevronLeft className="w-4 h-4" />
Albums
</button>
<div className="relative">
{(() => {
const dUrl = disguiseImageUrl(artist.image_url, artist.name);
const dArtistName = disguiseArtist(artist.name);
return dUrl && !imageError ? (
<img
src={dUrl}
alt={dArtistName}
className="w-full aspect-video object-cover"
onError={() => setImageError(true)}
/>
) : (
<div className="w-full aspect-video" style={{ background: gradient(dArtistName) }} />
);
})()}
<div className="absolute inset-0 bg-gradient-to-t from-zinc-950 via-transparent to-transparent" />
</div>
{/* Info */}
<div className="px-4 py-4 -mt-8 relative z-10">
<p className="text-xs text-zinc-600 dark:text-zinc-400 uppercase tracking-wider mb-1 font-semibold">
{disguiseArtist(artist.name)}
</p>
<h2 className="text-lg font-bold text-white leading-tight mb-3">
{disguiseAlbum(album.album || '') || 'Top Songs'}
</h2>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-zinc-600 dark:text-zinc-400">
<FileText className="w-3.5 h-3.5" />
<span>{songs.length} source lyrics</span>
</div>
{profileCount > 0 && (
<div className="flex items-center gap-2 text-zinc-600 dark:text-zinc-400">
<Users className="w-3.5 h-3.5" />
<span>{profileCount} profile{profileCount !== 1 ? 's' : ''}</span>
</div>
)}
{generationCount > 0 && (
<div className="flex items-center gap-2 text-zinc-600 dark:text-zinc-400">
<Music2 className="w-3.5 h-3.5" />
<span>{generationCount} generated lyric{generationCount !== 1 ? 's' : ''}</span>
</div>
)}
{songCount > 0 && (
<div className="flex items-center gap-2 text-zinc-600 dark:text-zinc-400">
<Headphones className="w-3.5 h-3.5" />
<span>{songCount} generated song{songCount !== 1 ? 's' : ''}</span>
</div>
)}
</div>
</div>
{/* Spacer pushes Preset + LLM to bottom */}
<div className="flex-1" />
{/* Preset button */}
<div className="px-4 pb-2">
<button
onClick={onOpenPreset}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-zinc-300 dark:border-white/10 text-sm text-zinc-700 dark:text-zinc-300 hover:text-white font-medium transition-all"
>
<Settings2 className="w-4 h-4" />
Album Preset
</button>
</div>
{/* LLM Model Selector — collapsed accordion */}
<div className="px-4 pb-4">
<button
onClick={() => setLlmExpanded(!llmExpanded)}
className="w-full flex items-center justify-between px-3 py-2 rounded-lg bg-white/[0.03] hover:bg-white/[0.06] border border-zinc-200 dark:border-white/5 text-[11px] text-zinc-500 uppercase tracking-wider font-semibold transition-colors"
>
<span>LLM Models</span>
{llmExpanded
? <ChevronDown className="w-3.5 h-3.5" />
: <ChevronRight className="w-3.5 h-3.5" />
}
</button>
{llmExpanded && (
<div className="mt-2 animate-in slide-in-from-top-1 duration-150">
<TripleProviderSelector
selections={modelSelections}
onSelectionsChange={(sel) => {
setModelSelections(sel);
saveSelections(sel);
}}
/>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,238 @@
import React, { useState, useEffect, useRef } from 'react';
import { Music, Plus, RefreshCw, Trash2, MoreVertical, Link2, Search, PenLine, ChevronDown } from 'lucide-react';
import type { Artist } from '../../services/lireekApi';
import { useDisguiseMode } from '../../hooks/useDisguiseMode';
interface ArtistGridProps {
artists: Artist[];
loading: boolean;
onSelectArtist: (artist: Artist) => void;
onAddNew: () => void;
onAddManual: () => void;
onDelete: (artist: Artist) => void;
onRefreshImage: (artist: Artist) => void;
onSetImage?: (artist: Artist, url: string) => void;
}
export const ArtistGrid: React.FC<ArtistGridProps> = ({
artists, loading, onSelectArtist, onAddNew, onAddManual, onDelete, onRefreshImage, onSetImage,
}) => {
const { disguiseArtist, disguiseImageUrl } = useDisguiseMode();
const [menuOpenId, setMenuOpenId] = useState<number | null>(null);
const [imageErrors, setImageErrors] = useState<Set<number>>(new Set());
const [addMenuOpen, setAddMenuOpen] = useState(false);
const gridRef = useRef<HTMLDivElement>(null);
const addBtnRef = useRef<HTMLDivElement>(null);
// Close context menus on click outside
useEffect(() => {
if (menuOpenId === null && !addMenuOpen) return;
const handler = (e: MouseEvent) => {
if (gridRef.current && !gridRef.current.contains(e.target as Node)) {
setMenuOpenId(null);
}
if (addBtnRef.current && !addBtnRef.current.contains(e.target as Node)) {
setAddMenuOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [menuOpenId, addMenuOpen]);
const gradient = (name: string) => {
const hash = name.split('').reduce((a, c) => ((a << 5) - a + c.charCodeAt(0)) | 0, 0);
const h1 = Math.abs(hash) % 360;
const h2 = (h1 + 40) % 360;
return `linear-gradient(135deg, hsl(${h1}, 70%, 35%), hsl(${h2}, 60%, 25%))`;
};
if (loading) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 p-6">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="aspect-[3/4] rounded-2xl bg-white/5 animate-pulse" />
))}
</div>
);
}
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<Music className="w-7 h-7 text-pink-400" />
Lyric Studio
</h1>
<div className="relative" ref={addBtnRef}>
<button
onClick={() => setAddMenuOpen(!addMenuOpen)}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-pink-600 hover:bg-pink-500 text-white text-sm font-semibold transition-all hover:scale-105 shadow-lg shadow-pink-600/20"
>
<Plus className="w-4 h-4" />
Add Artist
<ChevronDown className="w-3.5 h-3.5 opacity-70" />
</button>
{addMenuOpen && (
<div className="absolute right-0 top-full mt-1 z-30 min-w-[180px] rounded-xl bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 shadow-2xl py-1 animate-in fade-in slide-in-from-top-1">
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-white/5 hover:text-white transition-colors"
onClick={() => { setAddMenuOpen(false); onAddNew(); }}
>
<Search className="w-3.5 h-3.5" />
Fetch from Genius
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-white/5 hover:text-white transition-colors"
onClick={() => { setAddMenuOpen(false); onAddManual(); }}
>
<PenLine className="w-3.5 h-3.5" />
Add Manually
</button>
</div>
)}
</div>
</div>
{artists.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="w-20 h-20 rounded-full bg-white/5 flex items-center justify-center mb-4">
<Music className="w-10 h-10 text-zinc-600" />
</div>
<h2 className="text-lg font-semibold text-zinc-600 dark:text-zinc-400 mb-2">No artists yet</h2>
<p className="text-sm text-zinc-500 max-w-sm mb-6">
Start by fetching lyrics from Genius or adding an artist manually.
</p>
<div className="flex items-center gap-3">
<button
onClick={onAddNew}
className="flex items-center gap-2 px-5 py-3 rounded-xl bg-pink-600 hover:bg-pink-500 text-white text-sm font-semibold transition-all"
>
<Search className="w-4 h-4" />
Fetch from Genius
</button>
<button
onClick={onAddManual}
className="flex items-center gap-2 px-5 py-3 rounded-xl bg-white/5 hover:bg-white/10 border border-zinc-300 dark:border-white/10 text-zinc-700 dark:text-zinc-300 hover:text-white text-sm font-semibold transition-all"
>
<PenLine className="w-4 h-4" />
Add Manually
</button>
</div>
</div>
) : (
<div ref={gridRef} className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{artists.map((artist, idx) => (
<div
key={artist.id}
className={`group relative aspect-[3/4] rounded-2xl cursor-pointer transition-all duration-300 hover:scale-[1.03] hover:shadow-2xl hover:shadow-pink-500/10 ls2-card-in ls2-stagger-${Math.min(idx + 1, 11)} ${menuOpenId === artist.id ? 'z-30' : ''}`}
onClick={() => onSelectArtist(artist)}
>
{/* Image clip wrapper — overflow-hidden here so the context menu can extend beyond the card */}
<div className="absolute inset-0 rounded-2xl overflow-hidden">
{/* Background image or gradient */}
{(() => {
const dUrl = disguiseImageUrl(artist.image_url, artist.name);
const dName = disguiseArtist(artist.name);
return dUrl && !imageErrors.has(artist.id) ? (
<img
src={dUrl}
alt={dName}
className="absolute inset-0 w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
onError={() => setImageErrors(prev => new Set(prev).add(artist.id))}
/>
) : (
<div
className="absolute inset-0 flex items-center justify-center"
style={{ background: gradient(dName) }}
>
<span className="text-5xl font-black text-white/20 select-none">
{dName.charAt(0).toUpperCase()}
</span>
</div>
);
})()}
{/* Dark overlay gradient */}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent" />
</div>
{/* Content */}
<div className="absolute inset-x-0 bottom-0 p-4">
<h3 className="text-base font-bold text-white truncate mb-1 drop-shadow-lg">
{disguiseArtist(artist.name)}
</h3>
<p className="text-xs text-zinc-700 dark:text-zinc-300/80">
{artist.lyrics_set_count ?? 0} album{(artist.lyrics_set_count ?? 0) !== 1 ? 's' : ''}
</p>
</div>
{/* Hover glow ring */}
<div className="absolute inset-0 rounded-2xl ring-1 ring-white/10 group-hover:ring-pink-500/40 transition-all duration-300" />
{/* Context menu button */}
<button
className="absolute top-2 right-2 p-1.5 rounded-lg bg-black/20 dark:bg-black/50 text-white/60 hover:text-white hover:bg-black/20 dark:bg-black/40 dark:bg-black/70 opacity-0 group-hover:opacity-100 transition-all z-10"
onClick={(e) => {
e.stopPropagation();
setMenuOpenId(menuOpenId === artist.id ? null : artist.id);
}}
>
<MoreVertical className="w-4 h-4" />
</button>
{/* Context menu */}
{menuOpenId === artist.id && (
<div
className="absolute top-10 right-2 z-20 min-w-[160px] rounded-xl bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 shadow-2xl py-1 animate-in fade-in slide-in-from-top-1"
onClick={(e) => e.stopPropagation()}
>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-white/5 hover:text-white transition-colors"
onClick={() => { onRefreshImage(artist); setMenuOpenId(null); }}
>
<RefreshCw className="w-3.5 h-3.5" />
Re-fetch from Genius
</button>
{onSetImage && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-white/5 hover:text-white transition-colors"
onClick={() => {
setMenuOpenId(null);
const url = prompt(`Paste an image URL for ${artist.name}:`, artist.image_url || '');
if (url && url.trim()) onSetImage(artist, url.trim());
}}
>
<Link2 className="w-3.5 h-3.5" />
Set Custom Image
</button>
)}
<div className="border-t border-zinc-200 dark:border-white/5 my-1" />
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors"
onClick={() => { onDelete(artist); setMenuOpenId(null); }}
>
<Trash2 className="w-3.5 h-3.5" />
Delete Artist
</button>
</div>
)}
</div>
))}
{/* Add new card — dropdown */}
<div
className="relative aspect-[3/4] rounded-2xl border-2 border-dashed border-zinc-300 dark:border-white/10 hover:border-pink-500/30 flex flex-col items-center justify-center cursor-pointer transition-all duration-300 hover:bg-white/[0.02] group"
onClick={() => setAddMenuOpen(!addMenuOpen)}
>
<div className="w-12 h-12 rounded-full bg-white/5 group-hover:bg-pink-500/10 flex items-center justify-center mb-3 transition-colors">
<Plus className="w-6 h-6 text-zinc-500 group-hover:text-pink-400 transition-colors" />
</div>
<span className="text-sm text-zinc-500 group-hover:text-zinc-700 dark:text-zinc-300 font-medium transition-colors">
Add Artist
</span>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,229 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronDown, ChevronRight, ListOrdered, Code2, Download, Clock, Shuffle, Zap } from 'lucide-react';
import type { Artist } from '../../services/lireekApi';
import { TripleProviderSelector, type ModelSelections, loadSelections, saveSelections } from './ProviderSelector';
import { LLM_DURATION_KEY } from '../../utils/estimateDuration';
import { useDisguiseMode } from '../../hooks/useDisguiseMode';
// ── Persisted state hook ────────────────────────────────────────────────────
function useLocalPersistedState<T>(key: string, defaultValue: T): [T, React.Dispatch<React.SetStateAction<T>>] {
const [state, setState] = useState<T>(() => {
try {
const raw = localStorage.getItem(key);
return raw !== null ? JSON.parse(raw) : defaultValue;
} catch { return defaultValue; }
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(state));
}, [key, state]);
return [state, setState];
}
// ── Props ────────────────────────────────────────────────────────────────────
interface ArtistPageSidebarProps {
artist?: Artist;
albumCount?: number;
onOpenQueue: () => void;
onOpenPromptEditor: () => void;
onGenerateAll?: () => void;
}
export const ArtistPageSidebar: React.FC<ArtistPageSidebarProps> = ({
artist, albumCount, onOpenQueue, onOpenPromptEditor, onGenerateAll,
}) => {
const [imageError, setImageError] = useState(false);
const { t } = useTranslation();
const { disguiseArtist, disguiseImageUrl } = useDisguiseMode();
// ── LLM Models ──
const [modelSelections, setModelSelections] = useState<ModelSelections>(loadSelections);
const [llmExpanded, setLlmExpanded] = useState(false);
// ── Download filename prepend ──
const [filenamePrepend, setFilenamePrepend] = useLocalPersistedState<string>('lireek-downloadFilenamePrepend', '');
// ── LLM Duration toggle ──
const [useLlmDuration, setUseLlmDuration] = useLocalPersistedState<boolean>(LLM_DURATION_KEY, true);
// ── Randomize Timbre Reference ──
const [randomizeTimbre, setRandomizeTimbre] = useLocalPersistedState<boolean>('lireek-randomizeTimbreRef', false);
const gradient = (name: string) => {
const hash = name.split('').reduce((a, c) => ((a << 5) - a + c.charCodeAt(0)) | 0, 0);
const h1 = Math.abs(hash) % 360;
const h2 = (h1 + 40) % 360;
return `linear-gradient(180deg, hsl(${h1}, 50%, 20%) 0%, hsl(${h2}, 40%, 12%) 100%)`;
};
return (
<div className="flex flex-col h-full bg-zinc-50 dark:bg-zinc-950/50 overflow-hidden">
{/* Artist header — only shown when artist context available */}
{artist && (
<>
<div className="relative flex-shrink-0">
{(() => {
const dUrl = disguiseImageUrl(artist.image_url, artist.name);
return dUrl && !imageError ? (
<img
src={dUrl}
alt={disguiseArtist(artist.name)}
className="w-full aspect-[16/9] object-cover"
onError={() => setImageError(true)}
/>
) : (
<div className="w-full aspect-[16/9]" style={{ background: gradient(disguiseArtist(artist.name)) }} />
);
})()}
<div className="absolute inset-0 bg-gradient-to-t from-zinc-950 via-transparent to-transparent" />
</div>
<div className="px-4 py-3 -mt-6 relative z-10 flex-shrink-0">
<h2 className="text-base font-bold text-white leading-tight">{disguiseArtist(artist.name)}</h2>
<p className="text-xs text-zinc-600 dark:text-zinc-400 mt-0.5">
{(albumCount ?? 0)} album{(albumCount ?? 0) !== 1 ? 's' : ''}
</p>
</div>
</>
)}
{/* Action buttons */}
<div className="px-4 py-2 flex gap-2 flex-shrink-0">
<button
onClick={onOpenQueue}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg bg-pink-600/20 hover:bg-pink-600/30 text-pink-400 text-xs font-semibold transition-colors"
>
<ListOrdered className="w-3.5 h-3.5" />
{t('lyric.bulkOperations')}
</button>
<button
onClick={onOpenPromptEditor}
className="px-3 py-2 rounded-lg bg-white/5 hover:bg-white/10 text-zinc-600 dark:text-zinc-400 hover:text-white text-xs transition-colors"
title={t('lyric.editSystemPrompts')}
>
<Code2 className="w-3.5 h-3.5" />
</button>
</div>
{/* Scrollable settings area */}
<div className="flex-1 overflow-y-auto scrollbar-hide px-4 pb-4 space-y-3">
{/* ── Download Filename Prepend ─────────────────────────── */}
<div>
<div
className="w-full flex items-center justify-between px-3 py-2 rounded-lg bg-white/[0.03] border border-zinc-200 dark:border-white/5 text-[11px] text-zinc-500 uppercase tracking-wider font-semibold"
>
<span className="flex items-center gap-1.5">
<Download className="w-3 h-3" />
{t('lyric.filenamePrepend')}
</span>
</div>
<div className="mt-2 px-1">
<input
type="text"
value={filenamePrepend}
onChange={e => setFilenamePrepend(e.target.value)}
placeholder="e.g. MyLabel - "
className="w-full bg-zinc-200 dark:bg-black/20 border border-zinc-300 dark:border-white/10 rounded-lg px-3 py-1.5 text-xs text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-pink-500 transition-colors"
/>
<p className="text-[10px] text-zinc-600 mt-1 leading-tight">
Prepended to download filenames, e.g. <span className="text-zinc-500">{filenamePrepend || '...'}</span>Artist - Song.flac
</p>
</div>
</div>
{/* ── LLM Duration Override ───────────────────────────── */}
<div>
<label
className="w-full flex items-center justify-between px-3 py-2 rounded-lg bg-white/[0.03] border border-zinc-200 dark:border-white/5 cursor-pointer hover:bg-white/[0.06] transition-colors"
>
<span className="flex items-center gap-1.5 text-[11px] text-zinc-500 uppercase tracking-wider font-semibold">
<Clock className="w-3 h-3" />
{t('lyric.llmDuration')}
</span>
<input
type="checkbox"
checked={useLlmDuration}
onChange={e => setUseLlmDuration(e.target.checked)}
className="w-3.5 h-3.5 rounded border-zinc-600 bg-zinc-100 dark:bg-zinc-800 text-pink-500 focus:ring-pink-500 focus:ring-offset-0 cursor-pointer"
/>
</label>
<p className="text-[10px] text-zinc-600 mt-1 px-1 leading-tight">
{useLlmDuration
? <>Uses the LLM's estimated duration — may overshoot, causing "double song" artifacts.</>
: <>Uses calculated duration from lyrics + BPM — tighter fit, less wasted generation.</>
}
</p>
</div>
{/* ── Randomize Timbre Reference ────────────────────────── */}
<div>
<label
className="w-full flex items-center justify-between px-3 py-2 rounded-lg bg-white/[0.03] border border-zinc-200 dark:border-white/5 cursor-pointer hover:bg-white/[0.06] transition-colors"
>
<span className="flex items-center gap-1.5 text-[11px] text-zinc-500 uppercase tracking-wider font-semibold">
<Shuffle className="w-3 h-3" />
{t('lyric.randomizeTimbre')}
</span>
<input
type="checkbox"
checked={randomizeTimbre}
onChange={e => setRandomizeTimbre(e.target.checked)}
className="w-3.5 h-3.5 rounded border-zinc-600 bg-zinc-100 dark:bg-zinc-800 text-amber-500 focus:ring-amber-500 focus:ring-offset-0 cursor-pointer"
/>
</label>
<p className="text-[10px] text-zinc-600 mt-1 px-1 leading-tight">
{randomizeTimbre
? <>Picks a random track from the reference folder as timbre conditioner — prevents riff leakage from a single reference.</>
: <>Uses the exact reference track set in the album preset for timbre conditioning.</>
}
</p>
</div>
{/* ── Generate All Audio ──────────────────────────────── */}
{onGenerateAll && (
<div>
<button
onClick={onGenerateAll}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg bg-gradient-to-r from-amber-600/20 to-pink-600/20 hover:from-amber-600/30 hover:to-pink-600/30 border border-amber-500/10 hover:border-amber-500/20 text-amber-400 hover:text-amber-300 text-xs font-semibold transition-all"
>
<Zap className="w-3.5 h-3.5" />
Generate All Audio
</button>
<p className="text-[10px] text-zinc-600 mt-1 px-1 leading-tight">
Queue every written song from every artist for audio generation.
</p>
</div>
)}
{/* ── LLM Models ──────────────────────────────────────────── */}
<div>
<button
onClick={() => setLlmExpanded(!llmExpanded)}
className="w-full flex items-center justify-between px-3 py-2 rounded-lg bg-white/[0.03] hover:bg-white/[0.06] border border-zinc-200 dark:border-white/5 text-[11px] text-zinc-500 uppercase tracking-wider font-semibold transition-colors"
>
<span>{t('lyric.llmModels')}</span>
{llmExpanded
? <ChevronDown className="w-3.5 h-3.5" />
: <ChevronRight className="w-3.5 h-3.5" />
}
</button>
{llmExpanded && (
<div className="mt-2 animate-in slide-in-from-top-1 duration-150">
<TripleProviderSelector
selections={modelSelections}
onSelectionsChange={(sel) => {
setModelSelections(sel);
saveSelections(sel);
}}
/>
</div>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,121 @@
import React, { useRef, useEffect, useCallback } from 'react';
import { ChevronLeft } from 'lucide-react';
import type { Artist } from '../../services/lireekApi';
import { useDisguiseMode } from '../../hooks/useDisguiseMode';
interface ArtistSidebarProps {
artists: Artist[];
selectedArtistId: number;
onSelectArtist: (artist: Artist) => void;
onBack: () => void;
artistIdsWithAdapters?: Set<number>;
}
const SCROLL_KEY = 'ls-artist-sidebar-scroll';
export const ArtistSidebar: React.FC<ArtistSidebarProps> = ({
artists, selectedArtistId, onSelectArtist, onBack,
artistIdsWithAdapters,
}) => {
const { disguiseArtist, disguiseImageUrl } = useDisguiseMode();
const [imageErrors, setImageErrors] = React.useState<Set<number>>(new Set());
const scrollRef = useRef<HTMLDivElement>(null);
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Restore scroll position on mount
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
try {
const saved = sessionStorage.getItem(SCROLL_KEY);
if (saved) el.scrollTop = Number(saved);
} catch { /* ignore */ }
}, []);
// Debounced save on scroll
const handleScroll = useCallback(() => {
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
try {
const el = scrollRef.current;
if (el) sessionStorage.setItem(SCROLL_KEY, String(el.scrollTop));
} catch { /* ignore */ }
}, 150);
}, []);
const gradient = (name: string) => {
const hash = name.split('').reduce((a, c) => ((a << 5) - a + c.charCodeAt(0)) | 0, 0);
const h1 = Math.abs(hash) % 360;
return `hsl(${h1}, 50%, 30%)`;
};
return (
<div className="flex flex-col h-full bg-zinc-50 dark:bg-zinc-950/50">
{/* Back button */}
<button
onClick={onBack}
className="flex items-center gap-2 px-4 py-3 text-sm text-zinc-600 dark:text-zinc-400 hover:text-white hover:bg-white/5 border-b border-zinc-200 dark:border-white/5 transition-colors"
>
<ChevronLeft className="w-4 h-4" />
All Artists
</button>
{/* Artist list */}
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto py-2">
{artists.map((artist) => {
const isSelected = artist.id === selectedArtistId;
const hasAdapter = !artistIdsWithAdapters || artistIdsWithAdapters.size === 0 || artistIdsWithAdapters.has(artist.id);
return (
<button
key={artist.id}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-all ${
isSelected
? 'bg-pink-500/10 border-l-2 border-pink-500'
: 'hover:bg-white/5 border-l-2 border-transparent'
}`}
style={!isSelected && !hasAdapter ? { backgroundColor: 'rgba(220, 38, 38, 0.08)' } : undefined}
onClick={() => onSelectArtist(artist)}
>
{/* Mini avatar */}
<div className="w-8 h-8 flex-shrink-0 rounded-lg overflow-hidden">
{(() => {
const dUrl = disguiseImageUrl(artist.image_url, artist.name);
const dName = disguiseArtist(artist.name);
return dUrl && !imageErrors.has(artist.id) ? (
<img
src={dUrl}
alt={dName}
className="w-full h-full object-cover"
onError={() => setImageErrors(prev => new Set(prev).add(artist.id))}
/>
) : (
<div
className="w-full h-full flex items-center justify-center text-xs font-bold text-white/60"
style={{ backgroundColor: gradient(dName) }}
>
{dName.charAt(0).toUpperCase()}
</div>
);
})()}
</div>
<div className="min-w-0 flex-1">
<p className={`text-sm font-medium truncate ${isSelected ? 'text-pink-400' : 'text-zinc-700 dark:text-zinc-300'}`}>
{disguiseArtist(artist.name)}
</p>
<p className="text-[11px] text-zinc-500">
{artist.lyrics_set_count ?? 0} album{(artist.lyrics_set_count ?? 0) !== 1 ? 's' : ''}
</p>
</div>
</button>
);
})}
</div>
{/* Artist count */}
<div className="px-4 py-2 border-t border-zinc-200 dark:border-white/5 text-[10px] text-zinc-600 text-center">
{artists.length} artist{artists.length !== 1 ? 's' : ''}
</div>
</div>
);
};
@@ -0,0 +1,71 @@
import React from 'react';
import { FileText, Users, Music2, Headphones } from 'lucide-react';
export type TabId = 'source-lyrics' | 'profiles' | 'written-songs' | 'recordings';
interface Tab {
id: TabId;
label: string;
icon: React.ReactNode;
badge?: number;
}
interface ContentTabsProps {
activeTab: TabId;
onTabChange: (tab: TabId) => void;
sourceLyricsCount?: number;
profilesCount?: number;
writtenSongsCount?: number;
recordingsCount?: number;
children: React.ReactNode;
}
export const ContentTabs: React.FC<ContentTabsProps> = ({
activeTab, onTabChange, sourceLyricsCount, profilesCount, writtenSongsCount, recordingsCount, children,
}) => {
const tabs: Tab[] = [
{ id: 'source-lyrics', label: 'Source Lyrics', icon: <FileText className="w-4 h-4" />, badge: sourceLyricsCount },
{ id: 'profiles', label: 'Profiles', icon: <Users className="w-4 h-4" />, badge: profilesCount },
{ id: 'written-songs', label: 'Generated Lyrics', icon: <Music2 className="w-4 h-4" />, badge: writtenSongsCount },
{ id: 'recordings', label: 'Generated Songs', icon: <Headphones className="w-4 h-4" />, badge: recordingsCount },
];
return (
<div className="flex flex-col h-full">
{/* Tab bar */}
<div className="flex-shrink-0 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/30 dark:bg-zinc-950/30">
<div className="flex">
{tabs.map(tab => {
const isActive = tab.id === activeTab;
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium transition-all border-b-2 ${
isActive
? 'text-pink-400 border-pink-500 bg-pink-500/5'
: 'text-zinc-600 dark:text-zinc-400 border-transparent hover:text-zinc-800 dark:hover:text-zinc-200 hover:bg-white/[0.02]'
}`}
>
{tab.icon}
<span className="hidden sm:inline">{tab.label}</span>
{tab.badge != null && tab.badge > 0 && (
<span className={`min-w-[20px] h-5 px-1.5 rounded-full text-[11px] font-bold flex items-center justify-center ${
isActive ? 'bg-pink-500/20 text-pink-300' : 'bg-white/10 text-zinc-600 dark:text-zinc-400'
}`}>
{tab.badge}
</span>
)}
</button>
);
})}
</div>
</div>
{/* Tab content */}
<div key={activeTab} className="flex-1 overflow-y-auto ls2-tab-content">
{children}
</div>
</div>
);
};
@@ -0,0 +1,292 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Loader2, Sparkles, ChevronDown, ChevronRight, Check, Disc3, FileText, CheckSquare, Square } from 'lucide-react';
import { lireekApi } from '../../services/lireekApi';
import type { LyricsSet, SongLyric, Profile } from '../../services/lireekApi';
import { loadSelections } from './ProviderSelector';
function parseSongs(songs: SongLyric[] | string): SongLyric[] {
if (typeof songs === 'string') {
try { return JSON.parse(songs); } catch { return []; }
}
return songs || [];
}
interface CuratedProfileModalProps {
isOpen: boolean;
onClose: () => void;
artistId: number;
artistName: string;
albums: LyricsSet[];
showToast: (msg: string) => void;
onComplete: (lyricsSet: LyricsSet, profile: Profile) => void;
}
type SelectionMap = Record<number, Set<number>>;
export const CuratedProfileModal: React.FC<CuratedProfileModalProps> = ({
isOpen, onClose, artistId, artistName, albums, showToast, onComplete,
}) => {
const [expandedAlbumId, setExpandedAlbumId] = useState<number | null>(null);
const { t } = useTranslation();
const [selections, setSelections] = useState<SelectionMap>({});
const [building, setBuilding] = useState(false);
const [streamPhase, setStreamPhase] = useState('');
const [streamText, setStreamText] = useState('');
useEffect(() => {
if (isOpen) {
setSelections({});
setExpandedAlbumId(null);
setBuilding(false);
setStreamPhase('');
setStreamText('');
}
}, [isOpen]);
const [fullAlbums, setFullAlbums] = useState<LyricsSet[]>([]);
const [loadingAlbums, setLoadingAlbums] = useState(false);
useEffect(() => {
if (!isOpen) return;
const load = async () => {
setLoadingAlbums(true);
try {
const results = await Promise.all(
albums.map(a => lireekApi.getLyricsSet(a.id))
);
setFullAlbums(results);
} catch (err) {
console.error('Failed to load album details:', err);
} finally {
setLoadingAlbums(false);
}
};
load();
}, [isOpen, albums]);
const toggleSong = useCallback((albumId: number, songIdx: number) => {
setSelections(prev => {
const next = { ...prev };
const set = new Set(next[albumId] || []);
if (set.has(songIdx)) { set.delete(songIdx); } else { set.add(songIdx); }
next[albumId] = set;
return next;
});
}, []);
const toggleAlbum = useCallback((albumId: number) => {
const album = fullAlbums.find(a => a.id === albumId);
if (!album) return;
const songs = parseSongs(album.songs);
setSelections(prev => {
const next = { ...prev };
const currentSet = next[albumId] || new Set();
next[albumId] = currentSet.size === songs.length
? new Set()
: new Set(songs.map((_, i) => i));
return next;
});
}, [fullAlbums]);
const totalSelected = (Object.values(selections) as Set<number>[]).reduce((sum, set) => sum + set.size, 0);
const handleBuild = useCallback(async () => {
if (totalSelected === 0) return;
setBuilding(true);
setStreamPhase('Preparing curated selection…');
setStreamText('');
const selectionList = Object.entries(selections)
.filter(([_, set]) => (set as Set<number>).size > 0)
.map(([albumId, set]) => ({
lyrics_set_id: Number(albumId),
song_indices: Array.from(set as Set<number>).sort((a, b) => a - b),
}));
const { profiling } = loadSelections();
try {
// TODO: Backend curated-profile endpoint needs implementation
const res = await fetch('/api/lireek/curated-profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
artist_id: artistId,
selections: selectionList,
provider: profiling.provider,
model: profiling.model || undefined,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || `HTTP ${res.status}`);
}
// If streaming, consume SSE
if (res.headers.get('content-type')?.includes('text/event-stream')) {
const reader = res.body?.getReader();
const decoder = new TextDecoder();
if (reader) {
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
try {
const parsed = JSON.parse(data);
if (parsed.phase) {
setStreamPhase(parsed.phase);
setStreamText('');
} else if (parsed.chunk) {
setStreamText(prev => prev + parsed.chunk);
} else if (parsed.result) {
setBuilding(false);
showToast('Curated profile built successfully!');
onComplete(parsed.result.lyrics_set, parsed.result.profile);
onClose();
return;
} else if (parsed.error) {
throw new Error(parsed.error);
}
} catch {}
}
}
}
}
} else {
// Non-streaming response
const data = await res.json();
setBuilding(false);
showToast('Curated profile built successfully!');
onComplete(data.lyrics_set, data.profile);
onClose();
}
} catch (err: any) {
setBuilding(false);
showToast(`Build failed: ${err.message}`);
}
}, [totalSelected, selections, artistId, showToast, onComplete, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/30 dark:bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4" onClick={onClose}>
<div
className="bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-white/5 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-500/10 flex items-center justify-center">
<Sparkles className="w-4 h-4 text-purple-400" />
</div>
<div>
<h2 className="text-lg font-bold text-white">{t('lyric.buildCuratedProfile')}</h2>
<p className="text-xs text-zinc-500">{artistName} Pick songs from any album</p>
</div>
</div>
<button onClick={onClose} disabled={building}
className="p-1.5 rounded-lg hover:bg-white/5 text-zinc-600 dark:text-zinc-400 hover:text-white transition-colors disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Album list */}
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{loadingAlbums ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 text-zinc-500 animate-spin" />
</div>
) : fullAlbums.length === 0 ? (
<div className="text-center py-12 text-zinc-500">{t('lyric.noAlbumsFound')}</div>
) : (
fullAlbums.map(album => {
const songs = parseSongs(album.songs);
const isExpanded = expandedAlbumId === album.id;
const selectedInAlbum = selections[album.id]?.size || 0;
const allSelected = selectedInAlbum === songs.length && songs.length > 0;
return (
<div key={album.id} className="rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden">
<div className="flex items-center gap-3 px-4 py-3 hover:bg-white/[0.02] cursor-pointer transition-colors"
onClick={() => setExpandedAlbumId(isExpanded ? null : album.id)}
>
{isExpanded ? <ChevronDown className="w-4 h-4 text-zinc-500" /> : <ChevronRight className="w-4 h-4 text-zinc-500" />}
<Disc3 className="w-4 h-4 text-zinc-600 dark:text-zinc-400" />
<span className="flex-1 text-sm font-medium text-zinc-800 dark:text-zinc-200 truncate">{album.album || 'Top Songs'}</span>
{selectedInAlbum > 0 && (
<span className="px-2 py-0.5 rounded-full text-[11px] font-bold bg-purple-500/20 text-purple-300">{selectedInAlbum}/{songs.length}</span>
)}
<span className="text-xs text-zinc-500">{songs.length} songs</span>
</div>
{isExpanded && (
<div className="border-t border-zinc-200 dark:border-white/5">
<button className="w-full flex items-center gap-2 px-4 py-2 text-xs text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:hover:text-zinc-200 hover:bg-white/[0.02] transition-colors"
onClick={(e) => { e.stopPropagation(); toggleAlbum(album.id); }}
>
{allSelected ? <CheckSquare className="w-3.5 h-3.5 text-purple-400" /> : <Square className="w-3.5 h-3.5" />}
{allSelected ? t('lyric.deselectAll') : t('lyric.selectAll')}
</button>
{songs.map((song, idx) => {
const isSelected = selections[album.id]?.has(idx) || false;
return (
<button key={idx}
className={`w-full flex items-center gap-3 px-4 py-2 text-left hover:bg-white/[0.02] transition-colors ${isSelected ? 'bg-purple-500/5' : ''}`}
onClick={() => toggleSong(album.id, idx)}
>
{isSelected ? <Check className="w-3.5 h-3.5 text-purple-400 flex-shrink-0" /> : <div className="w-3.5 h-3.5 rounded border border-white/20 flex-shrink-0" />}
<FileText className="w-3 h-3 text-zinc-500 flex-shrink-0" />
<span className={`text-sm truncate ${isSelected ? 'text-white' : 'text-zinc-600 dark:text-zinc-400'}`}>{song.title}</span>
<span className="text-[11px] text-zinc-600 ml-auto flex-shrink-0">{(song.lyrics || '').split('\n').length} lines</span>
</button>
);
})}
</div>
)}
</div>
);
})
)}
{building && (
<div className="rounded-xl border border-purple-500/20 bg-purple-500/5 p-4 mt-4">
<div className="flex items-center gap-2 mb-2">
<Loader2 className="w-4 h-4 text-purple-400 animate-spin" />
<span className="text-sm font-medium text-purple-300">{streamPhase}</span>
</div>
{streamText && (
<pre className="text-xs text-zinc-600 dark:text-zinc-400 whitespace-pre-wrap max-h-32 overflow-y-auto font-mono leading-relaxed">{streamText}</pre>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-white/5 bg-zinc-100/80 dark:bg-zinc-900/80 flex-shrink-0">
<span className="text-sm text-zinc-600 dark:text-zinc-400">
{totalSelected > 0 ? (
<><strong className="text-white">{totalSelected}</strong> songs selected across{' '}
<strong className="text-white">
{(Object.values(selections) as Set<number>[]).filter(s => s.size > 0).length}
</strong> albums</>
) : t('lyric.selectSongsToProfile')}
</span>
<button onClick={handleBuild} disabled={totalSelected === 0 || building}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-purple-600 hover:bg-purple-500 disabled:bg-zinc-200 dark:disabled:bg-zinc-200 dark:bg-zinc-700 disabled:text-zinc-500 text-white text-sm font-semibold transition-all"
>
{building ? (<><Loader2 className="w-4 h-4 animate-spin" />{t('lyric.building')}</>) : (<><Sparkles className="w-4 h-4" />{t('lyric.buildProfile')}</>)}
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,129 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Search, Music, Disc3 } from 'lucide-react';
interface FetchLyricsModalProps {
isOpen: boolean;
onClose: () => void;
onFetch: (artist: string, album: string, maxSongs: number) => Promise<void>;
prefillArtist?: string;
}
export const FetchLyricsModal: React.FC<FetchLyricsModalProps> = ({
isOpen, onClose, onFetch, prefillArtist,
}) => {
const [artist, setArtist] = useState(prefillArtist || '');
const { t } = useTranslation();
const [album, setAlbum] = useState('');
const [maxSongs, setMaxSongs] = useState(50);
// Reset form when modal opens with a new prefill
React.useEffect(() => {
if (isOpen) {
setArtist(prefillArtist || '');
setAlbum('');
}
}, [isOpen, prefillArtist]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!artist.trim()) return;
// Close immediately — fetch runs in background with toast notification
onClose();
onFetch(artist.trim(), album.trim(), maxSongs);
};
return (
<div className="fixed inset-0 bg-black/30 dark:bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4" onClick={onClose}>
<div
className="bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 rounded-2xl shadow-2xl w-full max-w-md overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-white/5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-pink-500/10 flex items-center justify-center">
<Search className="w-4 h-4 text-pink-400" />
</div>
<h2 className="text-lg font-bold text-white">{t('lyric.fetchLyrics')}</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-white/5 text-zinc-600 dark:text-zinc-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-5">
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5">
<span className="flex items-center gap-1.5">
<Music className="w-3.5 h-3.5 text-zinc-600 dark:text-zinc-400" />
{t('lyric.artistName')}
</span>
</label>
<input
type="text"
value={artist}
onChange={(e) => setArtist(e.target.value)}
placeholder="e.g. Steel Panther"
disabled={!!prefillArtist}
className="w-full px-4 py-2.5 rounded-xl bg-white/5 border border-zinc-300 dark:border-white/10 text-white placeholder:text-zinc-500 focus:outline-none focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 transition-all disabled:opacity-50"
autoFocus={!prefillArtist}
/>
<p className="text-xs text-zinc-500 mt-1">
Supports artist names or Genius URLs
</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5">
<span className="flex items-center gap-1.5">
<Disc3 className="w-3.5 h-3.5 text-zinc-600 dark:text-zinc-400" />
{t('lyric.albumName')}
</span>
</label>
<input
type="text"
value={album}
onChange={(e) => setAlbum(e.target.value)}
placeholder="e.g. Feel the Steel (or leave empty for top songs)"
className="w-full px-4 py-2.5 rounded-xl bg-white/5 border border-zinc-300 dark:border-white/10 text-white placeholder:text-zinc-500 focus:outline-none focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 transition-all disabled:opacity-50"
autoFocus={!!prefillArtist}
/>
<p className="text-xs text-zinc-500 mt-1">
Also supports Genius album URLs
</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5">
{t('lyric.maxSongs')}
</label>
<input
type="number"
value={maxSongs}
onChange={(e) => setMaxSongs(Math.max(1, Math.min(100, parseInt(e.target.value) || 50)))}
min={1}
max={100}
className="w-24 px-3 py-2 rounded-xl bg-white/5 border border-zinc-300 dark:border-white/10 text-white text-center font-mono focus:outline-none focus:border-pink-500/50 transition-all disabled:opacity-50"
/>
</div>
<button
type="submit"
disabled={!artist.trim()}
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl bg-pink-600 hover:bg-pink-500 disabled:bg-zinc-200 dark:bg-zinc-700 disabled:text-zinc-500 text-white font-semibold transition-all"
>
<Search className="w-4 h-4" />
{t('lyric.fetchLyrics')}
</button>
</form>
</div>
</div>
);
};
@@ -0,0 +1,265 @@
/**
* GenerateAllModal.tsx — Confirmation modal for "Generate All Audio".
*
* Fetches all written songs (generations) across all artists/albums,
* shows a summary of how many songs will be queued, and on confirm
* enqueues every generation into the audio generation queue.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { X, Loader2, AlertTriangle, Music, Zap, Users, Disc3 } from 'lucide-react';
import { lireekApi } from '../../services/lireekApi';
import type { Artist, Generation, Profile } from '../../services/lireekApi';
import { enqueueAudioGen } from '../../stores/audioGenQueueStore';
import { useAuth } from '../../context/AuthContext';
import { useGlobalParamsStore } from '../../context/GlobalParamsContext';
interface GenerateAllModalProps {
open: boolean;
onClose: () => void;
artists: Artist[];
showToast?: (msg: string) => void;
}
interface GenerateAllStats {
totalSongs: number;
artistCount: number;
albumCount: number;
generations: (Generation & { artist_id: number; artist_name: string; album: string })[];
profiles: Profile[];
}
export const GenerateAllModal: React.FC<GenerateAllModalProps> = ({
open, onClose, artists, showToast,
}) => {
const { token } = useAuth();
const globalParams = useGlobalParamsStore();
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<GenerateAllStats | null>(null);
const [enqueuing, setEnqueuing] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0 });
// Load stats when modal opens
useEffect(() => {
if (!open) return;
setStats(null);
setEnqueuing(false);
setProgress({ current: 0, total: 0 });
const loadStats = async () => {
setLoading(true);
try {
const [genRes, profileRes] = await Promise.all([
lireekApi.listAllGenerations(),
lireekApi.listProfiles(),
]);
// listAllGenerations may return raw array or { generations }
const gens: any[] = Array.isArray(genRes) ? genRes : (genRes.generations || []);
const profiles = Array.isArray(profileRes) ? profileRes : (profileRes.profiles || []);
const artistIds = new Set(gens.map((g: any) => g.artist_id));
const albumNames = new Set(gens.map((g: any) => `${g.artist_id}-${g.album || 'Unknown'}`));
// Sort deterministically: artist name A→Z, then song title A→Z within each artist.
// This lets the user see exactly where a batch stopped and resume from there.
gens.sort((a: any, b: any) => {
const artistCmp = (a.artist_name || '').localeCompare(b.artist_name || '', undefined, { sensitivity: 'base' });
if (artistCmp !== 0) return artistCmp;
return (a.title || '').localeCompare(b.title || '', undefined, { sensitivity: 'base' });
});
setStats({
totalSongs: gens.length,
artistCount: artistIds.size,
albumCount: albumNames.size,
generations: gens,
profiles,
});
} catch (err: any) {
showToast?.(`Failed to load data: ${err.message}`);
onClose();
} finally {
setLoading(false);
}
};
loadStats();
}, [open]);
const handleConfirm = useCallback(async () => {
if (!stats || !token) return;
setEnqueuing(true);
setProgress({ current: 0, total: stats.totalSongs });
const paramsSnapshot = globalParams.getGlobalParams();
const profileMap = new Map(stats.profiles.map(p => [p.id, p]));
const artistMap = new Map(artists.map(a => [a.id, a]));
let queued = 0;
let skipped = 0;
for (let i = 0; i < stats.generations.length; i++) {
const gen = stats.generations[i];
const profile = profileMap.get(gen.profile_id);
if (!profile) {
skipped++;
setProgress({ current: i + 1, total: stats.totalSongs });
continue;
}
const artist = artistMap.get(gen.artist_id);
try {
await enqueueAudioGen(gen, {
artistId: gen.artist_id || 0,
artistName: gen.artist_name || artist?.name || 'Unknown',
artistImageUrl: artist?.image_url || '',
profileId: profile.id,
lyricsSetId: profile.lyrics_set_id,
}, paramsSnapshot, token);
queued++;
} catch (err) {
skipped++;
}
setProgress({ current: i + 1, total: stats.totalSongs });
}
const parts = [];
if (queued > 0) parts.push(`${queued} song${queued !== 1 ? 's' : ''} queued`);
if (skipped > 0) parts.push(`${skipped} skipped`);
showToast?.(parts.join(', ') || 'Done');
setEnqueuing(false);
onClose();
}, [stats, token, artists, globalParams, showToast, onClose]);
if (!open) return null;
// Estimate time: ~3 min per song is a rough average for generation
const estimatedMinutes = stats ? Math.ceil(stats.totalSongs * 3) : 0;
const estimatedHours = Math.floor(estimatedMinutes / 60);
const estimatedMins = estimatedMinutes % 60;
const estimatedStr = estimatedHours > 0
? `~${estimatedHours}h ${estimatedMins}m`
: `~${estimatedMins}m`;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 dark:bg-black/60 backdrop-blur-sm">
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-300 dark:border-white/10 shadow-2xl w-[480px] max-h-[80vh] flex flex-col animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-white/5">
<div className="flex items-center gap-2">
<Zap className="w-5 h-5 text-amber-400" />
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">Generate All Audio</h2>
</div>
<button
onClick={onClose}
disabled={enqueuing}
className="p-1.5 rounded-lg hover:bg-white/10 text-zinc-600 dark:text-zinc-400 hover:text-white transition-colors disabled:opacity-50"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
{loading ? (
<div className="flex flex-col items-center justify-center py-12 gap-3">
<Loader2 className="w-8 h-8 text-pink-400 animate-spin" />
<p className="text-sm text-zinc-500">Loading generation data</p>
</div>
) : stats && !enqueuing ? (
<>
{/* Warning */}
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-950/30 border border-amber-500/20">
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-amber-200/90 leading-relaxed">
<p className="font-semibold text-amber-300 mb-1">This is a massive operation!</p>
<p>
This will queue <strong>every written song</strong> from every album,
from every artist for audio generation. Each song will be processed
sequentially through the audio engine.
</p>
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-3 gap-3">
<div className="flex flex-col items-center p-3 rounded-xl bg-white/[0.03] border border-zinc-200 dark:border-white/5">
<Music className="w-5 h-5 text-pink-400 mb-1.5" />
<span className="text-2xl font-bold text-white">{stats.totalSongs}</span>
<span className="text-[10px] text-zinc-500 uppercase tracking-wider">Songs</span>
</div>
<div className="flex flex-col items-center p-3 rounded-xl bg-white/[0.03] border border-zinc-200 dark:border-white/5">
<Users className="w-5 h-5 text-blue-400 mb-1.5" />
<span className="text-2xl font-bold text-white">{stats.artistCount}</span>
<span className="text-[10px] text-zinc-500 uppercase tracking-wider">Artists</span>
</div>
<div className="flex flex-col items-center p-3 rounded-xl bg-white/[0.03] border border-zinc-200 dark:border-white/5">
<Disc3 className="w-5 h-5 text-green-400 mb-1.5" />
<span className="text-2xl font-bold text-white">{stats.albumCount}</span>
<span className="text-[10px] text-zinc-500 uppercase tracking-wider">Albums</span>
</div>
</div>
{/* Estimated time */}
{stats.totalSongs > 0 && (
<div className="text-center text-sm text-zinc-500">
Estimated time: <span className="font-semibold text-zinc-300">{estimatedStr}</span>
<span className="text-[10px] ml-1 text-zinc-600">(~3 min/song avg)</span>
</div>
)}
{stats.totalSongs === 0 && (
<div className="text-center py-4">
<p className="text-sm text-zinc-500">No written songs found to generate.</p>
<p className="text-xs text-zinc-600 mt-1">
Use the Bulk Operations panel to generate lyrics first.
</p>
</div>
)}
</>
) : enqueuing ? (
<div className="flex flex-col items-center justify-center py-8 gap-4">
<Loader2 className="w-8 h-8 text-pink-400 animate-spin" />
<div className="text-center">
<p className="text-sm text-white font-medium">
Queuing songs {progress.current}/{progress.total}
</p>
<div className="w-48 h-1.5 bg-zinc-800 rounded-full mt-3 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-pink-500 to-amber-500 rounded-full transition-all duration-300"
style={{ width: `${progress.total > 0 ? (progress.current / progress.total) * 100 : 0}%` }}
/>
</div>
</div>
</div>
) : null}
</div>
{/* Footer */}
{!loading && stats && !enqueuing && (
<div className="px-6 py-4 border-t border-zinc-200 dark:border-white/5 flex items-center justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 rounded-lg text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:text-zinc-200 hover:bg-white/5 transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={stats.totalSongs === 0}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold transition-all disabled:opacity-30 bg-gradient-to-r from-amber-600 to-pink-600 hover:from-amber-500 hover:to-pink-500 text-white shadow-lg shadow-amber-500/20 hover:shadow-amber-500/30 hover:scale-[1.02] active:scale-[0.98]"
>
<Zap className="w-4 h-4" />
Generate All {stats.totalSongs} Song{stats.totalSongs !== 1 ? 's' : ''}
</button>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,329 @@
/**
* InlineAudioQueue.tsx — Inline audio generation queue display.
* Shows active, pending, and completed audio generation jobs from the audioGenQueueStore.
*
* Order: Active (generating/loading) → Queued (pending) → Completed (succeeded/failed, newest first)
* Completed items play through the main player (onPlaySong).
*/
import React, { memo, useCallback, useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, CheckCircle2, XCircle, X, Music, Play, Square, ListPlus, Check, Download, RotateCcw } from 'lucide-react';
import {
useAudioGenQueue,
removeFromAudioQueue,
clearFinishedFromAudioQueue,
forceFailQueueItem,
resetServerQueue,
getSendToPlaylist,
setSendToPlaylist,
} from '../../stores/audioGenQueueStore';
import type { AudioQueueItem } from '../../stores/audioGenQueueStore';
import { usePlaylist } from './playlistStore';
import type { Song } from '../../types';
import { downloadTrack } from '../../utils/downloadTrack';
import { play as pbPlay, audioQueueItemToTrack, usePlaybackSelector } from '../../stores/playbackStore';
import { useDisguiseMode } from '../../hooks/useDisguiseMode';
import { ToggleSwitch } from '../global-bar/BarSection';
// ── Send To Playlist toggle reactivity ───────────────────────────────────────
// Uses a storage event listener so the toggle stays in sync if changed elsewhere.
const STORAGE_KEY = 'hs-sendToPlaylist';
const _s2pListeners = new Set<() => void>();
let _s2pSnapshot = getSendToPlaylist();
function _s2pSubscribe(cb: () => void): () => void {
_s2pListeners.add(cb);
const onStorage = (e: StorageEvent) => { if (e.key === STORAGE_KEY) { _s2pSnapshot = getSendToPlaylist(); cb(); } };
window.addEventListener('storage', onStorage);
return () => { _s2pListeners.delete(cb); window.removeEventListener('storage', onStorage); };
}
function _s2pGetSnapshot(): boolean { return _s2pSnapshot; }
function useSendToPlaylist(): [boolean, (v: boolean) => void] {
const value = useSyncExternalStore(_s2pSubscribe, _s2pGetSnapshot);
const toggle = useCallback((v: boolean) => {
setSendToPlaylist(v);
_s2pSnapshot = v;
_s2pListeners.forEach(fn => fn());
}, []);
return [value, toggle];
}
export const InlineAudioQueue: React.FC = () => {
const { items } = useAudioGenQueue();
const { t } = useTranslation();
const currentSongId = usePlaybackSelector(s => s.currentTrack?.id ?? null);
const [sendToPlaylist, setS2P] = useSendToPlaylist();
const active = items.filter(i => i.status === 'loading-adapter' || i.status === 'generating');
const queued = items.filter(i => i.status === 'pending');
const finished = items
.filter(i => i.status === 'succeeded' || i.status === 'failed')
.slice().reverse();
const pendingCount = queued.length;
const finishedCount = finished.length;
const handlePlay = useCallback((item: AudioQueueItem) => {
if (!item.audioUrl) return;
pbPlay(audioQueueItemToTrack(item));
}, []);
const handleDownload = useCallback((item: AudioQueueItem) => {
if (!item.audioUrl) return;
const song: Song = {
id: item.songId || item.id,
title: item.generation.title || 'Untitled',
lyrics: '',
style: item.generation.caption || '',
caption: item.generation.caption || '',
audioUrl: item.audioUrl,
masteredAudioUrl: item.masteredAudioUrl || '',
coverUrl: item.coverUrl || item.artistImageUrl || '',
duration: item.audioDuration || 0,
artistName: item.artistName || '',
tags: [],
};
downloadTrack(song);
}, []);
// Send To Playlist toggle — always visible at top
const sendToPlaylistRow = (
<div className="flex items-center justify-between px-2 py-1.5">
<span className="flex items-center gap-1.5 text-[10px] font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
<ListPlus className="w-3 h-3" />
{t('lyric.sendToPlaylist')}
</span>
<ToggleSwitch checked={sendToPlaylist} onChange={setS2P} accentColor="pink" />
</div>
);
if (items.length === 0) {
return (
<div className="px-2">
{sendToPlaylistRow}
<div className="flex flex-col items-center justify-center py-6 text-center px-4">
<Music className="w-5 h-5 text-zinc-600 mb-2" />
<p className="text-xs text-zinc-500">{t('lyric.noQueuedGenerations')}</p>
</div>
</div>
);
}
return (
<div className="space-y-1 px-2">
{sendToPlaylistRow}
<div className="flex items-center justify-between px-1 py-1">
<span className="text-[10px] font-semibold text-zinc-500 uppercase tracking-wider">
{pendingCount > 0 && `${pendingCount} pending`}
{pendingCount > 0 && finishedCount > 0 && ' · '}
{finishedCount > 0 && `${finishedCount} done`}
</span>
<div className="flex items-center gap-2">
{(active.length > 0 || queued.length > 0) && (
<button onClick={async () => {
if (!confirm('Reset the generation queue? All active and pending generations will be cancelled.')) return;
try { await resetServerQueue(); } catch (err) { console.error('Queue reset failed:', err); }
}}
className="text-[10px] text-red-400/60 hover:text-red-400 transition-colors flex items-center gap-0.5"
title="Force-reset the generation queue">
<RotateCcw className="w-2.5 h-2.5" />
Reset
</button>
)}
{finishedCount > 0 && (
<button onClick={clearFinishedFromAudioQueue}
className="text-[10px] text-zinc-500 hover:text-red-400 transition-colors">
{t('lyric.clearDone')}
</button>
)}
</div>
</div>
{active.length > 0 && (
<>
<GroupLabel label="Active" color="text-pink-400" />
{active.map(item => (
<QueueItemRow key={item.id} item={item} isPlayingInMain={currentSongId === item.id} onPlay={handlePlay} />
))}
</>
)}
{queued.length > 0 && (
<>
<GroupLabel label="Queued" color="text-zinc-600 dark:text-zinc-400" />
{queued.map(item => (
<QueueItemRow key={item.id} item={item} isPlayingInMain={currentSongId === item.id} onPlay={handlePlay} />
))}
</>
)}
{finished.length > 0 && (
<>
<GroupLabel label="Completed" color="text-green-400" />
{finished.map(item => (
<QueueItemRow key={item.id} item={item} isPlayingInMain={currentSongId === item.id} onPlay={handlePlay} onDownload={handleDownload} />
))}
</>
)}
</div>
);
};
const GroupLabel = memo<{ label: string; color: string }>(({ label, color }) => (
<p className={`text-[9px] font-bold uppercase tracking-widest ${color} px-1 pt-2 pb-0.5`}>
{label}
</p>
));
interface QueueItemRowProps {
item: AudioQueueItem;
isPlayingInMain: boolean;
onPlay: (item: AudioQueueItem) => void;
onDownload?: (item: AudioQueueItem) => void;
}
const QueueItemRow: React.FC<QueueItemRowProps> = ({ item, isPlayingInMain, onPlay, onDownload }) => {
const { disguiseArtist } = useDisguiseMode();
const isRunning = item.status === 'loading-adapter' || item.status === 'generating';
const isSucceeded = item.status === 'succeeded';
const isFailed = item.status === 'failed';
const isPending = item.status === 'pending';
// Always show generation elapsed time; track duration shown separately for completed items
const elapsedSeconds = item.elapsed || 0;
const eMins = Math.floor(elapsedSeconds / 60);
const eSecs = elapsedSeconds % 60;
const elapsedStr = elapsedSeconds > 0
? `${eMins}:${String(Math.floor(eSecs)).padStart(2, '0')}`
: '';
const durationSeconds = isSucceeded && item.audioDuration ? item.audioDuration : 0;
const dMins = Math.floor(durationSeconds / 60);
const dSecs = durationSeconds % 60;
const durationStr = durationSeconds > 0
? `${dMins}:${String(Math.floor(dSecs)).padStart(2, '0')}`
: '';
const borderColor = isSucceeded ? 'border-green-500/20'
: isFailed ? 'border-red-500/20'
: isRunning ? 'border-pink-500/20'
: 'border-zinc-200 dark:border-white/5';
return (
<div className={`rounded-lg border ${borderColor} bg-white/[0.02] px-3 py-2 transition-all ${isPlayingInMain ? 'ring-1 ring-pink-500/40 bg-pink-500/5' : ''}`}>
<div className="flex items-center gap-2">
<div className="flex-shrink-0">
{isPending && <div className="w-2 h-2 rounded-full bg-zinc-500" />}
{isRunning && <Loader2 className="w-3.5 h-3.5 text-pink-400 animate-spin" />}
{isSucceeded && item.audioUrl ? (
<button onClick={() => onPlay(item)}
className={`p-0.5 rounded-full transition-colors ${isPlayingInMain ? 'bg-green-500/20 text-green-300' : 'text-green-400 hover:bg-green-500/20'}`}
title={isPlayingInMain ? 'Playing' : 'Play'}>
{isPlayingInMain ? <Square className="w-3 h-3" /> : <Play className="w-3 h-3" />}
</button>
) : isSucceeded ? (
<CheckCircle2 className="w-3.5 h-3.5 text-green-400" />
) : null}
{isFailed && <XCircle className="w-3.5 h-3.5 text-red-400" />}
</div>
<div className="flex-1 min-w-0">
<p className={`text-xs font-medium truncate ${isPlayingInMain ? 'text-pink-300' : 'text-zinc-800 dark:text-zinc-200'}`}>
{item.generation.title || 'Untitled'}
</p>
<p className="text-[10px] text-zinc-500 truncate">{disguiseArtist(item.artistName || '')}</p>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{elapsedStr && (
<span className="text-[10px] text-zinc-500 font-mono" title="Generation time">
{elapsedStr}
</span>
)}
{isSucceeded && durationStr && (
<span className="text-[10px] text-zinc-600 font-mono" title="Track duration">
🎵{durationStr}
</span>
)}
{isPending && (
<button onClick={() => removeFromAudioQueue(item.id)}
className="p-0.5 rounded hover:bg-red-500/20 text-zinc-500 hover:text-red-400 transition-colors"
title="Remove from queue">
<X className="w-3 h-3" />
</button>
)}
{isRunning && (
<button onClick={() => forceFailQueueItem(item.id)}
className="p-0.5 rounded hover:bg-red-500/20 text-zinc-500 hover:text-red-400 transition-colors"
title="Dismiss stuck item">
<X className="w-3 h-3" />
</button>
)}
{isSucceeded && item.audioUrl && (
<>
<button onClick={() => onDownload?.(item)}
className="p-0.5 rounded hover:bg-emerald-500/20 text-zinc-500 hover:text-emerald-400 transition-colors"
title="Download Audio">
<Download className="w-3 h-3" />
</button>
<QueueAddToPlaylistBtn item={item} />
</>
)}
</div>
</div>
{isRunning && (
<div className="mt-1.5 space-y-1">
<div className="h-1 rounded-full bg-white/10 overflow-hidden">
<div className={`h-full bg-gradient-to-r from-pink-500 to-purple-600 transition-all duration-500 ${!item.progress ? 'animate-pulse opacity-40 w-full' : ''}`}
style={item.progress ? { width: `${item.progress}%` } : undefined} />
</div>
<div className="flex items-center justify-between">
<span className="text-[9px] text-zinc-500">{item.stage || 'Processing…'}</span>
{item.progress !== undefined && item.progress > 0 && (
<span className="text-[9px] font-bold text-pink-400">{Math.round(item.progress)}%</span>
)}
</div>
</div>
)}
{isFailed && item.error && (
<p className="mt-1 text-[9px] text-red-400 truncate">{item.error}</p>
)}
</div>
);
};
const QueueAddToPlaylistBtn = memo<{ item: AudioQueueItem }>(({ item }) => {
const playlist = usePlaylist();
const inPlaylist = playlist.isIn(item.songId || item.id);
const toggle = () => {
const resolvedId = item.songId || item.id;
if (inPlaylist) { playlist.remove(resolvedId); }
else {
playlist.add({
id: resolvedId,
title: item.generation.title || 'Untitled',
audioUrl: item.audioUrl || '',
masteredAudioUrl: item.masteredAudioUrl || '',
artistName: item.artistName || '',
coverUrl: item.coverUrl || item.artistImageUrl || '',
duration: item.audioDuration || 0,
});
}
};
return (
<button onClick={toggle}
className={`p-0.5 rounded transition-colors ${inPlaylist ? 'text-pink-400 bg-pink-500/10' : 'text-zinc-600 hover:text-pink-400 hover:bg-pink-500/10'}`}
title={inPlaylist ? 'Remove from playlist' : 'Add to playlist'}>
{inPlaylist ? <Check className="w-3 h-3" /> : <ListPlus className="w-3 h-3" />}
</button>
);
});
@@ -0,0 +1,848 @@
/**
* LyricStudioV2.tsx — Main container for the Lyric Studio interface.
*
* Three navigation levels:
* 1. Artist Grid with settings sidebar
* 2. Album Grid for selected artist
* 3. Album Detail with tabbed content (Source Lyrics, Profiles, Written Songs)
*
* State management: All data is loaded via lireekApi, with URL-based routing
* and popstate support for browser back/forward.
*
* Ported from hot-step-9000 with import path + API adaptations for the C++ engine.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { usePersistedState } from '../../hooks/usePersistedState';
import { lireekApi } from '../../services/lireekApi';
import type { Artist, LyricsSet, Profile, Generation, SongLyric } from '../../services/lireekApi';
import { ArtistGrid } from './ArtistGrid';
import { ArtistSidebar } from './ArtistSidebar';
import { ArtistPageSidebar } from './ArtistPageSidebar';
import { AlbumGrid } from './AlbumGrid';
import { AlbumHeader } from './AlbumHeader';
import { FetchLyricsModal } from './FetchLyricsModal';
import { AddArtistModal } from './AddArtistModal';
import { AddAlbumModal } from './AddAlbumModal';
import { AddSongModal } from './AddSongModal';
import { CuratedProfileModal } from './CuratedProfileModal';
import { PresetSettingsModal } from './PresetSettingsModal';
import { ContentTabs } from './ContentTabs';
import type { TabId } from './ContentTabs';
import { SourceLyricsTab } from './SourceLyricsTab';
import { ProfilesTab } from './ProfilesTab';
import { WrittenSongsTab } from './WrittenSongsTab';
import { RecordingsTab } from './RecordingsTab';
import { ActivitySidebar } from '../shared/ActivitySidebar';
import { useAudioGeneration } from './useAudioGeneration';
import { enqueueAudioGen, useResumeQueue, useAudioGenQueueSelector } from '../../stores/audioGenQueueStore';
import { usePlaybackSelector } from '../../stores/playbackStore';
import { useAuth } from '../../context/AuthContext';
import { useGlobalParamsStore } from '../../context/GlobalParamsContext';
import { QueuePanel } from './QueuePanel';
import { PromptEditor } from './PromptEditor';
import { GenerateAllModal } from './GenerateAllModal';
// streamingStore used via queue panel
import { loadSelections } from './ProviderSelector';
import { useDisguiseMode } from '../../hooks/useDisguiseMode';
// ── URL helpers ──────────────────────────────────────────────────────────────
const LS_BASE = '/lyric-studio';
function buildUrl(artistId?: number, albumId?: number, tab?: TabId): string {
if (artistId && albumId && tab) return `${LS_BASE}/artist/${artistId}/album/${albumId}/${tab}`;
if (artistId && albumId) return `${LS_BASE}/artist/${artistId}/album/${albumId}`;
if (artistId) return `${LS_BASE}/artist/${artistId}`;
return LS_BASE;
}
function parseUrl(path: string): { artistId?: number; albumId?: number; tab?: TabId } {
const m = path.match(/\/lyric-studio\/artist\/(\d+)(?:\/album\/(\d+)(?:\/(source-lyrics|profiles|written-songs|recordings))?)?/);
if (!m) return {};
return {
artistId: Number(m[1]),
albumId: m[2] ? Number(m[2]) : undefined,
tab: (m[3] as TabId) || undefined,
};
}
function parseSongs(songs: SongLyric[] | string): SongLyric[] {
if (typeof songs === 'string') {
try { return JSON.parse(songs); } catch { return []; }
}
return songs || [];
}
// ── Navigation state ─────────────────────────────────────────────────────────
type NavLevel = 'artists' | 'albums' | 'album-detail';
interface NavState {
level: NavLevel;
selectedArtist: Artist | null;
selectedAlbum: LyricsSet | null;
}
// ── Main Component ──────────────────────────────────────────────────────────
export const LyricStudioV2: React.FC = () => {
const { token } = useAuth();
const { t } = useTranslation();
const { disguiseArtist } = useDisguiseMode();
// ── Navigation ──
const [nav, setNav] = useState<NavState>({ level: 'artists', selectedArtist: null, selectedAlbum: null });
// ── Right panel width (persisted, pixel-based) ──
const [lsRightPanelWidth, setLsRightPanelWidth] = usePersistedState('hs-activitySidebarWidth', 320);
const compactRight = lsRightPanelWidth < 380;
const handleRightPanelResize = useCallback((e: React.MouseEvent) => {
e.preventDefault();
const startX = e.clientX;
const startW = lsRightPanelWidth;
const onMove = (ev: MouseEvent) => {
const newW = Math.min(700, Math.max(240, startW + startX - ev.clientX));
setLsRightPanelWidth(newW);
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}, [lsRightPanelWidth, setLsRightPanelWidth]);
// ── Data ──
const [artists, setArtists] = useState<Artist[]>([]);
const [albums, setAlbums] = useState<LyricsSet[]>([]);
const [profiles, setProfiles] = useState<Profile[]>([]);
const [generations, setGenerations] = useState<Generation[]>([]);
const [artistsLoading, setArtistsLoading] = useState(true);
const [albumsLoading, setAlbumsLoading] = useState(false);
const albumLoadIdRef = useRef(0);
const albumDataLoadIdRef = useRef(0);
// ── Tabs ──
const [activeTab, setActiveTab] = useState<TabId>('source-lyrics');
const [recordingsFilter, setRecordingsFilter] = useState<number | null>(null);
const [songCount, setSongCount] = useState(0);
const [recordingsRefreshKey, setRecordingsRefreshKey] = useState(0);
const isRestoringUrl = useRef(false);
// ── Modals ──
const [fetchModalOpen, setFetchModalOpen] = useState(false);
const [fetchModalPrefill, setFetchModalPrefill] = useState<string | undefined>();
const [presetModalOpen, setPresetModalOpen] = useState(false);
const [queueOpen, setQueueOpen] = useState(false);
const [promptEditorOpen, setPromptEditorOpen] = useState(false);
const [addArtistModalOpen, setAddArtistModalOpen] = useState(false);
const [addAlbumModalOpen, setAddAlbumModalOpen] = useState(false);
const [addSongModalOpen, setAddSongModalOpen] = useState(false);
const [curatedModalOpen, setCuratedModalOpen] = useState(false);
const [generateAllOpen, setGenerateAllOpen] = useState(false);
// ── Fetch lyrics progress ──
const [fetchingLyrics, setFetchingLyrics] = useState(false);
const [fetchingLabel, setFetchingLabel] = useState('');
// ── Bulk queue data ──
const [allLyricsSets, setAllLyricsSets] = useState<LyricsSet[]>([]);
const [allProfiles, setAllProfiles] = useState<Profile[]>([]);
const [artistIdsWithAdapters, setArtistIdsWithAdapters] = useState<Set<number>>(new Set());
// ── Playback (for backdrop effect) ──
const isPlaying = usePlaybackSelector(s => s.isPlaying);
const currentPlaybackTrack = usePlaybackSelector(s => s.currentTrack);
// ── Audio generation ──
useResumeQueue(token || undefined);
const completionCounter = useAudioGenQueueSelector(s => s.completionCounter);
// ── Toast ──
const [toast, setToast] = useState<string | null>(null);
const showToast = useCallback((msg: string) => {
setToast(msg);
setTimeout(() => setToast(null), 3500);
}, []);
// ── Load artists ──
const loadArtists = useCallback(async (retries = 5): Promise<Artist[]> => {
setArtistsLoading(true);
let artistsList: Artist[] = [];
try {
const res = await lireekApi.listArtists();
artistsList = res.artists;
setArtists(res.artists);
} catch (err) {
console.warn(`[LyricStudioV2] Failed to load artists (retries left: ${retries}):`, err);
if (retries > 0) {
await new Promise(r => setTimeout(r, 2000));
return loadArtists(retries - 1);
}
} finally {
setArtistsLoading(false);
}
// Background: fetch missing artist images
const missing = artistsList.filter(a => !a.image_url);
if (missing.length > 0) {
const fetchNext = (idx: number) => {
if (idx >= missing.length) return;
lireekApi.refreshArtistImage(missing[idx].id)
.then(result => {
if (result.image_url) {
setArtists(prev => prev.map(a => a.id === missing[idx].id ? { ...a, image_url: result.image_url } : a));
}
})
.catch(() => {})
.finally(() => setTimeout(() => fetchNext(idx + 1), 500));
};
fetchNext(0);
}
return artistsList;
}, []);
// ── Initial load: artists + URL restore ──
useEffect(() => {
const init = async () => {
const artistsList = await loadArtists();
// Background: load adapter mapping
lireekApi.listAllPresets().then(({ presets }) => {
const ids = new Set<number>();
for (const p of presets as any[]) {
if (p.adapter_path && p.artist_id) ids.add(p.artist_id);
}
setArtistIdsWithAdapters(ids);
}).catch(() => {});
const parsed = parseUrl(window.location.pathname);
if (parsed.artistId) {
isRestoringUrl.current = true;
try {
const artist = artistsList.find(a => a.id === parsed.artistId);
if (!artist) return;
const albumRes = await lireekApi.listLyricsSets(artist.id);
setAlbums(albumRes.lyrics_sets);
if (parsed.albumId) {
const album = albumRes.lyrics_sets.find(a => a.id === parsed.albumId);
if (!album) {
setNav({ level: 'albums', selectedArtist: artist, selectedAlbum: null });
return;
}
setNav({ level: 'album-detail', selectedArtist: artist, selectedAlbum: album });
if (parsed.tab) setActiveTab(parsed.tab);
} else {
setNav({ level: 'albums', selectedArtist: artist, selectedAlbum: null });
}
} finally {
isRestoringUrl.current = false;
}
}
};
init();
}, [loadArtists]);
// ── Load albums ──
const loadAlbums = useCallback(async (artistId: number) => {
const loadId = ++albumLoadIdRef.current;
setAlbumsLoading(true);
let albumsList: LyricsSet[] = [];
try {
const res = await lireekApi.listLyricsSets(artistId);
if (loadId !== albumLoadIdRef.current) return;
albumsList = res.lyrics_sets;
setAlbums(res.lyrics_sets);
} catch (err) {
console.error('[LyricStudioV2] Failed to load albums:', err);
} finally {
if (loadId === albumLoadIdRef.current) setAlbumsLoading(false);
}
// Background: fetch missing album images
const missing = albumsList.filter(a => !a.image_url && a.album);
if (missing.length > 0) {
const fetchNext = (idx: number) => {
if (idx >= missing.length || loadId !== albumLoadIdRef.current) return;
lireekApi.refreshAlbumImage(missing[idx].id)
.then(result => {
if (loadId !== albumLoadIdRef.current) return;
if (result.image_url) {
setAlbums(prev => prev.map(a => a.id === missing[idx].id ? { ...a, image_url: result.image_url } : a));
}
})
.catch(() => {})
.finally(() => setTimeout(() => fetchNext(idx + 1), 500));
};
fetchNext(0);
}
}, []);
// ── Load album detail data ──
const loadAlbumData = useCallback(async (albumId: number, retries = 2) => {
const loadId = ++albumDataLoadIdRef.current;
try {
const { lyrics_set, profiles: p, generations: g } = await lireekApi.getAlbumFullDetail(albumId);
if (loadId !== albumDataLoadIdRef.current) return;
setNav(prev => ({ ...prev, selectedAlbum: lyrics_set }));
setProfiles(p);
setGenerations(g);
} catch (err) {
if (loadId !== albumDataLoadIdRef.current) return;
if (retries > 0) {
await new Promise(r => setTimeout(r, 2000));
return loadAlbumData(albumId, retries - 1);
}
}
}, []);
// ── Navigation handlers ──
const pushUrl = useCallback((artistId?: number, albumId?: number, tab?: TabId) => {
if (isRestoringUrl.current) return;
const url = buildUrl(artistId, albumId, tab);
if (window.location.pathname !== url) window.history.pushState({}, '', url);
}, []);
const handleSelectArtist = useCallback((artist: Artist) => {
setAlbums([]);
setProfiles([]);
setGenerations([]);
setSongCount(0);
setNav({ level: 'albums', selectedArtist: artist, selectedAlbum: null });
loadAlbums(artist.id);
pushUrl(artist.id);
}, [loadAlbums, pushUrl]);
const handleSelectAlbum = useCallback((album: LyricsSet) => {
setNav(prev => ({ ...prev, level: 'album-detail', selectedAlbum: album }));
setActiveTab('source-lyrics');
pushUrl(nav.selectedArtist?.id, album.id, 'source-lyrics');
}, [pushUrl, nav.selectedArtist]);
// Reactive album data loading
const albumIdRef = useRef<number | null>(null);
useEffect(() => {
const albumId = nav.selectedAlbum?.id ?? null;
if (albumId === albumIdRef.current) return;
albumIdRef.current = albumId;
if (albumId == null) return;
setProfiles([]);
setGenerations([]);
setSongCount(0);
loadAlbumData(albumId);
}, [nav.selectedAlbum?.id, loadAlbumData]);
const handleBackToArtists = useCallback(() => {
setNav({ level: 'artists', selectedArtist: null, selectedAlbum: null });
setAlbums([]); setProfiles([]); setGenerations([]); setSongCount(0);
loadArtists();
pushUrl();
}, [loadArtists, pushUrl]);
const handleBackToAlbums = useCallback(() => {
setNav(prev => ({ ...prev, level: 'albums', selectedAlbum: null }));
setProfiles([]); setGenerations([]); setSongCount(0);
pushUrl(nav.selectedArtist?.id);
}, [pushUrl, nav.selectedArtist]);
const handleTabChange = useCallback((tab: TabId) => {
setActiveTab(tab);
pushUrl(nav.selectedArtist?.id, nav.selectedAlbum?.id, tab);
}, [pushUrl, nav.selectedArtist, nav.selectedAlbum]);
// ── popstate ──
useEffect(() => {
const handlePopState = async () => {
const parsed = parseUrl(window.location.pathname);
if (!parsed.artistId) {
setNav({ level: 'artists', selectedArtist: null, selectedAlbum: null });
setAlbums([]); setProfiles([]); setGenerations([]); setSongCount(0);
return;
}
const artist = artists.find(a => a.id === parsed.artistId);
if (!artist) return;
if (!parsed.albumId) {
setNav({ level: 'albums', selectedArtist: artist, selectedAlbum: null });
setProfiles([]); setGenerations([]); setSongCount(0);
loadAlbums(artist.id);
return;
}
const album = albums.find(a => a.id === parsed.albumId);
if (album) {
setNav({ level: 'album-detail', selectedArtist: artist, selectedAlbum: album });
if (parsed.tab) setActiveTab(parsed.tab);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [artists, albums, loadAlbums]);
// ── Actions ──
const handleDeleteArtist = useCallback(async (artist: Artist) => {
if (!confirm(`Delete ${artist.name} and ALL their albums, profiles, and generations?`)) return;
try {
await lireekApi.deleteArtist(artist.id);
showToast(`Deleted ${artist.name}`);
loadArtists();
} catch (err: any) { showToast(`Failed to delete: ${err.message}`); }
}, [loadArtists, showToast]);
const handleRefreshImage = useCallback(async (artist: Artist) => {
try {
showToast(`Refreshing image for ${artist.name}...`);
const res = await lireekApi.refreshArtistImage(artist.id);
setArtists(prev => prev.map(a => a.id === artist.id ? { ...a, image_url: res.image_url } : a));
showToast(`Updated image for ${artist.name}`);
} catch (err: any) { showToast(`Couldn't find image: ${err.message}`); }
}, [showToast]);
const handleSetImage = useCallback(async (artist: Artist, url: string) => {
try {
const res = await lireekApi.setArtistImage(artist.id, url);
setArtists(prev => prev.map(a => a.id === artist.id ? { ...a, image_url: res.image_url } : a));
showToast(`Image updated for ${artist.name}`);
} catch (err: any) { showToast(`Failed: ${err.message}`); }
}, [showToast]);
const handleDeleteAlbum = useCallback(async (album: LyricsSet) => {
if (!confirm(`Delete album "${album.album || 'Top Songs'}" and all associated data?`)) return;
try {
await lireekApi.deleteLyricsSet(album.id);
showToast('Album deleted');
if (nav.selectedArtist) loadAlbums(nav.selectedArtist.id);
} catch (err: any) { showToast(`Failed to delete: ${err.message}`); }
}, [nav.selectedArtist, loadAlbums, showToast]);
const handleRefreshAlbumImage = useCallback(async (album: LyricsSet) => {
try {
const res = await lireekApi.refreshAlbumImage(album.id);
setAlbums(prev => prev.map(a => a.id === album.id ? { ...a, image_url: res.image_url } : a));
showToast('Album image updated');
} catch { showToast('Could not find album image on Genius'); }
}, [showToast]);
const handleSetAlbumImage = useCallback(async (album: LyricsSet, url: string) => {
try {
const res = await lireekApi.setAlbumImage(album.id, url);
setAlbums(prev => prev.map(a => a.id === album.id ? { ...a, image_url: res.image_url } : a));
showToast('Album image set');
} catch (err: any) { showToast(`Failed: ${err.message}`); }
}, [showToast]);
const handleDeleteSong = useCallback(async (index: number) => {
if (!nav.selectedAlbum) return;
try {
await lireekApi.removeSong(nav.selectedAlbum.id, index);
showToast('Song removed');
const updated = await lireekApi.getLyricsSet(nav.selectedAlbum.id);
setNav(prev => ({ ...prev, selectedAlbum: updated }));
} catch (err: any) { showToast(`Failed: ${err.message}`); }
}, [nav.selectedAlbum, showToast]);
const handleEditSong = useCallback(async (index: number, lyrics: string) => {
if (!nav.selectedAlbum) return;
try {
const updated = await lireekApi.editSong(nav.selectedAlbum.id, index, lyrics);
setNav(prev => ({ ...prev, selectedAlbum: updated }));
showToast('Lyrics updated');
} catch (err: any) { showToast(`Failed: ${err.message}`); }
}, [nav.selectedAlbum, showToast]);
const handleFetchLyrics = useCallback(async (artist: string, album: string, maxSongs: number) => {
const label = `${artist}${album ? `${album}` : ''}`;
setFetchingLyrics(true);
setFetchingLabel(label);
showToast(`Fetching lyrics for ${label}`);
try {
const res = await lireekApi.fetchLyrics({ artist, album: album || undefined, max_songs: maxSongs });
showToast(`Fetched ${res.songs_fetched} songs`);
await loadArtists();
if (nav.selectedArtist && res.artist.id === nav.selectedArtist.id) {
await loadAlbums(nav.selectedArtist.id);
}
if (nav.level === 'artists') handleSelectArtist(res.artist);
} catch (err: any) { showToast(`Fetch failed: ${err.message}`); }
finally { setFetchingLyrics(false); setFetchingLabel(''); }
}, [loadArtists, loadAlbums, nav.selectedArtist, nav.level, handleSelectArtist, showToast]);
const refreshAlbumData = useCallback(() => {
if (nav.selectedAlbum) loadAlbumData(nav.selectedAlbum.id);
}, [nav.selectedAlbum, loadAlbumData]);
// ── Audio generation ──
const { sendToCreate } = useAudioGeneration({ profiles, showToast });
const globalParams = useGlobalParamsStore();
const handleGenerateAudio = useCallback(async (gen: Generation) => {
if (!token) { showToast('Not authenticated'); return; }
const profile = profiles.find(p => p.id === gen.profile_id);
if (!profile) { showToast('Profile not found'); return; }
// Capture globalParams snapshot NOW — same as Create page's getGlobalParams().
// This ensures every engine param (solver, guidance, DCW, latent, LM, etc.)
// flows through identically to the Create page path.
const paramsSnapshot = globalParams.getGlobalParams();
await enqueueAudioGen(gen, {
artistId: nav.selectedArtist?.id || 0,
artistName: nav.selectedArtist?.name || 'Unknown',
artistImageUrl: nav.selectedArtist?.image_url || '',
profileId: profile.id,
lyricsSetId: profile.lyrics_set_id,
}, paramsSnapshot, token);
showToast(`Queued: ${gen.title || 'Untitled'}`);
}, [token, profiles, nav.selectedArtist, globalParams, showToast]);
// Refresh album data on audio queue completions
useEffect(() => {
if (completionCounter > 0) {
refreshAlbumData();
setRecordingsRefreshKey(k => k + 1);
}
}, [completionCounter]);
const handleSendToCreate = useCallback(async (gen: Generation) => {
// Inject artist name — gen from getAlbumFullDetail doesn't include it
const enriched = { ...gen, artist_name: gen.artist_name || nav.selectedArtist?.name || '' };
await sendToCreate(enriched);
}, [sendToCreate, nav.selectedArtist]);
const openFetchForArtist = useCallback(() => {
setFetchModalPrefill(nav.selectedArtist?.name);
setFetchModalOpen(true);
}, [nav.selectedArtist]);
const openFetchNew = useCallback(() => {
setFetchModalPrefill(undefined);
setFetchModalOpen(true);
}, []);
// ── Manual add handlers ──
const handleAddArtistManual = useCallback(async (name: string, imageUrl?: string) => {
try {
const res = await lireekApi.createArtist({ name, image_url: imageUrl });
showToast(`Added ${res.artist.name}`);
await loadArtists();
handleSelectArtist(res.artist);
} catch (err: any) { showToast(`Failed to add artist: ${err.message}`); }
}, [loadArtists, handleSelectArtist, showToast]);
const handleAddAlbumManual = useCallback(async (albumName: string | undefined, imageUrl?: string) => {
if (!nav.selectedArtist) return;
try {
const res = await lireekApi.createLyricsSet({ artist_id: nav.selectedArtist.id, album: albumName, image_url: imageUrl });
showToast(`Created ${albumName || 'lyrics collection'}`);
await loadAlbums(nav.selectedArtist.id);
handleSelectAlbum(res.lyrics_set);
} catch (err: any) { showToast(`Failed to create album: ${err.message}`); }
}, [nav.selectedArtist, loadAlbums, handleSelectAlbum, showToast]);
const handleAddSong = useCallback(async (title: string, lyrics: string) => {
if (!nav.selectedAlbum) return;
try {
const updated = await lireekApi.addSongToSet(nav.selectedAlbum.id, { title, lyrics });
showToast(`Added "${title}"`);
setNav(prev => ({ ...prev, selectedAlbum: updated }));
} catch (err: any) { showToast(`Failed to add song: ${err.message}`); }
}, [nav.selectedAlbum, showToast]);
const handleCuratedComplete = useCallback(async (lyricsSet: any) => {
if (nav.selectedArtist) await loadAlbums(nav.selectedArtist.id);
handleSelectAlbum(lyricsSet);
}, [nav.selectedArtist, loadAlbums, handleSelectAlbum]);
// ── Keyboard shortcuts ──
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (fetchModalOpen) setFetchModalOpen(false);
else if (nav.level === 'album-detail') handleBackToAlbums();
else if (nav.level === 'albums') handleBackToArtists();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [nav.level, fetchModalOpen, handleBackToAlbums, handleBackToArtists]);
const sourceLyricsCount = nav.selectedAlbum ? parseSongs(nav.selectedAlbum.songs).length : 0;
// Queue open helper
const openQueuePanel = useCallback(async () => {
try {
const [lsRes, pRes] = await Promise.all([lireekApi.listLyricsSets(), lireekApi.listProfiles()]);
setAllLyricsSets(lsRes.lyrics_sets);
setAllProfiles(pRes.profiles);
} catch (err) { console.error('[LyricStudioV2] Failed to load queue data:', err); }
setQueueOpen(true);
}, []);
// ── Render ──
return (
<div className="h-full w-full flex flex-col relative bg-white dark:bg-zinc-950">
{/* Toast */}
{toast && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-50 px-5 py-2.5 rounded-xl bg-zinc-100/90 dark:bg-zinc-800/90 backdrop-blur-sm border border-zinc-300 dark:border-white/10 text-sm text-white shadow-2xl ls2-slide-up">
{toast}
</div>
)}
{/* Fetch-lyrics indicator */}
{fetchingLyrics && (
<div className="absolute top-14 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 px-5 py-2.5 rounded-xl bg-pink-950/80 backdrop-blur-sm border border-pink-500/20 text-sm text-pink-200 shadow-2xl ls2-slide-up">
<svg className="w-4 h-4 animate-spin flex-shrink-0" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span>{t('lyric.fetchingLyrics')} <strong className="text-white">{fetchingLabel}</strong></span>
</div>
)}
{/* Main content */}
<div className="flex-1 overflow-hidden">
{nav.level === 'artists' && (
<div className="h-full flex ls2-fade-in">
<div className="w-48 flex-shrink-0 border-r border-zinc-200 dark:border-white/5 overflow-hidden">
<ArtistSidebar artists={artists} selectedArtistId={-1}
onSelectArtist={handleSelectArtist} onBack={handleBackToArtists}
artistIdsWithAdapters={artistIdsWithAdapters} />
</div>
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header bar — spans above ArtistPageSidebar + Grid, matches ContentTabs height */}
<div className="flex-shrink-0 flex items-center px-5 py-3 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/30 dark:bg-zinc-950/30">
<span className="text-sm font-medium text-zinc-600 dark:text-zinc-400">{t('lyric.allArtists')}</span>
{artists.length > 0 && (
<span className="ml-2 min-w-[20px] h-5 px-1.5 rounded-full text-[11px] font-bold flex items-center justify-center bg-white/10 text-zinc-600 dark:text-zinc-400">
{artists.length}
</span>
)}
</div>
<div className="flex-1 flex min-h-0">
<div className="w-64 flex-shrink-0 border-r border-zinc-200 dark:border-white/5 overflow-hidden">
<ArtistPageSidebar onOpenQueue={openQueuePanel} onOpenPromptEditor={() => setPromptEditorOpen(true)} onGenerateAll={() => setGenerateAllOpen(true)} />
</div>
<div className="flex-1 overflow-y-auto">
<ArtistGrid
artists={artists} loading={artistsLoading}
onSelectArtist={handleSelectArtist} onAddNew={openFetchNew}
onAddManual={() => setAddArtistModalOpen(true)}
onDelete={handleDeleteArtist} onRefreshImage={handleRefreshImage} onSetImage={handleSetImage}
/>
</div>
</div>
</div>
{/* Resize handle */}
<div
className="flex-shrink-0 w-1.5 h-full cursor-col-resize group z-20 flex items-center hover:bg-pink-500/20 active:bg-pink-500/30 transition-colors"
onMouseDown={handleRightPanelResize}
>
<div className="w-0.5 h-8 rounded-full bg-zinc-600 group-hover:bg-pink-400 transition-colors" />
</div>
<div className="h-full flex-shrink-0 border-l border-zinc-200 dark:border-white/5 overflow-hidden" style={{ width: lsRightPanelWidth }}>
<ActivitySidebar source="lyric-studio" showToast={showToast}
refreshKey={recordingsRefreshKey} compact={compactRight} />
</div>
</div>
)}
{nav.level === 'albums' && nav.selectedArtist && (
<div className="h-full flex ls2-fade-in">
<div className="w-48 flex-shrink-0 border-r border-zinc-200 dark:border-white/5 overflow-hidden">
<ArtistSidebar artists={artists} selectedArtistId={nav.selectedArtist.id}
onSelectArtist={handleSelectArtist} onBack={handleBackToArtists}
artistIdsWithAdapters={artistIdsWithAdapters} />
</div>
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header bar — spans above ArtistPageSidebar + Grid, matches ContentTabs height */}
<div className="flex-shrink-0 flex items-center px-5 py-3 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/30 dark:bg-zinc-950/30">
<span className="text-sm font-medium text-zinc-600 dark:text-zinc-400">{disguiseArtist(nav.selectedArtist.name)}</span>
{albums.length > 0 && (
<span className="ml-2 min-w-[20px] h-5 px-1.5 rounded-full text-[11px] font-bold flex items-center justify-center bg-white/10 text-zinc-600 dark:text-zinc-400">
{albums.length}
</span>
)}
</div>
<div className="flex-1 flex min-h-0">
<div className="w-64 flex-shrink-0 border-r border-zinc-200 dark:border-white/5 overflow-hidden">
<ArtistPageSidebar artist={nav.selectedArtist} albumCount={albums.length}
onOpenQueue={openQueuePanel} onOpenPromptEditor={() => setPromptEditorOpen(true)} onGenerateAll={() => setGenerateAllOpen(true)} />
</div>
<div className="flex-1 overflow-y-auto">
<AlbumGrid
albums={albums} loading={albumsLoading} artistName={nav.selectedArtist.name}
onSelectAlbum={handleSelectAlbum} onAddAlbum={openFetchForArtist}
onAddManual={() => setAddAlbumModalOpen(true)}
onDeleteAlbum={handleDeleteAlbum} onRefreshImage={handleRefreshAlbumImage}
onSetImage={handleSetAlbumImage} onCuratedProfile={() => setCuratedModalOpen(true)}
/>
</div>
</div>
</div>
{/* Resize handle */}
<div
className="flex-shrink-0 w-1.5 h-full cursor-col-resize group z-20 flex items-center hover:bg-pink-500/20 active:bg-pink-500/30 transition-colors"
onMouseDown={handleRightPanelResize}
>
<div className="w-0.5 h-8 rounded-full bg-zinc-600 group-hover:bg-pink-400 transition-colors" />
</div>
<div className="h-full flex-shrink-0 border-l border-zinc-200 dark:border-white/5 overflow-hidden" style={{ width: lsRightPanelWidth }}>
<ActivitySidebar source="lyric-studio" showToast={showToast}
refreshKey={recordingsRefreshKey} compact={compactRight} />
</div>
</div>
)}
{nav.level === 'album-detail' && nav.selectedArtist && nav.selectedAlbum && (
<div className="h-full flex flex-col ls2-fade-in">
<div className="flex-1 flex min-h-0">
{/* Left: artist sidebar + album header */}
<div className="w-48 flex-shrink-0 border-r border-zinc-200 dark:border-white/5 overflow-hidden">
<ArtistSidebar artists={artists} selectedArtistId={nav.selectedArtist.id}
onSelectArtist={handleSelectArtist} onBack={handleBackToArtists}
artistIdsWithAdapters={artistIdsWithAdapters} />
</div>
<div className="w-64 flex-shrink-0 border-r border-zinc-200 dark:border-white/5 overflow-hidden relative">
<div className="relative z-[1] h-full">
<AlbumHeader
artist={nav.selectedArtist} album={nav.selectedAlbum}
onBack={handleBackToAlbums} onOpenPreset={() => setPresetModalOpen(true)}
profileCount={profiles.length} generationCount={generations.length} songCount={songCount}
/>
</div>
</div>
{/* Middle: tabbed content */}
<div className="flex-1 overflow-hidden relative">
{/* Cover art backdrop when playing */}
{isPlaying && currentPlaybackTrack?.coverUrl && (
<>
<style>{`
@keyframes ls-random-zoom { 0%, 100% { scale: 1.4; } 50% { scale: 1.6; } }
@keyframes ls-random-rotate { 0%, 100% { rotate: -5deg; } 25% { rotate: 15deg; } 50% { rotate: 2deg; } 75% { rotate: -15deg; } }
@keyframes ls-random-pan { 0%, 100% { translate: 0% 0%; } 20% { translate: -5% 4%; } 40% { translate: 6% -5%; } 60% { translate: -4% -6%; } 80% { translate: 5% 5%; } }
.ls-dynamic-backdrop { animation: ls-random-zoom 47s ease-in-out infinite, ls-random-rotate 61s ease-in-out infinite, ls-random-pan 53s ease-in-out infinite; }
`}</style>
<div className="absolute inset-0 z-0 pointer-events-none transition-[background-image] duration-700 ls-dynamic-backdrop"
style={{ backgroundImage: `url(${currentPlaybackTrack!.coverUrl})`, backgroundSize: 'cover', backgroundPosition: 'center', filter: 'brightness(0.15) blur(2px) saturate(1.4)' }} />
</>
)}
<div className="relative z-[1] h-full">
<ContentTabs activeTab={activeTab} onTabChange={handleTabChange}
sourceLyricsCount={sourceLyricsCount} profilesCount={profiles.length}
writtenSongsCount={generations.length} recordingsCount={songCount}>
{activeTab === 'source-lyrics' && (
<SourceLyricsTab album={nav.selectedAlbum} onDeleteSong={handleDeleteSong}
onEditSong={handleEditSong} onAddSong={() => setAddSongModalOpen(true)} />
)}
{activeTab === 'profiles' && (
<ProfilesTab lyricsSetId={nav.selectedAlbum.id} profiles={profiles}
onRefresh={refreshAlbumData} showToast={showToast}
profilingModel={loadSelections().profiling} />
)}
{activeTab === 'written-songs' && (
<WrittenSongsTab generations={generations} profiles={profiles}
onRefresh={refreshAlbumData} onGenerateAudio={handleGenerateAudio}
onSendToCreate={handleSendToCreate}
onViewRecordings={(genId) => {
setRecordingsFilter(genId);
setActiveTab('recordings');
pushUrl(nav.selectedArtist?.id, nav.selectedAlbum?.id, 'recordings');
}}
showToast={showToast} generationModel={loadSelections().generation}
refinementModel={loadSelections().refinement} />
)}
{activeTab === 'recordings' && (
<RecordingsTab
generations={generations}
showToast={showToast}
filterGenerationId={recordingsFilter}
onClearFilter={() => setRecordingsFilter(null)}
onSongCountChange={setSongCount}
refreshKey={recordingsRefreshKey}
artistName={nav.selectedArtist?.name}
onDeleteComplete={() => setRecordingsRefreshKey(k => k + 1)}
/>
)}
</ContentTabs>
</div>
</div>
{/* Resize handle */}
<div
className="flex-shrink-0 w-1.5 h-full cursor-col-resize group z-20 flex items-center hover:bg-pink-500/20 active:bg-pink-500/30 transition-colors"
onMouseDown={handleRightPanelResize}
>
<div className="w-0.5 h-8 rounded-full bg-zinc-600 group-hover:bg-pink-400 transition-colors" />
</div>
{/* Right: sidebar panel */}
<div className="flex-shrink-0 border-l border-zinc-200 dark:border-white/5 overflow-hidden flex flex-col relative" style={{ width: lsRightPanelWidth }}>
<div className="relative z-[1] flex-1 min-h-0 overflow-hidden">
<ActivitySidebar source="lyric-studio"
showToast={showToast}
refreshKey={recordingsRefreshKey} compact={compactRight} />
</div>
</div>
</div>
</div>
)}
</div>
{/* Fetch modal */}
<FetchLyricsModal isOpen={fetchModalOpen} onClose={() => setFetchModalOpen(false)}
onFetch={handleFetchLyrics} prefillArtist={fetchModalPrefill} />
{/* Preset modal */}
{nav.selectedAlbum && (
<PresetSettingsModal isOpen={presetModalOpen} lyricsSetId={nav.selectedAlbum.id}
albumName={nav.selectedAlbum.album || 'Top Songs'} onClose={() => setPresetModalOpen(false)}
showToast={showToast} />
)}
{/* Queue modal */}
<QueuePanel open={queueOpen} onClose={() => setQueueOpen(false)}
artists={artists} lyricsSets={allLyricsSets} profiles={allProfiles}
profilingModel={loadSelections().profiling} generationModel={loadSelections().generation}
refinementModel={loadSelections().refinement} showToast={showToast}
onFetchComplete={async () => {
await loadArtists();
if (nav.selectedArtist) loadAlbums(nav.selectedArtist.id);
try {
const [lsRes, pRes] = await Promise.all([lireekApi.listLyricsSets(), lireekApi.listProfiles()]);
setAllLyricsSets(lsRes.lyrics_sets);
setAllProfiles(pRes.profiles);
} catch {}
}} />
{/* Prompt Editor modal */}
<PromptEditor open={promptEditorOpen} onClose={() => setPromptEditorOpen(false)} />
{/* Generate All Audio modal */}
<GenerateAllModal open={generateAllOpen} onClose={() => setGenerateAllOpen(false)}
artists={artists} showToast={showToast} />
{/* Manual add modals */}
<AddArtistModal isOpen={addArtistModalOpen} onClose={() => setAddArtistModalOpen(false)} onSubmit={handleAddArtistManual} />
{nav.selectedArtist && (
<AddAlbumModal isOpen={addAlbumModalOpen} onClose={() => setAddAlbumModalOpen(false)}
onSubmit={handleAddAlbumManual} artistName={nav.selectedArtist.name} />
)}
{nav.selectedAlbum && (
<AddSongModal isOpen={addSongModalOpen} onClose={() => setAddSongModalOpen(false)}
onSubmit={handleAddSong} albumName={nav.selectedAlbum.album || 'Lyrics Collection'} />
)}
{nav.selectedArtist && (
<CuratedProfileModal isOpen={curatedModalOpen} onClose={() => setCuratedModalOpen(false)}
artistId={nav.selectedArtist.id} artistName={nav.selectedArtist.name}
albums={albums} showToast={showToast} onComplete={handleCuratedComplete} />
)}
</div>
);
};
@@ -0,0 +1,307 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Save, Loader2, ChevronDown, ChevronRight, Zap, Music, FolderSearch, Brain } from 'lucide-react';
import { lireekApi } from '../../services/lireekApi';
import { FileBrowserModal } from '../shared/FileBrowserModal';
interface PresetForm {
adapter_path: string;
self_attn: number;
cross_attn: number;
mlp: number;
cond_embed: number;
reference_track_path: string;
lm_adapter_path: string;
}
const DEFAULT_FORM: PresetForm = {
adapter_path: '',
self_attn: 1.0,
cross_attn: 1.0,
mlp: 1.0,
cond_embed: 1.0,
reference_track_path: '',
lm_adapter_path: '',
};
interface PresetSettingsModalProps {
isOpen: boolean;
lyricsSetId: number;
albumName: string;
onClose: () => void;
showToast: (msg: string) => void;
}
/** The weights file is always adapter_model.safetensors, so a pasted or
* browsed file path normalises to its folder — the only part that matters. */
const stripWeightsFile = (p: string): string =>
p.replace(/[\\/]adapter_model\.safetensors$/i, '');
// Simple inline slider
const Slider: React.FC<{
label: string; value: number; min: number; max: number; step: number;
onChange: (v: number) => void; help?: string;
}> = ({ label, value, min, max, step, onChange, help }) => (
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">{label}</label>
<span className="text-xs text-zinc-500 font-mono">{value.toFixed(2)}</span>
</div>
<input type="range" min={min} max={max} step={step} value={value}
onChange={e => onChange(parseFloat(e.target.value))}
className="w-full h-1.5 bg-zinc-100 dark:bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-pink-500"
/>
{help && <p className="text-[10px] text-zinc-600">{help}</p>}
</div>
);
export const PresetSettingsModal: React.FC<PresetSettingsModalProps> = ({
isOpen, lyricsSetId, albumName, onClose, showToast,
}) => {
const [form, setForm] = useState<PresetForm>(DEFAULT_FORM);
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [groupsExpanded, setGroupsExpanded] = useState(false);
const [browserOpen, setBrowserOpen] = useState(false);
const [browserTarget, setBrowserTarget] = useState<'adapter' | 'lmAdapter' | 'reference'>('adapter');
// Load existing preset
useEffect(() => {
if (!isOpen) return;
setLoading(true);
lireekApi.getPreset(lyricsSetId)
.then(res => {
if (res.preset) {
setForm({
adapter_path: res.preset.adapter_path || '',
self_attn: res.preset.adapter_group_scales?.self_attn ?? 1.0,
cross_attn: res.preset.adapter_group_scales?.cross_attn ?? 1.0,
mlp: res.preset.adapter_group_scales?.mlp ?? 1.0,
cond_embed: res.preset.adapter_group_scales?.cond_embed ?? 1.0,
reference_track_path: res.preset.reference_track_path || '',
lm_adapter_path: res.preset.lm_adapter_path || '',
});
} else {
setForm(DEFAULT_FORM);
}
})
.catch(err => showToast(`Failed to load preset: ${err.message}`))
.finally(() => setLoading(false));
}, [isOpen, lyricsSetId, showToast]);
const save = async () => {
setSaving(true);
try {
await lireekApi.upsertPreset(lyricsSetId, {
// Normalise typed/pasted file paths to their folder on the way out too.
adapter_path: stripWeightsFile(form.adapter_path) || undefined,
adapter_group_scales: { self_attn: form.self_attn, cross_attn: form.cross_attn, mlp: form.mlp, cond_embed: form.cond_embed },
reference_track_path: form.reference_track_path || undefined,
lm_adapter_path: stripWeightsFile(form.lm_adapter_path) || undefined,
});
showToast('Preset saved');
onClose();
} catch (err: any) {
showToast(`Save failed: ${err.message}`);
} finally {
setSaving(false);
}
};
const clear = async () => {
setSaving(true);
try {
await lireekApi.deletePreset(lyricsSetId);
setForm(DEFAULT_FORM);
showToast('Preset cleared');
onClose();
} catch (err: any) {
showToast(`Failed: ${err.message}`);
} finally {
setSaving(false);
}
};
if (!isOpen) return null;
// Last two path segments — for a per-run folder that reads "artist/stamp",
// which is the part a human recognises.
const adapterFileName = form.adapter_path
? form.adapter_path.split(/[\\/]/).filter(Boolean).slice(-2).join('/')
: '';
const matchFileName = form.reference_track_path ? form.reference_track_path.split(/[\\/]/).pop() || '' : '';
return (
<>
<div className="fixed inset-0 z-50 bg-black/30 dark:bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<div className="w-full max-w-lg rounded-2xl bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 shadow-2xl pointer-events-auto" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-white/5">
<div>
<h2 className="text-base font-bold text-white">{t('lyric.albumPreset')}</h2>
<p className="text-xs text-zinc-500 mt-0.5">{albumName || 'Top Songs'}</p>
</div>
<button onClick={onClose} className="p-2 rounded-lg hover:bg-white/5 text-zinc-600 dark:text-zinc-400 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{/* Content */}
<div className="px-6 py-5 space-y-5 max-h-[70vh] overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 text-zinc-500 animate-spin" />
</div>
) : (
<>
{/* Adapter Section */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-300">
<Zap className="w-4 h-4 text-pink-400" />
{t('lyric.adapter')}
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Adapter Folder</label>
<div className="flex gap-2">
<input type="text" value={form.adapter_path}
onChange={e => setForm(p => ({ ...p, adapter_path: e.target.value }))}
placeholder="Folder containing adapter_model.safetensors"
className="flex-1 bg-zinc-200 dark:bg-black/20 border border-zinc-300 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-pink-500 transition-colors"
/>
<button onClick={() => { setBrowserTarget('adapter'); setBrowserOpen(true); }}
className="px-2.5 py-2 rounded-lg text-xs font-semibold bg-pink-900/20 text-pink-400 hover:bg-pink-900/30 transition-colors flex items-center gap-1 flex-shrink-0">
<FolderSearch size={12} /> Browse
</button>
</div>
{form.adapter_path && (
<span className="text-[10px] text-zinc-500 truncate block" title={form.adapter_path}>{adapterFileName}</span>
)}
</div>
{/* Group Scales */}
<div className="space-y-2">
<button onClick={() => setGroupsExpanded(!groupsExpanded)}
className="flex items-center gap-1.5 text-[10px] font-semibold text-zinc-500 hover:text-zinc-700 dark:text-zinc-300 transition-colors uppercase tracking-wider"
>
{groupsExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
{t('lyric.groupScales')}
</button>
{groupsExpanded && (
<div className="space-y-2 pl-3 border-l-2 border-pink-500/20">
<Slider label="Self-Attn" value={form.self_attn} min={0} max={4} step={0.05}
onChange={v => setForm(p => ({ ...p, self_attn: v }))} help="Temporal coherence" />
<Slider label="Cross-Attn" value={form.cross_attn} min={0} max={4} step={0.05}
onChange={v => setForm(p => ({ ...p, cross_attn: v }))} help="Prompt adherence" />
<Slider label="MLP" value={form.mlp} min={0} max={4} step={0.05}
onChange={v => setForm(p => ({ ...p, mlp: v }))} help="Timbre/tonal texture" />
<Slider label="Cond" value={form.cond_embed} min={0} max={4} step={0.05}
onChange={v => setForm(p => ({ ...p, cond_embed: v }))} help="Prompt interpretation" />
</div>
)}
</div>
</div>
<div className="border-t border-zinc-200 dark:border-white/5" />
{/* Planner Adapter (LM) Section — song structure */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-300">
<Brain className="w-4 h-4 text-violet-400" />
Planner Adapter (LM)
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Adapter Folder</label>
<div className="flex gap-2">
<input type="text" value={form.lm_adapter_path}
onChange={e => setForm(p => ({ ...p, lm_adapter_path: e.target.value }))}
placeholder="Folder containing adapter_model.safetensors — empty = base planner"
className="flex-1 bg-zinc-200 dark:bg-black/20 border border-zinc-300 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-violet-500 transition-colors"
/>
<button onClick={() => { setBrowserTarget('lmAdapter'); setBrowserOpen(true); }}
className="px-2.5 py-2 rounded-lg text-xs font-semibold bg-violet-900/20 text-violet-400 hover:bg-violet-900/30 transition-colors flex items-center gap-1 flex-shrink-0">
<FolderSearch size={12} /> Browse
</button>
</div>
<p className="text-[10px] text-zinc-600">
Shapes song structure/phrasing via the 5Hz planner pairs with the DiT adapter above (same trigger word).
Strength comes from the global Adapters menu, like the DiT adapter scale.
</p>
</div>
</div>
<div className="border-t border-zinc-200 dark:border-white/5" />
{/* Reference Track Section */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-300">
<Music className="w-4 h-4 text-amber-400" />
{t('lyric.referenceTrack')}
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Reference Audio</label>
<div className="flex gap-2">
<input type="text" value={form.reference_track_path}
onChange={e => setForm(p => ({ ...p, reference_track_path: e.target.value }))}
placeholder="Path to reference audio (.wav, .mp3, .flac)"
className="flex-1 bg-zinc-200 dark:bg-black/20 border border-zinc-300 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-amber-500 transition-colors"
/>
<button onClick={() => { setBrowserTarget('reference'); setBrowserOpen(true); }}
className="px-2.5 py-2 rounded-lg text-xs font-semibold bg-amber-900/20 text-amber-400 hover:bg-amber-900/30 transition-colors flex items-center gap-1 flex-shrink-0">
<FolderSearch size={12} /> Browse
</button>
</div>
{form.reference_track_path && (
<span className="text-[10px] text-zinc-500 truncate block" title={form.reference_track_path}>{matchFileName}</span>
)}
</div>
<p className="text-[10px] text-zinc-600">
Used for timbre conditioning during generation
</p>
</div>
</>
)}
</div>
{/* Footer */}
{!loading && (
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-white/5">
<button onClick={clear} disabled={saving}
className="px-4 py-2 rounded-lg text-xs text-zinc-600 dark:text-zinc-400 hover:text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-50"
>{t('lyric.clearPreset')}</button>
<div className="flex items-center gap-2">
<button onClick={onClose} className="px-4 py-2 rounded-lg text-sm text-zinc-600 dark:text-zinc-400 hover:bg-white/5 transition-colors">{t('common.cancel')}</button>
<button onClick={save} disabled={saving}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-pink-600 to-purple-600 hover:from-pink-500 hover:to-purple-500 text-white text-sm font-semibold transition-all disabled:opacity-50 shadow-lg shadow-pink-500/10"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
{t('common.save')}
</button>
</div>
</div>
)}
</div>
</div>
{/* File Browser sub-modal */}
<FileBrowserModal
open={browserOpen}
onClose={() => setBrowserOpen(false)}
onSelect={(path) => {
// Both adapter targets take the FOLDER — the weights file inside is
// always adapter_model.safetensors, so the filename carries nothing.
if (browserTarget === 'adapter') setForm(p => ({ ...p, adapter_path: stripWeightsFile(path) }));
else if (browserTarget === 'lmAdapter') setForm(p => ({ ...p, lm_adapter_path: stripWeightsFile(path) }));
else setForm(p => ({ ...p, reference_track_path: path }));
setBrowserOpen(false);
}}
mode={browserTarget === 'reference' ? 'file' : 'folder'}
filter={browserTarget === 'reference' ? 'audio' : 'adapters'}
title={browserTarget === 'reference' ? 'Select Reference Audio'
: browserTarget === 'lmAdapter' ? 'Select Planner Adapter Folder'
: 'Select Adapter Folder'}
/>
</>
);
};
@@ -0,0 +1,333 @@
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Trash2, Loader2, Users, Sparkles, ChevronDown, ChevronRight } from 'lucide-react';
import { lireekApi, streamBuildProfile, skipThinking } from '../../services/lireekApi';
import type { Profile } from '../../services/lireekApi';
import { StreamingPanel } from './StreamingPanel';
interface ProfilesTabProps {
lyricsSetId: number;
profiles: Profile[];
onRefresh: () => void;
showToast: (msg: string) => void;
profilingModel: { provider: string; model?: string };
}
export const ProfilesTab: React.FC<ProfilesTabProps> = ({
lyricsSetId, profiles, onRefresh, showToast, profilingModel,
}) => {
const [building, setBuilding] = useState(false);
const { t } = useTranslation();
const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null);
// Inline streaming state (replaces zustand streamingStore)
const [streamVisible, setStreamVisible] = useState(false);
const [streamText, setStreamText] = useState('');
const [streamPhase, setStreamPhase] = useState('');
const [streamDone, setStreamDone] = useState(false);
const provider = profilingModel.provider;
const model = profilingModel.model || '';
const handleBuild = useCallback(async () => {
setBuilding(true);
setStreamVisible(true);
setStreamText('');
setStreamPhase('');
setStreamDone(false);
try {
await streamBuildProfile(lyricsSetId, { provider, model: model || undefined }, {
onChunk: (text) => setStreamText(prev => {
const next = prev + text;
return next.length > 200_000 ? '\u2026(earlier output trimmed)\u2026\n' + next.slice(-200_000) : next;
}),
onPhase: (phase) => setStreamPhase(phase),
onResult: () => {
setStreamDone(true);
setBuilding(false);
onRefresh();
showToast('Profile built successfully');
},
onError: (err) => {
setStreamDone(true);
setBuilding(false);
showToast(`Build failed: ${err}`);
},
});
} catch (err: any) {
showToast(`Build failed: ${err.message}`);
setBuilding(false);
setStreamDone(true);
}
}, [lyricsSetId, provider, model, onRefresh, showToast]);
const handleDelete = async (profile: Profile) => {
if (!confirm('Delete this profile?')) return;
try {
await lireekApi.deleteProfile(profile.id);
showToast('Profile deleted');
onRefresh();
} catch (err: any) {
showToast(`Failed: ${err.message}`);
}
};
const MetaBadge: React.FC<{ label: string; value: string; color: string }> = ({ label, value, color }) => {
const colors: Record<string, string> = {
pink: 'bg-pink-500/20 text-pink-300 border-pink-500/20',
blue: 'bg-blue-500/20 text-blue-300 border-blue-500/20',
purple: 'bg-purple-500/20 text-purple-300 border-purple-500/20',
green: 'bg-green-500/20 text-green-300 border-green-500/20',
amber: 'bg-amber-500/20 text-amber-300 border-amber-500/20',
};
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium border ${colors[color] || colors.pink}`}>
{label}: {value}
</span>
);
};
return (
<div className="p-4 space-y-4">
{/* Build button */}
<div className="flex items-center gap-3">
<button
onClick={handleBuild}
disabled={building}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 disabled:bg-zinc-200 dark:bg-zinc-700 disabled:text-zinc-500 text-white text-sm font-semibold transition-all"
>
{building ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('lyric.building')}
</>
) : (
<>
<Sparkles className="w-4 h-4" />
{t('lyric.buildNewProfile')}
</>
)}
</button>
</div>
{/* Streaming panel */}
{streamVisible && (
<div className="rounded-xl border border-indigo-500/20 bg-indigo-500/5 overflow-hidden">
<StreamingPanel
visible={streamVisible}
streamText={streamText}
phase={streamPhase}
done={streamDone}
onSkipThinking={() => skipThinking()}
/>
</div>
)}
{/* Profile list */}
{profiles.length === 0 && !building ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-14 h-14 rounded-full bg-white/5 flex items-center justify-center mb-4">
<Users className="w-7 h-7 text-zinc-600" />
</div>
<h3 className="text-base font-semibold text-zinc-600 dark:text-zinc-400 mb-2">{t('lyric.noProfilesYet')}</h3>
<p className="text-sm text-zinc-500 max-w-xs">
{t('lyric.buildProfileDesc')}
</p>
</div>
) : (
<div className="space-y-2">
{profiles.map((profile, idx) => {
const data = profile.profile_data;
const themes = data?.themes as string[] | undefined;
return (
<div
key={profile.id}
className={`group rounded-xl border border-zinc-200 dark:border-white/5 hover:border-zinc-300 dark:border-white/10 bg-white/[0.01] overflow-hidden transition-colors ls2-card-in ls2-stagger-${Math.min(idx + 1, 11)}`}
>
<div
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-white/[0.02] transition-colors"
onClick={() => setSelectedProfile(selectedProfile?.id === profile.id ? null : profile)}
>
{selectedProfile?.id === profile.id
? <ChevronDown className="w-4 h-4 text-zinc-500 flex-shrink-0" />
: <ChevronRight className="w-4 h-4 text-zinc-500 flex-shrink-0" />
}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs px-2 py-0.5 rounded-md bg-indigo-500/10 text-indigo-400 font-medium">
{profile.provider}
</span>
{profile.model && (
<span className="text-xs text-zinc-500 truncate">
{profile.model}
</span>
)}
</div>
{themes && themes.length > 0 && (
<p className="text-xs text-zinc-600 dark:text-zinc-400 truncate">
{themes.slice(0, 4).join(', ')}
</p>
)}
<p className="text-[11px] text-zinc-600 mt-1">
{new Date(profile.created_at).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
<button
onClick={() => handleDelete(profile)}
className="p-2 rounded-lg hover:bg-red-500/10 text-zinc-600 dark:text-zinc-400 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100"
title={t('lyric.deleteProfile')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Expanded profile detail */}
{selectedProfile?.id === profile.id && data && (
<div className="border-t border-zinc-200 dark:border-white/5 p-4 space-y-4">
{/* Themes */}
{data.themes?.length > 0 && (
<div>
<span className="text-[10px] text-amber-400 uppercase tracking-wider font-semibold">{t('lyric.themes')}</span>
<div className="flex flex-wrap gap-1.5 mt-1">
{(Array.isArray(data.themes) ? data.themes : [data.themes]).map((t: string, i: number) => (
<span key={i} className="px-2 py-0.5 rounded-md text-xs bg-amber-500/15 text-amber-300 border border-amber-500/20">{t}</span>
))}
</div>
</div>
)}
{/* Common Subjects */}
{data.common_subjects?.length > 0 && (
<div>
<span className="text-[10px] text-green-400 uppercase tracking-wider font-semibold">{t('lyric.commonSubjects')}</span>
<div className="flex flex-wrap gap-1.5 mt-1">
{(Array.isArray(data.common_subjects) ? data.common_subjects : [data.common_subjects]).map((s: string, i: number) => (
<span key={i} className="px-2 py-0.5 rounded-md text-xs bg-green-500/15 text-green-300 border border-green-500/20">{s}</span>
))}
</div>
</div>
)}
{/* Subject Categories */}
{data.subject_categories?.length > 0 && (
<div>
<span className="text-[10px] text-blue-400 uppercase tracking-wider font-semibold">{t('lyric.subjectCategories')}</span>
<div className="flex flex-wrap gap-1.5 mt-1">
{(Array.isArray(data.subject_categories) ? data.subject_categories : [data.subject_categories]).map((c: string, i: number) => (
<span key={i} className="px-2 py-0.5 rounded-md text-xs bg-blue-500/15 text-blue-300 border border-blue-500/20">{c}</span>
))}
</div>
</div>
)}
{/* Stat badges */}
<div className="flex flex-wrap gap-2">
{data.avg_verse_lines > 0 && <MetaBadge label="Avg Verse" value={`${data.avg_verse_lines} lines`} color="blue" />}
{data.avg_chorus_lines > 0 && <MetaBadge label="Avg Chorus" value={`${data.avg_chorus_lines} lines`} color="pink" />}
{data.rhyme_schemes?.length > 0 && <MetaBadge label="Rhyme" value={data.rhyme_schemes.slice(0, 3).join(', ')} color="purple" />}
{data.perspective && <MetaBadge label="Voice" value={typeof data.perspective === 'string' ? data.perspective.split('—')[0].trim() : ''} color="amber" />}
</div>
{/* Text sections */}
{[
{ label: 'Tone & Mood', value: data.tone_and_mood, color: 'text-pink-400' },
{ label: 'Vocabulary', value: data.vocabulary_notes, color: 'text-blue-400' },
{ label: 'Structural Patterns', value: data.structural_patterns, color: 'text-purple-400' },
{ label: 'Narrative Techniques', value: data.narrative_techniques, color: 'text-green-400' },
{ label: 'Imagery Patterns', value: data.imagery_patterns, color: 'text-amber-400' },
{ label: 'Signature Devices', value: data.signature_devices, color: 'text-cyan-400' },
{ label: 'Emotional Arc', value: data.emotional_arc, color: 'text-rose-400' },
].filter(s => s.value).map((section, i) => (
<div key={i}>
<span className={`text-[10px] uppercase tracking-wider font-semibold ${section.color}`}>{section.label}</span>
<p className="text-sm text-zinc-700 dark:text-zinc-300 mt-1 leading-relaxed">{section.value}</p>
</div>
))}
{/* Song subjects */}
{data.song_subjects && Object.keys(data.song_subjects).length > 0 && (
<details>
<summary className="text-[10px] text-amber-400 uppercase tracking-wider font-semibold cursor-pointer hover:text-amber-300">
Song Subjects ({Object.keys(data.song_subjects).length} songs)
</summary>
<div className="mt-2 space-y-1">
{Object.entries(data.song_subjects).map(([title, subject]: [string, any]) => (
<div key={title} className="flex gap-2 text-xs">
<span className="text-zinc-600 dark:text-zinc-400 font-medium shrink-0 w-32 truncate" title={title}>{title}</span>
<span className="text-zinc-500">{subject}</span>
</div>
))}
</div>
</details>
)}
{/* Detailed stats */}
{(data.meter_stats || data.vocabulary_stats || data.repetition_stats || data.rhyme_quality) && (
<details>
<summary className="text-[10px] text-zinc-500 uppercase tracking-wider cursor-pointer hover:text-zinc-700 dark:text-zinc-300">{t('lyric.detailedStats')}</summary>
<div className="mt-2 grid grid-cols-2 gap-3">
{data.meter_stats && (
<div className="p-3 rounded-lg bg-white/5 border border-zinc-200 dark:border-white/5">
<span className="text-[10px] text-blue-400 uppercase tracking-wider font-semibold">Meter</span>
<div className="text-xs text-zinc-600 dark:text-zinc-400 mt-1 space-y-0.5">
<div>Avg syllables: {data.meter_stats.avg_syllables_per_line}/line</div>
<div>σ = {data.meter_stats.syllable_std_dev}</div>
<div>Words: {data.meter_stats.avg_words_per_line}/line</div>
</div>
</div>
)}
{data.vocabulary_stats && (
<div className="p-3 rounded-lg bg-white/5 border border-zinc-200 dark:border-white/5">
<span className="text-[10px] text-green-400 uppercase tracking-wider font-semibold">Vocabulary</span>
<div className="text-xs text-zinc-600 dark:text-zinc-400 mt-1 space-y-0.5">
<div>TTR: {data.vocabulary_stats.type_token_ratio}</div>
<div>{data.vocabulary_stats.total_words} words ({data.vocabulary_stats.unique_words} unique)</div>
<div>Contractions: {data.vocabulary_stats.contraction_pct}%</div>
</div>
</div>
)}
{data.repetition_stats && (
<div className="p-3 rounded-lg bg-white/5 border border-zinc-200 dark:border-white/5">
<span className="text-[10px] text-pink-400 uppercase tracking-wider font-semibold">Repetition</span>
<div className="text-xs text-zinc-600 dark:text-zinc-400 mt-1 space-y-0.5">
<div>Chorus: {data.repetition_stats.chorus_repetition_pct}% repeated</div>
<div>Pattern: {data.repetition_stats.pattern}</div>
</div>
</div>
)}
{data.rhyme_quality && (
<div className="p-3 rounded-lg bg-white/5 border border-zinc-200 dark:border-white/5">
<span className="text-[10px] text-purple-400 uppercase tracking-wider font-semibold">Rhyme Quality</span>
<div className="text-xs text-zinc-600 dark:text-zinc-400 mt-1 space-y-0.5">
<div>Perfect: {data.rhyme_quality.perfect}</div>
<div>Slant: {data.rhyme_quality.slant}</div>
<div>Assonance: {data.rhyme_quality.assonance}</div>
</div>
</div>
)}
</div>
</details>
)}
{/* Raw summary */}
{data.raw_summary && (
<details>
<summary className="text-[10px] text-zinc-500 uppercase tracking-wider cursor-pointer hover:text-zinc-700 dark:text-zinc-300">{t('lyric.fullSummary')}</summary>
<div className="mt-2 p-3 rounded-lg bg-black/20 dark:bg-black/40 border border-zinc-200 dark:border-white/5 text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-wrap leading-relaxed max-h-[40vh] overflow-y-auto">
{data.raw_summary}
</div>
</details>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
};
@@ -0,0 +1,202 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Save, RotateCcw, Code2, Loader2 } from 'lucide-react';
import { lireekApi } from '../../services/lireekApi';
interface PromptData {
name: string;
source: string;
content: string;
default_content: string;
has_default: boolean;
}
const FRIENDLY_NAMES: Record<string, string> = {
generation_system: 'Generation',
metadata_system: 'Metadata Planning',
profile_system: 'Artist Profiler',
refine_system: 'Refinement',
};
interface Props {
open: boolean;
onClose: () => void;
}
export const PromptEditor: React.FC<Props> = ({ open, onClose }) => {
const [prompts, setPrompts] = useState<PromptData[]>([]);
const { t } = useTranslation();
const [selected, setSelected] = useState<string | null>(null);
const [editContent, setEditContent] = useState('');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [toast, setToast] = useState<string | null>(null);
useEffect(() => {
if (open) loadPrompts();
}, [open]);
const loadPrompts = async () => {
setLoading(true);
try {
const res = await lireekApi.listPrompts();
const rawData = Array.isArray(res?.prompts) ? res.prompts : Array.isArray(res) ? res : [];
const data: PromptData[] = rawData.map((p: any) => ({
name: p.name,
source: p.custom != null ? 'custom' : 'default',
content: p.custom ?? p.default_content ?? '',
default_content: p.default_content ?? '',
has_default: !!p.default_content,
}));
setPrompts(data);
if (data.length > 0 && !selected) {
setSelected(data[0].name);
setEditContent(data[0].content);
setDirty(false);
}
} catch { } finally {
setLoading(false);
}
};
const selectPrompt = (name: string) => {
const p = prompts.find(pp => pp.name === name);
if (p) {
setSelected(name);
setEditContent(p.content);
setDirty(false);
}
};
const handleSave = async () => {
if (!selected) return;
setSaving(true);
try {
await lireekApi.savePrompt(selected, editContent);
setDirty(false);
setToast('Saved');
setTimeout(() => setToast(null), 2000);
loadPrompts();
} catch (err) {
setToast(`Save failed: ${(err as Error).message}`);
setTimeout(() => setToast(null), 3000);
} finally {
setSaving(false);
}
};
const handleReset = async () => {
if (!selected) return;
if (!confirm('Reset this prompt to its default? Your customizations will be lost.')) return;
try {
await lireekApi.resetPrompt(selected);
setToast('Reset to default');
setTimeout(() => setToast(null), 2000);
await loadPrompts();
const updated = prompts.find(p => p.name === selected);
if (updated) setEditContent(updated.content);
} catch (err) {
setToast(`Reset failed: ${(err as Error).message}`);
setTimeout(() => setToast(null), 3000);
}
};
if (!open) return null;
const currentPrompt = prompts.find(p => p.name === selected);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 dark:bg-black/60 backdrop-blur-sm">
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-300 dark:border-white/10 shadow-2xl w-[900px] h-[95vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-white/5">
<div className="flex items-center gap-2">
<Code2 className="w-5 h-5 text-cyan-400" />
<h2 className="text-lg font-bold text-white">{t('lyric.systemPrompts')}</h2>
</div>
<div className="flex items-center gap-2">
{toast && <span className="text-xs text-green-400">{toast}</span>}
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/10 text-zinc-600 dark:text-zinc-400 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex flex-1 min-h-0">
{/* Sidebar */}
<div className="w-56 flex-shrink-0 border-r border-zinc-200 dark:border-white/5 overflow-y-auto py-2">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-zinc-500" />
</div>
) : (
prompts.map(p => (
<button
key={p.name}
onClick={() => selectPrompt(p.name)}
className={`w-full text-left px-4 py-2.5 text-sm transition-colors flex items-center justify-between ${
selected === p.name
? 'bg-white/10 text-white border-l-2 border-cyan-400'
: 'text-zinc-600 dark:text-zinc-400 hover:bg-white/5 hover:text-zinc-800 dark:text-zinc-200 border-l-2 border-transparent'
}`}
>
<span className="truncate">{FRIENDLY_NAMES[p.name] || p.name}</span>
{p.source === 'file' && (
<span className="text-[9px] text-cyan-400 bg-cyan-400/10 px-1 rounded">{t('lyric.custom')}</span>
)}
</button>
))
)}
</div>
{/* Editor */}
<div className="flex-1 flex flex-col min-w-0">
{selected ? (
<>
<div className="flex items-center justify-between px-4 py-2 border-b border-zinc-200 dark:border-white/5">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">{FRIENDLY_NAMES[selected] || selected}</span>
{currentPrompt?.source === 'file' && (
<span className="text-[10px] text-cyan-400 bg-cyan-400/10 px-1.5 py-0.5 rounded">{t('lyric.customized')}</span>
)}
{dirty && (
<span className="text-[10px] text-amber-400 bg-amber-400/10 px-1.5 py-0.5 rounded">{t('lyric.unsavedChanges')}</span>
)}
</div>
<div className="flex items-center gap-1.5">
{currentPrompt?.has_default && currentPrompt?.source === 'file' && (
<button onClick={handleReset}
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:text-zinc-200 hover:bg-white/5 transition-colors"
title="Reset to default"
>
<RotateCcw className="w-3 h-3" /> {t('lyric.reset')}
</button>
)}
<button onClick={handleSave} disabled={!dirty || saving}
className="flex items-center gap-1 px-3 py-1 rounded-lg text-xs font-medium bg-cyan-500 text-black hover:bg-cyan-400 disabled:opacity-30 transition-all"
>
{saving ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
{t('common.save')}
</button>
</div>
</div>
<textarea
value={editContent}
onChange={(e) => { setEditContent(e.target.value); setDirty(true); }}
className="flex-1 p-4 bg-black/20 dark:bg-black/40 text-sm text-zinc-800 dark:text-zinc-200 font-mono leading-relaxed resize-none focus:outline-none"
spellCheck={false}
style={{ minHeight: 0 }}
/>
</>
) : (
<div className="flex-1 flex items-center justify-center text-zinc-500 text-sm">
{t('lyric.selectPromptToEdit')}
</div>
)}
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,303 @@
// ProviderSelector.tsx — LLM provider/model selector for Lyric Studio
// Ported from hot-step-9000 with lireekApi import adaptation
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { lireekApi } from '../../services/lireekApi';
// ── Provider type (local, matches API response) ───────────────────────────
interface LlmProviderInfo {
id: string;
name: string;
available: boolean;
models: string[];
default_model: string;
}
// ── Global provider cache ─────────────────────────────────────────────────
let _providerCache: LlmProviderInfo[] | null = null;
let _providerCacheTime = 0;
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
async function getCachedProviders(forceRefresh = false): Promise<LlmProviderInfo[]> {
const now = Date.now();
if (!forceRefresh && _providerCache && (now - _providerCacheTime) < CACHE_TTL_MS) {
return _providerCache;
}
const providers = await lireekApi.getProviders();
_providerCache = providers.filter(p => p.available);
_providerCacheTime = now;
return _providerCache;
}
// ── localStorage persistence for model selections ─────────────────────────
const STORAGE_KEY = 'lireek-model-selections';
export interface ModelSelections {
profiling: { provider: string; model: string };
generation: { provider: string; model: string };
refinement: { provider: string; model: string };
coverCaption: { provider: string; model: string };
}
export function loadSelections(): ModelSelections {
const defaults: ModelSelections = {
profiling: { provider: '', model: '' },
generation: { provider: '', model: '' },
refinement: { provider: '', model: '' },
coverCaption: { provider: '', model: '' },
};
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
// Merge defensively — localStorage might contain '{}' or partial data
return {
profiling: { ...defaults.profiling, ...parsed.profiling },
generation: { ...defaults.generation, ...parsed.generation },
refinement: { ...defaults.refinement, ...parsed.refinement },
coverCaption: { ...defaults.coverCaption, ...parsed.coverCaption },
};
}
} catch { /* ignore */ }
return defaults;
}
export function saveSelections(sel: ModelSelections) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(sel));
}
// ── Single row selector ──────────────────────────────────────────────────
export const RowSelector: React.FC<{
label: string;
color: string;
providers: LlmProviderInfo[];
selectedProvider: string;
selectedModel: string;
onSelectionChange: (provider: string, model: string) => void;
}> = ({ label, color, providers, selectedProvider, selectedModel, onSelectionChange }) => {
const { t } = useTranslation();
const currentProvider = providers.find(p => p.id === selectedProvider);
const models = currentProvider?.models || [];
return (
<div className="space-y-1">
<span className={`text-[10px] font-semibold uppercase tracking-wider ${color}`}>{label}</span>
<select
value={selectedProvider}
onChange={e => {
const pid = e.target.value;
const prov = providers.find(p => p.id === pid);
onSelectionChange(pid, prov?.default_model || '');
}}
className="w-full px-2 py-1.5 rounded bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-[11px] text-white focus:outline-none focus:border-pink-500/50 appearance-none cursor-pointer"
title={`${label} Provider`}
>
{providers.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<select
value={selectedModel}
onChange={e => onSelectionChange(selectedProvider, e.target.value)}
className="w-full px-2 py-1.5 rounded bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-[11px] text-white focus:outline-none focus:border-pink-500/50 appearance-none cursor-pointer"
title={`${label} Model`}
>
{models.map(m => (
<option key={m} value={m}>{m}</option>
))}
{models.length === 0 && <option value="">{t('lyric.noModels')}</option>}
</select>
</div>
);
};
// ── Triple Provider Selector ──────────────────────────────────────────────
interface TripleProviderSelectorProps {
selections: ModelSelections;
onSelectionsChange: (sel: ModelSelections) => void;
}
export const TripleProviderSelector: React.FC<TripleProviderSelectorProps> = ({
selections,
onSelectionsChange,
}) => {
const [providers, setProviders] = useState<LlmProviderInfo[]>(_providerCache || []);
const { t } = useTranslation();
const [loading, setLoading] = useState(!_providerCache);
// Use ref to always have latest selections for the async callback
const selectionsRef = useRef(selections);
selectionsRef.current = selections;
useEffect(() => {
getCachedProviders()
.then(p => {
setProviders(p);
// Auto-select first available provider ONLY for empty slots
if (p.length > 0) {
const first = p[0];
const current = selectionsRef.current;
const updated = { ...current };
let changed = false;
for (const role of ['profiling', 'generation', 'refinement', 'coverCaption'] as const) {
if (!updated[role].provider || !p.find(pp => pp.id === updated[role].provider)) {
updated[role] = { provider: first.id, model: first.default_model || '' };
changed = true;
}
}
if (changed) {
onSelectionsChange(updated);
saveSelections(updated);
}
}
})
.catch(err => console.error('Failed to load LLM providers:', err))
.finally(() => setLoading(false));
}, []);
const updateRole = (role: keyof ModelSelections, provider: string, model: string) => {
const updated = {
...selectionsRef.current,
[role]: { provider, model },
};
onSelectionsChange(updated);
saveSelections(updated);
};
if (loading) {
return (
<div className="flex items-center gap-2 text-xs text-zinc-500 py-1">
<div className="w-3 h-3 border-2 border-zinc-500 border-t-transparent rounded-full animate-spin" />
{t('lyric.loadingProviders')}
</div>
);
}
if (providers.length === 0) {
return (
<div className="text-xs text-amber-400 py-1">
{t('lyric.noProvidersConfigured')}
</div>
);
}
return (
<div className="space-y-3">
<RowSelector
label={t('lyric.profile')}
color="text-amber-400"
providers={providers}
selectedProvider={selections.profiling.provider}
selectedModel={selections.profiling.model}
onSelectionChange={(p, m) => updateRole('profiling', p, m)}
/>
<RowSelector
label={t('lyric.generate')}
color="text-green-400"
providers={providers}
selectedProvider={selections.generation.provider}
selectedModel={selections.generation.model}
onSelectionChange={(p, m) => updateRole('generation', p, m)}
/>
<RowSelector
label={t('lyric.refine')}
color="text-purple-400"
providers={providers}
selectedProvider={selections.refinement.provider}
selectedModel={selections.refinement.model}
onSelectionChange={(p, m) => updateRole('refinement', p, m)}
/>
</div>
);
};
// ── Legacy single selector (kept for backward compat) ─────────────────────
interface ProviderSelectorProps {
selectedProvider: string;
selectedModel: string;
onProviderChange: (provider: string) => void;
onModelChange: (model: string) => void;
label?: string;
compact?: boolean;
}
export const ProviderSelector: React.FC<ProviderSelectorProps> = ({
selectedProvider,
selectedModel,
onProviderChange,
onModelChange,
label = 'LLM Provider',
compact = false,
}) => {
const { t } = useTranslation();
const [providers, setProviders] = useState<LlmProviderInfo[]>(_providerCache || []);
const [loading, setLoading] = useState(!_providerCache);
useEffect(() => {
getCachedProviders()
.then(p => {
setProviders(p);
if (!selectedProvider && p.length > 0) {
const first = p[0];
onProviderChange(first.id);
if (first.default_model) onModelChange(first.default_model);
}
})
.catch(err => console.error('Failed to load LLM providers:', err))
.finally(() => setLoading(false));
}, []);
const currentProvider = providers.find(p => p.id === selectedProvider);
const models = currentProvider?.models || [];
if (loading) {
return (
<div className={`flex items-center gap-2 text-xs text-zinc-500 ${compact ? '' : 'mb-3'}`}>
<div className="w-3 h-3 border-2 border-zinc-500 border-t-transparent rounded-full animate-spin" />
{t('lyric.loadingProviders')}
</div>
);
}
if (providers.length === 0) {
return (
<div className={`text-xs text-amber-400 ${compact ? '' : 'mb-3'}`}>
{t('lyric.noProvidersConfigured')}
</div>
);
}
return (
<div className={`flex ${compact ? 'flex-row items-center gap-2' : 'flex-col gap-2'}`}>
{!compact && <label className="text-xs font-medium text-zinc-600 dark:text-zinc-400 uppercase tracking-wider">{label}</label>}
<div className={`flex ${compact ? 'flex-row' : 'flex-row'} gap-2 flex-1`}>
<select
value={selectedProvider}
onChange={e => {
const pid = e.target.value;
onProviderChange(pid);
const prov = providers.find(p => p.id === pid);
if (prov?.default_model) onModelChange(prov.default_model);
}}
className="flex-1 px-2.5 py-1.5 rounded-lg bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-sm text-white focus:outline-none focus:border-pink-500/50 appearance-none cursor-pointer"
title="LLM Provider"
>
{providers.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<select
value={selectedModel}
onChange={e => onModelChange(e.target.value)}
className="flex-1 px-2.5 py-1.5 rounded-lg bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-sm text-white focus:outline-none focus:border-pink-500/50 appearance-none cursor-pointer"
title="Model"
>
{models.map(m => (
<option key={m} value={m}>{m}</option>
))}
{models.length === 0 && <option value="">{t('lyric.noModels')}</option>}
</select>
</div>
</div>
);
};
@@ -0,0 +1,810 @@
/**
* QueuePanel.tsx — Bulk operations panel for Lyric Studio V2.
*
* Modes:
* - Build Profiles: Queue profile builds for unprofiled albums
* - Generate Lyrics: Queue lyric generation for profiled albums
* - Assign Presets: Bulk-assign adapter + reference track presets to albums
* - Fetch Lyrics: Batch-fetch lyrics from Genius for new artists/albums
*
* Adapted for C++ engine: removed adapter type detection (not needed).
*/
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
X, Loader2, CheckCircle, AlertCircle, ListOrdered, Sparkles,
Wand2, Settings2, FolderSearch, ChevronDown, ChevronRight,
RefreshCw, Zap, Music, Search, Plus, Trash2, ClipboardPaste,
LayoutList, Play, Square,
} from 'lucide-react';
import {
useStreamingStore,
addBulkToQueue,
removeFromQueue,
clearQueue,
} from '../../stores/streamingStore';
import type { QueueItemType } from '../../stores/streamingStore';
import { lireekApi } from '../../services/lireekApi';
import { adapterApi } from '../../services/api';
import type { Artist, LyricsSet, Profile, AlbumPreset } from '../../services/lireekApi';
import { useAuth } from '../../context/AuthContext';
import { FileBrowserModal } from '../shared/FileBrowserModal';
import { EditableSlider } from '../shared/EditableSlider';
import { useDisguiseMode } from '../../hooks/useDisguiseMode';
interface QueuePanelProps {
open: boolean;
onClose: () => void;
artists: Artist[];
lyricsSets: LyricsSet[];
profiles: Profile[];
profilingModel: { provider: string; model?: string };
generationModel: { provider: string; model?: string };
refinementModel: { provider: string; model?: string };
showToast?: (msg: string) => void;
onFetchComplete?: () => void;
}
type QueueMode = 'profile' | 'generate' | 'presets' | 'fetch-lyrics';
interface FetchEntry { artist: string; album: string; }
type FetchStatus = 'pending' | 'running' | 'done' | 'error' | 'skipped';
interface FetchQueueItem extends FetchEntry {
id: string; status: FetchStatus; error?: string; songsFetched?: number;
}
type FetchInputMode = 'paste' | 'structured';
type PresetStatus = 'complete' | 'partial' | 'missing';
function getPresetStatus(preset?: AlbumPreset | null): PresetStatus {
if (!preset) return 'missing';
const hasAdapter = !!preset.adapter_path;
const hasRef = !!preset.reference_track_path;
if (hasAdapter && hasRef) return 'complete';
if (hasAdapter || hasRef) return 'partial';
return 'missing';
}
const STATUS_BADGE: Record<PresetStatus, { label: string; color: string; icon: string }> = {
complete: { label: 'PRESET', color: 'bg-green-900/30 text-green-400', icon: '✓' },
partial: { label: 'PARTIAL', color: 'bg-amber-900/30 text-amber-400', icon: '⚠' },
missing: { label: 'NONE', color: 'bg-red-900/30 text-red-400', icon: '✕' },
};
export const QueuePanel: React.FC<QueuePanelProps> = ({
open, onClose, artists: _artists, lyricsSets, profiles,
profilingModel, generationModel, refinementModel: _refinementModel, showToast,
onFetchComplete,
}) => {
const stream = useStreamingStore();
const { t } = useTranslation();
const { token: _token } = useAuth();
const { disguiseArtist, disguiseAlbum } = useDisguiseMode();
const [selected, setSelected] = useState<Set<number>>(new Set());
const [mode, setMode] = useState<QueueMode>('profile');
const [genCount, setGenCount] = useState(4);
const [genFillMode, setGenFillMode] = useState(false);
const [genFillTarget, setGenFillTarget] = useState(10);
const [genNoThink, setGenNoThink] = useState(false);
// Generation counts for the "generate" tab
const [genCountsMap, setGenCountsMap] = useState<Map<number, number>>(new Map());
const [genCountsLoading, setGenCountsLoading] = useState(false);
const [genFilterThreshold, setGenFilterThreshold] = useState('');
// Presets state
const [presetMap, setPresetMap] = useState<Map<number, AlbumPreset>>(new Map());
const [presetsLoading, setPresetsLoading] = useState(false);
const [applying, setApplying] = useState(false);
const [adapterPath, setAdapterPath] = useState('');
const [matcheringPath, setMatcheringPath] = useState('');
const [selfAttn, setSelfAttn] = useState(1.0);
const [crossAttn, setCrossAttn] = useState(1.0);
const [mlp, setMlp] = useState(1.0);
const [condEmbed, setCondEmbed] = useState(1.0);
const [groupsExpanded, setGroupsExpanded] = useState(false);
const [presetFilter, setPresetFilter] = useState('');
const [browserOpen, setBrowserOpen] = useState(false);
const [browserTarget, setBrowserTarget] = useState<'adapter' | 'matchering'>('adapter');
// Planner-LM adapter bulk assignment (local HOT-Step feature)
const [lmAdapterPath, setLmAdapterPath] = useState('');
const [lmAdapterList, setLmAdapterList] = useState<{ name: string; path: string }[]>([]);
useEffect(() => {
if (mode !== 'presets') return;
// Same scan folder the global Adapters menu uses (persisted store value)
let folder = '';
try { folder = JSON.parse(localStorage.getItem('hs-lmAdapterFolder') || '""') || ''; } catch { /* default */ }
adapterApi.lmList(folder || undefined).then(r => setLmAdapterList(r?.adapters || [])).catch(() => {});
}, [mode]);
// Fetch lyrics state
const [fetchInputMode, setFetchInputMode] = useState<FetchInputMode>('paste');
const [pasteText, setPasteText] = useState('');
const [structuredRows, setStructuredRows] = useState<FetchEntry[]>([{ artist: '', album: '' }]);
const [fetchMaxSongs, setFetchMaxSongs] = useState(50);
const [fetchQueue, setFetchQueue] = useState<FetchQueueItem[]>([]);
const [fetchRunning, setFetchRunning] = useState(false);
const fetchAbortRef = useRef(false);
const loadPresets = useCallback(async () => {
setPresetsLoading(true);
try {
const res = await lireekApi.listAllPresets();
const map = new Map<number, AlbumPreset>();
for (const p of res.presets) map.set(p.lyrics_set_id, p);
setPresetMap(map);
} catch (err) {
console.error('[QueuePanel] Failed to load presets:', err);
} finally {
setPresetsLoading(false);
}
}, []);
const loadGenerationCounts = useCallback(async () => {
setGenCountsLoading(true);
try {
const res = await lireekApi.listAllGenerations();
// API returns raw array (server) but type says { generations }, handle both
const gens = Array.isArray(res) ? res : (res.generations || []);
const counts = new Map<number, number>();
for (const g of gens) {
counts.set(g.profile_id, (counts.get(g.profile_id) || 0) + 1);
}
setGenCountsMap(counts);
} catch (err) {
console.error('[QueuePanel] Failed to load generation counts:', err);
} finally {
setGenCountsLoading(false);
}
}, []);
if (!open) return null;
const parsePasteText = (text: string): FetchEntry[] => {
return text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('#')).map(line => {
let parts: string[];
if (line.includes('|')) parts = line.split('|').map(s => s.trim());
else if (line.includes(' - ') && !line.startsWith('http')) parts = line.split(' - ', 2).map(s => s.trim());
else parts = [line.trim()];
return { artist: parts[0] || '', album: parts[1] || '' };
}).filter(e => e.artist.length > 0);
};
const addStructuredRow = () => setStructuredRows(prev => [...prev, { artist: '', album: '' }]);
const removeStructuredRow = (idx: number) => setStructuredRows(prev => prev.filter((_, i) => i !== idx));
const updateStructuredRow = (idx: number, field: 'artist' | 'album', value: string) => {
setStructuredRows(prev => prev.map((row, i) => i === idx ? { ...row, [field]: value } : row));
};
const startFetchQueue = async () => {
const entries = fetchInputMode === 'paste' ? parsePasteText(pasteText) : structuredRows.filter(r => r.artist.trim().length > 0);
if (entries.length === 0) { showToast?.('No valid entries to fetch'); return; }
const existingArtistAlbums = new Set(
lyricsSets.map(ls => `${(ls.artist_name || '').toLowerCase()}|||${(ls.album || '').toLowerCase()}`)
);
const items: FetchQueueItem[] = entries.map((e, i) => {
const key = `${e.artist.toLowerCase()}|||${e.album.toLowerCase()}`;
const alreadyExists = existingArtistAlbums.has(key);
return { ...e, id: `fetch-${Date.now()}-${i}`, status: alreadyExists ? 'skipped' as FetchStatus : 'pending' as FetchStatus, error: alreadyExists ? 'Already exists' : undefined };
});
setFetchQueue(items);
setFetchRunning(true);
fetchAbortRef.current = false;
let completed = 0, failed = 0, skipped = 0;
for (let i = 0; i < items.length; i++) {
if (fetchAbortRef.current) break;
const item = items[i];
if (item.status === 'skipped') { skipped++; continue; }
setFetchQueue(prev => prev.map((q, qi) => qi === i ? { ...q, status: 'running' } : q));
try {
const res = await lireekApi.fetchLyrics({ artist: item.artist, album: item.album || undefined, max_songs: fetchMaxSongs });
completed++;
setFetchQueue(prev => prev.map((q, qi) => qi === i ? { ...q, status: 'done', songsFetched: res.songs_fetched } : q));
} catch (err: any) {
failed++;
setFetchQueue(prev => prev.map((q, qi) => qi === i ? { ...q, status: 'error', error: err.message || 'Fetch failed' } : q));
}
}
setFetchRunning(false);
const parts = [];
if (completed > 0) parts.push(`${completed} fetched`);
if (skipped > 0) parts.push(`${skipped} skipped`);
if (failed > 0) parts.push(`${failed} failed`);
showToast?.(parts.join(', ') || 'Queue complete');
onFetchComplete?.();
};
const stopFetchQueue = () => { fetchAbortRef.current = true; };
const clearFetchQueue = () => { if (!fetchRunning) setFetchQueue([]); };
const fetchQueuePending = fetchQueue.filter(q => q.status === 'pending').length;
const fetchQueueDone = fetchQueue.filter(q => q.status === 'done').length;
const fetchQueueErrors = fetchQueue.filter(q => q.status === 'error').length;
const fetchQueueSkipped = fetchQueue.filter(q => q.status === 'skipped').length;
const toggleItem = (id: number) => {
setSelected(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; });
};
const selectAll = () => {
if (mode === 'profile') {
const profiledSetIds = new Set(profiles.map(p => p.lyrics_set_id));
setSelected(new Set(lyricsSets.filter(ls => !profiledSetIds.has(ls.id)).map(ls => ls.id)));
} else if (mode === 'generate') {
const threshold = genFilterThreshold.trim() !== '' ? parseInt(genFilterThreshold) : null;
const visible = profiles.filter(p => {
if (threshold == null || isNaN(threshold)) return true;
return (genCountsMap.get(p.id) || 0) < threshold;
});
setSelected(new Set(visible.map(p => p.id)));
} else {
setSelected(new Set(lyricsSets.map(ls => ls.id)));
}
};
const selectMissing = () => {
setSelected(new Set(lyricsSets.filter(ls => getPresetStatus(presetMap.get(ls.id)) === 'missing').map(ls => ls.id)));
};
const selectIncomplete = () => {
setSelected(new Set(lyricsSets.filter(ls => {
const st = getPresetStatus(presetMap.get(ls.id));
return st === 'missing' || st === 'partial';
}).map(ls => ls.id)));
};
const handleQueue = () => {
if (selected.size === 0) return;
if (mode === 'profile') {
addBulkToQueue(Array.from(selected).map(lsId => {
const ls = lyricsSets.find(l => l.id === lsId);
return { type: 'profile' as QueueItemType, targetId: lsId, label: `Profile: ${ls?.artist_name || '?'}${ls?.album || 'Unknown'}`, provider: profilingModel.provider, model: profilingModel.model };
}));
} else {
const items = Array.from(selected).map(profileId => {
const profile = profiles.find(p => p.id === profileId);
const ls = lyricsSets.find(l => l.id === profile?.lyrics_set_id);
const existing = genCountsMap.get(profileId) || 0;
const count = genFillMode ? Math.max(0, genFillTarget - existing) : genCount;
return {
type: 'generate' as QueueItemType,
targetId: profileId,
label: `Generate: ${ls?.artist_name || '?'}${ls?.album || 'Unknown'}${genNoThink ? ' · ⚡ no-think' : ''}`,
provider: generationModel.provider,
model: generationModel.model,
count,
noThink: genNoThink || undefined,
};
}).filter(item => item.count > 0);
if (items.length === 0) { showToast?.('All selected profiles already at or above target'); return; }
addBulkToQueue(items);
}
setSelected(new Set());
};
const handleApplyPresets = async () => {
if (selected.size === 0) return;
const hasAdapter = adapterPath.trim().length > 0;
const hasRef = matcheringPath.trim().length > 0;
const hasLm = lmAdapterPath.trim().length > 0;
if (!hasAdapter && !hasRef && !hasLm) { showToast?.('Set at least one field (adapter, reference, or planner adapter) to apply'); return; }
setApplying(true);
let success = 0, failed = 0;
for (const lsId of Array.from(selected)) {
try {
// upsertPreset is a FULL overwrite server-side — start from the album's
// existing preset so applying one field never wipes the others.
const existing = presetMap.get(lsId);
const params: any = {
adapter_path: existing?.adapter_path,
adapter_group_scales: existing?.adapter_group_scales,
reference_track_path: existing?.reference_track_path,
lm_adapter_path: existing?.lm_adapter_path,
lm_adapter_scale: existing?.lm_adapter_scale,
};
if (hasAdapter) {
params.adapter_path = adapterPath.trim();
params.adapter_group_scales = { self_attn: selfAttn, cross_attn: crossAttn, mlp, cond_embed: condEmbed };
}
if (hasRef) params.reference_track_path = matcheringPath.trim();
if (hasLm) params.lm_adapter_path = lmAdapterPath.trim();
await lireekApi.upsertPreset(lsId, params);
success++;
} catch (err) {
failed++;
console.error(`[QueuePanel] Failed to upsert preset for ls_id=${lsId}:`, err);
}
}
showToast?.(failed === 0 ? `Applied presets to ${success} album${success !== 1 ? 's' : ''}` : `Applied to ${success}, failed ${failed}`);
setSelected(new Set());
await loadPresets();
setApplying(false);
};
const queueItems = stream.queue;
const pendingCount = queueItems.filter(q => q.status === 'pending').length;
const runningItem = queueItems.find(q => q.status === 'running');
const doneCount = queueItems.filter(q => q.status === 'done').length;
const adapterFileName = adapterPath ? adapterPath.split(/[\\/]/).pop() || '' : '';
const matchFileName = matcheringPath ? matcheringPath.split(/[\\/]/).pop() || '' : '';
const presetStats = {
complete: lyricsSets.filter(ls => getPresetStatus(presetMap.get(ls.id)) === 'complete').length,
partial: lyricsSets.filter(ls => getPresetStatus(presetMap.get(ls.id)) === 'partial').length,
missing: lyricsSets.filter(ls => getPresetStatus(presetMap.get(ls.id)) === 'missing').length,
};
return (
<>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 dark:bg-black/60 backdrop-blur-sm">
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-300 dark:border-white/10 shadow-2xl w-[680px] max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-white/5">
<div className="flex items-center gap-2">
<ListOrdered className="w-5 h-5 text-pink-400" />
<h2 className="text-lg font-bold text-white">{t('lyric.bulkOperations')}</h2>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/10 text-zinc-600 dark:text-zinc-400 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{/* Mode tabs */}
<div className="flex items-center gap-2 px-6 pt-4">
<button onClick={() => { setMode('profile'); setSelected(new Set()); }}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${mode === 'profile' ? 'bg-amber-500/20 text-amber-300' : 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:text-zinc-200 hover:bg-white/5'}`}>
<Sparkles className="w-3.5 h-3.5" /> Build Profiles
</button>
<button onClick={() => { setMode('generate'); setSelected(new Set()); loadGenerationCounts(); }}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${mode === 'generate' ? 'bg-green-500/20 text-green-300' : 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:text-zinc-200 hover:bg-white/5'}`}>
<Wand2 className="w-3.5 h-3.5" /> Generate Lyrics
</button>
<button onClick={() => { setMode('presets'); setSelected(new Set()); loadPresets(); }}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${mode === 'presets' ? 'bg-pink-500/20 text-pink-300' : 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:text-zinc-200 hover:bg-white/5'}`}>
<Settings2 className="w-3.5 h-3.5" /> Assign Presets
</button>
<button onClick={() => { setMode('fetch-lyrics'); setSelected(new Set()); }}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${mode === 'fetch-lyrics' ? 'bg-cyan-500/20 text-cyan-300' : 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:text-zinc-200 hover:bg-white/5'}`}>
<Search className="w-3.5 h-3.5" /> Fetch Lyrics
</button>
</div>
{/* ══ Presets mode config panel ══ */}
{mode === 'presets' && (
<div className="px-6 pt-3 pb-2 space-y-3 border-b border-zinc-200 dark:border-white/5">
<div className="flex items-center gap-3 text-[10px] font-semibold">
<span className="text-green-400">{presetStats.complete} complete</span>
<span className="text-amber-400">{presetStats.partial} partial</span>
<span className="text-red-400">{presetStats.missing} missing</span>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500 pointer-events-none" />
<input type="text" value={presetFilter} onChange={e => setPresetFilter(e.target.value)}
placeholder="Filter by artist or album…"
className="w-full bg-zinc-200 dark:bg-black/20 border border-zinc-300 dark:border-white/10 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-pink-500/50 transition-colors" />
{presetFilter && (
<button onClick={() => setPresetFilter('')} className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-white/10 text-zinc-500 hover:text-white transition-colors">
<X className="w-3 h-3" />
</button>
)}
</div>
{/* Adapter path */}
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-xs font-semibold text-zinc-700 dark:text-zinc-300">
<Zap className="w-3.5 h-3.5 text-pink-400" /> Adapter to Apply
</div>
<div className="flex gap-2">
<input type="text" value={adapterPath} onChange={e => setAdapterPath(e.target.value)}
placeholder="Path to .safetensors adapter file"
className="flex-1 bg-zinc-200 dark:bg-black/20 border border-zinc-300 dark:border-white/10 rounded-lg px-3 py-1.5 text-xs text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-pink-500 transition-colors" />
<button onClick={() => { setBrowserTarget('adapter'); setBrowserOpen(true); }}
className="px-2.5 py-1.5 rounded-lg text-xs font-semibold bg-pink-900/20 text-pink-400 hover:bg-pink-900/30 transition-colors flex items-center gap-1 flex-shrink-0">
<FolderSearch size={12} /> Browse
</button>
</div>
{adapterPath && (
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-pink-900/30 text-pink-400">ADAPTER</span>
<span className="text-[10px] text-zinc-500 truncate" title={adapterPath}>{adapterFileName}</span>
</div>
)}
</div>
{/* Group scales for bulk preset assignment */}
{adapterPath && (
<div className="space-y-1">
<button onClick={() => setGroupsExpanded(!groupsExpanded)}
className="flex items-center gap-1.5 text-[10px] font-semibold text-zinc-500 hover:text-zinc-700 dark:text-zinc-300 transition-colors uppercase tracking-wider">
{groupsExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
Group Scales
</button>
{groupsExpanded && (
<div className="space-y-1 pl-3 border-l-2 border-pink-500/20">
<EditableSlider label="Self-Attn" value={selfAttn} min={0} max={4} step={0.05} onChange={setSelfAttn} formatDisplay={v => v.toFixed(2)} />
<EditableSlider label="Cross-Attn" value={crossAttn} min={0} max={4} step={0.05} onChange={setCrossAttn} formatDisplay={v => v.toFixed(2)} />
<EditableSlider label="MLP" value={mlp} min={0} max={4} step={0.05} onChange={setMlp} formatDisplay={v => v.toFixed(2)} />
<EditableSlider label="Cond" value={condEmbed} min={0} max={4} step={0.05} onChange={setCondEmbed} formatDisplay={v => v.toFixed(2)} />
</div>
)}
</div>
)}
{/* Planner-LM adapter (song structure) */}
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-xs font-semibold text-zinc-700 dark:text-zinc-300">
<Zap className="w-3.5 h-3.5 text-violet-400" /> Planner Adapter (LM) to Apply
</div>
<select value={lmAdapterPath} onChange={e => setLmAdapterPath(e.target.value)}
className="w-full bg-zinc-200 dark:bg-black/20 border border-zinc-300 dark:border-white/10 rounded-lg px-3 py-1.5 text-xs text-white focus:outline-none focus:border-violet-500 transition-colors cursor-pointer">
<option value="">Keep existing / none</option>
{lmAdapterList.map(a => (
<option key={a.path} value={a.path}>{a.name}</option>
))}
</select>
</div>
{/* Reference track */}
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-xs font-semibold text-zinc-700 dark:text-zinc-300">
<Music className="w-3.5 h-3.5 text-amber-400" /> Reference Track to Apply
</div>
<div className="flex gap-2">
<input type="text" value={matcheringPath} onChange={e => setMatcheringPath(e.target.value)}
placeholder="Path to reference audio (.wav, .mp3, .flac)"
className="flex-1 bg-zinc-200 dark:bg-black/20 border border-zinc-300 dark:border-white/10 rounded-lg px-3 py-1.5 text-xs text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-amber-500 transition-colors" />
<button onClick={() => { setBrowserTarget('matchering'); setBrowserOpen(true); }}
className="px-2.5 py-1.5 rounded-lg text-xs font-semibold bg-amber-900/20 text-amber-400 hover:bg-amber-900/30 transition-colors flex items-center gap-1 flex-shrink-0">
<FolderSearch size={12} /> Browse
</button>
</div>
{matcheringPath && (
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-amber-900/30 text-amber-400">REF</span>
<span className="text-[10px] text-zinc-500 truncate" title={matcheringPath}>{matchFileName}</span>
</div>
)}
</div>
</div>
)}
{/* ══ Fetch Lyrics mode config panel ══ */}
{mode === 'fetch-lyrics' && (
<div className="px-6 pt-3 pb-2 space-y-3 border-b border-zinc-200 dark:border-white/5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button onClick={() => setFetchInputMode('paste')}
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold transition-colors ${fetchInputMode === 'paste' ? 'bg-cyan-500/20 text-cyan-300' : 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-300 hover:bg-white/5'}`}>
<ClipboardPaste className="w-3 h-3" /> Paste
</button>
<button onClick={() => setFetchInputMode('structured')}
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] font-semibold transition-colors ${fetchInputMode === 'structured' ? 'bg-cyan-500/20 text-cyan-300' : 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-300 hover:bg-white/5'}`}>
<LayoutList className="w-3 h-3" /> Rows
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-zinc-500 uppercase tracking-wider">{t('lyric.maxSongs')}</span>
<input type="number" value={fetchMaxSongs} onChange={e => setFetchMaxSongs(Math.max(1, Math.min(200, parseInt(e.target.value) || 50)))} min={1} max={200}
className="w-14 px-2 py-1 rounded-lg bg-zinc-200 dark:bg-black/20 border border-zinc-300 dark:border-white/10 text-xs text-white text-center font-mono focus:outline-none focus:border-cyan-500/50 transition-all" />
</div>
</div>
{fetchInputMode === 'paste' && (
<div className="space-y-1.5">
<textarea value={pasteText} onChange={e => setPasteText(e.target.value)}
placeholder={`Paste one entry per line:\nArtist | Album\nArtist | Album\n\nAlso supports:\nArtist - Album\nArtist (fetches top songs)`}
rows={6} disabled={fetchRunning}
className="w-full bg-zinc-200 dark:bg-black/20 border border-zinc-300 dark:border-white/10 rounded-xl px-3 py-2.5 text-xs text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-cyan-500/40 transition-all resize-none font-mono leading-relaxed disabled:opacity-50" />
<p className="text-[10px] text-zinc-600">{parsePasteText(pasteText).length} entries detected · lines starting with # are ignored</p>
</div>
)}
{fetchInputMode === 'structured' && (
<div className="space-y-1.5 max-h-[200px] overflow-y-auto scrollbar-hide">
{structuredRows.map((row, idx) => (
<div key={idx} className="flex items-center gap-1.5">
<input type="text" value={row.artist} onChange={e => updateStructuredRow(idx, 'artist', e.target.value)} placeholder="Artist" disabled={fetchRunning}
className="flex-1 bg-zinc-200 dark:bg-black/20 border border-zinc-300 dark:border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-cyan-500/40 transition-all disabled:opacity-50" />
<input type="text" value={row.album} onChange={e => updateStructuredRow(idx, 'album', e.target.value)} placeholder="Album (optional)" disabled={fetchRunning}
className="flex-1 bg-zinc-200 dark:bg-black/20 border border-zinc-300 dark:border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-cyan-500/40 transition-all disabled:opacity-50" />
<button onClick={() => removeStructuredRow(idx)} disabled={fetchRunning || structuredRows.length <= 1}
className="p-1 rounded-lg text-zinc-600 hover:text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-30 disabled:pointer-events-none">
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
<button onClick={addStructuredRow} disabled={fetchRunning}
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-[11px] text-cyan-400 hover:text-cyan-300 hover:bg-cyan-500/10 transition-colors disabled:opacity-50">
<Plus className="w-3 h-3" /> Add Row
</button>
</div>
)}
</div>
)}
{/* Selection list */}
<div className="flex-1 overflow-y-auto px-6 py-3 space-y-1 scrollbar-hide" style={{ maxHeight: mode === 'presets' ? '250px' : '300px' }}>
{mode === 'profile' ? (() => {
const profiledSetIds = new Set(profiles.map(p => p.lyrics_set_id));
const unprofiled = lyricsSets.filter(ls => !profiledSetIds.has(ls.id));
return unprofiled.length === 0 ? (
<p className="text-zinc-500 text-sm text-center py-4">{lyricsSets.length === 0 ? 'No albums available' : 'All albums already have profiles ✓'}</p>
) : (<>{unprofiled.map(ls => (
<label key={ls.id} className={`flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors ${selected.has(ls.id) ? 'bg-amber-500/10 border border-amber-500/20' : 'bg-white/5 border border-transparent hover:bg-white/10'}`}>
<input type="checkbox" checked={selected.has(ls.id)} onChange={() => toggleItem(ls.id)} className="accent-amber-500" />
<div className="flex-1 min-w-0">
<span className="text-sm text-white truncate block">{disguiseAlbum(ls.album || '') || 'Unknown Album'}</span>
<span className="text-[10px] text-zinc-500">{disguiseArtist(ls.artist_name || '')}</span>
</div>
</label>
))}</>);
})() : mode === 'generate' ? (
profiles.length === 0 ? (
<p className="text-zinc-500 text-sm text-center py-4">{t('lyric.noProfilesAvailable')}</p>
) : (() => {
const threshold = genFilterThreshold.trim() !== '' ? parseInt(genFilterThreshold) : null;
const filtered = profiles.filter(profile => {
if (threshold == null || isNaN(threshold)) return true;
const count = genCountsMap.get(profile.id) || 0;
return count < threshold;
});
return filtered.length === 0 ? (
<p className="text-zinc-500 text-sm text-center py-4">
{threshold != null ? `All ${profiles.length} profiles have ≥ ${threshold} generation${threshold !== 1 ? 's' : ''} — try a higher threshold` : 'No profiles match'}
</p>
) : (<>{filtered.map(profile => {
const ls = lyricsSets.find(l => l.id === profile.lyrics_set_id);
const genCt = genCountsMap.get(profile.id) || 0;
return (
<label key={profile.id} className={`flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors ${selected.has(profile.id) ? 'bg-green-500/10 border border-green-500/20' : 'bg-white/5 border border-transparent hover:bg-white/10'}`}>
<input type="checkbox" checked={selected.has(profile.id)} onChange={() => toggleItem(profile.id)} className="accent-green-500" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-white truncate">{disguiseAlbum(ls?.album || '') || 'Unknown Album'} {disguiseArtist(ls?.artist_name || '?')}</span>
<span className={`text-[9px] font-bold px-1.5 py-0.5 rounded flex-shrink-0 ${genCt === 0 ? 'bg-red-900/30 text-red-400' : genCt < 3 ? 'bg-amber-900/30 text-amber-400' : 'bg-green-900/30 text-green-400'}`}>
{genCt} gen{genCt !== 1 ? 's' : ''}
</span>
{genFillMode && selected.has(profile.id) && (() => {
const need = Math.max(0, genFillTarget - genCt);
return need > 0
? <span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-green-900/30 text-green-400 flex-shrink-0">+{need}</span>
: <span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-800 text-zinc-500 flex-shrink-0"> full</span>;
})()}
</div>
<span className="text-[10px] text-zinc-500">{profile.provider}/{profile.model} · {new Date(profile.created_at).toLocaleDateString()}</span>
</div>
</label>
);
})}</>);
})()
) : mode === 'presets' ? (
presetsLoading ? (
<div className="flex items-center justify-center py-8"><Loader2 className="w-5 h-5 text-zinc-500 animate-spin" /></div>
) : lyricsSets.length === 0 ? (
<p className="text-zinc-500 text-sm text-center py-4">{t('lyric.noAlbumsAvailable')}</p>
) : (() => {
const needle = presetFilter.toLowerCase().trim();
const filtered = lyricsSets
.filter(ls => !needle || (ls.artist_name || '').toLowerCase().includes(needle) || (ls.album || '').toLowerCase().includes(needle))
.sort((a, b) => {
const cmp = (a.artist_name || '').localeCompare(b.artist_name || '', undefined, { sensitivity: 'base' });
return cmp !== 0 ? cmp : (a.album || '').localeCompare(b.album || '', undefined, { sensitivity: 'base' });
});
return filtered.length === 0 ? (
<p className="text-zinc-500 text-sm text-center py-4">{t('lyric.noAlbumsMatch', { filter: presetFilter })}</p>
) : (<>{filtered.map(ls => {
const preset = presetMap.get(ls.id);
const status = getPresetStatus(preset);
const badge = STATUS_BADGE[status];
return (
<label key={ls.id} className={`flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors ${selected.has(ls.id) ? 'bg-pink-500/10 border border-pink-500/20' : 'bg-white/5 border border-transparent hover:bg-white/10'}`}>
<input type="checkbox" checked={selected.has(ls.id)} onChange={() => toggleItem(ls.id)} className="accent-pink-500" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-white truncate">{disguiseArtist(ls.artist_name || '')}</span>
<span className="text-[10px] text-zinc-600"></span>
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">{disguiseAlbum(ls.album || '') || 'Top Songs'}</span>
<span className={`text-[9px] font-bold px-1.5 py-0.5 rounded flex-shrink-0 ${badge.color}`}>{badge.icon} {badge.label}</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
{preset?.adapter_path && <span className="text-[9px] text-zinc-600 truncate max-w-[120px]" title={preset.adapter_path}>🔌 {preset.adapter_path.split(/[\\/]/).pop()}</span>}
{preset?.reference_track_path && <span className="text-[9px] text-zinc-600 truncate max-w-[120px]" title={preset.reference_track_path}>🎵 {preset.reference_track_path.split(/[\\/]/).pop()}</span>}
</div>
</div>
</label>
);
})}</>);
})()
) : (
/* Fetch Lyrics queue list */
fetchQueue.length === 0 ? (
<p className="text-zinc-500 text-sm text-center py-4">{fetchInputMode === 'paste' ? 'Paste artist/album pairs above, then click Fetch All' : 'Add artist/album rows above, then click Fetch All'}</p>
) : (<>{fetchQueue.map(item => (
<div key={item.id} className="flex items-center gap-2 px-3 py-2 rounded-lg bg-white/5 border border-transparent">
{item.status === 'pending' && <div className="w-2 h-2 rounded-full bg-zinc-500 flex-shrink-0" />}
{item.status === 'running' && <Loader2 className="w-3.5 h-3.5 animate-spin text-cyan-400 flex-shrink-0" />}
{item.status === 'done' && <CheckCircle className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />}
{item.status === 'error' && <AlertCircle className="w-3.5 h-3.5 text-red-400 flex-shrink-0" />}
{item.status === 'skipped' && <span className="text-[10px] text-amber-400 flex-shrink-0">SKIP</span>}
<div className="flex-1 min-w-0">
<span className="text-sm text-white truncate block">{item.artist}</span>
<span className="text-[10px] text-zinc-500">
{item.album || 'Top songs'}
{item.songsFetched != null && <span className="text-green-400"> · {item.songsFetched} songs</span>}
{item.error && <span className="text-red-400"> · {item.error}</span>}
</span>
</div>
</div>
))}</>)
)}
</div>
{/* Generation count + filter */}
{mode === 'generate' && (
<div className="px-6 py-2 space-y-2">
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" checked={genFillMode} onChange={e => setGenFillMode(e.target.checked)} className="accent-green-500" />
<span className="text-xs text-zinc-600 dark:text-zinc-400">{t('lyric.fillToTarget')}</span>
</label>
{genFillMode ? (
<>
<input type="number" min={1} max={100} value={genFillTarget}
onChange={e => setGenFillTarget(Math.max(1, Math.min(100, parseInt(e.target.value) || 1)))}
className="w-16 px-2 py-1 rounded-lg bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-sm text-white text-center focus:outline-none focus:border-green-500/50" />
<span className="text-[10px] text-zinc-500">{t('lyric.fillToTargetDesc')}</span>
</>
) : (
<>
<span className="text-xs text-zinc-600 dark:text-zinc-400">{t('lyric.perProfile')}</span>
<input type="number" min={1} max={20} value={genCount}
onChange={e => setGenCount(Math.max(1, Math.min(20, parseInt(e.target.value) || 1)))}
className="w-16 px-2 py-1 rounded-lg bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-sm text-white text-center focus:outline-none focus:border-green-500/50" />
</>
)}
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer select-none" title={t('lyric.generateNoThinkHint')}>
<input type="checkbox" checked={genNoThink} onChange={e => setGenNoThink(e.target.checked)} className="accent-sky-500" />
<span className="text-xs text-zinc-600 dark:text-zinc-400 flex items-center gap-1">
<Zap className="w-3 h-3 text-sky-400" /> {t('lyric.noThinking')}
</span>
</label>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-zinc-600 dark:text-zinc-400">{t('lyric.hideProfilesWith')}</span>
<input type="number" min={0} value={genFilterThreshold}
onChange={e => setGenFilterThreshold(e.target.value)}
placeholder="—"
className="w-16 px-2 py-1 rounded-lg bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-sm text-white text-center focus:outline-none focus:border-green-500/50 placeholder-zinc-400 dark:placeholder-zinc-600" />
<span className="text-xs text-zinc-600 dark:text-zinc-400">{t('lyric.existingGens')}</span>
{genFilterThreshold.trim() !== '' && (
<button onClick={() => setGenFilterThreshold('')}
className="text-[10px] text-zinc-500 hover:text-zinc-700 dark:text-zinc-300 transition-colors">{t('lyric.clear')}</button>
)}
{genCountsLoading && <Loader2 className="w-3 h-3 animate-spin text-zinc-500" />}
</div>
</div>
)}
{/* Action bar */}
<div className="px-6 py-3 border-t border-zinc-200 dark:border-white/5 flex items-center justify-between">
{mode === 'fetch-lyrics' ? (
<>
<div className="flex items-center gap-2">
{fetchQueue.length > 0 && !fetchRunning && (
<button onClick={clearFetchQueue} className="px-3 py-1.5 rounded-lg text-xs text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:text-zinc-200 hover:bg-white/5 transition-colors">Clear Results</button>
)}
{fetchQueue.length > 0 && (
<span className="text-[10px] text-zinc-500">
{fetchQueueDone > 0 && <span className="text-green-400">{fetchQueueDone} done</span>}
{fetchQueueErrors > 0 && <span className="text-red-400"> · {fetchQueueErrors} failed</span>}
{fetchQueueSkipped > 0 && <span className="text-amber-400"> · {fetchQueueSkipped} skipped</span>}
{fetchQueuePending > 0 && <span> · {fetchQueuePending} pending</span>}
</span>
)}
</div>
<div className="flex items-center gap-2">
{fetchRunning ? (
<button onClick={stopFetchQueue} className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold bg-red-600 hover:bg-red-500 text-white transition-all">
<Square className="w-3.5 h-3.5" /> Stop
</button>
) : (
<button onClick={startFetchQueue}
disabled={fetchInputMode === 'paste' ? parsePasteText(pasteText).length === 0 : structuredRows.filter(r => r.artist.trim()).length === 0}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-all disabled:opacity-30 bg-gradient-to-r from-cyan-600 to-blue-600 hover:from-cyan-500 hover:to-blue-500 text-white shadow-lg shadow-cyan-500/10">
<Play className="w-3.5 h-3.5" /> Fetch All
</button>
)}
</div>
</>
) : (
<>
<div className="flex items-center gap-2">
<button onClick={selectAll} className="px-3 py-1.5 rounded-lg text-xs text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:text-zinc-200 hover:bg-white/5 transition-colors">{t('lyric.selectAll')}</button>
{mode === 'presets' && (
<>
<button onClick={selectMissing} className="px-3 py-1.5 rounded-lg text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10 transition-colors">{t('lyric.selectMissing')}</button>
<button onClick={selectIncomplete} className="px-3 py-1.5 rounded-lg text-xs text-amber-400 hover:text-amber-300 hover:bg-amber-500/10 transition-colors">{t('lyric.selectIncomplete')}</button>
</>
)}
<button onClick={() => setSelected(new Set())} className="px-3 py-1.5 rounded-lg text-xs text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:text-zinc-200 hover:bg-white/5 transition-colors">Clear</button>
</div>
{mode === 'presets' ? (
<div className="flex items-center gap-2">
<button onClick={loadPresets} disabled={presetsLoading}
className="p-2 rounded-lg text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:text-zinc-200 hover:bg-white/5 transition-colors disabled:opacity-50" title="Refresh preset data">
<RefreshCw className={`w-3.5 h-3.5 ${presetsLoading ? 'animate-spin' : ''}`} />
</button>
<button onClick={handleApplyPresets}
disabled={selected.size === 0 || applying || (!adapterPath.trim() && !matcheringPath.trim())}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-all disabled:opacity-30 bg-gradient-to-r from-pink-600 to-purple-600 hover:from-pink-500 hover:to-purple-500 text-white shadow-lg shadow-pink-500/10">
{applying ? <Loader2 className="w-4 h-4 animate-spin" /> : <Settings2 className="w-4 h-4" />}
Apply to {selected.size} Album{selected.size !== 1 ? 's' : ''}
</button>
</div>
) : (
<button onClick={handleQueue} disabled={selected.size === 0}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-all disabled:opacity-30 ${mode === 'profile' ? 'bg-amber-500 text-black hover:bg-amber-400' : 'bg-green-500 text-black hover:bg-green-400'}`}>
<ListOrdered className="w-4 h-4" />
Queue {selected.size} {mode === 'profile' ? 'Profile Build' : 'Generation Run'}{selected.size !== 1 ? 's' : ''}
</button>
)}
</>
)}
</div>
{/* Queue status */}
{queueItems.length > 0 && (
<div className="px-6 py-3 border-t border-zinc-200 dark:border-white/5">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-400 uppercase tracking-wider">{t('lyric.queueProgress')}</span>
<button onClick={clearQueue} className="text-[10px] text-zinc-500 hover:text-red-400 transition-colors">{t('lyric.clearFinished')}</button>
</div>
<div className="space-y-1 max-h-32 overflow-y-auto scrollbar-hide">
{queueItems.map(item => (
<div key={item.id} className="flex items-center gap-2 px-2 py-1 rounded-lg bg-white/5 text-xs">
{item.status === 'pending' && <div className="w-2 h-2 rounded-full bg-zinc-500" />}
{item.status === 'running' && <Loader2 className="w-3 h-3 animate-spin text-pink-400" />}
{item.status === 'done' && <CheckCircle className="w-3 h-3 text-green-400" />}
{item.status === 'error' && <AlertCircle className="w-3 h-3 text-red-400" />}
<span className="text-zinc-700 dark:text-zinc-300 flex-1 truncate">{item.label}</span>
{item.count && item.count > 1 && <span className="text-[10px] text-zinc-500">{item.countCompleted || 0}/{item.count}</span>}
{item.status === 'pending' && (
<button onClick={() => removeFromQueue(item.id)} className="p-0.5 rounded hover:bg-red-500/20 text-zinc-500 hover:text-red-400 transition-colors">
<X className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
{(pendingCount > 0 || runningItem) && (
<div className="mt-2 text-[10px] text-zinc-500">
{runningItem ? `Running: ${runningItem.label}` : ''}
{pendingCount > 0 ? ` · ${pendingCount} pending` : ''}
{doneCount > 0 ? ` · ${doneCount} done` : ''}
</div>
)}
</div>
)}
</div>
</div>
{/* File Browser sub-modal */}
<FileBrowserModal
open={browserOpen}
onClose={() => setBrowserOpen(false)}
onSelect={(path) => {
if (browserTarget === 'adapter') setAdapterPath(path);
else setMatcheringPath(path);
setBrowserOpen(false);
}}
mode="file"
filter={browserTarget === 'matchering' ? 'audio' : 'adapters'}
title={browserTarget === 'matchering' ? 'Select Reference Audio' : 'Select Adapter File'}
/>
</>
);
};
@@ -0,0 +1,336 @@
/**
* RecordingsTab.tsx — Shows generated audio recordings grouped by lyric generation.
*
* Data flow:
* 1. For each Generation, fetch audio_generations from Lireek DB
* 2. For each audio gen, check job status via generateApi
* 3. Render playable songs grouped by generation
*
* Adapted for cpp engine: uses generateApi.status() per-job (no bulk history endpoint).
*/
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Play, Trash2, Headphones, ChevronDown, ChevronRight, Loader2, Clock, X, Filter, Download, ListPlus, Check } from 'lucide-react';
import { lireekApi } from '../../services/lireekApi';
import type { Generation, AudioGeneration } from '../../services/lireekApi';
import { generateApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import type { Song } from '../../types';
import { downloadTrack } from '../../utils/downloadTrack';
import { usePlaylist } from './playlistStore';
import { playFromList, songToTrack } from '../../stores/playbackStore';
interface SongGroup {
generation: Generation;
audioGens: AudioGeneration[];
songs: Song[];
}
interface RecordingsTabProps {
generations: Generation[];
showToast: (msg: string) => void;
filterGenerationId?: number | null;
onClearFilter?: () => void;
onSongCountChange?: (count: number) => void;
refreshKey?: number;
artistName?: string;
onDeleteComplete?: () => void;
}
export const RecordingsTab: React.FC<RecordingsTabProps> = ({
generations, showToast, filterGenerationId, onClearFilter, onSongCountChange, refreshKey = 0, artistName, onDeleteComplete,
}) => {
const { token } = useAuth();
const { t } = useTranslation();
const [groups, setGroups] = useState<SongGroup[]>([]);
const [loading, setLoading] = useState(true);
const [expandedGenId, setExpandedGenId] = useState<number | null>(null);
const [localRefreshKey, setLocalRefreshKey] = useState(0);
const generationsRef = useRef(generations);
generationsRef.current = generations;
const genKey = useMemo(() => {
const ids = generations.map(g => g.id).sort().join(',');
return `${ids}|${filterGenerationId ?? 'all'}|${refreshKey}|${localRefreshKey}`;
}, [generations, filterGenerationId, refreshKey, localRefreshKey]);
const filteredGenerations = useMemo(() =>
filterGenerationId
? generations.filter(g => g.id === filterGenerationId)
: generations,
[generations, filterGenerationId]
);
useEffect(() => {
if (!token || genKey === '|all') {
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
const load = async () => {
try {
const gens = filterGenerationId
? generationsRef.current.filter(g => g.id === filterGenerationId)
: generationsRef.current;
const results: SongGroup[] = [];
for (const gen of gens) {
try {
const res = await lireekApi.getAudioGenerations(gen.id);
if (res.audio_generations.length > 0) {
const songs: Song[] = [];
for (const ag of res.audio_generations) {
// Use pre-resolved audio URL from Lireek DB first
if (ag.audio_url) {
songs.push({
id: ag.hotstep_job_id || `ag-${ag.id}`,
title: gen.title || 'Untitled',
style: gen.caption || '',
caption: gen.caption || '',
lyrics: gen.lyrics || '',
coverUrl: ag.cover_url || '',
duration: gen.duration || 0,
tags: [],
audioUrl: ag.audio_url,
masteredAudioUrl: ag.mastered_audio_url || '',
created_at: ag.created_at,
});
} else {
// Fallback: check job status
try {
const status = await generateApi.status(ag.hotstep_job_id);
if (status?.status === 'succeeded' && status.result?.audioUrls) {
for (const audioUrl of status.result.audioUrls) {
songs.push({
id: ag.hotstep_job_id,
title: gen.title || 'Untitled',
style: gen.caption || '',
caption: gen.caption || '',
lyrics: gen.lyrics || '',
coverUrl: '',
duration: status.result.duration || 0,
tags: [],
audioUrl,
masteredAudioUrl: status.result.masteredAudioUrl || '',
created_at: ag.created_at,
});
}
// Resolve in Lireek DB for next time
const firstUrl = status.result.audioUrls[0];
if (firstUrl) {
lireekApi.resolveAudioGeneration(ag.hotstep_job_id, firstUrl).catch(() => {});
}
}
} catch {
console.warn(`[RecordingsTab] Could not resolve job ${ag.hotstep_job_id}`);
}
}
}
if (songs.length > 0) {
results.push({ generation: gen, audioGens: res.audio_generations, songs });
}
}
} catch (err) {
console.error(`[RecordingsTab] Failed to get audio gens for gen ${gen.id}:`, err);
}
}
if (!cancelled) {
setGroups(results);
const totalSongs = results.reduce((n, g) => n + g.songs.length, 0);
onSongCountChange?.(totalSongs);
}
} catch (err) {
console.error('[RecordingsTab] Failed to load:', err);
} finally {
setLoading(false);
}
};
load();
return () => { cancelled = true; };
}, [genKey, token]);
useEffect(() => {
if (filterGenerationId && groups.length === 1) {
setExpandedGenId(groups[0].generation.id);
}
}, [filterGenerationId, groups.length]);
const handleDeleteAudioGen = useCallback(async (ag: AudioGeneration) => {
if (!confirm('Delete this audio generation?')) return;
try {
await lireekApi.deleteAudioGeneration(ag.id);
showToast('Audio generation deleted');
setLocalRefreshKey(k => k + 1);
onDeleteComplete?.();
} catch (err: any) {
showToast(`Failed to delete: ${err.message}`);
}
}, [showToast, onDeleteComplete]);
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-zinc-500 animate-spin" />
</div>
);
}
return (
<>
<div className="p-4 space-y-2">
{/* Filter indicator */}
{filterGenerationId && onClearFilter && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-pink-500/10 border border-pink-500/20 mb-2">
<Filter className="w-3.5 h-3.5 text-pink-400" />
<span className="text-xs text-pink-300 flex-1">
Showing songs from: <strong>{filteredGenerations[0]?.title || 'Untitled'}</strong>
</span>
<button onClick={onClearFilter}
className="flex items-center gap-1 px-2 py-0.5 rounded text-xs text-zinc-600 dark:text-zinc-400 hover:text-white hover:bg-white/10 transition-colors">
<X className="w-3 h-3" /> Clear
</button>
</div>
)}
{groups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center px-8">
<div className="w-14 h-14 rounded-full bg-white/5 flex items-center justify-center mb-4">
<Headphones className="w-7 h-7 text-zinc-600" />
</div>
<h3 className="text-base font-semibold text-zinc-600 dark:text-zinc-400 mb-2">
{filterGenerationId ? t('lyric.noGeneratedSongsYet') : t('lyric.noGeneratedSongsYet')}
</h3>
<p className="text-sm text-zinc-500 max-w-xs">
{t('lyric.goToGeneratedLyrics')}
</p>
</div>
) : (
groups.map((group, idx) => {
const isExpanded = expandedGenId === group.generation.id;
return (
<div key={group.generation.id}
className={`rounded-xl border border-zinc-200 dark:border-white/5 hover:border-zinc-300 dark:border-white/10 overflow-hidden transition-colors ls2-card-in ls2-stagger-${Math.min(idx + 1, 11)}`}>
<button className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-white/[0.02] transition-colors"
onClick={() => setExpandedGenId(isExpanded ? null : group.generation.id)}>
{isExpanded
? <ChevronDown className="w-4 h-4 text-zinc-500 flex-shrink-0" />
: <ChevronRight className="w-4 h-4 text-zinc-500 flex-shrink-0" />}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-zinc-800 dark:text-zinc-200 truncate">{group.generation.title || 'Untitled'}</p>
<p className="text-xs text-zinc-500 mt-0.5">
{group.generation.subject || group.generation.caption?.slice(0, 60) || 'No caption'}
</p>
</div>
<span className="text-xs text-zinc-500 flex items-center gap-1">
<Headphones className="w-3 h-3" />
{group.songs.length} song{group.songs.length !== 1 ? 's' : ''}
</span>
</button>
{isExpanded && (
<div className="border-t border-zinc-200 dark:border-white/5">
{group.songs.length === 0 ? (
<p className="px-4 py-6 text-sm text-zinc-500 text-center">
{t('lyric.audioPendingOrFailed')}
</p>
) : (
<div className="divide-y divide-white/5">
{group.songs.map((song, idx) => {
const ag = group.audioGens[idx];
return (
<div key={idx} className="flex items-center gap-3 px-4 py-2.5 hover:bg-white/[0.02] transition-colors">
<button onClick={() => {
playFromList(songToTrack(song), group.songs.map(songToTrack), 'lireek-recordings');
}}
className="w-8 h-8 rounded-full bg-pink-600/20 hover:bg-pink-600/30 flex items-center justify-center flex-shrink-0 transition-colors">
<Play className="w-3.5 h-3.5 text-pink-400 ml-0.5" />
</button>
<AddToPlaylistButton song={song} artistName={artistName} />
<div className="flex-1 min-w-0">
<p className="text-sm text-zinc-700 dark:text-zinc-300 truncate">{song.title || `Song ${idx + 1}`}</p>
{song.duration && (
<p className="text-[11px] text-zinc-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{Math.floor(Number(song.duration) / 60)}:{String(Math.floor(Number(song.duration) % 60)).padStart(2, '0')}
</p>
)}
</div>
<button onClick={() => downloadTrack(song)}
className="p-1.5 rounded-lg text-zinc-500 hover:text-blue-400 hover:bg-blue-500/10 transition-colors"
title="Download">
<Download className="w-3.5 h-3.5" />
</button>
{ag && (
<button onClick={() => handleDeleteAudioGen(ag)}
className="p-1.5 rounded-lg text-zinc-500 hover:text-red-400 hover:bg-red-500/10 transition-colors"
title="Delete">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
})
)}
</div>
</>
);
};
// ── Add-to-playlist helper ───────────────────────────────────────────────────
const AddToPlaylistButton: React.FC<{ song: Song; artistName?: string }> = ({ song, artistName }) => {
const playlist = usePlaylist();
const inPlaylist = playlist.isIn(song.id);
const toggle = (e: React.MouseEvent) => {
e.stopPropagation();
if (inPlaylist) {
playlist.remove(song.id);
} else {
const dur = song.duration;
let seconds = 0;
if (typeof dur === 'string' && dur.includes(':')) {
const [m, s] = dur.split(':').map(Number);
seconds = (m || 0) * 60 + (s || 0);
} else if (typeof dur === 'number') {
seconds = dur;
}
playlist.add({
id: song.id,
title: song.title || 'Untitled',
audioUrl: song.audioUrl || '',
masteredAudioUrl: song.masteredAudioUrl || '',
artistName: artistName || '',
coverUrl: song.coverUrl || '',
duration: seconds,
style: song.style || '',
generationParams: song.generationParams,
});
}
};
return (
<button onClick={toggle}
className={`p-1 rounded-md transition-colors flex-shrink-0 ${
inPlaylist ? 'text-pink-400 bg-pink-500/10 hover:bg-pink-500/20'
: 'text-zinc-600 hover:text-pink-400 hover:bg-pink-500/10'
}`}
title={inPlaylist ? 'Remove from playlist' : 'Add to playlist'}>
{inPlaylist ? <Check className="w-3.5 h-3.5" /> : <ListPlus className="w-3.5 h-3.5" />}
</button>
);
};
@@ -0,0 +1,211 @@
import React, { useState } from 'react';
import { ChevronDown, ChevronRight, Pencil, Trash2, Save, X, FileText, Plus } from 'lucide-react';
import type { LyricsSet, SongLyric } from '../../services/lireekApi';
function parseSongs(songs: SongLyric[] | string): SongLyric[] {
if (typeof songs === 'string') {
try { return JSON.parse(songs); } catch { return []; }
}
return songs || [];
}
interface SourceLyricsTabProps {
album: LyricsSet;
onDeleteSong: (index: number) => void;
onEditSong?: (index: number, lyrics: string) => void;
onAddSong?: () => void;
}
export const SourceLyricsTab: React.FC<SourceLyricsTabProps> = ({ album, onDeleteSong, onEditSong, onAddSong }) => {
const songs = parseSongs(album.songs);
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
const [editingIdx, setEditingIdx] = useState<number | null>(null);
const [editText, setEditText] = useState('');
if (songs.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center px-8">
<div className="w-14 h-14 rounded-full bg-white/5 flex items-center justify-center mb-4">
<FileText className="w-7 h-7 text-zinc-600" />
</div>
<h3 className="text-base font-semibold text-zinc-600 dark:text-zinc-400 mb-2">No source lyrics</h3>
<p className="text-sm text-zinc-500 max-w-xs mb-4">
Add songs manually or fetch lyrics from Genius.
</p>
{onAddSong && (
<button
onClick={onAddSong}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-amber-600 hover:bg-amber-500 text-white text-sm font-semibold transition-all"
>
<Plus className="w-4 h-4" />
Add Song Manually
</button>
)}
</div>
);
}
return (
<div className="p-4 space-y-1">
{/* Add Song button */}
{onAddSong && (
<div className="flex justify-end mb-2">
<button
onClick={onAddSong}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs text-amber-400 hover:bg-amber-500/10 transition-colors font-medium"
>
<Plus className="w-3 h-3" />
Add Song
</button>
</div>
)}
{songs.map((song, idx) => {
const isExpanded = expandedIdx === idx;
const isEditing = editingIdx === idx;
const lyrics = song.lyrics || '';
return (
<div
key={idx}
className={`rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden transition-colors hover:border-zinc-300 dark:border-white/10 ls2-card-in ls2-stagger-${Math.min(idx + 1, 11)}`}
>
{/* Song header */}
<button
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-white/[0.02] transition-colors"
onClick={() => { setExpandedIdx(isExpanded ? null : idx); setEditingIdx(null); }}
>
{isExpanded
? <ChevronDown className="w-4 h-4 text-zinc-500 flex-shrink-0" />
: <ChevronRight className="w-4 h-4 text-zinc-500 flex-shrink-0" />
}
<span className="flex-1 text-sm font-medium text-zinc-800 dark:text-zinc-200 truncate">
{song.title}
</span>
{song.genre ? (
<span className="text-[11px] text-amber-500/80 truncate max-w-[180px]" title={song.genre}>
{song.genre.split(',')[0].trim()}
</span>
) : null}
{song.bpm ? (
<span className="text-[11px] text-zinc-500 font-mono">{song.bpm} BPM</span>
) : null}
{song.key ? (
<span className="text-[11px] text-zinc-500 font-mono">{song.key}</span>
) : null}
<span className="text-xs text-zinc-500">
{lyrics.split('\n').length} lines
</span>
</button>
{/* Expanded content */}
{isExpanded && (
<div className="border-t border-zinc-200 dark:border-white/5">
{/* Enriched metadata from a Training Studio export */}
{(song.caption || song.genre || song.bpm || song.key || song.signature) && (
<div className="px-4 pt-3 space-y-3">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{song.genre && (
<div className="px-3 py-2 rounded-lg bg-white/5 border border-zinc-200 dark:border-white/5 col-span-2 sm:col-span-4">
<label className="text-[10px] text-zinc-500 uppercase tracking-wider block mb-1">Genre</label>
<p className="text-sm text-amber-300">{song.genre}</p>
</div>
)}
{song.bpm ? (
<div className="px-3 py-2 rounded-lg bg-white/5 border border-zinc-200 dark:border-white/5">
<label className="text-[10px] text-zinc-500 uppercase tracking-wider block mb-1">BPM</label>
<p className="text-sm text-pink-300">{song.bpm}</p>
</div>
) : null}
{song.key && (
<div className="px-3 py-2 rounded-lg bg-white/5 border border-zinc-200 dark:border-white/5">
<label className="text-[10px] text-zinc-500 uppercase tracking-wider block mb-1">Key</label>
<p className="text-sm text-blue-300">{song.key}</p>
</div>
)}
{song.signature && (
<div className="px-3 py-2 rounded-lg bg-white/5 border border-zinc-200 dark:border-white/5">
<label className="text-[10px] text-zinc-500 uppercase tracking-wider block mb-1">Time Sig</label>
<p className="text-sm text-purple-300">{song.signature}</p>
</div>
)}
{song.language && (
<div className="px-3 py-2 rounded-lg bg-white/5 border border-zinc-200 dark:border-white/5">
<label className="text-[10px] text-zinc-500 uppercase tracking-wider block mb-1">Language</label>
<p className="text-sm text-emerald-300">{song.language}</p>
</div>
)}
</div>
{song.caption && (
<div className="px-3 py-2 rounded-lg bg-white/5 border border-zinc-200 dark:border-white/5">
<label className="text-[10px] text-zinc-500 uppercase tracking-wider block mb-1">Caption</label>
<p className="text-sm text-zinc-700 dark:text-zinc-300 leading-relaxed">{song.caption}</p>
</div>
)}
</div>
)}
<div className="px-4 py-3">
{isEditing ? (
<textarea
value={editText}
onChange={e => setEditText(e.target.value)}
className="w-full h-80 text-sm text-zinc-800 dark:text-zinc-200 bg-black/30 border border-zinc-300 dark:border-white/10 rounded-lg p-3 font-sans leading-relaxed resize-y focus:outline-none focus:border-indigo-500/50"
/>
) : (
<pre className="text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-wrap font-sans leading-relaxed max-h-96 overflow-y-auto">
{lyrics || '(No lyrics available)'}
</pre>
)}
</div>
<div className="flex items-center gap-2 px-4 py-2 border-t border-zinc-200 dark:border-white/5 bg-white/[0.01]">
{isEditing ? (
<>
<button
onClick={() => {
if (onEditSong) onEditSong(idx, editText);
setEditingIdx(null);
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs text-emerald-400 hover:bg-emerald-500/10 transition-colors"
>
<Save className="w-3 h-3" />
Save
</button>
<button
onClick={() => setEditingIdx(null)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs text-zinc-600 dark:text-zinc-400 hover:bg-white/5 transition-colors"
>
<X className="w-3 h-3" />
Cancel
</button>
</>
) : (
<>
{onEditSong && (
<button
onClick={() => { setEditingIdx(idx); setEditText(lyrics); }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs text-indigo-400 hover:bg-indigo-500/10 transition-colors"
>
<Pencil className="w-3 h-3" />
Edit
</button>
)}
<button
onClick={() => {
if (confirm(`Delete "${song.title}" from this album?`)) {
onDeleteSong(idx);
}
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 className="w-3 h-3" />
Delete
</button>
</>
)}
</div>
</div>
)}
</div>
);
})}
</div>
);
};
@@ -0,0 +1,123 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, ChevronDown, ChevronUp, Terminal, SkipForward } from 'lucide-react';
interface StreamingPanelProps {
visible: boolean;
streamText: string;
phase: string;
done: boolean;
onSkipThinking?: () => void;
}
export const StreamingPanel: React.FC<StreamingPanelProps> = ({
visible, streamText, phase, done, onSkipThinking,
}) => {
const [collapsed, setCollapsed] = useState(false);
const { t } = useTranslation();
const [skipRequested, setSkipRequested] = useState(false);
const preRef = useRef<HTMLPreElement>(null);
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Only render the last chunk of text to avoid DOM/layout explosion
const MAX_DISPLAY = 50_000; // ~50KB visible in the panel
const displayText = useMemo(() => {
if (streamText.length <= MAX_DISPLAY) return streamText;
return '…(earlier output trimmed)…\n' + streamText.slice(-MAX_DISPLAY);
}, [streamText]);
// Throttled auto-scroll — fires at most every 100ms to avoid layout thrashing,
// but guarantees scrolling during active streaming (unlike a debounce which
// gets perpetually reset by fast rAF updates).
useEffect(() => {
if (collapsed) return;
if (scrollTimerRef.current) return; // already scheduled — skip
scrollTimerRef.current = setTimeout(() => {
scrollTimerRef.current = null;
if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight;
}, 100);
}, [displayText, collapsed]);
// Reset skip state when a new stream starts
useEffect(() => {
if (!done && streamText === '') {
setSkipRequested(false);
}
}, [done, streamText]);
if (!visible) return null;
// Detect if model is currently inside a thinking block.
// Only scan the tail of the text — we just need the last open/close tag.
const isThinking = useMemo(() => {
if (done || skipRequested) return false;
// Check last 500 chars for unclosed thinking tags
const tail = streamText.slice(-500);
const lastThinkOpen = tail.lastIndexOf('<think>');
const lastThinkClose = tail.lastIndexOf('</think>');
const lastChannelOpen = tail.lastIndexOf('<|channel>thought');
const lastChannelClose = tail.lastIndexOf('<channel|>');
return (lastThinkOpen > lastThinkClose) || (lastChannelOpen > lastChannelClose);
}, [streamText, done, skipRequested]);
const handleSkip = () => {
setSkipRequested(true);
onSkipThinking?.();
};
return (
<div className="rounded-xl overflow-hidden transition-all bg-zinc-100/60 dark:bg-zinc-900/60 border border-zinc-200 dark:border-white/5">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5">
<button
onClick={() => setCollapsed(!collapsed)}
className="flex items-center gap-2 text-xs font-medium text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:text-zinc-200 transition-colors"
>
<Terminal className="w-3 h-3 text-pink-400" />
{t('lyric.llmOutput')}
{phase && (
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-pink-500 text-white">
{phase}
</span>
)}
{!done && (
<Loader2 className="w-3 h-3 animate-spin text-pink-400" />
)}
{collapsed ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />}
</button>
{/* Skip Thinking button */}
{isThinking && onSkipThinking && (
<button
onClick={handleSkip}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-pink-500 text-white hover:bg-pink-600 transition-colors"
title="Stop the model's chain-of-thought and produce output immediately"
>
<SkipForward className="w-3 h-3" />
{t('lyric.skipThinking')}
</button>
)}
{skipRequested && !done && (
<span className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium text-pink-400">
<Loader2 className="w-3 h-3 animate-spin" />
{t('lyric.skipping')}
</span>
)}
</div>
{/* Content */}
{!collapsed && (
<pre
ref={preRef}
className="px-4 pb-3 text-xs leading-relaxed overflow-y-auto whitespace-pre-wrap break-words text-zinc-600 dark:text-zinc-400 scrollbar-hide"
style={{
maxHeight: '300px',
fontFamily: 'ui-monospace, "Cascadia Code", "Fira Code", Menlo, monospace',
}}
>
{displayText || (done ? t('lyric.noOutput') : t('lyric.waitingForLlm'))}
</pre>
)}
</div>
);
};
@@ -0,0 +1,416 @@
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Trash2, Pencil, Music2, Wand2, Play, Loader2, ChevronDown, ChevronRight, Send, FileText, Headphones, Sparkles, Zap } from 'lucide-react';
import { lireekApi, streamRefine, skipThinking } from '../../services/lireekApi';
import type { Generation, Profile } from '../../services/lireekApi';
import { StreamingPanel } from './StreamingPanel';
import { useStreamingStore, startStreamGenerate } from '../../stores/streamingStore';
interface WrittenSongsTabProps {
generations: Generation[];
profiles: Profile[];
onRefresh: () => void;
onGenerateAudio: (gen: Generation) => void;
onSendToCreate?: (gen: Generation) => void;
onViewRecordings?: (genId: number) => void;
showToast: (msg: string) => void;
generationModel: { provider: string; model?: string };
refinementModel: { provider: string; model?: string };
}
export const WrittenSongsTab: React.FC<WrittenSongsTabProps> = ({
generations, profiles, onRefresh, onGenerateAudio, onSendToCreate, onViewRecordings, showToast,
generationModel, refinementModel,
}) => {
const [expandedId, setExpandedId] = useState<number | null>(null);
const { t } = useTranslation();
const [generating, setGenerating] = useState(false);
const [refiningId, setRefiningId] = useState<number | null>(null);
const [genCount, setGenCount] = useState(1);
const [userSubject, setUserSubject] = useState('');
// Persistent streaming state — survives tab navigation
const streaming = useStreamingStore();
// Local streaming state for refine (one-off, doesn't need persistence)
const [refineStreamVisible, setRefineStreamVisible] = useState(false);
const [refineStreamText, setRefineStreamText] = useState('');
const [refineStreamPhase, setRefineStreamPhase] = useState('');
const [refineStreamDone, setRefineStreamDone] = useState(false);
const handleQuickGenerate = useCallback(async (noThink = false) => {
if (profiles.length === 0) {
showToast('Build a profile first');
return;
}
setGenerating(true);
const profile = profiles[0];
try {
for (let i = 0; i < genCount; i++) {
await startStreamGenerate(
profile.id,
{
profile_id: profile.id,
provider: generationModel.provider,
model: generationModel.model,
user_subject: userSubject.trim() || undefined,
no_think: noThink || undefined,
},
() => onRefresh(),
);
}
showToast(`Generated ${genCount} new song${genCount > 1 ? 's' : ''}`);
} catch (err: any) {
showToast(`Failed: ${err.message}`);
} finally {
setGenerating(false);
}
}, [profiles, genCount, generationModel, onRefresh, showToast, userSubject]);
const handleRefine = async (gen: Generation) => {
const { provider, model } = refinementModel;
if (!provider) {
showToast('Select a refinement model first');
return;
}
setRefiningId(gen.id);
setRefineStreamVisible(true);
setRefineStreamText('');
setRefineStreamPhase('');
setRefineStreamDone(false);
try {
await streamRefine(
gen.id,
{ provider, model },
{
onChunk: (text) => setRefineStreamText(prev => {
const next = prev + text;
// Cap at 200KB to prevent OOM — matches streaming store limit
return next.length > 200_000 ? '\u2026(earlier output trimmed)\u2026\n' + next.slice(-200_000) : next;
}),
onPhase: (phase) => setRefineStreamPhase(phase),
onResult: () => {
showToast(`Refined: ${gen.title || 'Untitled'}`);
onRefresh();
},
onError: (err) => showToast(`Refinement failed: ${err}`),
},
);
setRefineStreamDone(true);
} catch (err: any) {
showToast(`Refinement failed: ${err.message}`);
setRefineStreamDone(true);
} finally {
setRefiningId(null);
}
};
const handleDelete = async (gen: Generation) => {
if (!confirm(`Delete "${gen.title || 'Untitled'}"?`)) return;
try {
await lireekApi.deleteGeneration(gen.id);
showToast('Deleted');
onRefresh();
} catch (err: any) {
showToast(`Failed: ${err.message}`);
}
};
const handleSaveField = async (genId: number, field: string, value: any) => {
try {
await lireekApi.updateMetadata(genId, { [field]: value });
onRefresh();
} catch (err: any) {
showToast(`Failed to save: ${err.message}`);
}
};
// Show streaming panel if either generation or refinement is active
const showGenerationStream = streaming.visible || generating;
return (
<div className="p-4 space-y-4">
{/* Generate controls */}
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={() => handleQuickGenerate(false)}
disabled={generating || profiles.length === 0}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 disabled:bg-zinc-200 dark:bg-zinc-700 disabled:text-zinc-500 text-white text-sm font-semibold transition-all"
>
{generating ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('lyric.generating')}
</>
) : (
<>
<Wand2 className="w-4 h-4" />
{t('lyric.generateLyrics')}
</>
)}
</button>
<button
onClick={() => handleQuickGenerate(true)}
disabled={generating || profiles.length === 0}
title={t('lyric.generateNoThinkHint')}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-sky-600 hover:bg-sky-500 disabled:bg-zinc-200 dark:bg-zinc-700 disabled:text-zinc-500 text-white text-sm font-semibold transition-all"
>
<Zap className="w-4 h-4" />
{t('lyric.generateNoThink')}
</button>
<div className="flex items-center gap-2">
<label className="text-xs text-zinc-500">{t('lyric.count')}</label>
<select
value={genCount}
onChange={(e) => setGenCount(parseInt(e.target.value))}
className="px-2 py-1.5 rounded-lg bg-white/5 border border-zinc-300 dark:border-white/10 text-sm text-white focus:outline-none focus:border-emerald-500/50"
>
{[1, 2, 3, 4, 5, 8, 10].map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
</div>
{profiles.length === 0 && (
<span className="text-xs text-amber-400/60">{t('lyric.buildProfileFirst')}</span>
)}
</div>
{/* Optional subject input */}
<div className="flex items-center gap-2">
<input
type="text"
value={userSubject}
onChange={(e) => setUserSubject(e.target.value)}
placeholder={t('lyric.subjectPlaceholder')}
className="flex-1 px-3 py-2 rounded-lg bg-white/5 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-amber-500/50 transition-colors"
/>
{userSubject && (
<button
onClick={() => setUserSubject('')}
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors px-1.5"
title={t('lyric.clearSubject')}
>
</button>
)}
</div>
{/* Generation streaming panel — persists across tab navigation */}
{showGenerationStream && (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 overflow-hidden">
<StreamingPanel
visible={true}
streamText={streaming.text}
phase={streaming.phase}
done={streaming.done}
onSkipThinking={() => skipThinking()}
/>
</div>
)}
{/* Refine streaming panel — local, only shown during active refinement */}
{refineStreamVisible && (
<div className="rounded-xl border border-purple-500/20 bg-purple-500/5 overflow-hidden">
<StreamingPanel
visible={refineStreamVisible}
streamText={refineStreamText}
phase={refineStreamPhase}
done={refineStreamDone}
onSkipThinking={() => skipThinking()}
/>
</div>
)}
{/* Generations list */}
{generations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-14 h-14 rounded-full bg-white/5 flex items-center justify-center mb-4">
<Music2 className="w-7 h-7 text-zinc-600" />
</div>
<h3 className="text-base font-semibold text-zinc-600 dark:text-zinc-400 mb-2">{t('lyric.noGeneratedLyricsYet')}</h3>
<p className="text-sm text-zinc-500 max-w-xs">
{t('lyric.generateFromProfile')}
</p>
</div>
) : (
<div className="space-y-1">
{generations.map((gen, idx) => {
const isExpanded = expandedId === gen.id;
return (
<div
key={gen.id}
className={`rounded-xl border border-zinc-200 dark:border-white/5 hover:border-zinc-300 dark:border-white/10 overflow-hidden transition-colors ls2-card-in ls2-stagger-${Math.min(idx + 1, 11)}`}
>
{/* Header */}
<button
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-white/[0.02] transition-colors"
onClick={() => setExpandedId(isExpanded ? null : gen.id)}
>
{isExpanded
? <ChevronDown className="w-4 h-4 text-zinc-500 flex-shrink-0" />
: <ChevronRight className="w-4 h-4 text-zinc-500 flex-shrink-0" />
}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-zinc-800 dark:text-zinc-200 truncate">
{gen.title || 'Untitled'}
</p>
<p className="text-xs text-zinc-500 mt-0.5">
{gen.subject || 'No subject'}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{gen.parent_generation_id && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-purple-500/20 text-purple-300 flex items-center gap-0.5">
<Sparkles className="w-2.5 h-2.5" /> {t('lyric.refined')}
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); onGenerateAudio(gen); }}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[11px] text-emerald-400 hover:bg-emerald-500/10 transition-colors"
title="Generate audio from these lyrics"
>
<Play className="w-3 h-3" />
{t('lyric.audio')}
</button>
{onViewRecordings && (
<button
onClick={(e) => { e.stopPropagation(); onViewRecordings(gen.id); }}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[11px] text-pink-400 hover:bg-pink-500/10 transition-colors"
title="View generated songs from these lyrics"
>
<Headphones className="w-3 h-3" />
{t('lyric.songs')}
</button>
)}
{gen.bpm ? (
<span className="text-[11px] text-zinc-500 font-mono">{gen.bpm} BPM</span>
) : null}
{gen.key ? (
<span className="text-[11px] text-zinc-500 font-mono">{gen.key}</span>
) : null}
</div>
</button>
{/* Expanded content */}
{isExpanded && (
<div className="border-t border-zinc-200 dark:border-white/5">
<div className="p-4 space-y-4">
{/* Editable title */}
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-green-400 flex-shrink-0" />
<input
className="flex-1 text-lg font-bold text-white bg-transparent border-b border-transparent hover:border-white/20 focus:border-pink-500/50 focus:outline-none transition-colors"
defaultValue={gen.title || 'Untitled'}
onBlur={(e) => { if (e.target.value !== gen.title) handleSaveField(gen.id, 'title', e.target.value); }}
/>
<Pencil className="w-3.5 h-3.5 text-zinc-600" />
</div>
{/* Metadata grid */}
<div className="grid grid-cols-2 gap-3">
<div className="px-3 py-2 rounded-lg bg-white/5 border border-zinc-200 dark:border-white/5">
<label className="text-[10px] text-zinc-500 uppercase tracking-wider block mb-1">Subject</label>
<input
className="w-full bg-transparent text-sm text-amber-300 focus:outline-none border-b border-transparent hover:border-white/20 focus:border-amber-500/50 transition-colors"
defaultValue={gen.subject || ''}
onBlur={(e) => { if (e.target.value !== (gen.subject || '')) handleSaveField(gen.id, 'subject', e.target.value); }}
/>
</div>
<div className="px-3 py-2 rounded-lg bg-white/5 border border-zinc-200 dark:border-white/5">
<label className="text-[10px] text-zinc-500 uppercase tracking-wider block mb-1">BPM</label>
<input
type="number"
className="w-full bg-transparent text-sm text-pink-300 focus:outline-none border-b border-transparent hover:border-white/20 focus:border-pink-500/50 transition-colors"
defaultValue={gen.bpm || 0}
onBlur={(e) => { const v = parseInt(e.target.value) || 0; if (v !== gen.bpm) handleSaveField(gen.id, 'bpm', v); }}
/>
</div>
<div className="px-3 py-2 rounded-lg bg-white/5 border border-zinc-200 dark:border-white/5">
<label className="text-[10px] text-zinc-500 uppercase tracking-wider block mb-1">Key</label>
<input
className="w-full bg-transparent text-sm text-blue-300 focus:outline-none border-b border-transparent hover:border-white/20 focus:border-blue-500/50 transition-colors"
defaultValue={gen.key || ''}
onBlur={(e) => { if (e.target.value !== (gen.key || '')) handleSaveField(gen.id, 'key', e.target.value); }}
/>
</div>
<div className="px-3 py-2 rounded-lg bg-white/5 border border-zinc-200 dark:border-white/5">
<label className="text-[10px] text-zinc-500 uppercase tracking-wider block mb-1">Duration (seconds)</label>
<input
type="number"
className="w-full bg-transparent text-sm text-purple-300 focus:outline-none border-b border-transparent hover:border-white/20 focus:border-purple-500/50 transition-colors"
defaultValue={gen.duration || 0}
onBlur={(e) => { const v = parseInt(e.target.value) || 0; if (v !== gen.duration) handleSaveField(gen.id, 'duration', v); }}
/>
</div>
</div>
{/* Editable caption */}
<div className="px-3 py-2 rounded-lg bg-white/5 border border-zinc-200 dark:border-white/5">
<label className="text-[10px] text-zinc-500 uppercase tracking-wider block mb-1">Caption</label>
<textarea
className="w-full bg-transparent text-sm text-zinc-700 dark:text-zinc-300 focus:outline-none border-b border-transparent hover:border-white/20 focus:border-pink-500/50 transition-colors resize-none"
rows={2}
defaultValue={gen.caption || ''}
onBlur={(e) => { if (e.target.value !== (gen.caption || '')) handleSaveField(gen.id, 'caption', e.target.value); }}
/>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => onGenerateAudio(gen)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-gradient-to-r from-pink-500/30 to-purple-500/30 text-white hover:from-pink-500/40 hover:to-purple-500/40 text-sm font-semibold transition-all border border-pink-500/20"
>
<Play className="w-3.5 h-3.5" />
{t('lyric.generateAudio')}
</button>
<button
onClick={() => handleRefine(gen)}
disabled={refiningId === gen.id}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-purple-500/20 text-purple-300 hover:bg-purple-500/30 text-sm font-medium transition-colors disabled:opacity-50"
title="Refine these lyrics using the refinement LLM"
>
{refiningId === gen.id ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
{t('lyric.refine')}
</button>
{onSendToCreate && (
<button
onClick={() => onSendToCreate(gen)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 text-sm font-medium transition-colors border border-amber-500/10"
>
<Send className="w-3.5 h-3.5" />
{t('lyric.sendToCreate')}
</button>
)}
<div className="flex-1" />
<button
onClick={() => handleDelete(gen)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 className="w-3 h-3" />
{t('common.delete')}
</button>
</div>
{/* Editable lyrics */}
<div>
<h3 className="text-sm font-semibold text-zinc-600 dark:text-zinc-400 uppercase tracking-wider mb-2">{t('lyric.lyrics')}</h3>
<textarea
className="w-full p-4 rounded-xl bg-black/20 dark:bg-black/40 border border-zinc-200 dark:border-white/5 text-sm text-zinc-800 dark:text-zinc-200 font-mono leading-relaxed focus:outline-none focus:border-pink-500/30 resize-y transition-colors"
style={{ minHeight: '300px' }}
defaultValue={gen.lyrics || ''}
onBlur={(e) => { if (e.target.value !== (gen.lyrics || '')) handleSaveField(gen.id, 'lyrics', e.target.value); }}
/>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
};
@@ -0,0 +1,133 @@
/**
* playlistStore.ts — localStorage-backed play queue for Lyric Studio.
*
* Stores a list of PlaylistItems under `lireek-playQueue`.
* Provides a React hook `usePlaylist()` with automatic reactivity via
* a custom event (`lireek-playlist-change`) + window storage events.
*/
import { useCallback, useSyncExternalStore } from 'react';
// ── Types ────────────────────────────────────────────────────────────────────
export interface PlaylistItem {
id: string;
title: string;
audioUrl: string;
masteredAudioUrl?: string;
artistName?: string;
coverUrl?: string;
duration?: number; // seconds
style?: string;
/** Preserved so M/O toggle works when playing from playlist */
generationParams?: any;
}
// ── Storage ──────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'lireek-playQueue';
const CHANGE_EVENT = 'lireek-playlist-change';
let _snapshot: PlaylistItem[] | null = null;
let _persistTimer: ReturnType<typeof setTimeout> | null = null;
function read(): PlaylistItem[] {
if (_snapshot) return _snapshot;
try {
const raw = localStorage.getItem(STORAGE_KEY);
_snapshot = raw ? JSON.parse(raw) : [];
} catch {
_snapshot = [];
}
return _snapshot!;
}
/** Debounced persistence — for high-frequency ops (drag reorder). */
function _persistPlaylist(): void {
if (_persistTimer) clearTimeout(_persistTimer);
_persistTimer = setTimeout(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(_snapshot || []));
} catch (e) {
console.error('[Playlist] localStorage write failed (quota?):', e);
}
}, 500);
}
/** Force-flush persistence immediately (for clear, reorder — infrequent ops). */
function _persistPlaylistNow(): void {
if (_persistTimer) { clearTimeout(_persistTimer); _persistTimer = null; }
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(_snapshot || []));
} catch (e) {
console.error('[Playlist] localStorage write failed (quota?):', e);
}
}
function write(items: PlaylistItem[], immediate = false): void {
_snapshot = items;
if (immediate) _persistPlaylistNow(); else _persistPlaylist();
window.dispatchEvent(new CustomEvent(CHANGE_EVENT));
}
// ── Public API ───────────────────────────────────────────────────────────────
export function getPlaylist(): PlaylistItem[] { return read(); }
export function addToPlaylist(item: PlaylistItem): void {
const list = read();
if (list.some(i => i.id === item.id)) return;
write([...list, item], true); // persist immediately — playlist changes must not be lost
}
export function removeFromPlaylist(id: string): void {
write(read().filter(i => i.id !== id));
}
export function clearPlaylist(): void { write([], true); }
export function isInPlaylist(id: string): boolean {
return read().some(i => i.id === id);
}
export function reorderPlaylist(items: PlaylistItem[]): void { write(items, true); }
export function moveItem(id: string, direction: 'up' | 'down'): void {
const list = [...read()];
const idx = list.findIndex(i => i.id === id);
if (idx < 0) return;
const target = direction === 'up' ? idx - 1 : idx + 1;
if (target < 0 || target >= list.length) return;
[list[idx], list[target]] = [list[target], list[idx]];
write(list);
}
// ── React Hook ───────────────────────────────────────────────────────────────
function subscribe(cb: () => void): () => void {
const onCustom = () => cb();
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY) { _snapshot = null; cb(); }
};
window.addEventListener(CHANGE_EVENT, onCustom);
window.addEventListener('storage', onStorage);
return () => {
window.removeEventListener(CHANGE_EVENT, onCustom);
window.removeEventListener('storage', onStorage);
};
}
function getSnapshot(): PlaylistItem[] { return read(); }
export function usePlaylist() {
const items = useSyncExternalStore(subscribe, getSnapshot);
const add = useCallback((item: PlaylistItem) => addToPlaylist(item), []);
const remove = useCallback((id: string) => removeFromPlaylist(id), []);
const clear = useCallback(() => clearPlaylist(), []);
const isIn = useCallback((id: string) => items.some(i => i.id === id), [items]);
const move = useCallback((id: string, dir: 'up' | 'down') => moveItem(id, dir), []);
const reorder = useCallback((newItems: PlaylistItem[]) => reorderPlaylist(newItems), []);
return { items, add, remove, clear, isIn, move, reorder };
}
@@ -0,0 +1,101 @@
/**
* useAudioGeneration.ts — Send-to-Create flow for Lyric Studio V2.
*
* Handles: preset loading → localStorage writes → page navigation.
*
* NOTE: The generateAudio function and mergeCreatePanelSettings helper
* were removed — all audio generation now flows through
* audioGenQueueStore.enqueueAudioGen() which takes a getGlobalParams()
* snapshot, ensuring 100% parity with the Create page path.
*/
import { useCallback } from 'react';
import { lireekApi } from '../../services/lireekApi';
import { writePersistedState } from '../../hooks/usePersistedState';
import type { Generation, Profile, AlbumPreset } from '../../services/lireekApi';
import { resolveDuration } from '../../utils/estimateDuration';
import { useGlobalParamsStore } from '../../stores/globalParamsStore';
// ── Hook ─────────────────────────────────────────────────────────────────────
interface UseAudioGenerationOptions {
profiles: Profile[];
showToast: (msg: string) => void;
}
export function useAudioGeneration({ profiles, showToast: _showToast }: UseAudioGenerationOptions) {
const sendToCreate = useCallback(async (gen: Generation): Promise<void> => {
const profile = profiles.find(p => p.id === gen.profile_id);
let preset: AlbumPreset | null = null;
if (profile) {
try {
const res = await lireekApi.getPreset(profile.lyrics_set_id);
preset = res.preset;
} catch { /* ignore */ }
}
// Write to hs-* localStorage keys AND fire same-tab StorageEvent so
// usePersistedState hooks in the top bar update immediately.
const write = (key: string, value: any) => writePersistedState(key, value);
// Content
write('hs-caption', gen.caption || '');
write('hs-lyrics', gen.lyrics || '');
write('hs-instrumental', false);
// Song info (Title / Artist / Subject)
write('hs-title', gen.title || '');
write('hs-artist', gen.artist_name || '');
write('hs-subject', gen.subject || '');
// Metadata
if (gen.bpm) write('hs-bpm', gen.bpm);
if (gen.key) {
// Normalize casing: LLM may produce "B Major" but dropdown expects "B major"
const parts = gen.key.trim().split(/\s+/);
const normalized = parts.length === 2
? `${parts[0]} ${parts[1].toLowerCase()}`
: gen.key;
write('hs-keyScale', normalized);
}
if (gen.duration || gen.bpm) {
write('hs-duration', resolveDuration(gen.duration, gen.lyrics || '', gen.bpm || 120));
}
// Adapter from album preset — update Zustand store directly (writePersistedState
// only touches localStorage, which the Zustand store doesn't listen to after init)
const gps = useGlobalParamsStore.getState();
if (preset?.adapter_path) {
gps.setAdapter(preset.adapter_path);
// An Advanced-mode adapter stack supersedes the single adapter in
// getGlobalParams() — replace it too, otherwise the preset swap is
// silently ignored and the previously stacked adapters keep playing.
if (gps.advancedAdapters && gps.adapterStack && gps.adapterStack.length > 0) {
gps.setAdapterStack([{ path: preset.adapter_path, scale: gps.adapterScale ?? 1.0 }]);
}
gps.setAdaptersOpen(true);
}
// Planner-LM adapter from album preset (path only — strength stays with
// the global Adapters-menu slider, mirroring the DiT adapter semantics)
if (preset?.lm_adapter_path) {
gps.setLmAdapter(preset.lm_adapter_path);
}
// Mastering reference from album preset (does NOT force-enable — respects global toggle)
if (preset?.reference_track_path) {
gps.setMasteringReference(preset.reference_track_path);
gps.setTimbreReference(true);
}
console.log(`[LyricStudioV2] Send to Create: "${gen.title}" (adapter: ${preset?.adapter_path || 'none'}, mastering: ${preset?.reference_track_path || 'none'})`);
// Navigate to Create page — save current LS URL first so sidebar can restore it
try { localStorage.setItem('hs-lastLyricStudioUrl', window.location.pathname); } catch { /* ignore */ }
window.history.pushState({}, '', '/');
window.dispatchEvent(new PopStateEvent('popstate'));
}, [profiles]);
return { sendToCreate };
}
@@ -0,0 +1,376 @@
// MidiPlayer.tsx — live piano roll + synced playback for MIDI Studio
//
// Plays the ORIGINAL track (audio element) and the transcribed MIDI (WebAudio
// synth) in sync, with an equal-power crossfade slider between them — hear
// either or both. In live mode it consumes the job's SSE event stream, so
// notes appear on the roll (and become playable) while transcription is still
// running; for finished jobs it loads notes.json and offers the same player.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Pause, Play, Radio } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
getMidiNotes, getMidiStreamUrl, channelLabel,
} from '../../services/midiStudioApi';
import { MidiSynth, type PlayNote } from './midiSynth';
const PPS = 40; // px per second
const ROLL_H = 230;
const PITCH_MIN = 21, PITCH_MAX = 108;
function familyColor(family: string, alpha = 1): string {
if (/drum/i.test(family)) return `hsla(0, 70%, 55%, ${alpha})`;
let h = 0;
for (let i = 0; i < family.length; i++) h = (h * 31 + family.charCodeAt(i)) % 360;
return `hsla(${h}, 70%, 55%, ${alpha})`;
}
interface Props {
jobId: string;
sourceAudioUrl?: string;
live: boolean;
}
export const MidiPlayer: React.FC<Props> = ({ jobId, sourceAudioUrl, live }) => {
const { t } = useTranslation();
// NOTE: parent must mount this component with a key unique per (job, mode)
// — notes accumulate for the component's lifetime, no in-place reset.
const notesRef = useRef<PlayNote[]>([]);
const [noteCount, setNoteCount] = useState(0); // mirrors notesRef length -> redraw
const [redraw, setRedraw] = useState(0); // bump from event handlers
const [isPlaying, setIsPlaying] = useState(false);
const [crossfade, setCrossfade] = useState(50);
const [duration, setDuration] = useState(0);
const [curTime, setCurTime] = useState(0);
const [frontier, setFrontier] = useState<number | null>(live ? 0 : null);
const [chunks, setChunks] = useState<{ done: number; total: number } | null>(null);
const [liveDone, setLiveDone] = useState(!live);
const [families, setFamilies] = useState<string[]>([]);
const [muted, setMuted] = useState<Set<string>>(new Set());
const [soloed, setSoloed] = useState<Set<string>>(new Set());
const audioRef = useRef<HTMLAudioElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const synthRef = useRef<MidiSynth | null>(null);
const lastManualScroll = useRef(0);
const rafRef = useRef(0);
// family audible = no solos active ? not muted : soloed
const applyMixer = useCallback((synth: MidiSynth, fams: string[], mutedS: Set<string>, soloS: Set<string>) => {
for (const f of fams) {
synth.setFamilyAudible(f, soloS.size > 0 ? soloS.has(f) : !mutedS.has(f));
}
}, []);
const getSynth = useCallback((): MidiSynth => {
if (!synthRef.current) {
const synth = new MidiSynth();
// seed with everything that streamed in before first play — the synth
// is created lazily on the first user gesture, so notes accumulated in
// notesRef must be handed over here (this was the "MIDI side silent" bug)
synth.setAllNotes(notesRef.current);
synthRef.current = synth;
}
return synthRef.current;
}, []);
// keep the synth's family buses in sync with mute/solo state
useEffect(() => {
if (synthRef.current) applyMixer(synthRef.current, families, muted, soloed);
}, [families, muted, soloed, applyMixer]);
const addNotes = useCallback((ns: PlayNote[]) => {
if (!ns.length) return;
notesRef.current.push(...ns);
synthRef.current?.addNotes(ns);
setFamilies(prev => {
const s = new Set(prev);
let changed = false;
for (const n of ns) if (!s.has(n.family)) { s.add(n.family); changed = true; }
return changed ? [...s] : prev;
});
setNoteCount(notesRef.current.length);
}, []);
// ── data source: SSE (live) or notes.json (finished) ──
useEffect(() => {
if (live) {
const open = new Map<number, { pitch: number; time: number; instrument: string }>();
const es = new EventSource(getMidiStreamUrl(jobId));
const batch: PlayNote[] = [];
const flush = () => { if (batch.length) { addNotes(batch.splice(0)); } };
const flushTimer = window.setInterval(flush, 200);
es.onmessage = (msg) => {
try {
const ev = JSON.parse(msg.data);
if (ev.type === 'note_start') {
open.set(ev.index, { pitch: ev.pitch, time: ev.time, instrument: ev.instrument });
} else if (ev.type === 'note_end') {
const st = open.get(ev.index);
if (st) {
open.delete(ev.index);
batch.push({ pitch: st.pitch, start: st.time, duration: Math.max(0.03, ev.time - st.time), family: st.instrument });
}
} else if (ev.type === 'progress') {
setChunks({ done: ev.completed, total: ev.total });
setFrontier(ev.completed * 5.0);
} else if (ev.type === 'status') {
flush();
setLiveDone(true);
setFrontier(null);
es.close();
}
} catch { /* ignore malformed */ }
};
es.onerror = () => { /* server restarts end the stream; job list will reconcile */ };
return () => { window.clearInterval(flushTimer); es.close(); };
} else {
getMidiNotes(jobId).then(parsed => {
const label = new Map<number, string>();
for (const ch of parsed.channels) label.set(ch.channel, channelLabel(ch));
addNotes(parsed.notes.map(n => ({
pitch: n.pitch, start: n.start, duration: n.duration,
family: label.get(n.channel) || `ch${n.channel}`,
})));
setDuration(d => Math.max(d, parsed.durationSec));
}).catch(() => {});
return undefined;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jobId, live]);
// ── audio element wiring (original track = master clock) ──
useEffect(() => {
const el = audioRef.current;
if (!el || !sourceAudioUrl) return;
const onMeta = () => setDuration(d => Math.max(d, el.duration || 0));
const onEnd = () => setIsPlaying(false);
el.addEventListener('loadedmetadata', onMeta);
el.addEventListener('ended', onEnd);
return () => { el.removeEventListener('loadedmetadata', onMeta); el.removeEventListener('ended', onEnd); };
}, [sourceAudioUrl]);
useEffect(() => () => { synthRef.current?.dispose(); synthRef.current = null; }, []);
const togglePlay = useCallback(async () => {
const el = audioRef.current;
if (!el || !sourceAudioUrl) return;
const synth = getSynth();
synth.attachAudio(el);
synth.setCrossfade(crossfade / 100);
applyMixer(synth, families, muted, soloed);
if (synth.playing) {
synth.pause();
setIsPlaying(false);
} else {
await synth.play();
setIsPlaying(true);
}
}, [sourceAudioUrl, crossfade, getSynth, applyMixer, families, muted, soloed]);
const toggleMute = useCallback((f: string) => {
setMuted(prev => {
const s = new Set(prev);
if (s.has(f)) s.delete(f); else s.add(f);
return s;
});
}, []);
const toggleSolo = useCallback((f: string) => {
setSoloed(prev => {
const s = new Set(prev);
if (s.has(f)) s.delete(f); else s.add(f);
return s;
});
}, []);
const handleCrossfade = useCallback((v: number) => {
setCrossfade(v);
synthRef.current?.setCrossfade(v / 100);
}, []);
const seekTo = useCallback((time: number) => {
const el = audioRef.current;
if (!el) return;
if (synthRef.current) synthRef.current.seek(time);
else el.currentTime = time;
setCurTime(time);
setRedraw(r => r + 1);
}, []);
// ── canvas drawing + playhead follow ──
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const w = canvas.width, h = canvas.height;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dark = document.documentElement.classList.contains('dark');
ctx.clearRect(0, 0, w, h);
const rowH = h / (PITCH_MAX - PITCH_MIN + 1);
// 10 s gridlines
ctx.strokeStyle = dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)';
ctx.fillStyle = dark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.35)';
ctx.font = '9px sans-serif';
for (let s = 0; s * PPS < w; s += 10) {
const x = s * PPS;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
ctx.fillText(`${s}s`, x + 3, 10);
}
for (const n of notesRef.current) {
const p = Math.min(PITCH_MAX, Math.max(PITCH_MIN, n.pitch));
ctx.fillStyle = familyColor(n.family, 0.85);
ctx.fillRect(n.start * PPS, (PITCH_MAX - p) * rowH, Math.max(1.5, n.duration * PPS), Math.max(1.5, rowH - 0.5));
}
// un-transcribed region (live)
if (frontier !== null && duration > 0) {
const x = frontier * PPS;
ctx.fillStyle = dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)';
ctx.fillRect(x, 0, w - x, h);
}
// playhead
const t = synthRef.current?.currentTime ?? curTime;
ctx.strokeStyle = 'rgba(236,72,153,0.9)';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(t * PPS, 0); ctx.lineTo(t * PPS, h); ctx.stroke();
}, [frontier, duration, curTime]);
// resize canvas to content and redraw on data changes
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const wantW = Math.max(600, Math.ceil(Math.max(duration, frontier ?? 0, 10) * PPS) + 40);
if (canvas.width !== wantW) { canvas.width = wantW; canvas.height = ROLL_H; }
draw();
}, [noteCount, redraw, duration, frontier, draw]);
// animation loop while playing: playhead + auto-follow
useEffect(() => {
if (!isPlaying) { draw(); return; }
const loop = () => {
const tNow = synthRef.current?.currentTime ?? 0;
setCurTime(tNow);
draw();
const sc = scrollRef.current;
if (sc && Date.now() - lastManualScroll.current > 2500) {
const target = tNow * PPS - sc.clientWidth * 0.4;
sc.scrollLeft = Math.max(0, target);
}
rafRef.current = requestAnimationFrame(loop);
};
rafRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(rafRef.current);
}, [isPlaying, draw]);
const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
return (
<div className="mt-3">
{sourceAudioUrl && <audio ref={audioRef} src={sourceAudioUrl} preload="metadata" />}
{/* transport */}
<div className="flex items-center gap-3 flex-wrap mb-2">
<button
onClick={togglePlay}
disabled={!sourceAudioUrl}
className="w-9 h-9 rounded-full bg-purple-600 hover:bg-purple-500 text-white flex items-center justify-center disabled:opacity-40 transition-colors"
title={isPlaying ? t('midiStudio.player.pause') : t('midiStudio.player.play')}
>
{isPlaying ? <Pause size={16} /> : <Play size={16} className="ml-0.5" />}
</button>
<span className="text-[11px] tabular-nums text-zinc-500 dark:text-zinc-400 w-[86px]">
{fmt(curTime)} / {fmt(duration)}
</span>
{/* crossfade: original <-> MIDI */}
<div className="flex items-center gap-2">
<span className={`text-[11px] ${crossfade < 50 ? 'font-semibold text-zinc-800 dark:text-zinc-200' : 'text-zinc-400 dark:text-zinc-500'}`}>
{t('midiStudio.player.original')}
</span>
<input
type="range" min={0} max={100} value={crossfade}
onChange={e => handleCrossfade(Number(e.target.value))}
className="w-36 accent-purple-500"
title={t('midiStudio.player.crossfade')}
/>
<span className={`text-[11px] ${crossfade > 50 ? 'font-semibold text-zinc-800 dark:text-zinc-200' : 'text-zinc-400 dark:text-zinc-500'}`}>
MIDI
</span>
</div>
{live && !liveDone && chunks && (
<span className="flex items-center gap-1.5 text-[11px] text-purple-500 dark:text-purple-400">
<Radio size={11} className="animate-pulse" />
{t('midiStudio.player.liveChunk', { done: chunks.done, total: chunks.total })}
</span>
)}
<span className="text-[11px] text-zinc-400 dark:text-zinc-500">
{t('midiStudio.player.noteCount', { count: noteCount })}
</span>
</div>
{/* legend + per-track mute/solo (affects the MIDI side only) */}
{families.length > 0 && (
<div className="flex flex-wrap gap-x-2 gap-y-1 mb-1.5">
{families.map(f => {
const isMuted = muted.has(f);
const isSolo = soloed.has(f);
const audible = soloed.size > 0 ? isSolo : !isMuted;
return (
<span
key={f}
className={`flex items-center gap-1.5 text-[11px] rounded-md px-1.5 py-0.5 border transition-colors ${
isSolo
? 'border-purple-500/60 bg-purple-500/10 text-zinc-800 dark:text-zinc-200'
: 'border-transparent text-zinc-600 dark:text-zinc-400'
} ${audible ? '' : 'opacity-45'}`}
>
<span className="w-2.5 h-2.5 rounded-sm inline-block flex-shrink-0" style={{ backgroundColor: familyColor(f) }} />
<span className={isMuted && soloed.size === 0 ? 'line-through' : ''}>{f.replace(/_/g, ' ')}</span>
<button
onClick={() => toggleMute(f)}
title={t('midiStudio.player.mute')}
className={`w-4 h-4 rounded text-[9px] font-bold leading-none flex items-center justify-center transition-colors ${
isMuted ? 'bg-red-500 text-white' : 'bg-zinc-200 dark:bg-white/10 text-zinc-500 hover:bg-zinc-300 dark:hover:bg-white/20'
}`}
>M</button>
<button
onClick={() => toggleSolo(f)}
title={t('midiStudio.player.solo')}
className={`w-4 h-4 rounded text-[9px] font-bold leading-none flex items-center justify-center transition-colors ${
isSolo ? 'bg-amber-500 text-white' : 'bg-zinc-200 dark:bg-white/10 text-zinc-500 hover:bg-zinc-300 dark:hover:bg-white/20'
}`}
>S</button>
</span>
);
})}
</div>
)}
{/* piano roll (click to seek) */}
<div
ref={scrollRef}
onScroll={() => { lastManualScroll.current = Date.now(); }}
className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-white/5 bg-zinc-50 dark:bg-black/30"
>
<canvas
ref={canvasRef}
height={ROLL_H}
className="block cursor-pointer"
style={{ height: ROLL_H }}
onClick={e => {
const rect = (e.target as HTMLCanvasElement).getBoundingClientRect();
seekTo((e.clientX - rect.left) / PPS);
}}
/>
</div>
</div>
);
};
export default MidiPlayer;
@@ -0,0 +1,614 @@
// MidiStudio.tsx — MIDI Studio orchestrator (audio → MIDI transcription)
//
// Pick any track from the library, transcribe it to multi-instrument MIDI,
// preview the result as a piano roll, and download the .mid.
//
// Transcription runs on the NATIVE ace-midi engine (a GGML port of
// MuScriptor by Kyutai & Mirelo — full attribution in the footer card).
// Model weights are gated on Hugging Face and downloaded in-app with the
// user's read token.
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Piano, FolderOpen, Search, X, Download, Trash2, ChevronDown, ChevronUp,
CheckCircle2, ExternalLink, Music, KeyRound, Loader2, AlertTriangle,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import { songApi } from '../../services/api';
import type { Song } from '../../types';
import {
getMidiStatus, submitTranscription, listMidiJobs, deleteMidiJob,
getMidiFileUrl, saveHfToken, startModelDownload, getMidiProgress,
HF_MODEL_URLS, HF_TOKEN_SETTINGS_URL,
type MidiStudioStatus, type MidiJobSummary, type MuscriptorModel,
} from '../../services/midiStudioApi';
import { MidiPlayer } from './MidiPlayer';
const MODEL_INFO: Array<{ id: MuscriptorModel; params: string }> = [
{ id: 'small', params: '103M' },
{ id: 'medium', params: '307M' },
{ id: 'large', params: '1.4B' },
];
export const MidiStudio: React.FC = () => {
const { t } = useTranslation();
const { token } = useAuth();
const [status, setStatus] = useState<MidiStudioStatus | null>(null);
// ── HF access token (gated weights) ──
const [hfTokenInput, setHfTokenInput] = useState('');
const [hfTokenBusy, setHfTokenBusy] = useState(false);
const [hfTokenError, setHfTokenError] = useState('');
// Once a token is saved the card collapses to a slim row; "Change" reopens it
const [showTokenEditor, setShowTokenEditor] = useState(false);
// ── Source selection ──
const [showLibrary, setShowLibrary] = useState(false);
const [librarySearch, setLibrarySearch] = useState('');
const [librarySongs, setLibrarySongs] = useState<Song[]>([]);
const [sourceAudioUrl, setSourceAudioUrl] = useState('');
const [sourceName, setSourceName] = useState('');
const [sourceSongId, setSourceSongId] = useState<string | undefined>(undefined);
const [model, setModel] = useState<MuscriptorModel>(() =>
(localStorage.getItem('hs-midi-model') as MuscriptorModel) || 'small');
// ── Jobs ──
const [jobs, setJobs] = useState<MidiJobSummary[]>([]);
const [expandedJob, setExpandedJob] = useState<string | null>(null);
const [submitError, setSubmitError] = useState('');
const pollRef = useRef<number | null>(null);
const refreshStatus = useCallback(() => {
getMidiStatus().then(setStatus).catch(() => {});
}, []);
const refreshJobs = useCallback(() => {
listMidiJobs().then(setJobs).catch(() => {});
}, []);
useEffect(() => { refreshStatus(); refreshJobs(); }, [refreshStatus, refreshJobs]);
// Poll status while any model download is in flight
const anyDownloading = !!status && Object.values(status.models).some(ms => ms.downloading);
useEffect(() => {
if (!anyDownloading) return;
const iv = window.setInterval(refreshStatus, 1500);
return () => window.clearInterval(iv);
}, [anyDownloading, refreshStatus]);
// Poll progress of active transcription jobs
const hasActiveJobs = jobs.some(j => j.status === 'queued' || j.status === 'transcribing');
useEffect(() => {
if (!hasActiveJobs) { if (pollRef.current) { window.clearInterval(pollRef.current); pollRef.current = null; } return; }
pollRef.current = window.setInterval(async () => {
let anyFinished = false;
const updated = await Promise.all(jobs.map(async (j) => {
if (j.status !== 'queued' && j.status !== 'transcribing') return j;
try {
const p = await getMidiProgress(j.id);
if (p.status === 'done' || p.status === 'failed' || p.status === 'cancelled') anyFinished = true;
return {
...j, status: p.status, error: p.error, gated: p.gated,
noteCount: p.noteCount ?? j.noteCount,
chunksDone: p.chunksDone, chunksTotal: p.chunksTotal,
};
} catch { return j; }
}));
setJobs(updated);
if (anyFinished) refreshJobs();
}, 1500);
return () => { if (pollRef.current) { window.clearInterval(pollRef.current); pollRef.current = null; } };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasActiveJobs, jobs.map(j => `${j.id}:${j.status}`).join(',')]);
// Load library songs when picker opens
useEffect(() => {
if (showLibrary && token) {
songApi.list(token).then(({ songs }) => setLibrarySongs(songs.filter(s => s.audioUrl))).catch(() => {});
}
}, [showLibrary, token]);
const filteredSongs = useMemo(() => {
if (!librarySearch.trim()) return librarySongs;
const q = librarySearch.toLowerCase();
return librarySongs.filter(s =>
s.title?.toLowerCase().includes(q) ||
s.artistName?.toLowerCase().includes(q) ||
s.style?.toLowerCase().includes(q)
);
}, [librarySongs, librarySearch]);
const handleSelectSong = useCallback((song: Song) => {
setSourceAudioUrl(song.audioUrl || song.audio_url || '');
setSourceName(song.title || 'Library Track');
setSourceSongId(song.id);
setShowLibrary(false);
setSubmitError('');
}, []);
// ── Upload a track from the user's PC (same endpoint Repaint uses) ──
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileUpload = useCallback(async (file: File) => {
setSubmitError('');
setIsUploading(true);
try {
const fd = new FormData();
fd.append('audio', file);
const res = await fetch('/api/upload/audio', { method: 'POST', body: fd });
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
const { audio_url } = await res.json();
setSourceAudioUrl(audio_url);
setSourceName(file.name);
setSourceSongId(undefined);
setShowLibrary(false);
} catch (err) {
setSubmitError(err instanceof Error ? err.message : String(err));
} finally {
setIsUploading(false);
}
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const file = e.dataTransfer.files?.[0];
if (file) handleFileUpload(file);
}, [handleFileUpload]);
const handleModelChange = (m: MuscriptorModel) => {
setModel(m);
try { localStorage.setItem('hs-midi-model', m); } catch { /* ignore */ }
};
const handleTranscribe = async () => {
if (!sourceAudioUrl) return;
setSubmitError('');
try {
await submitTranscription({ sourceAudioUrl, sourceFileName: sourceName, songId: sourceSongId, model });
refreshJobs();
} catch (err) {
setSubmitError(err instanceof Error ? err.message : String(err));
}
};
const handleSaveHfToken = async (tok: string) => {
setHfTokenError('');
setHfTokenBusy(true);
try {
await saveHfToken(tok);
setHfTokenInput('');
if (tok.trim()) setShowTokenEditor(false);
refreshStatus();
} catch (err) {
setHfTokenError(err instanceof Error ? err.message : String(err));
} finally {
setHfTokenBusy(false);
}
};
const handleDelete = async (jobId: string) => {
try {
await deleteMidiJob(jobId);
if (expandedJob === jobId) setExpandedJob(null);
setJobs(js => js.filter(j => j.id !== jobId));
} catch { /* refresh will reconcile */ }
refreshJobs();
};
const engineMissing = !!status && !status.engineAvailable;
const modelState = status?.models?.[model];
const canTranscribe = !!status?.engineAvailable && !!modelState?.downloaded && !!sourceAudioUrl && !isUploading;
const fmtGB = (b: number) => `${(b / 1e9).toFixed(2)} GB`;
return (
<div className="h-full overflow-y-auto">
<div className="max-w-4xl mx-auto px-6 py-8 flex flex-col gap-6">
{/* ── Header ── */}
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white flex items-center gap-3">
<Piano size={26} className="text-purple-500 dark:text-purple-400" />
{t('midiStudio.title')}
</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">{t('midiStudio.subtitle')}</p>
</div>
{/* ── Engine missing banner (broken install / old build) ── */}
{engineMissing && (
<div className="rounded-xl border border-red-300/60 dark:border-red-500/20 bg-red-50 dark:bg-red-500/5 p-4 flex items-start gap-3">
<AlertTriangle size={18} className="text-red-500 mt-0.5 flex-shrink-0" />
<div className="text-xs text-zinc-600 dark:text-zinc-400 leading-relaxed">
<div className="text-sm font-semibold text-zinc-900 dark:text-white mb-1">{t('midiStudio.engineMissingTitle')}</div>
{t('midiStudio.engineMissingBody')}
</div>
</div>
)}
{/* ── Hugging Face model access (weights are gated) ──
Collapses to a slim confirmation row once a token is saved. */}
{status && status.hfTokenSet && !showTokenEditor && (
<div className="rounded-xl border border-zinc-200 dark:border-white/5 bg-white dark:bg-suno-card px-4 py-2.5 flex items-center gap-3 flex-wrap text-xs">
<span className="flex items-center gap-2 font-semibold text-zinc-900 dark:text-white">
<KeyRound size={14} className="text-purple-500 dark:text-purple-400" />
{t('midiStudio.hfAccessTitle')}
</span>
<span className="flex items-center gap-1 font-medium text-emerald-600 dark:text-emerald-400">
<CheckCircle2 size={12} /> {t('midiStudio.hfTokenSaved')}
</span>
<span className="flex-1" />
<button
onClick={() => setShowTokenEditor(true)}
className="text-zinc-500 dark:text-zinc-400 hover:text-purple-600 dark:hover:text-purple-400 hover:underline"
>
{t('midiStudio.hfChangeToken')}
</button>
</div>
)}
{status && (!status.hfTokenSet || showTokenEditor) && (
<div className="rounded-xl border border-zinc-200 dark:border-white/5 bg-white dark:bg-suno-card p-4">
<div className="text-sm font-semibold text-zinc-900 dark:text-white mb-1 flex items-center gap-2">
<KeyRound size={15} className="text-purple-500 dark:text-purple-400" />
{t('midiStudio.hfAccessTitle')}
{status.hfTokenSet && (
<span className="flex items-center gap-1 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
<CheckCircle2 size={12} /> {t('midiStudio.hfTokenSaved')}
</span>
)}
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-relaxed">{t('midiStudio.hfAccessBody')}</p>
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-xs">
{(Object.keys(HF_MODEL_URLS) as MuscriptorModel[]).map(m => (
<a key={m} href={HF_MODEL_URLS[m]} target="_blank" rel="noreferrer"
className="flex items-center gap-1 text-purple-600 dark:text-purple-400 hover:underline capitalize">
<ExternalLink size={11} /> {m}
</a>
))}
<a href={HF_TOKEN_SETTINGS_URL} target="_blank" rel="noreferrer"
className="flex items-center gap-1 text-purple-600 dark:text-purple-400 hover:underline">
<ExternalLink size={11} /> {t('midiStudio.hfGetToken')}
</a>
</div>
<div className="mt-3 flex items-center gap-2 flex-wrap">
<input
type="password"
value={hfTokenInput}
onChange={e => setHfTokenInput(e.target.value)}
placeholder={t('midiStudio.hfTokenPlaceholder')}
className="flex-1 min-w-[220px] px-3 py-2 rounded-lg text-xs bg-zinc-100 dark:bg-black/30 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white placeholder-zinc-400 outline-none focus:border-purple-500/40"
/>
<button
onClick={() => handleSaveHfToken(hfTokenInput)}
disabled={hfTokenBusy || !hfTokenInput.trim()}
className="px-4 py-2 rounded-lg text-xs font-semibold bg-purple-600 hover:bg-purple-500 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{hfTokenBusy ? <Loader2 size={13} className="animate-spin" /> : t('midiStudio.hfTokenSave')}
</button>
{status.hfTokenSet && (
<button
onClick={() => handleSaveHfToken('')}
disabled={hfTokenBusy}
className="px-3 py-2 rounded-lg text-xs font-medium text-zinc-500 dark:text-zinc-400 hover:text-red-500 border border-zinc-200 dark:border-white/10 transition-colors"
>
{t('midiStudio.hfTokenClear')}
</button>
)}
</div>
{hfTokenError && <div className="mt-2 text-xs text-red-500 dark:text-red-400">{hfTokenError}</div>}
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 mt-2">{t('midiStudio.hfTokenNote')}</p>
</div>
)}
{/* ── New transcription ── */}
<div
className="rounded-xl border border-zinc-200 dark:border-white/5 bg-white dark:bg-suno-card p-4"
onDragOver={e => e.preventDefault()}
onDrop={handleDrop}
>
<div className="text-sm font-semibold text-zinc-900 dark:text-white mb-3">{t('midiStudio.newTranscription')}</div>
{/* Source picker: library track or a file from the user's PC */}
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={() => setShowLibrary(v => !v)}
className="px-3 py-2 rounded-lg text-xs font-medium flex items-center gap-2 bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 text-zinc-700 dark:text-zinc-300 transition-colors"
>
<FolderOpen size={14} /> {t('midiStudio.chooseFromLibrary')}
</button>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="px-3 py-2 rounded-lg text-xs font-medium flex items-center gap-2 bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 text-zinc-700 dark:text-zinc-300 disabled:opacity-50 transition-colors"
>
{isUploading ? <Loader2 size={14} className="animate-spin" /> : <Music size={14} />}
{t('midiStudio.uploadFile')}
</button>
<input
ref={fileInputRef}
type="file"
accept=".wav,.mp3,audio/wav,audio/mpeg"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) handleFileUpload(f); e.target.value = ''; }}
/>
{sourceName ? (
<span className="flex items-center gap-2 text-sm text-zinc-800 dark:text-zinc-200">
<Music size={14} className="text-purple-500 dark:text-purple-400" />
{sourceName}
<button onClick={() => { setSourceAudioUrl(''); setSourceName(''); setSourceSongId(undefined); }}
className="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200">
<X size={14} />
</button>
</span>
) : (
<span className="text-xs text-zinc-400 dark:text-zinc-500">{t('midiStudio.noTrackSelected')}</span>
)}
</div>
{/* Inline library panel */}
{showLibrary && (
<div className="mt-3 rounded-lg border border-zinc-200 dark:border-white/5 bg-zinc-50 dark:bg-black/20">
<div className="p-2 border-b border-zinc-200 dark:border-white/5 flex items-center gap-2">
<Search size={14} className="text-zinc-400 flex-shrink-0" />
<input
value={librarySearch}
onChange={e => setLibrarySearch(e.target.value)}
placeholder={t('midiStudio.searchLibrary')}
className="flex-1 bg-transparent text-sm text-zinc-900 dark:text-white placeholder-zinc-400 outline-none"
/>
</div>
<div className="max-h-56 overflow-y-auto">
{filteredSongs.length === 0 && (
<div className="px-3 py-4 text-xs text-zinc-400 dark:text-zinc-500">{t('midiStudio.libraryEmpty')}</div>
)}
{filteredSongs.map(song => (
<button
key={song.id}
onClick={() => handleSelectSong(song)}
className="w-full text-left px-3 py-2 flex items-center justify-between gap-3 hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors"
>
<span className="text-sm text-zinc-800 dark:text-zinc-200 truncate">{song.title || t('midiStudio.untitled')}</span>
<span className="text-[11px] text-zinc-400 flex-shrink-0">
{song.duration ? `${Math.floor(Number(song.duration) / 60)}:${String(Math.floor(Number(song.duration) % 60)).padStart(2, '0')}` : ''}
</span>
</button>
))}
</div>
</div>
)}
{/* Model select + per-model weight download */}
<div className="mt-4">
<div className="text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">{t('midiStudio.model')}</div>
<div className="flex gap-2 flex-wrap">
{MODEL_INFO.map(mi => {
const ms = status?.models?.[mi.id];
const pct = ms?.downloading && ms.totalBytes > 0
? Math.round(100 * ms.receivedBytes / ms.totalBytes) : 0;
return (
<div
key={mi.id}
onClick={() => handleModelChange(mi.id)}
className={`px-3 py-2 rounded-lg text-xs border cursor-pointer transition-colors min-w-[150px] ${
model === mi.id
? 'border-purple-500/60 bg-purple-500/10 text-purple-600 dark:text-purple-300'
: 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-400 hover:border-zinc-300 dark:hover:border-white/20'
}`}
>
<span className="font-semibold capitalize">{mi.id}</span>
<span className="opacity-60 ml-1.5">{mi.params}</span>
<span className="block text-[10px] opacity-60 mt-0.5">{t(`midiStudio.model_${mi.id}`)}</span>
{ms?.downloaded ? (
<span className="flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 mt-1">
<CheckCircle2 size={10} /> {t('midiStudio.modelReady')} ({fmtGB(ms.sizeBytes)})
</span>
) : ms?.downloading ? (
<span className="block mt-1">
<span className="flex items-center gap-1 text-[10px] text-purple-500">
<Loader2 size={10} className="animate-spin" />
{t('midiStudio.downloading')} {pct}% ({fmtGB(ms.receivedBytes)}{ms.totalBytes ? ` / ${fmtGB(ms.totalBytes)}` : ''})
</span>
<span className="block h-1 mt-1 rounded bg-zinc-200 dark:bg-white/10 overflow-hidden">
<span className="block h-full bg-purple-500 transition-all" style={{ width: `${pct}%` }} />
</span>
</span>
) : (
<button
onClick={e => {
e.stopPropagation();
startModelDownload(mi.id).then(refreshStatus).catch(err =>
setSubmitError(err instanceof Error ? err.message : String(err)));
}}
className="flex items-center gap-1 text-[10px] text-purple-600 dark:text-purple-400 hover:underline mt-1"
>
<Download size={10} /> {t('midiStudio.downloadModel')}
</button>
)}
{ms?.error && !ms.downloading && (
<span className="block text-[10px] text-red-500 dark:text-red-400 mt-1 max-w-[200px]">
{ms.gated ? t('midiStudio.gatedHint') + ' ' : ''}{ms.error.slice(0, 120)}
</span>
)}
</div>
);
})}
</div>
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 mt-2">{t('midiStudio.firstRunNote')}</p>
</div>
{/* Primary action morphs with the selected model's state:
download weights -> downloading progress -> transcribe. */}
<div className="mt-4 flex items-center gap-3 flex-wrap">
{modelState && !modelState.downloaded ? (
<button
onClick={() => {
if (modelState.downloading) return;
setSubmitError('');
startModelDownload(model).then(refreshStatus).catch(err =>
setSubmitError(err instanceof Error ? err.message : String(err)));
}}
disabled={modelState.downloading || engineMissing}
className="px-5 py-2.5 rounded-lg text-sm font-semibold bg-purple-600 hover:bg-purple-500 text-white disabled:opacity-60 disabled:cursor-wait transition-colors flex items-center gap-2"
>
{modelState.downloading ? (
<>
<Loader2 size={16} className="animate-spin" />
{t('midiStudio.downloadingCta', {
model,
pct: modelState.totalBytes ? Math.round(100 * modelState.receivedBytes / modelState.totalBytes) : 0,
})}
</>
) : (
<>
<Download size={16} /> {t('midiStudio.downloadCta', { model })}
</>
)}
</button>
) : (
<button
onClick={handleTranscribe}
disabled={!canTranscribe}
className="px-5 py-2.5 rounded-lg text-sm font-semibold bg-purple-600 hover:bg-purple-500 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
<Piano size={16} /> {t('midiStudio.transcribe')}
</button>
)}
{modelState && !modelState.downloaded && !modelState.downloading && !status?.hfTokenSet && (
<span className="text-xs text-amber-600 dark:text-amber-400">{t('midiStudio.needTokenHint')}</span>
)}
{modelState?.downloaded && !sourceAudioUrl && (
<span className="text-xs text-zinc-400 dark:text-zinc-500">{t('midiStudio.pickTrackHint')}</span>
)}
{submitError && <span className="text-xs text-red-500 dark:text-red-400">{submitError}</span>}
</div>
</div>
{/* ── Transcriptions list ── */}
<div className="rounded-xl border border-zinc-200 dark:border-white/5 bg-white dark:bg-suno-card p-4">
<div className="text-sm font-semibold text-zinc-900 dark:text-white mb-3">{t('midiStudio.transcriptions')}</div>
{jobs.length === 0 && (
<div className="text-xs text-zinc-400 dark:text-zinc-500 py-2">{t('midiStudio.noJobs')}</div>
)}
<div className="flex flex-col gap-2">
{jobs.map(job => {
const running = job.status === 'queued' || job.status === 'transcribing';
const pct = job.chunksTotal ? Math.round(100 * (job.chunksDone || 0) / job.chunksTotal) : 0;
return (
<div key={job.id} className="rounded-lg border border-zinc-200 dark:border-white/5 bg-zinc-50 dark:bg-black/20 px-3 py-2.5">
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-zinc-800 dark:text-zinc-200 truncate">{job.sourceFileName}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-200 dark:bg-white/10 text-zinc-500 dark:text-zinc-400 capitalize flex-shrink-0">{job.model}</span>
</div>
{running && (
<div className="mt-1">
<span className="flex items-center gap-1.5 text-[11px] text-zinc-500 dark:text-zinc-400">
<Loader2 size={11} className="animate-spin flex-shrink-0" />
{job.status === 'queued'
? t('midiStudio.statusQueued')
: t('midiStudio.statusTranscribing', { done: job.chunksDone || 0, total: job.chunksTotal || 0, notes: job.noteCount })}
</span>
{job.status === 'transcribing' && (
<span className="block h-1 mt-1 rounded bg-zinc-200 dark:bg-white/10 overflow-hidden">
<span className="block h-full bg-purple-500 transition-all" style={{ width: `${pct}%` }} />
</span>
)}
</div>
)}
{/* live player appears as soon as transcription starts —
play immediately, crossfade original <-> MIDI */}
{job.status === 'done' && (
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-emerald-600 dark:text-emerald-400">
<CheckCircle2 size={11} />
{t('midiStudio.doneSummary', { notes: job.noteCount, seconds: Math.round(job.durationSec) })}
</div>
)}
{job.status === 'failed' && (
<>
<div className="mt-0.5 text-[11px] text-red-500 dark:text-red-400 truncate">{job.error || t('midiStudio.statusFailed')}</div>
{job.gated && (
<div className="mt-1 text-[11px] text-amber-600 dark:text-amber-400 flex items-start gap-1">
<AlertTriangle size={11} className="mt-0.5 flex-shrink-0" />
<span>
{t('midiStudio.gatedHint')}{' '}
<a href={HF_MODEL_URLS[job.model]} target="_blank" rel="noreferrer" className="underline">
{t('midiStudio.gatedHintLink', { model: job.model })}
</a>
</span>
</div>
)}
</>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{job.status === 'done' && (
<>
<button
onClick={() => setExpandedJob(e => e === job.id ? null : job.id)}
className="p-1.5 rounded-md text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"
title={t('midiStudio.preview')}
>
{expandedJob === job.id ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
</button>
<a
href={getMidiFileUrl(job.id)}
className="p-1.5 rounded-md text-zinc-500 hover:text-purple-600 dark:hover:text-purple-400 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"
title={t('midiStudio.downloadMid')}
download
>
<Download size={15} />
</a>
</>
)}
<button
onClick={() => handleDelete(job.id)}
className="p-1.5 rounded-md text-zinc-500 hover:text-red-500 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"
title={running ? t('midiStudio.cancel') : t('midiStudio.delete')}
>
{running ? <X size={15} /> : <Trash2 size={15} />}
</button>
</div>
</div>
{job.status === 'transcribing' && (
<MidiPlayer key={`${job.id}-live`} jobId={job.id} sourceAudioUrl={job.sourceAudioUrl} live />
)}
{expandedJob === job.id && job.status === 'done' && (
<MidiPlayer key={`${job.id}-done`} jobId={job.id} sourceAudioUrl={job.sourceAudioUrl} live={false} />
)}
</div>
);
})}
</div>
</div>
{/* ── Attribution ── */}
<div className="rounded-xl border border-zinc-200 dark:border-white/5 bg-zinc-50 dark:bg-black/20 p-4 text-xs text-zinc-500 dark:text-zinc-400 leading-relaxed">
<div className="font-semibold text-zinc-700 dark:text-zinc-300 mb-1">{t('midiStudio.creditsTitle')}</div>
<p>
{t('midiStudio.creditsBody')}{' '}
<span className="text-zinc-700 dark:text-zinc-300">
Simon Rouard, Michael Krause, Axel Roebel, Carl-Johann Simon-Gabriel, Alexandre Défossez
</span>.
</p>
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
<a href="https://github.com/muscriptor/muscriptor" target="_blank" rel="noreferrer"
className="flex items-center gap-1 text-purple-600 dark:text-purple-400 hover:underline">
<ExternalLink size={11} /> GitHub
</a>
<a href="https://arxiv.org/abs/2607.08168" target="_blank" rel="noreferrer"
className="flex items-center gap-1 text-purple-600 dark:text-purple-400 hover:underline">
<ExternalLink size={11} /> {t('midiStudio.paper')}
</a>
<a href="https://muscriptor.kyutai.org/" target="_blank" rel="noreferrer"
className="flex items-center gap-1 text-purple-600 dark:text-purple-400 hover:underline">
<ExternalLink size={11} /> {t('midiStudio.demo')}
</a>
</div>
<p className="mt-2 opacity-80">{t('midiStudio.licenseNote')}</p>
</div>
</div>
</div>
);
};
export default MidiStudio;
+260
View File
@@ -0,0 +1,260 @@
// midiSynth.ts — WebAudio playback engine for MIDI Studio
//
// Plays transcribed notes with simple per-family synth voices, in sync with
// the original audio track (an <audio> element is the master clock), with an
// equal-power crossfade between the two. Notes can be appended while playing
// (live transcription streaming): a lookahead scheduler picks them up.
export interface PlayNote {
pitch: number; // MIDI 0-127
start: number; // seconds
duration: number; // seconds
family: string; // instrument family key ('drums' special-cased)
}
const LOOKAHEAD_S = 0.35; // schedule window
const TICK_MS = 120; // scheduler tick
const VOICE_GAIN = 0.10;
interface FamilyVoice {
type: OscillatorType;
attack: number;
release: number;
sustain: number; // sustain level fraction of peak
detune?: number; // slight second-osc detune (cents) for width
}
function voiceForFamily(family: string): FamilyVoice {
const f = family.toLowerCase();
if (/bass|tuba|contrabass/.test(f)) return { type: 'sine', attack: 0.01, release: 0.12, sustain: 0.8 };
if (/piano|chromatic|harp|timpani/.test(f)) return { type: 'triangle', attack: 0.005, release: 0.25, sustain: 0.35 };
if (/guitar/.test(f)) return { type: 'sawtooth', attack: 0.005, release: 0.2, sustain: 0.4 };
if (/organ/.test(f)) return { type: 'square', attack: 0.02, release: 0.08, sustain: 0.9 };
if (/string|voice|pad|ensemble|orchestra/.test(f)) return { type: 'sawtooth', attack: 0.08, release: 0.25, sustain: 0.85, detune: 8 };
if (/brass|trumpet|trombone|horn|sax|reed|oboe|bassoon|clarinet|english/.test(f)) return { type: 'sawtooth', attack: 0.04, release: 0.15, sustain: 0.8 };
if (/flute|pipe/.test(f)) return { type: 'sine', attack: 0.05, release: 0.15, sustain: 0.85 };
if (/synth_lead|lead/.test(f)) return { type: 'square', attack: 0.01, release: 0.12, sustain: 0.7 };
return { type: 'triangle', attack: 0.01, release: 0.15, sustain: 0.6 };
}
function midiToHz(pitch: number): number {
return 440 * Math.pow(2, (pitch - 69) / 12);
}
export class MidiSynth {
private ctx: AudioContext;
private midiGain: GainNode;
private origGain: GainNode;
private noiseBuf: AudioBuffer;
private audioEl: HTMLAudioElement | null = null;
private mediaSrc: MediaElementAudioSourceNode | null = null;
private notes: PlayNote[] = []; // kept sorted by start
private schedIdx = 0; // next note to consider scheduling
private timer: number | null = null;
private active: Set<{ stop: (t: number) => void }> = new Set();
private crossfade = 0.5;
// per-instrument-family sub-mix (mute/solo support)
private familyGains = new Map<string, GainNode>();
private familyAudible = new Map<string, boolean>();
constructor() {
this.ctx = new AudioContext();
const comp = this.ctx.createDynamicsCompressor();
comp.threshold.value = -18;
comp.ratio.value = 6;
comp.connect(this.ctx.destination);
this.midiGain = this.ctx.createGain();
this.midiGain.connect(comp);
this.origGain = this.ctx.createGain();
this.origGain.connect(comp);
this.setCrossfade(0.5);
// shared white-noise buffer for drum voices
this.noiseBuf = this.ctx.createBuffer(1, this.ctx.sampleRate / 2, this.ctx.sampleRate);
const d = this.noiseBuf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
}
/** Wire the original-audio element into the crossfade graph (once). */
attachAudio(el: HTMLAudioElement): void {
if (this.audioEl === el) return;
this.audioEl = el;
if (!this.mediaSrc) {
this.mediaSrc = this.ctx.createMediaElementSource(el);
this.mediaSrc.connect(this.origGain);
}
}
/** Per-family output bus — lets the UI mute/solo instrument tracks. */
private gainForFamily(family: string): GainNode {
let g = this.familyGains.get(family);
if (!g) {
g = this.ctx.createGain();
g.gain.value = this.familyAudible.get(family) === false ? 0 : 1;
g.connect(this.midiGain);
this.familyGains.set(family, g);
}
return g;
}
/** Set a family's audibility (the UI computes mute/solo into a boolean). */
setFamilyAudible(family: string, audible: boolean): void {
this.familyAudible.set(family, audible);
const g = this.familyGains.get(family);
if (g) g.gain.setTargetAtTime(audible ? 1 : 0, this.ctx.currentTime, 0.02);
}
/** 0 = original only, 1 = MIDI only; equal-power blend in between. */
setCrossfade(v: number): void {
this.crossfade = Math.min(1, Math.max(0, v));
const t = this.ctx.currentTime;
this.origGain.gain.setTargetAtTime(Math.cos(this.crossfade * Math.PI / 2), t, 0.02);
this.midiGain.gain.setTargetAtTime(Math.sin(this.crossfade * Math.PI / 2), t, 0.02);
}
getCrossfade(): number { return this.crossfade; }
/** Append notes (live streaming). Keeps the schedule order consistent. */
addNotes(notes: PlayNote[]): void {
if (!notes.length) return;
this.notes.push(...notes);
// note_end events can arrive slightly out of start order — sort from the
// unscheduled tail only, so already-played history is untouched
const tail = this.notes.slice(this.schedIdx).sort((a, b) => a.start - b.start);
this.notes = this.notes.slice(0, this.schedIdx).concat(tail);
}
setAllNotes(notes: PlayNote[]): void {
this.notes = [...notes].sort((a, b) => a.start - b.start);
this.resync();
}
get currentTime(): number { return this.audioEl?.currentTime ?? 0; }
get playing(): boolean { return !!this.audioEl && !this.audioEl.paused; }
async play(): Promise<void> {
if (!this.audioEl) return;
await this.ctx.resume();
this.resync();
await this.audioEl.play();
if (this.timer === null) {
this.timer = window.setInterval(() => this.tick(), TICK_MS);
}
}
pause(): void {
this.audioEl?.pause();
this.stopScheduled();
if (this.timer !== null) { window.clearInterval(this.timer); this.timer = null; }
}
seek(t: number): void {
if (!this.audioEl) return;
this.audioEl.currentTime = Math.max(0, t);
this.stopScheduled();
this.resync();
}
dispose(): void {
this.pause();
try { this.ctx.close(); } catch { /* already closed */ }
}
// ── internals ──────────────────────────────────────────────────────────
private resync(): void {
const t = this.currentTime;
this.schedIdx = 0;
// binary search would be nicer; linear is fine at 10k notes on seek only
while (this.schedIdx < this.notes.length && this.notes[this.schedIdx].start < t - 0.05) this.schedIdx++;
}
private stopScheduled(): void {
const t = this.ctx.currentTime;
for (const v of this.active) v.stop(t);
this.active.clear();
}
private tick(): void {
if (!this.audioEl || this.audioEl.paused) return;
const trackTime = this.audioEl.currentTime;
const horizon = trackTime + LOOKAHEAD_S;
// map track seconds -> ctx seconds (recomputed every tick: absorbs drift)
const ctxBase = this.ctx.currentTime - trackTime;
while (this.schedIdx < this.notes.length && this.notes[this.schedIdx].start < horizon) {
const n = this.notes[this.schedIdx++];
if (n.start < trackTime - 0.05) continue; // stale (seek/underrun)
const when = ctxBase + n.start;
if (n.family === 'drums' || n.family === 'Drums') this.playDrum(n, when);
else this.playTone(n, when);
}
}
private playTone(n: PlayNote, when: number): void {
const v = voiceForFamily(n.family);
const dur = Math.max(0.04, Math.min(n.duration, 12));
const g = this.ctx.createGain();
g.connect(this.gainForFamily(n.family));
g.gain.setValueAtTime(0, when);
g.gain.linearRampToValueAtTime(VOICE_GAIN, when + v.attack);
g.gain.setTargetAtTime(VOICE_GAIN * v.sustain, when + v.attack, 0.08);
const end = when + dur;
g.gain.setTargetAtTime(0, end, v.release / 3);
const oscs: OscillatorNode[] = [];
const mk = (detune: number) => {
const o = this.ctx.createOscillator();
o.type = v.type;
o.frequency.value = midiToHz(n.pitch);
o.detune.value = detune;
o.connect(g);
o.start(when);
o.stop(end + v.release * 4);
oscs.push(o);
};
mk(0);
if (v.detune) mk(v.detune);
const voice = { stop: (t: number) => { try { g.gain.cancelScheduledValues(t); g.gain.setTargetAtTime(0, t, 0.01); oscs.forEach(o => o.stop(t + 0.05)); } catch { /* ended */ } } };
this.active.add(voice);
oscs[0].onended = () => this.active.delete(voice);
}
private playDrum(n: PlayNote, when: number): void {
const p = n.pitch;
const g = this.ctx.createGain();
g.connect(this.gainForFamily(n.family));
if (p === 35 || p === 36) {
// kick: sine pitch-drop thump
const o = this.ctx.createOscillator();
o.type = 'sine';
o.frequency.setValueAtTime(120, when);
o.frequency.exponentialRampToValueAtTime(45, when + 0.09);
g.gain.setValueAtTime(VOICE_GAIN * 2.2, when);
g.gain.setTargetAtTime(0, when + 0.02, 0.05);
o.connect(g);
o.start(when);
o.stop(when + 0.3);
return;
}
// snare / hats / percussion: filtered noise burst
const src = this.ctx.createBufferSource();
src.buffer = this.noiseBuf;
const filt = this.ctx.createBiquadFilter();
const isHat = p === 42 || p === 44 || p === 46;
const isSnare = p === 38 || p === 40;
filt.type = isHat ? 'highpass' : 'bandpass';
filt.frequency.value = isHat ? 7000 : isSnare ? 2200 : 900 + (p % 12) * 220;
filt.Q.value = isHat ? 0.8 : 1.2;
const len = isHat ? 0.05 : isSnare ? 0.14 : 0.1;
g.gain.setValueAtTime(VOICE_GAIN * (isHat ? 1.0 : 1.7), when);
g.gain.setTargetAtTime(0, when + 0.005, len / 3);
src.connect(filt);
filt.connect(g);
src.start(when);
src.stop(when + len + 0.1);
}
}
@@ -0,0 +1,107 @@
// DownloadProgressBar.tsx — Reusable download progress bar with speed/ETA
import React from 'react';
import { X, Play } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { DownloadJob } from '../../types';
interface Props {
job: DownloadJob;
onCancel: (jobId: string) => void;
onResume: (jobId: string) => void;
compact?: boolean;
}
/** Format bytes to human-readable string */
function formatSize(bytes: number): string {
if (bytes >= 1_073_741_824) return (bytes / 1_073_741_824).toFixed(2) + ' GB';
if (bytes >= 1_048_576) return (bytes / 1_048_576).toFixed(1) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(0) + ' KB';
return bytes + ' B';
}
/** Format speed to human-readable string */
function formatSpeed(bytesPerSec: number): string {
if (bytesPerSec >= 1_073_741_824) return (bytesPerSec / 1_073_741_824).toFixed(1) + ' GB/s';
if (bytesPerSec >= 1_048_576) return (bytesPerSec / 1_048_576).toFixed(1) + ' MB/s';
if (bytesPerSec >= 1024) return (bytesPerSec / 1024).toFixed(0) + ' KB/s';
return bytesPerSec.toFixed(0) + ' B/s';
}
/** Format ETA in human-readable form */
function formatEta(bytes: number, speed: number): string {
if (speed <= 0) return '—';
const secs = bytes / speed;
if (secs < 60) return `${Math.ceil(secs)}s`;
if (secs < 3600) return `${Math.floor(secs / 60)}m ${Math.ceil(secs % 60)}s`;
return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`;
}
const statusColors: Record<string, string> = {
queued: 'bg-zinc-600 text-zinc-700 dark:text-zinc-300',
downloading: 'bg-sky-500/20 text-sky-400',
paused: 'bg-amber-500/20 text-amber-400',
completed: 'bg-emerald-500/20 text-emerald-400',
failed: 'bg-red-500/20 text-red-400',
cancelled: 'bg-zinc-600 text-zinc-600 dark:text-zinc-400',
};
export const DownloadProgressBar: React.FC<Props> = ({ job, onCancel, onResume, compact }) => {
const { t } = useTranslation();
const pct = job.totalBytes > 0 ? Math.min(100, (job.bytesDownloaded / job.totalBytes) * 100) : 0;
const remaining = job.totalBytes - job.bytesDownloaded;
const isActive = job.status === 'downloading' || job.status === 'queued';
const canResume = job.status === 'paused' || job.status === 'failed';
return (
<div className={`rounded-xl border border-zinc-200 dark:border-white/5 bg-zinc-100/50 dark:bg-zinc-800/50 ${compact ? 'p-2' : 'p-3'}`}>
{/* Header row */}
<div className="flex items-center gap-2 mb-1.5">
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${statusColors[job.status] || statusColors.queued}`}>
{job.status === 'downloading' ? `${pct.toFixed(0)}%` : job.status.toUpperCase()}
</span>
<span className={`${compact ? 'text-xs' : 'text-sm'} text-zinc-700 dark:text-zinc-300 font-medium truncate flex-1`}>
{job.filename}
</span>
<div className="flex items-center gap-1 flex-shrink-0">
{canResume && (
<button onClick={() => onResume(job.jobId)} title={t('models.resume')}
className="p-1 rounded-lg hover:bg-white/10 text-amber-400 hover:text-amber-300 transition-colors">
<Play size={12} />
</button>
)}
{isActive && (
<button onClick={() => onCancel(job.jobId)} title={t('common.cancel')}
className="p-1 rounded-lg hover:bg-white/10 text-zinc-500 hover:text-red-400 transition-colors">
<X size={12} />
</button>
)}
</div>
</div>
{/* Progress bar */}
<div className="h-1.5 rounded-full bg-zinc-200 dark:bg-zinc-700/50 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${
isActive ? 'bg-gradient-to-r from-sky-500 to-sky-400' :
job.status === 'completed' ? 'bg-emerald-500' :
job.status === 'paused' ? 'bg-amber-500' :
'bg-zinc-600'
}`}
style={{ width: `${pct}%` }}
/>
</div>
{/* Stats row */}
<div className="flex items-center justify-between mt-1 text-[10px] text-zinc-500">
<span>{formatSize(job.bytesDownloaded)} / {formatSize(job.totalBytes)}</span>
{isActive && job.speed > 0 && (
<span>{formatSpeed(job.speed)} · ETA {formatEta(remaining, job.speed)}</span>
)}
{job.status === 'failed' && job.error && (
<span className="text-red-400 truncate ml-2">{job.error}</span>
)}
</div>
</div>
);
};
@@ -0,0 +1,460 @@
// ModelCatalogueTab.tsx — Tabbed model catalogue browser
import React, { useState, useMemo } from 'react';
import { ChevronDown, ChevronRight, Download, ExternalLink, Info, KeyRound, ShieldCheck } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { ModelRow } from './ModelRow';
import { usePersistedState } from '../../hooks/usePersistedState';
import type { RegistryFile, DownloadJob } from '../../types';
interface Props {
files: RegistryFile[];
downloadJobs: DownloadJob[];
onDownload: (fileId: string) => void;
onCancel: (jobId: string) => void;
onResume: (jobId: string) => void;
onDelete: (filename: string) => void;
}
type RoleTab = 'dit' | 'lm' | 'embedding' | 'vae' | 'pp-vae' | 'stablestep' | 'supersep' | 'whisper';
const TABS: { id: RoleTab; label: string }[] = [
{ id: 'dit', label: 'DiT Models' },
{ id: 'lm', label: 'Language Models' },
{ id: 'embedding', label: 'Text Encoder' },
{ id: 'vae', label: 'VAE' },
{ id: 'pp-vae', label: 'PP-VAE' },
{ id: 'stablestep', label: 'StableStep' },
{ id: 'supersep', label: 'Stem Separation' },
{ id: 'whisper', label: 'Whisper' },
];
// ── Info blocks per category ────────────────────────────────
const DIT_INFO: Record<string, string> = {
'Standard (2B)': 'Standard 2-billion parameter DiT models. Seven variants with different speed/quality trade-offs. Turbo is the fastest (8 steps), SFT has best lyric adherence (32-50 steps), Base offers maximum creative range (60-100 steps).',
'XL (4B)': 'XL 4-billion parameter DiT models — double the parameters for richer, more detailed audio. Same variant structure as Standard but with noticeably better quality.',
'XL Merges (Task Arithmetic)': 'Custom blended XL models created by merging two parent checkpoints using task arithmetic. The λ value controls the blend ratio. These use base-mode scheduling (60-100 steps).',
'MXFP4 (Blackwell Optimized)': 'Microscaling FP4 quantised models — 3.7x compression with native FP4 Tensor Core acceleration on RTX 5000 series GPUs. On older GPUs, they still work via software fallback with the same quality and compression.',
};
const ROLE_INFO: Record<string, string> = {
lm: 'The Language Model generates audio codes and musical structure from your text prompt. Larger models (4B) produce better quality but use more VRAM. The 4B Q8 is recommended for most users.',
embedding: 'The text encoder (Qwen3 Embedding) converts your caption and lyrics into embeddings for the DiT. It is architecturally locked — all DiT models were trained with this exact encoder. You need exactly one.',
vae: 'The VAE (Variational Autoencoder) decodes the DiT\'s latent output into audio waveforms. The standard VAE is required for all generation. ScragVAE is a fine-tuned decoder with improved high-frequency response — it\'s a drop-in replacement.',
'pp-vae': 'The Post-Processing VAE performs a neural audio polish pass — running generated audio through an encode→decode round-trip to smooth artifacts and improve tonal coherence. Optional but recommended. Use F32 for best quality.',
stablestep: 'Stable Audio 3 refiner models for the StableStep post-processing feature. StableStep re-renders the instrumental through Stable Audio 3 to replace VAE fizz with real detail; vocals are split out, cleaned with PP-VAE, and remixed. Two engine backends are available — install either (or both): the GGML backend (4 GGUF files, ~5.8 GB) runs on CUDA, Vulkan or CPU and is the fastest option on NVIDIA in current testing; the ONNX backend (~12 GB, fp32) runs via TensorRT on NVIDIA only and is slow on first use while the TensorRT engine builds (one-time per length bucket). The tokenizer files from the ONNX set are required by BOTH backends. Powered by Stability AI.',
supersep: 'Stem separation models for Cover Studio. Uses a 4-stage ONNX pipeline: BS-Roformer splits audio into 6 stems, Mel-Band RoFormer separates lead/backing vocals, MDX23C isolates drum components, and HTDemucs refines the "other" stem. All 4 models are required for full separation. Models run via ONNX Runtime GPU — no Python needed.',
whisper: 'OpenAI Whisper models for transcribing actual sung lyrics with word-level timestamps. Enable Whisper Lyrics in Post-Processing to use.',
};
// ── Grouping logic ──────────────────────────────────────────
interface ModelGroup {
name: string;
info?: string;
files: RegistryFile[];
}
function groupDitFiles(files: RegistryFile[]): ModelGroup[] {
const ditFiles = files.filter(f => f.role === 'dit');
const standard = ditFiles.filter(f => f.scale === 'standard' && f.quant !== 'MXFP4');
const xl = ditFiles.filter(f => f.scale === 'xl' && !f.variant?.startsWith('merge-') && f.quant !== 'MXFP4');
const xlMerges = ditFiles.filter(f => f.scale === 'xl' && f.variant?.startsWith('merge-') && f.quant !== 'MXFP4');
const mxfp4 = ditFiles.filter(f => f.quant === 'MXFP4');
const groups: ModelGroup[] = [];
if (standard.length) groups.push({ name: 'Standard (2B)', info: DIT_INFO['Standard (2B)'], files: standard });
if (xl.length) groups.push({ name: 'XL (4B)', info: DIT_INFO['XL (4B)'], files: xl });
if (xlMerges.length) groups.push({ name: 'XL Merges (Task Arithmetic)', info: DIT_INFO['XL Merges (Task Arithmetic)'], files: xlMerges });
if (mxfp4.length) groups.push({ name: 'MXFP4 (Blackwell Optimized)', info: DIT_INFO['MXFP4 (Blackwell Optimized)'], files: mxfp4 });
return groups;
}
function groupLmFiles(files: RegistryFile[]): ModelGroup[] {
const lmFiles = files.filter(f => f.role === 'lm');
const sizes = ['4B', '1.7B', '0.6B'];
return sizes
.map(s => ({
name: `${s} Parameters`,
files: lmFiles.filter(f => f.variant === s),
}))
.filter(g => g.files.length > 0);
}
// ── Collapsible group component ─────────────────────────────
const CollapsibleGroup: React.FC<{
group: ModelGroup;
downloadJobs: DownloadJob[];
onDownload: (fileId: string) => void;
onCancel: (jobId: string) => void;
onResume: (jobId: string) => void;
onDelete: (filename: string) => void;
defaultOpen?: boolean;
}> = ({ group, downloadJobs, onDownload, onCancel, onResume, onDelete, defaultOpen = false }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(defaultOpen);
const [showInfo, setShowInfo] = useState(false);
const installed = group.files.filter(f => f.installed).length;
return (
<div className="rounded-xl border border-zinc-200 dark:border-white/5 bg-zinc-50/80 dark:bg-zinc-900/50 overflow-hidden">
{/* Group header */}
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-2 px-4 py-3 hover:bg-white/[0.02] transition-colors"
>
{open ? <ChevronDown size={14} className="text-zinc-500" /> : <ChevronRight size={14} className="text-zinc-500" />}
<span className="text-sm font-semibold text-zinc-700 dark:text-zinc-300">{group.name}</span>
<span className="text-[10px] text-zinc-600 font-mono">
{installed}/{group.files.length} installed
</span>
{group.info && (
<button
onClick={(e) => { e.stopPropagation(); setShowInfo(!showInfo); }}
className="ml-auto p-1 rounded-lg hover:bg-white/5 text-zinc-600 hover:text-zinc-600 dark:text-zinc-400 transition-colors"
title={t('models.aboutCategory')}
>
<Info size={13} />
</button>
)}
</button>
{/* Info panel */}
{showInfo && group.info && (
<div className="px-4 py-2.5 bg-zinc-100/50 dark:bg-zinc-800/50 border-t border-zinc-200 dark:border-white/5 text-xs text-zinc-600 dark:text-zinc-400 leading-relaxed">
{group.info}
</div>
)}
{/* File list */}
{open && (
<div className="px-3 pb-3 space-y-1.5">
{group.files.map(f => (
<ModelRow
key={f.id}
file={f}
downloadJob={downloadJobs.find(j => j.fileId === f.id && j.status !== 'completed' && j.status !== 'cancelled')}
onDownload={onDownload}
onCancel={onCancel}
onResume={onResume}
onDelete={onDelete}
/>
))}
</div>
)}
</div>
);
};
// ── StableStep tab (license gate + optional HF token) ───────
const STABLESTEP_LICENSE_TEXT =
"These weights are derived from Stability AI's Stable Audio 3 and are licensed under the " +
'Stability AI Community License (free for individuals and organizations under $1M annual ' +
'revenue; commercial use above that requires a license from Stability AI).';
const StableStepTab: React.FC<{
files: RegistryFile[];
downloadJobs: DownloadJob[];
onDownload: (fileId: string) => void;
onCancel: (jobId: string) => void;
onResume: (jobId: string) => void;
onDelete: (filename: string) => void;
}> = ({ files, downloadJobs, onDownload, onCancel, onResume, onDelete }) => {
// License acceptance is persisted so it's asked once.
const [licenseAccepted, setLicenseAccepted] = usePersistedState('hs-stablestepLicenseAccepted', false);
// Optional Hugging Face token — forwarded as `Authorization: Bearer <token>`
// on huggingface.co requests (only needed if the repo is gated).
const [hfToken, setHfToken] = usePersistedState('hs-hfToken', '');
const [licenseNudge, setLicenseNudge] = useState(false);
const missing = files.filter(f => !f.installed);
// Two engine backends ship under the same repo: the GGUF files (models root)
// power the GGML backend; everything else is the ONNX/TensorRT set. The
// tokenizer JSONs in the ONNX set are required by BOTH backends.
const ggufFiles = files.filter(f => f.filename.endsWith('.gguf'));
const onnxFiles = files.filter(f => !f.filename.endsWith('.gguf'));
// Gate every download behind license acceptance.
const gatedDownload = (fileId: string) => {
if (!licenseAccepted) {
setLicenseNudge(true);
return;
}
onDownload(fileId);
};
const handleDownloadAll = () => {
if (!licenseAccepted) {
setLicenseNudge(true);
return;
}
for (const f of missing) onDownload(f.id);
};
return (
<div className="space-y-3">
{/* Info block */}
<div className="rounded-xl bg-zinc-100/50 dark:bg-zinc-800/50 border border-zinc-200 dark:border-white/5 px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400 leading-relaxed">
{ROLE_INFO.stablestep}
</div>
{/* License acceptance gate */}
<div className={`rounded-xl border px-4 py-3 transition-colors ${
licenseAccepted
? 'border-emerald-500/20 bg-emerald-500/5'
: licenseNudge
? 'border-amber-500/40 bg-amber-500/10'
: 'border-zinc-200 dark:border-white/5 bg-zinc-100/50 dark:bg-zinc-800/50'
}`}>
<div className="flex items-start gap-2.5">
<ShieldCheck size={15} className={`mt-0.5 flex-shrink-0 ${licenseAccepted ? 'text-emerald-400' : 'text-zinc-500'}`} />
<div className="flex-1">
<label className="flex items-start gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={licenseAccepted}
onChange={e => { setLicenseAccepted(e.target.checked); setLicenseNudge(false); }}
className="mt-0.5 h-3.5 w-3.5 rounded border-zinc-400 dark:border-zinc-600 accent-emerald-500 flex-shrink-0 cursor-pointer"
/>
<span className="text-xs text-zinc-600 dark:text-zinc-400 leading-relaxed">
{STABLESTEP_LICENSE_TEXT}
</span>
</label>
<a
href="https://stability.ai/community-license-agreement"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 mt-1.5 ml-6 text-[11px] text-sky-400 hover:text-sky-300 transition-colors"
>
<ExternalLink size={11} />
Stability AI Community License Agreement
</a>
{licenseNudge && !licenseAccepted && (
<p className="mt-1.5 ml-6 text-[11px] text-amber-400">
Please accept the license terms above before downloading.
</p>
)}
</div>
</div>
</div>
{/* Optional Hugging Face token */}
<div className="rounded-xl border border-zinc-200 dark:border-white/5 bg-zinc-100/50 dark:bg-zinc-800/50 px-4 py-3">
<label className="flex items-center gap-1.5 text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1.5">
<KeyRound size={12} />
Hugging Face token (optional)
</label>
<input
type="password"
value={hfToken}
onChange={e => setHfToken(e.target.value)}
placeholder="hf_..."
autoComplete="off"
spellCheck={false}
className="w-full px-3 py-2 rounded-xl bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 font-mono placeholder-zinc-500 focus:border-sky-500/50 focus:ring-1 focus:ring-sky-500/20 outline-none transition-colors"
/>
<p className="mt-1.5 text-[10px] text-zinc-500 leading-relaxed">
Only needed if the repository is gated on Hugging Face. Leave empty for an
anonymous download. Stored locally and sent only to huggingface.co.
</p>
</div>
{/* Download all */}
{missing.length > 0 && (
<div className="flex items-center justify-between px-1">
<span className="text-[11px] text-zinc-500">
{files.length - missing.length}/{files.length} files installed &middot; GGML set ~5.8 GB &middot; ONNX set ~12 GB
</span>
<button
onClick={handleDownloadAll}
disabled={!licenseAccepted}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
licenseAccepted
? 'bg-gradient-to-r from-pink-500 to-pink-600 text-white hover:from-pink-400 hover:to-pink-500 shadow-lg shadow-pink-500/10'
: 'bg-zinc-200 dark:bg-zinc-800 text-zinc-500 cursor-not-allowed'
}`}
title={licenseAccepted ? 'Download all missing StableStep files' : 'Accept the license first'}
>
<Download size={12} />
Download all missing ({missing.length})
</button>
</div>
)}
{/* File list — grouped by engine backend */}
<div className={`space-y-3 ${licenseAccepted ? '' : 'opacity-60'}`}>
{ggufFiles.length > 0 && (
<div className="space-y-1.5">
<div className="px-1">
<h4 className="text-xs font-semibold text-zinc-700 dark:text-zinc-300">
GGML backend (universal CUDA/Vulkan/CPU)
</h4>
<p className="text-[10px] text-zinc-500 leading-relaxed">
4 GGUF files (~5.8 GB). Fastest option on NVIDIA in current testing
and the only backend for Vulkan/CPU builds. Also requires the
tokenizer files from the ONNX set below.
</p>
</div>
{ggufFiles.map(f => (
<ModelRow
key={f.id}
file={f}
downloadJob={downloadJobs.find(j => j.fileId === f.id && j.status !== 'completed' && j.status !== 'cancelled')}
onDownload={gatedDownload}
onCancel={onCancel}
onResume={onResume}
onDelete={onDelete}
/>
))}
</div>
)}
{onnxFiles.length > 0 && (
<div className="space-y-1.5">
<div className="px-1">
<h4 className="text-xs font-semibold text-zinc-700 dark:text-zinc-300">
ONNX backend (NVIDIA TensorRT)
</h4>
<p className="text-[10px] text-zinc-500 leading-relaxed">
fp32 ONNX set (~12 GB), NVIDIA only. The tokenizer files in this
set are required by BOTH backends.
</p>
</div>
{onnxFiles.map(f => (
<ModelRow
key={f.id}
file={f}
downloadJob={downloadJobs.find(j => j.fileId === f.id && j.status !== 'completed' && j.status !== 'cancelled')}
onDownload={gatedDownload}
onCancel={onCancel}
onResume={onResume}
onDelete={onDelete}
/>
))}
</div>
)}
</div>
</div>
);
};
// ── Main component ──────────────────────────────────────────
export const ModelCatalogueTab: React.FC<Props> = ({ files, downloadJobs, onDownload, onCancel, onResume, onDelete }) => {
const [activeTab, setActiveTab] = useState<RoleTab>('dit');
const ditGroups = useMemo(() => groupDitFiles(files), [files]);
const lmGroups = useMemo(() => groupLmFiles(files), [files]);
const embeddingFiles = useMemo(() => files.filter(f => f.role === 'embedding'), [files]);
const vaeFiles = useMemo(() => files.filter(f => f.role === 'vae'), [files]);
const ppVaeFiles = useMemo(() => files.filter(f => f.role === 'pp-vae'), [files]);
const stablestepFiles = useMemo(() => files.filter(f => f.role === 'stablestep'), [files]);
const supersepFiles = useMemo(() => files.filter(f => f.role === 'supersep'), [files]);
const whisperFiles = useMemo(() => files.filter(f => f.role === 'whisper'), [files]);
const renderSimpleGroup = (roleFiles: RegistryFile[], info?: string) => (
<div className="space-y-3">
{info && (
<div className="rounded-xl bg-zinc-100/50 dark:bg-zinc-800/50 border border-zinc-200 dark:border-white/5 px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400 leading-relaxed">
{info}
</div>
)}
<div className="space-y-1.5">
{roleFiles.map(f => (
<ModelRow
key={f.id}
file={f}
downloadJob={downloadJobs.find(j => j.fileId === f.id && j.status !== 'completed' && j.status !== 'cancelled')}
onDownload={onDownload}
onCancel={onCancel}
onResume={onResume}
onDelete={onDelete}
/>
))}
</div>
</div>
);
return (
<div>
{/* Tab bar */}
<div className="flex gap-1 border-b border-zinc-200 dark:border-white/5 mb-4">
{TABS.map(tab => {
const count = files.filter(f => f.role === tab.id).length;
const installedCount = files.filter(f => f.role === tab.id && f.installed).length;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2.5 text-xs font-medium transition-colors border-b-2 -mb-px ${
activeTab === tab.id
? 'text-pink-400 border-pink-500'
: 'text-zinc-500 border-transparent hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
{tab.label}
<span className="ml-1.5 text-[10px] text-zinc-600 font-mono">{installedCount}/{count}</span>
</button>
);
})}
</div>
{/* Tab content */}
{activeTab === 'dit' && (
<div className="space-y-3">
{ditGroups.map(g => (
<CollapsibleGroup
key={g.name}
group={g}
downloadJobs={downloadJobs}
onDownload={onDownload}
onCancel={onCancel}
onResume={onResume}
onDelete={onDelete}
/>
))}
</div>
)}
{activeTab === 'lm' && (
<div className="space-y-3">
<div className="rounded-xl bg-zinc-100/50 dark:bg-zinc-800/50 border border-zinc-200 dark:border-white/5 px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400 leading-relaxed">
{ROLE_INFO.lm}
</div>
{lmGroups.map(g => (
<CollapsibleGroup
key={g.name}
group={g}
downloadJobs={downloadJobs}
onDownload={onDownload}
onCancel={onCancel}
onResume={onResume}
onDelete={onDelete}
defaultOpen
/>
))}
</div>
)}
{activeTab === 'embedding' && renderSimpleGroup(embeddingFiles, ROLE_INFO.embedding)}
{activeTab === 'vae' && renderSimpleGroup(vaeFiles, ROLE_INFO.vae)}
{activeTab === 'pp-vae' && renderSimpleGroup(ppVaeFiles, ROLE_INFO['pp-vae'])}
{activeTab === 'stablestep' && (
<StableStepTab
files={stablestepFiles}
downloadJobs={downloadJobs}
onDownload={onDownload}
onCancel={onCancel}
onResume={onResume}
onDelete={onDelete}
/>
)}
{activeTab === 'supersep' && renderSimpleGroup(supersepFiles, ROLE_INFO.supersep)}
{activeTab === 'whisper' && renderSimpleGroup(whisperFiles, ROLE_INFO.whisper)}
</div>
);
};
@@ -0,0 +1,226 @@
// ModelManagerModal.tsx — Full-screen model manager modal
//
// Entry point for browsing, downloading, and managing GGUF models.
// Features starter packs at the top and a full tabbed catalogue below.
import React, { useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { X, HardDrive, FolderOpen, Download } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useModelRegistry } from './useModelRegistry';
import { useDownloadStream } from './useDownloadStream';
import { StarterPackCard } from './StarterPackCard';
import { ModelCatalogueTab } from './ModelCatalogueTab';
import { DownloadProgressBar } from './DownloadProgressBar';
import { modelManagerApi } from '../../services/api';
interface Props {
onClose: () => void;
}
function formatSize(bytes: number): string {
if (bytes >= 1_073_741_824) return (bytes / 1_073_741_824).toFixed(1) + ' GB';
if (bytes >= 1_048_576) return (bytes / 1_048_576).toFixed(0) + ' MB';
return (bytes / 1024).toFixed(0) + ' KB';
}
/** Read the persisted Hugging Face token (set on the StableStep tab).
* Forwarded with every download request; the server only sends it to
* huggingface.co and only when non-empty (gated repos). */
function getStoredHfToken(): string | undefined {
try {
const raw = localStorage.getItem('hs-hfToken');
const token = raw !== null ? JSON.parse(raw) : '';
return typeof token === 'string' && token.trim() ? token.trim() : undefined;
} catch {
return undefined;
}
}
export const ModelManagerModal: React.FC<Props> = ({ onClose }) => {
const { t } = useTranslation();
const { registry, loading, error, silentRefresh, getPackFiles, installedFiles } = useModelRegistry();
const { jobs, hasActiveDownloads } = useDownloadStream({
onComplete: silentRefresh,
});
// ── Actions ─────────────────────────────────────────────────
const handleDownload = useCallback(async (fileId: string) => {
try {
await modelManagerApi.download(fileId, getStoredHfToken());
} catch (err: any) {
console.error('[ModelManager] Download failed:', err);
}
}, []);
const handleDownloadPack = useCallback(async (fileIds: string[]) => {
for (const id of fileIds) {
try {
await modelManagerApi.download(id, getStoredHfToken());
} catch (err: any) {
console.error('[ModelManager] Pack download failed:', err);
}
}
}, []);
const handleCancel = useCallback(async (jobId: string) => {
try {
await modelManagerApi.cancel(jobId);
} catch (err: any) {
console.error('[ModelManager] Cancel failed:', err);
}
}, []);
const handleResume = useCallback(async (jobId: string) => {
try {
await modelManagerApi.resume(jobId);
} catch (err: any) {
console.error('[ModelManager] Resume failed:', err);
}
}, []);
const handleDelete = useCallback(async (filename: string) => {
try {
await modelManagerApi.deleteFile(filename);
silentRefresh();
} catch (err: any) {
console.error('[ModelManager] Delete failed:', err);
}
}, [silentRefresh]);
// ── Computed ────────────────────────────────────────────────
const activeJobs = useMemo(() =>
jobs.filter(j => j.status === 'downloading' || j.status === 'queued' || j.status === 'paused' || j.status === 'failed'),
[jobs]
);
const totalInstalled = installedFiles.size;
const totalDiskUsage = useMemo(() => {
if (!registry) return 0;
return registry.files
.filter(f => f.installed)
.reduce((a, f) => a + f.sizeBytes, 0);
}, [registry]);
// ── Render ──────────────────────────────────────────────────
return createPortal(
<div className="fixed inset-0 z-50 flex items-start justify-center">
{/* Backdrop — does NOT close on click; only the X button closes the modal */}
<div className="absolute inset-0 bg-black/20 dark:bg-black/40 dark:bg-black/70 backdrop-blur-sm" />
{/* Modal */}
<div className="relative w-full max-w-5xl max-h-[90vh] mt-[5vh] mx-4 rounded-2xl bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-white/10 shadow-2xl flex flex-col overflow-hidden">
{/* ── Header ───────────────────────────────────────── */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-white/5 flex-shrink-0">
<div>
<h2 className="text-lg font-bold text-zinc-100 flex items-center gap-2">
<HardDrive size={18} className="text-pink-400" />
Model Manager
</h2>
<p className="text-xs text-zinc-500 mt-0.5">{t('models.subtitle')}</p>
</div>
<button onClick={onClose}
className="p-2 rounded-xl hover:bg-white/5 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors">
<X size={18} />
</button>
</div>
{/* ── Scrollable content ───────────────────────────── */}
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-6 scrollbar-thin">
{/* Loading/Error states */}
{loading && (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-pink-500 border-t-transparent" />
</div>
)}
{error && (
<div className="rounded-xl bg-red-500/10 border border-red-500/20 px-4 py-3 text-sm text-red-400">
Failed to load model registry: {error}
</div>
)}
{registry && !loading && (
<>
{/* ── Active Downloads Banner ──────────────── */}
{activeJobs.length > 0 && (
<div className="rounded-2xl border border-sky-500/20 bg-sky-500/5 p-4">
<h3 className="text-xs font-semibold text-sky-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<Download size={13} className="animate-bounce" />
{activeJobs.length} download{activeJobs.length > 1 ? 's' : ''} in progress
</h3>
<div className="space-y-2">
{activeJobs.map(job => (
<DownloadProgressBar
key={job.jobId}
job={job}
onCancel={handleCancel}
onResume={handleResume}
compact
/>
))}
</div>
</div>
)}
{/* ── Starter Packs ────────────────────────── */}
<div>
<h3 className="text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-1">{t('models.starterPacks')}</h3>
<p className="text-xs text-zinc-600 mb-4">{t('models.starterPacksDesc')}</p>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3">
{registry.packs.map(pack => (
<StarterPackCard
key={pack.id}
pack={pack}
files={getPackFiles(pack.id)}
downloadJobs={jobs}
onDownloadPack={handleDownloadPack}
/>
))}
</div>
</div>
{/* ── Divider ──────────────────────────────── */}
<div className="border-t border-zinc-200 dark:border-white/5" />
{/* ── Full Catalogue ────────────────────────── */}
<div>
<h3 className="text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-1">{t('models.allModels')}</h3>
<p className="text-xs text-zinc-600 mb-4">{t('models.allModelsDesc', { count: registry.files.length })}</p>
<ModelCatalogueTab
files={registry.files}
downloadJobs={jobs}
onDownload={handleDownload}
onCancel={handleCancel}
onResume={handleResume}
onDelete={handleDelete}
/>
</div>
</>
)}
</div>
{/* ── Footer ──────────────────────────────────────── */}
<div className="flex items-center justify-between px-6 py-3 border-t border-zinc-200 dark:border-white/5 bg-white dark:bg-zinc-900/80 flex-shrink-0">
<div className="flex items-center gap-4 text-[10px] text-zinc-600 font-mono">
<span className="flex items-center gap-1">
<FolderOpen size={11} />
{registry?.modelsDir || '—'}
</span>
<span>{totalInstalled} {t('models.installed').toLowerCase()}</span>
<span>{formatSize(totalDiskUsage)} {t('models.onDisk', { size: '' }).trim()}</span>
</div>
{hasActiveDownloads && (
<span className="text-[10px] text-sky-400 animate-pulse">{t('models.downloadsActive')}</span>
)}
</div>
</div>
</div>,
document.body
);
};

Some files were not shown because too many files have changed in this diff Show More