Compare commits

...

10 Commits

Author SHA1 Message Date
fspecii b9e24018e1 docs(readme): add web development services section linking to websitefirma.ro 2026-06-04 23:47:26 +03:00
fspecii 8f67d6a1aa Validate source audio is loadable before cover/repaint generation
When prepareAudioFile fails (file not found), it was returning null
silently. For cover and repaint modes this caused Gradio to run without
source audio, generating a short/empty/silent result instead of failing
with a clear error message.
2026-03-02 18:12:57 +02:00
fspecii 78426c00dd Fix LoRA training Error 500 - remove Radio serialization issue
The @gradio/client was wrapping Radio component values as
{"value": "replace", "__type__": "update"} instead of plain strings,
causing the update_settings Gradio call to fail and crash the server.

- /update-settings: removed Gradio call, settings are applied at save time
- /save-dataset: migrated to REST API (/v1/dataset/save) which accepts
  tag_position and other settings directly as strings
- Frontend: pass dataset settings (tag, position, etc.) when saving
2026-03-02 18:06:44 +02:00
fspecii a5879e60a5 Fix audio2audio task type, demucs-web proxy, and health endpoint
- Map audio2audio to cover in Gradio args and Python spawn fallback
  (audio2audio is not a valid ACE-Step task type, cover is the equivalent)
- Add /demucs-web to Vite proxy so stem extractor opens correctly in Pinokio
  (in Pinokio, Vite runs on a dynamic port and /demucs-web was not proxied)
- Expose aceStepUrl in /api/generate/health response for debugging
2026-03-02 17:58:09 +02:00
fspecii e1625a717d Fix multiple issues: format API, Gradio availability, storage, UI responsiveness
- Format endpoint now calls ACE-Step /format_input REST API directly instead of
  spawning Python, fixing ENOENT errors on Windows (#44, #27, #34)
- isGradioAvailable() tries /gradio_api/info, /info, / in sequence to handle
  Gradio 4.x/5.x/6.x version differences, fixing generation fallback (#53, #20)
- Storage getUrl/getPublicUrl normalize /audio/ prefix to prevent double-prefix
  URLs when reference tracks are used for cover generation (#10)
- Gradio args: fix normalization_db default from 0.0 to -1.0 (Gradio default)
- Volume popover: add 400ms delay before hiding to prevent accidental dismissal (#51)
- Polling: skip setSongs state update when nothing changed to reduce re-renders (#51)
- FFmpeg: add jsdelivr CDN fallback when unpkg fails (#30)
- Model switching: add switchModelIfNeeded() via /v1/init REST API (#45)
2026-03-02 17:43:53 +02:00
fspecii 553e0bce2f Fix Gradio args for ACE-Step v1.5 API 2026-03-02 16:30:08 +02:00
fspecii 6b99e418a9 Update README: Gradio API instead of REST API, document AI Enhance
- Replace all acestep-api/api_server.py references with acestep --enable-api
- Update Quick Start, Usage, and Configuration sections for Gradio
- Document AI Enhance toggle and its effect on genre accuracy
- Add troubleshooting entry for ballad output issue
- Wait message changed to "API endpoints enabled"
2026-02-10 19:34:14 +02:00
fspecii 8433349af5 Add AI Enhance toggle for better genre accuracy
When enabled, uses the LLM to enrich genre/style tags into detailed
music descriptions and generate proper BPM, key, and time signature
metadata (CoT features). Fixes genre tags like "pop, rock" producing
ballad-like output by matching Gradio UI default behavior.

- Add enhance toggle in Style of Music card header
- Gate CoT metas/caption/language by enhance OR thinking flag
- Remove unsupported --lm-backend/--lm-model from Python fallback
- Add i18n translations (en/zh/ja/ko) with tooltip
2026-02-10 13:24:36 +02:00
fspecii ac99e9efcb Fix player: clickable empty state, skip non-playable songs, spacebar shortcut
- Make bottom player empty state a clickable button that plays first available song
- Guard togglePlay to show toast when audioUrl is missing instead of silently failing
- playNext/playPrevious now skip songs without audioUrl or still generating
- repeatMode 'none' stops at queue boundaries instead of wrapping
- Add ShareModal to mobile fullscreen player (was missing, share silently failed)
- Add spacebar play/pause keyboard shortcut (skips when typing in inputs)
- Add onPlayFirst prop to Player component
- Add selectSongToPlay i18n key (en, zh, ja, ko)
2026-02-10 12:26:28 +02:00
fspecii 565faacb7b Gradio API migration, training pipeline, news page, and UI improvements
- Migrate backend from REST API to Gradio @gradio/client for generation
- Fix Gradio parameter alignment (positions 36-49) for reference/cover audio
- Add LoRA training pipeline with dataset upload, preprocessing, and export
- Add News page with dismiss/restore and GitHub star button
- Add localization info icon in Settings language section
- Fix upload audio URL prefix, add missing MIME types
- Add training API routes and Python preprocess script
- Update i18n with news keys for all languages
2026-02-09 22:30:15 +02:00
29 changed files with 3599 additions and 366 deletions
+120 -24
View File
@@ -20,6 +20,8 @@ import { List } from 'lucide-react';
import { PlaylistDetail } from './components/PlaylistDetail'; import { PlaylistDetail } from './components/PlaylistDetail';
import { Toast, ToastType } from './components/Toast'; import { Toast, ToastType } from './components/Toast';
import { SearchPage } from './components/SearchPage'; import { SearchPage } from './components/SearchPage';
import { TrainingPanel } from './components/TrainingPanel';
import { NewsPage } from './components/NewsPage';
import { ConfirmDialog } from './components/ConfirmDialog'; import { ConfirmDialog } from './components/ConfirmDialog';
@@ -106,6 +108,7 @@ function AppContent() {
const [reuseData, setReuseData] = useState<{ song: Song, timestamp: number } | null>(null); const [reuseData, setReuseData] = useState<{ song: Song, timestamp: number } | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const selectedSongRef = useRef<Song | null>(null);
const currentSongIdRef = useRef<string | null>(null); const currentSongIdRef = useRef<string | null>(null);
const pendingSeekRef = useRef<number | null>(null); const pendingSeekRef = useRef<number | null>(null);
const playNextRef = useRef<() => void>(() => {}); const playNextRef = useRef<() => void>(() => {});
@@ -164,6 +167,9 @@ function AppContent() {
} }
}, [token]); }, [token]);
// Keep selectedSongRef in sync for use in callbacks without stale closures
useEffect(() => { selectedSongRef.current = selectedSong; }, [selectedSong]);
// Cleanup active jobs on unmount // Cleanup active jobs on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -280,6 +286,8 @@ function AppContent() {
} }
} else if (path === '/search') { } else if (path === '/search') {
setCurrentView('search'); setCurrentView('search');
} else if (path === '/news') {
setCurrentView('news');
} }
}; };
@@ -400,19 +408,34 @@ function AppContent() {
return; return;
} }
// Find next playable song (has audioUrl and not generating)
const queueLen = queue.length;
for (let i = 1; i <= queueLen; i++) {
let nextIndex; let nextIndex;
if (isShuffle) { if (isShuffle) {
do { nextIndex = Math.floor(Math.random() * queueLen);
nextIndex = Math.floor(Math.random() * queue.length); if (queueLen > 1 && nextIndex === currentIndex) continue;
} while (queue.length > 1 && nextIndex === currentIndex);
} else { } else {
nextIndex = (currentIndex + 1) % queue.length; nextIndex = currentIndex + i;
// In 'none' repeat mode, stop at end of queue
if (repeatMode === 'none' && nextIndex >= queueLen) {
setIsPlaying(false);
return;
}
nextIndex = nextIndex % queueLen;
} }
const nextSong = queue[nextIndex]; const candidate = queue[nextIndex];
if (candidate.audioUrl && !candidate.isGenerating) {
setQueueIndex(nextIndex); setQueueIndex(nextIndex);
setCurrentSong(nextSong); setCurrentSong(candidate);
setIsPlaying(true); setIsPlaying(true);
return;
}
}
// No playable songs found
setIsPlaying(false);
}, [currentSong, queueIndex, isShuffle, repeatMode, playQueue, songs]); }, [currentSong, queueIndex, isShuffle, repeatMode, playQueue, songs]);
const playPrevious = useCallback(() => { const playPrevious = useCallback(() => {
@@ -430,16 +453,35 @@ function AppContent() {
return; return;
} }
let prevIndex = (currentIndex - 1 + queue.length) % queue.length; // Find previous playable song (has audioUrl and not generating)
const queueLen = queue.length;
for (let i = 1; i <= queueLen; i++) {
let prevIndex;
if (isShuffle) { if (isShuffle) {
prevIndex = Math.floor(Math.random() * queue.length); prevIndex = Math.floor(Math.random() * queueLen);
if (queueLen > 1 && prevIndex === currentIndex) continue;
} else {
prevIndex = currentIndex - i;
// In 'none' repeat mode, stop at beginning of queue
if (repeatMode === 'none' && prevIndex < 0) {
if (audioRef.current) audioRef.current.currentTime = 0;
return;
}
prevIndex = (prevIndex + queueLen) % queueLen;
} }
const prevSong = queue[prevIndex]; const candidate = queue[prevIndex];
if (candidate.audioUrl && !candidate.isGenerating) {
setQueueIndex(prevIndex); setQueueIndex(prevIndex);
setCurrentSong(prevSong); setCurrentSong(candidate);
setIsPlaying(true); setIsPlaying(true);
}, [currentSong, queueIndex, currentTime, isShuffle, playQueue, songs]); return;
}
}
// No playable songs found
setIsPlaying(false);
}, [currentSong, queueIndex, currentTime, isShuffle, repeatMode, playQueue, songs]);
useEffect(() => { useEffect(() => {
playNextRef.current = playNext; playNextRef.current = playNext;
@@ -557,6 +599,29 @@ function AppContent() {
} }
}, [playbackRate]); }, [playbackRate]);
// Spacebar play/pause
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code !== 'Space') return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return;
e.preventDefault();
if (currentSong) {
if (currentSong.audioUrl) {
setIsPlaying(prev => !prev);
}
} else {
// No song selected — play first available
const available = songs.filter(s => s.audioUrl && !s.isGenerating);
if (available.length > 0) {
playSong(available[0], available);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [currentSong, songs]);
// Helper to cleanup a job and check if all jobs are done // Helper to cleanup a job and check if all jobs are done
const cleanupJob = useCallback((jobId: string, tempId: string) => { const cleanupJob = useCallback((jobId: string, tempId: string) => {
const jobData = activeJobsRef.current.get(jobId); const jobData = activeJobsRef.current.get(jobId);
@@ -622,7 +687,8 @@ function AppContent() {
}); });
// If the current selection was a temp/generating song, replace it with newest real song // If the current selection was a temp/generating song, replace it with newest real song
if (selectedSong?.isGenerating || (selectedSong && !loadedSongs.some(s => s.id === selectedSong.id))) { const current = selectedSongRef.current;
if (current?.isGenerating || (current && !loadedSongs.some(s => s.id === current.id))) {
setSelectedSong(loadedSongs[0] ?? null); setSelectedSong(loadedSongs[0] ?? null);
} }
} catch (error) { } catch (error) {
@@ -641,17 +707,21 @@ function AppContent() {
? (Number(status.progress) > 1 ? Number(status.progress) / 100 : Number(status.progress)) ? (Number(status.progress) > 1 ? Number(status.progress) / 100 : Number(status.progress))
: undefined; : undefined;
setSongs(prev => prev.map(s => { setSongs(prev => {
if (s.id === tempId) { const song = prev.find(s => s.id === tempId);
return { if (!song) return prev;
...s, const newQueuePos = status.status === 'queued' ? status.queuePosition : undefined;
queuePosition: status.status === 'queued' ? status.queuePosition : undefined, const newProgress = normalizedProgress ?? song.progress;
progress: normalizedProgress ?? s.progress, const newStage = status.stage ?? song.stage;
stage: status.stage ?? s.stage, // Skip update if nothing changed to avoid unnecessary re-renders
}; if (newProgress === song.progress && newStage === song.stage && newQueuePos === song.queuePosition) {
return prev;
} }
return s; return prev.map(s => {
})); if (s.id !== tempId) return s;
return { ...s, queuePosition: newQueuePos, progress: newProgress, stage: newStage };
});
});
if (status.status === 'succeeded' && status.result) { if (status.status === 'succeeded' && status.result) {
cleanupJob(jobId, tempId); cleanupJob(jobId, tempId);
@@ -853,9 +923,20 @@ function AppContent() {
const togglePlay = () => { const togglePlay = () => {
if (!currentSong) return; if (!currentSong) return;
if (!currentSong.audioUrl) {
showToast(t('songNotAvailable'), 'error');
return;
}
setIsPlaying(!isPlaying); setIsPlaying(!isPlaying);
}; };
const playFirst = () => {
const available = songs.filter(s => s.audioUrl && !s.isGenerating);
if (available.length > 0) {
playSong(available[0], available);
}
};
const playSong = (song: Song, list?: Song[]) => { const playSong = (song: Song, list?: Song[]) => {
const nextQueue = list && list.length > 0 const nextQueue = list && list.length > 0
? list ? list
@@ -1040,7 +1121,7 @@ function AppContent() {
if (songToAddToPlaylist) { if (songToAddToPlaylist) {
await playlistsApi.addSong(res.playlist.id, songToAddToPlaylist.id, token); await playlistsApi.addSong(res.playlist.id, songToAddToPlaylist.id, token);
setSongToAddToPlaylist(null); setSongToAddToPlaylist(null);
playlistsApi.getMyPlaylists(token).then(r => setPlaylists(r.playlists)); playlistsApi.getMyPlaylists(token).then(r => setPlaylists(r.playlists)).catch(() => {});
} }
showToast(t('playlistCreated')); showToast(t('playlistCreated'));
} catch (error) { } catch (error) {
@@ -1060,7 +1141,7 @@ function AppContent() {
await playlistsApi.addSong(playlistId, songToAddToPlaylist.id, token); await playlistsApi.addSong(playlistId, songToAddToPlaylist.id, token);
setSongToAddToPlaylist(null); setSongToAddToPlaylist(null);
showToast(t('songAddedToPlaylist')); showToast(t('songAddedToPlaylist'));
playlistsApi.getMyPlaylists(token).then(r => setPlaylists(r.playlists)); playlistsApi.getMyPlaylists(token).then(r => setPlaylists(r.playlists)).catch(() => {});
} catch (error) { } catch (error) {
console.error('Add song error:', error); console.error('Add song error:', error);
showToast(t('failedToAddSong'), 'error'); showToast(t('failedToAddSong'), 'error');
@@ -1212,6 +1293,12 @@ function AppContent() {
/> />
); );
case 'training':
return <TrainingPanel />;
case 'news':
return <NewsPage />;
case 'create': case 'create':
default: default:
return ( return (
@@ -1278,6 +1365,9 @@ function AppContent() {
isLiked={selectedSong ? likedSongIds.has(selectedSong.id) : false} isLiked={selectedSong ? likedSongIds.has(selectedSong.id) : false}
onToggleLike={toggleLike} onToggleLike={toggleLike}
onDelete={handleDeleteSong} onDelete={handleDeleteSong}
onPlay={playSong}
isPlaying={isPlaying}
currentSong={currentSong}
/> />
</div> </div>
)} )}
@@ -1311,6 +1401,8 @@ function AppContent() {
window.history.pushState({}, '', '/library'); window.history.pushState({}, '', '/library');
} else if (v === 'search') { } else if (v === 'search') {
window.history.pushState({}, '', '/search'); window.history.pushState({}, '', '/search');
} else if (v === 'news') {
window.history.pushState({}, '', '/news');
} }
if (isMobile) setShowLeftSidebar(false); if (isMobile) setShowLeftSidebar(false);
}} }}
@@ -1354,6 +1446,7 @@ function AppContent() {
onReusePrompt={() => currentSong && handleReuse(currentSong)} onReusePrompt={() => currentSong && handleReuse(currentSong)}
onAddToPlaylist={() => currentSong && openAddToPlaylistModal(currentSong)} onAddToPlaylist={() => currentSong && openAddToPlaylistModal(currentSong)}
onDelete={() => currentSong && handleDeleteSong(currentSong)} onDelete={() => currentSong && handleDeleteSong(currentSong)}
onPlayFirst={playFirst}
/> />
<CreatePlaylistModal <CreatePlaylistModal
@@ -1413,6 +1506,9 @@ function AppContent() {
isLiked={selectedSong ? likedSongIds.has(selectedSong.id) : false} isLiked={selectedSong ? likedSongIds.has(selectedSong.id) : false}
onToggleLike={toggleLike} onToggleLike={toggleLike}
onDelete={handleDeleteSong} onDelete={handleDeleteSong}
onPlay={playSong}
isPlaying={isPlaying}
currentSong={currentSong}
/> />
</div> </div>
</div> </div>
+51 -17
View File
@@ -89,7 +89,8 @@
| **Custom Mode** | Fine-tune BPM, key, time signature, and duration | | **Custom Mode** | Fine-tune BPM, key, time signature, and duration |
| **Style Tags** | Define genre, mood, tempo, and instrumentation | | **Style Tags** | Define genre, mood, tempo, and instrumentation |
| **Batch Generation** | Generate multiple variations at once | | **Batch Generation** | Generate multiple variations at once |
| **Thinking Mode** | Let AI enhance your prompts automatically | | **AI Enhance** | Enrich genre tags into detailed captions with proper BPM/key/time |
| **Thinking Mode** | Let AI reason about structure and generate audio codes |
### 🎨 Advanced Parameters ### 🎨 Advanced Parameters
| Feature | Description | | Feature | Description |
@@ -134,7 +135,7 @@
|-------|-------------| |-------|-------------|
| **Frontend** | React 18, TypeScript, TailwindCSS, Vite | | **Frontend** | React 18, TypeScript, TailwindCSS, Vite |
| **Backend** | Express.js, SQLite, better-sqlite3 | | **Backend** | Express.js, SQLite, better-sqlite3 |
| **AI Engine** | [ACE-Step 1.5](https://github.com/ace-step/ACE-Step-1.5) | | **AI Engine** | [ACE-Step 1.5](https://github.com/ace-step/ACE-Step-1.5) (Gradio API) |
| **Audio Tools** | AudioMass, Demucs, FFmpeg | | **Audio Tools** | AudioMass, Demucs, FFmpeg |
--- ---
@@ -184,9 +185,9 @@ start-all.bat
### 🪟 Windows - Manual Start ### 🪟 Windows - Manual Start
```batch ```batch
REM 1. Start ACE-Step API REM 1. Start ACE-Step Gradio (with API endpoints)
cd C:\ACE-Step-1.5 cd C:\ACE-Step-1.5
python_embeded\python acestep\api_server.py python_embeded\python -m acestep --port 8001 --enable-api --backend pt --server-name 127.0.0.1
REM 2. Start ACE-Step UI (in another terminal) REM 2. Start ACE-Step UI (in another terminal)
cd ace-step-ui cd ace-step-ui
@@ -198,7 +199,7 @@ start.bat
cd ace-step-ui cd ace-step-ui
./start-all.sh ./start-all.sh
``` ```
**That's it!** This starts everything: API + Backend + Frontend in one command. **That's it!** This starts everything: Gradio + Backend + Frontend in one command.
> **Note:** By default, it looks for ACE-Step in `../ACE-Step-1.5`. > **Note:** By default, it looks for ACE-Step in `../ACE-Step-1.5`.
> If yours is elsewhere, set `ACESTEP_PATH` first: > If yours is elsewhere, set `ACESTEP_PATH` first:
@@ -210,9 +211,9 @@ cd ace-step-ui
### Linux / macOS - Manual Start ### Linux / macOS - Manual Start
```bash ```bash
# 1. Start ACE-Step API (in ACE-Step-1.5 directory) # 1. Start ACE-Step Gradio with API (in ACE-Step-1.5 directory)
cd /path/to/ACE-Step-1.5 cd /path/to/ACE-Step-1.5
uv run acestep-api --port 8001 uv run acestep --port 8001 --enable-api --backend pt --server-name 127.0.0.1
# 2. Start ACE-Step UI (in another terminal) # 2. Start ACE-Step UI (in another terminal)
cd ace-step-ui cd ace-step-ui
@@ -221,9 +222,9 @@ cd ace-step-ui
### Windows (Standard Installation) ### Windows (Standard Installation)
```batch ```batch
REM 1. Start ACE-Step API (in ACE-Step-1.5 directory) REM 1. Start ACE-Step Gradio with API (in ACE-Step-1.5 directory)
cd C:\path\to\ACE-Step-1.5 cd C:\path\to\ACE-Step-1.5
uv run acestep-api --port 8001 uv run acestep --port 8001 --enable-api --backend pt --server-name 127.0.0.1
REM 2. Start ACE-Step UI (in another terminal) REM 2. Start ACE-Step UI (in another terminal)
cd ace-step-ui cd ace-step-ui
@@ -311,27 +312,27 @@ copy server\.env.example server\.env
## 🎮 Usage ## 🎮 Usage
### Step 1: Start ACE-Step API Server ### Step 1: Start ACE-Step Gradio Server
**🪟 Windows Portable Package:** **🪟 Windows Portable Package:**
```batch ```batch
cd C:\ACE-Step-1.5 cd C:\ACE-Step-1.5
python_embeded\python acestep\api_server.py python_embeded\python -m acestep --port 8001 --enable-api --backend pt --server-name 127.0.0.1
``` ```
**Linux / macOS:** **Linux / macOS:**
```bash ```bash
cd /path/to/ACE-Step-1.5 cd /path/to/ACE-Step-1.5
uv run acestep-api --port 8001 uv run acestep --port 8001 --enable-api --backend pt --server-name 127.0.0.1
``` ```
**Windows (Standard Installation):** **Windows (Standard Installation):**
```batch ```batch
cd C:\path\to\ACE-Step-1.5 cd C:\path\to\ACE-Step-1.5
uv run acestep-api --port 8001 uv run acestep --port 8001 --enable-api --backend pt --server-name 127.0.0.1
``` ```
Wait for "Application startup complete" before proceeding. Wait for "API endpoints enabled" before proceeding.
### Step 2: Start ACE-Step UI ### Step 2: Start ACE-Step UI
@@ -364,7 +365,7 @@ Edit `server/.env`:
# Server # Server
PORT=3001 PORT=3001
# ACE-Step API (seamless integration) # ACE-Step Gradio URL (must match --port used when starting ACE-Step)
ACESTEP_API_URL=http://localhost:8001 ACESTEP_API_URL=http://localhost:8001
# Database (local-first, no cloud) # Database (local-first, no cloud)
@@ -394,6 +395,16 @@ Full control over every parameter:
| **BPM** | 60-200 beats per minute | | **BPM** | 60-200 beats per minute |
| **Key** | Musical key (C major, A minor, etc.) | | **Key** | Musical key (C major, A minor, etc.) |
### AI Enhance & Thinking Mode
| Mode | What it does | Speed impact |
|------|-------------|--------------|
| **AI Enhance OFF** | Sends your style tags directly to the model | Fastest |
| **AI Enhance ON** | LLM enriches your tags into a detailed caption and generates proper BPM, key, time signature | +10-20s |
| **Thinking Mode** | Full LLM reasoning with audio code generation | Slowest, best quality |
> **Tip:** If your genre tags (e.g. "pop, rock") produce ballad-like output, turn on **AI Enhance** for much better genre accuracy. No extra VRAM needed — the LLM runs on CPU with the PT backend.
### Batch Size & Bulk Generation ### Batch Size & Bulk Generation
| Setting | Description | | Setting | Description |
@@ -421,9 +432,10 @@ Full control over every parameter:
| Issue | Solution | | Issue | Solution |
|-------|----------| |-------|----------|
| **ACE-Step API not reachable** | Ensure API server is running (see Usage section) | | **ACE-Step not reachable** | Ensure Gradio server is running with `--enable-api` flag (see Usage section) |
| **CUDA out of memory** | Switch LM Backend to **PT**, set batch size to **1**, reduce duration, or disable Thinking Mode | | **CUDA out of memory** | Use `--backend pt` (default), set batch size to **1**, reduce duration, or disable Thinking Mode |
| **4GB GPU - Out of memory** | Use **PT** backend (default), batch size **1**, and keep **Thinking Mode OFF**. LLM features require 12GB+ | | **4GB GPU - Out of memory** | Use **PT** backend (default), batch size **1**, and keep **Thinking Mode OFF**. LLM features require 12GB+ |
| **Genre always sounds like ballad** | Enable **AI Enhance** toggle in the Style section — it enriches your tags with proper metadata |
| **AttributeError: 'NoneType'** | Update to latest ACE-Step-1.5 (fix merged in PR #109) | | **AttributeError: 'NoneType'** | Update to latest ACE-Step-1.5 (fix merged in PR #109) |
| **Songs show 0:00 duration** | Install FFmpeg: `sudo apt install ffmpeg` (Linux) or download from [ffmpeg.org](https://ffmpeg.org) (Windows) | | **Songs show 0:00 duration** | Install FFmpeg: `sudo apt install ffmpeg` (Linux) or download from [ffmpeg.org](https://ffmpeg.org) (Windows) |
| **LAN access not working** | Check firewall allows ports 3000 and 3001 | | **LAN access not working** | Check firewall allows ports 3000 and 3001 |
@@ -478,6 +490,28 @@ This is a community-driven project and contributions are what make open source a
--- ---
## 💼 Need a Website Like This?
If you like the engineering and design behind ACE-Step UI and want something similar built for your business, the same team offers professional web development services.
**We build:**
- 🌐 Custom websites & web apps — Next.js, Astro, WordPress, React
- 🤖 AI integrations & automations — OpenAI, Anthropic, custom LLM workflows
- 📱 Mobile apps — iOS, Android, React Native
- 🎨 UI/UX design tailored to your brand
<p align="center">
<a href="https://websitefirma.ro">
<img src="https://img.shields.io/badge/Get_in_Touch-websitefirma.ro-d4ff00?style=for-the-badge&labelColor=000000" alt="websitefirma.ro">
</a>
</p>
<p align="center">
<em>From the makers of ACE-Step UI — we ship production-grade web experiences.</em>
</p>
---
## 🙏 Credits ## 🙏 Credits
- **[ACE-Step](https://github.com/ace-step/ACE-Step-1.5)** - The revolutionary open source AI music generation model - **[ACE-Step](https://github.com/ace-step/ACE-Step-1.5)** - The revolutionary open source AI music generation model
+206 -133
View File
@@ -4,7 +4,7 @@ import { GenerationParams, Song } from '../types';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useI18n } from '../context/I18nContext'; import { useI18n } from '../context/I18nContext';
import { generateApi } from '../services/api'; import { generateApi } from '../services/api';
import { MAIN_STYLES, SUB_STYLES } from '../data/genres'; import { MAIN_STYLES } from '../data/genres';
import { EditableSlider } from './EditableSlider'; import { EditableSlider } from './EditableSlider';
interface ReferenceTrack { interface ReferenceTrack {
@@ -48,7 +48,12 @@ const KEY_SIGNATURES = [
'B major', 'B minor' 'B major', 'B minor'
]; ];
const TIME_SIGNATURES = ['', '2/4', '3/4', '4/4', '6/8']; const TIME_SIGNATURES = ['', '2', '3', '4', '6', 'N/A'];
const TRACK_NAMES = [
'woodwinds', 'brass', 'fx', 'synth', 'strings', 'percussion',
'keyboard', 'guitar', 'bass', 'drums', 'backing_vocals', 'vocals',
];
const VOCAL_LANGUAGE_KEYS = [ const VOCAL_LANGUAGE_KEYS = [
{ value: 'unknown', key: 'autoInstrumental' as const }, { value: 'unknown', key: 'autoInstrumental' as const },
@@ -163,6 +168,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
const [randomSeed, setRandomSeed] = useState(true); const [randomSeed, setRandomSeed] = useState(true);
const [seed, setSeed] = useState(-1); const [seed, setSeed] = useState(-1);
const [thinking, setThinking] = useState(false); // Default false for GPU compatibility const [thinking, setThinking] = useState(false); // Default false for GPU compatibility
const [enhance, setEnhance] = useState(false); // AI Enhance: uses LLM to enrich caption & generate metadata
const [audioFormat, setAudioFormat] = useState<'mp3' | 'flac'>('mp3'); const [audioFormat, setAudioFormat] = useState<'mp3' | 'flac'>('mp3');
const [inferenceSteps, setInferenceSteps] = useState(12); const [inferenceSteps, setInferenceSteps] = useState(12);
const [inferMethod, setInferMethod] = useState<'ode' | 'sde'>('ode'); const [inferMethod, setInferMethod] = useState<'ode' | 'sde'>('ode');
@@ -215,6 +221,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
const [showLoraPanel, setShowLoraPanel] = useState(false); const [showLoraPanel, setShowLoraPanel] = useState(false);
const [loraPath, setLoraPath] = useState('./lora_output/final/adapter'); const [loraPath, setLoraPath] = useState('./lora_output/final/adapter');
const [loraLoaded, setLoraLoaded] = useState(false); const [loraLoaded, setLoraLoaded] = useState(false);
const [loraEnabled, setLoraEnabled] = useState(true);
const [loraScale, setLoraScale] = useState(1.0); const [loraScale, setLoraScale] = useState(1.0);
const [loraError, setLoraError] = useState<string | null>(null); const [loraError, setLoraError] = useState<string | null>(null);
const [isLoraLoading, setIsLoraLoading] = useState(false); const [isLoraLoading, setIsLoraLoading] = useState(false);
@@ -263,19 +270,6 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
return modelId.includes('turbo'); return modelId.includes('turbo');
}; };
// Genre selection state (cascading)
const [selectedMainGenre, setSelectedMainGenre] = useState<string>('');
const [selectedSubGenre, setSelectedSubGenre] = useState<string>('');
// Filter sub-genres based on selected main genre
const filteredSubGenres = useMemo(() => {
if (!selectedMainGenre) return [];
const mainLower = selectedMainGenre.toLowerCase().trim();
return SUB_STYLES.filter(style =>
style.toLowerCase().includes(mainLower)
);
}, [selectedMainGenre]);
const [isUploadingReference, setIsUploadingReference] = useState(false); const [isUploadingReference, setIsUploadingReference] = useState(false);
const [isUploadingSource, setIsUploadingSource] = useState(false); const [isUploadingSource, setIsUploadingSource] = useState(false);
const [isTranscribingReference, setIsTranscribingReference] = useState(false); const [isTranscribingReference, setIsTranscribingReference] = useState(false);
@@ -437,6 +431,61 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
} }
}; };
const handleLoraEnabledToggle = async () => {
if (!token || !loraLoaded) return;
const newEnabled = !loraEnabled;
setLoraEnabled(newEnabled);
try {
await generateApi.toggleLora({ enabled: newEnabled }, token);
} catch (err) {
console.error('Failed to toggle LoRA:', err);
setLoraEnabled(!newEnabled); // revert on error
}
};
// Load generation parameters from JSON file
const handleLoadParamsFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target?.result as string);
if (data.lyrics !== undefined) setLyrics(data.lyrics);
if (data.style !== undefined) setStyle(data.style);
if (data.title !== undefined) setTitle(data.title);
if (data.caption !== undefined) setStyle(data.caption);
if (data.instrumental !== undefined) setInstrumental(data.instrumental);
if (data.vocal_language !== undefined) setVocalLanguage(data.vocal_language);
if (data.bpm !== undefined) setBpm(data.bpm);
if (data.key_scale !== undefined) setKeyScale(data.key_scale);
if (data.time_signature !== undefined) setTimeSignature(data.time_signature);
if (data.duration !== undefined) setDuration(data.duration);
if (data.inference_steps !== undefined) setInferenceSteps(data.inference_steps);
if (data.guidance_scale !== undefined) setGuidanceScale(data.guidance_scale);
if (data.audio_format !== undefined) setAudioFormat(data.audio_format);
if (data.infer_method !== undefined) setInferMethod(data.infer_method);
if (data.seed !== undefined) { setSeed(data.seed); setRandomSeed(false); }
if (data.shift !== undefined) setShift(data.shift);
if (data.lm_temperature !== undefined) setLmTemperature(data.lm_temperature);
if (data.lm_cfg_scale !== undefined) setLmCfgScale(data.lm_cfg_scale);
if (data.lm_top_k !== undefined) setLmTopK(data.lm_top_k);
if (data.lm_top_p !== undefined) setLmTopP(data.lm_top_p);
if (data.lm_negative_prompt !== undefined) setLmNegativePrompt(data.lm_negative_prompt);
if (data.task_type !== undefined) setTaskType(data.task_type);
if (data.audio_codes !== undefined) setAudioCodes(data.audio_codes);
if (data.repainting_start !== undefined) setRepaintingStart(data.repainting_start);
if (data.repainting_end !== undefined) setRepaintingEnd(data.repainting_end);
if (data.instruction !== undefined) setInstruction(data.instruction);
if (data.audio_cover_strength !== undefined) setAudioCoverStrength(data.audio_cover_strength);
} catch {
console.error('Failed to parse parameters JSON');
}
};
reader.readAsText(file);
e.target.value = ''; // reset so same file can be reloaded
};
// Reuse Effect - must be after all state declarations // Reuse Effect - must be after all state declarations
useEffect(() => { useEffect(() => {
if (initialData) { if (initialData) {
@@ -628,28 +677,6 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
setIsResizing(true); setIsResizing(true);
}; };
const uploadAudio = async (file: File, target: 'reference' | 'source') => {
if (!token) {
setUploadError('Please sign in to upload audio.');
return;
}
setUploadError(null);
const setUploading = target === 'reference' ? setIsUploadingReference : setIsUploadingSource;
const setUrl = target === 'reference' ? setReferenceAudioUrl : setSourceAudioUrl;
setUploading(true);
try {
const result = await generateApi.uploadAudio(file, token);
setUrl(result.url);
setShowAudioModal(false);
setTempAudioUrl('');
} catch (err) {
const message = err instanceof Error ? err.message : 'Upload failed';
setUploadError(message);
} finally {
setUploading(false);
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>, target: 'reference' | 'source') => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>, target: 'reference' | 'source') => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
@@ -977,6 +1004,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
randomSeed: randomSeed || i > 0, // Force random for subsequent bulk jobs randomSeed: randomSeed || i > 0, // Force random for subsequent bulk jobs
seed: jobSeed, seed: jobSeed,
thinking, thinking,
enhance,
audioFormat, audioFormat,
inferMethod, inferMethod,
lmBackend, lmBackend,
@@ -1182,8 +1210,28 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
<div className="space-y-5"> <div className="space-y-5">
{/* Song Description */} {/* Song Description */}
<div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden"> <div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden">
<div className="px-3 py-2.5 text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 border-b border-zinc-100 dark:border-white/5 bg-zinc-50 dark:bg-white/5"> <div className="px-3 py-2.5 flex items-center justify-between border-b border-zinc-100 dark:border-white/5 bg-zinc-50 dark:bg-white/5">
<span className="text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
{t('describeYourSong')} {t('describeYourSong')}
</span>
<button
type="button"
onClick={async () => {
if (!token) return;
try {
const result = await generateApi.getRandomDescription(token);
setSongDescription(result.description);
setInstrumental(result.instrumental);
setVocalLanguage(result.vocalLanguage || 'unknown');
} catch (err) {
console.error('Failed to load random description:', err);
}
}}
title="Load random description"
className="p-1 rounded-md text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"
>
<Dices size={14} />
</button>
</div> </div>
<textarea <textarea
value={songDescription} value={songDescription}
@@ -1194,32 +1242,37 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
</div> </div>
{/* Vocal Language (Simple) */} {/* Vocal Language (Simple) */}
<div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden"> <div className="grid grid-cols-2 gap-3">
<div className="px-3 py-2.5 text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 border-b border-zinc-100 dark:border-white/5 bg-zinc-50 dark:bg-white/5"> <div className="space-y-1.5">
<label className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide px-1">
{t('vocalLanguage')} {t('vocalLanguage')}
</div> </label>
<div className="flex flex-wrap items-center gap-2 p-3">
<select <select
value={vocalLanguage} value={vocalLanguage}
onChange={(e) => setVocalLanguage(e.target.value)} onChange={(e) => setVocalLanguage(e.target.value)}
className="flex-1 min-w-[180px] bg-transparent text-sm text-zinc-900 dark:text-white focus:outline-none" className="w-full bg-white dark:bg-suno-card border border-zinc-200 dark:border-white/5 rounded-xl px-3 py-2 text-sm text-zinc-900 dark:text-white focus:outline-none focus:border-pink-500 dark:focus:border-pink-500 transition-colors cursor-pointer [&>option]:bg-white [&>option]:dark:bg-zinc-800 [&>option]:text-zinc-900 [&>option]:dark:text-white"
> >
{VOCAL_LANGUAGE_KEYS.map(lang => ( {VOCAL_LANGUAGE_KEYS.map(lang => (
<option key={lang.value} value={lang.value}>{lang.key}</option> <option key={lang.value} value={lang.value}>{t(lang.key)}</option>
))} ))}
</select> </select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide px-1">
{t('vocalGender')}
</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"
onClick={() => setVocalGender(vocalGender === 'male' ? '' : 'male')} onClick={() => setVocalGender(vocalGender === 'male' ? '' : 'male')}
className={`px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors ${vocalGender === 'male' ? 'bg-pink-600 text-white border-pink-600' : 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-zinc-300 dark:hover:border-white/20'}`} className={`flex-1 px-3 py-2 rounded-lg text-xs font-semibold border transition-colors ${vocalGender === 'male' ? 'bg-pink-600 text-white border-pink-600' : 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-zinc-300 dark:hover:border-white/20'}`}
> >
{t('male')} {t('male')}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setVocalGender(vocalGender === 'female' ? '' : 'female')} onClick={() => setVocalGender(vocalGender === 'female' ? '' : 'female')}
className={`px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors ${vocalGender === 'female' ? 'bg-pink-600 text-white border-pink-600' : 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-zinc-300 dark:hover:border-white/20'}`} className={`flex-1 px-3 py-2 rounded-lg text-xs font-semibold border transition-colors ${vocalGender === 'female' ? 'bg-pink-600 text-white border-pink-600' : 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-zinc-300 dark:hover:border-white/20'}`}
> >
{t('female')} {t('female')}
</button> </button>
@@ -1239,7 +1292,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
label={t('duration')} label={t('duration')}
value={duration} value={duration}
min={-1} min={-1}
max={600} max={activeMaxDuration}
step={5} step={5}
onChange={setDuration} onChange={setDuration}
formatDisplay={(val) => val === -1 ? t('auto') : `${val}${t('seconds')}`} formatDisplay={(val) => val === -1 ? t('auto') : `${val}${t('seconds')}`}
@@ -1550,7 +1603,17 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
<div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden transition-colors group focus-within:border-zinc-400 dark:focus-within:border-white/20"> <div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden transition-colors group focus-within:border-zinc-400 dark:focus-within:border-white/20">
<div className="flex items-center justify-between px-3 py-2.5 bg-zinc-50 dark:bg-white/5 border-b border-zinc-100 dark:border-white/5"> <div className="flex items-center justify-between px-3 py-2.5 bg-zinc-50 dark:bg-white/5 border-b border-zinc-100 dark:border-white/5">
<div> <div>
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{t('styleOfMusic')}</span> <span className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{t('styleOfMusic')}</span>
<button
onClick={() => setEnhance(!enhance)}
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium transition-all cursor-pointer ${enhance ? 'bg-violet-100 dark:bg-violet-500/20 text-violet-600 dark:text-violet-400' : 'text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300'}`}
title={t('enhanceTooltip')}
>
<Sparkles size={9} />
<span>{enhance ? 'ON' : 'OFF'}</span>
</button>
</div>
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 mt-0.5">{t('genreMoodInstruments')}</p> <p className="text-[11px] text-zinc-400 dark:text-zinc-500 mt-0.5">{t('genreMoodInstruments')}</p>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -1567,7 +1630,6 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
> >
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
</div>
<button <button
className={`p-1.5 hover:bg-zinc-200 dark:hover:bg-white/10 rounded transition-colors ${isFormattingStyle ? 'text-pink-500' : 'text-zinc-500 hover:text-black dark:hover:text-white'}`} className={`p-1.5 hover:bg-zinc-200 dark:hover:bg-white/10 rounded transition-colors ${isFormattingStyle ? 'text-pink-500' : 'text-zinc-500 hover:text-black dark:hover:text-white'}`}
title="AI Format - Enhance style & auto-fill parameters" title="AI Format - Enhance style & auto-fill parameters"
@@ -1577,6 +1639,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
{isFormattingStyle ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />} {isFormattingStyle ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />}
</button> </button>
</div> </div>
</div>
<textarea <textarea
value={style} value={style}
onChange={(e) => setStyle(e.target.value)} onChange={(e) => setStyle(e.target.value)}
@@ -1584,70 +1647,6 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
className="w-full h-20 bg-transparent p-3 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none resize-none" className="w-full h-20 bg-transparent p-3 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none resize-none"
/> />
<div className="px-3 pb-3 space-y-3"> <div className="px-3 pb-3 space-y-3">
{/* Cascading Genre Selector */}
<div className="space-y-2">
{/* First Level: Main Genre */}
<div className="flex gap-2">
<select
value={selectedMainGenre}
onChange={(e) => {
setSelectedMainGenre(e.target.value);
setSelectedSubGenre(''); // Reset sub genre when main changes
if (e.target.value) {
setStyle(prev => prev ? `${prev}, ${e.target.value}` : e.target.value);
}
}}
className="flex-1 bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-xl px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none focus:border-pink-500 dark:focus:border-pink-500 transition-colors cursor-pointer [&>option]:bg-white [&>option]:dark:bg-zinc-800 [&>option]:text-zinc-900 [&>option]:dark:text-white"
>
<option value="">{t('mainGenre')}</option>
{MAIN_STYLES.map(genre => (
<option key={genre} value={genre}>{genre}</option>
))}
</select>
{selectedMainGenre && (
<button
onClick={() => {
setSelectedMainGenre('');
setSelectedSubGenre('');
}}
className="px-2 py-1.5 text-xs text-zinc-500 hover:text-zinc-900 dark:hover:text-white transition-colors"
title={t('cancel')}
>
</button>
)}
</div>
{/* Second Level: Sub Genre (only show when main genre is selected) */}
{selectedMainGenre && filteredSubGenres.length > 0 && (
<div className="flex gap-2 pl-4 border-l-2 border-zinc-200 dark:border-white/10">
<select
value={selectedSubGenre}
onChange={(e) => {
setSelectedSubGenre(e.target.value);
if (e.target.value) {
setStyle(prev => prev ? `${prev}, ${e.target.value}` : e.target.value);
}
}}
className="flex-1 bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-xl px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none focus:border-pink-500 dark:focus:border-pink-500 transition-colors cursor-pointer [&>option]:bg-white [&>option]:dark:bg-zinc-800 [&>option]:text-zinc-900 [&>option]:dark:text-white"
>
<option value="">{t('subGenre')} ({filteredSubGenres.length})</option>
{filteredSubGenres.map(genre => (
<option key={genre} value={genre}>{genre}</option>
))}
</select>
{selectedSubGenre && (
<button
onClick={() => setSelectedSubGenre('')}
className="px-2 py-1.5 text-xs text-zinc-500 hover:text-zinc-900 dark:hover:text-white transition-colors"
title={t('cancel')}
>
</button>
)}
</div>
)}
</div>
{/* Quick Tags */} {/* Quick Tags */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{musicTags.map(tag => ( {musicTags.map(tag => (
@@ -1799,13 +1798,27 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
)} )}
</div> </div>
{/* Use LoRA Checkbox (enable/disable without unloading) */}
<div className={`flex items-center justify-between py-2 border-t border-zinc-100 dark:border-white/5 ${!loraLoaded ? 'opacity-40 pointer-events-none' : ''}`}>
<label className="flex items-center gap-2 text-xs font-medium text-zinc-600 dark:text-zinc-400 cursor-pointer">
<input
type="checkbox"
checked={loraEnabled}
onChange={handleLoraEnabledToggle}
disabled={!loraLoaded}
className="accent-pink-600"
/>
Use LoRA
</label>
</div>
{/* LoRA Scale Slider */} {/* LoRA Scale Slider */}
<div className={!loraLoaded ? 'opacity-40 pointer-events-none' : ''}> <div className={!loraLoaded || !loraEnabled ? 'opacity-40 pointer-events-none' : ''}>
<EditableSlider <EditableSlider
label={t('loraScale')} label={t('loraScale')}
value={loraScale} value={loraScale}
min={0} min={0}
max={2} max={1}
step={0.05} step={0.05}
onChange={handleLoraScaleChange} onChange={handleLoraScaleChange}
formatDisplay={(val) => val.toFixed(2)} formatDisplay={(val) => val.toFixed(2)}
@@ -1881,6 +1894,17 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
{showAdvanced && ( {showAdvanced && (
<div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 p-4 space-y-4"> <div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 p-4 space-y-4">
{/* Load Parameters from JSON */}
<label className="flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-zinc-300 dark:border-white/15 text-xs font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-white/5 cursor-pointer transition-colors">
<Upload size={14} />
Load Parameters (JSON)
<input
type="file"
accept=".json"
onChange={handleLoadParamsFile}
className="hidden"
/>
</label>
{/* Duration */} {/* Duration */}
<EditableSlider <EditableSlider
@@ -1937,8 +1961,8 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
<EditableSlider <EditableSlider
label={t('inferenceSteps')} label={t('inferenceSteps')}
value={inferenceSteps} value={inferenceSteps}
min={4} min={1}
max={32} max={isTurboModel(selectedModel) ? 20 : 200}
step={1} step={1}
onChange={setInferenceSteps} onChange={setInferenceSteps}
helpText={t('moreStepsBetterQuality')} helpText={t('moreStepsBetterQuality')}
@@ -1951,7 +1975,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
value={guidanceScale} value={guidanceScale}
min={1} min={1}
max={15} max={15}
step={0.5} step={0.1}
onChange={setGuidanceScale} onChange={setGuidanceScale}
formatDisplay={(val) => val.toFixed(1)} formatDisplay={(val) => val.toFixed(1)}
helpText={t('howCloselyFollowPrompt')} helpText={t('howCloselyFollowPrompt')}
@@ -2098,8 +2122,8 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
value={lmTemperature} value={lmTemperature}
min={0} min={0}
max={2} max={2}
step={0.05} step={0.1}
onChange={(e) => setLmTemperature(Number(e.target.value))} onChange={setLmTemperature}
formatDisplay={(val) => val.toFixed(2)} formatDisplay={(val) => val.toFixed(2)}
helpText={t('higherMoreRandom')} helpText={t('higherMoreRandom')}
title="Higher temperature = more random word choices." title="Higher temperature = more random word choices."
@@ -2167,6 +2191,33 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
placeholder={t('optionalAudioCodes')} placeholder={t('optionalAudioCodes')}
className="w-full h-16 bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg p-2 text-xs text-zinc-900 dark:text-white focus:outline-none resize-none" className="w-full h-16 bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg p-2 text-xs text-zinc-900 dark:text-white focus:outline-none resize-none"
/> />
<div className="flex gap-2">
<button
type="button"
onClick={() => {
// Convert source audio to LM codes — requires Gradio lambda (not exposed as API)
// This is a placeholder: Gradio's convert_src_audio_to_codes_wrapper is not a named endpoint
console.log('Convert to Codes: requires source audio upload. Use Gradio UI for this feature.');
}}
disabled={!sourceAudioUrl}
title="Convert source audio to LM codes (requires source audio)"
className="px-2 py-1 rounded text-[10px] font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Convert to Codes
</button>
<button
type="button"
onClick={() => {
// Transcribe audio codes to metadata — requires Gradio lambda (not exposed as API)
console.log('Transcribe: requires audio codes. Use Gradio UI for this feature.');
}}
disabled={!audioCodes.trim()}
title="Transcribe audio codes to metadata (requires audio codes)"
className="px-2 py-1 rounded text-[10px] font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Transcribe
</button>
</div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -2187,7 +2238,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400" title="How strongly the source audio shapes the result.">{t('audioCoverStrength')}</label> <label className="text-xs font-medium text-zinc-600 dark:text-zinc-400" title="How strongly the source audio shapes the result.">{t('audioCoverStrength')}</label>
<input <input
type="number" type="number"
step="0.05" step="0.01"
min="0" min="0"
max="1" max="1"
value={audioCoverStrength} value={audioCoverStrength}
@@ -2238,7 +2289,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400" title="Fraction of the diffusion process to start applying guidance.">{t('cfgIntervalStart')}</label> <label className="text-xs font-medium text-zinc-600 dark:text-zinc-400" title="Fraction of the diffusion process to start applying guidance.">{t('cfgIntervalStart')}</label>
<input <input
type="number" type="number"
step="0.05" step="0.01"
min="0" min="0"
max="1" max="1"
value={cfgIntervalStart} value={cfgIntervalStart}
@@ -2250,7 +2301,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400" title="Fraction of the diffusion process to stop applying guidance.">{t('cfgIntervalEnd')}</label> <label className="text-xs font-medium text-zinc-600 dark:text-zinc-400" title="Fraction of the diffusion process to stop applying guidance.">{t('cfgIntervalEnd')}</label>
<input <input
type="number" type="number"
step="0.05" step="0.01"
min="0" min="0"
max="1" max="1"
value={cfgIntervalEnd} value={cfgIntervalEnd}
@@ -2276,7 +2327,9 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400" title="Scales score-based guidance (advanced).">{t('scoreScale')}</label> <label className="text-xs font-medium text-zinc-600 dark:text-zinc-400" title="Scales score-based guidance (advanced).">{t('scoreScale')}</label>
<input <input
type="number" type="number"
step="0.05" step="0.01"
min="0.01"
max="1"
value={scoreScale} value={scoreScale}
onChange={(e) => setScoreScale(Number(e.target.value))} onChange={(e) => setScoreScale(Number(e.target.value))}
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none" className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
@@ -2287,6 +2340,8 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
<input <input
type="number" type="number"
min="1" min="1"
max="32"
step="1"
value={lmBatchChunkSize} value={lmBatchChunkSize}
onChange={(e) => setLmBatchChunkSize(Number(e.target.value))} onChange={(e) => setLmBatchChunkSize(Number(e.target.value))}
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none" className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
@@ -2296,24 +2351,42 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">{t('trackName')}</label> <label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">{t('trackName')}</label>
<input <select
type="text"
value={trackName} value={trackName}
onChange={(e) => setTrackName(e.target.value)} onChange={(e) => setTrackName(e.target.value)}
placeholder={t('optionalTrackName')} className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none cursor-pointer [&>option]:bg-white [&>option]:dark:bg-zinc-800"
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none" >
/> <option value="">None</option>
{TRACK_NAMES.map(name => (
<option key={name} value={name}>{name}</option>
))}
</select>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">{t('completeTrackClasses')}</label> <label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">{t('completeTrackClasses')}</label>
<div className="flex flex-wrap gap-2">
{TRACK_NAMES.map(name => {
const selected = completeTrackClasses.split(',').map(s => s.trim()).filter(Boolean);
const isChecked = selected.includes(name);
return (
<label key={name} className="flex items-center gap-1 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 cursor-pointer">
<input <input
type="text" type="checkbox"
value={completeTrackClasses} checked={isChecked}
onChange={(e) => setCompleteTrackClasses(e.target.value)} onChange={() => {
placeholder={t('trackClassesPlaceholder')} const next = isChecked
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none" ? selected.filter(s => s !== name)
: [...selected, name];
setCompleteTrackClasses(next.join(','));
}}
className="accent-pink-600"
/> />
{name}
</label>
);
})}
</div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
+180
View File
@@ -0,0 +1,180 @@
import React, { useState } from 'react';
import { Newspaper, X, Star, Github } from 'lucide-react';
import { useI18n } from '../context/I18nContext';
import newsData from '../data/news.json';
interface NewsItem {
id: string;
date: string;
title: string;
body: string;
tags: string[];
}
export const NewsPage: React.FC = () => {
const { t } = useI18n();
const [dismissedNews, setDismissedNews] = useState<Set<string>>(() => {
try {
const stored = localStorage.getItem('ace-dismissed-news');
return stored ? new Set(JSON.parse(stored)) : new Set();
} catch {
return new Set();
}
});
const allNews = newsData as NewsItem[];
const activeNews = allNews.filter(n => !dismissedNews.has(n.id));
const dismissed = allNews.filter(n => dismissedNews.has(n.id));
const dismissNewsItem = (id: string) => {
setDismissedNews(prev => {
const next = new Set(prev);
next.add(id);
localStorage.setItem('ace-dismissed-news', JSON.stringify([...next]));
return next;
});
};
const restoreNewsItem = (id: string) => {
setDismissedNews(prev => {
const next = new Set(prev);
next.delete(id);
localStorage.setItem('ace-dismissed-news', JSON.stringify([...next]));
return next;
});
};
const tagColor = (tag: string) => {
switch (tag) {
case 'experimental':
return 'bg-amber-500/15 text-amber-600 dark:text-amber-400';
case 'backend':
return 'bg-blue-500/15 text-blue-600 dark:text-blue-400';
case 'training':
return 'bg-purple-500/15 text-purple-600 dark:text-purple-400';
case 'feature':
return 'bg-green-500/15 text-green-600 dark:text-green-400';
case 'bugfix':
return 'bg-red-500/15 text-red-600 dark:text-red-400';
default:
return 'bg-zinc-200 dark:bg-white/10 text-zinc-500 dark:text-zinc-400';
}
};
const renderCard = (item: NewsItem, isDismissed: boolean) => (
<div
key={item.id}
className={`
group rounded-2xl border transition-all duration-200
${isDismissed
? 'bg-zinc-100 dark:bg-white/[0.02] border-zinc-200 dark:border-white/5 opacity-50'
: 'bg-white dark:bg-suno-card border-zinc-200 dark:border-white/5 hover:border-zinc-300 dark:hover:border-white/10'
}
`}
>
<div className="p-5 sm:p-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-zinc-900 dark:text-zinc-100 leading-snug">
{item.title}
</h3>
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-1">{item.date}</p>
</div>
{!isDismissed ? (
<button
onClick={() => dismissNewsItem(item.id)}
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-lg text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/10 transition-all flex-shrink-0"
title="Dismiss"
>
<X size={16} />
</button>
) : (
<button
onClick={() => restoreNewsItem(item.id)}
className="text-xs text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:underline transition-colors flex-shrink-0"
>
Restore
</button>
)}
</div>
{/* Body */}
<p className="text-sm text-zinc-600 dark:text-zinc-400 mt-3 leading-relaxed">
{item.body}
</p>
{/* Tags */}
<div className="flex flex-wrap items-center gap-2 mt-4">
{item.tags.map(tag => (
<span
key={tag}
className={`text-[11px] font-medium px-2.5 py-1 rounded-full ${tagColor(tag)}`}
>
{tag}
</span>
))}
</div>
</div>
</div>
);
return (
<div className="flex-1 bg-white dark:bg-black overflow-y-auto p-6 lg:p-10 pb-32 transition-colors duration-300">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center gap-3 mb-8">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center flex-shrink-0">
<Newspaper size={20} className="text-amber-600 dark:text-amber-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white">{t('news')}</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400">Updates and announcements</p>
</div>
</div>
{/* Star Repo */}
<a
href="https://github.com/fspecii/ace-step-ui"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 mb-8 px-5 py-4 rounded-2xl border border-zinc-200 dark:border-white/5 bg-white dark:bg-suno-card hover:border-zinc-300 dark:hover:border-white/10 transition-all group"
>
<Github size={20} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">fspecii/ace-step-ui</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400">Star the repo to support the project</p>
</div>
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-700 dark:text-zinc-300 text-sm font-medium group-hover:bg-amber-500/15 group-hover:text-amber-600 dark:group-hover:text-amber-400 transition-colors flex-shrink-0">
<Star size={14} />
Star
</div>
</a>
{/* Active News */}
{activeNews.length > 0 ? (
<div className="space-y-4">
{activeNews.map(item => renderCard(item, false))}
</div>
) : (
<div className="text-center py-16">
<Newspaper size={48} className="mx-auto text-zinc-300 dark:text-zinc-600 mb-4" />
<p className="text-zinc-500 dark:text-zinc-400 text-sm">No new updates</p>
</div>
)}
{/* Dismissed News */}
{dismissed.length > 0 && (
<div className="mt-10">
<h2 className="text-xs font-semibold uppercase tracking-wider text-zinc-400 dark:text-zinc-500 mb-4">
Dismissed
</h2>
<div className="space-y-3">
{dismissed.map(item => renderCard(item, true))}
</div>
</div>
)}
</div>
</div>
);
};
+24 -7
View File
@@ -33,6 +33,7 @@ interface PlayerProps {
onReusePrompt?: () => void; onReusePrompt?: () => void;
onAddToPlaylist?: () => void; onAddToPlaylist?: () => void;
onDelete?: () => void; onDelete?: () => void;
onPlayFirst?: () => void;
} }
export const Player: React.FC<PlayerProps> = ({ export const Player: React.FC<PlayerProps> = ({
@@ -59,7 +60,8 @@ export const Player: React.FC<PlayerProps> = ({
onOpenVideo, onOpenVideo,
onReusePrompt, onReusePrompt,
onAddToPlaylist, onAddToPlaylist,
onDelete onDelete,
onPlayFirst
}) => { }) => {
const { user } = useAuth(); const { user } = useAuth();
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
@@ -67,6 +69,7 @@ export const Player: React.FC<PlayerProps> = ({
const progressBarRef = useRef<HTMLDivElement>(null); const progressBarRef = useRef<HTMLDivElement>(null);
const fullscreenProgressRef = useRef<HTMLDivElement>(null); const fullscreenProgressRef = useRef<HTMLDivElement>(null);
const [isHoveringVolume, setIsHoveringVolume] = useState(false); const [isHoveringVolume, setIsHoveringVolume] = useState(false);
const volumeHideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [showDropdown, setShowDropdown] = useState(false); const [showDropdown, setShowDropdown] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [shareModalOpen, setShareModalOpen] = useState(false); const [shareModalOpen, setShareModalOpen] = useState(false);
@@ -100,12 +103,15 @@ export const Player: React.FC<PlayerProps> = ({
if (!currentSong) { if (!currentSong) {
return ( return (
<div className="h-20 lg:h-24 bg-white dark:bg-black/95 backdrop-blur border-t border-zinc-200 dark:border-white/10 flex items-center justify-center z-50 transition-colors duration-300 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] dark:shadow-none"> <div className="h-20 lg:h-24 bg-white dark:bg-black/95 backdrop-blur border-t border-zinc-200 dark:border-white/10 flex items-center justify-center z-50 transition-colors duration-300 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] dark:shadow-none">
<div className="flex items-center gap-3 text-zinc-400 dark:text-zinc-600"> <button
onClick={() => onPlayFirst?.()}
className="flex items-center gap-3 text-zinc-400 dark:text-zinc-600 hover:text-zinc-600 dark:hover:text-zinc-400 cursor-pointer transition-colors"
>
<div className="w-10 h-10 lg:w-12 lg:h-12 rounded bg-zinc-200 dark:bg-zinc-800 flex items-center justify-center"> <div className="w-10 h-10 lg:w-12 lg:h-12 rounded bg-zinc-200 dark:bg-zinc-800 flex items-center justify-center">
<Play size={20} className="text-zinc-400 dark:text-zinc-600" /> <Play size={20} />
</div>
<span className="text-sm font-medium">Select a song to play</span>
</div> </div>
<span className="text-sm font-medium">{t('selectSongToPlay')}</span>
</button>
</div> </div>
); );
} }
@@ -321,6 +327,12 @@ export const Player: React.FC<PlayerProps> = ({
/> />
</div> </div>
)} )}
<ShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
song={currentSong}
/>
</div> </div>
); );
} }
@@ -744,8 +756,13 @@ export const Player: React.FC<PlayerProps> = ({
{/* Volume Control with Vertical Slider */} {/* Volume Control with Vertical Slider */}
<div <div
className="relative group hidden md:block" className="relative group hidden md:block"
onMouseEnter={() => setIsHoveringVolume(true)} onMouseEnter={() => {
onMouseLeave={() => setIsHoveringVolume(false)} if (volumeHideTimer.current) clearTimeout(volumeHideTimer.current);
setIsHoveringVolume(true);
}}
onMouseLeave={() => {
volumeHideTimer.current = setTimeout(() => setIsHoveringVolume(false), 400);
}}
> >
<button <button
onClick={() => onVolumeChange(volume === 0 ? 0.8 : 0)} onClick={() => onVolumeChange(volume === 0 ? 0.8 : 0)}
+1 -1
View File
@@ -40,7 +40,7 @@ export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBa
duration: s.duration, duration: s.duration,
bpm: s.bpm, bpm: s.bpm,
tags: s.tags || [], tags: s.tags || [],
isPublic: s.is_public || false, is_public: s.is_public || false,
likeCount: s.like_count || 0, likeCount: s.like_count || 0,
viewCount: s.view_count || 0, viewCount: s.view_count || 0,
creator: s.creator, creator: s.creator,
+1 -1
View File
@@ -489,7 +489,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
</span> </span>
)) ))
) : ( ) : (
song.style.split(',').map((tag, idx) => ( (song.style || '').split(',').filter(Boolean).map((tag, idx) => (
<span key={idx} className="px-2 py-0.5 bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 border border-zinc-200 dark:border-white/10 rounded text-[11px] font-medium text-zinc-600 dark:text-zinc-300 transition-colors"> <span key={idx} className="px-2 py-0.5 bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 border border-zinc-200 dark:border-white/10 rounded text-[11px] font-medium text-zinc-600 dark:text-zinc-300 transition-colors">
{tag.trim()} {tag.trim()}
</span> </span>
-1
View File
@@ -103,7 +103,6 @@ export const SearchPage: React.FC<SearchPageProps> = ({
uniqueCreators.set(song.creator, { uniqueCreators.set(song.creator, {
id: song.user_id || song.userId || song.creator, id: song.user_id || song.userId || song.creator,
username: song.creator, username: song.creator,
email: '',
created_at: song.created_at || song.createdAt, created_at: song.created_at || song.createdAt,
avatar_url: song.creator_avatar || song.creatorAvatar || null, avatar_url: song.creator_avatar || song.creatorAvatar || null,
}); });
+51 -28
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { X, User as UserIcon, Palette, Info, Edit3, ExternalLink, Globe, ChevronDown, Github } from 'lucide-react'; import { X, User as UserIcon, Palette, Info, Edit3, ExternalLink, Globe, ChevronDown, Github } from 'lucide-react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useI18n } from '../context/I18nContext'; import { useI18n } from '../context/I18nContext';
@@ -16,6 +16,19 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
const { user } = useAuth(); const { user } = useAuth();
const { t, language, setLanguage } = useI18n(); const { t, language, setLanguage } = useI18n();
const [isEditProfileOpen, setIsEditProfileOpen] = useState(false); const [isEditProfileOpen, setIsEditProfileOpen] = useState(false);
const [showLangInfo, setShowLangInfo] = useState(false);
const langInfoRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showLangInfo) return;
const handleClick = (e: MouseEvent) => {
if (langInfoRef.current && !langInfoRef.current.contains(e.target as Node)) {
setShowLangInfo(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [showLangInfo]);
if (!isOpen || !user) { if (!isOpen || !user) {
if (isEditProfileOpen && user) { if (isEditProfileOpen && user) {
@@ -108,6 +121,43 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
<div className="flex items-center gap-2 text-zinc-900 dark:text-white"> <div className="flex items-center gap-2 text-zinc-900 dark:text-white">
<Globe size={20} /> <Globe size={20} />
<h3 className="font-semibold">{t('language')}</h3> <h3 className="font-semibold">{t('language')}</h3>
<div className="relative" ref={langInfoRef}>
<button
onClick={() => setShowLangInfo(!showLangInfo)}
className="p-1 rounded-full text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors"
>
<Info size={14} />
</button>
{showLangInfo && (
<div className="absolute left-0 top-8 z-10 w-64 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-xl p-3">
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-2">{t('localizedBy')}</p>
<div className="flex flex-wrap gap-1.5">
<a
href="https://x.com/bdsqlsz"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-black dark:bg-white text-white dark:text-black rounded-lg text-xs font-medium hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
>
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
@bdsqlsz
</a>
<a
href="https://space.bilibili.com/219296"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-[#00A1D6] text-white rounded-lg text-xs font-medium hover:bg-[#0090C0] transition-colors"
>
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
<path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/>
</svg>
</a>
</div>
</div>
)}
</div>
</div> </div>
<div className="pl-7 space-y-3"> <div className="pl-7 space-y-3">
<div className="relative"> <div className="relative">
@@ -200,33 +250,6 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
Report issues or request features on GitHub Report issues or request features on GitHub
</p> </p>
</div> </div>
<div>
<p className="text-zinc-900 dark:text-white font-medium mb-2">{t('localizedBy')}</p>
<div className="flex flex-wrap gap-2">
<a
href="https://x.com/bdsqlsz"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-black dark:bg-white text-white dark:text-black rounded-lg text-sm font-medium hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
{t('follow')} @bdsqlsz
</a>
<a
href="https://space.bilibili.com/219296"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-[#00A1D6] text-white rounded-lg text-sm font-medium hover:bg-[#0090C0] transition-colors"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/>
</svg>
{t('follow')}
</a>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
+16 -1
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Library, Disc, Search, User, LogIn, LogOut, Sun, Moon } from 'lucide-react'; import { Library, Disc, Search, LogIn, LogOut, Sun, Moon, GraduationCap, Newspaper } from 'lucide-react';
import { View } from '../types'; import { View } from '../types';
import { useI18n } from '../context/I18nContext'; import { useI18n } from '../context/I18nContext';
@@ -104,6 +104,21 @@ export const Sidebar: React.FC<SidebarProps> = ({
onClick={() => onNavigate('search')} onClick={() => onNavigate('search')}
isExpanded={isOpen} isExpanded={isOpen}
/> />
<NavItem
icon={<GraduationCap size={20} />}
label={t('training')}
active={currentView === 'training'}
onClick={() => onNavigate('training')}
isExpanded={isOpen}
/>
<NavItem
icon={<Newspaper size={20} />}
label={t('news')}
active={currentView === 'news'}
onClick={() => onNavigate('news')}
isExpanded={isOpen}
/>
<div className="mt-auto flex flex-col gap-2"> <div className="mt-auto flex flex-col gap-2">
{/* Theme Toggle */} {/* Theme Toggle */}
<button <button
File diff suppressed because it is too large Load Diff
+15 -2
View File
@@ -228,17 +228,30 @@ export const VideoGeneratorModal: React.FC<VideoGeneratorModalProps> = ({ isOpen
} }
}); });
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm'; const cdnBases = [
'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm',
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm',
];
let loaded = false;
for (const baseURL of cdnBases) {
try {
await ffmpeg.load({ await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'), coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'), wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
}); });
loaded = true;
break;
} catch {
console.warn(`FFmpeg load failed from ${baseURL}, trying next CDN...`);
}
}
if (!loaded) throw new Error('All CDN sources failed');
ffmpegRef.current = ffmpeg; ffmpegRef.current = ffmpeg;
setFfmpegLoaded(true); setFfmpegLoaded(true);
} catch (error) { } catch (error) {
console.error('Failed to load FFmpeg:', error); console.error('Failed to load FFmpeg:', error);
alert('Failed to load video encoder. Please refresh and try again.'); alert('Failed to load video encoder. Check your internet connection and try again.');
} finally { } finally {
setFfmpegLoading(false); setFfmpegLoading(false);
} }
+16
View File
@@ -0,0 +1,16 @@
[
{
"id": "v1-gradio-migration",
"date": "2025-02-09",
"title": "Switched to Gradio API Backend",
"body": "We migrated the backend from REST API to Gradio API for direct communication with ACE-Step. This version is experimental — if you encounter any bugs, please report them or open a PR on our GitHub repo.",
"tags": ["backend", "experimental"]
},
{
"id": "v1-training-experimental",
"date": "2025-02-09",
"title": "LoRA Training (Experimental)",
"body": "The training feature is now available but still experimental. You may encounter bugs during dataset building, preprocessing, or training. Bug reports and PRs are welcome!",
"tags": ["training", "experimental"]
}
]
+20
View File
@@ -7,6 +7,7 @@ export const translations = {
library: 'Library', library: 'Library',
search: 'Search', search: 'Search',
training: 'Training', training: 'Training',
news: 'News',
// Theme // Theme
lightMode: 'Light Mode', lightMode: 'Light Mode',
@@ -264,6 +265,9 @@ export const translations = {
randomSeedRecommended: 'Randomized every run (recommended)', randomSeedRecommended: 'Randomized every run (recommended)',
fixedSeedReproducible: 'Fixed seed for reproducible results', fixedSeedReproducible: 'Fixed seed for reproducible results',
enterFixedSeed: 'Enter fixed seed', enterFixedSeed: 'Enter fixed seed',
enhance: 'AI Enhance',
enhanceHint: 'better genre accuracy, slightly slower',
enhanceTooltip: 'Uses the AI language model to enrich your genre/style tags into a detailed music description and generate proper BPM, key, and time signature. Improves genre accuracy but adds 10-20s to generation time. No extra VRAM needed.',
thinkingCot: 'Thinking (CoT)', thinkingCot: 'Thinking (CoT)',
mp3Smaller: 'MP3 (smaller)', mp3Smaller: 'MP3 (smaller)',
flacLossless: 'FLAC (lossless)', flacLossless: 'FLAC (lossless)',
@@ -473,6 +477,7 @@ export const translations = {
// Player // Player
nowPlaying: 'Now Playing', nowPlaying: 'Now Playing',
selectSongToPlay: 'Select a song to play',
downloadAudio: 'Download Audio', downloadAudio: 'Download Audio',
openInEditor: 'Open in Editor', openInEditor: 'Open in Editor',
anonymous: 'Anonymous', anonymous: 'Anonymous',
@@ -601,6 +606,7 @@ export const translations = {
library: '音乐库', library: '音乐库',
search: '搜索', search: '搜索',
training: '训练', training: '训练',
news: '新闻',
// Theme // Theme
lightMode: '浅色模式', lightMode: '浅色模式',
@@ -858,6 +864,9 @@ export const translations = {
randomSeedRecommended: '每次运行随机(推荐)', randomSeedRecommended: '每次运行随机(推荐)',
fixedSeedReproducible: '固定种子以获得可重现结果', fixedSeedReproducible: '固定种子以获得可重现结果',
enterFixedSeed: '输入固定种子', enterFixedSeed: '输入固定种子',
enhance: 'AI 增强',
enhanceHint: '更准确的风格,稍慢',
enhanceTooltip: '使用AI语言模型将风格标签丰富为详细的音乐描述,并生成准确的BPM、调性和拍号。提高风格准确度,但增加10-20秒生成时间。无需额外显存。',
thinkingCot: '思考模式(CoT', thinkingCot: '思考模式(CoT',
mp3Smaller: 'MP3(较小)', mp3Smaller: 'MP3(较小)',
flacLossless: 'FLAC(无损)', flacLossless: 'FLAC(无损)',
@@ -1067,6 +1076,7 @@ export const translations = {
// Player // Player
nowPlaying: '正在播放', nowPlaying: '正在播放',
selectSongToPlay: '选择一首歌曲播放',
downloadAudio: '下载音频', downloadAudio: '下载音频',
openInEditor: '在编辑器中打开', openInEditor: '在编辑器中打开',
anonymous: '匿名用户', anonymous: '匿名用户',
@@ -1195,6 +1205,7 @@ export const translations = {
library: 'ライブラリ', library: 'ライブラリ',
search: '検索', search: '検索',
training: 'トレーニング', training: 'トレーニング',
news: 'ニュース',
// Theme // Theme
lightMode: 'ライトモード', lightMode: 'ライトモード',
@@ -1452,6 +1463,9 @@ export const translations = {
randomSeedRecommended: '毎回ランダム化(推奨)', randomSeedRecommended: '毎回ランダム化(推奨)',
fixedSeedReproducible: '再現可能な結果のための固定シード', fixedSeedReproducible: '再現可能な結果のための固定シード',
enterFixedSeed: '固定シードを入力', enterFixedSeed: '固定シードを入力',
enhance: 'AI エンハンス',
enhanceHint: 'ジャンル精度向上、やや遅い',
enhanceTooltip: 'AIがジャンル/スタイルタグを詳細な音楽説明に変換し、適切なBPM、キー、拍子を生成します。ジャンルの精度が向上しますが、生成に10-20秒追加されます。追加VRAMは不要です。',
thinkingCot: '思考(CoT', thinkingCot: '思考(CoT',
mp3Smaller: 'MP3(小さい)', mp3Smaller: 'MP3(小さい)',
flacLossless: 'FLAC(ロスレス)', flacLossless: 'FLAC(ロスレス)',
@@ -1661,6 +1675,7 @@ export const translations = {
// Player // Player
nowPlaying: '再生中', nowPlaying: '再生中',
selectSongToPlay: '曲を選択して再生',
downloadAudio: 'オーディオをダウンロード', downloadAudio: 'オーディオをダウンロード',
openInEditor: 'エディターで開く', openInEditor: 'エディターで開く',
anonymous: '匿名ユーザー', anonymous: '匿名ユーザー',
@@ -1789,6 +1804,7 @@ export const translations = {
library: '라이브러리', library: '라이브러리',
search: '검색', search: '검색',
training: '훈련', training: '훈련',
news: '뉴스',
// Theme // Theme
lightMode: '라이트 모드', lightMode: '라이트 모드',
@@ -2046,6 +2062,9 @@ export const translations = {
randomSeedRecommended: '매 실행마다 무작위 (권장)', randomSeedRecommended: '매 실행마다 무작위 (권장)',
fixedSeedReproducible: '재현 가능한 결과를 위한 고정 시드', fixedSeedReproducible: '재현 가능한 결과를 위한 고정 시드',
enterFixedSeed: '고정 시드 입력', enterFixedSeed: '고정 시드 입력',
enhance: 'AI 향상',
enhanceHint: '더 정확한 장르, 약간 느림',
enhanceTooltip: 'AI 언어 모델을 사용하여 장르/스타일 태그를 상세한 음악 설명으로 변환하고 적절한 BPM, 키, 박자를 생성합니다. 장르 정확도가 향상되지만 생성 시간이 10-20초 추가됩니다. 추가 VRAM이 필요하지 않습니다.',
thinkingCot: '생각 (CoT)', thinkingCot: '생각 (CoT)',
mp3Smaller: 'MP3 (작음)', mp3Smaller: 'MP3 (작음)',
flacLossless: 'FLAC (무손실)', flacLossless: 'FLAC (무손실)',
@@ -2255,6 +2274,7 @@ export const translations = {
// Player // Player
nowPlaying: '재생 중', nowPlaying: '재생 중',
selectSongToPlay: '재생할 곡을 선택하세요',
downloadAudio: '오디오 다운로드', downloadAudio: '오디오 다운로드',
openInEditor: '편집기에서 열기', openInEditor: '편집기에서 열기',
anonymous: '익명 사용자', anonymous: '익명 사용자',
+140
View File
@@ -0,0 +1,140 @@
"""
Standalone dataset preprocessor for ACE-Step LoRA training.
Converts labeled audio samples from a dataset JSON into pre-computed
tensor files (.pt) suitable for training. This script loads the VAE and
text encoder independently, so it does NOT require the Gradio app to be
running.
Usage:
python preprocess_dataset.py --dataset /path/to/dataset.json --output /path/to/tensors [--json]
The --json flag makes the script output a final JSON summary line to stdout.
"""
import argparse
import json
import os
import sys
def main():
parser = argparse.ArgumentParser(description="Preprocess dataset to tensors for LoRA training")
parser.add_argument("--dataset", required=True, help="Path to dataset JSON file")
parser.add_argument("--output", required=True, help="Output directory for tensor files")
parser.add_argument("--max-duration", type=float, default=240.0, help="Max audio duration in seconds")
parser.add_argument("--json", action="store_true", help="Output JSON summary")
args = parser.parse_args()
if not os.path.exists(args.dataset):
print(f"Error: Dataset file not found: {args.dataset}", file=sys.stderr)
sys.exit(1)
# Add ACE-Step root to path for imports
ace_step_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Walk up to find ACE-Step-1.5 directory
for candidate in [
os.path.join(ace_step_root, "ACE-Step-1.5"),
os.path.join(os.path.dirname(ace_step_root), "ACE-Step-1.5"),
os.getcwd(),
]:
if os.path.isdir(candidate) and os.path.isdir(os.path.join(candidate, "acestep")):
ace_step_root = candidate
break
if ace_step_root not in sys.path:
sys.path.insert(0, ace_step_root)
try:
from acestep.training.dataset_builder import DatasetBuilder
except ImportError as e:
print(f"Error: Could not import ACE-Step modules: {e}", file=sys.stderr)
print("Make sure this script is run from the ACE-Step-1.5 directory or with the correct Python environment.", file=sys.stderr)
sys.exit(1)
# Load dataset JSON
print(f"Loading dataset: {args.dataset}")
with open(args.dataset, "r") as f:
dataset_data = json.load(f)
# Reconstruct DatasetBuilder from JSON
builder = DatasetBuilder()
builder.load_from_dict(dataset_data)
labeled_count = sum(1 for s in builder.samples if s.labeled)
total_count = len(builder.samples)
print(f"Dataset loaded: {total_count} samples, {labeled_count} labeled")
if labeled_count == 0:
msg = "No labeled samples found. Please label samples before preprocessing."
print(f"Warning: {msg}", file=sys.stderr)
if args.json:
print(json.dumps({"status": "error", "message": msg, "labeled": 0, "total": total_count}))
sys.exit(1)
# Load models for preprocessing
print("Loading models for preprocessing (this may take a moment)...")
try:
from acestep.pipeline_ace_step import ACEStepPipeline
checkpoint_dir = os.path.join(ace_step_root, "checkpoints")
if not os.path.isdir(checkpoint_dir):
checkpoint_dir = os.path.join(ace_step_root, "checkpoints", "ACE-Step-v1.5")
pipe = ACEStepPipeline(checkpoint_dir=checkpoint_dir)
pipe.load_checkpoint()
# Create a minimal dit_handler-like object for preprocess_to_tensors
class DitHandlerProxy:
def __init__(self, pipeline):
self.model = pipeline.dit
self.vae = pipeline.vae
self.text_encoder = pipeline.text_encoder
self.text_tokenizer = pipeline.text_tokenizer
self.silence_latent = getattr(pipeline, "silence_latent", None)
self.device = pipeline.device
self.dtype = pipeline.dtype
handler = DitHandlerProxy(pipe)
except Exception as e:
# If pipeline loading fails, try a simpler approach
print(f"Warning: Could not load full pipeline: {e}", file=sys.stderr)
print("Preprocessing requires model access. Please use the Gradio UI for preprocessing.", file=sys.stderr)
if args.json:
print(json.dumps({
"status": "error",
"message": f"Model loading failed: {str(e)}. Use Gradio UI preprocess instead.",
"labeled": labeled_count,
"total": total_count,
}))
sys.exit(1)
# Run preprocessing
os.makedirs(args.output, exist_ok=True)
print(f"Preprocessing to: {args.output}")
def progress_cb(msg):
print(f" {msg}")
output_paths, status = builder.preprocess_to_tensors(
dit_handler=handler,
output_dir=args.output,
max_duration=args.max_duration,
progress_callback=progress_cb,
)
print(f"Done: {status}")
print(f"Output files: {len(output_paths)}")
if args.json:
print(json.dumps({
"status": "complete",
"message": status,
"output_files": len(output_paths),
"output_dir": args.output,
"labeled": labeled_count,
"total": total_count,
}))
if __name__ == "__main__":
main()
+6
View File
@@ -35,6 +35,12 @@ export const config = {
audioDir: process.env.AUDIO_DIR || path.join(__dirname, '../../public/audio'), audioDir: process.env.AUDIO_DIR || path.join(__dirname, '../../public/audio'),
}, },
// Training datasets (inside ACE-Step-1.5 so Gradio can access them)
datasets: {
dir: process.env.DATASETS_DIR || path.join(__dirname, '../../../ACE-Step-1.5/datasets'),
uploadsDir: process.env.DATASETS_UPLOADS_DIR || path.join(__dirname, '../../../ACE-Step-1.5/datasets/uploads'),
},
// Simplified JWT (for local session, not critical security) // Simplified JWT (for local session, not critical security)
jwt: { jwt: {
secret: process.env.JWT_SECRET || 'ace-step-ui-local-secret', secret: process.env.JWT_SECRET || 'ace-step-ui-local-secret',
+1
View File
@@ -19,6 +19,7 @@ try {
const dbInstance = new Database(config.database.path); const dbInstance = new Database(config.database.path);
dbInstance.pragma('journal_mode = WAL'); dbInstance.pragma('journal_mode = WAL');
dbInstance.pragma('foreign_keys = ON'); dbInstance.pragma('foreign_keys = ON');
dbInstance.pragma('busy_timeout = 5000');
export { dbInstance as db }; export { dbInstance as db };
+2
View File
@@ -24,6 +24,7 @@ import playlistsRoutes from './routes/playlists.js';
import contactRoutes from './routes/contact.js'; import contactRoutes from './routes/contact.js';
import referenceTrackRoutes from './routes/referenceTrack.js'; import referenceTrackRoutes from './routes/referenceTrack.js';
import loraRoutes from './routes/lora.js'; import loraRoutes from './routes/lora.js';
import trainingRoutes from './routes/training.js';
import { pool } from './db/pool.js'; import { pool } from './db/pool.js';
import './db/migrate.js'; import './db/migrate.js';
@@ -405,6 +406,7 @@ app.use('/api/playlists', playlistsRoutes);
app.use('/api/contact', contactRoutes); app.use('/api/contact', contactRoutes);
app.use('/api/reference-tracks', referenceTrackRoutes); app.use('/api/reference-tracks', referenceTrackRoutes);
app.use('/api/lora', loraRoutes); app.use('/api/lora', loraRoutes);
app.use('/api/training', trainingRoutes);
// Error handler // Error handler
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
+1 -15
View File
@@ -36,21 +36,7 @@ router.post('/', async (req: Request, res: Response) => {
return; return;
} }
// Create table if not exists // Insert submission (table created in migrate.ts)
await pool.query(`
CREATE TABLE IF NOT EXISTS contact_submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
subject VARCHAR(500) NOT NULL,
message TEXT NOT NULL,
category VARCHAR(50) DEFAULT 'general',
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Insert submission
const result = await pool.query( const result = await pool.query(
`INSERT INTO contact_submissions (name, email, subject, message, category) `INSERT INTO contact_submissions (name, email, subject, message, category)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
+215 -31
View File
@@ -4,7 +4,9 @@ import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { pool } from '../db/pool.js'; import { pool } from '../db/pool.js';
import { generateUUID } from '../db/sqlite.js'; import { generateUUID } from '../db/sqlite.js';
import { config } from '../config/index.js';
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth.js'; import { authMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
import { getGradioClient } from '../services/gradio-client.js';
import { import {
generateMusicViaAPI, generateMusicViaAPI,
getJobStatus, getJobStatus,
@@ -20,6 +22,30 @@ import { getStorageProvider } from '../services/storage/factory.js';
const router = Router(); const router = Router();
// Auto-generate a song title from lyrics or style when none is provided
function autoTitle(params: { title?: string; lyrics?: string; instrumental?: boolean; style?: string; songDescription?: string }): string {
if (params.title?.trim()) return params.title.trim();
// Try first meaningful lyric line (skip section markers like [verse], [chorus])
if (!params.instrumental && params.lyrics) {
for (const line of params.lyrics.split('\n')) {
const t = line.trim();
if (t && !/^\[.*\]$/.test(t)) {
return t.length > 40 ? t.slice(0, 40).trimEnd() + '…' : t;
}
}
}
// Fall back to first 4 words of style or description
const source = params.style || params.songDescription || '';
if (source) {
const words = source.trim().split(/\s+/).slice(0, 4).join(' ');
return words.charAt(0).toUpperCase() + words.slice(1);
}
return 'Untitled';
}
const audioUpload = multer({ const audioUpload = multer({
storage: multer.memoryStorage(), storage: multer.memoryStorage(),
limits: { fileSize: 25 * 1024 * 1024 }, // 25MB max limits: { fileSize: 25 * 1024 * 1024 }, // 25MB max
@@ -123,9 +149,20 @@ interface GenerateBody {
trackName?: string; trackName?: string;
completeTrackClasses?: string[]; completeTrackClasses?: string[];
isFormatCaption?: boolean; isFormatCaption?: boolean;
// Model selection
ditModel?: string;
} }
router.post('/upload-audio', authMiddleware, audioUpload.single('audio'), async (req: AuthenticatedRequest, res: Response) => { router.post('/upload-audio', authMiddleware, (req: AuthenticatedRequest, res: Response, next: Function) => {
audioUpload.single('audio')(req, res, (err: any) => {
if (err) {
res.status(400).json({ error: err.message || 'Invalid file upload' });
return;
}
next();
});
}, async (req: AuthenticatedRequest, res: Response) => {
try { try {
if (!req.file) { if (!req.file) {
res.status(400).json({ error: 'Audio file is required' }); res.status(400).json({ error: 'Audio file is required' });
@@ -161,7 +198,7 @@ router.post('/upload-audio', authMiddleware, audioUpload.single('audio'), async
const ext = extFromName || extFromType || '.audio'; const ext = extFromName || extFromType || '.audio';
const key = `references/${req.user!.id}/${Date.now()}-${generateUUID()}${ext}`; const key = `references/${req.user!.id}/${Date.now()}-${generateUUID()}${ext}`;
const storedKey = await storage.upload(key, req.file.buffer, req.file.mimetype); const storedKey = await storage.upload(key, req.file.buffer, req.file.mimetype);
const publicUrl = storedKey; const publicUrl = storage.getPublicUrl(storedKey);
res.json({ url: publicUrl, key: storedKey }); res.json({ url: publicUrl, key: storedKey });
} catch (error) { } catch (error) {
@@ -227,6 +264,7 @@ router.post('/', authMiddleware, async (req: AuthenticatedRequest, res: Response
trackName, trackName,
completeTrackClasses, completeTrackClasses,
isFormatCaption, isFormatCaption,
ditModel,
} = req.body as GenerateBody; } = req.body as GenerateBody;
if (!customMode && !songDescription) { if (!customMode && !songDescription) {
@@ -294,6 +332,7 @@ router.post('/', authMiddleware, async (req: AuthenticatedRequest, res: Response
trackName, trackName,
completeTrackClasses, completeTrackClasses,
isFormatCaption, isFormatCaption,
ditModel,
}; };
// Create job record in database // Create job record in database
@@ -351,6 +390,7 @@ router.get('/status/:jobId', authMiddleware, async (req: AuthenticatedRequest, r
const aceStatus = await getJobStatus(job.acestep_task_id); const aceStatus = await getJobStatus(job.acestep_task_id);
if (aceStatus.status !== job.status) { if (aceStatus.status !== job.status) {
// Use optimistic lock: only update if status hasn't changed (prevents duplicate song creation)
let updateQuery = `UPDATE generation_jobs SET status = ?, updated_at = datetime('now')`; let updateQuery = `UPDATE generation_jobs SET status = ?, updated_at = datetime('now')`;
const updateParams: unknown[] = [aceStatus.status]; const updateParams: unknown[] = [aceStatus.status];
@@ -362,24 +402,26 @@ router.get('/status/:jobId', authMiddleware, async (req: AuthenticatedRequest, r
updateParams.push(aceStatus.error); updateParams.push(aceStatus.error);
} }
updateQuery += ` WHERE id = ?`; updateQuery += ` WHERE id = ? AND status = ?`;
updateParams.push(req.params.jobId); updateParams.push(req.params.jobId, job.status);
await pool.query(updateQuery, updateParams); const updateResult = await pool.query(updateQuery, updateParams);
const wasUpdated = updateResult.rowCount > 0;
// If succeeded, create song records // If succeeded AND we were the first to update (optimistic lock), create song records
if (aceStatus.status === 'succeeded' && aceStatus.result) { if (aceStatus.status === 'succeeded' && aceStatus.result && wasUpdated) {
const params = typeof job.params === 'string' ? JSON.parse(job.params) : job.params; const params = typeof job.params === 'string' ? JSON.parse(job.params) : job.params;
const audioUrls = aceStatus.result.audioUrls.filter((url: string) => const audioUrls = aceStatus.result.audioUrls.filter((url: string) => {
url.endsWith('.mp3') || url.endsWith('.flac') const lower = url.toLowerCase();
); return lower.endsWith('.mp3') || lower.endsWith('.flac') || lower.endsWith('.wav');
});
const localPaths: string[] = []; const localPaths: string[] = [];
const storage = getStorageProvider(); const storage = getStorageProvider();
for (let i = 0; i < audioUrls.length; i++) { for (let i = 0; i < audioUrls.length; i++) {
const audioUrl = audioUrls[i]; const audioUrl = audioUrls[i];
const variationSuffix = audioUrls.length > 1 ? ` (v${i + 1})` : ''; const variationSuffix = audioUrls.length > 1 ? ` (v${i + 1})` : '';
const songTitle = (params.title || 'Untitled') + variationSuffix; const songTitle = autoTitle(params) + variationSuffix;
const songId = generateUUID(); const songId = generateUUID();
@@ -403,7 +445,7 @@ router.get('/status/:jobId', authMiddleware, async (req: AuthenticatedRequest, r
params.style, params.style,
params.style, params.style,
storedPath, storedPath,
aceStatus.result.duration && aceStatus.result.duration > 0 ? aceStatus.result.duration : (params.duration && params.duration > 0 ? params.duration : 120), aceStatus.result.duration && aceStatus.result.duration > 0 ? aceStatus.result.duration : (params.duration && params.duration > 0 ? params.duration : 0),
aceStatus.result.bpm || params.bpm, aceStatus.result.bpm || params.bpm,
aceStatus.result.keyScale || params.keyScale, aceStatus.result.keyScale || params.keyScale,
aceStatus.result.timeSignature || params.timeSignature, aceStatus.result.timeSignature || params.timeSignature,
@@ -429,7 +471,7 @@ router.get('/status/:jobId', authMiddleware, async (req: AuthenticatedRequest, r
params.style, params.style,
params.style, params.style,
audioUrl, audioUrl,
aceStatus.result.duration && aceStatus.result.duration > 0 ? aceStatus.result.duration : (params.duration && params.duration > 0 ? params.duration : 120), aceStatus.result.duration && aceStatus.result.duration > 0 ? aceStatus.result.duration : (params.duration && params.duration > 0 ? params.duration : 0),
aceStatus.result.bpm || params.bpm, aceStatus.result.bpm || params.bpm,
aceStatus.result.keyScale || params.keyScale, aceStatus.result.keyScale || params.keyScale,
aceStatus.result.timeSignature || params.timeSignature, aceStatus.result.timeSignature || params.timeSignature,
@@ -554,19 +596,116 @@ router.get('/endpoints', authMiddleware, async (_req: AuthenticatedRequest, res:
} }
}); });
router.get('/models', async (_req, res: Response) => {
try {
const ACESTEP_DIR = process.env.ACESTEP_PATH || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../ACE-Step-1.5');
const checkpointsDir = path.join(ACESTEP_DIR, 'checkpoints');
// All known DiT models from Gradio's model_downloader.py registry:
// - MAIN_MODEL_COMPONENTS includes "acestep-v15-turbo" (bundled with main download)
// - SUBMODEL_REGISTRY includes the rest (separate HuggingFace repos, auto-downloaded on init)
const ALL_DIT_MODELS = [
'acestep-v15-turbo', // default, from main model repo
'acestep-v15-base', // submodel
'acestep-v15-sft', // submodel
'acestep-v15-turbo-shift1', // submodel
'acestep-v15-turbo-shift3', // submodel
'acestep-v15-turbo-continuous', // submodel
];
// Query Gradio /v1/models to get the currently loaded/active model
let activeModel: string | null = null;
try {
const apiRes = await fetch(`${config.acestep.apiUrl}/v1/models`);
if (apiRes.ok) {
const data = await apiRes.json() as any;
const gradioModels = data?.data?.models || data?.models || [];
if (gradioModels.length > 0) {
activeModel = gradioModels[0]?.name || null;
}
}
} catch {
// Gradio API unavailable
}
// Check which models are downloaded (exist on disk)
// Matches Gradio's handler.py check_model_exists() and get_available_acestep_v15_models()
const { existsSync, statSync } = await import('fs');
const downloaded = new Set<string>();
for (const model of ALL_DIT_MODELS) {
const modelPath = path.join(checkpointsDir, model);
try {
if (existsSync(modelPath) && statSync(modelPath).isDirectory()) {
downloaded.add(model);
}
} catch { /* skip */ }
}
// Also scan for any additional acestep-v15-* models on disk not in the registry
// (e.g. user-trained or community models)
try {
const { readdirSync } = await import('fs');
for (const entry of readdirSync(checkpointsDir)) {
if (entry.startsWith('acestep-v15-') && statSync(path.join(checkpointsDir, entry)).isDirectory()) {
downloaded.add(entry);
if (!ALL_DIT_MODELS.includes(entry)) {
ALL_DIT_MODELS.push(entry);
}
}
}
} catch { /* checkpoints dir may not exist */ }
const models = ALL_DIT_MODELS.map(name => ({
name,
is_active: name === activeModel,
is_preloaded: downloaded.has(name),
}));
// Sort: active first, then downloaded, then alphabetical
models.sort((a, b) => {
if (a.is_active !== b.is_active) return a.is_active ? -1 : 1;
if (a.is_preloaded !== b.is_preloaded) return a.is_preloaded ? -1 : 1;
return a.name.localeCompare(b.name);
});
res.json({ models });
} catch (error) {
console.error('Models error:', error);
res.status(500).json({ error: (error as Error).message });
}
});
// GET /api/generate/random-description — Load a random simple description from Gradio
router.get('/random-description', authMiddleware, async (_req: AuthenticatedRequest, res: Response) => {
try {
const client = await getGradioClient();
const result = await client.predict('/load_random_simple_description', []);
const data = result.data as unknown[];
// Returns [description, instrumental, vocal_language]
res.json({
description: data[0] || '',
instrumental: data[1] || false,
vocalLanguage: data[2] || 'unknown',
});
} catch (error) {
console.error('Random description error:', error);
res.status(500).json({ error: (error as Error).message });
}
});
router.get('/health', async (_req, res: Response) => { router.get('/health', async (_req, res: Response) => {
try { try {
const healthy = await checkSpaceHealth(); const healthy = await checkSpaceHealth();
res.json({ healthy }); res.json({ healthy, aceStepUrl: config.acestep.apiUrl });
} catch (error) { } catch (error) {
res.json({ healthy: false, error: (error as Error).message }); res.json({ healthy: false, aceStepUrl: config.acestep.apiUrl, error: (error as Error).message });
} }
}); });
router.get('/limits', async (_req, res: Response) => { router.get('/limits', async (_req, res: Response) => {
try { try {
const { spawn } = await import('child_process'); const { spawn } = await import('child_process');
const ACESTEP_DIR = process.env.ACESTEP_PATH || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../ACE-Step-1.5'); const ACESTEP_DIR = process.env.ACESTEP_PATH || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../ACE-Step-1.5');
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const SCRIPTS_DIR = path.join(__dirname, '../../scripts'); const SCRIPTS_DIR = path.join(__dirname, '../../scripts');
@@ -640,21 +779,71 @@ router.post('/format', authMiddleware, async (req: AuthenticatedRequest, res: Re
return; return;
} }
const { spawn } = await import('child_process'); const ACESTEP_API_URL = config.acestep.apiUrl;
const ACESTEP_DIR = process.env.ACESTEP_PATH || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../ACE-Step-1.5'); // Build param_obj for the REST API
const paramObj: Record<string, unknown> = {};
if (bpm && bpm > 0) paramObj.bpm = bpm;
if (duration && duration > 0) paramObj.duration = duration;
if (keyScale) paramObj.key = keyScale;
if (timeSignature) paramObj.time_signature = timeSignature;
// Primary path: call ACE-Step's /format_input REST endpoint (avoids Python spawn ENOENT on Windows)
try {
console.log(`[Format] Calling REST API: ${ACESTEP_API_URL}/format_input`);
const apiRes = await fetch(`${ACESTEP_API_URL}/format_input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: caption,
lyrics: lyrics || '',
temperature: temperature ?? 0.85,
param_obj: paramObj,
}),
signal: AbortSignal.timeout(300_000), // 5 min — LLM may need to init first
});
const apiData = await apiRes.json() as any;
if (!apiRes.ok || apiData.code !== 200) {
const errMsg = apiData.error || apiData.detail || `Format API returned ${apiRes.status}`;
console.error('[Format] API error:', errMsg);
res.status(500).json({ success: false, error: errMsg });
return;
}
const d = apiData.data;
res.json({
caption: d.caption,
lyrics: d.lyrics,
bpm: d.bpm,
duration: d.duration,
key_scale: d.key_scale,
time_signature: d.time_signature,
vocal_language: d.vocal_language,
});
return;
} catch (fetchErr: any) {
// Only fall back to Python spawn on network errors (service not yet reachable)
if (fetchErr?.name !== 'AbortError' && (fetchErr?.code === 'ECONNREFUSED' || fetchErr?.cause?.code === 'ECONNREFUSED')) {
console.warn('[Format] REST API unreachable, falling back to Python spawn');
} else {
console.error('[Format] REST API request failed:', fetchErr?.message);
res.status(500).json({ success: false, error: fetchErr?.message || 'Format request failed' });
return;
}
}
// Fallback: Python spawn (only reached when REST API is unreachable)
const { spawn } = await import('child_process');
const ACESTEP_DIR = process.env.ACESTEP_PATH || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../ACE-Step-1.5');
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const SCRIPTS_DIR = path.join(__dirname, '../../scripts'); const SCRIPTS_DIR = path.join(__dirname, '../../scripts');
const FORMAT_SCRIPT = path.join(SCRIPTS_DIR, 'format_sample.py'); const FORMAT_SCRIPT = path.join(SCRIPTS_DIR, 'format_sample.py');
const pythonPath = resolvePythonPath(ACESTEP_DIR); const pythonPath = resolvePythonPath(ACESTEP_DIR);
const args = [ const args = [FORMAT_SCRIPT, '--caption', caption, '--json'];
FORMAT_SCRIPT,
'--caption', caption,
'--json',
];
if (lyrics) args.push('--lyrics', lyrics); if (lyrics) args.push('--lyrics', lyrics);
if (bpm && bpm > 0) args.push('--bpm', String(bpm)); if (bpm && bpm > 0) args.push('--bpm', String(bpm));
if (duration && duration > 0) args.push('--duration', String(duration)); if (duration && duration > 0) args.push('--duration', String(duration));
@@ -666,15 +855,11 @@ router.post('/format', authMiddleware, async (req: AuthenticatedRequest, res: Re
if (lmModel) args.push('--lm-model', lmModel); if (lmModel) args.push('--lm-model', lmModel);
if (lmBackend) args.push('--lm-backend', lmBackend); if (lmBackend) args.push('--lm-backend', lmBackend);
console.log(`[Format] Running: ${pythonPath} ${args.join(' ')}`); console.log(`[Format] Fallback spawn: ${pythonPath} ${args.join(' ')}`);
console.log(`[Format] CWD: ${ACESTEP_DIR}`);
const result = await new Promise<{ success: boolean; data?: any; error?: string }>((resolve) => { const result = await new Promise<{ success: boolean; data?: any; error?: string }>((resolve) => {
const proc = spawn(pythonPath, args, { const proc = spawn(pythonPath, args, {
cwd: ACESTEP_DIR, cwd: ACESTEP_DIR,
env: { env: { ...process.env, ACESTEP_PATH: ACESTEP_DIR },
...process.env,
ACESTEP_PATH: ACESTEP_DIR,
},
}); });
let stdout = ''; let stdout = '';
@@ -685,7 +870,6 @@ router.post('/format', authMiddleware, async (req: AuthenticatedRequest, res: Re
proc.on('close', (code) => { proc.on('close', (code) => {
if (code === 0 && stdout) { if (code === 0 && stdout) {
// stdout may contain log lines before the JSON — extract last JSON line
const lines = stdout.trim().split('\n'); const lines = stdout.trim().split('\n');
let jsonStr = ''; let jsonStr = '';
for (let i = lines.length - 1; i >= 0; i--) { for (let i = lines.length - 1; i >= 0; i--) {
+1 -11
View File
@@ -519,13 +519,9 @@ router.get('/liked/list', authMiddleware, async (req: AuthenticatedRequest, res:
} }
}); });
// Toggle song privacy (paid users only can make songs private) // Toggle song privacy
router.patch('/:id/privacy', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { router.patch('/:id/privacy', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try { try {
// Get user's account tier
const userResult = await pool.query('SELECT account_tier FROM users WHERE id = $1', [req.user!.id]);
const accountTier = userResult.rows[0]?.account_tier || 'free';
const check = await pool.query('SELECT user_id, is_public FROM songs WHERE id = $1', [req.params.id]); const check = await pool.query('SELECT user_id, is_public FROM songs WHERE id = $1', [req.params.id]);
if (check.rows.length === 0) { if (check.rows.length === 0) {
res.status(404).json({ error: 'Song not found' }); res.status(404).json({ error: 'Song not found' });
@@ -538,12 +534,6 @@ router.patch('/:id/privacy', authMiddleware, async (req: AuthenticatedRequest, r
const newPublicState = !check.rows[0].is_public; const newPublicState = !check.rows[0].is_public;
// Free users cannot make songs private
if (accountTier === 'free' && !newPublicState) {
res.status(403).json({ error: 'Upgrade to Pro or Unlimited to make songs private' });
return;
}
await pool.query('UPDATE songs SET is_public = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', [ await pool.query('UPDATE songs SET is_public = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', [
newPublicState, newPublicState,
req.params.id, req.params.id,
+874
View File
@@ -0,0 +1,874 @@
import { Router, Request, Response } from 'express';
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
import { getGradioClient } from '../services/gradio-client.js';
import { config } from '../config/index.js';
import { resolvePythonPath } from '../services/acestep.js';
import multer from 'multer';
import path from 'path';
import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
import { mkdir, writeFile, readFile } from 'fs/promises';
import { execSync, spawn } from 'child_process';
import { randomUUID } from 'crypto';
const router = Router();
// --- Audio upload via multer disk storage ---
const AUDIO_EXTENSIONS = ['.wav', '.mp3', '.flac', '.ogg', '.opus'];
const audioStorage = multer.diskStorage({
destination: async (_req: Request, _file, cb) => {
const datasetName = (_req.body?.datasetName as string) || 'default';
const dest = path.join(config.datasets.uploadsDir, datasetName);
try {
await mkdir(dest, { recursive: true });
cb(null, dest);
} catch (err) {
cb(err as Error, dest);
}
},
filename: (_req, file, cb) => {
// Preserve original filename but ensure uniqueness
const ext = path.extname(file.originalname).toLowerCase();
const base = path.basename(file.originalname, ext);
const safeName = base.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
cb(null, `${safeName}${ext}`);
},
});
const audioUpload = multer({
storage: audioStorage,
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB per file
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (AUDIO_EXTENSIONS.includes(ext)) {
cb(null, true);
} else {
cb(new Error(`Unsupported file type: ${ext}. Allowed: ${AUDIO_EXTENSIONS.join(', ')}`));
}
},
});
// Get audio duration via ffprobe
function getAudioDuration(filePath: string): number {
try {
const result = execSync(
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`,
{ encoding: 'utf-8', timeout: 10000 }
);
const duration = parseFloat(result.trim());
return isNaN(duration) ? 0 : Math.round(duration);
} catch {
return 0;
}
}
// Resolve ACE-Step base directory
function getAceStepDir(): string {
const envPath = process.env.ACESTEP_PATH;
if (envPath) {
return path.isAbsolute(envPath) ? envPath : path.resolve(process.cwd(), envPath);
}
return path.resolve(config.datasets.dir, '..');
}
// ================== NEW ROUTES ==================
// POST /api/training/upload-audio — Upload audio files for a dataset
router.post('/upload-audio', authMiddleware, audioUpload.array('audio', 50), async (req: AuthenticatedRequest, res: Response) => {
try {
const files = req.files as Express.Multer.File[];
if (!files || files.length === 0) {
res.status(400).json({ error: 'No audio files uploaded' });
return;
}
const datasetName = (req.body?.datasetName as string) || 'default';
const uploadDir = path.join(config.datasets.uploadsDir, datasetName);
res.json({
files: files.map(f => ({
filename: f.filename,
originalName: f.originalname,
size: f.size,
path: f.path,
})),
uploadDir,
count: files.length,
});
} catch (error) {
console.error('[Training] Upload audio error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Upload failed' });
}
});
// POST /api/training/build-dataset — Scan audio directory + create dataset JSON
router.post('/build-dataset', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const {
datasetName = 'my_lora_dataset',
customTag = '',
tagPosition = 'prepend',
allInstrumental = true,
} = req.body;
const audioDir = path.join(config.datasets.uploadsDir, datasetName);
if (!existsSync(audioDir)) {
res.status(400).json({ error: `Audio directory not found: uploads/${datasetName}` });
return;
}
// Scan for audio files
const entries = readdirSync(audioDir);
const audioFiles = entries.filter(f => AUDIO_EXTENSIONS.includes(path.extname(f).toLowerCase()));
if (audioFiles.length === 0) {
res.status(400).json({ error: 'No audio files found in directory' });
return;
}
// Build samples in Gradio's exact format
const samples = audioFiles.map(filename => {
const audioPath = path.join(audioDir, filename);
const duration = getAudioDuration(audioPath);
const baseName = path.basename(filename, path.extname(filename));
// Check for companion .txt lyrics file
let rawLyrics = '';
const lyricsPath = path.join(audioDir, `${baseName}.txt`);
if (existsSync(lyricsPath)) {
try {
rawLyrics = readFileSync(lyricsPath, 'utf-8').trim();
} catch { /* ignore */ }
}
const isInstrumental = allInstrumental || !rawLyrics;
return {
id: randomUUID().slice(0, 8),
audio_path: audioPath,
filename,
caption: '',
genre: '',
lyrics: isInstrumental ? '[Instrumental]' : rawLyrics,
raw_lyrics: rawLyrics,
formatted_lyrics: '',
bpm: null as number | null,
keyscale: '',
timesignature: '',
duration,
language: isInstrumental ? 'instrumental' : 'unknown',
is_instrumental: isInstrumental,
custom_tag: customTag,
labeled: false,
prompt_override: null as string | null,
};
});
// Build dataset JSON
const dataset = {
metadata: {
name: datasetName,
custom_tag: customTag,
tag_position: tagPosition,
created_at: new Date().toISOString(),
num_samples: samples.length,
all_instrumental: allInstrumental,
genre_ratio: 0,
},
samples,
};
// Save JSON to datasets dir
await mkdir(config.datasets.dir, { recursive: true });
const jsonPath = path.join(config.datasets.dir, `${datasetName}.json`);
await writeFile(jsonPath, JSON.stringify(dataset, null, 2), 'utf-8');
// Now load into Gradio state via the existing endpoint
try {
const client = await getGradioClient();
const result = await client.predict('/load_existing_dataset_for_preprocess', [jsonPath]);
const data = result.data as unknown[];
res.json({
status: data[0],
dataframe: data[1],
sampleCount: samples.length,
sample: {
index: data[2],
audio: data[3],
filename: data[4],
caption: data[5],
genre: data[6],
promptOverride: data[7],
lyrics: data[8],
bpm: data[9],
key: data[10],
timeSignature: data[11],
duration: data[12],
language: data[13],
instrumental: data[14],
rawLyrics: data[15],
},
settings: {
datasetName: data[16],
customTag: data[17],
tagPosition: data[18],
allInstrumental: data[19],
genreRatio: data[20],
},
datasetPath: jsonPath,
});
} catch (gradioError) {
// Gradio may not be running — still return dataset info
console.warn('[Training] Gradio load failed, returning dataset JSON only:', gradioError);
res.json({
status: `Dataset saved (${samples.length} samples). Gradio not available for live preview.`,
dataframe: null,
sampleCount: samples.length,
sample: samples.length > 0 ? {
index: 0,
audio: null,
filename: samples[0].filename,
caption: samples[0].caption,
genre: samples[0].genre,
promptOverride: null,
lyrics: samples[0].lyrics,
bpm: samples[0].bpm,
key: samples[0].keyscale,
timeSignature: samples[0].timesignature,
duration: samples[0].duration,
language: samples[0].language,
instrumental: samples[0].is_instrumental,
rawLyrics: samples[0].raw_lyrics,
} : null,
settings: {
datasetName,
customTag,
tagPosition,
allInstrumental,
genreRatio: 0,
},
datasetPath: jsonPath,
});
}
} catch (error) {
console.error('[Training] Build dataset error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to build dataset' });
}
});
// GET /api/training/audio — Proxy audio files from datasets directory
router.get('/audio', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
let filePath: string;
const aceStepDir = getAceStepDir();
if (req.query.path) {
filePath = req.query.path as string;
} else if (req.query.file) {
// Relative path within datasets dir
filePath = path.join(config.datasets.dir, req.query.file as string);
} else {
res.status(400).json({ error: 'path or file parameter required' });
return;
}
// Path traversal protection
const resolved = path.resolve(filePath);
if (resolved.includes('..') || !resolved.startsWith(aceStepDir)) {
res.status(403).json({ error: 'Access denied: path outside ACE-Step directory' });
return;
}
if (!existsSync(resolved)) {
res.status(404).json({ error: 'Audio file not found' });
return;
}
// Determine content type
const ext = path.extname(resolved).toLowerCase();
const mimeTypes: Record<string, string> = {
'.wav': 'audio/wav',
'.mp3': 'audio/mpeg',
'.flac': 'audio/flac',
'.ogg': 'audio/ogg',
'.opus': 'audio/opus',
};
res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream');
res.sendFile(resolved);
} catch (error) {
console.error('[Training] Audio proxy error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to serve audio' });
}
});
// POST /api/training/preprocess — Spawn Python preprocessing script
router.post('/preprocess', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { datasetPath, outputDir } = req.body;
if (!datasetPath) {
res.status(400).json({ error: 'datasetPath is required' });
return;
}
const aceStepDir = getAceStepDir();
const scriptPath = path.resolve(__dirname, '../../scripts/preprocess_dataset.py');
const pythonPath = resolvePythonPath(aceStepDir);
const resolvedOutput = outputDir || path.join(config.datasets.dir, 'preprocessed_tensors');
// Ensure output dir exists
await mkdir(resolvedOutput, { recursive: true });
// Spawn Python process
const child = spawn(pythonPath, [
scriptPath,
'--dataset', datasetPath,
'--output', resolvedOutput,
'--json',
], {
cwd: aceStepDir,
env: { ...process.env },
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
child.on('close', (code: number | null) => {
if (code === 0) {
// Try to parse JSON output
try {
const result = JSON.parse(stdout.trim().split('\n').pop() || '{}');
res.json({ status: 'Preprocessing complete', ...result });
} catch {
res.json({ status: 'Preprocessing complete', output: stdout.trim() });
}
} else {
res.status(500).json({
error: 'Preprocessing failed',
code,
stderr: stderr.trim(),
stdout: stdout.trim(),
});
}
});
child.on('error', (err: Error) => {
res.status(500).json({ error: `Failed to spawn process: ${err.message}` });
});
} catch (error) {
console.error('[Training] Preprocess error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Preprocessing failed' });
}
});
// POST /api/training/scan-directory — Scan a directory for audio files (Node.js implementation)
router.post('/scan-directory', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const {
audioDir,
datasetName = 'my_lora_dataset',
customTag = '',
tagPosition = 'prepend',
allInstrumental = true,
} = req.body;
if (!audioDir || typeof audioDir !== 'string') {
res.status(400).json({ error: 'audioDir is required' });
return;
}
// Resolve path — if relative, resolve from ACE-Step dir
const aceStepDir = getAceStepDir();
const resolvedDir = path.isAbsolute(audioDir)
? audioDir
: path.resolve(aceStepDir, audioDir);
if (!existsSync(resolvedDir)) {
res.status(400).json({ error: `Directory not found: ${audioDir}` });
return;
}
// Scan for audio files
const entries = readdirSync(resolvedDir);
const audioFiles = entries.filter(f => AUDIO_EXTENSIONS.includes(path.extname(f).toLowerCase()));
if (audioFiles.length === 0) {
res.status(400).json({ error: 'No audio files found in directory' });
return;
}
// Build table data matching Gradio's format: [#, Filename, Duration, Lyrics, Labeled, BPM, Key, Caption]
const tableHeaders = ['#', 'Filename', 'Duration', 'Lyrics', 'Labeled', 'BPM', 'Key', 'Caption'];
const tableData = audioFiles.map((filename, i) => {
const audioPath = path.join(resolvedDir, filename);
const duration = getAudioDuration(audioPath);
const baseName = path.basename(filename, path.extname(filename));
// Check for companion .txt lyrics file
let lyrics = allInstrumental ? '[Instrumental]' : '';
const lyricsPath = path.join(resolvedDir, `${baseName}.txt`);
if (existsSync(lyricsPath)) {
try {
lyrics = readFileSync(lyricsPath, 'utf-8').trim().slice(0, 50) + '...';
} catch { /* ignore */ }
}
return [i + 1, filename, `${duration}s`, lyrics, '❌', '', '', ''];
});
res.json({
status: `Found ${audioFiles.length} audio files`,
dataframe: {
headers: tableHeaders,
data: tableData,
},
sampleCount: audioFiles.length,
audioDir: resolvedDir,
});
} catch (error) {
console.error('[Training] Scan directory error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to scan directory' });
}
});
// POST /api/training/auto-label — Auto-label dataset samples
// NOTE: Auto-labeling requires the DIT model + LLM to be loaded in Gradio.
// This endpoint attempts to call the Gradio handler. If the Gradio app does not
// expose auto_label_all as a named API, this will fail and the user should use
// the Gradio UI directly.
router.post('/auto-label', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const {
skipMetas = false,
formatLyrics = false,
transcribeLyrics = false,
onlyUnlabeled = false,
} = req.body;
// auto_label_all is a lambda-wrapped handler in Gradio, so it may not be accessible
// by name. We try the likely endpoint name; if it fails, return a helpful message.
const client = await getGradioClient();
try {
const result = await client.predict('/auto_label_all', [
skipMetas,
formatLyrics,
transcribeLyrics,
onlyUnlabeled,
]);
const data = result.data as unknown[];
res.json({
dataframe: data[0],
status: data[1],
});
} catch (gradioError) {
// Lambda endpoints aren't named — suggest using Gradio UI
res.status(501).json({
error: 'Auto-labeling requires the Gradio UI. The model must be initialized and the dataset loaded in the Gradio training tab.',
hint: 'Use the Gradio UI at the ACE-Step server URL to auto-label your dataset, then reload it here.',
});
}
} catch (error) {
console.error('[Training] Auto-label error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Auto-label failed' });
}
});
// POST /api/training/init-model — Initialize or change model for training
// NOTE: Model initialization requires the Gradio app. This endpoint attempts to
// call the init_service_wrapper. Since it's a lambda, this may not be accessible.
router.post('/init-model', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const {
checkpoint,
configPath,
device = 'auto',
initLlm = false,
lmModelPath = '',
backend = 'pt',
useFlashAttention = false,
offloadToCpu = false,
offloadDitToCpu = false,
compileModel = false,
quantization = false,
} = req.body;
const client = await getGradioClient();
try {
// Try calling by function name (may work if Gradio auto-names it)
const result = await client.predict('/init_service_wrapper', [
checkpoint ?? '',
configPath ?? '',
device,
initLlm,
lmModelPath,
backend,
useFlashAttention,
offloadToCpu,
offloadDitToCpu,
compileModel,
quantization,
]);
const data = result.data as unknown[];
res.json({
status: data[0],
modelReady: !!data[1],
});
} catch (gradioError) {
// Lambda endpoints aren't named — suggest using Gradio UI
res.status(501).json({
error: 'Model initialization requires the Gradio UI.',
hint: 'Initialize the model in the ACE-Step Gradio UI service configuration section, then return here for training.',
});
}
} catch (error) {
console.error('[Training] Init model error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Model init failed' });
}
});
// GET /api/training/checkpoints — List available model checkpoints
router.get('/checkpoints', authMiddleware, async (_req: AuthenticatedRequest, res: Response) => {
try {
const aceStepDir = getAceStepDir();
const checkpointDir = path.join(aceStepDir, 'checkpoints');
if (!existsSync(checkpointDir)) {
res.json({ checkpoints: [], configs: [] });
return;
}
// List checkpoint directories
const entries = readdirSync(checkpointDir);
const checkpoints = entries.filter(e => {
const fullPath = path.join(checkpointDir, e);
return statSync(fullPath).isDirectory();
});
// List config directories (acestep-v15-*)
const configDirs = entries.filter(e =>
e.startsWith('acestep-v15') && statSync(path.join(checkpointDir, e)).isDirectory()
);
res.json({ checkpoints, configs: configDirs });
} catch (error) {
console.error('[Training] List checkpoints error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to list checkpoints' });
}
});
// GET /api/training/lora-checkpoints — List LoRA training checkpoints in output dir
router.get('/lora-checkpoints', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const outputDir = (req.query.dir as string) || './lora_output';
const aceStepDir = getAceStepDir();
const resolvedDir = path.isAbsolute(outputDir)
? outputDir
: path.resolve(aceStepDir, outputDir);
if (!existsSync(resolvedDir)) {
res.json({ checkpoints: [] });
return;
}
const entries = readdirSync(resolvedDir);
const checkpointsDir = path.join(resolvedDir, 'checkpoints');
const checkpoints: string[] = [];
if (existsSync(checkpointsDir)) {
const cpEntries = readdirSync(checkpointsDir);
cpEntries.forEach(e => {
if (statSync(path.join(checkpointsDir, e)).isDirectory()) {
checkpoints.push(path.join(checkpointsDir, e));
}
});
}
// Also check for "final" directory
const finalDir = path.join(resolvedDir, 'final');
if (existsSync(finalDir)) {
checkpoints.push(finalDir);
}
res.json({ checkpoints, outputDir: resolvedDir });
} catch (error) {
console.error('[Training] List LoRA checkpoints error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to list checkpoints' });
}
});
// ================== EXISTING ROUTES ==================
// POST /api/training/load-dataset — Load an existing dataset JSON for preprocessing
router.post('/load-dataset', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { datasetPath } = req.body;
if (!datasetPath || typeof datasetPath !== 'string') {
res.status(400).json({ error: 'datasetPath is required' });
return;
}
// Reject path traversal
if (datasetPath.includes('..')) {
res.status(400).json({ error: 'Invalid path' });
return;
}
const client = await getGradioClient();
const result = await client.predict('/load_existing_dataset_for_preprocess', [datasetPath]);
const data = result.data as unknown[];
// Returns: [status, dataframe, sampleIdx, audioPreview, filename, caption, genre,
// promptOverride, lyrics, bpm, key, timesig, duration, language, instrumental,
// rawLyrics, datasetName, customTag, tagPosition, allInstrumental, genreRatio]
res.json({
status: data[0],
dataframe: data[1],
sampleCount: Array.isArray((data[1] as any)?.data) ? (data[1] as any).data.length : 0,
sample: {
index: data[2],
audio: data[3],
filename: data[4],
caption: data[5],
genre: data[6],
promptOverride: data[7],
lyrics: data[8],
bpm: data[9],
key: data[10],
timeSignature: data[11],
duration: data[12],
language: data[13],
instrumental: data[14],
rawLyrics: data[15],
},
settings: {
datasetName: data[16],
customTag: data[17],
tagPosition: data[18],
allInstrumental: data[19],
genreRatio: data[20],
},
});
} catch (error) {
console.error('[Training] Load dataset error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load dataset' });
}
});
// GET /api/training/sample-preview — Get preview data for a specific sample
router.get('/sample-preview', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const idx = parseInt(req.query.idx as string) || 0;
const client = await getGradioClient();
const result = await client.predict('/get_sample_preview', [idx]);
const data = result.data as unknown[];
// Returns: [audio, filename, caption, genre, promptOverride, lyrics, bpm, key, timesig, duration, language, instrumental, rawLyrics]
res.json({
audio: data[0],
filename: data[1],
caption: data[2],
genre: data[3],
promptOverride: data[4],
lyrics: data[5],
bpm: data[6],
key: data[7],
timeSignature: data[8],
duration: data[9],
language: data[10],
instrumental: data[11],
rawLyrics: data[12],
});
} catch (error) {
console.error('[Training] Sample preview error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to get sample preview' });
}
});
// POST /api/training/save-sample — Save edits to a dataset sample
router.post('/save-sample', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { sampleIdx, caption, genre, promptOverride, lyrics, bpm, key, timeSignature, language, instrumental } = req.body;
const client = await getGradioClient();
const result = await client.predict('/save_sample_edit', [
sampleIdx ?? 0,
caption ?? '',
genre ?? '',
promptOverride ?? 'Use Global Ratio',
lyrics ?? '',
bpm ?? 120,
key ?? '',
timeSignature ?? '',
language ?? 'instrumental',
instrumental ?? true,
]);
const data = result.data as unknown[];
// Returns: [dataframe, editStatus]
res.json({
dataframe: data[0],
status: data[1],
});
} catch (error) {
console.error('[Training] Save sample error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to save sample edit' });
}
});
// POST /api/training/update-settings — Update dataset global settings
// Settings are applied directly when saving (via REST API), so no Gradio call needed here.
router.post('/update-settings', authMiddleware, (_req: AuthenticatedRequest, res: Response) => {
res.json({ success: true });
});
// POST /api/training/save-dataset — Save the dataset to a JSON file
router.post('/save-dataset', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { savePath, datasetName, customTag, tagPosition, allInstrumental, genreRatio } = req.body;
const resolvedPath = (savePath ?? `./datasets/${datasetName ?? 'my_lora_dataset'}.json`).trim();
// Use REST API to avoid @gradio/client Radio serialization issues
const apiUrl = config.acestep.apiUrl;
const body: Record<string, unknown> = {
save_path: resolvedPath,
dataset_name: datasetName ?? 'my_lora_dataset',
};
if (customTag !== undefined) body.custom_tag = customTag;
if (tagPosition !== undefined) body.tag_position = tagPosition;
if (allInstrumental !== undefined) body.all_instrumental = allInstrumental;
if (genreRatio !== undefined) body.genre_ratio = genreRatio;
const apiRes = await fetch(`${apiUrl}/v1/dataset/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
if (!apiRes.ok) {
const err = await apiRes.json().catch(() => ({})) as any;
throw new Error(err?.detail || err?.error || `Save failed: ${apiRes.status}`);
}
const data = await apiRes.json() as any;
res.json({
status: data.status ?? 'Saved',
path: data.save_path ?? resolvedPath,
});
} catch (error) {
console.error('[Training] Save dataset error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to save dataset' });
}
});
// POST /api/training/load-tensors — Load preprocessed tensors for training
router.post('/load-tensors', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { tensorDir } = req.body;
const client = await getGradioClient();
const result = await client.predict('/load_training_dataset', [
tensorDir ?? './datasets/preprocessed_tensors',
]);
const data = result.data as unknown[];
res.json({ status: data[0] });
} catch (error) {
console.error('[Training] Load tensors error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load training dataset' });
}
});
// POST /api/training/start — Start LoRA training
router.post('/start', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const {
tensorDir, rank, alpha, dropout, learningRate,
epochs, batchSize, gradientAccumulation, saveEvery,
shift, seed, outputDir, resumeCheckpoint,
} = req.body;
const client = await getGradioClient();
const result = await client.predict('/training_wrapper', [
tensorDir ?? './datasets/preprocessed_tensors',
rank ?? 64,
alpha ?? 128,
dropout ?? 0.1,
learningRate ?? 0.0003,
epochs ?? 1000,
batchSize ?? 1,
gradientAccumulation ?? 1,
saveEvery ?? 200,
shift ?? 3.0,
seed ?? 42,
outputDir ?? './lora_output',
resumeCheckpoint ?? null,
]);
const data = result.data as unknown[];
// Returns: [trainingProgress, trainingLog, lineplotData]
res.json({
progress: data[0],
log: data[1],
metrics: data[2],
});
} catch (error) {
console.error('[Training] Start training error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to start training' });
}
});
// POST /api/training/stop — Stop current training
router.post('/stop', authMiddleware, async (_req: AuthenticatedRequest, res: Response) => {
try {
const client = await getGradioClient();
const result = await client.predict('/stop_training', []);
const data = result.data as unknown[];
res.json({ status: data[0] });
} catch (error) {
console.error('[Training] Stop training error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to stop training' });
}
});
// POST /api/training/export — Export trained LoRA weights
router.post('/export', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { exportPath, loraOutputDir } = req.body;
const client = await getGradioClient();
const result = await client.predict('/export_lora', [
exportPath ?? './lora_output/final_lora',
loraOutputDir ?? './lora_output',
]);
const data = result.data as unknown[];
res.json({ status: data[0] });
} catch (error) {
console.error('[Training] Export LoRA error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to export LoRA' });
}
});
// POST /api/training/import-dataset — Import train/test split
router.post('/import-dataset', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { datasetType } = req.body;
const client = await getGradioClient();
const result = await client.predict('/import_dataset', [
datasetType ?? 'train',
]);
const data = result.data as unknown[];
res.json({ status: data[0] });
} catch (error) {
console.error('[Training] Import dataset error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to import dataset' });
}
});
export default router;
+2
View File
@@ -31,9 +31,11 @@ async function main() {
tier: job.tier as 'free' | 'pro' | 'unlimited', tier: job.tier as 'free' | 'pro' | 'unlimited',
createdAt: Date.now(), createdAt: Date.now(),
params: { params: {
customMode: true,
lyrics: 'test', lyrics: 'test',
style: 'test', style: 'test',
title: 'test', title: 'test',
instrumental: false,
duration: 30, duration: 30,
}, },
run: async () => { run: async () => {
+155 -45
View File
@@ -34,8 +34,8 @@ function resolveAceStepPath(): string {
if (envPath) { if (envPath) {
return path.isAbsolute(envPath) ? envPath : path.resolve(process.cwd(), envPath); return path.isAbsolute(envPath) ? envPath : path.resolve(process.cwd(), envPath);
} }
// Default: sibling directory // Default: sibling directory (server/src/services -> ../../../ACE-Step-1.5 = app/ACE-Step-1.5)
return path.resolve(__dirname, '../../../../ACE-Step-1.5'); return path.resolve(__dirname, '../../../ACE-Step-1.5');
} }
// Resolve Python path cross-platform (supports venv and portable installations) // Resolve Python path cross-platform (supports venv and portable installations)
@@ -54,11 +54,22 @@ export function resolvePythonPath(baseDir: string): string {
return portablePath; return portablePath;
} }
// Standard venv path (different structure on Windows vs Unix) // Check common venv directory names (Pinokio uses 'env', others use '.venv' or 'venv')
if (isWindows) { const venvDirs = ['env', '.venv', 'venv'];
return path.join(baseDir, '.venv', 'Scripts', pythonExe); for (const venvDir of venvDirs) {
const venvPython = isWindows
? path.join(baseDir, venvDir, 'Scripts', pythonExe)
: path.join(baseDir, venvDir, 'bin', 'python');
if (existsSync(venvPython)) {
return venvPython;
} }
return path.join(baseDir, '.venv', 'bin', 'python'); }
// Fallback to first option (will produce a clear error if not found)
if (isWindows) {
return path.join(baseDir, 'env', 'Scripts', pythonExe);
}
return path.join(baseDir, 'env', 'bin', 'python');
} }
const ACESTEP_DIR = resolveAceStepPath(); const ACESTEP_DIR = resolveAceStepPath();
@@ -66,7 +77,7 @@ const SCRIPTS_DIR = path.join(__dirname, '../../scripts');
const PYTHON_SCRIPT = path.join(SCRIPTS_DIR, 'simple_generate.py'); const PYTHON_SCRIPT = path.join(SCRIPTS_DIR, 'simple_generate.py');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Gradio generation: map params to the 45 positional args for /generation_wrapper // Gradio generation: map params to the 51 positional args for /generation_wrapper
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
@@ -99,7 +110,11 @@ async function prepareAudioFile(audioUrl: string | undefined): Promise<unknown>
try { try {
const buffer = await readFile(filePath); const buffer = await readFile(filePath);
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
const mimeType = ext === '.flac' ? 'audio/flac' : ext === '.wav' ? 'audio/wav' : 'audio/mpeg'; const mimeMap: Record<string, string> = {
'.flac': 'audio/flac', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
'.opus': 'audio/opus', '.m4a': 'audio/mp4', '.mp4': 'audio/mp4',
};
const mimeType = mimeMap[ext] || 'audio/mpeg';
const blob = new Blob([buffer], { type: mimeType }); const blob = new Blob([buffer], { type: mimeType });
return handle_file(blob); return handle_file(blob);
} catch (error) { } catch (error) {
@@ -113,18 +128,28 @@ async function prepareAudioFile(audioUrl: string | undefined): Promise<unknown>
} }
/** /**
* Build the 45 positional arguments for the Gradio /generation_wrapper endpoint. * Build the 50 positional arguments for the Gradio /generation_wrapper endpoint.
*/ */
async function buildGradioArgs(params: GenerationParams): Promise<unknown[]> { async function buildGradioArgs(params: GenerationParams): Promise<unknown[]> {
const caption = params.style || 'pop music'; const caption = params.style || 'pop music';
const prompt = params.customMode ? caption : (params.songDescription || caption); const prompt = params.customMode ? caption : (params.songDescription || caption);
const lyrics = params.instrumental ? '' : (params.lyrics || ''); const lyrics = params.instrumental ? '' : (params.lyrics || '');
const isThinking = params.thinking ?? false; const isThinking = params.thinking ?? false;
const isEnhance = params.enhance ?? false;
// Prepare audio files (async — reads from disk) // Prepare audio files (async — reads from disk)
const referenceAudio = await prepareAudioFile(params.referenceAudioUrl); const referenceAudio = await prepareAudioFile(params.referenceAudioUrl);
const sourceAudio = await prepareAudioFile(params.sourceAudioUrl); const sourceAudio = await prepareAudioFile(params.sourceAudioUrl);
// Guard: cover/repaint modes require source audio to be loadable
const needsSource = params.taskType === 'cover' || params.taskType === 'audio2audio' || params.taskType === 'repaint';
if (needsSource && params.sourceAudioUrl && sourceAudio === null) {
throw new Error(`Source audio file could not be loaded from: ${params.sourceAudioUrl}. Make sure the file was uploaded successfully.`);
}
// CoT features are gated by enhance OR thinking (either enables LLM enrichment)
const useCot = isEnhance || isThinking;
return [ return [
prompt, // 0: Music Caption prompt, // 0: Music Caption
lyrics, // 1: Lyrics lyrics, // 1: Lyrics
@@ -138,39 +163,47 @@ async function buildGradioArgs(params: GenerationParams): Promise<unknown[]> {
String(params.seed ?? -1), // 9: Seed String(params.seed ?? -1), // 9: Seed
referenceAudio, // 10: Reference Audio (filepath | null) referenceAudio, // 10: Reference Audio (filepath | null)
params.duration && params.duration > 0 ? params.duration : -1, // 11: Audio Duration (-1 = auto) params.duration && params.duration > 0 ? params.duration : -1, // 11: Audio Duration (-1 = auto)
params.batchSize ?? 1, // 12: Batch Size Math.min(Math.max(params.batchSize ?? 1, 1), 16), // 12: Batch Size (clamped 1-16)
sourceAudio, // 13: Source Audio (filepath | null) sourceAudio, // 13: Source Audio (filepath | null)
params.audioCodes || '', // 14: LM Codes Hints params.audioCodes || '', // 14: LM Codes Hints
params.repaintingStart ?? 0.0, // 15: Repainting Start params.repaintingStart ?? 0.0, // 15: Repainting Start
params.repaintingEnd ?? -1, // 16: Repainting End params.repaintingEnd ?? -1, // 16: Repainting End
params.instruction || 'Fill the audio semantic mask with the style described in the text prompt.', // 17: Instruction params.instruction || 'Fill the audio semantic mask with the style described in the text prompt.', // 17: Instruction
params.audioCoverStrength ?? 1.0, // 18: LM Codes Strength params.audioCoverStrength ?? 1.0, // 18: Audio Cover Strength
params.taskType || 'text2music', // 19: Task Type 0.0, // 19: Cover Noise Strength (ACE-Step v1.5 new param, default 0.0)
params.useAdg ?? false, // 20: Use ADG (params.taskType === 'audio2audio' ? 'cover' : params.taskType) || 'text2music', // 20: Task Type
params.cfgIntervalStart ?? 0.0, // 21: CFG Interval Start params.useAdg ?? false, // 21: Use ADG
params.cfgIntervalEnd ?? 1.0, // 22: CFG Interval End params.cfgIntervalStart ?? 0.0, // 22: CFG Interval Start
params.shift ?? 3.0, // 23: Shift params.cfgIntervalEnd ?? 1.0, // 23: CFG Interval End
params.inferMethod || 'ode', // 24: Inference Method params.shift ?? 3.0, // 24: Shift
params.customTimesteps || '', // 25: Custom Timesteps params.inferMethod || 'ode', // 25: Inference Method
params.audioFormat || 'mp3', // 26: Audio Format params.customTimesteps || '', // 26: Custom Timesteps
params.lmTemperature ?? 0.85, // 27: LM Temperature params.audioFormat || 'mp3', // 27: Audio Format
isThinking, // 28: Think params.lmTemperature ?? 0.85, // 28: LM Temperature
params.lmCfgScale ?? 2.0, // 29: LM CFG Scale isThinking, // 29: Think
params.lmTopK ?? 0, // 30: LM Top-K params.lmCfgScale ?? 2.0, // 30: LM CFG Scale
params.lmTopP ?? 0.9, // 31: LM Top-P params.lmTopK ?? 0, // 31: LM Top-K
params.lmNegativePrompt || 'NO USER INPUT', // 32: LM Negative Prompt params.lmTopP ?? 0.9, // 32: LM Top-P
isThinking ? (params.useCotMetas ?? true) : false, // 33: CoT Metas params.lmNegativePrompt || 'NO USER INPUT', // 33: LM Negative Prompt
isThinking ? (params.useCotCaption ?? true) : false, // 34: CaptionRewrite useCot ? (params.useCotMetas ?? true) : false, // 34: CoT Metas
isThinking ? (params.useCotLanguage ?? true) : false, // 35: CoT Language useCot ? (params.useCotCaption ?? true) : false, // 35: CaptionRewrite
params.constrainedDecodingDebug ?? false, // 36: Constrained Decoding Debug useCot ? (params.useCotLanguage ?? true) : false, // 36: CoT Language
params.allowLmBatch ?? true, // 37: ParallelThinking params.isFormatCaption ?? false, // 37: Is Format Caption State
params.getScores ?? false, // 38: Auto Score params.constrainedDecodingDebug ?? false, // 38: Constrained Decoding Debug
params.getLrc ?? false, // 39: Auto LRC params.allowLmBatch ?? true, // 39: ParallelThinking
params.scoreScale ?? 0.5, // 40: Quality Score Sensitivity params.getScores ?? false, // 40: Auto Score
params.lmBatchChunkSize ?? 8, // 41: LM Batch Chunk Size params.getLrc ?? false, // 41: Auto LRC (timestamped lyrics)
params.trackName || '', // 42: Track Name params.scoreScale ?? 0.5, // 42: Quality Score Sensitivity (0.01-1.0)
params.completeTrackClasses || [], // 43: Track Names params.lmBatchChunkSize ?? 8, // 43: LM Batch Chunk Size
params.autogen ?? false, // 44: AutoGen params.trackName || null, // 44: Track Name
params.completeTrackClasses || [], // 45: Track Names
true, // 46: Enable Normalization (ACE-Step v1.5, default true)
-1.0, // 47: Normalization DB (ACE-Step v1.5, default -1.0)
0.0, // 48: Latent Shift (ACE-Step v1.5, default 0.0)
1.0, // 49: Latent Rescale (ACE-Step v1.5, default 1.0)
params.autogen ?? false, // 50: AutoGen
// Note: current_batch_index, total_batches, batch_queue, generation_params_state
// are hidden Gradio state variables and must NOT be passed via client.predict()
]; ];
} }
@@ -191,14 +224,20 @@ async function downloadGradioAudioFile(
return; return;
} }
// Fall back to HTTP download via Gradio URL // Fall back to HTTP download via Gradio URL (use temp file for atomicity)
if (fileObj.url) { if (fileObj.url) {
const response = await fetch(fileObj.url); const response = await fetch(fileObj.url);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to download Gradio audio: ${response.status}`); throw new Error(`Failed to download Gradio audio: ${response.status}`);
} }
const buffer = Buffer.from(await response.arrayBuffer()); const buffer = Buffer.from(await response.arrayBuffer());
await writeFile(destPath, buffer); if (buffer.length === 0) {
throw new Error('Downloaded audio file is empty');
}
const tmpPath = destPath + '.tmp';
await writeFile(tmpPath, buffer);
const { rename } = await import('fs/promises');
await rename(tmpPath, destPath);
return; return;
} }
@@ -238,6 +277,7 @@ export interface GenerationParams {
randomSeed?: boolean; randomSeed?: boolean;
seed?: number; seed?: number;
thinking?: boolean; thinking?: boolean;
enhance?: boolean;
audioFormat?: 'mp3' | 'flac'; audioFormat?: 'mp3' | 'flac';
inferMethod?: 'ode' | 'sde'; inferMethod?: 'ode' | 'sde';
shift?: number; shift?: number;
@@ -319,6 +359,9 @@ interface ActiveJob {
const activeJobs = new Map<string, ActiveJob>(); const activeJobs = new Map<string, ActiveJob>();
// Periodic cleanup of old jobs (every 10 minutes, remove jobs older than 1 hour)
setInterval(() => cleanupOldJobs(3600000), 600000);
// Job queue for sequential processing (GPU can only handle one job at a time) // Job queue for sequential processing (GPU can only handle one job at a time)
const jobQueue: string[] = []; const jobQueue: string[] = [];
let isProcessingQueue = false; let isProcessingQueue = false;
@@ -328,6 +371,40 @@ export async function checkSpaceHealth(): Promise<boolean> {
return isGradioAvailable(); return isGradioAvailable();
} }
// ---------------------------------------------------------------------------
// Model switching — call /v1/init to change the active DiT model
// ---------------------------------------------------------------------------
async function getActiveModel(): Promise<string | null> {
try {
const res = await fetch(`${ACESTEP_API}/v1/models`);
if (!res.ok) return null;
const data = await res.json() as any;
const models = data?.data?.models || data?.models || [];
return models[0]?.name || null;
} catch {
return null;
}
}
async function switchModelIfNeeded(ditModel: string): Promise<void> {
const activeModel = await getActiveModel();
if (activeModel === ditModel) return; // already loaded, no-op
console.log(`[Model] Switching from '${activeModel ?? 'unknown'}' to '${ditModel}'`);
const res = await fetch(`${ACESTEP_API}/v1/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: ditModel, init_llm: false }),
});
if (!res.ok) {
const err = await res.text().catch(() => '');
throw new Error(`Model switch to '${ditModel}' failed: ${res.status} ${err}`);
}
console.log(`[Model] Switched to '${ditModel}'`);
}
// Discover endpoints (for compatibility) // Discover endpoints (for compatibility)
export async function discoverEndpoints(): Promise<unknown> { export async function discoverEndpoints(): Promise<unknown> {
return { provider: 'acestep-gradio', endpoint: ACESTEP_API }; return { provider: 'acestep-gradio', endpoint: ACESTEP_API };
@@ -435,6 +512,12 @@ async function processGenerationViaGradio(
params: GenerationParams, params: GenerationParams,
job: ActiveJob, job: ActiveJob,
): Promise<void> { ): Promise<void> {
// Switch DiT model if a specific one was requested
if (params.ditModel) {
job.stage = `Loading model ${params.ditModel}...`;
await switchModelIfNeeded(params.ditModel);
}
const client = await getGradioClient(); const client = await getGradioClient();
const args = await buildGradioArgs(params); const args = await buildGradioArgs(params);
@@ -453,6 +536,10 @@ async function processGenerationViaGradio(
const result = await client.predict('/generation_wrapper', args); const result = await client.predict('/generation_wrapper', args);
const data = result.data as unknown[]; const data = result.data as unknown[];
if (!Array.isArray(data) || data.length === 0) {
throw new Error(`Gradio returned unexpected data format: ${typeof data}`);
}
// Extract audio files from the result // Extract audio files from the result
// Outputs 0-7: individual audio samples (filepath objects) // Outputs 0-7: individual audio samples (filepath objects)
// Output 8: "All Generated Files" as list[filepath] // Output 8: "All Generated Files" as list[filepath]
@@ -511,7 +598,7 @@ async function processGenerationViaGradio(
const finalDuration = actualDuration > 0 const finalDuration = actualDuration > 0
? actualDuration ? actualDuration
: (metas.duration || params.duration || 60); : (metas.duration || params.duration || 0);
job.status = 'succeeded'; job.status = 'succeeded';
job.result = { job.result = {
@@ -598,7 +685,8 @@ async function processGenerationViaPython(
if (params.vocalLanguage) args.push('--vocal-language', params.vocalLanguage); if (params.vocalLanguage) args.push('--vocal-language', params.vocalLanguage);
if (params.seed !== undefined && params.seed >= 0 && !params.randomSeed) args.push('--seed', String(params.seed)); if (params.seed !== undefined && params.seed >= 0 && !params.randomSeed) args.push('--seed', String(params.seed));
if (params.shift !== undefined) args.push('--shift', String(params.shift)); if (params.shift !== undefined) args.push('--shift', String(params.shift));
if (params.taskType && params.taskType !== 'text2music') args.push('--task-type', params.taskType); const resolvedTaskType = params.taskType === 'audio2audio' ? 'cover' : params.taskType;
if (resolvedTaskType && resolvedTaskType !== 'text2music') args.push('--task-type', resolvedTaskType);
if (params.referenceAudioUrl) { if (params.referenceAudioUrl) {
args.push('--reference-audio', resolveAudioPath(params.referenceAudioUrl)); args.push('--reference-audio', resolveAudioPath(params.referenceAudioUrl));
@@ -621,8 +709,7 @@ async function processGenerationViaPython(
if (params.lmTopK !== undefined && params.lmTopK > 0) args.push('--lm-top-k', String(params.lmTopK)); if (params.lmTopK !== undefined && params.lmTopK > 0) args.push('--lm-top-k', String(params.lmTopK));
if (params.lmTopP !== undefined) args.push('--lm-top-p', String(params.lmTopP)); if (params.lmTopP !== undefined) args.push('--lm-top-p', String(params.lmTopP));
if (params.lmNegativePrompt) args.push('--lm-negative-prompt', params.lmNegativePrompt); if (params.lmNegativePrompt) args.push('--lm-negative-prompt', params.lmNegativePrompt);
if (params.lmBackend) args.push('--lm-backend', params.lmBackend); // Note: --lm-backend and --lm-model are not supported by simple_generate.py
if (params.lmModel) args.push('--lm-model', params.lmModel);
if (params.useCotMetas === false) args.push('--no-cot-metas'); if (params.useCotMetas === false) args.push('--no-cot-metas');
if (params.useCotCaption === false) args.push('--no-cot-caption'); if (params.useCotCaption === false) args.push('--no-cot-caption');
if (params.useCotLanguage === false) args.push('--no-cot-language'); if (params.useCotLanguage === false) args.push('--no-cot-language');
@@ -663,7 +750,7 @@ async function processGenerationViaPython(
console.warn(`Job ${jobId}: Failed to cleanup output dir`, cleanupError); console.warn(`Job ${jobId}: Failed to cleanup output dir`, cleanupError);
} }
const finalDuration = actualDuration > 0 ? actualDuration : (params.duration && params.duration > 0 ? params.duration : 60); const finalDuration = actualDuration > 0 ? actualDuration : (params.duration && params.duration > 0 ? params.duration : 0);
job.status = 'succeeded'; job.status = 'succeeded';
job.result = { job.result = {
@@ -696,7 +783,7 @@ interface PythonResult {
error?: string; error?: string;
} }
function runPythonGeneration(scriptArgs: string[]): Promise<PythonResult> { function runPythonGeneration(scriptArgs: string[], timeoutMs = 600000): Promise<PythonResult> {
return new Promise((resolve) => { return new Promise((resolve) => {
const pythonPath = resolvePythonPath(ACESTEP_DIR); const pythonPath = resolvePythonPath(ACESTEP_DIR);
const args = [PYTHON_SCRIPT, ...scriptArgs]; const args = [PYTHON_SCRIPT, ...scriptArgs];
@@ -709,6 +796,13 @@ function runPythonGeneration(scriptArgs: string[]): Promise<PythonResult> {
}, },
}); });
// Kill process after timeout (default 10 minutes)
const timer = setTimeout(() => {
proc.kill('SIGTERM');
setTimeout(() => { if (!proc.killed) proc.kill('SIGKILL'); }, 5000);
resolve({ success: false, error: `Generation timed out after ${timeoutMs / 1000}s` });
}, timeoutMs);
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
@@ -727,6 +821,7 @@ function runPythonGeneration(scriptArgs: string[]): Promise<PythonResult> {
}); });
proc.on('close', (code) => { proc.on('close', (code) => {
clearTimeout(timer);
if (code !== 0) { if (code !== 0) {
resolve({ success: false, error: stderr || `Process exited with code ${code}` }); resolve({ success: false, error: stderr || `Process exited with code ${code}` });
return; return;
@@ -749,6 +844,7 @@ function runPythonGeneration(scriptArgs: string[]): Promise<PythonResult> {
}); });
proc.on('error', (err) => { proc.on('error', (err) => {
clearTimeout(timer);
resolve({ success: false, error: err.message }); resolve({ success: false, error: err.message });
}); });
}); });
@@ -831,6 +927,20 @@ export async function getAudioStream(audioPath: string): Promise<Response> {
} }
} }
// Absolute path — try reading directly from disk (Gradio output files)
if (audioPath.startsWith('/')) {
try {
const buffer = await readFile(audioPath);
const ext = audioPath.endsWith('.flac') ? 'flac' : audioPath.endsWith('.wav') ? 'wav' : 'mpeg';
return new Response(buffer, {
status: 200,
headers: { 'Content-Type': `audio/${ext}` }
});
} catch {
// Fall through to Gradio API
}
}
const url = `${ACESTEP_API}/v1/audio?path=${encodeURIComponent(audioPath)}`; const url = `${ACESTEP_API}/v1/audio?path=${encodeURIComponent(audioPath)}`;
console.log('Fetching audio from:', url); console.log('Fetching audio from:', url);
return fetch(url); return fetch(url);
+16 -7
View File
@@ -41,17 +41,26 @@ export function resetGradioClient(): void {
/** /**
* Check if the Gradio app is reachable. * Check if the Gradio app is reachable.
* Tries multiple well-known endpoints to handle version differences.
*/ */
export async function isGradioAvailable(): Promise<boolean> { export async function isGradioAvailable(): Promise<boolean> {
const baseUrl = config.acestep.apiUrl;
const candidates = [
`${baseUrl}/gradio_api/info`, // Gradio 5+
`${baseUrl}/info`, // Gradio 4.x fallback
`${baseUrl}/`, // Any HTTP response means server is up
];
for (const url of candidates) {
try { try {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000); const timer = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`${config.acestep.apiUrl}/gradio_api/info`, { const response = await fetch(url, { signal: controller.signal });
signal: controller.signal, clearTimeout(timer);
}); if (response.ok || response.status < 500) return true;
clearTimeout(timeout);
return response.ok;
} catch { } catch {
// Try next candidate
}
}
return false; return false;
} }
}
+4 -5
View File
@@ -22,14 +22,13 @@ export class LocalStorageProvider implements StorageProvider {
} }
async getUrl(key: string, _expiresIn?: number): Promise<string> { async getUrl(key: string, _expiresIn?: number): Promise<string> {
return `/audio/${key}`; const cleanKey = key.startsWith('/audio/') ? key.slice('/audio/'.length) : key;
return `/audio/${cleanKey.replace(/^\/+/, '')}`;
} }
getPublicUrl(key: string): string { getPublicUrl(key: string): string {
if (key.startsWith('/audio/')) { const cleanKey = key.startsWith('/audio/') ? key.slice('/audio/'.length) : key;
return key; return `/audio/${cleanKey.replace(/^\/+/, '')}`;
}
return `/audio/${key}`;
} }
async delete(key: string): Promise<void> { async delete(key: string): Promise<void> {
+264
View File
@@ -106,6 +106,7 @@ export interface Song {
user_id?: string; user_id?: string;
created_at: string; created_at: string;
creator?: string; creator?: string;
creator_avatar?: string;
ditModel?: string; ditModel?: string;
generation_params?: any; generation_params?: any;
} }
@@ -311,11 +312,14 @@ export interface GenerationParams {
export interface GenerationJob { export interface GenerationJob {
jobId: string; jobId: string;
id?: string;
status: 'pending' | 'queued' | 'running' | 'succeeded' | 'failed'; status: 'pending' | 'queued' | 'running' | 'succeeded' | 'failed';
queuePosition?: number; queuePosition?: number;
etaSeconds?: number; etaSeconds?: number;
progress?: number; progress?: number;
stage?: string; stage?: string;
params?: any;
created_at?: string;
result?: { result?: {
audioUrls: string[]; audioUrls: string[];
bpm?: number; bpm?: number;
@@ -375,6 +379,13 @@ export const generateApi = {
error?: string; error?: string;
}> => api('/api/generate/format', { method: 'POST', body: params, token }), }> => api('/api/generate/format', { method: 'POST', body: params, token }),
// Random description from Gradio's example library
getRandomDescription: (token: string): Promise<{
description: string;
instrumental: boolean;
vocalLanguage: string;
}> => api('/api/generate/random-description', { token }),
// LoRA Inference (requires ACE-Step training fork) // LoRA Inference (requires ACE-Step training fork)
loadLora: (params: { loadLora: (params: {
lora_path: string; lora_path: string;
@@ -393,6 +404,20 @@ export const generateApi = {
message: string; message: string;
scale: number; scale: number;
}> => api('/api/lora/scale', { method: 'POST', body: params, token }), }> => api('/api/lora/scale', { method: 'POST', body: params, token }),
toggleLora: (params: {
enabled: boolean;
}, token: string): Promise<{
message: string;
active: boolean;
}> => api('/api/lora/toggle', { method: 'POST', body: params, token }),
getLoraStatus: (token: string): Promise<{
loaded: boolean;
active: boolean;
scale: number;
path: string;
}> => api('/api/lora/status', { token }),
}; };
// Users API // Users API
@@ -532,3 +557,242 @@ export const contactApi = {
submit: (data: ContactFormData): Promise<{ success: boolean; message: string; id: string }> => submit: (data: ContactFormData): Promise<{ success: boolean; message: string; id: string }> =>
api('/api/contact', { method: 'POST', body: data }), api('/api/contact', { method: 'POST', body: data }),
}; };
// Training API (LoRA fine-tuning via Gradio)
export interface TrainingSample {
audio: unknown;
filename: string;
caption: string;
genre: string;
promptOverride: string;
lyrics: string;
bpm: number;
key: string;
timeSignature: string;
duration: number;
language: string;
instrumental: boolean;
rawLyrics?: string;
}
export interface DatasetSettings {
datasetName: string;
customTag: string;
tagPosition: 'prepend' | 'append' | 'replace';
allInstrumental: boolean;
genreRatio: number;
}
export interface TrainingParams {
tensorDir?: string;
rank?: number;
alpha?: number;
dropout?: number;
learningRate?: number;
epochs?: number;
batchSize?: number;
gradientAccumulation?: number;
saveEvery?: number;
shift?: number;
seed?: number;
outputDir?: string;
resumeCheckpoint?: string | null;
}
// Helper: build proxy URL for training audio files
export function getTrainingAudioUrl(audioPath: unknown, token?: string): string | undefined {
if (!audioPath) return undefined;
// Handle Gradio FileData objects
if (typeof audioPath === 'object' && audioPath !== null) {
const fd = audioPath as Record<string, unknown>;
if (fd.url && typeof fd.url === 'string') return fd.url;
if (fd.path && typeof fd.path === 'string') {
return `${API_BASE}/api/training/audio?path=${encodeURIComponent(fd.path)}`;
}
return undefined;
}
// Handle absolute path string
if (typeof audioPath === 'string') {
if (audioPath.startsWith('http://') || audioPath.startsWith('https://') || audioPath.startsWith('/audio/')) {
return audioPath;
}
return `${API_BASE}/api/training/audio?path=${encodeURIComponent(audioPath)}`;
}
return undefined;
}
export const trainingApi = {
// Upload audio files for a dataset
uploadAudio: async (files: File[], datasetName: string, token: string): Promise<{
files: Array<{ filename: string; originalName: string; size: number; path: string }>;
uploadDir: string;
count: number;
}> => {
const formData = new FormData();
formData.append('datasetName', datasetName);
for (const file of files) {
formData.append('audio', file);
}
const response = await fetch(`${API_BASE}/api/training/upload-audio`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Upload failed' }));
throw new Error(error.error || 'Upload failed');
}
return response.json();
},
// Build dataset JSON from uploaded audio files
buildDataset: (params: {
datasetName: string;
customTag?: string;
tagPosition?: string;
allInstrumental?: boolean;
}, token: string): Promise<{
status: string;
dataframe: unknown;
sampleCount: number;
sample: TrainingSample;
settings: DatasetSettings;
datasetPath: string;
}> => api('/api/training/build-dataset', { method: 'POST', body: params, token }),
// Scan directory for audio files (Node.js implementation)
scanDirectory: (params: {
audioDir: string;
datasetName?: string;
customTag?: string;
tagPosition?: string;
allInstrumental?: boolean;
}, token: string): Promise<{
status: string;
dataframe: unknown;
sampleCount: number;
audioDir: string;
}> => api('/api/training/scan-directory', { method: 'POST', body: params, token }),
// Auto-label dataset samples (requires model loaded in Gradio)
autoLabel: (params: {
skipMetas?: boolean;
formatLyrics?: boolean;
transcribeLyrics?: boolean;
onlyUnlabeled?: boolean;
}, token: string): Promise<{
dataframe?: unknown;
status: string;
error?: string;
hint?: string;
}> => api('/api/training/auto-label', { method: 'POST', body: params, token }),
// Initialize model for training (requires Gradio)
initModel: (params: {
checkpoint?: string;
configPath?: string;
device?: string;
initLlm?: boolean;
lmModelPath?: string;
backend?: string;
useFlashAttention?: boolean;
offloadToCpu?: boolean;
offloadDitToCpu?: boolean;
compileModel?: boolean;
quantization?: boolean;
}, token: string): Promise<{
status: string;
modelReady?: boolean;
error?: string;
hint?: string;
}> => api('/api/training/init-model', { method: 'POST', body: params, token }),
// List available checkpoints
getCheckpoints: (token: string): Promise<{
checkpoints: string[];
configs: string[];
}> => api('/api/training/checkpoints', { token }),
// List LoRA training checkpoints
getLoraCheckpoints: (dir: string, token: string): Promise<{
checkpoints: string[];
outputDir: string;
}> => api(`/api/training/lora-checkpoints?dir=${encodeURIComponent(dir)}`, { token }),
// Preprocess dataset to tensors
preprocess: (params: {
datasetPath: string;
outputDir?: string;
}, token: string): Promise<{
status: string;
message?: string;
output_files?: number;
}> => api('/api/training/preprocess', { method: 'POST', body: params, token }),
loadDataset: (datasetPath: string, token: string): Promise<{
status: string;
dataframe: unknown;
sampleCount: number;
sample: TrainingSample;
settings: DatasetSettings;
}> => api('/api/training/load-dataset', { method: 'POST', body: { datasetPath }, token }),
getSamplePreview: (idx: number, token: string): Promise<TrainingSample> =>
api(`/api/training/sample-preview?idx=${idx}`, { token }),
saveSample: (params: {
sampleIdx: number;
caption: string;
genre: string;
promptOverride: string;
lyrics: string;
bpm: number;
key: string;
timeSignature: string;
language: string;
instrumental: boolean;
}, token: string): Promise<{ dataframe: unknown; status: string }> =>
api('/api/training/save-sample', { method: 'POST', body: params, token }),
updateSettings: (params: {
customTag: string;
tagPosition: string;
allInstrumental: boolean;
genreRatio: number;
}, token: string): Promise<{ success: boolean }> =>
api('/api/training/update-settings', { method: 'POST', body: params, token }),
saveDataset: (params: {
savePath?: string;
datasetName?: string;
customTag?: string;
tagPosition?: string;
allInstrumental?: boolean;
genreRatio?: number;
}, token: string): Promise<{ status: string; path: string }> =>
api('/api/training/save-dataset', { method: 'POST', body: params, token }),
loadTensors: (tensorDir: string, token: string): Promise<{ status: string }> =>
api('/api/training/load-tensors', { method: 'POST', body: { tensorDir }, token }),
startTraining: (params: TrainingParams, token: string): Promise<{
progress: string;
log: string;
metrics: unknown;
}> => api('/api/training/start', { method: 'POST', body: params, token }),
stopTraining: (token: string): Promise<{ status: string }> =>
api('/api/training/stop', { method: 'POST', token }),
exportLora: (params: {
exportPath?: string;
loraOutputDir?: string;
}, token: string): Promise<{ status: string }> =>
api('/api/training/export', { method: 'POST', body: params, token }),
importDataset: (datasetType: string, token: string): Promise<{ status: string }> =>
api('/api/training/import-dataset', { method: 'POST', body: { datasetType }, token }),
};
+2 -1
View File
@@ -78,6 +78,7 @@ export interface GenerationParams {
randomSeed: boolean; randomSeed: boolean;
seed: number; seed: number;
thinking: boolean; thinking: boolean;
enhance?: boolean;
audioFormat: 'mp3' | 'flac'; audioFormat: 'mp3' | 'flac';
inferMethod: 'ode' | 'sde'; inferMethod: 'ode' | 'sde';
shift: number; shift: number;
@@ -151,4 +152,4 @@ export interface UserProfile {
} }
// Simplified views for ACE-Step UI // Simplified views for ACE-Step UI
export type View = 'create' | 'library' | 'profile' | 'song' | 'playlist' | 'search'; export type View = 'create' | 'library' | 'training' | 'profile' | 'song' | 'playlist' | 'search' | 'news';
+4
View File
@@ -25,6 +25,10 @@ export default defineConfig(({ mode }) => {
target: 'http://127.0.0.1:3001', target: 'http://127.0.0.1:3001',
changeOrigin: true, changeOrigin: true,
}, },
'/demucs-web': {
target: 'http://127.0.0.1:3001',
changeOrigin: true,
},
}, },
}, },
optimizeDeps: { optimizeDeps: {