diff --git a/.github/ISSUE_TEMPLATE/1-bug.yml b/.github/ISSUE_TEMPLATE/1-bug.yml index b28f81d9..de9d6e3d 100644 --- a/.github/ISSUE_TEMPLATE/1-bug.yml +++ b/.github/ISSUE_TEMPLATE/1-bug.yml @@ -39,7 +39,6 @@ body: attributes: label: ACE-Step Studio version / Версия description: "**Settings → About** · **Настройки → О программе**" - placeholder: "3.5.5" validations: required: true - type: dropdown diff --git a/CHANGELOG.md b/CHANGELOG.md index 723b7182..4721677b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ What changed, newest first. Dates are release dates. +## 2026-10-16 — 3.6.0 + +### Added + +- **A library as roomy as a music service's.** Compact rows show three times as many songs; the tabs count their songs and the filters say how many of how many are shown. Filters pick where songs came from (made in the studio, imported, stems and processing) and those with karaoke or processed versions; Recently played joins the orders. A right click on a song opens its menu, which gains Play next and Add to queue. In a selection the bar stays on screen while the list scrolls, Shift+click takes a range, Ctrl+A every song and Esc leaves it, and the chosen songs can be liked, queued or downloaded at once. Liked and Playlists say what to do when they are empty. +- **Playlists to keep and play.** A playlist's name and description change in place, its songs move by dragging or Alt+Up/Down, and songs are found and added on its own page; a song taken out comes back with Undo. Its picture is the covers of its songs, the same on its card and its page; the song playing is marked; Shuffle plays it in a mixed order, and a card has Play on its cover and a menu on a right click. Adding songs says which playlist they went to and which were there already. Back from a playlist returns to the Playlists tab. Agents queue songs with `player_queue_add`; `playlist_update` takes the whole order. +- **Winamp plays the studio's playlists.** The studio's shuffle and repeat go into Winamp and come back; LOAD LIST and ADD open the studio's playlists, the liked songs and the whole library, and SAVE LIST writes over the playlist the music came from or into a new one. +- **A track's file carries how it was made.** Besides the title, lyrics and cover, the file now holds the song's note as its comment, its whole style, and its generation settings (a JSON document of seed, models and options, without paths on your computer) in fields of the studios' own. Importing such a file back fills Reuse Prompt with what it was made with, in the studio that made it; any file brings its title and lyrics, and the library says how many files came in. Tracks made before get theirs at the first start. Settings › Privacy turn it off, and every stored track is tagged again ([ACE-Step-Studio #50](https://github.com/timoncool/ACE-Step-Studio/issues/50)). +- **Several tracks to MIDI at once.** Choose songs in the library or on the create page and press To MIDI: they are turned into MIDI one after another, Studio tools shows which track it is on and how many are done, Stop ends the set, and a track that fails is named while the rest go on. Agents give `song_ids` to `midi_transcribe` ([YuE2-Studio #94](https://github.com/timoncool/YuE2-Studio/issues/94)). +- **Sorting out a long list of songs.** A song's row has a Delete button beside Add to playlist, and the chosen songs of a selection go to a playlist in one go. The Library's All and Liked tabs have the create page's search, filters, selection, Like, Add to playlist and Delete, and a details button that opens the song's details over the page and closes them again ([ACE-Step-Studio #48](https://github.com/timoncool/ACE-Step-Studio/issues/48)). +- **OGG and FLAC songs can be imported into the library**, beside MP3 and WAV, and turned into MIDI, stems or karaoke like any other ([YuE2-Studio #94](https://github.com/timoncool/YuE2-Studio/issues/94)). +- **The version in the window title and under the studio's name**, with "Portable" on a portable copy, so a copy waiting to be updated tells which it is ([ACE-Step-Studio #40](https://github.com/timoncool/ACE-Step-Studio/issues/40)). +- **Every settings slider takes a typed number.** The value beside a slider is a field: type `0.40` for a LoRA's strength, `2:10` for a duration or `6` for an equalizer band, and Enter sets it, kept within the slider's range (a comma works as the decimal point, Escape puts the value back). On LoRA strength, the creation settings, audio processing, the equalizer, the Winamp size and the video clip editor. +- **Any engine launch flag and a hand-built engine.** Settings - Local engine take one line of extra flags for the engine, split as a shell would and placed after the studio's own, and an engine executable of your own build, picked in a file dialog and checked when saved. The restart button saves the page first and starts what it shows; an old engine still holding the port is waited for and refused instead of being reused with its old flags. By [@wuyue92tree](https://github.com/wuyue92tree) ([#46](https://github.com/timoncool/ACE-Step-Studio/pull/46)). + +### Fixed + +- **"Instrumental" is not a genre.** A style that starts with "instrumental" no longer files the track under the genre Instrumental in its file; the next phrase that names music is taken, or none. +- **An installation under Program Files downloads the engine's libraries.** The studio put NVIDIA's cuBLAS beside the engine, where Windows lets no program write without administrator rights, so an installed studio stood at "Downloading the engine library - 0%" for ever. Where the engine's folder cannot be written, the libraries now go to the studio's data folder and the engine finds them there ([ACE-Step-Studio #49](https://github.com/timoncool/ACE-Step-Studio/issues/49)). +- **Stems, karaoke and Audio to MIDI work on Linux.** The studio downloaded the Windows builds of their runtimes on Linux and stopped there without a word. It now takes Microsoft's and NVIDIA's Linux builds of the same versions - ONNX Runtime with CUDA, cuBLAS, cuDNN, cuFFT, cuRAND - and Purfview's Linux Whisper, runs the separator and recogniser on an NVIDIA card there, and the Linux packages carry the MIDI transcriber ([YuE2-Studio #95](https://github.com/timoncool/YuE2-Studio/issues/95)). +- **A download that cannot start says so in its panel** instead of leaving the bar where it was. +- **"Use models I already have" takes only the models in the folder.** A file was taken for every model of the same size, and many models of one architecture weigh the same to the byte, so four files came in as a dozen models with the wrong weights under their names; a file under another name is now taken only when its contents are that model's ([ACE-Step-Studio #40](https://github.com/timoncool/ACE-Step-Studio/issues/40)). +- **One version everywhere.** The studio's version is written in `desktop/src-tauri/tauri.conf.json` alone: the window, the service, the interface, the project page and the release all read it there, and the studio names itself to the sites it asks with it instead of 0.1.0. +- **An agent's command to a closed window fails at once.** A window that closed without saying so kept the agent waiting the full five minutes for its answer; the command now fails as the window goes, and the next call reaches another open window or says none is open. +- **The window gets the microphone it asks for.** WebView2 left the request with no answer and no prompt, so recording in the audio editor waited for ever; the studio's own page is now allowed the microphone, nothing else is. +- **A song lost to the graphics card names the error.** "The graphics card failed" now carries the engine's own CUDA or Vulkan error, and a song lost to an engine that ended without a word says the last thing it wrote, so the cause can be told apart instead of guessed. +- **A recording that will not play says why.** The player of a training song or a stem turned to Pause and stayed silent when the window could not play the file; it now stays on Play and names the reason the window gave ([YuE2-Studio #75](https://github.com/timoncool/YuE2-Studio/issues/75)). +- **Training songs play in the window.** The player of a song in a training set asked the window's own page for the recording instead of the studio, so in the window it stayed silent while a browser played it ([YuE2-Studio #75](https://github.com/timoncool/YuE2-Studio/issues/75)). +- **Access from the network can be turned on from a browser on the studio's own computer**, not only in its window, so a studio on a server without a desktop is reachable; and access turned on by hand in `studio-settings.json` without a key now gets one when the studio starts instead of staying on this computer only ([ACE-Step-Studio #47](https://github.com/timoncool/ACE-Step-Studio/issues/47)). +- **Downloads that stall come back.** A connection that stopped sending held the download at the same byte for ever, with no error and no retry - most visibly the engine libraries from NVIDIA, which kept the start screen at "Downloading the engine libraries - 0%" ([YuE2-Studio #92](https://github.com/timoncool/YuE2-Studio/issues/92)). A silent connection is now dropped after a minute and the piece is fetched again, and a server that refuses a file says so at once instead of after two minutes of retries. +- **Only the studio's own window and agents can use it.** A web page open in your browser could send requests to the studio on this computer, and the studio answered any site; it now refuses pages of other sites, and requests that reach it under another site's name (DNS rebinding), as Dub Studio does. + ## 2026-10-14 — 3.5.5 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 8b73d037..e741f689 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -361,6 +361,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "combine" version = "4.6.8" @@ -476,6 +482,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + [[package]] name = "daachorse" version = "3.0.3" @@ -604,6 +619,7 @@ dependencies = [ "block-buffer", "const-oid", "crypto-common", + "ctutils", ] [[package]] @@ -776,6 +792,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -1016,6 +1042,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + [[package]] name = "hound" version = "3.5.1" @@ -1575,6 +1610,26 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" +[[package]] +name = "lzma-rust2" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fde178a3caf126c440fa15628147772d8ee590a39477d711353fbe2e58a73a5b" +dependencies = [ + "sha2", +] + +[[package]] +name = "machine-uid" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe0d6336d341b10ae80e099b8f3c59f34bf8aef66ef25e83aa98bf2955c0ae7" +dependencies = [ + "libc", + "windows-registry", + "windows-sys 0.61.2", +] + [[package]] name = "macro_rules_attribute" version = "0.2.3" @@ -1783,6 +1838,7 @@ dependencies = [ "hyper-util", "include_dir", "lofty", + "lzma-rust2", "music-core", "music-engine", "ort", @@ -1799,6 +1855,7 @@ dependencies = [ "studio-hub-client", "symphonia", "sysinfo", + "tar", "tempfile", "tokio", "tokio-stream", @@ -3053,14 +3110,17 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "studio-hub-client" version = "0.1.0" -source = "git+https://github.com/timoncool/studio-hub-client?rev=531327a64882580bd44f8e173908be0a046c7adc#531327a64882580bd44f8e173908be0a046c7adc" +source = "git+https://github.com/timoncool/studio-hub-client?rev=365c2b757bfddd45fff67e7264b41f4f961a0032#365c2b757bfddd45fff67e7264b41f4f961a0032" dependencies = [ "axum", "chrono", + "hmac", + "machine-uid", "regex", "reqwest", "serde", "serde_json", + "sha2", "tokio", "tracing", "uuid", @@ -3322,6 +3382,17 @@ dependencies = [ "libc", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3329,7 +3400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -4205,6 +4276,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index a1fcd0d5..3e1bd466 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,8 @@ ort = { version = "=2.0.0-rc.13", default-features = false, features = ["load-dy symphonia = { version = "0.6", default-features = false, features = ["mp3", "wav", "pcm", "isomp4", "aac", "flac", "ogg", "vorbis", "aiff", "alac", "all-meta", "opt-simd"] } flate2 = "1" zip = { version = "8", default-features = false, features = ["deflate"] } +tar = "0.4" +lzma-rust2 = { version = "0.21", default-features = false, features = ["std", "optimization", "xz"] } windows-sys = { version = "0.61", features = ["Win32_System_JobObjects", "Win32_System_Threading", "Win32_Security", "Win32_Foundation"] } uuid = { version = "1.16", features = ["serde", "v7"] } diff --git a/app/App.tsx b/app/App.tsx index ddad0f6d..05b601b1 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -115,6 +115,7 @@ import { PlayerExtras } from './components/player/PlayerExtras'; import { registerPlayer } from './services/audioGraph'; import { serveVisualizerFeed } from './services/visualizerFeed'; import { winampControl, winampOn } from './services/winamp'; +import { notePlayed } from './services/playHistory'; import { ConfirmDialog } from './components/ConfirmDialog'; import { SetupGate } from './components/SetupGate'; import { EngineStarting } from './components/EngineStarting'; @@ -329,7 +330,7 @@ function AppContent() { // Modals const [isCreatePlaylistModalOpen, setIsCreatePlaylistModalOpen] = useState(false); const [isAddToPlaylistModalOpen, setIsAddToPlaylistModalOpen] = useState(false); - const [songToAddToPlaylist, setSongToAddToPlaylist] = useState(null); + const [songsToAddToPlaylist, setSongsToAddToPlaylist] = useState([]); // Video Modal @@ -352,6 +353,8 @@ function AppContent() { // Playlist View const [viewingPlaylistId, setViewingPlaylistId] = useState(null); + // the studio playlist the queue was started from: Winamp's SAVE LIST offers it first + const [queuePlaylistId, setQueuePlaylistId] = useState(null); // Reuse State const [reuseData, setReuseData] = useState<{ song: Song, timestamp: number } | null>(null); @@ -366,7 +369,7 @@ function AppContent() { const [showMobileDetails, setShowMobileDetails] = useState(false); // Toast State - const [toast, setToast] = useState<{ message: string; type: ToastType; isVisible: boolean }>({ + const [toast, setToast] = useState<{ message: string; type: ToastType; isVisible: boolean; action?: { label: string; onClick: () => void } }>({ message: '', type: 'success', isVisible: false, @@ -381,8 +384,8 @@ function AppContent() { // a message shown for a moment stays readable in the journal - const showToast = (message: string, type: ToastType = 'success') => { - setToast({ message, type, isVisible: true }); + const showToast = (message: string, type: ToastType = 'success', action?: { label: string; onClick: () => void }) => { + setToast({ message, type, isVisible: true, action }); noteStudioMessage(message, type); }; @@ -445,6 +448,31 @@ function AppContent() { else setShowMobileDetails(true); }; + // the chosen songs go to the MIDI transcriber one after another; the tools page shows how far it is + const transcribeManyToMidi = async (chosen: Song[]) => { + try { + const response = await fetch('/v1/midi/transcribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ song_ids: chosen.map(song => song.id) }) }); + if (!response.ok) { + const problem = await response.json().catch(() => null); + throw new Error(problem?.error || `HTTP ${response.status}`); + } + showToast(t('midiBatchStarted').replace('{count}', String(chosen.length))); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), 'error'); + } + }; + + // the library has no side panel: its details open over the page, and the same button closes them + const toggleLibraryDetails = (song: Song) => { + if (showMobileDetails && selectedSong?.id === song.id) { + setShowMobileDetails(false); + return; + } + setSelectedSong(song); + setShowMobileDetails(true); + }; + + // Reuse Handler const handleReuse = (song: Song) => { setReuseData({ song, timestamp: Date.now() }); @@ -611,6 +639,11 @@ function AppContent() { playNextRef.current = playNext; }, [playNext]); + // the "recently played" order reads when each song last played + useEffect(() => { + if (currentSong && isPlaying) notePlayed(currentSong.id); + }, [currentSong?.id, isPlaying]); // eslint-disable-line react-hooks/exhaustive-deps + // Audio Setup useEffect(() => { audioRef.current = new Audio(); @@ -787,6 +820,7 @@ function AppContent() { const nextIndex = nextQueue.findIndex(s => s.id === song.id); setPlayQueue(nextQueue); setQueueIndex(nextIndex); + if (list) setQueuePlaylistId(null); if (currentSong?.id !== song.id) { const updatedSong = { ...song, viewCount: (song.viewCount || 0) + 1 }; @@ -802,6 +836,38 @@ function AppContent() { } }; + const playFromPlaylist = (playlistId: string) => (song: Song, list?: Song[]) => { + playSong(song, list); + setQueuePlaylistId(playlistId); + }; + + // "play next" goes right after the song playing, "add to queue" to the end of what plays + const queueSongs = (chosen: Song[], next: boolean): { count: number; started: boolean } => { + const playable = chosen.filter(song => song.audioUrl && !song.isGenerating && song.id !== currentSong?.id); + if (!playable.length) throw new Error(t('nothingToQueue')); + if (!currentSong) { + playSong(playable[0], playable); + return { count: playable.length, started: true }; + } + const active = getActiveQueue(); + const base = active.some(s => s.id === currentSong.id) ? active : [currentSong, ...active]; + const ids = new Set(playable.map(s => s.id)); + const rest = base.filter(s => !ids.has(s.id)); + const at = next ? rest.findIndex(s => s.id === currentSong.id) + 1 : rest.length; + const nextQueue = [...rest.slice(0, at), ...playable, ...rest.slice(at)]; + setPlayQueue(nextQueue); + setQueueIndex(nextQueue.findIndex(s => s.id === currentSong.id)); + return { count: playable.length, started: false }; + }; + const queueFromWindow = (chosen: Song[], next: boolean) => { + try { + const { count, started } = queueSongs(chosen, next); + showToast((started ? t('queueStarted') : next ? t('queuedNext') : t('queuedLast')).replace('{count}', String(count))); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), 'error'); + } + }; + // An agent connected over MCP works this window like a user const VIEWS: View[] = ['create', 'library', 'tools', 'adapters', 'playlist', 'search', 'news']; const songById = (id: unknown) => { @@ -886,6 +952,13 @@ function AppContent() { playNext(); return { text: 'Next song.' }; }); + useBridgeCommand('player_queue_add', ({ song_ids, next }) => { + if (!Array.isArray(song_ids) || !song_ids.length) throw new Error('song_ids takes the songs to queue.'); + const list = song_ids.map((id) => songById(id)); + if (winampControl()) return inWinamp({ append_song_ids: list.map((song) => song.id) }); + const { count, started } = queueSongs(list, Boolean(next)); + return { text: started ? `Nothing was playing: ${count} song(s) became the queue and the first plays.` : `${count} song(s) ${next ? 'play next' : 'are at the end of the queue'}.` }; + }); useBridgeCommand('player_previous', () => { if (winampControl()) return inWinamp({ action: 'previous' }); playPrevious(); @@ -1008,9 +1081,9 @@ function AppContent() { const createPlaylist = async (name: string, description: string) => { try { - const playlist = await createNativePlaylist(name, description, songToAddToPlaylist ? [songToAddToPlaylist.id] : []); + const playlist = await createNativePlaylist(name, description, songsToAddToPlaylist.map(song => song.id)); updateLibraryPlaylists(prev => [playlist, ...prev]); - if (songToAddToPlaylist) setSongToAddToPlaylist(null); + setSongsToAddToPlaylist([]); showToast(t('playlistCreated')); } catch (error) { console.error('Create playlist error:', error); @@ -1032,21 +1105,31 @@ function AppContent() { } }; - const openAddToPlaylistModal = (song: Song) => { - setSongToAddToPlaylist(song); + const openAddToPlaylistModal = (chosen: Song | Song[]) => { + setSongsToAddToPlaylist(Array.isArray(chosen) ? chosen : [chosen]); setIsAddToPlaylistModalOpen(true); }; const addSongToPlaylist = async (playlistId: string) => { - if (!songToAddToPlaylist) return; + if (songsToAddToPlaylist.length === 0) return; try { const playlist = playlists.find(item => item.id === playlistId); if (!playlist) throw new Error('Playlist was not found in the local library'); - const songIds = Array.from(new Set([...(playlist.songIds || []), songToAddToPlaylist.id])); - const updated = await updateNativePlaylist(playlist.id, playlist, songIds); - updateLibraryPlaylists(prev => prev.map(item => item.id === updated.id ? updated : item)); - setSongToAddToPlaylist(null); - showToast(t('songAddedToPlaylist')); + const had = new Set(playlist.songIds || []); + const fresh = Array.from(new Set(songsToAddToPlaylist.map(song => song.id))).filter(id => !had.has(id)); + if (fresh.length) { + const updated = await updateNativePlaylist(playlist.id, playlist, [...(playlist.songIds || []), ...fresh]); + updateLibraryPlaylists(prev => prev.map(item => item.id === updated.id ? updated : item)); + } + const already = songsToAddToPlaylist.length - fresh.length; + const added = fresh.length === 0 + ? t(songsToAddToPlaylist.length === 1 ? 'songAlreadyInPlaylist' : 'songsAlreadyInPlaylist').replace('{name}', playlist.name) + : t('songsAddedToNamedPlaylist').replace('{count}', String(fresh.length)).replace('{name}', playlist.name); + showToast(already && fresh.length ? `${added} ${t('songsAlreadyThere').replace('{count}', String(already))}` : added, fresh.length ? 'success' : 'info', { + label: t('openPlaylist'), + onClick: () => handleNavigateToPlaylist(playlist.id), + }); + setSongsToAddToPlaylist([]); } catch (error) { console.error('Add song error:', error); showToast(t('failedToAddSong'), 'error'); @@ -1120,11 +1203,20 @@ function AppContent() { currentSong={currentSong} isPlaying={isPlaying} onCreatePlaylist={() => { - setSongToAddToPlaylist(null); + setSongsToAddToPlaylist([]); setIsCreatePlaylistModalOpen(true); }} onSelectPlaylist={(p) => handleNavigateToPlaylist(p.id)} - onImported={libraryChanged} + onPlayPlaylist={(p, songs) => playFromPlaylist(p.id)(songs[0], songs)} + onError={message => showToast(message, 'error')} + onImported={count => { libraryChanged(); showToast(t('importedToLibrary').replace('{count}', String(count))); }} + likedSongIds={likedSongIds} + onToggleLike={toggleLike} + onAddToPlaylist={openAddToPlaylistModal} + onDeleteMany={handleDeleteSongs} + onMidiMany={transcribeManyToMidi} + onShowDetails={toggleLibraryDetails} + detailsSongId={showMobileDetails ? selectedSong?.id ?? null : null} /> ); } @@ -1135,7 +1227,10 @@ function AppContent() { { setSelectedSong(s); setShowRightSidebar(true); @@ -1221,8 +1316,11 @@ function AppContent() { }} onToggleLike={toggleLike} onAddToPlaylist={openAddToPlaylistModal} + onAddManyToPlaylist={openAddToPlaylistModal} + onMidiMany={transcribeManyToMidi} onOpenCoverRegen={openCoverRegen} onShowDetails={handleShowDetails} + detailsBesideList onDeleteMany={handleDeleteSongs} onSongUpdate={handleSongUpdate} onCancelJob={generations.cancel} @@ -1282,6 +1380,7 @@ function AppContent() { addToPlaylist: openAddToPlaylistModal, remove: handleDeleteSong, update: handleSongUpdate, + queue: queueFromWindow, }; const songActions = useMemo(() => ({ reusePrompt: song => songActionsLatest.current.reusePrompt(song), @@ -1290,6 +1389,7 @@ function AppContent() { addToPlaylist: song => songActionsLatest.current.addToPlaylist(song), remove: song => songActionsLatest.current.remove(song), update: song => songActionsLatest.current.update(song), + queue: (songs, next) => songActionsLatest.current.queue(songs, next), }), []); // Anonymous statistics are on by default: the start screen's checkbox decides on a first run, and an install that @@ -1391,10 +1491,17 @@ function AppContent() { currentTime={currentTime} isPlaying={isPlaying} volume={volume} + shuffle={isShuffle} + repeat={repeatMode === 'all'} library={librarySongs} + playlists={playlists} + sourcePlaylistId={queuePlaylistId} onPause={() => setIsPlaying(false)} onLeaveWinamp={(exit) => { setVolume(exit.volume); + setIsShuffle(exit.shuffle); + // Winamp repeats the list or nothing: a changed switch is the studio's repeat-the-list, the rest stays + if (exit.repeat !== (repeatMode === 'all')) setRepeatMode(exit.repeat ? 'all' : 'none'); const song = exit.songId ? librarySongs.find((entry) => entry.id === exit.songId) : null; if (song && song.id !== currentSong?.id) { // the position waits for the new song to load @@ -1406,10 +1513,16 @@ function AppContent() { // Winamp played a file from disk: the studio's own song stays paused setIsPlaying(Boolean(song) && exit.playing); }} - onSavePlaylist={async (songIds) => { - const playlist = await createNativePlaylist(`Winamp ${new Date().toLocaleString()}`, '', songIds); - updateLibraryPlaylists((prev) => [playlist, ...prev]); - showToast(t('playlistCreated')); + onSavePlaylist={async (songIds, into) => { + if ('playlistId' in into) { + const playlist = playlists.find((entry) => entry.id === into.playlistId); + if (!playlist) throw new Error(t('playlistNotFound')); + const updated = await updateNativePlaylist(playlist.id, playlist, songIds); + updateLibraryPlaylists((prev) => prev.map((entry) => (entry.id === updated.id ? updated : entry))); + } else { + const playlist = await createNativePlaylist(into.name, '', songIds); + updateLibraryPlaylists((prev) => [playlist, ...prev]); + } }} /> @@ -1434,6 +1547,7 @@ function AppContent() { type={toast.type} isVisible={toast.isVisible} onClose={closeToast} + action={toast.action} duration={toast.type === 'error' ? 8000 : 3000} /> {/* Cover regen modal — only mounted while a song is selected for regen. @@ -1481,7 +1595,7 @@ function AppContent() { {/* Mobile Details Modal */} {showMobileDetails && selectedShown && ( -
+
setShowMobileDetails(false)} diff --git a/app/components/AdapterPicker.tsx b/app/components/AdapterPicker.tsx index 8f26b039..49b00e97 100644 --- a/app/components/AdapterPicker.tsx +++ b/app/components/AdapterPicker.tsx @@ -1,3 +1,4 @@ +import { NumberField } from './NumberField'; import React, { useEffect, useRef, useState } from 'react'; import { AlertTriangle, Layers, Plus, X } from 'lucide-react'; import { useI18n } from '../context/I18nContext'; @@ -101,7 +102,7 @@ export const AdapterPicker: React.FC = ({ value, onChange, o {roleOf(slot) ? tt(`adapterRole_${roleOf(slot)}`) : slot} - {scale.toFixed(2)} + setScale(use.id, slot, value)} />
{slotLabel(tt, slots, slot)} - {(scales[slot] ?? 1).toFixed(2)} + setScales(current => ({ ...current, [slot]: value }))} />
{label} - - {shown.toFixed(decimals)}{suffix ?? ''} + + `${number.toFixed(decimals)}${suffix ?? ''}`} onChange={number => onChange(String(number))} /> {value.trim() === '' && ·}
diff --git a/app/components/EngineSettings.tsx b/app/components/EngineSettings.tsx index 6a6e4a76..41db7c3b 100644 --- a/app/components/EngineSettings.tsx +++ b/app/components/EngineSettings.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { AlertTriangle, Check, Cpu, Loader2, RotateCw , Terminal } from 'lucide-react'; +import { AlertTriangle, Check, Cpu, FolderOpen, Loader2, RotateCw , Terminal } from 'lucide-react'; import { useI18n } from '../context/I18nContext'; /** @@ -22,6 +22,10 @@ interface EngineOptions { clamp_fp16: boolean; /** The NVIDIA card by nvidia-smi index; null: the first. Taken at the studio's start. */ gpu?: number | null; + /** Any other launch flag, as one line. It reaches the engine after the flags above. */ + extra_args: string; + /** A hand-built engine (acestep.cpp) to run instead of the bundled one; null: the bundled engine. */ + engine_bin?: string | null; } const DEFAULTS: EngineOptions = { @@ -32,6 +36,8 @@ const DEFAULTS: EngineOptions = { split_cfg_forwards: false, clamp_fp16: false, gpu: null, + extra_args: '', + engine_bin: null, }; const CONTROL = @@ -56,17 +62,49 @@ export const EngineSettings: React.FC = () => { void fetch('/v1/system/gpus').then(response => (response.ok ? response.json() : null)).then(setCards).catch(() => undefined); }, []); const [saved, setSaved] = useState(DEFAULTS); + // Which engine executable a start takes and which one is running, so the + // page answers "did my change apply?" without opening the engine log. + const [runtime, setRuntime] = useState<{ executable: string | null; executable_source: string; running_executable: string | null; engine_starting: boolean } | null>(null); const [busy, setBusy] = useState(false); + const [picking, setPicking] = useState(false); const [notice, setNotice] = useState(null); const [error, setError] = useState(null); + // The native file picker belongs to the studio process: this window is a + // browser and cannot open one. Nothing is changed until the user saves, + // the same as every other field on this page. + const browseEngine = async () => { + setPicking(true); + setError(null); + try { + const response = await fetch('/engine/pick-executable', { method: 'POST' }); + const body = await response.json().catch(() => null); + if (!response.ok) throw new Error(body?.error || `Opening the file picker failed (${response.status})`); + if (body?.picked && typeof body.path === 'string') { + setOptions(current => ({ ...current, engine_bin: body.path })); + } + } catch (reason) { + setError(reason instanceof Error ? reason.message : 'Opening the file picker failed.'); + } finally { + setPicking(false); + } + }; + const load = useCallback(async () => { try { const response = await fetch('/engine/options'); if (!response.ok) throw new Error(`Engine options are unavailable (${response.status})`); - const body: { options: EngineOptions } = await response.json(); - setOptions(body.options); - setSaved(body.options); + const body: { options: EngineOptions; executable?: string | null; executable_source?: string; running_executable?: string | null; engine_starting?: boolean } = await response.json(); + // A settings file written before a flag existed arrives without it; + // the default keeps the comparison below honest. + setOptions({ ...DEFAULTS, ...body.options }); + setSaved({ ...DEFAULTS, ...body.options }); + setRuntime({ + executable: body.executable ?? null, + executable_source: body.executable_source ?? 'bundled', + running_executable: body.running_executable ?? null, + engine_starting: body.engine_starting === true, + }); } catch (reason) { setError(reason instanceof Error ? reason.message : 'Could not read the engine options.'); } @@ -92,6 +130,8 @@ export const EngineSettings: React.FC = () => { setNotice(body.engine_restarted ? t('engineOptionsSavedRestarted') : t('engineOptionsSavedLater')); + // The restart, or the lack of one, changed which engine is running. + void load(); } catch (reason) { setError(reason instanceof Error ? reason.message : 'Saving the engine options failed.'); } finally { @@ -123,6 +163,7 @@ export const EngineSettings: React.FC = () => { const body = await response.json().catch(() => null); if (!response.ok) throw new Error(body?.error || `Restarting the engine failed (${response.status})`); setNotice(t('engineRestarted')); + void load(); } catch (reason) { setError(reason instanceof Error ? reason.message : 'Restarting the engine failed.'); } finally { @@ -172,6 +213,44 @@ export const EngineSettings: React.FC = () => { )} +
+ {t('engineBinLabel')} + setOptions(current => ({ ...current, engine_bin: event.target.value === '' ? null : event.target.value }))} + spellCheck={false} + placeholder={t('engineBinBundled')} + className={`${CONTROL} mt-2 font-mono text-xs`} + /> +
+ + +
+ {t('engineBinHint')} + {runtime && ( + + {runtime.running_executable ? `${t('engineBinRunning')}: ${runtime.running_executable}` : runtime.engine_starting ? t('engineBinStarting') : t('engineBinNotRunning')} + {runtime.executable && runtime.executable !== runtime.running_executable ? ` · ${t('engineBinNextStart')}: ${runtime.executable}` : ''} + {runtime.executable_source === 'environment' ? ` · ${t('engineBinEnvOverride')}` : ''} + + )} +
+ {
+
+ {t('extraArgsLabel')} +
+