From cadd51c3a4905e81b015085edbc153d69c32942d Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:51:16 -0700 Subject: [PATCH] feat: integrate K.G.One music generation panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConfigManager: add `general.kgone` config block (enabled toggle + base URL); add `setKGOneManagedByServer()` / `isKGOneServerManaged()` for managed deployments that supply a `kgone-server.txt` override - App.tsx: probe for `kgone-server.txt` on startup (Content-Type guard to avoid Vite SPA fallback false-positive) - GeneralSettings: new K.G.One section with enable toggle + base URL input; fields disabled when server-managed - Toolbar: magic wand button (FaWandMagicSparkles) that toggles the panel; disabled with reduced opacity when K.G.One is turned off; mutually exclusive with the Chat panel - projectStore: add `showKGOnePanel` state + `toggleKGOnePanel` action - KGOnePanel (new): three-tab panel (Clip / Full Song / Separator) - Clip: prompt + advanced params → POST /v1/clip/generate → poll /v1/clip/result → download WAV → AudioPlayer preview - Full Song: caption + lyrics + advanced params → POST /v1/fullsong/generate → poll /v1/fullsong/result (live progress % from nested JSON string) → download MP3 → AudioPlayer preview - Separator: reads selected audio region from OPFS via KGAudioFileStorage → POST /v1/separator/separate (multipart) → poll /v1/separator/result → parallel-download all stem files → multiple labeled AudioPlayer instances rendered vertically - Shared: custom AudioPlayer (play/pause, clickable seek bar), spinner + hint text, error card, AbortController cleanup, DEBUG_MODE.KGONE-gated REQ/RES console logging --- public/config.json | 4 + src/App.tsx | 19 +- src/components/KGOnePanel.css | 347 ++++++ src/components/KGOnePanel.tsx | 1039 +++++++++++++++++ src/components/Toolbar.tsx | 24 +- .../settings/sections/GeneralSettings.tsx | 62 + src/constants/uiConstants.ts | 5 +- src/core/config/ConfigManager.ts | 24 + src/stores/projectStore.ts | 16 +- 9 files changed, 1534 insertions(+), 6 deletions(-) create mode 100644 src/components/KGOnePanel.css create mode 100644 src/components/KGOnePanel.tsx diff --git a/public/config.json b/public/config.json index 044f135..e63d612 100644 --- a/public/config.json +++ b/public/config.json @@ -28,6 +28,10 @@ }, "soundfont": { "base_url": "https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/" + }, + "kgone": { + "enabled": false, + "base_url": "http://127.0.0.1:8000" } }, "hotkeys": { diff --git a/src/App.tsx b/src/App.tsx index 365fb93..4426ebd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import InstrumentSelection from './components/InstrumentSelection'; import ChatBox from './components/ChatBox'; import { SettingsPanel } from './components/settings'; import LoadingOverlay from './components/common/LoadingOverlay'; +import KGOnePanel from './components/KGOnePanel'; import { useEffect as useEffectReact, useState, useRef } from 'react'; import { KGToneBuffersPool } from './core/audio-interface/KGToneBuffersPool'; import { KGOfflineRenderer } from './core/audio-interface/KGOfflineRenderer'; @@ -26,7 +27,7 @@ function App() { const { refreshStatus, loadProject, showChatBox, showSettings, setShowSettings, initializeFromConfig, - showInstrumentSelection + showInstrumentSelection, showKGOnePanel } = useProjectStore(); // Track if app has been initialized to prevent multiple initializations @@ -47,6 +48,21 @@ function App() { // Initialize ConfigManager first to load config.json and user settings await ConfigManager.instance().initialize(); + // Check for kgone-server.txt (managed deployment override) + try { + const kgoneServerResponse = await fetch(`${import.meta.env.BASE_URL}kgone-server.txt`); + const contentType = kgoneServerResponse.headers.get('Content-Type') ?? ''; + if (kgoneServerResponse.ok && contentType.includes('text/plain')) { + const url = (await kgoneServerResponse.text()).trim(); + if (url) { + ConfigManager.instance().setKGOneManagedByServer(url); + console.log('K.G.One: server-managed config loaded from kgone-server.txt, base URL:', url); + } + } + } catch { + // File not present — user configures manually + } + // Initialize store from config after ConfigManager is ready await initializeFromConfig(); @@ -134,6 +150,7 @@ function App() { )} + diff --git a/src/components/KGOnePanel.css b/src/components/KGOnePanel.css new file mode 100644 index 0000000..ec08d1e --- /dev/null +++ b/src/components/KGOnePanel.css @@ -0,0 +1,347 @@ +/* KGOnePanel — mirrors ChatBox layout and style */ +.kgone-panel { + display: flex; + flex-direction: column; + width: var(--chat-box-width); + background-color: #2d2d2d; + border-left: 1px solid #3a3a3a; + flex-shrink: 0; + overflow: hidden; +} + +.kgone-panel.is-hidden { + display: none; +} + +/* Header */ +.kgone-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + background-color: #3a3a3a; + height: 40px; + border-bottom: 1px solid #4a4a4a; + padding: 0 15px; + flex-shrink: 0; +} + +.kgone-panel-header h3 { + color: #e0e0e0; + font-size: 12px; + font-weight: bold; + margin: 0; +} + +/* Tab switcher */ +.kgone-tabs { + display: flex; + background-color: #2d2d2d; + border-bottom: 1px solid #3a3a3a; + flex-shrink: 0; +} + +.kgone-tab { + flex: 1; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: #999; + font-size: 11px; + font-weight: 500; + padding: 8px 4px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} + +.kgone-tab:hover { + color: #ccc; +} + +.kgone-tab.active { + color: #e0e0e0; + border-bottom-color: #7a9cff; +} + +/* Scrollable body */ +.kgone-panel-body { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +/* Field groups */ +.kgone-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.kgone-label { + color: #aaa; + font-size: 11px; + font-weight: 500; +} + +.kgone-input, +.kgone-textarea, +.kgone-select { + background-color: #3a3a3a; + border: 1px solid #4a4a4a; + border-radius: 4px; + color: #e0e0e0; + font-size: 12px; + padding: 6px 8px; + outline: none; + width: 100%; + box-sizing: border-box; +} + +.kgone-input:focus, +.kgone-textarea:focus, +.kgone-select:focus { + border-color: #5a6aaa; +} + +.kgone-textarea { + resize: vertical; + min-height: 60px; + font-family: inherit; +} + +.kgone-input::placeholder, +.kgone-textarea::placeholder { + color: #666; +} + +/* Hint text below textarea */ +.kgone-hint { + color: #666; + font-size: 10px; + line-height: 1.4; + margin-top: 2px; +} + +/* Two-column row (e.g. note + scale) */ +.kgone-row { + display: flex; + gap: 8px; +} + +.kgone-row .kgone-field { + flex: 1; +} + +/* Advanced expander */ +.kgone-expander-toggle { + background: transparent; + border: none; + color: #888; + font-size: 11px; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 0; +} + +.kgone-expander-toggle:hover { + color: #bbb; +} + +.kgone-expander-toggle .arrow { + font-size: 9px; + transition: transform 0.15s; +} + +.kgone-expander-toggle .arrow.open { + transform: rotate(90deg); +} + +.kgone-expander-body { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 0 0 0; +} + +/* Separator tab */ +.kgone-separator-hint { + color: #888; + font-size: 11px; + line-height: 1.5; + text-align: center; + padding: 20px 8px; + background-color: #252525; + border-radius: 6px; +} + +.kgone-region-info { + background-color: #252525; + border: 1px solid #3a3a3a; + border-radius: 6px; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.kgone-region-info-label { + color: #777; + font-size: 10px; +} + +.kgone-region-info-value { + color: #e0e0e0; + font-size: 11px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Generate / Separate button */ +.kgone-btn-generate { + background-color: #4a5fa0; + border: none; + border-radius: 4px; + color: #e0e0e0; + font-size: 12px; + font-weight: 600; + padding: 8px 12px; + cursor: pointer; + width: 100%; + margin-top: 6px; + transition: background-color 0.15s; +} + +.kgone-btn-generate:hover:not(:disabled) { + background-color: #5a70b8; +} + +.kgone-btn-generate:disabled { + background-color: #3a3a3a; + color: #666; + cursor: not-allowed; +} + +/* ── Audio player ──────────────────────────────────────────────────────────── */ +.kgone-audio-player { + display: flex; + align-items: center; + gap: 8px; + background-color: #252525; + border: 1px solid #3a3a3a; + border-radius: 6px; + padding: 8px 10px; +} + +.kgone-player-play-btn { + background: transparent; + border: none; + color: #e0e0e0; + cursor: pointer; + font-size: 13px; + padding: 2px 4px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.kgone-player-play-btn:hover { + color: #7a9cff; +} + +.kgone-player-progress-track { + flex: 1; + height: 4px; + background-color: #3a3a3a; + border-radius: 2px; + cursor: pointer; + position: relative; +} + +.kgone-player-progress-track:hover { + height: 6px; + margin-top: -1px; +} + +.kgone-player-progress-fill { + height: 100%; + background-color: #7a9cff; + border-radius: 2px; + pointer-events: none; + transition: width 0.1s linear; +} + +.kgone-player-time { + color: #888; + font-size: 10px; + white-space: nowrap; + flex-shrink: 0; +} + +/* ── Generation state feedback ─────────────────────────────────────────────── */ +.kgone-gen-hint { + color: #888; + font-size: 10px; + text-align: center; + margin-top: -4px; +} + +.kgone-error-msg { + color: #e07a7a; + font-size: 11px; + background-color: #2a1a1a; + border: 1px solid #5a2a2a; + border-radius: 4px; + padding: 6px 8px; + word-break: break-word; +} + +/* Spinner on button */ +.kgone-spinner { + animation: kgone-spin 0.8s linear infinite; + margin-right: 6px; +} + +@keyframes kgone-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Stem players (Separator tab) */ +.kgone-stems { + display: flex; + flex-direction: column; + gap: 8px; +} + +.kgone-stem-player { + display: flex; + flex-direction: column; +} + +/* Checkbox row (instrumental toggle) */ +.kgone-checkbox-row { + display: flex; + align-items: center; + gap: 8px; +} + +.kgone-checkbox-row input[type="checkbox"] { + width: 14px; + height: 14px; + accent-color: #7a9cff; + cursor: pointer; +} + +.kgone-checkbox-row label { + color: #aaa; + font-size: 11px; + cursor: pointer; + user-select: none; +} diff --git a/src/components/KGOnePanel.tsx b/src/components/KGOnePanel.tsx new file mode 100644 index 0000000..7cd8d9e --- /dev/null +++ b/src/components/KGOnePanel.tsx @@ -0,0 +1,1039 @@ +import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'; +import './KGOnePanel.css'; +import { FaPlay, FaPause } from 'react-icons/fa'; +import { FaCircleNotch } from 'react-icons/fa6'; +import { useProjectStore } from '../stores/projectStore'; +import { KGCore } from '../core/KGCore'; +import { KGAudioRegion } from '../core/region/KGAudioRegion'; +import { KGAudioFileStorage } from '../core/io/KGAudioFileStorage'; +import { ConfigManager } from '../core/config/ConfigManager'; +import { DEBUG_MODE } from '../constants/uiConstants'; +import type { KeySignature } from '../core/KGProject'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type Tab = 'clip' | 'fullsong' | 'separator'; + +type GenStatus = 'idle' | 'loading-model' | 'generating' | 'polling' | 'downloading' | 'done' | 'error'; + +const SEPARATOR_MODELS = [ + { label: 'Vocal and Instrument (High Accuracy)', value: 'UVR-MDX-NET-Inst_HQ_3.onnx' }, + { label: 'Vocal and Instrument (Medium Accuracy)', value: 'MDX23C-8KFFT-InstVoc_HQ.ckpt' }, + { label: 'Vocal, Drums, Bass, Guitar, Piano, and Others', value: 'htdemucs_6s.yaml' }, +] as const; + +const CLIP_NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + +const FLAT_TO_SHARP: Record = { + Bb: 'A#', Eb: 'D#', Ab: 'G#', Db: 'C#', Gb: 'F#', Cb: 'B', Fb: 'E', +}; + +function parseKeySignature(ks: string): { note: string; scale: 'major' | 'minor' } { + const parts = ks.split(' '); + const rawNote = parts[0] ?? 'C'; + const note = FLAT_TO_SHARP[rawNote] ?? rawNote; + const scale = (parts[1] === 'minor' ? 'minor' : 'major') as 'major' | 'minor'; + return { note, scale }; +} + +// Debug helper — logs KGOne requests and responses when DEBUG_MODE.KGONE is on +function kgoneLog(direction: 'REQ' | 'RES', label: string, payload: unknown) { + if (!DEBUG_MODE.KGONE) return; + console.log(`[KGOne ${direction}] ${label}`, payload); +} + +function getKGOneBaseUrl(): string { + return (ConfigManager.instance().get('general.kgone.base_url') as string) || 'http://127.0.0.1:8000'; +} + +function formatTime(sec: number): string { + if (!isFinite(sec)) return '0:00'; + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + return `${m}:${s.toString().padStart(2, '0')}`; +} + +// ─── Shared components ──────────────────────────────────────────────────────── + +interface ExpanderProps { + label: string; + children: React.ReactNode; +} + +const Expander: React.FC = ({ label, children }) => { + const [open, setOpen] = useState(false); + return ( +
+ + {open &&
{children}
} +
+ ); +}; + +// ─── Audio Player ───────────────────────────────────────────────────────────── + +interface AudioPlayerProps { + src: string; +} + +const AudioPlayer: React.FC = ({ src }) => { + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + + const togglePlay = () => { + const audio = audioRef.current; + if (!audio) return; + if (audio.paused) { + audio.play().catch(() => {}); + } else { + audio.pause(); + } + }; + + const handleProgressClick = (e: React.MouseEvent) => { + const audio = audioRef.current; + if (!audio || duration === 0) return; + const rect = e.currentTarget.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + audio.currentTime = ratio * duration; + setCurrentTime(ratio * duration); + }; + + const progress = duration > 0 ? (currentTime / duration) * 100 : 0; + + return ( +
+