From f7aa7a1ae096ddcd24889b87174fe7fe8562af1f Mon Sep 17 00:00:00 2001 From: Soficis Date: Mon, 5 Oct 2026 15:26:28 -0500 Subject: [PATCH 1/5] fix(ui): working shortcuts dialog, unsaved-changes prompt, readable status badge, Default Streaming as the default master - The shortcuts dialog was never added to the desktop, so "?" and Ctrl+/ showed nothing and swallowed the next click; it now opens as a DialogWindow. Key names and the batch em dash were built with the integer String constructor ("8212"). - Closing with unsaved changes now asks Save / Don't Save / Cancel; the header shows "*" while the session differs from the last save or load. - Clear's confirmation no longer contradicts itself or shows an internal ticket id. - The status badge uses dark text where white failed contrast; READY replaces IDLE. - New sessions default to the Default Streaming master preset instead of Udio Optimized. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 1 + src/app/Main.cpp | 15 ++++ src/app/ui/ControlDeck.cpp | 2 +- src/app/ui/MainLayout.cpp | 105 ++++++++++++++++++++--- src/app/ui/MainLayout.h | 16 +++- src/app/ui/SessionManager.cpp | 15 ++++ src/app/ui/SessionManager.h | 8 ++ src/app/ui/TaskCenterPanel.cpp | 22 +++-- src/app/ui/TaskCenterPanel.h | 1 + src/domain/JsonSerialization.cpp | 2 +- src/domain/Session.h | 2 +- tests/unit/SessionSerializationTests.cpp | 21 ++++- tests/unit/TaskCenterPanelTests.cpp | 2 +- 13 files changed, 186 insertions(+), 26 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index cdb1919..a131914 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -551,6 +551,7 @@ if(BUILD_TESTING) tests/unit/ControllerTests.cpp tests/unit/TaskCenterPanelTests.cpp tests/unit/SessionSerializationTests.cpp + src/app/ui/SessionManager.cpp tests/unit/AudioIoTests.cpp tests/unit/AnalysisTests.cpp tests/unit/StemOriginSafetyTests.cpp diff --git a/src/app/Main.cpp b/src/app/Main.cpp index 54fe999..3609780 100644 --- a/src/app/Main.cpp +++ b/src/app/Main.cpp @@ -28,6 +28,13 @@ class MainWindow final : public juce::DocumentWindow { setVisible(true); } + bool requestQuit(std::function quitNow) { + if (auto* layout = dynamic_cast(getContentComponent())) + return layout->requestQuit(std::move(quitNow)); + quitNow(); + return true; + } + void closeButtonPressed() override { juce::JUCEApplication::getInstance()->systemRequestedQuit(); } @@ -50,6 +57,14 @@ class AutoMixMasterApplication final : public juce::JUCEApplication { automix::ai::OrtRuntime::instance().warmUpAsync(); } + void systemRequestedQuit() override { + if (mainWindow_ == nullptr) { + quit(); + return; + } + mainWindow_->requestQuit([] { juce::JUCEApplication::quit(); }); + } + void shutdown() override { mainWindow_.reset(); juce::LookAndFeel::setDefaultLookAndFeel(nullptr); diff --git a/src/app/ui/ControlDeck.cpp b/src/app/ui/ControlDeck.cpp index 90cd0bb..26e40c1 100644 --- a/src/app/ui/ControlDeck.cpp +++ b/src/app/ui/ControlDeck.cpp @@ -25,7 +25,7 @@ ControlDeck::ControlDeck() { // Tooltips importButton_.setTooltip("Import Stems (Ctrl+I)"); autoMixButton_.setTooltip("Auto Mix (Ctrl+M)"); - autoMasterButton_.setTooltip("Auto Master"); + autoMasterButton_.setTooltip("Auto Master (Ctrl+Shift+A)"); autoMixMasterButton_.setTooltip("One-click: Auto Mix -> Auto Master -> Export (Ctrl+Shift+M)"); batchButton_.setTooltip("Batch Process"); exportButton_.setTooltip("Export (Ctrl+E)"); diff --git a/src/app/ui/MainLayout.cpp b/src/app/ui/MainLayout.cpp index bcaf3ef..7947680 100644 --- a/src/app/ui/MainLayout.cpp +++ b/src/app/ui/MainLayout.cpp @@ -159,6 +159,8 @@ MainLayout::MainLayout() { // 6. Application command manager: keyboard shortcuts are dispatched here commandManager_.setFirstCommandTarget(this); commandManager_.registerAllCommandsForTarget(this); + + sessionManager_.markSaved(); } // ── Controllers factory ──────────────────────────────────────── @@ -497,16 +499,21 @@ void MainLayout::initControllers() { if (result.cancelled) { safe->taskOrchestrator_->finishTaskCancelled(ActiveTask::Session, "Session save cancelled"); + safe->finishPendingSave(false); return; } if (!result.success) { safe->taskOrchestrator_->finishTaskFailed(ActiveTask::Session, result.errorText.toStdString()); + safe->finishPendingSave(false); return; } safe->taskOrchestrator_->appendHistory("Session saved to " + juce::String(result.path)); - safe->headerBar_->setSessionName(juce::File(result.path).getFileNameWithoutExtension()); + safe->sessionManager_.markSaved(); + safe->sessionShownModified_ = false; + safe->setSessionDisplayName(juce::File(result.path).getFileNameWithoutExtension()); safe->taskOrchestrator_->finishTaskCompleted(ActiveTask::Session, "Session saved"); + safe->finishPendingSave(true); }); }; cb.onLoadComplete = [safe](SessionLoadResult result) { @@ -672,7 +679,7 @@ class ShortcutsDialog final : public juce::Component { } closeButton_.setButtonText("Close"); - closeButton_.onClick = [this] { exitModalState(0); }; + closeButton_.onClick = [this] { closeHostWindow(); }; addAndMakeVisible(closeButton_); } @@ -698,13 +705,18 @@ class ShortcutsDialog final : public juce::Component { bool keyPressed(const juce::KeyPress& key) override { if (key == juce::KeyPress::escapeKey) { - exitModalState(0); + closeHostWindow(); return true; } return juce::Component::keyPressed(key); } private: + void closeHostWindow() { + if (auto* dw = findParentComponentOfClass()) + dw->exitModalState(0); + } + static constexpr int kDialogWidth = 460; static constexpr int kDialogHeight = 560; @@ -785,11 +797,14 @@ void MainLayout::onTogglePlayPause() { } void MainLayout::showShortcutsDialog() { - auto* dialog = new ShortcutsDialog(shortcutTable()); - dialog->setAlwaysOnTop(true); - dialog->centreWithSize(dialog->getWidth(), dialog->getHeight()); - dialog->setVisible(true); - dialog->enterModalState(true, nullptr, true); // modal manager owns + deletes it + juce::DialogWindow::LaunchOptions options; + options.content.setOwned(new ShortcutsDialog(shortcutTable())); + options.dialogTitle = "Keyboard Shortcuts"; + options.dialogBackgroundColour = colour(colours::surface); + options.escapeKeyTriggersCloseButton = true; + options.useNativeTitleBar = true; + options.resizable = false; + options.launchAsync(); } // ───────────────────────────────────────────────────────────────── @@ -799,6 +814,15 @@ void MainLayout::showShortcutsDialog() { void MainLayout::timerCallback() { updateTransportDisplay(); + if (++modifiedCheckTicks_ >= 30) { + modifiedCheckTicks_ = 0; + const bool modified = sessionManager_.isModified(); + if (modified != sessionShownModified_) { + sessionShownModified_ = modified; + refreshSessionTitle(); + } + } + // Reconcile the transport bar with the atomic play state (covers end-of-track // auto-stop, which is realtime-only and posts no change message). const bool playing = transportController_.isPlaying(); @@ -971,10 +995,10 @@ void MainLayout::onClearTracks() { return; // Nothing imported; nothing to clear. juce::AlertWindow::showOkCancelBox( - juce::MessageBoxIconType::WarningIcon, "Clear imported tracks", - "Clear " + juce::String(numTracks) + - " imported tracks? This cannot be undone \xe2\x80\x94 use Undo after T4.1.", - "Clear", "Cancel", this, + juce::MessageBoxIconType::WarningIcon, "Remove all stems?", + "Remove all " + juce::String(numTracks) + (numTracks == 1 ? " stem" : " stems") + + " from this session? This can't be undone.", + "Remove stems", "Cancel", this, juce::ModalCallbackFunction::create([safe = safeAsync(this), numTracks](int result) { if (safe != nullptr && confirmClear(numTracks, result == 1)) safe->performClearTracks(); @@ -1538,9 +1562,13 @@ void MainLayout::startBatchVerification(const std::string& outputFolder) { // Action: Save Session // ───────────────────────────────────────────────────────────────── -void MainLayout::onSaveSession() { +void MainLayout::onSaveSession(std::function done) { + finishPendingSave(false); + pendingSaveCompletion_ = std::move(done); + if (taskOrchestrator_->isTaskRunning()) { taskOrchestrator_->setStatus("Busy", "A task is already running"); + finishPendingSave(false); return; } @@ -1554,11 +1582,13 @@ void MainLayout::onSaveSession() { const auto selected = chooser.getResult(); if (selected == juce::File()) { saveSessionChooser_.reset(); + finishPendingSave(false); return; } if (!taskOrchestrator_->beginTask(ActiveTask::Session, "Saving session", "", "Session save started")) { saveSessionChooser_.reset(); + finishPendingSave(false); return; } @@ -1570,6 +1600,51 @@ void MainLayout::onSaveSession() { }); } +void MainLayout::finishPendingSave(bool success) { + if (auto done = std::move(pendingSaveCompletion_)) { + pendingSaveCompletion_ = nullptr; + done(success); + } +} + +void MainLayout::setSessionDisplayName(const juce::String& name) { + sessionDisplayName_ = name; + refreshSessionTitle(); +} + +void MainLayout::refreshSessionTitle() { + headerBar_->setSessionName(sessionDisplayName_ + (sessionShownModified_ ? " *" : "")); +} + +bool MainLayout::requestQuit(std::function quitNow) { + if (!sessionManager_.isModified()) { + quitNow(); + return true; + } + if (quitPromptOpen_) + return false; + quitPromptOpen_ = true; + + juce::AlertWindow::showYesNoCancelBox( + juce::MessageBoxIconType::QuestionIcon, "Save changes?", + "Save changes to " + sessionDisplayName_ + " before closing?", + "Save", "Don't Save", "Cancel", this, + juce::ModalCallbackFunction::create([safe = safeAsync(this), quitNow](int result) { + if (safe == nullptr) + return; + safe->quitPromptOpen_ = false; + if (result == 1) { + safe->onSaveSession([quitNow](bool saved) { + if (saved) + quitNow(); + }); + } else if (result == 2) { + quitNow(); + } + })); + return false; +} + // ───────────────────────────────────────────────────────────────── // Action: Load Session // ───────────────────────────────────────────────────────────────── @@ -1822,7 +1897,8 @@ void MainLayout::updateMeterPanel(const automaster::MasteringReport& report) { void MainLayout::applyLoadedSession(domain::Session loadedSession, const juce::String& sourcePath) { sessionManager_.replaceSession(std::move(loadedSession)); const auto& session = sessionManager_.session(); - headerBar_->setSessionName(juce::File(sourcePath).getFileNameWithoutExtension()); + sessionShownModified_ = false; + setSessionDisplayName(juce::File(sourcePath).getFileNameWithoutExtension()); updateStemPanelFromSession(controlDeck_->getStemPanel(), session); transportBar_->setLoopEnabled(session.timeline.loopEnabled); heroWaveform_->setZoom(session.timeline.zoom, 0.5); @@ -1833,6 +1909,7 @@ void MainLayout::applyLoadedSession(domain::Session loadedSession, const juce::S refreshProjectProfiles(); applySessionUiSelections(); syncSessionUiSelections(); + sessionManager_.markSaved(); taskOrchestrator_->appendHistory("Session loaded: " + sourcePath); rebuildPreview(); } diff --git a/src/app/ui/MainLayout.h b/src/app/ui/MainLayout.h index 77a4431..ae1c5cd 100644 --- a/src/app/ui/MainLayout.h +++ b/src/app/ui/MainLayout.h @@ -145,6 +145,10 @@ class MainLayout final : public juce::Component, void resized() override; bool keyPressed(const juce::KeyPress& key) override; + /// Runs quitNow immediately when the session is unchanged (returns true); + /// otherwise asks Save / Don't Save / Cancel and returns false. + bool requestQuit(std::function quitNow); + private: // Timer / Listener overrides void timerCallback() override; @@ -172,7 +176,10 @@ class MainLayout final : public juce::Component, void onAutoMixMaster(); // Pipeline: Mix -> Master -> Export void onBatch(); void onExport(); - void onSaveSession(); + void onSaveSession(std::function done = {}); + void finishPendingSave(bool success); + void refreshSessionTitle(); + void setSessionDisplayName(const juce::String& name); void onLoadSession(); void onModelsDialog(); void onSettings(); @@ -257,6 +264,13 @@ class MainLayout final : public juce::Component, std::unique_ptr previewManager_; SessionManager sessionManager_; + // Unsaved-changes tracking (message thread only). + juce::String sessionDisplayName_{"Untitled Session"}; + bool sessionShownModified_ = false; + int modifiedCheckTicks_ = 0; + bool quitPromptOpen_ = false; + std::function pendingSaveCompletion_; + // State std::vector analysisEntries_; std::vector rendererInfos_; diff --git a/src/app/ui/SessionManager.cpp b/src/app/ui/SessionManager.cpp index 9435f32..bf186e7 100644 --- a/src/app/ui/SessionManager.cpp +++ b/src/app/ui/SessionManager.cpp @@ -1,5 +1,7 @@ #include "app/ui/SessionManager.h" +#include "domain/JsonSerialization.h" + namespace automix::app { domain::Session& SessionManager::session() { @@ -14,4 +16,17 @@ void SessionManager::replaceSession(domain::Session session) { session_ = std::move(session); } +std::string SessionManager::snapshot() const { + const domain::Json json = session_; + return json.dump(); +} + +void SessionManager::markSaved() { + savedSnapshot_ = snapshot(); +} + +bool SessionManager::isModified() const { + return snapshot() != savedSnapshot_; +} + } // namespace automix::app diff --git a/src/app/ui/SessionManager.h b/src/app/ui/SessionManager.h index 536e022..f7e3e0a 100644 --- a/src/app/ui/SessionManager.h +++ b/src/app/ui/SessionManager.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "domain/Session.h" @@ -15,8 +16,15 @@ class SessionManager { void replaceSession(domain::Session session); + /// Records the current session as the saved baseline for isModified(). + void markSaved(); + bool isModified() const; + private: + std::string snapshot() const; + domain::Session session_; + std::string savedSnapshot_; }; } // namespace automix::app diff --git a/src/app/ui/TaskCenterPanel.cpp b/src/app/ui/TaskCenterPanel.cpp index 4b55df8..04061b1 100644 --- a/src/app/ui/TaskCenterPanel.cpp +++ b/src/app/ui/TaskCenterPanel.cpp @@ -11,16 +11,16 @@ namespace automix::app { using namespace theme; TaskCenterPanel::TaskCenterPanel() : progressBar_(progressValue_), batchProgressBar_(batchProgressValue_) { - taskLabel_.setText("Ready", juce::dontSendNotification); + taskLabel_.setText("", juce::dontSendNotification); taskLabel_.setFont(typography::body()); taskLabel_.setColour(juce::Label::textColourId, colour(colours::text)); taskLabel_.setJustificationType(juce::Justification::centredLeft); - stateBadge_.setText("IDLE", juce::dontSendNotification); + stateBadge_.setText("READY", juce::dontSendNotification); stateBadge_.setFont(typography::caption()); stateBadge_.setJustificationType(juce::Justification::centred); stateBadge_.setColour(juce::Label::backgroundColourId, stateColour(TaskState::Idle)); - stateBadge_.setColour(juce::Label::textColourId, juce::Colours::white); + stateBadge_.setColour(juce::Label::textColourId, stateTextColour(TaskState::Idle)); progressLabel_.setText("0%", juce::dontSendNotification); progressLabel_.setFont(typography::caption()); @@ -279,7 +279,7 @@ void TaskCenterPanel::updateBatchSummary() { if (total == 0) { // No batch items: em dash "—". - etaLabel_.setText(juce::String(static_cast(0x2014)), juce::dontSendNotification); + etaLabel_.setText(juce::String::charToString(static_cast(0x2014)), juce::dontSendNotification); } else { const int currentIndex = static_cast(batchDetail_.itemIndex) + 1; if (currentIndex >= static_cast(total)) { @@ -303,6 +303,7 @@ void TaskCenterPanel::setTaskState(TaskState state) { currentState_ = state; stateBadge_.setText(juce::String(stateLabel(state)), juce::dontSendNotification); stateBadge_.setColour(juce::Label::backgroundColourId, stateColour(state)); + stateBadge_.setColour(juce::Label::textColourId, stateTextColour(state)); stateBadge_.repaint(); } @@ -369,9 +370,20 @@ juce::Colour TaskCenterPanel::stateColour(TaskState state) { return colour(colours::textMuted); } +juce::Colour TaskCenterPanel::stateTextColour(TaskState state) { + switch (state) { + case TaskState::Idle: + case TaskState::Cancelled: + case TaskState::Completed: return colour(colours::background); + case TaskState::Running: + case TaskState::Failed: return juce::Colours::white; + } + return colour(colours::background); +} + const char* TaskCenterPanel::stateLabel(TaskState state) { switch (state) { - case TaskState::Idle: return "IDLE"; + case TaskState::Idle: return "READY"; case TaskState::Running: return "RUNNING"; case TaskState::Cancelled: return "CANCELLED"; case TaskState::Completed: return "COMPLETED"; diff --git a/src/app/ui/TaskCenterPanel.h b/src/app/ui/TaskCenterPanel.h index bf3a79b..79b5d15 100644 --- a/src/app/ui/TaskCenterPanel.h +++ b/src/app/ui/TaskCenterPanel.h @@ -67,6 +67,7 @@ class TaskCenterPanel final : public juce::Component { static constexpr int kMaxVisibleQueueItems = 8; static juce::Colour stateColour(TaskState state); + static juce::Colour stateTextColour(TaskState state); static const char* stateLabel(TaskState state); void drawQueueItem(juce::Graphics& g, juce::Rectangle bounds, const BatchQueueItem& item, int index); static juce::String formatEta(const juce::RelativeTime& eta); diff --git a/src/domain/JsonSerialization.cpp b/src/domain/JsonSerialization.cpp index e6c73d9..4aaaa51 100644 --- a/src/domain/JsonSerialization.cpp +++ b/src/domain/JsonSerialization.cpp @@ -311,7 +311,7 @@ void from_json(const Json& j, Session& value) { value.residualBlend = std::clamp(j.value("residualBlend", 0.0), 0.0, 10.0); value.aiStemsEnabled = j.value("aiStemsEnabled", false); value.batchRecursiveEnabled = j.value("batchRecursiveEnabled", false); - value.selectedMasterPreset = masterPresetFromString(j.value("selectedMasterPreset", "udio_optimized")); + value.selectedMasterPreset = masterPresetFromString(j.value("selectedMasterPreset", "default_streaming")); value.selectedPlatformPreset = masterPresetFromString(j.value("selectedPlatformPreset", "youtube")); value.stems = j.value("stems", std::vector{}); value.buses = j.value("buses", std::vector{}); diff --git a/src/domain/Session.h b/src/domain/Session.h index e47898a..6ffda79 100644 --- a/src/domain/Session.h +++ b/src/domain/Session.h @@ -27,7 +27,7 @@ struct Session { double residualBlend = 0.0; bool aiStemsEnabled = false; bool batchRecursiveEnabled = false; - MasterPreset selectedMasterPreset = MasterPreset::UdioOptimized; + MasterPreset selectedMasterPreset = MasterPreset::DefaultStreaming; MasterPreset selectedPlatformPreset = MasterPreset::YouTube; std::vector stems; std::vector buses; diff --git a/tests/unit/SessionSerializationTests.cpp b/tests/unit/SessionSerializationTests.cpp index 2763d43..4654d2f 100644 --- a/tests/unit/SessionSerializationTests.cpp +++ b/tests/unit/SessionSerializationTests.cpp @@ -3,6 +3,7 @@ #include #include +#include "app/ui/SessionManager.h" #include "domain/JsonSerialization.h" #include "engine/SessionRepository.h" @@ -105,7 +106,7 @@ TEST_CASE("Session deserialization handles missing optional fields", "[session]" REQUIRE(decoded.residualBlend == Catch::Approx(0.0)); REQUIRE(decoded.aiStemsEnabled == false); REQUIRE(decoded.batchRecursiveEnabled == false); - REQUIRE(decoded.selectedMasterPreset == automix::domain::MasterPreset::UdioOptimized); + REQUIRE(decoded.selectedMasterPreset == automix::domain::MasterPreset::DefaultStreaming); REQUIRE(decoded.selectedPlatformPreset == automix::domain::MasterPreset::YouTube); REQUIRE(decoded.renderSettings.blockSize == 1024); REQUIRE(decoded.renderSettings.outputFormat == "auto"); @@ -179,4 +180,20 @@ TEST_CASE("Sessions from before PhaseLimiter became opt-in load with BuiltIn", " REQUIRE(automix::domain::Session{}.schemaVersion == 3); REQUIRE(automix::domain::RenderSettings{}.rendererName == "BuiltIn"); -} \ No newline at end of file +} +TEST_CASE("Default session uses the Default Streaming master preset", "[session]") { + const automix::domain::Session session; + REQUIRE(session.selectedMasterPreset == automix::domain::MasterPreset::DefaultStreaming); +} + +TEST_CASE("SessionManager tracks unsaved changes", "[session]") { + automix::app::SessionManager manager; + manager.markSaved(); + REQUIRE_FALSE(manager.isModified()); + + manager.session().selectedMasterPreset = automix::domain::MasterPreset::Broadcast; + REQUIRE(manager.isModified()); + + manager.markSaved(); + REQUIRE_FALSE(manager.isModified()); +} diff --git a/tests/unit/TaskCenterPanelTests.cpp b/tests/unit/TaskCenterPanelTests.cpp index 0202658..7d57270 100644 --- a/tests/unit/TaskCenterPanelTests.cpp +++ b/tests/unit/TaskCenterPanelTests.cpp @@ -80,7 +80,7 @@ TEST_CASE("TaskCenterPanel batch ETA row state transitions", "[ui][taskcenter][e juce::ScopedJuceInitialiser_GUI juceInit; automix::app::TaskCenterPanel panel; - const juce::String emDash(static_cast(0x2014)); + const auto emDash = juce::String::charToString(static_cast(0x2014)); SECTION("no batch items shows dash") { REQUIRE(panel.batchEtaText() == emDash); From fa47e5bdb8b3f63d0d6e444bed52b0f8920c9ea1 Mon Sep 17 00:00:00 2001 From: Soficis Date: Mon, 5 Oct 2026 15:32:46 -0500 Subject: [PATCH 2/5] feat(ui): clear visual hierarchy, guided empty state, and plain-language separation controls - Button variants (primary/secondary/quiet/danger): Import is the one primary action until stems exist, then Mix + Master; Save/Load/Models/Settings are quiet; Clear is styled as destructive. - Actions that need stems are disabled until stems are imported; transport too. - The empty waveform is a bordered drop zone that highlights on drag and opens Import on click. - Vocal Model and the model name appear only when AI Stem Separation is on; model names are plain language instead of Hugging Face repo ids. - Renderer and Active chain move into Advanced; combos widened; tooltips for Master, Platform, Renderer and Mode; accessibility titles for combos, sliders and transport. Co-Authored-By: Claude Opus 5.5 --- src/app/style/AutoMixLookAndFeel.cpp | 63 ++++++++++---- src/app/style/AutoMixLookAndFeel.h | 15 ++++ src/app/ui/ControlDeck.cpp | 122 ++++++++++++++++----------- src/app/ui/ControlDeck.h | 8 +- src/app/ui/GlowMeters.cpp | 1 + src/app/ui/HeaderBar.cpp | 6 ++ src/app/ui/HeroWaveform.cpp | 37 ++++++-- src/app/ui/HeroWaveform.h | 3 + src/app/ui/MainLayout.cpp | 46 ++++++++-- src/app/ui/MainLayout.h | 1 + src/app/ui/MainLayoutInternal.h | 15 ++++ src/app/ui/TaskCenterPanel.cpp | 4 + src/app/ui/TransportBar.cpp | 23 +++++ src/app/ui/TransportBar.h | 2 + 14 files changed, 266 insertions(+), 80 deletions(-) diff --git a/src/app/style/AutoMixLookAndFeel.cpp b/src/app/style/AutoMixLookAndFeel.cpp index 10bb576..500c24e 100644 --- a/src/app/style/AutoMixLookAndFeel.cpp +++ b/src/app/style/AutoMixLookAndFeel.cpp @@ -68,28 +68,54 @@ AutoMixLookAndFeel::AutoMixLookAndFeel() { // Button // ───────────────────────────────────────────────────────────────── +namespace { +juce::String variantOf(const juce::Button& button) { + return button.getProperties()[buttonVariant::key].toString(); +} +} // namespace + void AutoMixLookAndFeel::drawButtonBackground(juce::Graphics& g, juce::Button& button, const juce::Colour& /*backgroundColour*/, bool shouldDrawButtonAsHighlighted, bool shouldDrawButtonAsDown) { auto bounds = button.getLocalBounds().toFloat().reduced(0.5f); auto cornerSize = metrics::cornerRadius; - - juce::Colour bg; - if (!button.isEnabled()) { - bg = colour(colours::surfaceBorder); - } else if (shouldDrawButtonAsDown) { - bg = colour(colours::primaryPressed); - } else if (shouldDrawButtonAsHighlighted) { - bg = colour(colours::primary).interpolatedWith(colour(colours::primaryHover), 0.6f); - } else if (button.getToggleState()) { - bg = colour(colours::primary); + const auto variant = variantOf(button); + const bool enabled = button.isEnabled(); + + if (variant == buttonVariant::secondary) { + juce::Colour bg = colour(colours::surfaceLight); + if (enabled && shouldDrawButtonAsDown) + bg = bg.darker(0.1f); + else if (enabled && shouldDrawButtonAsHighlighted) + bg = bg.brighter(0.1f); + g.setColour(bg); + g.fillRoundedRectangle(bounds, cornerSize); + g.setColour(colour(colours::surfaceBorder)); + g.drawRoundedRectangle(bounds, cornerSize, 1.0f); + } else if (variant == buttonVariant::quiet || variant == buttonVariant::danger) { + if (enabled && (shouldDrawButtonAsHighlighted || shouldDrawButtonAsDown)) { + g.setColour(variant == buttonVariant::quiet ? colour(colours::surfaceLight) + : colour(colours::error).withAlpha(0.15f)); + g.fillRoundedRectangle(bounds, cornerSize); + } } else { - bg = colour(colours::primary); - } + juce::Colour bg; + if (!enabled) { + bg = colour(colours::surfaceBorder); + } else if (shouldDrawButtonAsDown) { + bg = colour(colours::primaryPressed); + } else if (shouldDrawButtonAsHighlighted) { + bg = colour(colours::primary).interpolatedWith(colour(colours::primaryHover), 0.6f); + } else if (button.getToggleState()) { + bg = colour(colours::primary); + } else { + bg = colour(colours::primary); + } - g.setColour(bg); - g.fillRoundedRectangle(bounds, cornerSize); + g.setColour(bg); + g.fillRoundedRectangle(bounds, cornerSize); + } // Focus ring drawFocusRing(g, button); @@ -101,7 +127,14 @@ void AutoMixLookAndFeel::drawButtonText(juce::Graphics& g, juce::TextButton& but auto font = getTextButtonFont(button, button.getHeight()); g.setFont(font); - auto textColour = button.isEnabled() ? colour(colours::text) : colour(colours::textDisabled); + const auto variant = variantOf(button); + juce::Colour textColour = colour(colours::text); + if (variant == buttonVariant::quiet) + textColour = colour(colours::primaryHover); + else if (variant == buttonVariant::danger) + textColour = colour(colours::error); + if (!button.isEnabled()) + textColour = colour(colours::textDisabled); g.setColour(textColour); diff --git a/src/app/style/AutoMixLookAndFeel.h b/src/app/style/AutoMixLookAndFeel.h index b6c5fde..359268c 100644 --- a/src/app/style/AutoMixLookAndFeel.h +++ b/src/app/style/AutoMixLookAndFeel.h @@ -6,6 +6,21 @@ namespace automix::app { +/// Button visual variants, selected via the "variant" component property. +/// No property (or an unknown value) means primary. +namespace buttonVariant { +inline const juce::Identifier key{"variant"}; +inline constexpr const char* primary = "primary"; +inline constexpr const char* secondary = "secondary"; +inline constexpr const char* quiet = "quiet"; +inline constexpr const char* danger = "danger"; +} // namespace buttonVariant + +inline void setButtonVariant(juce::Button& button, const char* variant) { + button.getProperties().set(buttonVariant::key, juce::String(variant)); + button.repaint(); +} + /// Custom LookAndFeel for AutoMixMaster. /// Applies the dark audio-app theme defined in Theme.h to all standard JUCE widgets. class AutoMixLookAndFeel final : public juce::LookAndFeel_V4 { diff --git a/src/app/ui/ControlDeck.cpp b/src/app/ui/ControlDeck.cpp index 26e40c1..63ce2e3 100644 --- a/src/app/ui/ControlDeck.cpp +++ b/src/app/ui/ControlDeck.cpp @@ -1,5 +1,6 @@ #include "app/ui/ControlDeck.h" +#include "app/style/AutoMixLookAndFeel.h" #include "app/ui/GlowMeters.h" #include "app/ui/StemPanel.h" @@ -35,33 +36,30 @@ ControlDeck::ControlDeck() { "instrumental that is the residual (mix - vocals), not a second separation. Takes minutes on CPU; falls back to " "the standard separator if the pack cannot run."); tensorSeparationToggle_.setEnabled(false); + rendererBox_.setTooltip("Engine that renders the exported file. BuiltIn needs no external tools."); + exportModeBox_.setTooltip("Final renders at full quality. Quick Preview renders faster for checking the result."); + masterPresetBox_.setTooltip("Mastering target. Default Streaming suits Spotify, Apple Music and YouTube."); + platformPresetBox_.setTooltip("Loudness target for the platform you will publish to."); + + // Accessibility titles (match the visible labels) + rendererBox_.setTitle("Renderer"); + profileBox_.setTitle("Profile"); + masterPresetBox_.setTitle("Master preset"); + platformPresetBox_.setTitle("Platform"); + exportFormatBox_.setTitle("Export format"); + exportModeBox_.setTitle("Render mode"); + rendererChainModeBox_.setTitle("Chain"); + residualBlendSlider_.setTitle("Residual blend"); batchRecursiveToggle_.setTooltip("Include subfolders when scanning batch input"); rendererChainToggle_.setTooltip("Run renderers in a staged chain"); rendererChainModeBox_.setTooltip("Renderer chain strategy"); residualBlendSlider_.setTooltip("Control residual audio blend"); - // Button visual hierarchy: primary > secondary > tertiary - // Primary — Mix+Master is the hero action; filled brand colour. - autoMixMasterButton_.setColour(juce::TextButton::buttonColourId, colour(colours::primary)); - autoMixMasterButton_.setColour(juce::TextButton::buttonOnColourId, colour(colours::primaryPressed)); - autoMixMasterButton_.setColour(juce::TextButton::textColourOffId, juce::Colours::white); - autoMixMasterButton_.setColour(juce::TextButton::textColourOnId, juce::Colours::white); - - // Secondary — Import and Export are the gateway/output actions; tinted text on dark surface. - for (juce::TextButton* btn : {&importButton_, &exportButton_}) { - btn->setColour(juce::TextButton::buttonColourId, colour(colours::surface)); - btn->setColour(juce::TextButton::buttonOnColourId, colour(colours::surfaceLight)); - btn->setColour(juce::TextButton::textColourOffId, colour(colours::primary)); - btn->setColour(juce::TextButton::textColourOnId, colour(colours::primaryHover)); - } - - // Tertiary — AutoMix, AutoMaster, Batch are advanced ops; visually receded. - for (juce::TextButton* btn : {&autoMixButton_, &autoMasterButton_, &batchButton_}) { - btn->setColour(juce::TextButton::buttonColourId, colour(colours::surface)); - btn->setColour(juce::TextButton::buttonOnColourId, colour(colours::surfaceLight)); - btn->setColour(juce::TextButton::textColourOffId, colour(colours::textMuted)); - btn->setColour(juce::TextButton::textColourOnId, colour(colours::text)); - } + // Button visual hierarchy: primary > secondary > quiet (see buttonVariant in AutoMixLookAndFeel.h). + setButtonVariant(autoMixMasterButton_, buttonVariant::primary); + for (juce::TextButton* btn : {&autoMixButton_, &autoMasterButton_, &batchButton_, &exportButton_}) + setButtonVariant(*btn, buttonVariant::secondary); + setButtonVariant(importButton_, buttonVariant::primary); // Keyboard focus on action buttons importButton_.setWantsKeyboardFocus(true); @@ -123,14 +121,14 @@ ControlDeck::ControlDeck() { rendererChainPreviewLabel_.setFont(typography::caption()); rendererChainPreviewLabel_.setColour(juce::Label::textColourId, colour(colours::textMuted)); rendererChainPreviewLabel_.setJustificationType(juce::Justification::centredLeft); - addAndMakeVisible(rendererChainPreviewLabel_); + addChildComponent(rendererChainPreviewLabel_); blendLabel_.setFont(typography::caption()); blendLabel_.setColour(juce::Label::textColourId, colour(colours::textMuted)); blendLabel_.setJustificationType(juce::Justification::centredLeft); separationModelStatusLabel_.setFont(typography::caption()); separationModelStatusLabel_.setColour(juce::Label::textColourId, colour(colours::warning)); separationModelStatusLabel_.setJustificationType(juce::Justification::centredLeft); - separationModelStatusLabel_.setText("Separation model: none", juce::dontSendNotification); + separationModelStatusLabel_.setText("Model: none installed", juce::dontSendNotification); residualBlendSlider_.setSliderStyle(juce::Slider::LinearHorizontal); residualBlendSlider_.setTextBoxStyle(juce::Slider::TextBoxRight, false, 48, 20); @@ -139,10 +137,7 @@ ControlDeck::ControlDeck() { rendererChainModeBox_.setEnabled(false); // Advanced disclosure toggle - advancedToggle_.setColour(juce::TextButton::buttonColourId, colour(colours::surface)); - advancedToggle_.setColour(juce::TextButton::buttonOnColourId, colour(colours::surfaceLight)); - advancedToggle_.setColour(juce::TextButton::textColourOffId, colour(colours::textMuted)); - advancedToggle_.setColour(juce::TextButton::textColourOnId, colour(colours::text)); + setButtonVariant(advancedToggle_, buttonVariant::quiet); advancedToggle_.onClick = [this] { advancedExpanded_ = !advancedExpanded_; advancedToggle_.setButtonText(advancedExpanded_ ? "v Advanced" : "> Advanced"); @@ -151,8 +146,6 @@ ControlDeck::ControlDeck() { }; // Always-visible settings - addAndMakeVisible(rendererLabel_); - addAndMakeVisible(rendererBox_); addAndMakeVisible(profileLabel_); addAndMakeVisible(profileBox_); addAndMakeVisible(masterPresetLabel_); @@ -160,11 +153,13 @@ ControlDeck::ControlDeck() { addAndMakeVisible(platformPresetLabel_); addAndMakeVisible(platformPresetBox_); addAndMakeVisible(separatedStemsToggle_); - addAndMakeVisible(tensorSeparationToggle_); - addAndMakeVisible(separationModelStatusLabel_); + addChildComponent(tensorSeparationToggle_); + addChildComponent(separationModelStatusLabel_); addAndMakeVisible(advancedToggle_); // Advanced settings — hidden by default, revealed by advancedToggle_ + addChildComponent(rendererLabel_); + addChildComponent(rendererBox_); addChildComponent(exportFormatLabel_); addChildComponent(exportFormatBox_); addChildComponent(exportModeLabel_); @@ -175,6 +170,8 @@ ControlDeck::ControlDeck() { addChildComponent(blendLabel_); addChildComponent(residualBlendSlider_); addChildComponent(batchRecursiveToggle_); + + setHasStems(false); } ControlDeck::~ControlDeck() = default; @@ -216,26 +213,25 @@ void ControlDeck::resized() { centerArea.removeFromTop(spacing::gapSmall); - // Always-visible context rows: Renderer/Profile and Master/Platform + // Always-visible context rows: Profile/Master/Platform, then the separation row auto settingsRow1 = centerArea.removeFromTop(28); - rendererLabel_.setBounds(settingsRow1.removeFromLeft(64).reduced(1)); - rendererBox_.setBounds(settingsRow1.removeFromLeft(160).reduced(1)); profileLabel_.setBounds(settingsRow1.removeFromLeft(50).reduced(1)); - profileBox_.setBounds(settingsRow1.removeFromLeft(140).reduced(1)); - - auto settingsRow2 = centerArea.removeFromTop(28); - masterPresetLabel_.setBounds(settingsRow2.removeFromLeft(50).reduced(1)); - masterPresetBox_.setBounds(settingsRow2.removeFromLeft(140).reduced(1)); - platformPresetLabel_.setBounds(settingsRow2.removeFromLeft(64).reduced(1)); - platformPresetBox_.setBounds(settingsRow2.removeFromLeft(140).reduced(1)); - settingsRow2.removeFromLeft(spacing::gapSmall); - separatedStemsToggle_.setBounds(settingsRow2.removeFromLeft(180).reduced(1)); - tensorSeparationToggle_.setBounds(settingsRow2.removeFromLeft(120).reduced(1)); - settingsRow2.removeFromLeft(spacing::gapSmall); - separationModelStatusLabel_.setBounds(settingsRow2.reduced(1)); - - auto chainPreviewRow = centerArea.removeFromTop(24); - rendererChainPreviewLabel_.setBounds(chainPreviewRow.removeFromLeft(640).reduced(1)); + profileBox_.setBounds(settingsRow1.removeFromLeft(200).reduced(1)); + masterPresetLabel_.setBounds(settingsRow1.removeFromLeft(50).reduced(1)); + masterPresetBox_.setBounds(settingsRow1.removeFromLeft(170).reduced(1)); + platformPresetLabel_.setBounds(settingsRow1.removeFromLeft(64).reduced(1)); + platformPresetBox_.setBounds(settingsRow1.removeFromLeft(150).reduced(1)); + + auto separationRow = centerArea.removeFromTop(28); + separatedStemsToggle_.setBounds(separationRow.removeFromLeft(180).reduced(1)); + tensorSeparationToggle_.setVisible(separationControlsVisible_); + separationModelStatusLabel_.setVisible(separationControlsVisible_); + if (separationControlsVisible_) { + separationRow.removeFromLeft(16); + tensorSeparationToggle_.setBounds(separationRow.removeFromLeft(120).reduced(1)); + separationRow.removeFromLeft(spacing::gapSmall); + separationModelStatusLabel_.setBounds(separationRow.reduced(1)); + } centerArea.removeFromTop(spacing::gapSmall); // Advanced disclosure row @@ -243,6 +239,9 @@ void ControlDeck::resized() { centerArea.removeFromTop(spacing::gapSmall); // Advanced settings — shown only when expanded + rendererLabel_.setVisible(advancedExpanded_); + rendererBox_.setVisible(advancedExpanded_); + rendererChainPreviewLabel_.setVisible(advancedExpanded_); exportFormatLabel_.setVisible(advancedExpanded_); exportFormatBox_.setVisible(advancedExpanded_); exportModeLabel_.setVisible(advancedExpanded_); @@ -255,6 +254,12 @@ void ControlDeck::resized() { batchRecursiveToggle_.setVisible(advancedExpanded_); if (advancedExpanded_) { + auto rendererRow = centerArea.removeFromTop(28); + rendererLabel_.setBounds(rendererRow.removeFromLeft(64).reduced(1)); + rendererBox_.setBounds(rendererRow.removeFromLeft(160).reduced(1)); + rendererRow.removeFromLeft(spacing::gapSmall); + rendererChainPreviewLabel_.setBounds(rendererRow.removeFromLeft(640).reduced(1)); + auto settingsRow3 = centerArea.removeFromTop(28); exportFormatLabel_.setBounds(settingsRow3.removeFromLeft(50).reduced(1)); exportFormatBox_.setBounds(settingsRow3.removeFromLeft(100).reduced(1)); @@ -282,7 +287,22 @@ void ControlDeck::setSeparationModelStatus(const juce::String& text, const bool separationModelStatusLabel_.setText(text, juce::dontSendNotification); separationModelStatusLabel_.setColour( juce::Label::textColourId, - ready ? colour(colours::success) : colour(colours::warning)); + ready ? colour(colours::textMuted) : colour(colours::warning)); +} + +void ControlDeck::setHasStems(const bool hasStems) { + autoMixButton_.setEnabled(hasStems); + autoMasterButton_.setEnabled(hasStems); + autoMixMasterButton_.setEnabled(hasStems); + exportButton_.setEnabled(hasStems); + batchButton_.setEnabled(true); + importButton_.setEnabled(true); + setButtonVariant(importButton_, hasStems ? buttonVariant::secondary : buttonVariant::primary); +} + +void ControlDeck::setSeparationControlsVisible(const bool visible) { + separationControlsVisible_ = visible; + resized(); } } // namespace automix::app diff --git a/src/app/ui/ControlDeck.h b/src/app/ui/ControlDeck.h index 8f5b891..ada2f5a 100644 --- a/src/app/ui/ControlDeck.h +++ b/src/app/ui/ControlDeck.h @@ -46,6 +46,11 @@ class ControlDeck final : public juce::Component { juce::ToggleButton& getBatchRecursiveToggle() { return batchRecursiveToggle_; } void setRendererChainPreviewText(const juce::String& text); void setSeparationModelStatus(const juce::String& text, bool ready); + /// Enables Auto Mix, Auto Master, Mix + Master and Export only when stems exist; Import becomes the + /// primary action while the session is empty. + void setHasStems(bool hasStems); + /// Progressive disclosure: the Vocal Model toggle and model label are shown only while AI Stem Separation is on. + void setSeparationControlsVisible(bool visible); private: std::unique_ptr stemPanel_; @@ -80,12 +85,13 @@ class ControlDeck final : public juce::Component { juce::Slider residualBlendSlider_; juce::ToggleButton separatedStemsToggle_{"AI Stem Separation"}; juce::ToggleButton tensorSeparationToggle_{"Vocal Model"}; - juce::Label separationModelStatusLabel_{"", "Separation model: none"}; + juce::Label separationModelStatusLabel_{"", "Model: none installed"}; juce::ToggleButton batchRecursiveToggle_{"Recursive Batch"}; // Advanced section toggle juce::TextButton advancedToggle_{"> Advanced"}; bool advancedExpanded_ = false; + bool separationControlsVisible_ = false; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ControlDeck) }; diff --git a/src/app/ui/GlowMeters.cpp b/src/app/ui/GlowMeters.cpp index 4bd161e..124dcf5 100644 --- a/src/app/ui/GlowMeters.cpp +++ b/src/app/ui/GlowMeters.cpp @@ -8,6 +8,7 @@ namespace automix::app { using namespace theme; GlowMeters::GlowMeters() { + setTitle("Loudness meters"); lufsLabel_.setText("I: -- LUFS", juce::dontSendNotification); lufsLabel_.setFont(typography::caption()); lufsLabel_.setColour(juce::Label::textColourId, colour(colours::textMuted)); diff --git a/src/app/ui/HeaderBar.cpp b/src/app/ui/HeaderBar.cpp index e1362e9..97f8a64 100644 --- a/src/app/ui/HeaderBar.cpp +++ b/src/app/ui/HeaderBar.cpp @@ -1,5 +1,7 @@ #include "app/ui/HeaderBar.h" +#include "app/style/AutoMixLookAndFeel.h" + namespace automix::app { using namespace theme; @@ -17,6 +19,10 @@ HeaderBar::HeaderBar() { addAndMakeVisible(settingsButton_); addAndMakeVisible(profileSelector_); + for (juce::Button* btn : {static_cast(&saveButton_), static_cast(&loadButton_), + static_cast(&modelsButton_), static_cast(&settingsButton_)}) + setButtonVariant(*btn, buttonVariant::quiet); + saveButton_.setTooltip("Save Session (Ctrl+S)"); loadButton_.setTooltip("Load Session (Ctrl+O)"); modelsButton_.setTooltip("Model Manager (Ctrl+K)"); diff --git a/src/app/ui/HeroWaveform.cpp b/src/app/ui/HeroWaveform.cpp index 02702b0..4530d64 100644 --- a/src/app/ui/HeroWaveform.cpp +++ b/src/app/ui/HeroWaveform.cpp @@ -127,6 +127,17 @@ void HeroWaveform::mouseDrag(const juce::MouseEvent& event) { onSeek(progress); } +void HeroWaveform::mouseUp(const juce::MouseEvent& /*event*/) { + if (waveformPeaks_.empty() && onImportRequested) + onImportRequested(); +} + +juce::MouseCursor HeroWaveform::getMouseCursor() { + if (waveformPeaks_.empty()) + return juce::MouseCursor::PointingHandCursor; + return juce::Component::getMouseCursor(); +} + void HeroWaveform::mouseWheelMove(const juce::MouseEvent& event, const juce::MouseWheelDetails& wheel) { const double zoomSensitivity = 0.3; double zoomDelta = -wheel.deltaY * zoomSensitivity; @@ -355,16 +366,32 @@ void HeroWaveform::paint(juce::Graphics& g) { float midY = h * 0.5f; if (waveformPeaks_.empty()) { + const auto zone = bounds.reduced(12.0f); + if (isDragOver_) { + g.setColour(colour(colours::primary).withAlpha(0.08f)); + g.fillRoundedRectangle(zone, metrics::cornerRadiusLarge); + g.setColour(colour(colours::primary)); + g.drawRoundedRectangle(zone, metrics::cornerRadiusLarge, 2.0f); + } else { + juce::Path zonePath; + zonePath.addRoundedRectangle(zone, metrics::cornerRadiusLarge); + juce::Path dashedPath; + const float dashes[] = {6.0f, 4.0f}; + juce::PathStrokeType(1.5f).createDashedStroke(dashedPath, zonePath, dashes, 2); + g.setColour(colour(colours::surfaceBorder)); + g.fillPath(dashedPath); + } + auto upperHalf = bounds.withHeight(h * 0.5f); g.setFont(typography::subhead()); - g.setColour(colour(colours::primary).withAlpha(0.85f)); - g.drawText("Drop stems here or click Import (Ctrl+I)", upperHalf, juce::Justification::centredBottom); + g.setColour(colour(colours::text)); + g.drawText("Drop stems here or click to import", upperHalf, juce::Justification::centredBottom); auto lowerHalf = bounds.withY(h * 0.5f).withHeight(h * 0.5f); g.setFont(typography::caption()); g.setColour(colour(colours::textMuted)); - g.drawText("1 Import Stems -> 2 Mix + Master -> 3 Export\n" - "Tip: enable 'AI Stem Separation' in the control deck to split one full mix into stems.", + const auto arrow = juce::String(juce::CharPointer_UTF8("\xe2\x86\x92")); + g.drawText("1 Import stems " + arrow + " 2 Mix + Master " + arrow + " 3 Export", lowerHalf.reduced(0.0f, 8.0f), juce::Justification::centredTop); } else { // Draw stem overlay if we have per-stem data @@ -440,7 +467,7 @@ void HeroWaveform::paint(juce::Graphics& g) { drawZoomControls(g); } - if (isDragOver_) { + if (isDragOver_ && !waveformPeaks_.empty()) { g.setColour(colour(colours::primary).withAlpha(0.12f)); g.fillAll(); g.setColour(colour(colours::primary)); diff --git a/src/app/ui/HeroWaveform.h b/src/app/ui/HeroWaveform.h index 1af1b33..f6be808 100644 --- a/src/app/ui/HeroWaveform.h +++ b/src/app/ui/HeroWaveform.h @@ -21,6 +21,8 @@ class HeroWaveform final : public juce::Component, void paint(juce::Graphics& g) override; void mouseDown(const juce::MouseEvent& event) override; void mouseDrag(const juce::MouseEvent& event) override; + void mouseUp(const juce::MouseEvent& event) override; + juce::MouseCursor getMouseCursor() override; void mouseWheelMove(const juce::MouseEvent& event, const juce::MouseWheelDetails& wheel) override; void resized() override; @@ -38,6 +40,7 @@ class HeroWaveform final : public juce::Component, void setStemGroups(const std::vector& groups); // Callbacks + std::function onImportRequested; // empty-state zone clicked std::function onSeek; // progress fraction 0..1 std::function onZoomChanged; // new zoom factor std::function)> onFilesDropped; // audio/preset files dropped diff --git a/src/app/ui/MainLayout.cpp b/src/app/ui/MainLayout.cpp index 7947680..b0fadda 100644 --- a/src/app/ui/MainLayout.cpp +++ b/src/app/ui/MainLayout.cpp @@ -13,10 +13,12 @@ #include "app/ui/TaskOrchestrator.h" #include "app/ui/TransportBar.h" #include "app/ui/VerificationEngine.h" +#include "ai/BsRoformerPack.h" #include "ai/GpuRuntimePack.h" #include "ai/HuggingFaceModelHub.h" #include "ai/ModelStorage.h" #include "ai/OnnxTensorInference.h" +#include "ai/UmxPack.h" #include "renderers/RendererPipeline.h" #include "util/FileUtils.h" @@ -111,12 +113,9 @@ MainLayout::MainLayout() { addAndMakeVisible(*taskCenter_); // Destructive Clear: MainLayout-owned (not part of TransportBar), confirmed on click. - clearTracksButton_.setTooltip("Clear Imported Tracks (asks for confirmation)"); + clearTracksButton_.setTooltip("Remove all stems from the session"); clearTracksButton_.setWantsKeyboardFocus(true); - clearTracksButton_.setColour(juce::TextButton::buttonColourId, colour(colours::surface)); - clearTracksButton_.setColour(juce::TextButton::buttonOnColourId, colour(colours::surfaceLight)); - clearTracksButton_.setColour(juce::TextButton::textColourOffId, colour(colours::warning)); - clearTracksButton_.setColour(juce::TextButton::textColourOnId, colour(colours::warning)); + setButtonVariant(clearTracksButton_, buttonVariant::danger); clearTracksButton_.onClick = [this] { onClearTracks(); }; addAndMakeVisible(clearTracksButton_); @@ -161,6 +160,16 @@ MainLayout::MainLayout() { commandManager_.registerAllCommandsForTarget(this); sessionManager_.markSaved(); + refreshStemDependentUi(); +} + +void MainLayout::refreshStemDependentUi() { + const bool hasStems = !sessionManager_.session().stems.empty(); + if (controlDeck_ != nullptr) + controlDeck_->setHasStems(hasStems); + if (transportBar_ != nullptr) + transportBar_->setHasMedia(hasStems); + clearTracksButton_.setEnabled(hasStems); } // ── Controllers factory ──────────────────────────────────────── @@ -290,6 +299,7 @@ void MainLayout::initControllers() { safe->sessionManager_.session().stems = result.stems; safe->sessionManager_.session().originalMixPath = result.originalMixPath; updateStemPanelFromSession(safe->controlDeck_->getStemPanel(), safe->sessionManager_.session()); + safe->refreshStemDependentUi(); safe->taskOrchestrator_->finishTaskCompleted(ActiveTask::Import, "Import complete"); safe->rebuildPreview(); @@ -1011,6 +1021,7 @@ void MainLayout::performClearTracks() { transportBar_->setTimeDisplay(0.0, 0.0); sessionManager_.session().stems.clear(); detail::updateStemPanelFromSession(controlDeck_->getStemPanel(), sessionManager_.session()); + refreshStemDependentUi(); } // ───────────────────────────────────────────────────────────────── @@ -1100,6 +1111,7 @@ void MainLayout::wireControlDeckCallbacks() { const bool enabled = controlDeck_->getSeparatedStemsToggle().getToggleState(); sessionManager_.session().aiStemsEnabled = enabled; controlDeck_->getTensorSeparationToggle().setEnabled(enabled); + controlDeck_->setSeparationControlsVisible(enabled); }; controlDeck_->getTensorSeparationToggle().onClick = [this] { const bool enabled = controlDeck_->getTensorSeparationToggle().getToggleState(); @@ -1132,6 +1144,7 @@ void MainLayout::wireHeroWaveformCallbacks() { heroWaveform_->onSeek = [this](double progress) { transportController_.seekToFraction(std::clamp(progress, 0.0, 1.0)); }; + heroWaveform_->onImportRequested = [this] { onImport(); }; heroWaveform_->onFilesDropped = [this](std::vector files) { importFiles(std::move(files)); }; @@ -1900,6 +1913,7 @@ void MainLayout::applyLoadedSession(domain::Session loadedSession, const juce::S sessionShownModified_ = false; setSessionDisplayName(juce::File(sourcePath).getFileNameWithoutExtension()); updateStemPanelFromSession(controlDeck_->getStemPanel(), session); + refreshStemDependentUi(); transportBar_->setLoopEnabled(session.timeline.loopEnabled); heroWaveform_->setZoom(session.timeline.zoom, 0.5); refreshRenderers(); @@ -2092,9 +2106,10 @@ void MainLayout::updateSeparationModelBadge() { return; } + const juce::String noneText(juce::CharPointer_UTF8("Model: none installed \xe2\x80\x94 open Models")); const auto activeId = modelManager_.activePackId("separation"); if (activeId.empty()) { - controlDeck_->setSeparationModelStatus("Separation model: none", false); + controlDeck_->setSeparationModelStatus(noneText, false); return; } @@ -2103,7 +2118,7 @@ void MainLayout::updateSeparationModelBadge() { return util::toLower(pack.taskScope) == "separation" && pack.id == activeId; }); if (selected == packs.end()) { - controlDeck_->setSeparationModelStatus("Separation model: none", false); + controlDeck_->setSeparationModelStatus(noneText, false); return; } @@ -2111,7 +2126,21 @@ void MainLayout::updateSeparationModelBadge() { const auto modelPath = selected->rootPath / selected->modelFile; const bool ready = std::filesystem::is_regular_file(modelPath, error) && !error; const auto displayName = selected->name.empty() ? selected->id : selected->name; - auto badgeText = juce::String("Separation model: ") + juce::String(displayName); + const auto mentions = [&](const char* repoId) { + const juce::String repo(repoId); + const auto sanitizedRepo = repo.replaceCharacter('/', '_'); + for (const auto& field : {selected->id, selected->name, selected->source}) { + const juce::String value(field); + if (value.containsIgnoreCase(repo) || value.containsIgnoreCase(sanitizedRepo)) + return true; + } + return false; + }; + juce::String badgeText = "Model: " + juce::String(displayName); + if (mentions(ai::kBsRoformerRepoId)) + badgeText = "Model: BS-RoFormer (best quality, GPU recommended)"; + else if (mentions(ai::kUmxVocalsRepoId)) + badgeText = "Model: Open-Unmix (fast, lower quality)"; if (!ready) { badgeText << " (missing)"; } @@ -2217,6 +2246,7 @@ void MainLayout::applySessionUiSelections() { controlDeck_->getTensorSeparationToggle().setToggleState(session.renderSettings.tensorSeparationEnabled, juce::dontSendNotification); controlDeck_->getTensorSeparationToggle().setEnabled(session.aiStemsEnabled); + controlDeck_->setSeparationControlsVisible(session.aiStemsEnabled); controlDeck_->getBatchRecursiveToggle().setToggleState(session.batchRecursiveEnabled, juce::dontSendNotification); controlDeck_->getRendererChainToggle().setToggleState( session.renderSettings.rendererChainEnabled, diff --git a/src/app/ui/MainLayout.h b/src/app/ui/MainLayout.h index ae1c5cd..0238fd5 100644 --- a/src/app/ui/MainLayout.h +++ b/src/app/ui/MainLayout.h @@ -179,6 +179,7 @@ class MainLayout final : public juce::Component, void onSaveSession(std::function done = {}); void finishPendingSave(bool success); void refreshSessionTitle(); + void refreshStemDependentUi(); void setSessionDisplayName(const juce::String& name); void onLoadSession(); void onModelsDialog(); diff --git a/src/app/ui/MainLayoutInternal.h b/src/app/ui/MainLayoutInternal.h index 1ecce63..ceda4d5 100644 --- a/src/app/ui/MainLayoutInternal.h +++ b/src/app/ui/MainLayoutInternal.h @@ -18,6 +18,7 @@ #include #include +#include "app/style/AutoMixLookAndFeel.h" #include "ai/ModelManager.h" #include "ai/OnnxModelInference.h" #include "automaster/IAutoMasterStrategy.h" @@ -246,6 +247,7 @@ class SettingsPanel final : public juce::Component { gpuStatusLabel_.setText(gpuStatus, juce::dontSendNotification); gpuButton_.setButtonText(gpuButtonText); gpuButton_.setVisible(gpuButtonText.isNotEmpty()); + setButtonVariant(gpuButton_, gpuButtonText == "Remove" ? buttonVariant::danger : buttonVariant::secondary); gpuButton_.onClick = [this] { gpuButton_.setEnabled(false); // one action per dialog if (onGpuButton_) { @@ -269,6 +271,7 @@ class SettingsPanel final : public juce::Component { } void resized() override { + styleAudioSelectorButtons(audioSelector_); auto area = getLocalBounds().reduced(10); reportSidecarToggle_.setBounds(area.removeFromTop(28)); area.removeFromTop(6); @@ -283,6 +286,18 @@ class SettingsPanel final : public juce::Component { } private: + // The "Test" button lives inside JUCE's audio selector; find it by its text. + static void styleAudioSelectorButtons(juce::Component& parent) { + for (auto* child : parent.getChildren()) { + if (auto* button = dynamic_cast(child)) { + if (button->getButtonText() == "Test") + setButtonVariant(*button, buttonVariant::secondary); + } else if (child != nullptr) { + styleAudioSelectorButtons(*child); + } + } + } + juce::AudioDeviceSelectorComponent audioSelector_; juce::ToggleButton reportSidecarToggle_; std::function onWriteReportSidecarChanged_; diff --git a/src/app/ui/TaskCenterPanel.cpp b/src/app/ui/TaskCenterPanel.cpp index 04061b1..e884b04 100644 --- a/src/app/ui/TaskCenterPanel.cpp +++ b/src/app/ui/TaskCenterPanel.cpp @@ -1,5 +1,7 @@ #include "app/ui/TaskCenterPanel.h" +#include "app/style/AutoMixLookAndFeel.h" + #include #include @@ -43,6 +45,8 @@ TaskCenterPanel::TaskCenterPanel() : progressBar_(progressValue_), batchProgress juce::SystemClipboard::copyTextToClipboard(historyEditor_.getText()); }; + setButtonVariant(copyLogButton_, buttonVariant::quiet); + setButtonVariant(cancelButton_, buttonVariant::secondary); cancelButton_.setEnabled(false); cancelButton_.onClick = [this] { if (onCancel) diff --git a/src/app/ui/TransportBar.cpp b/src/app/ui/TransportBar.cpp index f5aec32..9aef5ff 100644 --- a/src/app/ui/TransportBar.cpp +++ b/src/app/ui/TransportBar.cpp @@ -1,5 +1,7 @@ #include "app/ui/TransportBar.h" +#include "app/style/AutoMixLookAndFeel.h" + #include #include @@ -19,6 +21,20 @@ TransportBar::TransportBar() { addAndMakeVisible(volumeSlider_); addAndMakeVisible(loopToggle_); + for (juce::Button* btn : {static_cast(&skipStartButton_), static_cast(&playPauseButton_), + static_cast(&stopButton_), static_cast(&skipEndButton_), + static_cast(&shortcutsButton_)}) + setButtonVariant(*btn, buttonVariant::secondary); + + // Accessibility titles + skipStartButton_.setTitle("Skip to start"); + playPauseButton_.setTitle("Play"); + stopButton_.setTitle("Stop"); + skipEndButton_.setTitle("Skip to end"); + shortcutsButton_.setTitle("Keyboard shortcuts"); + loopToggle_.setTitle("Loop"); + volumeSlider_.setTitle("Volume"); + // Tooltips skipStartButton_.setTooltip("Skip to Start (Home)"); playPauseButton_.setTooltip("Play / Pause (Space)"); @@ -110,6 +126,13 @@ void TransportBar::resized() { timeLabel_.setBounds(area); } +void TransportBar::setHasMedia(bool hasMedia) { + skipStartButton_.setEnabled(hasMedia); + playPauseButton_.setEnabled(hasMedia); + stopButton_.setEnabled(hasMedia); + skipEndButton_.setEnabled(hasMedia); +} + void TransportBar::setPlaying(bool playing) { isPlaying_ = playing; playPauseButton_.setButtonText(playing ? "Pause" : "Play"); diff --git a/src/app/ui/TransportBar.h b/src/app/ui/TransportBar.h index 236bf53..468dc15 100644 --- a/src/app/ui/TransportBar.h +++ b/src/app/ui/TransportBar.h @@ -17,6 +17,8 @@ class TransportBar final : public juce::Component, private juce::Slider::Listene bool keyPressed(const juce::KeyPress& key) override; void setPlaying(bool playing); + /// Enables the skip/play/stop buttons only when there is media to transport. + void setHasMedia(bool hasMedia); void setTimeDisplay(double currentSeconds, double totalSeconds); void setVolume(double volume); void setLoopEnabled(bool enabled); From 11d52355061c8a649ef432789d63fd048e05c589 Mon Sep 17 00:00:00 2001 From: Soficis Date: Mon, 5 Oct 2026 15:42:17 -0500 Subject: [PATCH 3/5] feat(ui): quieter task area, safer settings, and a layout that holds at the minimum window size - The batch strip shows only while a batch has items (nothing fed it before, so it only ever showed placeholders); the log is collapsible, collapsed by default, and opens on failure; dialog-open noise is no longer logged; idle progress is hidden. - Meters read "--" instead of -70.0 when there is no signal. - The header profile selector, which was never populated, is hidden; the deck's Profile combo is the single profile control. - Settings has Export / GPU acceleration / Audio output sections, and removing the GPU runtime asks first. The Model Browser opens at 760x560. - Disabled buttons are outlined instead of filled, so they no longer outshine enabled ones. - At 960x640 the settings row wraps instead of clipping Platform, and action buttons use a compact font instead of wrapping. - Tests: ControlDeck stem gating, separation visibility, meter formatting and layout at 940/1500 px; TaskCenterPanel batch strip, log and idle progress visibility. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 4 + src/app/style/AutoMixLookAndFeel.cpp | 16 ++-- src/app/ui/ControlDeck.cpp | 47 +++++++++--- src/app/ui/GlowMeters.cpp | 14 +++- src/app/ui/GlowMeters.h | 4 + src/app/ui/HeaderBar.cpp | 7 +- src/app/ui/MainLayout.cpp | 31 ++++++-- src/app/ui/MainLayoutInternal.h | 17 ++++- src/app/ui/TaskCenterPanel.cpp | 108 +++++++++++++++++++++------ src/app/ui/TaskCenterPanel.h | 14 ++++ tests/unit/ControlDeckTests.cpp | 98 ++++++++++++++++++++++++ tests/unit/TaskCenterPanelTests.cpp | 49 ++++++++++++ 12 files changed, 351 insertions(+), 58 deletions(-) create mode 100644 tests/unit/ControlDeckTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a131914..01f72ad 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -550,6 +550,7 @@ if(BUILD_TESTING) tests/unit/TestMain.cpp tests/unit/ControllerTests.cpp tests/unit/TaskCenterPanelTests.cpp + tests/unit/ControlDeckTests.cpp tests/unit/SessionSerializationTests.cpp src/app/ui/SessionManager.cpp tests/unit/AudioIoTests.cpp @@ -591,6 +592,9 @@ if(BUILD_TESTING) tests/integration/PipelineIntegrationTests.cpp src/app/ui/TaskCenterPanel.cpp src/app/style/AutoMixLookAndFeel.cpp + src/app/ui/ControlDeck.cpp + src/app/ui/StemPanel.cpp + src/app/ui/GlowMeters.cpp src/app/controllers/ModelController.cpp src/app/controllers/ImportController.cpp src/app/controllers/ExportController.cpp diff --git a/src/app/style/AutoMixLookAndFeel.cpp b/src/app/style/AutoMixLookAndFeel.cpp index 500c24e..5d7591a 100644 --- a/src/app/style/AutoMixLookAndFeel.cpp +++ b/src/app/style/AutoMixLookAndFeel.cpp @@ -83,7 +83,13 @@ void AutoMixLookAndFeel::drawButtonBackground(juce::Graphics& g, juce::Button& b const auto variant = variantOf(button); const bool enabled = button.isEnabled(); - if (variant == buttonVariant::secondary) { + if (!enabled) { + // Disabled: no fill, outline only for primary/secondary so it never out-shouts an enabled button. + if (variant != buttonVariant::quiet && variant != buttonVariant::danger) { + g.setColour(colour(colours::surfaceBorder)); + g.drawRoundedRectangle(bounds, cornerSize, 1.0f); + } + } else if (variant == buttonVariant::secondary) { juce::Colour bg = colour(colours::surfaceLight); if (enabled && shouldDrawButtonAsDown) bg = bg.darker(0.1f); @@ -101,9 +107,7 @@ void AutoMixLookAndFeel::drawButtonBackground(juce::Graphics& g, juce::Button& b } } else { juce::Colour bg; - if (!enabled) { - bg = colour(colours::surfaceBorder); - } else if (shouldDrawButtonAsDown) { + if (shouldDrawButtonAsDown) { bg = colour(colours::primaryPressed); } else if (shouldDrawButtonAsHighlighted) { bg = colour(colours::primary).interpolatedWith(colour(colours::primaryHover), 0.6f); @@ -399,7 +403,9 @@ void AutoMixLookAndFeel::drawToggleButton(juce::Graphics& g, juce::ToggleButton& // Fonts // ───────────────────────────────────────────────────────────────── -juce::Font AutoMixLookAndFeel::getTextButtonFont(juce::TextButton& /*button*/, int /*buttonHeight*/) { +juce::Font AutoMixLookAndFeel::getTextButtonFont(juce::TextButton& button, int /*buttonHeight*/) { + if (static_cast(button.getProperties().getWithDefault("compact", false))) + return typography::caption(); return typography::body(); } diff --git a/src/app/ui/ControlDeck.cpp b/src/app/ui/ControlDeck.cpp index 63ce2e3..0198fbc 100644 --- a/src/app/ui/ControlDeck.cpp +++ b/src/app/ui/ControlDeck.cpp @@ -188,8 +188,8 @@ void ControlDeck::resized() { auto area = getLocalBounds().reduced(static_cast(metrics::paddingMedium)); // Three-column layout - int stemWidth = std::max(200, area.getWidth() * 35 / 100); - int meterWidth = std::max(100, area.getWidth() * 15 / 100); + int stemWidth = juce::jlimit(200, 360, area.getWidth() * 28 / 100); + int meterWidth = juce::jlimit(110, 180, area.getWidth() * 13 / 100); auto stemArea = area.removeFromLeft(stemWidth); area.removeFromLeft(spacing::gapMedium); @@ -201,26 +201,49 @@ void ControlDeck::resized() { glowMeters_->setBounds(meterArea); // Action row - // Slots: import(1) | autoMix(1) | autoMaster(1) | Mix+Master(2) | batch(1) | export(1) = 7 slots + // Slots: import(1) | autoMix(1) | autoMaster(1) | Mix+Master(1.5) | batch(1) | export(1) = 6.5 slots auto actionRow = centerArea.removeFromTop(44); - const int slotW = actionRow.getWidth() / 7; + const int slotW = juce::roundToInt(static_cast(actionRow.getWidth()) / 6.5f); + const bool compact = slotW < 90; + for (auto* b : {&importButton_, &autoMixButton_, &autoMasterButton_, &autoMixMasterButton_, + &batchButton_, &exportButton_}) + b->getProperties().set("compact", compact); importButton_.setBounds(actionRow.removeFromLeft(slotW).reduced(2)); autoMixButton_.setBounds(actionRow.removeFromLeft(slotW).reduced(2)); autoMasterButton_.setBounds(actionRow.removeFromLeft(slotW).reduced(2)); - autoMixMasterButton_.setBounds(actionRow.removeFromLeft(slotW * 2).reduced(2)); + autoMixMasterButton_.setBounds(actionRow.removeFromLeft(juce::roundToInt(static_cast(slotW) * 1.5f)).reduced(2)); batchButton_.setBounds(actionRow.removeFromLeft(slotW).reduced(2)); exportButton_.setBounds(actionRow.reduced(2)); centerArea.removeFromTop(spacing::gapSmall); // Always-visible context rows: Profile/Master/Platform, then the separation row - auto settingsRow1 = centerArea.removeFromTop(28); - profileLabel_.setBounds(settingsRow1.removeFromLeft(50).reduced(1)); - profileBox_.setBounds(settingsRow1.removeFromLeft(200).reduced(1)); - masterPresetLabel_.setBounds(settingsRow1.removeFromLeft(50).reduced(1)); - masterPresetBox_.setBounds(settingsRow1.removeFromLeft(170).reduced(1)); - platformPresetLabel_.setBounds(settingsRow1.removeFromLeft(64).reduced(1)); - platformPresetBox_.setBounds(settingsRow1.removeFromLeft(150).reduced(1)); + // Pairs wrap onto a new row when they do not fit; combos shrink toward their minimum first. + { + struct Pair { + juce::Label* label; + juce::ComboBox* box; + int labelW, prefW, minW; + }; + const Pair pairs[] = {{&profileLabel_, &profileBox_, 50, 200, 150}, + {&masterPresetLabel_, &masterPresetBox_, 50, 170, 130}, + {&platformPresetLabel_, &platformPresetBox_, 64, 150, 120}}; + const int rowWidth = centerArea.getWidth(); + auto row = centerArea.removeFromTop(28); + int x = 0; + for (const auto& p : pairs) { + const int avail = rowWidth - x - p.labelW; + if (x > 0 && avail < p.minW) { + row = centerArea.removeFromTop(28); + x = 0; + } + const int comboW = std::min(p.prefW, std::max(p.minW, rowWidth - x - p.labelW)); + p.label->setBounds(row.getX() + x, row.getY(), p.labelW, row.getHeight()); + p.label->setBounds(p.label->getBounds().reduced(1)); + p.box->setBounds(juce::Rectangle(row.getX() + x + p.labelW, row.getY(), comboW, row.getHeight()).reduced(1)); + x += p.labelW + comboW; + } + } auto separationRow = centerArea.removeFromTop(28); separatedStemsToggle_.setBounds(separationRow.removeFromLeft(180).reduced(1)); diff --git a/src/app/ui/GlowMeters.cpp b/src/app/ui/GlowMeters.cpp index 124dcf5..5589968 100644 --- a/src/app/ui/GlowMeters.cpp +++ b/src/app/ui/GlowMeters.cpp @@ -7,6 +7,12 @@ namespace automix::app { using namespace theme; +juce::String GlowMeters::formatReadout(const juce::String& prefix, const double value, const juce::String& unit) { + if (!std::isfinite(value) || value <= kNoSignalDb) + return prefix + "--" + unit; + return prefix + juce::String(value, 1) + unit; +} + GlowMeters::GlowMeters() { setTitle("Loudness meters"); lufsLabel_.setText("I: -- LUFS", juce::dontSendNotification); @@ -108,10 +114,10 @@ void GlowMeters::timerCallback() { } // Pre-allocated string updates (avoid ostringstream allocation per frame) - lufsText_ = "I: " + juce::String(integratedLufs_, 1) + " LUFS"; - stText_ = "S: " + juce::String(shortTermLufs_, 1) + " LUFS"; - tpText_ = "TP: " + juce::String(truePeakDbtp_, 1) + " dBTP"; - momentText_ = "M: " + juce::String(momentaryLufs_, 1) + " LUFS"; + lufsText_ = formatReadout("I: ", integratedLufs_, " LUFS"); + stText_ = formatReadout("S: ", shortTermLufs_, " LUFS"); + tpText_ = formatReadout("TP: ", truePeakDbtp_, " dBTP"); + momentText_ = formatReadout("M: ", momentaryLufs_, " LUFS"); lufsLabel_.setText(lufsText_, juce::dontSendNotification); shortTermLabel_.setText(stText_, juce::dontSendNotification); diff --git a/src/app/ui/GlowMeters.h b/src/app/ui/GlowMeters.h index 3e6c282..c188702 100644 --- a/src/app/ui/GlowMeters.h +++ b/src/app/ui/GlowMeters.h @@ -21,6 +21,9 @@ class GlowMeters final : public juce::Component, private juce::Timer { void setTruePeak(double truePeakDbtp); void setMomentaryLufs(double momentary); + /// Values at or below kNoSignalDb (or non-finite) read as "--". + static juce::String formatReadout(const juce::String& prefix, double value, const juce::String& unit); + private: void timerCallback() override; void drawMeter(juce::Graphics& g, juce::Rectangle bounds, float levelDb, float peakDb) const; @@ -62,6 +65,7 @@ class GlowMeters final : public juce::Component, private juce::Timer { juce::Label momentaryLabel_; juce::Label lufsBarLabel_; + static constexpr double kNoSignalDb = -69.95; static constexpr float kMinDb = -60.0f; static constexpr float kMaxDb = 6.0f; static constexpr int kPeakHoldFrames = 40; diff --git a/src/app/ui/HeaderBar.cpp b/src/app/ui/HeaderBar.cpp index 97f8a64..42ec413 100644 --- a/src/app/ui/HeaderBar.cpp +++ b/src/app/ui/HeaderBar.cpp @@ -17,7 +17,9 @@ HeaderBar::HeaderBar() { addAndMakeVisible(loadButton_); addAndMakeVisible(modelsButton_); addAndMakeVisible(settingsButton_); - addAndMakeVisible(profileSelector_); + // Profile selection lives in the ControlDeck "Profile" combo; this selector is never populated. + addChildComponent(profileSelector_); + profileSelector_.setVisible(false); for (juce::Button* btn : {static_cast(&saveButton_), static_cast(&loadButton_), static_cast(&modelsButton_), static_cast(&settingsButton_)}) @@ -84,9 +86,6 @@ void HeaderBar::resized() { loadButton_.setBounds(rightArea.removeFromRight(80).reduced(2)); saveButton_.setBounds(rightArea.reduced(2)); - auto profileArea = area.removeFromLeft(220); - profileSelector_.setBounds(profileArea.reduced(2)); - sessionNameLabel_.setBounds(area); } diff --git a/src/app/ui/MainLayout.cpp b/src/app/ui/MainLayout.cpp index b0fadda..d01298a 100644 --- a/src/app/ui/MainLayout.cpp +++ b/src/app/ui/MainLayout.cpp @@ -146,6 +146,7 @@ MainLayout::MainLayout() { wireControlDeckCallbacks(); wireHeroWaveformCallbacks(); + taskCenter_->onPreferredHeightChanged = [this] { resized(); }; taskCenter_->onCancel = [this] { taskOrchestrator_->cancelActiveTask(); }; // 5. Audio device & transport @@ -606,7 +607,7 @@ void MainLayout::resized() { fb.items.add(juce::FlexItem(*headerBar_).withHeight(static_cast(kHeaderHeight))); fb.items.add(juce::FlexItem(*heroWaveform_).withFlex(1.5f).withMinHeight(120.0f)); fb.items.add(juce::FlexItem(*controlDeck_).withFlex(2.5f).withMinHeight(180.0f)); - fb.items.add(juce::FlexItem(*taskCenter_).withFlex(1.2f).withMinHeight(160.0f)); + fb.items.add(juce::FlexItem(*taskCenter_).withHeight(static_cast(taskCenter_->getPreferredHeight()))); fb.performLayout(area); } @@ -1814,8 +1815,9 @@ void MainLayout::onModelsDialog() { options.escapeKeyTriggersCloseButton = true; options.useNativeTitleBar = true; options.resizable = true; - options.launchAsync(); - taskOrchestrator_->appendHistory("Model browser opened"); + panel->setSize(760, 560); + if (auto* dialog = options.launchAsync()) + dialog->setResizeLimits(640, 460, 4096, 4096); } // ───────────────────────────────────────────────────────────────── @@ -1834,7 +1836,7 @@ void MainLayout::onSettings() { : "Export sidecar JSON disabled"); }, gpuRuntimeStatusText(), gpuRuntimeButtonText(), [this] { onGpuRuntimeButton(); }); - settingsPanel->setSize(540, 430); + settingsPanel->setSize(540, 520); juce::DialogWindow::LaunchOptions options; options.content.setOwned(settingsPanel); @@ -1844,7 +1846,6 @@ void MainLayout::onSettings() { options.useNativeTitleBar = true; options.resizable = false; options.launchAsync(); - taskOrchestrator_->appendHistory("Settings dialog opened"); } // ───────────────────────────────────────────────────────────────── @@ -2483,9 +2484,23 @@ juce::String MainLayout::gpuRuntimeButtonText() const { void MainLayout::onGpuRuntimeButton() { namespace pack = ai::GpuRuntimePack; if (pack::isInstalled(pack::defaultRoot())) { - const auto removed = pack::uninstall(); - ai::invalidateTensorProviderProbeCache(); - taskOrchestrator_->appendHistory(juce::String(removed.message)); + juce::AlertWindow::showAsync( + juce::MessageBoxOptions::makeOptionsOkCancel( + juce::MessageBoxIconType::WarningIcon, + "Remove GPU runtime?", + "This deletes the downloaded NVIDIA libraries. The vocal model will run on the CPU until you install them " + "again.", + "Remove", + "Cancel"), + [safe = juce::Component::SafePointer(this)](const int result) { + if (result == 0 || safe == nullptr) { + return; + } + namespace pack = ai::GpuRuntimePack; + const auto removed = pack::uninstall(); + ai::invalidateTensorProviderProbeCache(); + safe->taskOrchestrator_->appendHistory(juce::String(removed.message)); + }); return; } gpuRuntimeOffered_ = true; // asked explicitly; no need to offer again this run diff --git a/src/app/ui/MainLayoutInternal.h b/src/app/ui/MainLayoutInternal.h index ceda4d5..832e007 100644 --- a/src/app/ui/MainLayoutInternal.h +++ b/src/app/ui/MainLayoutInternal.h @@ -257,6 +257,15 @@ class SettingsPanel final : public juce::Component { addAndMakeVisible(gpuStatusLabel_); addChildComponent(gpuButton_); + for (auto* heading : {&exportHeading_, &gpuHeading_, &audioHeading_}) { + heading->setFont(theme::typography::subhead()); + heading->setColour(juce::Label::textColourId, theme::colour(theme::colours::textMuted)); + addAndMakeVisible(*heading); + } + exportHeading_.setText("Export", juce::dontSendNotification); + gpuHeading_.setText("GPU acceleration", juce::dontSendNotification); + audioHeading_.setText("Audio output", juce::dontSendNotification); + reportSidecarToggle_.setButtonText("Write .report.json sidecar next to each exported file"); reportSidecarToggle_.setTooltip("Disable to export only audio files without per-file JSON report sidecars."); reportSidecarToggle_.setToggleState(writeReportJsonSidecar, juce::dontSendNotification); @@ -273,8 +282,10 @@ class SettingsPanel final : public juce::Component { void resized() override { styleAudioSelectorButtons(audioSelector_); auto area = getLocalBounds().reduced(10); + exportHeading_.setBounds(area.removeFromTop(24)); reportSidecarToggle_.setBounds(area.removeFromTop(28)); - area.removeFromTop(6); + area.removeFromTop(10); + gpuHeading_.setBounds(area.removeFromTop(24)); auto gpuRow = area.removeFromTop(28); if (gpuButton_.isVisible()) { gpuButton_.setBounds(gpuRow.removeFromRight(130)); @@ -282,6 +293,7 @@ class SettingsPanel final : public juce::Component { } gpuStatusLabel_.setBounds(gpuRow); area.removeFromTop(10); + audioHeading_.setBounds(area.removeFromTop(24)); audioSelector_.setBounds(area); } @@ -301,6 +313,9 @@ class SettingsPanel final : public juce::Component { juce::AudioDeviceSelectorComponent audioSelector_; juce::ToggleButton reportSidecarToggle_; std::function onWriteReportSidecarChanged_; + juce::Label exportHeading_; + juce::Label gpuHeading_; + juce::Label audioHeading_; juce::Label gpuStatusLabel_; juce::TextButton gpuButton_; std::function onGpuButton_; diff --git a/src/app/ui/TaskCenterPanel.cpp b/src/app/ui/TaskCenterPanel.cpp index e884b04..1773d96 100644 --- a/src/app/ui/TaskCenterPanel.cpp +++ b/src/app/ui/TaskCenterPanel.cpp @@ -41,6 +41,9 @@ TaskCenterPanel::TaskCenterPanel() : progressBar_(progressValue_), batchProgress .withPointHeight(12.0f))); historyEditor_.setText("Task history will appear here."); + toggleLogButton_.onClick = [this] { setLogVisible(!logVisible_); }; + setButtonVariant(toggleLogButton_, buttonVariant::quiet); + copyLogButton_.onClick = [this] { juce::SystemClipboard::copyTextToClipboard(historyEditor_.getText()); }; @@ -80,6 +83,7 @@ TaskCenterPanel::TaskCenterPanel() : progressBar_(progressValue_), batchProgress addAndMakeVisible(stateBadge_); addAndMakeVisible(progressBar_); addAndMakeVisible(progressLabel_); + addAndMakeVisible(toggleLogButton_); addAndMakeVisible(copyLogButton_); addAndMakeVisible(cancelButton_); addAndMakeVisible(queueHeaderLabel_); @@ -90,6 +94,9 @@ TaskCenterPanel::TaskCenterPanel() : progressBar_(progressValue_), batchProgress addAndMakeVisible(batchProgressBar_); addAndMakeVisible(historyEditor_); + progressLabel_.setVisible(false); // Idle (READY) at start + progressBar_.setVisible(false); + historyEditor_.setVisible(false); // Log is collapsed by default updateBatchSummary(); } @@ -100,7 +107,7 @@ void TaskCenterPanel::paint(juce::Graphics& g) { g.setColour(colour(colours::surfaceBorder)); g.fillRect(0, 0, getWidth(), 1); - if (!queueItems_.empty()) { + if (batchActive_ && !queueItems_.empty()) { auto area = getLocalBounds().reduced(static_cast(metrics::paddingMedium)); // Mirror resized() layout: skip top row (24) + queue header row (18) area.removeFromTop(24); @@ -149,43 +156,92 @@ void TaskCenterPanel::drawQueueItem(juce::Graphics& g, juce::Rectangle bo void TaskCenterPanel::resized() { auto area = getLocalBounds().reduced(static_cast(metrics::paddingMedium)); - // Top row: state badge + task label + progress + cancel - auto topRow = area.removeFromTop(24); + // Top row: state badge + task label + progress + log toggle + copy log + cancel + auto topRow = area.removeFromTop(kTopRowHeight); cancelButton_.setBounds(topRow.removeFromRight(64).reduced(1)); copyLogButton_.setBounds(topRow.removeFromRight(84).reduced(1)); + toggleLogButton_.setBounds(topRow.removeFromRight(84).reduced(1)); progressLabel_.setBounds(topRow.removeFromRight(48).reduced(1)); auto progressArea = topRow.removeFromRight(std::min(220, topRow.getWidth() / 2)); progressBar_.setBounds(progressArea.reduced(2)); stateBadge_.setBounds(topRow.removeFromLeft(80).reduced(1)); taskLabel_.setBounds(topRow); - // Batch queue header row - auto queueHeaderRow = area.removeFromTop(18); - queueEtaLabel_.setBounds(queueHeaderRow.removeFromRight(100).reduced(1)); - queueThroughputLabel_.setBounds(queueHeaderRow.removeFromRight(80).reduced(1)); - queueHeaderLabel_.setBounds(queueHeaderRow.reduced(1)); + if (batchActive_) { + // Batch queue header row + auto queueHeaderRow = area.removeFromTop(18); + queueEtaLabel_.setBounds(queueHeaderRow.removeFromRight(100).reduced(1)); + queueThroughputLabel_.setBounds(queueHeaderRow.removeFromRight(80).reduced(1)); + queueHeaderLabel_.setBounds(queueHeaderRow.reduced(1)); + + // Queue items + if (!queueItems_.empty()) { + int queueHeight = std::min(static_cast(queueItems_.size()), kMaxVisibleQueueItems) * kQueueItemHeight; + area.removeFromTop(queueHeight + 4); + } - // Queue items - if (!queueItems_.empty()) { - int queueHeight = std::min(static_cast(queueItems_.size()), kMaxVisibleQueueItems) * kQueueItemHeight; - area.removeFromTop(queueHeight + 4); + // Batch ETA / summary row: ETA (left) + overall progress bar (middle) + counts (right) + auto summaryRow = area.removeFromTop(22); + etaLabel_.setBounds(summaryRow.removeFromLeft(92).reduced(1)); + batchCountsLabel_.setBounds(summaryRow.removeFromRight(190).reduced(1)); + batchProgressBar_.setBounds(summaryRow.reduced(2, 4)); } - // Batch ETA / summary row: ETA (left) + overall progress bar (middle) + counts (right) - auto summaryRow = area.removeFromTop(22); - etaLabel_.setBounds(summaryRow.removeFromLeft(92).reduced(1)); - batchCountsLabel_.setBounds(summaryRow.removeFromRight(190).reduced(1)); - batchProgressBar_.setBounds(summaryRow.reduced(2, 4)); - - // Remaining: history editor + // Remaining: history editor (only when the log is shown) area.removeFromTop(4); - historyEditor_.setBounds(area); + if (logVisible_) + historyEditor_.setBounds(area); } -void TaskCenterPanel::setQueueItems(const std::vector& items) { - queueItems_ = items; +int TaskCenterPanel::getPreferredHeight() const { + int height = 2 * static_cast(metrics::paddingMedium) + kTopRowHeight; + if (batchActive_) { + height += 18 + 22; + if (!queueItems_.empty()) + height += std::min(static_cast(queueItems_.size()), kMaxVisibleQueueItems) * kQueueItemHeight + 4; + } + if (logVisible_) + height += 4 + kLogAreaHeight; + return height; +} + +void TaskCenterPanel::setLogVisible(bool visible) { + if (logVisible_ == visible) + return; + logVisible_ = visible; + toggleLogButton_.setButtonText(logVisible_ ? "Hide log" : "Show log"); + historyEditor_.setVisible(logVisible_); resized(); repaint(); + if (onPreferredHeightChanged) + onPreferredHeightChanged(); +} + +bool TaskCenterPanel::isBatchStripVisible() const { + return batchActive_; +} + +bool TaskCenterPanel::isLogVisible() const { + return logVisible_; +} + +void TaskCenterPanel::refreshBatchVisibility() { + batchActive_ = batchDetail_.totalCount > 0 || !queueItems_.empty(); + queueHeaderLabel_.setVisible(batchActive_); + queueEtaLabel_.setVisible(batchActive_); + queueThroughputLabel_.setVisible(batchActive_); + etaLabel_.setVisible(batchActive_); + batchCountsLabel_.setVisible(batchActive_); + batchProgressBar_.setVisible(batchActive_); + resized(); + repaint(); + if (onPreferredHeightChanged) + onPreferredHeightChanged(); +} + +void TaskCenterPanel::setQueueItems(const std::vector& items) { + queueItems_ = items; + refreshBatchVisibility(); } void TaskCenterPanel::setQueueEta(const juce::String& eta) { @@ -214,8 +270,7 @@ void TaskCenterPanel::removeQueueItem(int index) { if (index < 0 || index >= static_cast(queueItems_.size())) return; queueItems_.erase(queueItems_.begin() + index); - resized(); - repaint(); + refreshBatchVisibility(); if (onQueueItemRemoved) onQueueItemRemoved(index); } @@ -277,6 +332,7 @@ juce::String TaskCenterPanel::formatEta(const juce::RelativeTime& eta) { } void TaskCenterPanel::updateBatchSummary() { + refreshBatchVisibility(); const size_t total = batchDetail_.totalCount; const size_t completed = batchDetail_.completedCount; const size_t failed = batchDetail_.failedCount; @@ -305,6 +361,10 @@ void TaskCenterPanel::setCanCancel(bool canCancel) { void TaskCenterPanel::setTaskState(TaskState state) { currentState_ = state; + progressLabel_.setVisible(state != TaskState::Idle); + progressBar_.setVisible(state != TaskState::Idle); // the bar paints its own "0%" text + if (state == TaskState::Failed) + setLogVisible(true); stateBadge_.setText(juce::String(stateLabel(state)), juce::dontSendNotification); stateBadge_.setColour(juce::Label::backgroundColourId, stateColour(state)); stateBadge_.setColour(juce::Label::textColourId, stateTextColour(state)); diff --git a/src/app/ui/TaskCenterPanel.h b/src/app/ui/TaskCenterPanel.h index 79b5d15..08bd239 100644 --- a/src/app/ui/TaskCenterPanel.h +++ b/src/app/ui/TaskCenterPanel.h @@ -55,7 +55,14 @@ class TaskCenterPanel final : public juce::Component { /// Current item-status counts text ("completed/failed/total"). juce::String batchCountsText() const; + /// Height needed for the top row, the batch strip (when active) and the log (when shown). + int getPreferredHeight() const; + bool isBatchStripVisible() const; + bool isLogVisible() const; + bool isProgressPercentVisible() const { return progressLabel_.isVisible(); } + // Callbacks + std::function onPreferredHeightChanged; std::function onCancel; std::function onQueueItemMoved; std::function onQueueItemRemoved; @@ -65,6 +72,8 @@ class TaskCenterPanel final : public juce::Component { static constexpr int kHistoryTrimChunkLines = 300; static constexpr int kQueueItemHeight = 24; static constexpr int kMaxVisibleQueueItems = 8; + static constexpr int kTopRowHeight = 24; + static constexpr int kLogAreaHeight = 160; static juce::Colour stateColour(TaskState state); static juce::Colour stateTextColour(TaskState state); @@ -72,12 +81,15 @@ class TaskCenterPanel final : public juce::Component { void drawQueueItem(juce::Graphics& g, juce::Rectangle bounds, const BatchQueueItem& item, int index); static juce::String formatEta(const juce::RelativeTime& eta); void updateBatchSummary(); + void refreshBatchVisibility(); + void setLogVisible(bool visible); juce::Label taskLabel_; juce::Label stateBadge_; double progressValue_ = 0.0; juce::ProgressBar progressBar_; juce::Label progressLabel_; + juce::TextButton toggleLogButton_{"Show log"}; juce::TextButton copyLogButton_{"Copy Log"}; juce::TextButton cancelButton_{"Cancel"}; juce::TextEditor historyEditor_; @@ -97,6 +109,8 @@ class TaskCenterPanel final : public juce::Component { juce::ProgressBar batchProgressBar_; TaskState currentState_ = TaskState::Idle; + bool batchActive_ = false; + bool logVisible_ = false; bool hasHistoryEntries_ = false; int historyLineCount_ = 0; diff --git a/tests/unit/ControlDeckTests.cpp b/tests/unit/ControlDeckTests.cpp new file mode 100644 index 0000000..b5daf69 --- /dev/null +++ b/tests/unit/ControlDeckTests.cpp @@ -0,0 +1,98 @@ +#include +#include + +#include +#include + +#include "app/ui/ControlDeck.h" +#include "app/ui/GlowMeters.h" + +namespace { + +juce::Button* findButtonByText(juce::Component& parent, const juce::String& text) { + for (auto* child : parent.getChildren()) { + if (auto* button = dynamic_cast(child); button != nullptr && button->getButtonText() == text) + return button; + if (child != nullptr) { + if (auto* nested = findButtonByText(*child, text)) + return nested; + } + } + return nullptr; +} + +} // namespace + +TEST_CASE("ControlDeck gates stem-dependent actions on setHasStems", "[ui][controldeck]") { + juce::ScopedJuceInitialiser_GUI juceInit; + + automix::app::ControlDeck deck; + deck.setSize(1000, 300); + + const auto enabled = [&](const char* name) { + auto* button = findButtonByText(deck, name); + REQUIRE(button != nullptr); + return button->isEnabled(); + }; + + deck.setHasStems(false); + REQUIRE_FALSE(enabled("Auto Mix")); + REQUIRE_FALSE(enabled("Auto Master")); + REQUIRE_FALSE(enabled("Mix + Master")); + REQUIRE_FALSE(enabled("Export")); + REQUIRE(enabled("Batch")); + REQUIRE(enabled("Import")); + + deck.setHasStems(true); + REQUIRE(enabled("Auto Mix")); + REQUIRE(enabled("Auto Master")); + REQUIRE(enabled("Mix + Master")); + REQUIRE(enabled("Export")); + REQUIRE(enabled("Batch")); + REQUIRE(enabled("Import")); +} + +TEST_CASE("ControlDeck shows the Vocal Model toggle only with AI Stem Separation", "[ui][controldeck]") { + juce::ScopedJuceInitialiser_GUI juceInit; + + automix::app::ControlDeck deck; + deck.setSize(1000, 300); + + deck.setSeparationControlsVisible(false); + REQUIRE_FALSE(deck.getTensorSeparationToggle().isVisible()); + + deck.setSeparationControlsVisible(true); + REQUIRE(deck.getTensorSeparationToggle().isVisible()); +} + +TEST_CASE("GlowMeters readouts show -- for no signal", "[ui][glowmeters]") { + using automix::app::GlowMeters; + REQUIRE(GlowMeters::formatReadout("M: ", -70.0, " LUFS") == "M: -- LUFS"); + REQUIRE(GlowMeters::formatReadout("TP: ", -69.95, " dBTP") == "TP: -- dBTP"); + REQUIRE(GlowMeters::formatReadout("I: ", -std::numeric_limits::infinity(), " LUFS") == "I: -- LUFS"); + REQUIRE(GlowMeters::formatReadout("S: ", -14.0, " LUFS") == "S: -14.0 LUFS"); +} + +TEST_CASE("ControlDeck settings row keeps Platform combo visible and non-overlapping", "[ui][controldeck]") { + juce::ScopedJuceInitialiser_GUI juceInit; + + for (const int width : {940, 1500}) { + automix::app::ControlDeck deck; + deck.setSize(width, 420); + + const auto platform = deck.getPlatformPresetBox().getBounds(); + const auto profile = deck.getProfileBox().getBounds(); + const auto master = deck.getMasterPresetBox().getBounds(); + REQUIRE_FALSE(platform.isEmpty()); + REQUIRE(deck.getLocalBounds().contains(platform)); + REQUIRE(platform.getRight() <= deck.getWidth()); + REQUIRE_FALSE(platform.intersects(profile)); + REQUIRE_FALSE(platform.intersects(master)); + + auto* autoMaster = findButtonByText(deck, "Auto Master"); + auto* import = findButtonByText(deck, "Import"); + REQUIRE(autoMaster != nullptr); + REQUIRE(import != nullptr); + REQUIRE(autoMaster->getHeight() == import->getHeight()); + } +} diff --git a/tests/unit/TaskCenterPanelTests.cpp b/tests/unit/TaskCenterPanelTests.cpp index 7d57270..6a2164f 100644 --- a/tests/unit/TaskCenterPanelTests.cpp +++ b/tests/unit/TaskCenterPanelTests.cpp @@ -232,3 +232,52 @@ TEST_CASE("confirmClear gates destructive clear on dialog result and non-empty s REQUIRE_FALSE(confirmClear(0, false)); } } + +TEST_CASE("TaskCenterPanel shows the batch strip only for a batch", "[ui][taskcenter][batch]") { + juce::ScopedJuceInitialiser_GUI juceInit; + + automix::app::TaskCenterPanel panel; + panel.setSize(900, 400); + REQUIRE_FALSE(panel.isBatchStripVisible()); + const int idleHeight = panel.getPreferredHeight(); + + automix::engine::BatchQueueRunner::ProgressDetail detail; + detail.totalCount = 3; + panel.setBatchProgress(detail, 0.0); + REQUIRE(panel.isBatchStripVisible()); + REQUIRE(panel.getPreferredHeight() > idleHeight); + + automix::engine::BatchQueueRunner::ProgressDetail cleared; + panel.setBatchProgress(cleared, 0.0); + REQUIRE_FALSE(panel.isBatchStripVisible()); +} + +TEST_CASE("TaskCenterPanel log is hidden by default and shown on failure", "[ui][taskcenter][log]") { + juce::ScopedJuceInitialiser_GUI juceInit; + + automix::app::TaskCenterPanel panel; + panel.setSize(900, 400); + auto* editor = findHistoryEditor(panel); + REQUIRE(editor != nullptr); + REQUIRE_FALSE(panel.isLogVisible()); + REQUIRE_FALSE(editor->isVisible()); + const int collapsedHeight = panel.getPreferredHeight(); + + panel.setTaskState(automix::app::TaskState::Failed); + REQUIRE(panel.isLogVisible()); + REQUIRE(editor->isVisible()); + REQUIRE(panel.getPreferredHeight() == collapsedHeight + 4 + 160); +} + +TEST_CASE("TaskCenterPanel hides the percent label while idle", "[ui][taskcenter]") { + juce::ScopedJuceInitialiser_GUI juceInit; + + automix::app::TaskCenterPanel panel; + REQUIRE_FALSE(panel.isProgressPercentVisible()); + + panel.setTaskState(automix::app::TaskState::Running); + REQUIRE(panel.isProgressPercentVisible()); + + panel.setTaskState(automix::app::TaskState::Idle); + REQUIRE_FALSE(panel.isProgressPercentVisible()); +} From 42a8fb482de460e8fc1aae174238a84ff4a55f4a Mon Sep 17 00:00:00 2001 From: Soficis Date: Mon, 5 Oct 2026 16:48:43 -0500 Subject: [PATCH 4/5] fix(ui): confirm quitting over a running task, narrow click-to-import, restore the single-file tip - Quitting while a task runs now asks first, then continues into the unsaved-changes prompt. - The empty state opens Import only on a left click released inside it, and only when the session has no stems; stems without a preview show "Preview unavailable" instead of the import prompt. - The empty state again points single-file users at AI Stem Separation. - Session files saved before the master preset field existed load as Udio Optimized again, as they were mastered; new sessions still default to Default Streaming. - Unit tests cover the quit decision, the click decision and the preset fallback. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 1 + src/app/ui/HeroWaveform.cpp | 37 +++++++++++++++++------- src/app/ui/HeroWaveform.h | 10 +++++++ src/app/ui/MainLayout.cpp | 26 ++++++++++++++++- src/app/ui/MainLayout.h | 17 +++++++++-- src/domain/JsonSerialization.cpp | 4 ++- tests/unit/SessionSerializationTests.cpp | 17 ++++++++++- tests/unit/UiDecisionTests.cpp | 34 ++++++++++++++++++++++ 8 files changed, 130 insertions(+), 16 deletions(-) create mode 100644 tests/unit/UiDecisionTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 01f72ad..63080ad 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -551,6 +551,7 @@ if(BUILD_TESTING) tests/unit/ControllerTests.cpp tests/unit/TaskCenterPanelTests.cpp tests/unit/ControlDeckTests.cpp + tests/unit/UiDecisionTests.cpp tests/unit/SessionSerializationTests.cpp src/app/ui/SessionManager.cpp tests/unit/AudioIoTests.cpp diff --git a/src/app/ui/HeroWaveform.cpp b/src/app/ui/HeroWaveform.cpp index 4530d64..8cb2dc7 100644 --- a/src/app/ui/HeroWaveform.cpp +++ b/src/app/ui/HeroWaveform.cpp @@ -127,13 +127,22 @@ void HeroWaveform::mouseDrag(const juce::MouseEvent& event) { onSeek(progress); } -void HeroWaveform::mouseUp(const juce::MouseEvent& /*event*/) { - if (waveformPeaks_.empty() && onImportRequested) +void HeroWaveform::mouseUp(const juce::MouseEvent& event) { + if (shouldOpenImportOnClick(hasStems_, event.mods.isLeftButtonDown(), event.mouseWasClicked(), + getLocalBounds().contains(event.getPosition())) && + onImportRequested) onImportRequested(); } +void HeroWaveform::setHasStems(bool hasStems) { + if (hasStems_ == hasStems) + return; + hasStems_ = hasStems; + repaint(); +} + juce::MouseCursor HeroWaveform::getMouseCursor() { - if (waveformPeaks_.empty()) + if (!hasStems_) return juce::MouseCursor::PointingHandCursor; return juce::Component::getMouseCursor(); } @@ -385,14 +394,20 @@ void HeroWaveform::paint(juce::Graphics& g) { auto upperHalf = bounds.withHeight(h * 0.5f); g.setFont(typography::subhead()); g.setColour(colour(colours::text)); - g.drawText("Drop stems here or click to import", upperHalf, juce::Justification::centredBottom); - - auto lowerHalf = bounds.withY(h * 0.5f).withHeight(h * 0.5f); - g.setFont(typography::caption()); - g.setColour(colour(colours::textMuted)); - const auto arrow = juce::String(juce::CharPointer_UTF8("\xe2\x86\x92")); - g.drawText("1 Import stems " + arrow + " 2 Mix + Master " + arrow + " 3 Export", - lowerHalf.reduced(0.0f, 8.0f), juce::Justification::centredTop); + g.drawText(hasStems_ ? "Preview unavailable" : "Drop stems here or click to import", upperHalf, + juce::Justification::centredBottom); + + if (!hasStems_) { + auto lowerHalf = bounds.withY(h * 0.5f).withHeight(h * 0.5f); + g.setFont(typography::caption()); + g.setColour(colour(colours::textMuted)); + const auto arrow = juce::String(juce::CharPointer_UTF8("\xe2\x86\x92")); + auto steps = lowerHalf.reduced(0.0f, 8.0f); + g.drawText("1 Import stems " + arrow + " 2 Mix + Master " + arrow + " 3 Export", + steps.removeFromTop(18.0f), juce::Justification::centredTop); + g.drawText("One file? Turn on AI Stem Separation first.", steps.removeFromTop(18.0f), + juce::Justification::centredTop); + } } else { // Draw stem overlay if we have per-stem data if (numStemGroups_ > 0) { diff --git a/src/app/ui/HeroWaveform.h b/src/app/ui/HeroWaveform.h index f6be808..75cd629 100644 --- a/src/app/ui/HeroWaveform.h +++ b/src/app/ui/HeroWaveform.h @@ -38,6 +38,8 @@ class HeroWaveform final : public juce::Component, void setLoopRange(bool enabled, double startProgress, double endProgress); struct StemGroup { std::vector peaks; juce::Colour colour; juce::String name; }; void setStemGroups(const std::vector& groups); + /// Whether the session has any stems; drives the empty-state prompt, click-to-import and cursor. + void setHasStems(bool hasStems); // Callbacks std::function onImportRequested; // empty-state zone clicked @@ -62,6 +64,13 @@ class HeroWaveform final : public juce::Component, return {x, kZoomControlTop, kZoomButtonSize, kZoomButtonSize}; } + /// Pure decision backing click-to-import: only a plain left-button click (not a drag) that is + /// released inside the component, on a session with no stems, opens Import. + static constexpr bool shouldOpenImportOnClick(bool hasStems, bool leftButton, bool wasClicked, + bool insideBounds) { + return !hasStems && leftButton && wasClicked && insideBounds; + } + /// Pure dirty-check seam backing setPlayheadProgress: returns true when the playhead's /// x-pixel changed relative to `lastPixel` (updating `lastPixel`), i.e. a repaint is /// actually needed. Unchanged pixels skip the repaint. @@ -117,6 +126,7 @@ class HeroWaveform final : public juce::Component, int numStemGroups_ = 0; bool isDragOver_ = false; + bool hasStems_ = false; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(HeroWaveform) }; diff --git a/src/app/ui/MainLayout.cpp b/src/app/ui/MainLayout.cpp index d01298a..bea9361 100644 --- a/src/app/ui/MainLayout.cpp +++ b/src/app/ui/MainLayout.cpp @@ -168,6 +168,8 @@ void MainLayout::refreshStemDependentUi() { const bool hasStems = !sessionManager_.session().stems.empty(); if (controlDeck_ != nullptr) controlDeck_->setHasStems(hasStems); + if (heroWaveform_ != nullptr) + heroWaveform_->setHasStems(hasStems); if (transportBar_ != nullptr) transportBar_->setHasMedia(hasStems); clearTracksButton_.setEnabled(hasStems); @@ -1631,7 +1633,13 @@ void MainLayout::refreshSessionTitle() { } bool MainLayout::requestQuit(std::function quitNow) { - if (!sessionManager_.isModified()) { + return requestQuitStep(std::move(quitNow), false); +} + +bool MainLayout::requestQuitStep(std::function quitNow, bool taskQuitConfirmed) { + const auto step = nextQuitStep(taskOrchestrator_->isTaskRunning(), taskQuitConfirmed, + sessionManager_.isModified()); + if (step == QuitStep::QuitNow) { quitNow(); return true; } @@ -1639,6 +1647,22 @@ bool MainLayout::requestQuit(std::function quitNow) { return false; quitPromptOpen_ = true; + if (step == QuitStep::ConfirmRunningTask) { + juce::AlertWindow::showOkCancelBox( + juce::MessageBoxIconType::WarningIcon, "Task still running", + "A task is still running. Quitting now will cancel it, and an unfinished export may be left " + "incomplete. Quit anyway?", + "Quit", "Cancel", this, + juce::ModalCallbackFunction::create([safe = safeAsync(this), quitNow](int result) { + if (safe == nullptr) + return; + safe->quitPromptOpen_ = false; + if (result == 1) + safe->requestQuitStep(quitNow, true); + })); + return false; + } + juce::AlertWindow::showYesNoCancelBox( juce::MessageBoxIconType::QuestionIcon, "Save changes?", "Save changes to " + sessionDisplayName_ + " before closing?", diff --git a/src/app/ui/MainLayout.h b/src/app/ui/MainLayout.h index 0238fd5..da3cd80 100644 --- a/src/app/ui/MainLayout.h +++ b/src/app/ui/MainLayout.h @@ -31,6 +31,17 @@ namespace automix::app { +/// Next step of the quit flow (pure decision, unit-tested; the prompts themselves are modal UI). +enum class QuitStep { QuitNow, ConfirmRunningTask, PromptSave }; + +/// A running task must be confirmed once before anything else; after that (or with no task) a +/// modified session gets the Save prompt and an unmodified one quits immediately. +inline QuitStep nextQuitStep(bool taskRunning, bool taskQuitConfirmed, bool modified) { + if (taskRunning && !taskQuitConfirmed) + return QuitStep::ConfirmRunningTask; + return modified ? QuitStep::PromptSave : QuitStep::QuitNow; +} + // ── Keyboard shortcut table (single source of truth) ──────────────────── // Command ids start at 1000 (0 is reserved as "no command" by JUCE). @@ -145,8 +156,9 @@ class MainLayout final : public juce::Component, void resized() override; bool keyPressed(const juce::KeyPress& key) override; - /// Runs quitNow immediately when the session is unchanged (returns true); - /// otherwise asks Save / Don't Save / Cancel and returns false. + /// Runs quitNow immediately when no task is running and the session is unchanged (returns true); + /// otherwise confirms quitting over a running task and/or asks Save / Don't Save / Cancel and + /// returns false. bool requestQuit(std::function quitNow); private: @@ -179,6 +191,7 @@ class MainLayout final : public juce::Component, void onSaveSession(std::function done = {}); void finishPendingSave(bool success); void refreshSessionTitle(); + bool requestQuitStep(std::function quitNow, bool taskQuitConfirmed); void refreshStemDependentUi(); void setSessionDisplayName(const juce::String& name); void onLoadSession(); diff --git a/src/domain/JsonSerialization.cpp b/src/domain/JsonSerialization.cpp index 4aaaa51..cccb667 100644 --- a/src/domain/JsonSerialization.cpp +++ b/src/domain/JsonSerialization.cpp @@ -311,7 +311,9 @@ void from_json(const Json& j, Session& value) { value.residualBlend = std::clamp(j.value("residualBlend", 0.0), 0.0, 10.0); value.aiStemsEnabled = j.value("aiStemsEnabled", false); value.batchRecursiveEnabled = j.value("batchRecursiveEnabled", false); - value.selectedMasterPreset = masterPresetFromString(j.value("selectedMasterPreset", "default_streaming")); + // Session files saved before this field existed were mastered with Udio Optimized, so keep that + // fallback for them; new sessions get Default Streaming from the Session struct default instead. + value.selectedMasterPreset = masterPresetFromString(j.value("selectedMasterPreset", "udio_optimized")); value.selectedPlatformPreset = masterPresetFromString(j.value("selectedPlatformPreset", "youtube")); value.stems = j.value("stems", std::vector{}); value.buses = j.value("buses", std::vector{}); diff --git a/tests/unit/SessionSerializationTests.cpp b/tests/unit/SessionSerializationTests.cpp index 4654d2f..813f2d7 100644 --- a/tests/unit/SessionSerializationTests.cpp +++ b/tests/unit/SessionSerializationTests.cpp @@ -106,7 +106,7 @@ TEST_CASE("Session deserialization handles missing optional fields", "[session]" REQUIRE(decoded.residualBlend == Catch::Approx(0.0)); REQUIRE(decoded.aiStemsEnabled == false); REQUIRE(decoded.batchRecursiveEnabled == false); - REQUIRE(decoded.selectedMasterPreset == automix::domain::MasterPreset::DefaultStreaming); + REQUIRE(decoded.selectedMasterPreset == automix::domain::MasterPreset::UdioOptimized); REQUIRE(decoded.selectedPlatformPreset == automix::domain::MasterPreset::YouTube); REQUIRE(decoded.renderSettings.blockSize == 1024); REQUIRE(decoded.renderSettings.outputFormat == "auto"); @@ -186,6 +186,21 @@ TEST_CASE("Default session uses the Default Streaming master preset", "[session] REQUIRE(session.selectedMasterPreset == automix::domain::MasterPreset::DefaultStreaming); } +TEST_CASE("Old session without selectedMasterPreset keeps the Udio Optimized fallback", "[session]") { + const nlohmann::json legacy = {{"schemaVersion", 1}, {"sessionName", "legacy"}}; + REQUIRE(legacy.get().selectedMasterPreset == + automix::domain::MasterPreset::UdioOptimized); +} + +TEST_CASE("Explicit default_streaming master preset round-trips", "[session]") { + automix::domain::Session session; + REQUIRE(session.selectedMasterPreset == automix::domain::MasterPreset::DefaultStreaming); + const nlohmann::json encoded = session; + REQUIRE(encoded.at("selectedMasterPreset").get() == "default_streaming"); + REQUIRE(encoded.get().selectedMasterPreset == + automix::domain::MasterPreset::DefaultStreaming); +} + TEST_CASE("SessionManager tracks unsaved changes", "[session]") { automix::app::SessionManager manager; manager.markSaved(); diff --git a/tests/unit/UiDecisionTests.cpp b/tests/unit/UiDecisionTests.cpp new file mode 100644 index 0000000..36a6b95 --- /dev/null +++ b/tests/unit/UiDecisionTests.cpp @@ -0,0 +1,34 @@ +#include + +#include "app/ui/HeroWaveform.h" +#include "app/ui/MainLayout.h" + +using automix::app::HeroWaveform; +using automix::app::QuitStep; +using automix::app::nextQuitStep; + +TEST_CASE("Quit flow confirms a running task before anything else", "[ui][quit]") { + // No task: modified -> save prompt, unmodified -> quit now. + REQUIRE(nextQuitStep(false, false, false) == QuitStep::QuitNow); + REQUIRE(nextQuitStep(false, false, true) == QuitStep::PromptSave); + REQUIRE(nextQuitStep(false, true, false) == QuitStep::QuitNow); + REQUIRE(nextQuitStep(false, true, true) == QuitStep::PromptSave); + // Task running and not yet confirmed: always ask first. + REQUIRE(nextQuitStep(true, false, false) == QuitStep::ConfirmRunningTask); + REQUIRE(nextQuitStep(true, false, true) == QuitStep::ConfirmRunningTask); + // Confirmed: continue into the unsaved-changes flow. + REQUIRE(nextQuitStep(true, true, false) == QuitStep::QuitNow); + REQUIRE(nextQuitStep(true, true, true) == QuitStep::PromptSave); +} + +TEST_CASE("Click-to-import only fires for a left click inside an empty session", "[ui][import]") { + for (int bits = 0; bits < 16; ++bits) { + const bool hasStems = (bits & 1) != 0; + const bool left = (bits & 2) != 0; + const bool clicked = (bits & 4) != 0; + const bool inside = (bits & 8) != 0; + const bool expected = !hasStems && left && clicked && inside; + INFO("hasStems=" << hasStems << " left=" << left << " clicked=" << clicked << " inside=" << inside); + REQUIRE(HeroWaveform::shouldOpenImportOnClick(hasStems, left, clicked, inside) == expected); + } +} From fd545a13e4a30fbf2dea7e8b336bb082fa62c7d7 Mon Sep 17 00:00:00 2001 From: Soficis Date: Mon, 5 Oct 2026 18:07:57 -0500 Subject: [PATCH 5/5] docs(readme): rewrite for users with new screenshots; fix the zoom minus glyph and clipped meter captions README - Corrects the shortcuts (Ctrl+Shift+A is Auto Master; Ctrl+/ opens the shortcut list) and the inference notes, which still said no audio-tensor path exists. - Adds presets, the light vocal model, export formats and sessions. - Build steps and developer notes move into collapsed sections. UI - The zoom-out button showed "?": a \u escape in a narrow string is mangled by MSVC. It is now UTF-8 bytes. - The L/R captions were drawn above the meter panel and clipped. - The loudness bar was painted behind the first readout. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 5 + README.md | 524 ++++++++++++---------------- assets/screenshots/main-empty.png | Bin 0 -> 45544 bytes assets/screenshots/main-session.png | Bin 0 -> 62815 bytes src/app/ui/GlowMeters.cpp | 16 +- src/app/ui/HeroWaveform.cpp | 3 +- 6 files changed, 238 insertions(+), 310 deletions(-) create mode 100644 assets/screenshots/main-empty.png create mode 100644 assets/screenshots/main-session.png diff --git a/.gitignore b/.gitignore index ba61e7f..ed4bbc9 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,8 @@ docs/* !docs/model-licensing-audit.json + +# README screenshots that are captured but not embedded +assets/screenshots/model-hub.png +assets/screenshots/settings.png +assets/screenshots/shortcuts.png diff --git a/README.md b/README.md index 759d12e..6d33414 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,7 @@ **Version 0.4.2** - -AutoMixMaster application interface +AutoMixMaster with a session loaded **FIXED-RULE AUDIO WORKFLOW FOR MIXING AND MASTERING MUSIC STEMS** @@ -15,11 +14,10 @@

Overview • -Feature Set • +What It Does • First Session • -Quick Start • -System Requirements • -Build + Install • +Install • +What You Need • Licensing

@@ -27,287 +25,145 @@ ## Overview -AutoMixMaster helps you turn raw stems into a cleaner, release-ready track with fewer manual steps. +Mixing and mastering take years to learn. AutoMixMaster does both with one button. -It focuses on predictable, fixed-rule processing so results are repeatable and beginner-friendly, with optional extras like AI stem separation, batch mode, and bundled renderer integrations. +Drop in your stems. Press **Mix + Master**. Get a finished track. ---- +The app works by fixed rules, so the same stems give the same result every time. The AI features are extras. Each one falls back to the fixed rules when you have no model installed. + +
+AutoMixMaster on first launch +
-## Feature Set +--- -> **Core capabilities at a glance** +## What It Does -| Module | Description | +| Feature | What you get | | :--- | :--- | -| **Auto Mix + Auto Master** | Deterministic stem balancing, gain staging, and limiting workflow. | -| **One-Click Pipeline** | `Mix + Master` (`Ctrl+Shift+M`) runs Auto Mix → Auto Master → Export. | -| **ITO-Master AI Mastering** | Experimental AI mastering strategy driving a 46-parameter native white-box FX chain with licensing consent gating. | -| **AI Stem Separation (Optional)** | Splits a single full-mix import into stems before processing when enabled (`Ctrl+Shift+A`). | -| **Task-Scoped Model Browser** | Install/uninstall models and set active packs per task (`mix`, `master`, `analysis`, `separation`) from Hugging Face or GitHub Releases. | -| **Batch Processing** | Queue folders, auto-group stems by filename role patterns, and render one mastered song per group (`_AutoMixMaster_YYYYMMDD_XX.`). Supports recursive discovery via UI toggle or `AUTOMIX_BATCH_RECURSIVE=1`. | -| **Renderer Integrations** | Built-in discovery for PhaseLimiter, FFmpeg, SoX, and rsgain; only available tools are shown (`*_BIN` env overrides supported). | -| **Verification Reporting** | Export verification report plus batch completion summary; optional per-export `.report.json` sidecar. | -| **Task Center + ETA** | Real-time progress tracking with batch ETA countdown, summary status row, timestamped activity log, and copy-log utility. | -| **Transport & Audio Preview** | Realtime-safe lock-free audio preview buffer, live peak/RMS meters, 0–1.5x gain control (+3.5 dB), and modal confirmation for session clearing. | -| **Shortcuts & Commands** | `ApplicationCommandManager` integration with built-in Keyboard Shortcuts Cheatsheet modal (`?`). | -| **Analysis Meters** | Live LUFS and peak metering via GlowMeters. | +| **Auto Mix + Auto Master** | The app balances your stems, sets their levels and limits the result. | +| **Mix + Master** | One button (`Ctrl+Shift+M`) mixes, masters and exports. | +| **Master presets** | Default Streaming, Broadcast, Udio Optimized or Custom. | +| **Platform targets** | Spotify, Apple Music, YouTube, Amazon Music, Tidal or Broadcast EBU R128. | +| **AI Stem Separation** | Optional. Splits one full mix into stems, so you can start from a single file. | +| **Vocal Model** | Optional. BS-RoFormer pulls the vocals out of a mix. It is slow without a strong NVIDIA card. | +| **Light vocal model** | Optional. Open-Unmix is a 36 MB download for weaker machines. It is fast, and rougher. | +| **AI mastering** | Experimental. The ITO-Master model picks the mastering settings. | +| **Models window** | `Ctrl+K`. Download, remove and choose models. | +| **Batch** | Point the app at folders. It groups the stems by file name and renders one mastered song per group. | +| **Export** | WAV, AIFF, FLAC, OGG or MP3. Each export comes with a report that checks the result. | +| **Preview** | Play the mix, watch the level and loudness meters, solo or mute any stem. | +| **Sessions** | Save, load, undo and redo. The app asks before it throws away unsaved work. | +| **Progress** | A progress bar, a time estimate for batches and a log you can copy. | + +### Shortcuts + +| Action | Keys | +| :--- | :--- | +| Import | `Ctrl+I` | +| Auto Mix | `Ctrl+M` | +| Auto Master | `Ctrl+Shift+A` | +| Mix + Master | `Ctrl+Shift+M` | +| Export | `Ctrl+E` | +| Models | `Ctrl+K` | +| Save / Load session | `Ctrl+S` / `Ctrl+O` | +| Undo / Redo | `Ctrl+Z` / `Ctrl+Y` | +| Play / Pause | `Space` | +| Show all shortcuts | `Ctrl+/` | --- ## First Session -Welcome to your first mixing and mastering session. AutoMixMaster simplifies the process into a few core steps: +1. **Import your audio.** Drop files on the waveform area, click it, or press `Ctrl+I`. The app reads WAV, AIFF, FLAC, MP3 and OGG. +2. **Only one file? Turn on AI Stem Separation.** The app splits a single full mix into stems first. With several files it treats them as stems and skips this step. +3. **Press Mix + Master.** The app mixes, masters and exports. -1. **Import audio**: Drag and drop files onto the waveform area, or click `Import`. Supported formats: WAV, AIFF, FLAC, MP3, OGG. -2. **(Optional) enable AI Stem Separation**: Toggle **AI Stem Separation** and check the badge beside it (`Separation model: `). - - If exactly one full-mix track is loaded, separation runs before Auto Mix. - - If multiple files are loaded, they are treated as regular stems and separation is skipped. -3. **Manage models in Model Browser**: Open **Models** to fetch catalog entries, install/uninstall models, and set active packs per task (`mix`, `master`, `analysis`, `separation`) using **Set Active** or **Use Selected for Task**. -4. **Auto Mix**: Click **Auto Mix** to analyze stems and apply deterministic balancing rules. -5. **Auto Master**: Click **Auto Master** to apply mastering strategy and limiting. -6. **One-click pipeline**: Click **Mix + Master** (`Ctrl+Shift+M`) to run Auto Mix → Auto Master → Export. If AI Stem Separation is enabled and one full mix is loaded, separation is performed first, then the pipeline continues automatically. -7. **Export**: Use **Export** (`Ctrl+E`) for manual output control, or rely on pipeline export. +Want more control? Run **Auto Mix**, **Auto Master** and **Export** one at a time. Pick a master preset and a platform target before you master. --- -## Quick Start - -Getting started with AutoMixMaster is simple. - -> ⚠️ **Testing disclaimer:** only the **Windows** version has been manually tested end-to-end so far. -> Linux, macOS, and ARM64 artifacts are currently provided as best-effort builds. - -### Windows (Pre-compiled Executable) - -Windows users can download the portable release zip (`AutoMixMaster-windows-.zip`), extract it, and run `AutoMixMaster.exe`. - -### macOS (Pre-compiled App Bundle) - -macOS users can download the release zip (`AutoMixMaster-macos-.zip`), extract it, and open `AutoMixMaster.app`. - -### Linux (Prebuilt Packages) - -Linux users can download either: - -- **AppImage** (`AutoMixMaster--.AppImage`) for a portable one-file launch. -- **Debian package** (`automixmaster__.deb`) for Ubuntu/Debian install. -- **Flatpak bundle** (`AutoMixMaster-linux-.flatpak`) for Flatpak-based installs. - -### Build From Source - -If you are on Linux, or prefer to build the application from source on Windows, refer to the [Build + Install](#build--install) section below for verified instructions. - -## Estimated System Requirements - -These are **practical estimates** for AI-heavy workflows (especially ONNX-based separation/mix/master inference), not strict hard limits. - -AutoMixMaster is designed to benefit from **GPU acceleration** via ONNX Runtime providers. - -### Minimum OS requirements (release artifacts) - -- **Windows:** **Windows 10 or Windows 11** (x64 or ARM64¹) -- **macOS (Apple Silicon / ARM64):** **macOS 14+** -- **macOS (Intel / x64):** **macOS 15+** -- **Linux:** **Ubuntu 24.04 LTS+** for current prebuilt `.deb`/AppImage artifacts - -> ¹ *Note: On Windows 11 ARM64, PhaseLimiter runs seamlessly via Windows on ARM built-in x64 emulation (WOW64/Prism); AI tensor inference runs natively on ARM64.* -> Note: Ubuntu 22.04 may still work if you build from source on 22.04 with compatible dependencies, but official CI/release packaging currently targets Ubuntu 24.04. - -### Minimum workable - -- **CPU:** modern **6-core / 12-thread** desktop CPU (Ryzen 5 5600 / Core i5-12400 class) -- **RAM:** **16 GB minimum** -- **GPU:** compatible acceleration path with ~**6 GB VRAM** - - Windows: **WebGPU (DirectX 12 / Vulkan)** or **NVIDIA CUDA** - - Linux: **WebGPU (Vulkan, requires `libvulkan1`)** or **NVIDIA CUDA** - - macOS (Apple Silicon): **CoreML / ANE** - - *(Note: Intel Macs do not support AI tensor/model inference; heuristics and audio processing remain functional)* -- **Storage:** ~10 GB free (models, temp files, exports) -- **PhaseLimiter rendering:** requires ffmpeg (install ffmpeg on Windows; `brew install ffmpeg` / `apt install ffmpeg` elsewhere, or set `FFMPEG_BIN`). - -### Recommended (smoother) - -- **CPU:** **8 cores / 16 threads or better** (Ryzen 7 / Core i7 class) -- **RAM:** **32 GB** -- **GPU:** **8–12 GB VRAM** - -### Heavy batch / long sessions - -- **CPU:** **12 cores+** strongly recommended -- **RAM:** **32–64 GB** -- **GPU:** **12 GB+ VRAM** - -### Why these estimates - -- GPU acceleration matters most: WebGPU needs a **DirectX 12 or Vulkan-capable** GPU, CoreML uses Apple Silicon GPU/ANE, and CUDA needs an **NVIDIA CUDA-capable** GPU ([CUDA](https://onnxruntime.ai/docs/execution-providers/CUDA-ExecutionProvider.html)). -- Demucs notes roughly **3 GB minimum** and around **7 GB typical** GPU memory, so **8 GB+ VRAM** is a safer real-world target; CPU-only runs work but are slower ([Demucs README](https://github.com/facebookresearch/demucs/blob/main/README.md)). +## Install -### ONNX Runtime - -ONNX Runtime is an **optional** dependency. When it is not found at configure time the build -falls back to a deterministic adapter and every model-dependent feature degrades to a -heuristic — it does not fail the build. - -| | | -|---|---| -| Validated against | **ORT 1.30.x** (1.30.0, 2026-09-10) | -| Minimum for the optional GPU paths | **1.22** | -| Release cadence | roughly monthly — pin a minor series, not a patch | - -The build locates ONNX Runtime with `find_package(onnxruntime 1.30.0 EXACT CONFIG)` when fetched -via `AUTOMIX_FETCH_ORT=ON`, or `find_path`/`find_library` for system-installed SDKs. - -#### Provider status (as of 1.30.x) - -| Provider | Status | Notes | -|---|---|---| -| **CPU** | always available | The baseline. Every GPU path falls back here on OOM or device loss, so the app never loses inference capability. | -| **CUDA** | current | Default packages target **CUDA 13.0** since 1.27. cuDNN and CUDA runtime libraries are loaded dynamically at runtime when present. | -| **WebGPU** | current | Native plugin EP on Windows/Linux (via `Microsoft.ML.OnnxRuntime.EP.WebGpu` 0.4.0) and in-tree provider on Apple Silicon macOS. Default non-NVIDIA path for Windows and Linux. Requires `libvulkan1` on Linux. | -| **CoreML** | current | Built-in on macOS. Covers the **Apple Neural Engine** (`MLComputeUnits=CPUAndNeuralEngine` or `MLComputeUnits=ALL`). Note: Intel Macs do not support AI inference. | -| **DirectML** | maintenance mode | The `Microsoft.ML.OnnxRuntime.DirectML` NuGet is **frozen at 1.24.4** and caps at **opset ≤ 20**. WebGPU is now the primary non-NVIDIA Windows path. | -| **OpenVINO** | split | Legacy wheel pinned at 1.24.1; the plugin `onnxruntime-ep-openvino` 1.7.0 requires ORT ≥ 1.23. | -| **Windows ML** | GA (2025-09-23) | The recommended path for new Windows work. C++ needs the **self-contained** NuGet; framework-dependent C/C++ packages are not published. | - -AutoMixMaster probes available providers and walks its own priority chain — **ANE → CoreML → -CUDA → WebGPU → OpenVINO → DirectML → CPU** (`src/ai/GpuProvider.h`). If session creation or inference -fails, the provider is recorded as failed and the chain continues, so a broken or missing GPU -runtime degrades to CPU instead of failing the render. - -> **fp16 caveat:** the CPU execution provider does not run fp16 graphs. Quantize to int8 (QDQ -> format) for CPU-only deployment; 16-bit and 4-bit quantization additionally require **opset ≥ 21**. - -#### Optional runtime capabilities - -Two further runtime paths are detected at configure time and are **off unless the installed -ONNX Runtime exposes the matching API**. The provider priority chain above is unchanged either -way, and both features default to off. - -| Capability | Compile guard | Minimum ORT | Status | -|---|---|---|---| -| WebGPU provider supplied as a plugin library | `AUTOMIX_HAS_EP_PLUGIN` | 1.23 | Wired for WebGPU via `OrtRuntime` and `RegisterExecutionProviderLibrary` | -| Per-GPU compiled-model cache (EPContext) | `AUTOMIX_HAS_EP_CONTEXT` | 1.22 | Policy implemented; the `OrtCompileApi` call is not yet wired | - -`src/ai/GpuProvider.h` holds the deciding logic for both — `parseOrtVersion`, -`supportsEpPlugin`, `supportsEpContext`, `decidePluginEpAttempt` and -`compiledModelCacheKey` — as pure functions, so it is covered by the test suite even on a -build with no ONNX Runtime SDK present. The cache key covers the model digest, the provider, -the GPU architecture, the driver version and the ORT version, so recompiling for a different -card or driver can never reuse another card's artifact. A model digest that is not a valid -64-character SHA-256 yields no key at all, because a key that cannot distinguish two models -would alias their caches. - -To finish the wiring, the guarded code should ask `decidePluginEpAttempt(...)` and, when it -returns `attempt == false`, log its `reason` and continue down the existing priority chain; -`Ort::GetAvailableProviders()` already covers every built-in provider. - -#### GPU acceleration for the vocal model (Windows + NVIDIA) - -The BS-RoFormer vocal model runs on CUDA when three things are true: the build links the -**CUDA build of ONNX Runtime**, the user has installed the **GPU runtime pack**, and the GPU -has room for the model. Otherwise it runs on the CPU, with the reason in the log. - -- **GPU runtime pack.** NVIDIA's CUDA runtime, cuBLAS, cuFFT and cuDNN (~1 GB download, - ~1.3 GB on disk) are not shipped. When *Vocal Model* is switched on, or from *Settings → - GPU acceleration*, the app offers a one-time download of NVIDIA's own redistributable - packages from pypi.org, pinned by SHA-256, into - `%LOCALAPPDATA%\AutoMixMaster\gpu-runtime\`. No admin rights, no system CUDA, no - PATH changes; the libraries are preloaded by full path. *Settings* can remove it again. - It is offered only for an NVIDIA GPU with ≥ 11.5 GiB of memory and driver **580 or newer** - (CUDA 13). `automix_dev_tools gpu-runtime status|install|remove|upgrade-models` does the - same from the command line. -- **Model build.** Where a CUDA session opens and the GPU totals ≥ 11.5 GiB, the catalog - installs BS-RoFormer's **fp32** build (its external weights are folded into one file at - install time, because ONNX Runtime 1.30 cannot load that export otherwise); elsewhere the - smaller **quantized** build, which is faster on CPU but 4-7x slower than fp32 on a GPU. Installing - the runtime pack upgrades an already-installed quantized model automatically. -- **Memory.** fp32 needs 10 GiB of free GPU memory while it runs (measured peak 9.2 GiB). - With less free, it runs on the CPU rather than spill into shared memory, which is slower. - -Measured on an RTX 5060 Ti (16 GB) for a 196 s track: fp32 on CUDA **66–91 s**, quantized on -CPU 687 s, quantized on CUDA 303–504 s. +> ⚠️ Only the **Windows** version has been tested by hand from start to finish. The Linux, macOS and ARM64 builds may have rough edges. +| System | Download | Then | +| :--- | :--- | :--- | +| **Windows** | `AutoMixMaster-windows-.zip` | Extract it and run `AutoMixMaster.exe`. | +| **macOS** | `AutoMixMaster-macos-.zip` | Extract it and open `AutoMixMaster.app`. | +| **Linux** | `.AppImage`, `.deb` or `.flatpak` | Run the AppImage, or install the package. | ---- +**macOS:** the app is unsigned. If macOS refuses to open it, go to **System Settings → Privacy & Security** and click **Open Anyway**. -## Build + Install +
+Build it yourself ### Windows (Visual Studio 2026) -1. Configure - ```bash cmake -S . -B build -G "Visual Studio 18 2026" -A x64 -``` - -1. Build - -```bash cmake --build build --config Release --parallel +ctest --test-dir build -C Release --output-on-failure ``` -#### Windows release package - -`packaging/windows/build-release.ps1` configures a fresh build directory against an ONNX -Runtime SDK, builds, runs the tests and writes a portable ZIP with CPack. Use the CUDA build -of ONNX Runtime so the package can use the GPU: +**Release package.** `packaging/windows/build-release.ps1` builds, runs the tests and writes a portable ZIP. Give it the CUDA build of ONNX Runtime so the package can use the GPU: ```powershell powershell -File packaging\windows\build-release.ps1 -OnnxRuntimeDir C:\lib\onnxruntime-win-x64-gpu_cuda13-1.30.0 ``` -The ZIP holds the app, its assets and the ONNX Runtime DLLs (the CUDA provider adds ~190 MB). -It never contains model weights or NVIDIA's CUDA libraries: the install step fails if any -`.onnx` file or `cudart`/`cublas`/`cudnn`/`cufft` library is present. For a developer build -that runs on CUDA without the runtime pack, point `-DAUTOMIX_CUDA_RUNTIME_DIR` at a folder of -those DLLs; they are copied next to the executables, never into the package. +The ZIP never holds model files or NVIDIA's CUDA libraries. The install step fails if it finds one. To run a developer build on CUDA without the GPU pack, point `-DAUTOMIX_CUDA_RUNTIME_DIR` at a folder of those DLLs. ### Ubuntu Linux (24.04+) -1. Install dependencies - ```bash sudo apt-get install -y \ - libasound2-dev libfreetype6-dev libx11-dev libxcomposite-dev \ - libxcursor-dev libxext-dev libxinerama-dev libxrandr-dev \ - libxrender-dev libwebkit2gtk-4.1-dev libglu1-mesa-dev mesa-common-dev -``` - -1. Configure + build + build-essential cmake pkg-config \ + libasound2-dev libjack-jackd2-dev libfreetype6-dev libfontconfig1-dev \ + libx11-dev libxcomposite-dev libxcursor-dev libxext-dev libxinerama-dev \ + libxrandr-dev libxrender-dev libwebkit2gtk-4.1-dev libgtk-3-dev \ + libglu1-mesa-dev mesa-common-dev libcurl4-openssl-dev -```bash cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build --parallel +ctest --test-dir build --output-on-failure ``` -1. Run tests (optional but recommended) +Do not skip `libcurl4-openssl-dev`. Without it the app cannot download models. + +**Packages.** `./tools/package_linux.sh` writes a `.deb` and an AppImage to `dist/linux/`. + +**Flatpak.** ```bash -ctest --test-dir build --output-on-failure +sudo apt-get install -y flatpak flatpak-builder +flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo +flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 +./tools/build_flatpak.sh ``` -### macOS (Apple Silicon + Intel) +The bundle lands in `dist/flatpak/AutoMixMaster.flatpak`. The manifest is `packaging/flatpak/io.automixmaster.AutoMixMaster.yml`. It fetches its sources ahead of time, so the build needs no network inside the sandbox. -1. Install build tools +### macOS (Apple Silicon + Intel) ```bash xcode-select --install brew install cmake ninja ``` -1. Configure + build (pick one architecture) +Pick one architecture: ```bash -# Apple Silicon (arm64) +# Apple Silicon cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES=arm64 -DBUILD_TESTING=OFF -DBUILD_TOOLS=OFF -# Intel (x64) +# Intel cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES=x86_64 -DBUILD_TESTING=OFF -DBUILD_TOOLS=OFF cmake --build build --target AutoMixMasterApp --parallel ``` -1. Configure + build (universal binary: arm64 + x86_64) +Or build for both at once: ```bash cmake -S . -B build-universal \ @@ -319,7 +175,7 @@ cmake -S . -B build-universal \ cmake --build build-universal --target AutoMixMasterApp --parallel ``` -1. Install app bundle +Then install the app: ```bash APP_BUNDLE="$(find build build-universal -maxdepth 6 -type d -name 'AutoMixMaster.app' 2>/dev/null | head -n 1)" @@ -328,100 +184,154 @@ sudo cp -R "$APP_BUNDLE" /Applications/ open /Applications/AutoMixMaster.app ``` -> **macOS Note:** On first launch of the unsigned application, macOS Gatekeeper may prompt that the developer cannot be verified. To allow the application to open, go to **System Settings → Privacy & Security**, scroll down to the Security section, and click **Open Anyway**. +### Extra render tools +The app needs none of these. It finds them if you put them here or set the variable: -### Linux Package Builds (.deb + AppImage) +- `assets/ffmpeg/bin/ffmpeg(.exe)` or `FFMPEG_BIN` +- `assets/sox/bin/sox(.exe)` or `SOX_BIN` +- `assets/rsgain/bin/rsgain(.exe)` or `RSGAIN_BIN` -After building, create distributable Linux packages with: +PhaseLimiter runs only when you select it, and it needs ffmpeg. -```bash -./tools/package_linux.sh -``` +
-Output artifacts are written to `dist/linux/`. +--- -### Flatpak +## What You Need -Manifest path: +The fixed-rule mix and master run on almost any recent computer. The numbers below are for the AI features. They are estimates, not hard limits. -`packaging/flatpak/io.automixmaster.AutoMixMaster.yml` +| | Works | Comfortable | Big batches | +| :--- | :--- | :--- | :--- | +| **Processor** | 6 cores | 8 cores | 12 cores or more | +| **Memory** | 16 GB | 32 GB | 32–64 GB | +| **Graphics memory** | 6 GB | 8–12 GB | 12 GB or more | +| **Free disk** | 10 GB | | | -> Note: the Flatpak manifest prefetches JUCE/nlohmann/libebur128 sources and passes `FETCHCONTENT_SOURCE_DIR_*` flags so CMake does not need live GitHub access inside the Flatpak sandbox. +**Systems the release builds support** -Install Flatpak tooling: +- **Windows** 10 or 11, x64 or ARM64 +- **macOS** 14 or later on Apple Silicon; 15 or later on Intel +- **Linux:** Ubuntu 24.04 or later -```bash -sudo apt-get install -y flatpak flatpak-builder -``` +**Graphics cards** -Add Flathub and install required runtime/SDK: +- **Windows and Linux:** an NVIDIA card is fastest. Other cards work if they support DirectX 12 or Vulkan. Linux needs `libvulkan1`. +- **Apple Silicon Macs:** the app uses the built-in graphics chip. +- **Intel Macs:** no AI features. Mixing and mastering still work. +- **No usable card:** the AI features run on the processor. They work, and they are slow. -```bash -flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo -flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 -``` +**Macs with less than 12 GB of memory.** The Vocal Model runs slowly on them. The app warns you and points you to the light Open-Unmix model. -Build bundle: +### The Vocal Model on an NVIDIA card (Windows) -```bash -./tools/build_flatpak.sh -``` +The Vocal Model runs on your graphics card when all of this is true: -Output: +- The card has at least 12 GB of memory, with 10 GB free. +- Your NVIDIA driver is version 580 or newer. +- You have installed the **GPU pack**. -- `dist/flatpak/AutoMixMaster.flatpak` +The GPU pack is a one-time download of about 1 GB. The app offers it when you switch on the Vocal Model. You can also install or remove it under **Settings → GPU acceleration**. It needs no admin rights and changes nothing else on your system. -### Bundled Renderer Tools (Optional) +Without these, the Vocal Model runs on the processor and the log tells you why. The difference is large: one 196-second track took 66 to 91 seconds on an RTX 5060 Ti and 687 seconds on the processor. -The renderer registry can auto-discover optional CLI tools if you place binaries under: +--- -- `assets/ffmpeg/bin/ffmpeg(.exe)` or set `FFMPEG_BIN` -- `assets/sox/bin/sox(.exe)` or set `SOX_BIN` -- `assets/rsgain/bin/rsgain(.exe)` or set `RSGAIN_BIN` +## Licensing -If a tool is missing, it is hidden from selectable available renderers automatically. +AutoMixMaster is free software under the **GNU General Public License v3**. ---- +**The app ships with no AI models.** You download the ones you want from the Models window. The app shows each model's license and asks you to accept it first. -## Licensing +**Some models are for non-commercial use only.** If you sell your music, use the MIT-licensed models or the fixed rules. + +| Model | Used for | License | Commercial use | +| :--- | :--- | :--- | :--- | +| BS-RoFormer | Vocal Model | MIT | Yes | +| Open-Unmix | Light vocal model | MIT | Yes | +| Whisper, CLAP, PANNs | Analysis | MIT | Yes | +| Demucs / HTDemucs | Stem separation | CC-BY-NC 4.0 weights | No | +| Denoiser | Vocal clean-up | CC-BY-NC 4.0 | No | +| ITO-Master | AI mastering | CC-BY-NC 4.0 | No | -AutoMixMaster is distributed under the **GNU General Public License v3 (GPLv3)**. +The full license record for every model is in [docs/model-licensing-audit.json](docs/model-licensing-audit.json). -### Core & Libraries +**Software the app is built on** | Component | License | Role | | :--- | :--- | :--- | -| JUCE 8.0.8 | AGPLv3 / Commercial | Audio & GUI Framework | -| libebur128 | MIT | EBU R128 Loudness Metering | -| nlohmann/json | MIT | JSON Serialization & Model Metadata | -| Catch2 3.7.1 | BSL-1.0 | Unit Testing Framework | -| PhaseLimiter | GPL-2.0 / Custom | Optional External Limiter | -| FFmpeg | GPL-compatible / LGPL | Optional External Audio Renderer | -| SoX | GPL-2.0-or-later | Optional External Processor | -| rsgain | BSD-2-Clause | Optional ReplayGain Tagging Stage | +| JUCE 8.0.8 | AGPLv3 / Commercial | Audio and interface framework | +| libebur128 | MIT | Loudness metering | +| nlohmann/json | MIT | Reading and writing JSON | +| Catch2 3.7.1 | BSL-1.0 | Tests | +| PhaseLimiter | GPL-2.0 / Custom | Optional limiter | +| FFmpeg | GPL-compatible / LGPL | Optional renderer | +| SoX | GPL-2.0-or-later | Optional processor | +| rsgain | BSD-2-Clause | Optional loudness tagging | + +--- -### AI Model Hub & Third-Party Weights +## Developer Notes -Model weights are **not bundled** into the installer or executable binaries. Users can optionally download models on-demand through the built-in Model Hub (`ModelManager`), which preserves and displays upstream model licensing metadata (`license` / `cardData` tags): +Most people can stop reading here. These notes are for building against ONNX Runtime or writing a model pack. -| Model / Model Family | Upstream Author | License | Usage & Compatibility | -| :--- | :--- | :--- | :--- | -| **HTDemucs / Demucs 4-stem** | Meta Research | MIT (code), CC-BY-NC 4.0 (MUSDB18-HQ weights) | Stem Separation (Non-Commercial weights) | -| **HTDemucs 6-stem (`htdemucs_6s`)** | Meta / Community ONNX | CC-BY-NC 4.0 | 6-Stem Separation (Guitar/Piano/Drums/Bass/Vocal/Other) | -| **Denoiser (`dns64` / Speech Enhancer)** | Meta Research | CC-BY-NC 4.0 | Speech & Vocal Denoising (Non-Commercial evaluation) | -| **ITO-Master (`ito-master-v1`)** | Community / Open | CC-BY-NC 4.0 | AI Mastering (46-param native white-box FX chain) | -| **Whisper Tiny / Small** | OpenAI | MIT | Transcripts, Vocal Alignment & Pitch Analysis | -| **CLAP (`clap-htsat`)** | LAION | MIT | Style Retrieval & Audio Embeddings | -| **PANNs (`PANNs_CNN14`)** | Bio-DSP / Open | MIT | General Audio Tagging & Classification | +
+ONNX Runtime and graphics providers + +### ONNX Runtime + +ONNX Runtime is optional. Without it, the build still succeeds and every model feature falls back to the fixed rules. + +| | | +|---|---| +| Validated against | **ORT 1.30.x** (1.30.0, 2026-09-10) | +| Minimum for the optional GPU paths | **1.22** | +| Release cadence | roughly monthly — pin a minor series, not a patch | + +The build finds ONNX Runtime with `find_package(onnxruntime 1.30.0 EXACT CONFIG)` when fetched via `AUTOMIX_FETCH_ORT=ON`, or with `find_path`/`find_library` for a system SDK. + +#### Provider status (as of 1.30.x) + +| Provider | Status | Notes | +|---|---|---| +| **CPU** | always available | The baseline. Every GPU path falls back here on out-of-memory or device loss. | +| **CUDA** | current | Default packages target **CUDA 13.0** since 1.27. cuDNN and the CUDA runtime load at run time when present. | +| **WebGPU** | current | A plugin provider on Windows and Linux (`Microsoft.ML.OnnxRuntime.EP.WebGpu` 0.4.0) and in-tree on Apple Silicon. The default non-NVIDIA path on Windows and Linux. Needs `libvulkan1` on Linux. | +| **CoreML** | current | Built in on macOS. Covers the **Apple Neural Engine** (`MLComputeUnits=CPUAndNeuralEngine` or `ALL`). Intel Macs run no AI inference. | +| **DirectML** | maintenance mode | The `Microsoft.ML.OnnxRuntime.DirectML` NuGet is frozen at 1.24.4 and caps at opset ≤ 20. WebGPU replaces it. | +| **OpenVINO** | split | The legacy wheel is pinned at 1.24.1. The plugin `onnxruntime-ep-openvino` 1.7.0 needs ORT ≥ 1.23. | +| **Windows ML** | GA (2025-09-23) | Recommended for new Windows work. C++ needs the self-contained NuGet. | + +The app probes the providers and walks its own priority chain: **ANE → CoreML → CUDA → WebGPU → OpenVINO → DirectML → CPU** (`src/ai/GpuProvider.h`). A provider that fails is recorded and skipped. A broken GPU runtime slows the render down. It does not stop it. + +> **fp16 caveat:** the CPU provider does not run fp16 graphs. Quantize to int8 (QDQ format) for CPU-only use. 16-bit and 4-bit quantization also need opset ≥ 21. + +#### Optional runtime capabilities + +Two more paths are detected at configure time. Both are off unless the installed ONNX Runtime exposes the matching API. Neither changes the priority chain. + +| Capability | Compile guard | Minimum ORT | Status | +|---|---|---|---| +| WebGPU provider supplied as a plugin library | `AUTOMIX_HAS_EP_PLUGIN` | 1.23 | Wired for WebGPU via `OrtRuntime` and `RegisterExecutionProviderLibrary` | +| Per-GPU compiled-model cache (EPContext) | `AUTOMIX_HAS_EP_CONTEXT` | 1.22 | Policy implemented. The `OrtCompileApi` call is not wired yet. | + +`src/ai/GpuProvider.h` holds the deciding logic as pure functions: `parseOrtVersion`, `supportsEpPlugin`, `supportsEpContext`, `decidePluginEpAttempt` and `compiledModelCacheKey`. The tests cover them even on a build with no ONNX Runtime SDK. + +The cache key covers the model digest, the provider, the GPU architecture, the driver version and the ORT version. One card can never reuse another card's compiled model. A digest that is not a valid 64-character SHA-256 yields no key at all. -> **Non-Commercial Notice**: Models licensed under **CC-BY-NC 4.0** (such as Meta Demucs, Denoiser, and ITO-Master weights) are restricted to personal, educational, and non-commercial evaluation use. Commercial workflows can use open-source MIT-licensed models (e.g. Whisper, CLAP) or the built-in deterministic heuristic DSP engines. User consent gating is enforced prior to model download and execution. +To finish the wiring, the guarded code should call `decidePluginEpAttempt(...)`. When it returns `attempt == false`, log its `reason` and continue down the chain. -> **Model Licensing Audit**: For complete machine-checkable model license metadata and audit specifications, see [docs/model-licensing-audit.json](docs/model-licensing-audit.json). +
-#### Mix-Scope Model Contract +
+Model pack contracts -No curated `mix` model ships today — the AI mix path is fully wired (`AutoMixStrategyAI`), so it activates as soon as a valid mix pack is installed, and otherwise falls back to the deterministic heuristic. A downloadable mix model must satisfy all of the following: +### Mix-Scope Model Contract + +No curated `mix` model ships today. The AI mix path is wired (`AutoMixStrategyAI`). It activates when you install a valid mix pack. Until then it uses the fixed rules. + +A mix model must meet all of these: | Requirement | Value | Enforced by | | :--- | :--- | :--- | @@ -429,18 +339,20 @@ No curated `mix` model ships today — the AI mix path is fully wired (`AutoMixS | Manifest metadata | non-empty `license`, `source`, `feature_schema_version` | `ModelPackLoader` | | `feature_schema_version` | `1.0.0` | `FeatureSchemaV1::isCompatible` | | Required output keys | `confidence`, `global_gain_db` (±12 dB), `global_pan_bias` (±1.0) | `ModelPackLoader` + `OnnxModelInference` | -| Optional per-stem keys | `stem_gain_db` (±24 dB), `stem_pan` (±1.0) — a superset of the required keys | `AutoMixStrategyAI` | -| Input features | **66 floats per stem, concatenated** — `input_feature_count` must equal `66 × stem count` exactly | `OnnxModelInference::run` | +| Optional per-stem keys | `stem_gain_db` (±24 dB), `stem_pan` (±1.0) | `AutoMixStrategyAI` | +| Input features | **66 floats per stem, concatenated** — `input_feature_count` must equal `66 × stem count` | `OnnxModelInference::run` | | `allowed_tasks` | must include `mix_parameters` | `OnnxModelInference::run` | -Two consequences worth knowing before authoring a pack: +Two things to know before you author a pack: -- **The stem count is baked into the model's input width.** Because features are concatenated per stem, a pack trained for 4 stems (`input_feature_count: 264`) is rejected outright on a 3-stem session. A model intended for varying stem counts must accept a padded or per-stem input, not a fixed concatenation. -- **The leading public model is not plug-and-play.** `csteinmetz1/automix-toolkit` (Apache-2.0) is the best-licensed downloadable mixer — it predicts per-track gain and pan, which maps cleanly onto the `stem_gain_db` / `stem_pan` keys — but its published weights are PyTorch `.ckpt` checkpoints and its input is an audio encoder (log-mel/STFT), not the 66-float feature vector. Using it requires an ONNX export **and** a host-side audio-encoder frontend, so it is deliberately absent from the curated list rather than listed as broken. +- **The stem count is baked into the input width.** A pack trained for 4 stems (`input_feature_count: 264`) is rejected on a 3-stem session. A model for varying stem counts must accept a padded or per-stem input. +- **The leading public model is not plug-and-play.** `csteinmetz1/automix-toolkit` (Apache-2.0) predicts per-track gain and pan, which maps onto the `stem_*` keys. But its weights are PyTorch `.ckpt` files and its input is an audio encoder, not the 66-float vector. It needs an ONNX export and a host-side encoder, so it is not on the curated list. -#### Model Inference Contract (all scopes) +### Model Inference Contracts -`IModelInference` is a **features-in, scalars-out** interface. A request carries one flat `std::vector` (`InferenceRequest::features`); a response carries flat named scalars (`InferenceResult::outputs`). There is no audio-tensor path through it. These six tasks are the complete set: +The app has two inference interfaces. + +**1. `IModelInference`: features in, scalars out.** A request carries one flat `std::vector`. A response carries named scalars. These tasks use it: | Task | Input | Output keys | Consumer | | :--- | :--- | :--- | :--- | @@ -449,23 +361,27 @@ Two consequences worth knowing before authoring a pack: | `role_classifier` | 66 floats per stem | `prob_vocals`, `prob_bass`, `prob_drums`, `prob_fx` | `StemRoleClassifierAI` | | `stem_separation` | per-4096-sample-frame feature vector | `stem_weight` \| `source_weight` \| `mask_` \| `_weight` | `StemSeparator` | | `mix_master_override` | all stems' features, concatenated | `dryWet`, `targetLufs`, `preGainDb` (legacy) | `ModelStrategy` | -| `ito_fxencoder`, `ito_predictor` | the first N stereo samples flattened channel-major into one `features` vector (encoder); the same vector with the encoder's 2048 outputs appended to it (predictor) — fed positionally, never bound by name | 2048-dim embedding, then 46 normalized chain parameters | `ItoMasterModelRunner` | +| `ito_fxencoder`, `ito_predictor` | the first N stereo samples, flattened channel-major (encoder); the same vector plus the encoder's 2048 outputs (predictor) | 2048-dim embedding, then 46 normalized chain parameters | `ItoMasterModelRunner` | -**Consequence: a model whose output is an audio-shaped tensor, or whose input needs audio semantics, cannot be used through this interface.** The wall is not the number of graph inputs — `xycld/BS-RoFormer-ONNX`, for example, has exactly one input and one output. It is two things the contract has no words for: (1) **output rank and volume** — BS-RoFormer returns a rank-5 `[1, 1, 2050, 801, 2]` float tensor (~3.3 M values), and `InferenceResult::outputs` is a `map` that cannot carry a tensor at any rank; (2) **audio semantics on the way in** — `features` is a flat vector with no shape, no axis meaning, no channel identity, no phase and no STFT front-end, so there is no way to say "801 frames × 1025 bins × 2 channels × real/imag". That excludes essentially the whole published audio ecosystem — Demucs/HTDemucs, BS-Roformer and Mel-Band Roformer, Open-Unmix, Spleeter, Whisper, CLAP, PANNs, CED, Basic Pitch, CREPE, skey, beat-this, chordmini — regardless of license. Installing one yields a pack that validates and downloads, then either fails the `features.size() != input_feature_count` check or receives a feature vector where it expects audio. +This interface cannot carry audio. Its output is a `map`, and its input has no shape, channels or phase. A model that returns an audio-shaped tensor does not fit. -This applies to the three **already-curated** separation models (`rysertio/Demucs-onnx`, `StemSplitio/htdemucs-ft-onnx`, `StemSplitio/htdemucs-6s-onnx`): the separator feeds them a per-frame feature vector and reads back per-stem weights, so with no weight key in the response it applies its own heuristic. `StemSeparator` now reports that case honestly — `SeparationResult::usedModel` is `false` and the log says the fallback weights were used — rather than claiming "Model-backed overlap-add separation completed". +The three curated Demucs models (`rysertio/Demucs-onnx`, `StemSplitio/htdemucs-ft-onnx`, `StemSplitio/htdemucs-6s-onnx`) go through this interface. They return no weight keys, so the separator applies its own fixed rules. `StemSeparator` reports that honestly: `SeparationResult::usedModel` is `false`, and the log says it used fallback weights. -The verified-later candidates below are held back by that single missing frontend, not by their licenses (licenses confirmed against the Hugging Face model API; all ungated): +**2. `ITensorInference`: shaped tensors in and out.** `OnnxTensorInference` passes named float32 tensors to ONNX Runtime. `SeparationRunner` adds a host-side STFT around it. This is how the audio models run: -| Model | License | Why it is not curated yet | +| Model | Input the host builds | Output the host applies | | :--- | :--- | :--- | -| `xycld/BS-RoFormer-ONNX` | MIT | emits a real-valued rank-5 mask tensor (real/imag on the trailing axis) that the caller multiplies against a host-side STFT; needs a tensor-level interface and an STFT front-end | -| `musetric/skey-onnx` | MIT | expects 22.05 kHz audio, not the 66-float vector | -| `musetric/chordmini-onnx` | MIT | expects a 144-bin log-CQT the host does not compute | -| `musetric/beat-this-onnx` | MIT | expects a 128-bin log-mel the host does not compute | -| `mispeech/ced-base` | Apache-2.0 | expects 16 kHz waveform input | -| Basic Pitch `nmp.onnx` | Apache-2.0 | expects a 43844-sample CQT input | +| `xycld/BS-RoFormer-ONNX` | STFT of the mix | a real/imaginary mask | +| `MixDirective/open-unmix-umxhq-vocals-onnx` | STFT magnitudes | a ratio mask; the mix phase is kept | -**The unblock is one interface, not a bigger catalog.** `ItoMasterModelRunner` already drives a real audio→audio→parameters graph in-process, so the pattern is proven; generalising it into a tensor-level audio interface (shaped, named float32 tensors in and out, plus a host-side STFT, alongside `IModelInference`) is what would make the entire download ecosystem reachable, and is the reason adding more curated ids before then only adds download size. +These candidates are still held back. Each needs a front end the host does not compute yet. Their licenses are fine (confirmed against the Hugging Face model API; all ungated): +| Model | License | What it needs | +| :--- | :--- | :--- | +| `musetric/skey-onnx` | MIT | 22.05 kHz audio input | +| `musetric/chordmini-onnx` | MIT | a 144-bin log-CQT | +| `musetric/beat-this-onnx` | MIT | a 128-bin log-mel | +| `mispeech/ced-base` | Apache-2.0 | 16 kHz waveform input | +| Basic Pitch `nmp.onnx` | Apache-2.0 | a 43844-sample CQT input | +
diff --git a/assets/screenshots/main-empty.png b/assets/screenshots/main-empty.png new file mode 100644 index 0000000000000000000000000000000000000000..52fa8ae332f8c67de232dffc0b3f16d72d1969c9 GIT binary patch literal 45544 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

{2a z08q_fGIW~f^SPgd8%YxAKU)y;0N}sDWy>*;wQtr#V7VFslp^?Cz5p=WG625a>f-m} zw&a2We2$R1e@C45`q!;XBHzRQo;0w-e}ovQ<1+HV!x!$$JmV0~w6!8!8r2NTO4<24 zK>RN2Nwjb3wT2N7S6r^kqIkg|p#l&YycmA_IQX2~^T4KU;{J|l2rdB0w;w#WVWT9; zeHtRa$wCI)`j~^!yObQh24MBc$;jT9i{Zms1ZIqWR(fc|pNM=3I6v9_JKq0Kv_2F= zwA3aBCw3JWrkeWzsQ;&J*801X;gU80o!rp7-b8HozV8_U-+}#qV*H73zBi30@~w>d z@x2gEZGqHxQ1OxIAK~KvasITQvNKQ`77zq;A@PD0$_~ZgL=n_*MIUc6qh% zAwDH+a;XHg%SgqH?kQ=pac#NuntzMEo}yQPbWJ13Ws-=q82A>R-`Ll=5;la30PYeP zIPyAh#|nSr-ikWD3*YstZ%V;X^@ifVj*{MVPcTV${@U=Z7S%$jmYuSw9{WVC*kXd7 zqV3F7Er2_-V5usCm*@Pnp9`=eBYC4saJ+uLPZOmr=cpnv|X$O*Z*UfyqnAwcNAUqdl02-XVJ_Er?k31NIl^&dNC7(8=Km_Eyo(-nlU^)+N%ANCXniRb-Uhl5*gC~FO9%ITq=X6nt{M!+ga7zX531Fd$^!DAqW}1qvrQ2~5YmWk z!T!hJIyWnRZb{i$i7nAJWz*%<;F;!v{aWt3yjk6>$#u6Jt7~?d z?tI?RC*T7BRkNB!=6Q)+-%}*h+c^&#>=sv50nj$RJs=7~90s^cJ-oNKY|moCt>$N` zUg>{bWYJ5EEhbaL_$KER?tF@7h?J1*C9fG%%~Lvga4n@LI`bMMmzm@Rf4Fj}0yvJ1 zv|>AFWHY7*1hq1dee5WVxr=Yr2mwiYqM;q#i|bH$A{p>dCaiTvGd2%l;h;(4BJk@+}N|L6Eh2nKCCn>R`@)^9(C7BfIJ*~=_ zATV4om^RL|thBZ2(xXiF$ks=>r=mLSko>itm`pXi$sdbt=Ql8Wm*i9LI74=aGkex- z{Kulh9gB1UuL5mgFoFB{dIak!a6d%&p6Jj=tJh) z!X0ooOh^Vrp^$S#vb;>v^K!^2e4=H9ztBw4*!UYrQ&t=CxOf?H?A^fH_S8EDYdx=N zZaU^Uf4AnQu_gS_!)V1T&XLzPT`e$cdRHxfK6QyiL#apot3QPt5c8sV2w%h_hnCX{ zkJj)Z+|PMc>G^}&3|s!5n!1b=9FO>oW zb}JjVj7Hq*ba2k7BA;}X3KXuzR=f^v;)cLiqr^?Rs5pl?FJV4iIp5_-sNQt-b}@In z0Qkj7!YDR4P+d4tbn`f6uj@qG_WA<$YHnrz_?&##G!8@LT-u$$cq&|{Nw#2DE0%N+ zP3|e{C%q=4{G217kDyzln4O!O*f#b076oRk;!r>Xa1vPW;*CvYxaDN9*3^cl8ifo6 zVNbjX3zHalZnDpR5>H(_ZE(8UzUg3URs08k(9Ph&OBc1y%0C407)2kNG$JEk(LW`k zugXPUorAyHyeY(2!NXCw9LTOZsLRr~s_fd`$~8qx^vWJ#yPm+=JU%`py1Al-mu zU_;NPG`0czRortt^3ujYE2M>8VG`$t{H1n5^OIf}xSn)NuE-}L>iUl{9wOqSpR`On%pal}- z_|;sIxqQtnC)d)JC)|x77oH{!SXrq9ZOj%6uT*=-uBqZ3n-lN7EuVMo@x_;j38&Mi zznm{KfNrss?Dzqe!&Pp| zY5w~^1<<+uHwkw`C{f9j>)Eu&bK7kClYM)eL;#^;;XDdhjaJsSi%(vhD($9J=sCf< zp12SGZg3RTl7h2S-mhAtMrncocMKILe?}~FY4bG0q%#F)hztZgaRT2>+fKp-)z#&q zIM%?`(V1`l=ALtC41b^xdfUSHUi-YpSI-7`gh9quEM{Rmiv&dg>+$m-m`}3S-H~Ts zMdVkx*+Rdm8>~jJ_W1HFfjhP<&=cYDTlz@ujkrp}2G%&}c=Y*E^Uo^cGa0=XY%9qo^e(AW-DepFB(I9UiAq{^yzZQbH5w$fM(i+ zh)e0i^gr97q*@rZbJ`we4YXRK9c8?D!W-%A9qLnOPPc|S3I2i0o?CT3-3D*)R#V`p z(BBu-j%pU{-->Dw9H1R~>MAVXvi}b)E)FSV9^1n}M$@%Pg9qbM+6AuciFH0&FkzA! zZ0e~7zQ}b8eoy!Dfp+N!nUALDIEIXR1dXiYb1O8$WU$&~k{fP|1%zOahVU4cLe7T{ z2-kpL)7qrV7iaTBrG-6)3x2tt5v}hbMsho4{xJ{N`ekd=x0L{T3s^dq6S9-wWn?Gf z)N#s2Ad(cr&1Lem1gpcX1HT=}%G4+{D_>b~m8S0rvIncSUcFIl?lbg#L2nN{;idn4 zdpfTSJGVe2!W$I6woiQzz|92Tr!IYy+bOz~8anHVsBdT>U{QRaZ;vwC@vU1&qu5Ko z6)03qbtHEJS|jEf%i3cK_Uk)f<1R>MU1HDdpF?Xc`piEfqF_&r$XE>v#l(&hsL89m z@eNPQVmpF!CiNV$lgcJ<+qZGB9ifqc8y1}Yf%U?}r+3NGbAPxES`~;+Q?4&bHD0g% zSj1V;^a=C3Cn9kmCf7!s4e*s%gG|rd8Vx7Pv@38FEP2Lt*L0v#&A)r2(Sw-oL;d^f zwyjcA+BDN~^})2Q&7Id+|7&$5T8lqBH|&LlGc)Eju9hqecah|%ydW7&_u_cqWCd?b zI(ko8ck^lvrYX)aN#`_+<-_QmG&pacc8x%8i0spN+=di7Ew#P%*V8RXv_ z)T$|zBYbXZUVrC}&A)r3pJ_WlwBA!nUcBxB*l{Op{2XhAaXl*V*Gn}#Akt=T@@u{*9rRRaf$Cu?gP$!FL3Um z<3_Y@ka0|Q?rgaDnjgc4gh|?c^6Pf{u;pIHi>tyH6Irk^QMkkN@7F)7>6%Q{w+MST-DCKdoFUb^&ejn zLBm;Im7MDuGrqCiYZ_t1+*{5?=)YN|z{AUm3Hwwt^csXguBnU+wH+~&^g;bHSRt3$ zCf^8DC?(Vg7MwS|)S6J8>7t5yTfb*JI<8MFk1&AzG5)Ug-%Ms!E)0ag$dKYefv z{@tnB1HPGPm}$pZ58|Pr%*n1c=IE%;XZ{1Q2!D^7ff^wDOlDN3-$QIOthwLgaWe>8 z?=@f+K#50<^8kGZT@q|N#r@V)Z`wv56t$#MK zwz5-eb{;JHTw46)6ZqZfn^VS}$dBT5Y}K+f`rD(YRz4ul_I`3n z_S1s~a$_gDW!;k;2%ODLDc)e@aKyYCpY`+Buq|5qf{v7`{2KCpqg6uvb%tbpv)%11 zAD1-SfCwP92Epjh(`XDExy>L3qug!ZJA#6aOsflL>gc`ywcevebBFakvNOl3 zuq0%Y?})R`d|~wr!?Df;pr6-05{S|pbvI!_Z{lkfrDjqUN%gM3F-9H9BVx?jjJ4eWb6pR16o0ea z)xQUUCRWLNbOm9O&q$3QFkzL6LTOkJ-Qk{%J*tCgsSx-`;Hk+bLp-B*wZpo4D-sGWS00S$}s(+`D! zJzrtr8j!|aOyJAju!1%8v9m#0rv0r3OEhuanQPtSs_Z^gwnJ`~S68-=8d$_k)a_{i z1oYQW-IKY*?h2*zTus!q$t(jduNsj?%pBla6#(Gaz1`+|E1?Jb)*T>ChsP3S z1l##tAeuH>(iD_;KPXOq;H}=A9N>uv$CFw=QHgSAOtz@RHKUyOSh#*`xycO*b6*&r z<#`W~u`}`juT#>50u>JkUcHL0w8Y@|mOzV=_Pwyb_TdCd}+8Fg%^L4iI- zxOl=c5H)Zs+ap|zD2%8$Yfo)#vp&lP_ zq-4?IldzLH4xx!WLIbIHFQJ+}z(zy+IPDn%;YgTN2iZKd`V&%ES-!QVHiv?pin;Ak z-1d|nHtuMWRdm!P$?nwvm;!+DXJz3Fl>FeaqwflAjGR@JS7puWlW)`gzH)a zgPCmhuQdjWx|E@MOseXsnG=Na(eF!C{{+b0b?f@fMqmy0k&8F1`grMWVYx4}Z2*!WTe?)skR< z#?=Q)KJAt62LT>EnsTh*ZrBT_DWW4TCp|A{s!who zU#AJeWxVD?nzS(kK2C|oS&yq@xf81drkG{@9(WKgA-kUM(Ziy6NT=Z%k)vmin9u4c z`po+ER1jWmNVnWlIvghpmcXJ1h_Mr+;I6cn)lrSi(ua2VW9RldU7TZiNGEc4>o$$l z+Od){%nFjzWA`Eu{+PGRe0UyZAc*q9(^5k{l}@V*G6C$JnN?OR*jffz&@w#4`gJj; zHeFbepj%d8X3sH{n#K1Y!0^|p9Xcewn?7Dw)Dk!jAcebF9e`Y4=F+0{rd{^?GwhGq zCB*I3kiHC{L}Kzr2Xu=Ax;h$0M#}_vqIM8I6S5hriqc^M&xR|Xx6if%o_PH>Zm=># zyfMZt(rqK3}U@)$Lo z*su;KvIkh#27JhX2c`?cE$I@839v5Y`X>HT{vPLxQ|$sv;8vs&gTjP8X_RIG$}2U< zEqYS%d{0(#K`cx9x)y`-^LdCLa9~S`qUrHKMW_Ouc0m)i$oV%jb_*c0wuwP&==WHc=O&~27T{&Zd4K72G`G7Z)Fv%YUcq{iF zTH=#XS#B|GXc1Po9R~hIG8jRIO5p*oqE(k|qiXO+<*1~>H^e6Gdt{R4JcXeo4$ zmB=*gSE%kd?bns*dq{(NA&$0Po5~7;x6?&N*y7le_JwXF%Hw&`d=Ve*b!{out2;1G z{z)%}fhkN?XEbeB__|X5o=2%n6p6fIbNfe|&<^%tVb_fIp}3V^twn*a zZiLY7CxZ8MZI`ROj2w7vr<;(=;lpDkikc~_cp?Zr_ha`R6Uh*)Aj98~xO4rAG!61^eme98XqxJK_fQ;q#9?Hq!2!^u1>3s1Tyny*VRF!F?) zPI)za+5`DHELJJK9$Y+*3lKJp#n_ryCxIuiMzl81C<{B$jv@)RY-#zQZ2J<~B&;~m zhwS-CvjT^WJ9l8{;oXu&*Shh|#tRqcu#4?kr6DmUY*a5&!LxP}X>pu`ANK1I-z*M! zlW$jOun&Z@-fnIp6&rkRR`^D{LH5bV`=B0@ba;d)R1>R;+<8dmzUTBxeCmp_SWfzc z^tMgM%PS_Z-x*kS!S%uX%cQd1nO>-!*mtwZFKq5aFuBNa$29V>8mWP)qj7Vy#|Sb7 zXQ)9v*fh?WE+k!AmV`;?RF+@Y-+D9n32kmsdQh@Cd7q1Yj9uZXbhcQ7q61+CmDcK_ z8L+A7v?-Oyv*tN)ne&zyC+cg-3Ki1&ibJ$4jX_LTh}I0HP?(!oB^!{ybs0 zyzLVQ{Twf)$JiNGm2HiVIiGF#0?;CmMCN&~k~P`oiC;4k4GSO7Gc#A%-hTSrmvY%w z|9r#yo4#!&4P4&M$-8(E8ChCZs1mt_panzAmkckV45_E`5Q+#Q7x$Fs(#G!RLDQ+u zc=;WGas31o7wcU3Zqz;-n$9+VaUv?Ht-evb-Sl8K+3dS&rbT2tS)-W5s&KV8$>Q&F zhTEF3_@ch@%5`cK!T#2Yto<01)qD_T?4TGY1ZUI3LS2z-snV_vIbL156un4$VbAJx zq(UNA2@#bP>%hrtPwOD$kq{O%X))IT+y%Cg>LCy8(R3V7;F%9iyC*MA2|JX?D&T9> z7gxEi1<*-QG6L~v1DE(#I@F9!mxdZ=!NT|k!n79tR&G(}1G_Z!wG|<5GkJHo!}a%0 zgQ`ujuh#;;u#pto15p%dfmPza?>$X>xK%vg6ho<7k;-U6ZOL--fl#Zu7G(8ld)MmZuhU*)1#l^kR@cwqQsx-;C0L$Qh+X@7<7CzwQofG%{84r|X4tja6@qgauz zTV9mS8=W3=`W8S8n%OwH)}M&s z=P^<=)vG6lIB&4Fm3@igSZK}I=R-;+xEAUyyY5^8jw$itMhi;SmR3EQVCf)(diUX7`vu9oI zLxf%Uc~Q8&zOe*cmQ`*2;IhXe#s`}5kY|8i?MGx(7;*Bh*6X9OGhF8 z28Q{S(0<)$r9np?+1#-Z6t#Q`3B~v#G4}eC0pS5aOv%3x6R3EyaJl7jr#ZbwPXgPO z^%I`q8^qh|-RR{c}*J@DpG(!9LQ7J%yz}H(6+L+m@gcyk%}@ z9U(2A{#Mq94L<(?LSNQF^J<$o4m&@*wK3M|V1IK`V9@o77#za3L z40l^S>bquTp``2^K!UGkPQIHV=yG$_3biEdo20%KD!`VFJD;^#?)&AN zN(b7vlek6E6=`{cS$3?IFFQ=M7xzI;eO>1l&<7RsFw*C>?aAx zbCgrVE1%F@yjV3Vf9^Z;7w7CV{TkTjjtcbt2}*s#M)+V!TanChwhwNDTA>rU*p+A{ zAJ!$`IHE5_;QOC(bm@>rnE86G({uqY0vli~zVsrERVoIv0F)rCiwIV>dy?34hV5xP z=s6ECl~DUf%WifCfPE^4>L#<+Ym>z-6W%vD{m;#Zk2yhi4-m2=I;wnJ>c^@!m?BP= zrD6GppFboYZD3{1E92`7h|0og#|lj81#7e-5ov&~zG(ncHb61J0SM~lZA~*<34odX z%@DzS(e?RLRSirf1Ogz!ARATt0dOM4|XvXgR6%1sAv1du9VJ>8p0L-bO1q%S@8bLyp_W0SfDmN`Ke4%=%O2Jn17W>zLwj0Na!sAYVwmP z%az`{;SHvKSIhVo{qhG{7q-0wh>k^3P!gNZt&ZNg-XL8{0@wf53z2eXBUZ5czLsre zm+q%Yipg6h5MoHh3nNIy=2U!45h$AjG9V>|xG&Fq$!2IbhiP=}mA%t!pGDU!(dyaF zrhZ$m_@rHbvWD3|1m$PPQgdHD!N9P6Ak%bKr!ygJhU>7xyd&%GM7*=pSZwPa21|z> z@OQKi?+#bSI4Qv91ZGY*Y=9SyRnx{OC^ zwF4wKwz29pquo*-eE`#c4qscu^N&>PR+{alrF1iR7=4LSa7P*K#GP3mt4<%jzv0T+ zd3T>W#~Fo$pOtO=RhKt*;IkuYLG|iz(wTd$72tGC?$k}H8G8*k!MU_Ob+za0ddV`F zwQN&d0X`ZvFvQitOIx|4FSzR~+0;9;Xmtj0+OvHi0TBCj>i{}D7?To6WFzabN(09r zN`(+SAL0c~T~?=BQ#hkSZ^*pbFoIOr+YjpO?IPhu(2n<=NAEN@oCcw+b2OTey$8L0 zEd!@jUHqKxDi=CDsv^;y#g`k(I)AU7Qi#%o^3PWNvMWUP+Kp$g7V2C0&M%7&f8(T~ z%^d}7v`qs(o|{(Ft>RK%b*f0=H4PWVTRN&g!V2zDv7ToFFBHLZ`%k{5AmM=Ai8#R(_3fVGV?qQ=oV1TyYeOsrG$)2p-VwkMG4CeqtluIvKtr;p%-m984t{@chjU#Q;6!#(@WiKND--fyEKix_1Yjrk>bNjR1 z^#jxw`dq6+ITA7F=fCQbROdbB2B|0W4Wg^PwWaEwTIug@shPJ`ytV#~c-e?P=HNy7^t`|uVR^}r>^#P%!aUDC{2P~l7idF{k&4QO#H;9xH&3_reF z1MciQ1|}#UgW2V%9v;{JL-VCR!ORE1fIF8H?{55s0zZqM*O;8Wdi~G& z;eXH0e;a7=-)rY@Q}Fk`^NXX_-}}yYZjQgV(f`lSY+t9~92t8}{b9pdHIdf>+^;TJ zt){jZy)WR{EqUsTqNS>592Z^r$Dqc_`SE)fL=?x#(QD(5?t6p^a2#98U5LI`1(4_0 zYaS)M~=gfeG*_t|@U5O*khH!U12H zh89a=q5B*7U1g1vEdgHyNbtZv;+8xsPm%bS_f;t#l+9v8>Qe?8JOI+po_|6}j*+Kg zoYP&B2RYtJp(fygWN>xzzt>6s(A5@sQmG4g zqB~5}J#xIKL9NTz6Tz1?LecyCLm z{gVP4O78Q$oZjOc*2A+?cLBMGzrKX6XUCxe@+p{p_5Z2=G-Vv((5uF|P(5wpH<2l3 zxK2uGoZn0io3xhd8_d&jbmz5Zz!h#$ZMpORB6hodNvQJRa3(cx>TAPDGAjNrLm2QO z=fCP%rN(f*-7a6_xaBWmcoQHCirm5r>pWu^`FH;^XPYh3%;G|Vfz;)WwMcx>H+R^( zpwKrPEvk*7&{zjU`ioiqY`$A40qf7O3*eSlgSpG<~`Cz?Tdi_s6AfUBw+?VZV(7&g)6eEjl0 zwx+xulojoIQDjsY>d>K)KifS zE~G?=h1R-3@Ple~@Yrq*z)T5A-a)41H@TvT(g82v+2j+42wi zlm_5h++%)vp`8|18OzJ&bh%S)Xitf=%1g03Ma300p;Nu?Lnq>#eV8R{%bG)engYO8 zAP}$76K+O@X_zrPtVW+waE*IAWO#i)NGampzve$wTct-NP)#OZ?|m?5VlX;5d)bG+ zqT>Q1(Q?-`N@%Q6@MvQa>o(*wUa(?+f71_ojC=a>oy~dYKFnJK=z^DDrlnoHW_@hh z;$^BoNn6?yU~jm0u+RAr`NfwxfF9&oRg44CsIIT!=Vm)?Uw!mi5}y~s*_*WSr=~^^ z`f9U{uDZ|uN-#wX%pC#T{vo`zKR>vQ8-avw98R?l9=Q(n zBR_upX*JlIdafrvP2M^f1IY^ol(XP^NTP$s4qVt=TgDL)%aH(kBgC7 zvZl)QUH#}`>h=sq=#$~fCQ%S{V%r)uhlyNdzK_~$2pd&#tkOM!0<6MbR1C9nu~@Dre* zdv&3m!+j%%ABXDR0;1K|3Wjd?ahHGebTMJ9hRCYH=hs2O9ztN2mAn0@EHRFn7K-z0 z*2%Lt*kbJHO=aQEAD4+?e~L0X;JndJ>A+oT^lnvsvk`<<9F2aprGl3JqgvFm z%o*%I2?lMl>*o>LOUL71_AH6v1*%%OTK^;f-wkSHx*j3?17wM>AWXx;7rx6QY+hrM z=8uL>CvSe^)2aKeL|Dp;k&gP&gO8sn3yXhLwtv-L@ZY+fEx(g$R^10DG0A7}4+>#R zP!j16)eaIxXblnq2MntoqoR;I|C-d5x=>$a#_{wJ7YusjvO1 zfsiWnP?-{~N;Dvrs&}x%p0NQ~ss=dj$%{ARr{1g}Y*qaa@?+m0XT z)jwVQZx8h4Xetf(^mxhKNgi#|Dix1A*}3JB9U&?f zY0BxUl5hObpcMDA0#9qEdu}0rcD07m3N`CE@(S?PWvVzMYYNT#SO}MC@(|yx5pj~0 zriP~E6H3y67!9^U+wnCM9(qclW6PxazO+HC0pr_9bQnR{O_}^4;dd803}B|jVd!HX zdA?q}#=9oQ<*x0`yhA8<;60WQb^=ta1GT+o&DvWqr(61Fu+n(%R4CdxE+j25>?=o# z%v+h}mLtv%BWt7&wH+%n!iWOh=9Lkp;U#o4!cAp@w%23-#MJe4d!p<-j|N&e(`}OL zR&#uEYy>Ye;Na+jFLbEMGb%(Ed>7(i-YML&+hVTt0X8R``M&H}wQ*%;+Gz!ns06!O zEsU3JB2;-aK^LSc$Qy1dPwg8BXWDcId&bdPuqNtXC%#xugU4_Pg;V;^PO zMA1N%1ts|^?0AOKM6)R=29^&iy<(I4aVuus2S2ha0napZhSSqKO)t$qn*e zNK(K;~`Hk!3J>F7rb?1G`z7m$*%(G%xwO=^R+q!j_tfZi%-@)pC0epfz z=57H4ij8YVamtrgTX2A_jDek`72JLN&FA|XIptQpF8Slxb=L|tFM8J;g0G@*AQuPC zn(S*_w5b~!m+vwoSNDj5U2ATM5Dr$05Twsb=>~?4Jx1Z)r>9>D2!2JF3>_dUvXMMxQ-pQ| z4bilrQFHGw36b8*S`<$;_D$s(eP3066_8`Yb6?qvJ%q1~eOYT%c&$n^&YRWF$xVI? zABbR9O=h=j?Q9ONu*_gT#XiR`1B>>oc!D!IZk!R8mANc8c@iX-R}yvc@{8@PmwH$( z4iC$*T5cC+_SUWAmainC8)4oL^HC8qlMyQnJ}VlIFpD^gr*Xe(d^F8&HdD- zX|wa3m*e;BT0+Jwod}xE{rjvky{mauWiDa%Xz?yiM&K7yMA(ofbmIsQBm`()HO-$Z+N;&fjedKJh&3MCDKg-=jT;4fA`rGrLC`O2wR=2p|rU zTrT*qjQ0#x`<9fMr#bL*$J-!7*QzIsciZXVl^VZ>7B*{UUCmw75(ByE;IxA#^|F~@ z$}Lj9bTO3EDQw<`(ORacmb9H6(F=BPd&8#;BpM_u!1oKpntBr4=&Z|;?cpz$K-Iqv zPS3~VGkNbr;lxc)SXW23w_3zUQ4gFICwMztwCpT-kxTla9yiw#<7nA;I2JXAg^3Ag z8fE3^igB^)CX#ofm9U{xO7kYdb|eJ2L4ADnH=XdA?X%$ZnmPCnx@Ntj3r2v9^4?Dk zo8a_<*o3JDhTVcj&96T7qd07K1woGcdA(gB9FLbfz8K(QXU$S{U94&A#!65o6jE-z zd@b#?t6cHk?Xz*TCXrV^wHR#;y)x+1&oY literal 0 HcmV?d00001 diff --git a/assets/screenshots/main-session.png b/assets/screenshots/main-session.png new file mode 100644 index 0000000000000000000000000000000000000000..cf2a4e80ff8fb69dcd26ff005aec7a8ee3114525 GIT binary patch literal 62815 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 z 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 F{|joLBgX&$ literal 0 HcmV?d00001 diff --git a/src/app/ui/GlowMeters.cpp b/src/app/ui/GlowMeters.cpp index 5589968..2206b74 100644 --- a/src/app/ui/GlowMeters.cpp +++ b/src/app/ui/GlowMeters.cpp @@ -211,8 +211,11 @@ void GlowMeters::paint(juce::Graphics& g) { auto area = getLocalBounds().toFloat().reduced(metrics::paddingSmall); // Labels at bottom - float labelHeight = 68.0f; - auto meterArea = area.withTrimmedBottom(labelHeight); + // 16 px for the LUFS bar strip, then the four readouts and the bar caption. + float labelHeight = 84.0f; + // The top strip holds the L/R captions drawn above the bars. + const float channelCaptionHeight = 14.0f; + auto meterArea = area.withTrimmedBottom(labelHeight).withTrimmedTop(channelCaptionHeight); // Two meter bars side by side float meterWidth = std::min(32.0f, meterArea.getWidth() * 0.35f); @@ -231,8 +234,10 @@ void GlowMeters::paint(juce::Graphics& g) { // L/R labels g.setColour(colour(colours::textMuted)); g.setFont(typography::caption()); - g.drawText("L", leftBounds.withHeight(14.0f).translated(0.0f, -14.0f), juce::Justification::centred); - g.drawText("R", rightBounds.withHeight(14.0f).translated(0.0f, -14.0f), juce::Justification::centred); + g.drawText("L", leftBounds.withHeight(channelCaptionHeight).translated(0.0f, -channelCaptionHeight), + juce::Justification::centred); + g.drawText("R", rightBounds.withHeight(channelCaptionHeight).translated(0.0f, -channelCaptionHeight), + juce::Justification::centred); // LUFS bar below meters auto lufsBarArea = juce::Rectangle(area.getX(), meterArea.getBottom() + 6.0f, @@ -242,7 +247,8 @@ void GlowMeters::paint(juce::Graphics& g) { void GlowMeters::resized() { auto area = getLocalBounds().reduced(static_cast(metrics::paddingSmall)); - auto labelArea = area.removeFromBottom(68); + auto labelArea = area.removeFromBottom(84); + labelArea.removeFromTop(16); // the LUFS bar is painted here momentaryLabel_.setBounds(labelArea.removeFromTop(14)); shortTermLabel_.setBounds(labelArea.removeFromTop(14)); diff --git a/src/app/ui/HeroWaveform.cpp b/src/app/ui/HeroWaveform.cpp index 8cb2dc7..a966704 100644 --- a/src/app/ui/HeroWaveform.cpp +++ b/src/app/ui/HeroWaveform.cpp @@ -297,7 +297,8 @@ void HeroWaveform::drawZoomControls(juce::Graphics& g) { }; drawBtn(0, "+", zoomInHover_); - drawBtn(1, "\u2212", zoomOutHover_); + // UTF-8 bytes, not "\u2212": MSVC turns that escape into "?" in a narrow string. + drawBtn(1, juce::String(juce::CharPointer_UTF8("\xe2\x88\x92")), zoomOutHover_); drawBtn(2, "R", zoomResetHover_); // Zoom level text