diff --git a/src/components/KGOnePanel.tsx b/src/components/KGOnePanel.tsx index 386aa4a..c772e8a 100644 --- a/src/components/KGOnePanel.tsx +++ b/src/components/KGOnePanel.tsx @@ -1,4 +1,5 @@ import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'; +import * as Tone from 'tone'; import './KGOnePanel.css'; import { FaPlay, FaPause, FaDownload } from 'react-icons/fa'; import { FaCircleNotch, FaGripVertical } from 'react-icons/fa6'; @@ -8,7 +9,10 @@ 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 { fetchWithRetry } from '../util/retryUtil'; import type { KeySignature } from '../core/KGProject'; +import { ImportStemsCommand } from '../core/commands'; +import type { StemImportEntry } from '../core/commands'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -17,8 +21,8 @@ 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 and Instrument (Medium Accuracy)', value: 'UVR-MDX-NET-Inst_HQ_3.onnx' }, + { label: 'Vocal and Instrument (High Accuracy)', value: 'MDX23C-8KFFT-InstVoc_HQ.ckpt' }, { label: 'Vocal, Drums, Bass, Guitar, Piano, and Others', value: 'htdemucs_6s.yaml' }, ] as const; @@ -94,7 +98,7 @@ const AudioPlayer: React.FC = ({ src, dragData }) => { const audio = audioRef.current; if (!audio) return; if (audio.paused) { - audio.play().catch(() => {}); + audio.play().catch(() => { }); } else { audio.pause(); } @@ -306,12 +310,7 @@ const ClipTab: React.FC = ({ bpm, keySignature }) => { if (signal.aborted) return; kgoneLog('REQ', `GET /v1/clip/result/${task_id}`, null); - const resultResp = await fetch(`${baseUrl}/v1/clip/result/${task_id}`, { signal }); - if (!resultResp.ok) { - const body = await resultResp.text().catch(() => ''); - kgoneLog('RES', `GET /v1/clip/result/${task_id} → ${resultResp.status}`, body); - throw new Error(`Poll failed (${resultResp.status}): ${body}`); - } + const resultResp = await fetchWithRetry(`${baseUrl}/v1/clip/result/${task_id}`, { signal }); const result = (await resultResp.json()) as { task_id: string; status: string; error?: string }; kgoneLog('RES', `GET /v1/clip/result/${task_id} → ${resultResp.status}`, result); @@ -328,11 +327,7 @@ const ClipTab: React.FC = ({ bpm, keySignature }) => { setGenHint('Downloading audio...'); kgoneLog('REQ', `GET /v1/clip/audio/${task_id}`, null); - const audioResp = await fetch(`${baseUrl}/v1/clip/audio/${task_id}`, { signal }); - if (!audioResp.ok) { - kgoneLog('RES', `GET /v1/clip/audio/${task_id} → ${audioResp.status}`, '(error)'); - throw new Error(`Audio download failed (${audioResp.status})`); - } + const audioResp = await fetchWithRetry(`${baseUrl}/v1/clip/audio/${task_id}`, { signal }); const blob = await audioResp.blob(); const url = URL.createObjectURL(blob); @@ -357,10 +352,10 @@ const ClipTab: React.FC = ({ bpm, keySignature }) => { const btnLabel = () => { switch (genStatus) { case 'loading-model': return 'Loading model...'; - case 'generating': return 'Generating...'; - case 'polling': return 'Processing...'; - case 'downloading': return 'Downloading...'; - default: return 'Generate Clip'; + case 'generating': return 'Generating...'; + case 'polling': return 'Processing...'; + case 'downloading': return 'Downloading...'; + default: return 'Generate Clip'; } }; @@ -636,12 +631,7 @@ const FullSongTab: React.FC = () => { if (signal.aborted) return; kgoneLog('REQ', `GET /v1/fullsong/result/${task_id}`, null); - const resultResp = await fetch(`${baseUrl}/v1/fullsong/result/${task_id}`, { signal }); - if (!resultResp.ok) { - const body = await resultResp.text().catch(() => ''); - kgoneLog('RES', `GET /v1/fullsong/result/${task_id} → ${resultResp.status}`, body); - throw new Error(`Poll failed (${resultResp.status}): ${body}`); - } + const resultResp = await fetchWithRetry(`${baseUrl}/v1/fullsong/result/${task_id}`, { signal }); const pollJson = (await resultResp.json()) as PollResponse; kgoneLog('RES', `GET /v1/fullsong/result/${task_id} → ${resultResp.status}`, pollJson); @@ -671,11 +661,7 @@ const FullSongTab: React.FC = () => { setGenHint('Downloading audio...'); kgoneLog('REQ', `GET /v1/fullsong/audio/${task_id}?index=0`, null); - const audioResp = await fetch(`${baseUrl}/v1/fullsong/audio/${task_id}?index=0`, { signal }); - if (!audioResp.ok) { - kgoneLog('RES', `GET /v1/fullsong/audio/${task_id}?index=0 → ${audioResp.status}`, '(error)'); - throw new Error(`Audio download failed (${audioResp.status})`); - } + const audioResp = await fetchWithRetry(`${baseUrl}/v1/fullsong/audio/${task_id}?index=0`, { signal }); const blob = await audioResp.blob(); const url = URL.createObjectURL(blob); @@ -696,10 +682,10 @@ const FullSongTab: React.FC = () => { const btnLabel = () => { switch (genStatus) { case 'loading-model': return 'Loading model...'; - case 'generating': return 'Generating...'; - case 'polling': return 'Processing...'; - case 'downloading': return 'Downloading...'; - default: return 'Generate Song'; + case 'generating': return 'Generating...'; + case 'polling': return 'Processing...'; + case 'downloading': return 'Downloading...'; + default: return 'Generate Song'; } }; @@ -818,8 +804,8 @@ function extractStemName(filename: string): string { } const SeparatorTab: React.FC = () => { - const { selectedRegionIds, projectName } = useProjectStore(); - const [model, setModel] = useState(SEPARATOR_MODELS[0].value); + const { selectedRegionIds, projectName, bpm, timeSignature, maxBars, refreshProjectState } = useProjectStore(); + const [model, setModel] = useState(SEPARATOR_MODELS[0].value); // Generation state const [genStatus, setGenStatus] = useState('idle'); @@ -829,6 +815,14 @@ const SeparatorTab: React.FC = () => { const abortRef = useRef(null); const taskIdRef = useRef(''); + const originalRegionRef = useRef<{ + regionName: string; + startFromBeat: number; + trackIndex: number; + } | null>(null); + + const [isImporting, setIsImporting] = useState(false); + const [importError, setImportError] = useState(''); // Revoke all blob URLs on unmount useEffect(() => { @@ -848,7 +842,7 @@ const SeparatorTab: React.FC = () => { selectedRegionIds.includes(region.getId()) && region.getCurrentType() === 'KGAudioRegion' ) { - return { region: region as KGAudioRegion, trackName: track.getName() }; + return { region: region as KGAudioRegion, trackName: track.getName(), trackIndex: track.getTrackIndex() }; } } } @@ -860,6 +854,14 @@ const SeparatorTab: React.FC = () => { const handleSeparate = useCallback(async () => { if (!selectedAudioRegion) return; + // Capture snapshot before anything changes — selection may shift during generation + originalRegionRef.current = { + regionName: selectedAudioRegion.region.getName(), + startFromBeat: selectedAudioRegion.region.getStartFromBeat(), + trackIndex: selectedAudioRegion.trackIndex, + }; + setImportError(''); + // Abort any in-flight request abortRef.current?.abort(); const ctrl = new AbortController(); @@ -956,12 +958,7 @@ const SeparatorTab: React.FC = () => { if (signal.aborted) return; kgoneLog('REQ', `GET /v1/separator/result/${task_id}`, null); - const resultResp = await fetch(`${baseUrl}/v1/separator/result/${task_id}`, { signal }); - if (!resultResp.ok) { - const body = await resultResp.text().catch(() => ''); - kgoneLog('RES', `GET /v1/separator/result/${task_id} → ${resultResp.status}`, body); - throw new Error(`Poll failed (${resultResp.status}): ${body}`); - } + const resultResp = await fetchWithRetry(`${baseUrl}/v1/separator/result/${task_id}`, { signal }); const pollJson = (await resultResp.json()) as SepPollResponse; kgoneLog('RES', `GET /v1/separator/result/${task_id} → ${resultResp.status}`, pollJson); @@ -983,11 +980,10 @@ const SeparatorTab: React.FC = () => { const stemResults = await Promise.all( files.map(async (filename) => { kgoneLog('REQ', `GET /v1/separator/download/${filename}`, null); - const dlResp = await fetch(`${baseUrl}/v1/separator/download/${encodeURIComponent(filename)}`, { signal }); - if (!dlResp.ok) { - kgoneLog('RES', `GET /v1/separator/download/${filename} → ${dlResp.status}`, '(error)'); - throw new Error(`Stem download failed (${dlResp.status}): ${filename}`); - } + const dlResp = await fetchWithRetry( + `${baseUrl}/v1/separator/download/${encodeURIComponent(filename)}`, + { signal } + ); const blob = await dlResp.blob(); const url = URL.createObjectURL(blob); kgoneLog('RES', `GET /v1/separator/download/${filename} → ${dlResp.status} (binary MP3)`, url); @@ -1007,13 +1003,74 @@ const SeparatorTab: React.FC = () => { } }, [selectedAudioRegion, projectName, model, stemAudioUrls]); + const handleImportAll = useCallback(async () => { + const snap = originalRegionRef.current; + if (!snap || stemAudioUrls.length === 0) return; + + setIsImporting(true); + setImportError(''); + + try { + const audioContext = Tone.getContext().rawContext as AudioContext; + + // Decode and store every stem before touching the core model + const stems: StemImportEntry[] = await Promise.all( + stemAudioUrls.map(async (stem) => { + const blob = await fetch(stem.url).then(r => r.blob()); + const fileName = `KGOne_Stem_${stem.name}_${taskIdRef.current}.mp3`; + const fileId = `kgone_stem_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + const audioFile = new File([blob], fileName, { type: 'audio/mpeg' }); + + const arrayBuffer = await blob.arrayBuffer(); + const toneBuffer = new Tone.ToneAudioBuffer(); + await new Promise((resolve, reject) => { + audioContext.decodeAudioData( + arrayBuffer.slice(0), + decoded => { toneBuffer.set(decoded); resolve(); }, + reject, + ); + }); + + await KGAudioFileStorage.storeAudioFile(projectName, fileId, audioFile); + + return { + trackName: `${snap.regionName} (${stem.name})`, + audioFileId: fileId, + audioFileName: fileName, + audioDurationSeconds: toneBuffer.duration, + toneBuffer, + }; + }) + ); + + // Execute composite command (single undo step) + const project = KGCore.instance().getCurrentProject(); + const cmd = new ImportStemsCommand( + project.getTracks().length, + snap.trackIndex, + snap.startFromBeat, + stems, + maxBars, + ); + KGCore.instance().executeCommand(cmd); + + // Sync store — triggers MainContent's useEffect to rebuild tracks + regions + refreshProjectState(); + + } catch (err) { + setImportError(err instanceof Error ? err.message : String(err)); + } finally { + setIsImporting(false); + } + }, [stemAudioUrls, projectName, maxBars, refreshProjectState]); + const btnLabel = () => { switch (genStatus) { case 'loading-model': return 'Loading model...'; - case 'generating': return 'Preparing upload...'; - case 'polling': return 'Separating stems...'; - case 'downloading': return 'Downloading...'; - default: return 'Separate Stems'; + case 'generating': return 'Preparing upload...'; + case 'polling': return 'Separating stems...'; + case 'downloading': return 'Downloading...'; + default: return 'Separate Stems'; } }; @@ -1030,7 +1087,8 @@ const SeparatorTab: React.FC = () => {
- setModel(e.target.value as typeof SEPARATOR_MODELS[number]['value'])}> + {SEPARATOR_MODELS.map(m => ( ))} @@ -1062,7 +1120,23 @@ const SeparatorTab: React.FC = () => {
)} - {/* Error message */} + {/* Bulk import button — shown after successful separation */} + {genStatus === 'done' && stemAudioUrls.length > 0 && ( + <> + + {importError &&
{importError}
} + + )} + + {/* Separation error message */} {genStatus === 'error' && errorMsg && (
{errorMsg}
)} diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index f1df80e..67dacd3 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -246,6 +246,14 @@ const MainContent: React.FC = ({ if (!track) return; updateTrack(track); setSelectedTrack(track.getId().toString()); + + // Sync maxBars from core model — ImportAudioCommand may have expanded it + const coreMaxBars = KGCore.instance().getCurrentProject().getMaxBars(); + if (coreMaxBars > maxBars) { + useProjectStore.setState({ maxBars: coreMaxBars }); + document.documentElement.style.setProperty('--max-number-of-bars', coreMaxBars.toString()); + } + setRegions(prev => { const updated = [...prev, regionUI]; selectRegion(regionUI.id, updated); diff --git a/src/components/track/Track.css b/src/components/track/Track.css index fe1d7c6..d273679 100644 --- a/src/components/track/Track.css +++ b/src/components/track/Track.css @@ -97,6 +97,9 @@ font-size: 12px; cursor: pointer; position: relative; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .track-name:hover { diff --git a/src/components/track/TrackInfoItem.tsx b/src/components/track/TrackInfoItem.tsx index f6d3e8d..2d41efb 100644 --- a/src/components/track/TrackInfoItem.tsx +++ b/src/components/track/TrackInfoItem.tsx @@ -289,9 +289,10 @@ const TrackInfoItem: React.FC = ({ )}
-
{track.getName()}
diff --git a/src/constants/coreConstants.ts b/src/constants/coreConstants.ts index 6db6c1d..9c42339 100644 --- a/src/constants/coreConstants.ts +++ b/src/constants/coreConstants.ts @@ -108,3 +108,9 @@ export const CONFIG_UPGRADER_CONSTANTS = { export const URL_CONSTANTS = { DEFAULT_OPENAI_BASE_URL: 'https://api.openai.com/v1', }; + +export const KGONE_CONSTANTS = { + MAX_RETRY_ATTEMPTS: 3, // max retries for polling & download calls + RETRY_INITIAL_DELAY_MS: 1000, // first wait before retry (ms) + RETRY_BACKOFF_MULTIPLIER: 2, // doubles each attempt: 1 s → 2 s → 4 s +}; diff --git a/src/core/commands/index.ts b/src/core/commands/index.ts index 0aada8e..be06600 100644 --- a/src/core/commands/index.ts +++ b/src/core/commands/index.ts @@ -22,6 +22,8 @@ export { PasteRegionsCommand } from './region/PasteRegionsCommand'; export { UpdateRegionCommand, type RegionUpdateProperties } from './region/UpdateRegionCommand'; export { ImportAudioCommand } from './region/ImportAudioCommand'; export { ImportMidiClipCommand } from './region/ImportMidiClipCommand'; +export { ImportStemsCommand } from './region/ImportStemsCommand'; +export type { StemImportEntry } from './region/ImportStemsCommand'; // Note commands export { CreateNoteCommand } from './note/CreateNoteCommand'; diff --git a/src/core/commands/region/ImportStemsCommand.ts b/src/core/commands/region/ImportStemsCommand.ts new file mode 100644 index 0000000..070cbf5 --- /dev/null +++ b/src/core/commands/region/ImportStemsCommand.ts @@ -0,0 +1,173 @@ +import * as Tone from 'tone'; +import { KGCommand } from '../KGCommand'; +import { KGCore } from '../../KGCore'; +import { KGAudioTrack } from '../../track/KGAudioTrack'; +import { KGAudioRegion } from '../../region/KGAudioRegion'; +import { KGAudioInterface } from '../../audio-interface/KGAudioInterface'; + +/** + * Per-stem data passed to ImportStemsCommand. + * All async work (OPFS storage, audio decode) must be completed before + * constructing the command — execute() is synchronous. + */ +export interface StemImportEntry { + trackName: string; + audioFileId: string; + audioFileName: string; + audioDurationSeconds: number; + toneBuffer: Tone.ToneAudioBuffer; +} + +/** + * Composite command that atomically: + * 1. Creates one KGAudioTrack per stem (appended to end) + * 2. Reorders each new track to sit immediately below the original source track + * 3. Creates one KGAudioRegion per stem, all starting at the same beat + * 4. Expands maxBars if needed + * + * This is a single undo/redo step. + */ +export class ImportStemsCommand extends KGCommand { + private readonly originalTrackCount: number; + private readonly originalTrackIndex: number; + private readonly insertBeat: number; + private readonly stems: StemImportEntry[]; + private readonly originalMaxBars: number; + + private createdTrackIds: number[] = []; + private createdRegionIds: string[] = []; + private finalMaxBars: number; + + constructor( + originalTrackCount: number, + originalTrackIndex: number, + insertBeat: number, + stems: StemImportEntry[], + originalMaxBars: number, + ) { + super(); + this.originalTrackCount = originalTrackCount; + this.originalTrackIndex = originalTrackIndex; + this.insertBeat = insertBeat; + this.stems = stems; + this.originalMaxBars = originalMaxBars; + this.finalMaxBars = originalMaxBars; + } + + execute(): void { + const core = KGCore.instance(); + const project = core.getCurrentProject(); + const audioInterface = KGAudioInterface.instance(); + + this.createdTrackIds = []; + this.createdRegionIds = []; + + // ── 1. Compute starting track ID ────────────────────────────────────── + const existingTracks = project.getTracks(); + let nextId = existingTracks.length > 0 + ? Math.max(...existingTracks.map(t => t.getId())) + 1 + : 1; + + // ── 2. Append one audio track per stem ──────────────────────────────── + for (const stem of this.stems) { + const trackId = nextId++; + this.createdTrackIds.push(trackId); + + const track = new KGAudioTrack(stem.trackName, trackId); + track.setTrackIndex(project.getTracks().length); + project.setTracks([...project.getTracks(), track]); + + // Bus creation is async; chain buffer load so it runs once the bus is ready + audioInterface.createTrackAudioPlayerBus(trackId.toString()).then(() => { + audioInterface.loadAudioBufferForTrack(trackId.toString(), stem.audioFileId, stem.toneBuffer); + }).catch(err => { + console.error(`[ImportStemsCommand] Failed to create bus for track ${trackId}:`, err); + }); + } + + // ── 3. Reorder each stem track to sit just below the original ───────── + // For stem i: sourceIndex = originalTrackCount + i, destIndex = originalTrackIndex + 1 + i + // Each reorder shifts the remaining appended stems left by 1 in the tail, but their + // absolute indices stay at originalTrackCount + i because each move keeps them packed. + for (let i = 0; i < this.stems.length; i++) { + const srcIdx = this.originalTrackCount + i; + const destIdx = this.originalTrackIndex + 1 + i; + + if (srcIdx === destIdx) continue; // no-op when original is the last track + + const tracks = project.getTracks(); + const updated = [...tracks]; + const [moved] = updated.splice(srcIdx, 1); + updated.splice(destIdx, 0, moved); + updated.forEach((t, idx) => t.setTrackIndex(idx)); + project.setTracks(updated); + } + + // ── 4. Create one audio region per stem ─────────────────────────────── + const bpm = project.getBpm(); + const beatsPerBar = project.getTimeSignature().numerator; + let currentMaxBars = this.originalMaxBars; + + for (let i = 0; i < this.stems.length; i++) { + const stem = this.stems[i]; + const trackId = this.createdTrackIds[i]; + + // Look up the track by ID so we use the post-reorder trackIndex + const track = project.getTracks().find(t => t.getId() === trackId); + if (!track) continue; + + const durationInBeats = stem.audioDurationSeconds * (bpm / 60); + const endBeat = this.insertBeat + durationInBeats; + const requiredBars = Math.ceil(endBeat / beatsPerBar); + const newMaxBars = Math.max(currentMaxBars, requiredBars); + + const regionId = `audio_region_${Date.now()}_${Math.random().toString(36).substring(2, 8)}_${i}`; + this.createdRegionIds.push(regionId); + + const region = new KGAudioRegion( + regionId, + trackId.toString(), + track.getTrackIndex(), + stem.audioFileName, + this.insertBeat, + durationInBeats, + stem.audioFileId, + stem.audioFileName, + stem.audioDurationSeconds, + ); + track.addRegion(region); + currentMaxBars = newMaxBars; + } + + // ── 5. Expand project maxBars if needed ─────────────────────────────── + this.finalMaxBars = currentMaxBars; + if (this.finalMaxBars > this.originalMaxBars) { + project.setMaxBars(this.finalMaxBars); + } + } + + undo(): void { + const core = KGCore.instance(); + const project = core.getCurrentProject(); + const audioInterface = KGAudioInterface.instance(); + + // Remove all created tracks and their audio buses + const remaining = project.getTracks().filter(t => !this.createdTrackIds.includes(t.getId())); + remaining.forEach((t, idx) => t.setTrackIndex(idx)); + project.setTracks(remaining); + + for (const trackId of this.createdTrackIds) { + audioInterface.removeTrackAudioPlayerBus(trackId.toString()); + } + + // Revert maxBars if it was expanded + if (this.finalMaxBars > this.originalMaxBars) { + project.setMaxBars(this.originalMaxBars); + } + } + + getDescription(): string { + const n = this.stems.length; + return `Import ${n} stem${n !== 1 ? 's' : ''} to timeline`; + } +} diff --git a/src/util/retryUtil.ts b/src/util/retryUtil.ts new file mode 100644 index 0000000..76e8f08 --- /dev/null +++ b/src/util/retryUtil.ts @@ -0,0 +1,56 @@ +import { KGONE_CONSTANTS } from '../constants/coreConstants'; + +export interface RetryConfig { + maxAttempts?: number; + initialDelayMs?: number; + backoffMultiplier?: number; +} + +/** + * Wraps fetch() with exponential-backoff retry on non-200 responses or + * network errors. AbortErrors propagate immediately — no retry. + * + * Use only for idempotent calls (polling, downloads). + * Do NOT use for load-model or job-submission calls. + */ +export async function fetchWithRetry( + url: string, + options: RequestInit, + config: RetryConfig = {} +): Promise { + const { + maxAttempts = KGONE_CONSTANTS.MAX_RETRY_ATTEMPTS, + initialDelayMs = KGONE_CONSTANTS.RETRY_INITIAL_DELAY_MS, + backoffMultiplier = KGONE_CONSTANTS.RETRY_BACKOFF_MULTIPLIER, + } = config; + + const signal = options.signal as AbortSignal | undefined; + let delay = initialDelayMs; + let lastError: Error = new Error('Unknown error'); + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const resp = await fetch(url, options); + if (resp.ok) return resp; + + const body = await resp.text().catch(() => ''); + lastError = new Error(`HTTP ${resp.status}${body ? `: ${body}` : ''}`); + } catch (err) { + // Propagate abort immediately — user cancelled, do not retry + if (err instanceof DOMException && err.name === 'AbortError') throw err; + lastError = err instanceof Error ? err : new Error(String(err)); + } + + if (attempt < maxAttempts - 1) { + // Abort-aware sleep before the next attempt + await new Promise(resolve => { + const t = setTimeout(resolve, delay); + signal?.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true }); + }); + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); + delay *= backoffMultiplier; + } + } + + throw lastError; +}