From 294472b72b389c61849c8b90a3c150513eaf3697 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Fri, 15 May 2026 09:55:51 +0530 Subject: [PATCH 1/4] chore: Improved midi editor ux, improved app crash conditions --- .gitignore | 3 + AGENTS.md | 15 + Source/AudioEngine.cpp | 1616 +++- Source/AudioEngine.h | 20 +- Source/AutomationList.cpp | 234 +- Source/AutomationList.h | 89 +- Source/Main.cpp | 194 + Source/MainComponent.cpp | 362 +- Source/MainComponent.h | 14 +- Source/MixerWindowManager.cpp | 78 +- Source/MixerWindowManager.h | 13 +- Source/StemSeparator.cpp | 500 +- Source/StemSeparator.h | 42 + Source/TrackProcessor.cpp | 1084 ++- Source/TrackProcessor.h | 115 +- WORKFLOWS.md | 13 + build.py | 2 +- frontend/src/App.tsx | 465 +- frontend/src/MidiEditorWindowApp.tsx | 189 + frontend/src/MixerWindowApp.tsx | 43 +- .../__tests__/aiToolsFeatureInstaller.test.ts | 32 + .../src/__tests__/automationParams.test.ts | 26 +- .../__tests__/interactionSafetyGuards.test.ts | 55 + frontend/src/components/AITrackHeader.tsx | 26 +- frontend/src/components/AIWorkflowModal.tsx | 24 +- frontend/src/components/AiToolsSetupModal.tsx | 246 +- frontend/src/components/ChannelStrip.tsx | 2 +- frontend/src/components/CommandPalette.tsx | 4 + frontend/src/components/ContextMenu.tsx | 23 +- frontend/src/components/CorrectPitchModal.tsx | 9 +- .../src/components/EnvelopeManagerModal.tsx | 14 +- frontend/src/components/ErrorBoundary.tsx | 10 +- frontend/src/components/FXChainPanel.css | 20 + frontend/src/components/FXChainPanel.tsx | 1483 +++- .../src/components/GettingStartedGuide.tsx | 11 +- frontend/src/components/HelpOverlay.tsx | 10 +- .../src/components/MIDIDeviceSelector.tsx | 48 +- frontend/src/components/MIDIFXControls.tsx | 307 + frontend/src/components/MainToolbar.tsx | 22 +- frontend/src/components/MenuBar.tsx | 9 +- frontend/src/components/MetronomeSettings.tsx | 11 +- frontend/src/components/PianoRoll.css | 994 ++- frontend/src/components/PianoRoll.tsx | 4826 ++++++++++- .../PianoRollControllerLaneSection.tsx | 119 + frontend/src/components/PianoRollInfoLine.tsx | 45 + .../components/PianoRollInspectorSummary.tsx | 36 + .../components/PianoRollLaneEditorSection.tsx | 92 + .../PianoRollNoteInspectorSection.tsx | 79 + .../components/PianoRollPitchBendSection.tsx | 56 + .../src/components/PianoRollStatusStrip.tsx | 30 + frontend/src/components/PianoRollToolbar.tsx | 736 ++ frontend/src/components/PitchEditorCanvas.ts | 5 +- .../src/components/PitchEditorLowerZone.tsx | 26 +- frontend/src/components/PluginBrowser.tsx | 51 +- frontend/src/components/PreferencesModal.tsx | 48 +- frontend/src/components/ScriptEditor.tsx | 7 +- frontend/src/components/SettingsModal.tsx | 55 +- .../src/components/StemSeparationModal.tsx | 68 +- frontend/src/components/Timeline.tsx | 2087 ++++- frontend/src/components/TrackHeader.tsx | 198 +- .../src/components/menus/MenuDropdown.tsx | 2 +- frontend/src/components/ui/Modal/Modal.tsx | 14 +- frontend/src/main.tsx | 96 +- frontend/src/services/NativeBridge.ts | 357 +- frontend/src/store/actionRegistry.ts | 19 +- frontend/src/store/actions/automation.ts | 563 +- frontend/src/store/actions/clipEditing.ts | 836 +- frontend/src/store/actions/clips.ts | 2 +- frontend/src/store/actions/midi.ts | 1553 +++- frontend/src/store/actions/project.ts | 191 +- frontend/src/store/actions/rendering.ts | 37 +- frontend/src/store/actions/routing.ts | 57 +- frontend/src/store/actions/storeHelpers.ts | 7 + frontend/src/store/actions/tracks.ts | 185 +- frontend/src/store/actions/transport.ts | 131 +- frontend/src/store/actions/uiState.ts | 5 +- frontend/src/store/automationParams.ts | 138 +- frontend/src/store/useDAWStore.ts | 474 +- .../src/utils/globalShortcutDispatcher.ts | 76 +- frontend/src/utils/midiClipSerialization.ts | 467 +- frontend/src/utils/midiControllerLanes.ts | 319 + frontend/src/utils/midiEditorWindowSync.ts | 513 ++ frontend/src/utils/midiNotes.ts | 396 + frontend/src/utils/modalEventGuards.ts | 53 + frontend/src/utils/pianoRollInteraction.ts | 177 + frontend/src/utils/pianoRollLanes.ts | 50 + frontend/src/utils/pianoRollPitch.ts | 39 + frontend/src/utils/pianoRollTools.ts | 40 + frontend/src/utils/pianoRollVelocity.ts | 40 + frontend/src/utils/responsiveToolbar.ts | 79 + frontend/src/utils/sharedTransportSync.ts | 62 + frontend/src/utils/timelineClipGestures.ts | 87 + frontend/src/utils/timelineClipHitTest.ts | 104 + frontend/src/utils/timelineGeometry.ts | 40 + frontend/src/utils/windowEnvironment.ts | 1 + midi_editor_reference_matrix.md | 102 + midi_instrument_editor_feat_plan.md | 379 + tests/test_install_ai_tools_feature_gating.py | 133 + tools/ai-runtime-install-plan-linux-cuda.json | 6 +- tools/ai-runtime-install-plan-linux-rocm.json | 6 +- .../ai-runtime-install-plan-windows-cuda.json | 7 + ...runtime-install-plan-windows-directml.json | 3 + tools/install_ai_tools.py | 494 +- tools/midi-editor-acceptance-harness.mjs | 7495 +++++++++++++++++ 104 files changed, 30240 insertions(+), 2636 deletions(-) create mode 100644 frontend/src/MidiEditorWindowApp.tsx create mode 100644 frontend/src/__tests__/aiToolsFeatureInstaller.test.ts create mode 100644 frontend/src/__tests__/interactionSafetyGuards.test.ts create mode 100644 frontend/src/components/MIDIFXControls.tsx create mode 100644 frontend/src/components/PianoRollControllerLaneSection.tsx create mode 100644 frontend/src/components/PianoRollInfoLine.tsx create mode 100644 frontend/src/components/PianoRollInspectorSummary.tsx create mode 100644 frontend/src/components/PianoRollLaneEditorSection.tsx create mode 100644 frontend/src/components/PianoRollNoteInspectorSection.tsx create mode 100644 frontend/src/components/PianoRollPitchBendSection.tsx create mode 100644 frontend/src/components/PianoRollStatusStrip.tsx create mode 100644 frontend/src/components/PianoRollToolbar.tsx create mode 100644 frontend/src/utils/midiControllerLanes.ts create mode 100644 frontend/src/utils/midiEditorWindowSync.ts create mode 100644 frontend/src/utils/midiNotes.ts create mode 100644 frontend/src/utils/modalEventGuards.ts create mode 100644 frontend/src/utils/pianoRollInteraction.ts create mode 100644 frontend/src/utils/pianoRollLanes.ts create mode 100644 frontend/src/utils/pianoRollPitch.ts create mode 100644 frontend/src/utils/pianoRollTools.ts create mode 100644 frontend/src/utils/pianoRollVelocity.ts create mode 100644 frontend/src/utils/responsiveToolbar.ts create mode 100644 frontend/src/utils/sharedTransportSync.ts create mode 100644 frontend/src/utils/timelineClipGestures.ts create mode 100644 frontend/src/utils/timelineClipHitTest.ts create mode 100644 frontend/src/utils/timelineGeometry.ts create mode 100644 midi_editor_reference_matrix.md create mode 100644 midi_instrument_editor_feat_plan.md create mode 100644 tests/test_install_ai_tools_feature_gating.py create mode 100644 tools/midi-editor-acceptance-harness.mjs diff --git a/.gitignore b/.gitignore index 4896094..6a1618c 100644 --- a/.gitignore +++ b/.gitignore @@ -142,6 +142,9 @@ get-pip.py # Audio test samples /audio_test_samples/ +# Local QA screenshots/reports generated by browser and native harnesses +/qa/ + # Logs *.log npm-debug.log* diff --git a/AGENTS.md b/AGENTS.md index 207c02d..a93a64d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,6 +165,21 @@ python build.py prod **No feature flags** — all features (ASIO, WASAPI, DirectSound, VST3 hosting, WebView2) are always enabled via hardcoded `target_compile_definitions` in CMakeLists.txt. The `build.py dev` mode uses Debug config; `build.py prod` uses Release. +### Manual Testing Handoff Requirement + +When handing work to the user for manual testing, assume the user will run exactly: + +```bash +python build.py dev --run +``` + +Before asking for manual testing: +- Make sure the latest frontend code is built into `frontend/dist` when packaged fallback could be used. +- Run `cmake --build build --config Debug` after frontend or C++ changes so the Debug app and copied `webui` assets are current. +- Do not require the user to pre-run Vite, npm, or any other server. `python build.py dev --run` must start what it needs. +- Stop any Codex-started dev servers, harness browsers, or background Vite/npm processes before handing off. Verify port `5173` is not left occupied by a Codex-started process. +- In the handoff, state that the CMake Debug build was completed and that no pre-running server is required. + ## Key Technical Details ### State Management diff --git a/Source/AudioEngine.cpp b/Source/AudioEngine.cpp index 4226206..0b271b5 100644 --- a/Source/AudioEngine.cpp +++ b/Source/AudioEngine.cpp @@ -6,6 +6,7 @@ #include "PitchAnalyzer.h" #include "PitchResynthesizer.h" #include "CrashDiagnostics.h" +#include namespace { @@ -3644,7 +3645,10 @@ void AudioEngine::applyMasterGainPanMono (float* const* outputChannelData, float leftGain = cachedMasterPanL.load (std::memory_order_relaxed); float rightGain = cachedMasterPanR.load (std::memory_order_relaxed); - if (masterPanAutomation.shouldPlayback()) + const bool forceAutomationRead = isRendering.load(std::memory_order_relaxed); + if ((forceAutomationRead ? masterPanAutomation.shouldPlaybackForRead() + : masterPanAutomation.shouldPlayback()) + && masterPanAutomation.getNumPoints() > 0) { float autoPan = masterPanAutomation.eval (currentTimeSeconds); computePanLawGains (currentPanLaw, autoPan, 1.0f, leftGain, rightGain); @@ -3662,7 +3666,10 @@ void AudioEngine::applyMasterGainPanMono (float* const* outputChannelData, // Master Volume (with automation) { float effectiveMasterVol = masterVolume; - if (masterVolumeAutomation.shouldPlayback()) + const bool forceAutomationRead = isRendering.load(std::memory_order_relaxed); + if ((forceAutomationRead ? masterVolumeAutomation.shouldPlaybackForRead() + : masterVolumeAutomation.shouldPlayback()) + && masterVolumeAutomation.getNumPoints() > 0) { float autoDb = masterVolumeAutomation.eval (currentTimeSeconds); effectiveMasterVol = (autoDb <= -60.0f) ? 0.0f : std::pow (10.0f, autoDb / 20.0f); @@ -3923,8 +3930,11 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha const bool isMidiTrack = track->getTrackType() == TrackType::MIDI || track->getTrackType() == TrackType::Instrument; + const bool muteAutomationCanUnmuteLive = track->getMuteAutomation().shouldPlayback() + && track->getMuteAutomation().getNumPoints() > 0; + const bool staticMuteBlocksInput = track->getMute() && !muteAutomationCanUnmuteLive; const bool shouldReadHardwareInput = !isMidiTrack - && !track->getMute() + && !staticMuteBlocksInput && (track->getRecordArmed() || track->getInputMonitoring() || audioRecorder.isRecording(trackId)); @@ -4180,7 +4190,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha // ========== MIDI OUTPUT: route MIDI to hardware output ========== if (!midiMessages.isEmpty() && track->getMIDIOutputDeviceName().isNotEmpty()) - track->sendMIDIToOutput(midiMessages); + track->sendMIDIToOutput(midiMessages, currentSampleRate, track->getMute()); // Mix track output to device outputs (only if master send is enabled) if (track->getMasterSendEnabled()) @@ -4386,7 +4396,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha audioCallbackDeadlineMissCount.fetch_add (1, std::memory_order_relaxed); } -juce::String AudioEngine::addTrack(const juce::String& explicitId) +juce::String AudioEngine::addTrack(const juce::String& explicitId, const juce::String& initialType) { logToDisk("AudioEngine: Adding Track..."); @@ -4408,6 +4418,11 @@ juce::String AudioEngine::addTrack(const juce::String& explicitId) auto newTrack = std::make_unique(); auto* rawTrackPtr = newTrack.get(); // Keep raw pointer for metering (owned by graph) + if (initialType == "midi") + rawTrackPtr->setTrackType(TrackType::MIDI); + else if (initialType == "instrument") + rawTrackPtr->setTrackType(TrackType::Instrument); + rawTrackPtr->setARAPlaybackRequestHandlers({ [this]() { @@ -4458,11 +4473,13 @@ juce::String AudioEngine::addTrack(const juce::String& explicitId) int scBlockSize = getSafeHostedPluginBlockSize(currentBlockSize); auto sidechainBuffer = std::make_shared>(); sidechainBuffer->setSize(2, scBlockSize); + sidechainBuffer->clear(); sidechainOutputBuffers[trackId] = sidechainBuffer; // Pre-allocate send accumulation buffer for this track auto sendBuffer = std::make_shared>(); sendBuffer->setSize(2, scBlockSize); + sendBuffer->clear(); sendAccumBuffers[trackId] = sendBuffer; rebuildRealtimeProcessingSnapshots(); @@ -4685,7 +4702,8 @@ juce::var AudioEngine::getOpenMIDIDevices() void AudioEngine::setTrackType(const juce::String& trackId, const juce::String& type) { - if (trackMap.find(trackId) == trackMap.end()) + auto it = trackMap.find(trackId); + if (it == trackMap.end() || it->second == nullptr) return; TrackType trackType = TrackType::Audio; @@ -4695,7 +4713,8 @@ void AudioEngine::setTrackType(const juce::String& trackId, const juce::String& else if (type == "instrument") trackType = TrackType::Instrument; - trackMap[trackId]->setTrackType(trackType); + queueAllNotesOffForTrack(*it->second); + it->second->setTrackType(trackType); juce::Logger::writeToLog("AudioEngine: Track " + trackId + " type set to: " + type); } @@ -4752,15 +4771,21 @@ void AudioEngine::setTrackMIDIClips(const juce::String& trackId, const juce::Str juce::MidiMessage message; if (eventType == "noteOn") { - const int note = static_cast(eventObj->getProperty("note")); + const int note = juce::jlimit(0, 127, static_cast(eventObj->getProperty("note"))); const int velocity = static_cast(eventObj->getProperty("velocity")); message = juce::MidiMessage::noteOn(channel, note, static_cast(juce::jlimit(0, 127, velocity))); } else if (eventType == "noteOff") { - const int note = static_cast(eventObj->getProperty("note")); - message = juce::MidiMessage::noteOff(channel, note); + const int note = juce::jlimit(0, 127, static_cast(eventObj->getProperty("note"))); + const int velocity = eventObj->hasProperty("releaseVelocity") + ? static_cast(eventObj->getProperty("releaseVelocity")) + : (eventObj->hasProperty("velocity") + ? static_cast(eventObj->getProperty("velocity")) + : 0); + message = juce::MidiMessage::noteOff(channel, note, + static_cast(juce::jlimit(0, 127, velocity))); } else if (eventType == "cc") { @@ -4776,6 +4801,32 @@ void AudioEngine::setTrackMIDIClips(const juce::String& trackId, const juce::Str : 8192; message = juce::MidiMessage::pitchWheel(channel, juce::jlimit(0, 16383, value)); } + else if (eventType == "programChange") + { + const int program = eventObj->hasProperty("value") + ? static_cast(eventObj->getProperty("value")) + : 0; + message = juce::MidiMessage::programChange(channel, juce::jlimit(0, 127, program)); + } + else if (eventType == "channelPressure") + { + const int pressure = eventObj->hasProperty("value") + ? static_cast(eventObj->getProperty("value")) + : 0; + message = juce::MidiMessage::channelPressureChange(channel, juce::jlimit(0, 127, pressure)); + } + else if (eventType == "polyPressure") + { + const int note = eventObj->hasProperty("note") + ? static_cast(eventObj->getProperty("note")) + : 60; + const int pressure = eventObj->hasProperty("value") + ? static_cast(eventObj->getProperty("value")) + : 0; + message = juce::MidiMessage::aftertouchChange(channel, + juce::jlimit(0, 127, note), + juce::jlimit(0, 127, pressure)); + } else { continue; @@ -4794,6 +4845,7 @@ void AudioEngine::setTrackMIDIClips(const juce::String& trackId, const juce::Str } } + queueAllNotesOffForTrack(*it->second); it->second->setScheduledMIDIClips(std::move(clips)); } @@ -4840,6 +4892,35 @@ bool AudioEngine::sendMidiNote(const juce::String& trackId, int note, int veloci return queued; } +juce::var AudioEngine::getTrackMIDINoteActivity(const juce::String& trackId, int maxAgeMs) const +{ + juce::Array result; + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return result; + + const auto activities = it->second->getRecentMIDINoteActivity( + static_cast(juce::jlimit(1, 5000, maxAgeMs))); + for (const auto& activity : activities) + { + auto* object = new juce::DynamicObject(); + object->setProperty("note", activity.note); + object->setProperty("channel", activity.channel); + object->setProperty("velocity", activity.velocity); + object->setProperty("active", activity.active); + object->setProperty("ageMs", static_cast(activity.ageMs)); + result.add(juce::var(object)); + } + return result; +} + +bool AudioEngine::panicMIDI() +{ + queueAllNotesOffForAllTracks(false); + juce::Logger::writeToLog("AudioEngine: MIDI panic queued for all MIDI/instrument tracks"); + return true; +} + bool AudioEngine::loadInstrument(const juce::String& trackId, const juce::String& vstPath) { if (trackMap.find(trackId) == trackMap.end()) @@ -4884,6 +4965,94 @@ bool AudioEngine::loadInstrument(const juce::String& trackId, const juce::String return true; } +bool AudioEngine::removeInstrument(const juce::String& trackId) +{ + auto it = trackMap.find(trackId); + if (it == trackMap.end() || it->second == nullptr) + return false; + + auto* track = it->second; + if (auto* instrument = track->getInstrument()) + pluginWindowManager.closeEditorSync(instrument); + + queueAllNotesOffForTrack(*track); + track->clearInstrument(); + track->setTrackType(track->hasFallbackSamplerSample() ? TrackType::Instrument : TrackType::MIDI); + + juce::Logger::writeToLog("AudioEngine: Removed instrument from track " + trackId); + return true; +} + +bool AudioEngine::setTrackSamplerSample(const juce::String& trackId, const juce::String& samplePath, int rootNote) +{ + auto it = trackMap.find(trackId); + if (it == trackMap.end() || it->second == nullptr) + return false; + + auto* track = it->second; + if (!track->loadFallbackSamplerSample(samplePath, rootNote)) + return false; + + track->setTrackType(TrackType::Instrument); + juce::Logger::writeToLog("AudioEngine: Loaded fallback sampler sample on track " + trackId + ": " + samplePath); + return true; +} + +bool AudioEngine::clearTrackSamplerSample(const juce::String& trackId) +{ + auto it = trackMap.find(trackId); + if (it == trackMap.end() || it->second == nullptr) + return false; + + it->second->clearFallbackSamplerSample(); + juce::Logger::writeToLog("AudioEngine: Cleared fallback sampler sample on track " + trackId); + return true; +} + +juce::String AudioEngine::getInstrumentState(const juce::String& trackId) +{ + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto it = trackMap.find(trackId); + if (it == trackMap.end() || it->second == nullptr) + return {}; + + auto* processor = it->second->getInstrument(); + if (processor == nullptr) + return {}; + + juce::MemoryBlock stateData; + { + const juce::ScopedLock processorLock(processor->getCallbackLock()); + processor->getStateInformation(stateData); + } + + return stateData.toBase64Encoding(); +} + +bool AudioEngine::setInstrumentState(const juce::String& trackId, const juce::String& base64State) +{ + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto it = trackMap.find(trackId); + if (it == trackMap.end() || it->second == nullptr) + return false; + + auto* processor = it->second->getInstrument(); + if (processor == nullptr) + return false; + + juce::MemoryBlock stateData; + if (!stateData.fromBase64Encoding(base64State)) + return false; + + { + const juce::ScopedLock processorLock(processor->getCallbackLock()); + processor->setStateInformation(stateData.getData(), static_cast(stateData.getSize())); + } + + juce::Logger::writeToLog("AudioEngine: Restored instrument state for track " + trackId); + return true; +} + //============================================================================== // MIDI Message Routing (Phase 2) @@ -5054,19 +5223,19 @@ juce::MidiBuffer AudioEngine::buildTrackMidiBlock(const juce::String& trackId, d return midiMessages; } -void AudioEngine::queueAllNotesOffForTrack(TrackProcessor& track) +void AudioEngine::queueAllNotesOffForTrack(TrackProcessor& track, bool requestChase) { if (track.getTrackType() == TrackType::MIDI || track.getTrackType() == TrackType::Instrument) - track.queueAllNotesOff(); + track.queueAllNotesOff(requestChase); } -void AudioEngine::queueAllNotesOffForAllTracks() +void AudioEngine::queueAllNotesOffForAllTracks(bool requestChase) { for (const auto& [trackId, track] : trackMap) { juce::ignoreUnused(trackId); if (track) - queueAllNotesOffForTrack(*track); + queueAllNotesOffForTrack(*track, requestChase); } } @@ -5514,6 +5683,14 @@ void AudioEngine::setTransportPlaying(bool playing) logToDisk("Transport STOP at position: " + juce::String(currentSamplePosition / currentSampleRate) + "s"); const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); queueAllNotesOffForAllTracks(); + masterVolumeAutomation.resetTouchAndLatch(); + masterPanAutomation.resetTouchAndLatch(); + for (auto const& [trackId, track] : trackMap) + { + juce::ignoreUnused(trackId); + if (track != nullptr) + track->resetAutomationTouchState(); + } playbackEngine.commitAllDeferredClipAudioFiles(); // Don't reset position here - let the stop() action control that } @@ -5607,9 +5784,27 @@ void AudioEngine::setTransportRecording(bool recording) if (trackType == TrackType::MIDI || trackType == TrackType::Instrument) { + if (midiManager) + { + const auto midiInputDevice = track->getMIDIInputDevice(); + if (midiInputDevice.isNotEmpty()) + { + midiManager->openDevice(midiInputDevice); + } + else + { + const auto availableMidiInputs = midiManager->getAvailableDevices(); + for (const auto& inputDevice : availableMidiInputs) + midiManager->openDevice(inputDevice); + } + } + // MIDI recording — in-memory accumulation logToDisk("Track " + trackId + " IS ARMED (MIDI). Starting MIDI record..."); midiRecorder.startRecording(trackId, currentSampleRate); + midiRecorder.setRecordingStartTime( + trackId, + currentSampleRate > 0.0 ? currentSamplePosition / currentSampleRate : 0.0); anyAudioStarted = true; // Reuse flag for pending start capture } else @@ -6303,68 +6498,898 @@ juce::var AudioEngine::runAutomatedRegressionSuite() hybridAvg = avgMs; } } - else + else + { + modesComparable = false; + } + + modesComparable = modesComparable && floatAvg >= 0.0 && hybridAvg >= 0.0; + addSuite("precision_benchmark_matrix", modesComparable, + "float32AvgMs=" + juce::String(floatAvg) + ", hybrid64AvgMs=" + juce::String(hybridAvg)); + } + else + { + addSuite("precision_benchmark_matrix", false, "Benchmark payload missing"); + } + + auto compatibility = guardrailObj->getProperty("compatibility"); + if (auto* compatObj = compatibility.getDynamicObject()) + { + auto plugins = compatObj->getProperty("plugins"); + bool metadataConsistent = true; + int pluginCount = 0; + int doubleCapableCount = 0; + if (plugins.isArray()) + { + pluginCount = plugins.getArray()->size(); + for (const auto& plugin : *plugins.getArray()) + { + auto* pluginObj = plugin.getDynamicObject(); + if (pluginObj == nullptr) + { + metadataConsistent = false; + continue; + } + + const bool supportsDouble = static_cast(pluginObj->getProperty("supportsDoublePrecision")); + const juce::String pluginFormat = pluginObj->getProperty("pluginFormat").toString(); + if (pluginFormat.isEmpty()) + metadataConsistent = false; + if (supportsDouble) + ++doubleCapableCount; + } + } + else + { + metadataConsistent = false; + } + + addSuite("plugin_capability_matrix", metadataConsistent, + "pluginCount=" + juce::String(pluginCount) + + ", doubleCapable=" + juce::String(doubleCapableCount)); + } + else + { + addSuite("plugin_capability_matrix", false, "Compatibility payload missing"); + } + } + else + { + addSuite("release_guardrails", false, "Guardrail payload missing"); + addSuite("precision_benchmark_matrix", false, "Guardrail payload missing"); + addSuite("plugin_capability_matrix", false, "Guardrail payload missing"); + } + + const juce::String midiFixtureTrackId = "midi_scheduler_regression_" + juce::Uuid().toString(); + bool midiFixturePass = false; + juce::String midiFixtureDetail; + if (addTrack(midiFixtureTrackId, "midi").isNotEmpty()) + { + setTrackMIDIClips(midiFixtureTrackId, + R"json([{"id":"midi-regression-clip","startTime":0.0,"duration":1.0,"events":[{"type":"noteOn","timestamp":0.0,"note":60,"velocity":100,"channel":1},{"type":"cc","timestamp":0.01,"controller":1,"value":96,"channel":1},{"type":"pitchBend","timestamp":0.02,"value":12288,"channel":1},{"type":"programChange","timestamp":0.03,"value":5,"channel":1},{"type":"channelPressure","timestamp":0.04,"value":42,"channel":1},{"type":"polyPressure","timestamp":0.05,"note":60,"value":55,"channel":1},{"type":"noteOff","timestamp":0.25,"note":60,"velocity":0,"channel":1}]}])json"); + + auto it = trackMap.find(midiFixtureTrackId); + if (it != trackMap.end() && it->second != nullptr) + { + auto* track = it->second; + const int blockSize = juce::jmax(512, currentBlockSize); + const double sampleRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + juce::AudioBuffer fixtureBuffer(2, blockSize); + fixtureBuffer.clear(); + juce::MidiBuffer fixtureMidi; + track->buildMidiBuffer(fixtureMidi, 0.0, blockSize, sampleRate, true); + track->processBlock(fixtureBuffer, fixtureMidi); + + const float outputPeak = fixtureBuffer.getMagnitude(0, fixtureBuffer.getNumSamples()); + bool finite = true; + for (int channel = 0; channel < fixtureBuffer.getNumChannels(); ++channel) + { + auto* samples = fixtureBuffer.getReadPointer(channel); + for (int sample = 0; sample < fixtureBuffer.getNumSamples(); ++sample) + { + if (!std::isfinite(samples[sample])) + { + finite = false; + break; + } + } + if (!finite) + break; + } + + const int scheduledClips = track->getScheduledMIDIClipCount(); + const int scheduledEvents = track->getScheduledMIDIEventCount(); + const int builtEvents = track->getLastBuiltMidiEventCount(); + midiFixturePass = scheduledClips == 1 + && scheduledEvents == 7 + && builtEvents > 0 + && finite + && outputPeak <= 1.0e-6f; + midiFixtureDetail = "scheduledClips=" + juce::String(scheduledClips) + + ", scheduledEvents=" + juce::String(scheduledEvents) + + ", builtEvents=" + juce::String(builtEvents) + + ", finite=" + juce::String(finite ? "true" : "false") + + ", outputPeak=" + juce::String(outputPeak, 8); + } + else + { + midiFixtureDetail = "Temporary MIDI regression track missing after addTrack"; + } + + removeTrack(midiFixtureTrackId); + } + else + { + midiFixtureDetail = "Failed to create temporary MIDI regression track"; + } + addSuite("midi_scheduler_fixture", midiFixturePass, midiFixtureDetail); + + const juce::String midiChaseTrackId = "midi_chase_regression_" + juce::Uuid().toString(); + bool midiChasePass = false; + juce::String midiChaseDetail; + if (addTrack(midiChaseTrackId, "midi").isNotEmpty()) + { + setTrackMIDIClips(midiChaseTrackId, + R"json([{"id":"midi-chase-regression-clip","startTime":0.0,"duration":2.0,"events":[{"type":"programChange","timestamp":0.02,"value":5,"channel":1},{"type":"cc","timestamp":0.08,"controller":0,"value":4,"channel":1},{"type":"cc","timestamp":0.09,"controller":32,"value":64,"channel":1},{"type":"noteOn","timestamp":0.10,"note":60,"velocity":100,"channel":1},{"type":"cc","timestamp":0.20,"controller":1,"value":96,"channel":1},{"type":"pitchBend","timestamp":0.25,"value":12288,"channel":1},{"type":"channelPressure","timestamp":0.30,"value":42,"channel":1},{"type":"noteOff","timestamp":1.50,"note":60,"velocity":0,"channel":1}]}])json"); + + auto it = trackMap.find(midiChaseTrackId); + if (it != trackMap.end() && it->second != nullptr) + { + auto* track = it->second; + const int blockSize = juce::jmax(512, currentBlockSize); + const double sampleRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + juce::MidiBuffer drainQueuedResetMidi; + track->buildMidiBuffer(drainQueuedResetMidi, 0.0, blockSize, sampleRate, false); + juce::MidiBuffer chaseMidi; + track->requestMIDIChase(); + track->buildMidiBuffer(chaseMidi, 0.75, blockSize, sampleRate, true); + + bool allAtSampleZero = chaseMidi.getNumEvents() > 0; + bool hasProgram = false; + bool hasPitchBend = false; + bool hasBankMSB = false; + bool hasBankLSB = false; + bool hasController = false; + bool hasChannelPressure = false; + bool hasActiveNote = false; + int pitchBendValue = -1; + int builtEventCount = track->getLastBuiltMidiEventCount(); + + for (const auto metadata : chaseMidi) + { + allAtSampleZero = allAtSampleZero && metadata.samplePosition == 0; + const auto message = metadata.getMessage(); + if (message.isProgramChange() && message.getChannel() == 1 && message.getProgramChangeNumber() == 5) + hasProgram = true; + else if (message.isPitchWheel() && message.getChannel() == 1) + { + pitchBendValue = message.getPitchWheelValue(); + hasPitchBend = pitchBendValue == 12288; + } + else if (message.isController() && message.getChannel() == 1 + && message.getControllerNumber() == 0 && message.getControllerValue() == 4) + { + hasBankMSB = true; + } + else if (message.isController() && message.getChannel() == 1 + && message.getControllerNumber() == 32 && message.getControllerValue() == 64) + { + hasBankLSB = true; + } + else if (message.isController() && message.getChannel() == 1 + && message.getControllerNumber() == 1 && message.getControllerValue() == 96) + { + hasController = true; + } + else if (message.isChannelPressure() && message.getChannel() == 1 + && message.getChannelPressureValue() == 42) + { + hasChannelPressure = true; + } + else if (message.isNoteOn() && message.getChannel() == 1 + && message.getNoteNumber() == 60 && message.getVelocity() > 0.0f) + { + hasActiveNote = true; + } + } + + midiChasePass = allAtSampleZero + && hasProgram + && hasPitchBend + && hasBankMSB + && hasBankLSB + && hasController + && hasChannelPressure + && hasActiveNote + && chaseMidi.getNumEvents() >= 7 + && builtEventCount >= 7; + midiChaseDetail = "events=" + juce::String(chaseMidi.getNumEvents()) + + ", builtEvents=" + juce::String(builtEventCount) + + ", allAtSampleZero=" + juce::String(allAtSampleZero ? "true" : "false") + + ", program=" + juce::String(hasProgram ? "true" : "false") + + ", pitchBendValue=" + juce::String(pitchBendValue) + + ", bankMSB=" + juce::String(hasBankMSB ? "true" : "false") + + ", bankLSB=" + juce::String(hasBankLSB ? "true" : "false") + + ", cc1=" + juce::String(hasController ? "true" : "false") + + ", channelPressure=" + juce::String(hasChannelPressure ? "true" : "false") + + ", activeNote=" + juce::String(hasActiveNote ? "true" : "false"); + } + else + { + midiChaseDetail = "Temporary MIDI chase regression track missing after addTrack"; + } + + removeTrack(midiChaseTrackId); + } + else + { + midiChaseDetail = "Failed to create temporary MIDI chase regression track"; + } + addSuite("midi_pitchbend_chase_fixture", midiChasePass, midiChaseDetail); + + const juce::String fallbackInstrumentTrackId = "instrument_fallback_regression_" + juce::Uuid().toString(); + bool fallbackInstrumentPass = false; + juce::String fallbackInstrumentDetail; + if (addTrack(fallbackInstrumentTrackId, "instrument").isNotEmpty()) + { + setTrackMIDIClips(fallbackInstrumentTrackId, + R"json([{"id":"instrument-fallback-clip","startTime":0.0,"duration":1.0,"events":[{"type":"noteOn","timestamp":0.0,"note":60,"velocity":127,"channel":1},{"type":"noteOff","timestamp":0.4,"note":60,"velocity":0,"channel":1}]}])json"); + + auto it = trackMap.find(fallbackInstrumentTrackId); + if (it != trackMap.end() && it->second != nullptr) + { + auto* track = it->second; + const int blockSize = juce::jmax(512, currentBlockSize); + const double sampleRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + juce::AudioBuffer fixtureBuffer(2, blockSize); + fixtureBuffer.clear(); + juce::MidiBuffer fixtureMidi; + track->buildMidiBuffer(fixtureMidi, 0.0, blockSize, sampleRate, true); + track->processBlock(fixtureBuffer, fixtureMidi); + + float outputPeak = 0.0f; + bool finite = true; + for (int channel = 0; channel < fixtureBuffer.getNumChannels(); ++channel) + { + outputPeak = juce::jmax(outputPeak, fixtureBuffer.getMagnitude(channel, 0, fixtureBuffer.getNumSamples())); + auto* samples = fixtureBuffer.getReadPointer(channel); + for (int sample = 0; sample < fixtureBuffer.getNumSamples(); ++sample) + { + if (!std::isfinite(samples[sample])) + { + finite = false; + break; + } + } + if (!finite) + break; + } + + fallbackInstrumentPass = track->isUsingFallbackInstrument() + && track->getInstrument() == nullptr + && finite + && outputPeak > 1.0e-5f + && outputPeak < 0.75f; + fallbackInstrumentDetail = "fallbackActive=" + juce::String(track->isUsingFallbackInstrument() ? "true" : "false") + + ", finite=" + juce::String(finite ? "true" : "false") + + ", outputPeak=" + juce::String(outputPeak, 8) + + ", builtEvents=" + juce::String(track->getLastBuiltMidiEventCount()); + } + else + { + fallbackInstrumentDetail = "Temporary instrument fallback regression track missing after addTrack"; + } + + removeTrack(fallbackInstrumentTrackId); + } + else + { + fallbackInstrumentDetail = "Failed to create temporary instrument fallback regression track"; + } + addSuite("instrument_fallback_synth_fixture", fallbackInstrumentPass, fallbackInstrumentDetail); + + const juce::String samplerTrackId = "instrument_sampler_regression_" + juce::Uuid().toString(); + bool samplerPass = false; + juce::String samplerDetail; + const double samplerFixtureRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + juce::File samplerFixtureFile = juce::File::getSpecialLocation(juce::File::tempDirectory) + .getChildFile("studio13_sampler_fixture_" + juce::Uuid().toString() + ".wav"); + { + juce::AudioBuffer sampleBuffer(1, static_cast(samplerFixtureRate * 0.1)); + for (int sample = 0; sample < sampleBuffer.getNumSamples(); ++sample) + { + const float t = static_cast(sample) / static_cast(samplerFixtureRate); + sampleBuffer.setSample(0, sample, std::sin(juce::MathConstants::twoPi * 440.0f * t) * 0.5f); + } + + juce::WavAudioFormat wavFormat; + std::unique_ptr stream(samplerFixtureFile.createOutputStream()); + if (stream) + { + std::unique_ptr writer( + wavFormat.createWriterFor(stream.get(), samplerFixtureRate, 1, 16, {}, 0)); + if (writer) + { + stream.release(); + writer->writeFromAudioSampleBuffer(sampleBuffer, 0, sampleBuffer.getNumSamples()); + } + } + } + + if (samplerFixtureFile.existsAsFile() && addTrack(samplerTrackId, "instrument").isNotEmpty()) + { + const bool sampleLoaded = setTrackSamplerSample(samplerTrackId, samplerFixtureFile.getFullPathName(), 69); + setTrackMIDIClips(samplerTrackId, + R"json([{"id":"instrument-sampler-clip","startTime":0.0,"duration":1.0,"events":[{"type":"noteOn","timestamp":0.0,"note":69,"velocity":127,"channel":1},{"type":"noteOff","timestamp":0.25,"note":69,"velocity":0,"channel":1}]}])json"); + + auto it = trackMap.find(samplerTrackId); + if (it != trackMap.end() && it->second != nullptr) + { + auto* track = it->second; + const int blockSize = juce::jmax(512, currentBlockSize); + juce::AudioBuffer fixtureBuffer(2, blockSize); + fixtureBuffer.clear(); + juce::MidiBuffer fixtureMidi; + track->buildMidiBuffer(fixtureMidi, 0.0, blockSize, samplerFixtureRate, true); + track->processBlock(fixtureBuffer, fixtureMidi); + + float outputPeak = 0.0f; + bool finite = true; + for (int channel = 0; channel < fixtureBuffer.getNumChannels(); ++channel) + { + outputPeak = juce::jmax(outputPeak, fixtureBuffer.getMagnitude(channel, 0, fixtureBuffer.getNumSamples())); + auto* samples = fixtureBuffer.getReadPointer(channel); + for (int sample = 0; sample < fixtureBuffer.getNumSamples(); ++sample) + { + if (!std::isfinite(samples[sample])) + { + finite = false; + break; + } + } + if (!finite) + break; + } + + samplerPass = sampleLoaded + && track->hasFallbackSamplerSample() + && finite + && outputPeak > 1.0e-5f + && outputPeak < 0.75f; + samplerDetail = "sampleLoaded=" + juce::String(sampleLoaded ? "true" : "false") + + ", samplerActive=" + juce::String(track->hasFallbackSamplerSample() ? "true" : "false") + + ", finite=" + juce::String(finite ? "true" : "false") + + ", outputPeak=" + juce::String(outputPeak, 8) + + ", builtEvents=" + juce::String(track->getLastBuiltMidiEventCount()); + } + else + { + samplerDetail = "Temporary sampler regression track missing after addTrack"; + } + + removeTrack(samplerTrackId); + } + else + { + samplerDetail = "Failed to create temporary sampler regression track or fixture file"; + } + samplerFixtureFile.deleteFile(); + addSuite("instrument_basic_sampler_fixture", samplerPass, samplerDetail); + + const juce::String savedReloadTrackId = "saved_reload_midi_regression_" + juce::Uuid().toString(); + bool savedReloadPass = false; + juce::String savedReloadDetail; + if (addTrack(savedReloadTrackId, "instrument").isNotEmpty()) + { + setTrackType(savedReloadTrackId, "instrument"); + setTrackMIDIClips(savedReloadTrackId, + R"json([{"id":"saved-reload-midi-clip","startTime":0.0,"duration":1.0,"events":[{"type":"programChange","timestamp":0.0,"value":0,"channel":1},{"type":"noteOn","timestamp":0.0,"note":64,"velocity":110,"channel":1},{"type":"cc","timestamp":0.02,"controller":1,"value":96,"channel":1},{"type":"pitchBend","timestamp":0.025,"value":12288,"channel":1},{"type":"channelPressure","timestamp":0.03,"value":42,"channel":1},{"type":"noteOff","timestamp":0.45,"note":64,"velocity":0,"channel":1}]}])json"); + + auto it = trackMap.find(savedReloadTrackId); + if (it != trackMap.end() && it->second != nullptr) + { + auto* track = it->second; + const int blockSize = juce::jmax(512, currentBlockSize); + const double sampleRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + juce::AudioBuffer fixtureBuffer(2, blockSize); + fixtureBuffer.clear(); + juce::MidiBuffer drainQueuedResetMidi; + track->buildMidiBuffer(drainQueuedResetMidi, 0.0, blockSize, sampleRate, false); + juce::MidiBuffer fixtureMidi; + track->buildMidiBuffer(fixtureMidi, 0.0, blockSize, sampleRate, true); + const int fixtureMidiEvents = fixtureMidi.getNumEvents(); + track->processBlock(fixtureBuffer, fixtureMidi); + + int nonFiniteCount = 0; + for (int channel = 0; channel < fixtureBuffer.getNumChannels(); ++channel) + { + const auto* samples = fixtureBuffer.getReadPointer(channel); + for (int sample = 0; sample < fixtureBuffer.getNumSamples(); ++sample) + { + if (!std::isfinite(samples[sample])) + ++nonFiniteCount; + } + } + + const float outputPeak = peakFromFloatBuffer(fixtureBuffer, fixtureBuffer.getNumSamples()); + const int scheduledClips = track->getScheduledMIDIClipCount(); + const int scheduledEvents = track->getScheduledMIDIEventCount(); + const int builtEvents = juce::jmax(track->getLastBuiltMidiEventCount(), fixtureMidiEvents); + savedReloadPass = track->getTrackType() == TrackType::Instrument + && track->isUsingFallbackInstrument() + && scheduledClips == 1 + && scheduledEvents == 6 + && builtEvents > 0 + && nonFiniteCount == 0 + && outputPeak > 1.0e-5f + && outputPeak < 0.75f; + savedReloadDetail = "typeOk=" + juce::String(track->getTrackType() == TrackType::Instrument ? "true" : "false") + + ", fallbackActive=" + juce::String(track->isUsingFallbackInstrument() ? "true" : "false") + + ", scheduledClips=" + juce::String(scheduledClips) + + ", scheduledEvents=" + juce::String(scheduledEvents) + + ", midiBufferEvents=" + juce::String(fixtureMidiEvents) + + ", builtEvents=" + juce::String(builtEvents) + + ", nonFinite=" + juce::String(nonFiniteCount) + + ", outputPeak=" + juce::String(outputPeak, 8); + } + else + { + savedReloadDetail = "Temporary saved-reload regression track missing after addTrack"; + } + + removeTrack(savedReloadTrackId); + } + else + { + savedReloadDetail = "Failed to create temporary saved-reload regression track"; + } + addSuite("saved_project_midi_reload_playback_fixture", savedReloadPass, savedReloadDetail); + + bool addTrackFirstBlockPass = true; + juce::StringArray addTrackFirstBlockDetails; + auto runAddTrackFirstBlockFixture = [this, &addTrackFirstBlockPass, &addTrackFirstBlockDetails] (const juce::String& type) + { + const juce::String trackId = "add_track_first_block_" + type + "_" + juce::Uuid().toString(); + const juce::String createdId = addTrack(trackId, type); + bool pass = false; + juce::String detail; + + if (createdId.isNotEmpty()) + { + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second != nullptr) + { + auto* track = it->second; + const int blockSize = juce::jmax(512, currentBlockSize); + const double sampleRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + juce::AudioBuffer fixtureBuffer(2, blockSize); + fixtureBuffer.clear(); + juce::MidiBuffer fixtureMidi; + track->prepareToPlay(sampleRate, blockSize); + track->processBlock(fixtureBuffer, fixtureMidi); + + int nonFiniteCount = 0; + for (int channel = 0; channel < fixtureBuffer.getNumChannels(); ++channel) + { + const auto* samples = fixtureBuffer.getReadPointer(channel); + for (int sample = 0; sample < fixtureBuffer.getNumSamples(); ++sample) + { + if (!std::isfinite(samples[sample])) + ++nonFiniteCount; + } + } + + const float outputPeak = peakFromFloatBuffer(fixtureBuffer, fixtureBuffer.getNumSamples()); + const TrackType expectedType = type == "instrument" ? TrackType::Instrument : TrackType::MIDI; + pass = track->getTrackType() == expectedType + && track->getScheduledMIDIClipCount() == 0 + && nonFiniteCount == 0 + && outputPeak <= 1.0e-6f; + detail = type + + ": typeOk=" + juce::String(track->getTrackType() == expectedType ? "true" : "false") + + ", scheduledClips=" + juce::String(track->getScheduledMIDIClipCount()) + + ", nonFinite=" + juce::String(nonFiniteCount) + + ", outputPeak=" + juce::String(outputPeak, 8); + } + else + { + detail = type + ": Temporary track missing after addTrack"; + } + + removeTrack(trackId); + } + else + { + detail = type + ": Failed to create temporary track"; + } + + addTrackFirstBlockPass = addTrackFirstBlockPass && pass; + addTrackFirstBlockDetails.add(detail); + }; + + runAddTrackFirstBlockFixture("midi"); + runAddTrackFirstBlockFixture("instrument"); + addSuite("add_track_first_block_silence_fixture", + addTrackFirstBlockPass, + addTrackFirstBlockDetails.joinIntoString("; ")); + + bool addTrackCallbackPass = true; + juce::StringArray addTrackCallbackDetails; + auto runAddTrackCallbackFixture = [this, &addTrackCallbackPass, &addTrackCallbackDetails] (const juce::String& type) + { + const juce::String trackId = "add_track_callback_" + type + "_" + juce::Uuid().toString(); + const juce::String createdId = addTrack(trackId, type); + bool pass = false; + juce::String detail; + + if (createdId.isNotEmpty()) + { + const int blockSize = juce::jmax(512, currentBlockSize); + juce::AudioBuffer callbackBuffer(2, blockSize); + callbackBuffer.clear(); + float* outputChannels[2] = { + callbackBuffer.getWritePointer(0), + callbackBuffer.getWritePointer(1), + }; + juce::AudioIODeviceCallbackContext callbackContext; + const bool previousPlaying = isPlaying.load(std::memory_order_acquire); + const bool previousRecording = isRecordMode.load(std::memory_order_acquire); + isPlaying.store(false, std::memory_order_release); + isRecordMode.store(false, std::memory_order_release); + audioDeviceIOCallbackWithContext(nullptr, 0, outputChannels, 2, blockSize, callbackContext); + isPlaying.store(previousPlaying, std::memory_order_release); + isRecordMode.store(previousRecording, std::memory_order_release); + + int nonFiniteCount = 0; + for (int channel = 0; channel < callbackBuffer.getNumChannels(); ++channel) + { + const auto* samples = callbackBuffer.getReadPointer(channel); + for (int sample = 0; sample < callbackBuffer.getNumSamples(); ++sample) + { + if (!std::isfinite(samples[sample])) + ++nonFiniteCount; + } + } + + auto it = trackMap.find(trackId); + const bool trackPresent = it != trackMap.end() && it->second != nullptr; + const TrackType expectedType = type == "instrument" ? TrackType::Instrument : TrackType::MIDI; + const bool typeOk = trackPresent && it->second->getTrackType() == expectedType; + const float outputPeak = peakFromFloatBuffer(callbackBuffer, callbackBuffer.getNumSamples()); + pass = trackPresent + && typeOk + && nonFiniteCount == 0 + && outputPeak <= 1.0e-6f; + detail = type + + ": trackPresent=" + juce::String(trackPresent ? "true" : "false") + + ", typeOk=" + juce::String(typeOk ? "true" : "false") + + ", nonFinite=" + juce::String(nonFiniteCount) + + ", outputPeak=" + juce::String(outputPeak, 8); + + removeTrack(trackId); + } + else + { + detail = type + ": Failed to create temporary track"; + } + + addTrackCallbackPass = addTrackCallbackPass && pass; + addTrackCallbackDetails.add(detail); + }; + + runAddTrackCallbackFixture("midi"); + runAddTrackCallbackFixture("instrument"); + addSuite("add_track_audio_callback_spike_fixture", + addTrackCallbackPass, + addTrackCallbackDetails.joinIntoString("; ")); + + const juce::String renderFreezeTrackId = "midi_render_freeze_regression_" + juce::Uuid().toString(); + bool renderFreezePass = false; + juce::String renderFreezeDetail; + if (addTrack(renderFreezeTrackId, "instrument").isNotEmpty()) + { + setTrackType(renderFreezeTrackId, "instrument"); + setTrackSolo(renderFreezeTrackId, true); + setTrackMIDIClips(renderFreezeTrackId, + R"json([{"id":"render-freeze-midi-clip","startTime":0.0,"duration":1.0,"events":[{"type":"noteOn","timestamp":0.0,"note":67,"velocity":118,"channel":1},{"type":"cc","timestamp":0.02,"controller":11,"value":100,"channel":1},{"type":"pitchBend","timestamp":0.04,"value":12288,"channel":1},{"type":"channelPressure","timestamp":0.06,"value":48,"channel":1},{"type":"noteOff","timestamp":0.5,"note":67,"velocity":0,"channel":1}]}])json"); + + const juce::File tempDir = juce::File::getSpecialLocation(juce::File::tempDirectory); + const juce::File renderFile = tempDir.getChildFile("studio13_midi_render_fixture_" + juce::Uuid().toString() + ".wav"); + const bool renderOk = renderProject("master", 0.0, 1.0, renderFile.getFullPathName(), + "wav", currentSampleRate, 24, 2, + false, false, 0.0, false); + juce::AudioBuffer renderBuffer; + double renderSampleRate = 0.0; + const bool renderReadable = renderOk && readAudioFileForParity(renderFile, renderBuffer, renderSampleRate); + const float renderPeak = renderReadable + ? peakFromFloatBuffer(renderBuffer, renderBuffer.getNumSamples()) + : 0.0f; + + const juce::var freezeResult = freezeTrack(renderFreezeTrackId); + bool freezeOk = false; + juce::File freezeFile; + if (auto* freezeObj = freezeResult.getDynamicObject()) + { + freezeOk = static_cast(freezeObj->getProperty("success")); + freezeFile = juce::File(freezeObj->getProperty("filePath").toString()); + } + + juce::AudioBuffer freezeBuffer; + double freezeSampleRate = 0.0; + const bool freezeReadable = freezeOk && readAudioFileForParity(freezeFile, freezeBuffer, freezeSampleRate); + const float freezePeak = freezeReadable + ? peakFromFloatBuffer(freezeBuffer, freezeBuffer.getNumSamples()) + : 0.0f; + + renderFreezePass = renderOk + && renderReadable + && renderPeak > 1.0e-5f + && renderPeak < 0.75f + && freezeOk + && freezeReadable + && freezePeak > 1.0e-5f + && freezePeak < 0.75f; + renderFreezeDetail = "renderOk=" + juce::String(renderOk ? "true" : "false") + + ", renderReadable=" + juce::String(renderReadable ? "true" : "false") + + ", renderPeak=" + juce::String(renderPeak, 8) + + ", freezeOk=" + juce::String(freezeOk ? "true" : "false") + + ", freezeReadable=" + juce::String(freezeReadable ? "true" : "false") + + ", freezePeak=" + juce::String(freezePeak, 8) + + ", pitchBendValue=12288" + + ", renderSampleRate=" + juce::String(renderSampleRate, 2) + + ", freezeSampleRate=" + juce::String(freezeSampleRate, 2); + + renderFile.deleteFile(); + if (freezeFile.existsAsFile()) + freezeFile.deleteFile(); + removeTrack(renderFreezeTrackId); + } + else + { + renderFreezeDetail = "Failed to create temporary render/freeze regression track"; + } + addSuite("midi_render_freeze_fixture", renderFreezePass, renderFreezeDetail); + + bool midiExportPass = false; + juce::String midiExportDetail; + const juce::File midiExportFile = juce::File::getSpecialLocation(juce::File::tempDirectory) + .getChildFile("studio13_midi_export_fixture_" + juce::Uuid().toString() + ".mid"); + auto midiExportTracks = juce::JSON::parse( + R"json([{"name":"Export Fixture","clips":[{"startTime":0.5,"duration":1.0,"events":[{"type":"programChange","timestamp":0.0,"value":7,"channel":3},{"type":"cc","timestamp":0.04,"controller":0,"value":3,"channel":3},{"type":"cc","timestamp":0.05,"controller":32,"value":45,"channel":3},{"type":"noteOn","timestamp":0.1,"note":60,"velocity":101,"channel":3},{"type":"cc","timestamp":0.12,"controller":1,"value":96,"channel":3},{"type":"cc","timestamp":0.12,"controller":33,"value":12,"channel":3},{"type":"pitchBend","timestamp":0.15,"value":12288,"channel":3},{"type":"channelPressure","timestamp":0.18,"value":55,"channel":3},{"type":"polyPressure","timestamp":0.2,"note":60,"value":66,"channel":3},{"type":"noteOff","timestamp":0.45,"note":60,"releaseVelocity":40,"channel":3}]}]}])json"); + const bool midiExportOk = exportProjectMIDI(midiExportFile.getFullPathName(), midiExportTracks, 120.0); + bool midiReadable = false; + bool hasExportNote = false; + bool hasExportBankMSB = false; + bool hasExportBankLSB = false; + bool hasExportCC1 = false; + bool hasExportCC33 = false; + bool hasExportPitch = false; + bool hasExportProgram = false; + bool hasExportChannelPressure = false; + bool hasExportPolyPressure = false; + bool hasExportNoteOff = false; + int midiTrackCount = 0; + if (midiExportOk && midiExportFile.existsAsFile()) + { + juce::FileInputStream stream(midiExportFile); + juce::MidiFile midiFile; + midiReadable = ! stream.failedToOpen() && midiFile.readFrom(stream); + if (midiReadable) + { + midiTrackCount = midiFile.getNumTracks(); + for (int trackIndex = 0; trackIndex < midiFile.getNumTracks(); ++trackIndex) + { + const auto* midiTrack = midiFile.getTrack(trackIndex); + if (midiTrack == nullptr) + continue; + + for (int eventIndex = 0; eventIndex < midiTrack->getNumEvents(); ++eventIndex) + { + const auto* holder = midiTrack->getEventPointer(eventIndex); + if (holder == nullptr) + continue; + + const auto& message = holder->message; + if (message.isNoteOn() && message.getChannel() == 3 && message.getNoteNumber() == 60) + hasExportNote = true; + else if (message.isNoteOff() && message.getChannel() == 3 && message.getNoteNumber() == 60) + hasExportNoteOff = true; + else if (message.isController() && message.getChannel() == 3 && message.getControllerNumber() == 0 && message.getControllerValue() == 3) + hasExportBankMSB = true; + else if (message.isController() && message.getChannel() == 3 && message.getControllerNumber() == 32 && message.getControllerValue() == 45) + hasExportBankLSB = true; + else if (message.isController() && message.getChannel() == 3 && message.getControllerNumber() == 1 && message.getControllerValue() == 96) + hasExportCC1 = true; + else if (message.isController() && message.getChannel() == 3 && message.getControllerNumber() == 33 && message.getControllerValue() == 12) + hasExportCC33 = true; + else if (message.isPitchWheel() && message.getChannel() == 3 && message.getPitchWheelValue() == 12288) + hasExportPitch = true; + else if (message.isProgramChange() && message.getChannel() == 3 && message.getProgramChangeNumber() == 7) + hasExportProgram = true; + else if (message.isChannelPressure() && message.getChannel() == 3 && message.getChannelPressureValue() == 55) + hasExportChannelPressure = true; + else if (message.isAftertouch() && message.getChannel() == 3 + && message.getNoteNumber() == 60 && message.getAfterTouchValue() == 66) + hasExportPolyPressure = true; + } + } + } + } + + midiExportPass = midiExportOk + && midiReadable + && midiTrackCount > 0 + && hasExportNote + && hasExportNoteOff + && hasExportBankMSB + && hasExportBankLSB + && hasExportCC1 + && hasExportCC33 + && hasExportPitch + && hasExportProgram + && hasExportChannelPressure + && hasExportPolyPressure; + midiExportDetail = "exportOk=" + juce::String(midiExportOk ? "true" : "false") + + ", readable=" + juce::String(midiReadable ? "true" : "false") + + ", tracks=" + juce::String(midiTrackCount) + + ", note=" + juce::String(hasExportNote ? "true" : "false") + + ", noteOff=" + juce::String(hasExportNoteOff ? "true" : "false") + + ", bankMSB=" + juce::String(hasExportBankMSB ? "true" : "false") + + ", bankLSB=" + juce::String(hasExportBankLSB ? "true" : "false") + + ", cc1=" + juce::String(hasExportCC1 ? "true" : "false") + + ", cc33=" + juce::String(hasExportCC33 ? "true" : "false") + + ", pitchBend=" + juce::String(hasExportPitch ? "true" : "false") + + ", program=" + juce::String(hasExportProgram ? "true" : "false") + + ", channelPressure=" + juce::String(hasExportChannelPressure ? "true" : "false") + + ", polyPressure=" + juce::String(hasExportPolyPressure ? "true" : "false"); + midiExportFile.deleteFile(); + addSuite("midi_export_fixture", midiExportPass, midiExportDetail); + + const juce::String combinedInstrumentTrackId = "combined_midi_render_instrument_" + juce::Uuid().toString(); + const juce::String combinedExternalTrackId = "combined_midi_render_external_" + juce::Uuid().toString(); + bool combinedRenderPass = false; + juce::String combinedRenderDetail; + const bool combinedInstrumentAdded = addTrack(combinedInstrumentTrackId, "instrument").isNotEmpty(); + const bool combinedExternalAdded = addTrack(combinedExternalTrackId, "midi").isNotEmpty(); + if (combinedInstrumentAdded && combinedExternalAdded) + { + setTrackType(combinedInstrumentTrackId, "instrument"); + setTrackSolo(combinedInstrumentTrackId, true); + setTrackMute(combinedExternalTrackId, true); + setTrackMIDIClips(combinedInstrumentTrackId, + R"json([{"id":"combined-render-a","startTime":0.0,"duration":0.75,"events":[{"type":"programChange","timestamp":0.0,"value":11,"channel":2},{"type":"cc","timestamp":0.01,"controller":1,"value":90,"channel":2},{"type":"cc","timestamp":0.015,"controller":33,"value":23,"channel":2},{"type":"pitchBend","timestamp":0.02,"value":12345,"channel":2},{"type":"channelPressure","timestamp":0.025,"value":57,"channel":2},{"type":"polyPressure","timestamp":0.03,"note":62,"value":61,"channel":2},{"type":"noteOn","timestamp":0.04,"note":62,"velocity":104,"channel":2},{"type":"noteOff","timestamp":0.45,"note":62,"releaseVelocity":45,"channel":2}]},{"id":"combined-render-b","startTime":0.8,"duration":0.45,"events":[{"type":"noteOn","timestamp":0.0,"note":67,"velocity":88,"channel":2},{"type":"pitchBend","timestamp":0.02,"value":8192,"channel":2},{"type":"noteOff","timestamp":0.30,"note":67,"releaseVelocity":32,"channel":2}]}])json"); + setTrackMIDIClips(combinedExternalTrackId, + R"json([{"id":"combined-muted-external","startTime":0.0,"duration":0.5,"events":[{"type":"noteOn","timestamp":0.0,"note":72,"velocity":127,"channel":1},{"type":"noteOff","timestamp":0.25,"note":72,"releaseVelocity":12,"channel":1}]}])json"); + + auto instrumentIt = trackMap.find(combinedInstrumentTrackId); + auto externalIt = trackMap.find(combinedExternalTrackId); + if (instrumentIt != trackMap.end() && instrumentIt->second != nullptr + && externalIt != trackMap.end() && externalIt->second != nullptr) + { + auto* instrumentTrack = instrumentIt->second; + auto* externalTrack = externalIt->second; + const double sampleRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + const int schedulerBlockSize = juce::jmax(32768, static_cast(std::ceil(sampleRate * 0.7))); + juce::MidiBuffer drainQueuedResetMidi; + instrumentTrack->buildMidiBuffer(drainQueuedResetMidi, 0.0, schedulerBlockSize, sampleRate, false); + juce::MidiBuffer combinedMidi; + instrumentTrack->buildMidiBuffer(combinedMidi, 0.0, schedulerBlockSize, sampleRate, true); + + bool hasCombinedProgram = false; + bool hasCombinedCC1 = false; + bool hasCombinedCC33 = false; + bool hasCombinedPitchBend = false; + bool hasCombinedChannelPressure = false; + bool hasCombinedPolyPressure = false; + bool hasCombinedNoteOn = false; + bool hasCombinedNoteOffRelease = false; + for (const auto metadata : combinedMidi) { - modesComparable = false; + const auto message = metadata.getMessage(); + if (message.isProgramChange() && message.getChannel() == 2 && message.getProgramChangeNumber() == 11) + hasCombinedProgram = true; + else if (message.isController() && message.getChannel() == 2 && message.getControllerNumber() == 1 && message.getControllerValue() == 90) + hasCombinedCC1 = true; + else if (message.isController() && message.getChannel() == 2 && message.getControllerNumber() == 33 && message.getControllerValue() == 23) + hasCombinedCC33 = true; + else if (message.isPitchWheel() && message.getChannel() == 2 && message.getPitchWheelValue() == 12345) + hasCombinedPitchBend = true; + else if (message.isChannelPressure() && message.getChannel() == 2 && message.getChannelPressureValue() == 57) + hasCombinedChannelPressure = true; + else if (message.isAftertouch() && message.getChannel() == 2 + && message.getNoteNumber() == 62 && message.getAfterTouchValue() == 61) + hasCombinedPolyPressure = true; + else if (message.isNoteOn() && message.getChannel() == 2 + && message.getNoteNumber() == 62 && message.getVelocity() > 0.0f) + hasCombinedNoteOn = true; + else if (message.isNoteOff() && message.getChannel() == 2 + && message.getNoteNumber() == 62 && message.getVelocity() > 0.0f) + hasCombinedNoteOffRelease = true; } - modesComparable = modesComparable && floatAvg >= 0.0 && hybridAvg >= 0.0; - addSuite("precision_benchmark_matrix", modesComparable, - "float32AvgMs=" + juce::String(floatAvg) + ", hybrid64AvgMs=" + juce::String(hybridAvg)); - } - else - { - addSuite("precision_benchmark_matrix", false, "Benchmark payload missing"); - } - - auto compatibility = guardrailObj->getProperty("compatibility"); - if (auto* compatObj = compatibility.getDynamicObject()) - { - auto plugins = compatObj->getProperty("plugins"); - bool metadataConsistent = true; - int pluginCount = 0; - int doubleCapableCount = 0; - if (plugins.isArray()) + const juce::File combinedRenderFile = juce::File::getSpecialLocation(juce::File::tempDirectory) + .getChildFile("studio13_combined_midi_render_fixture_" + juce::Uuid().toString() + ".wav"); + const bool renderOk = renderProject("master", 0.0, 1.35, combinedRenderFile.getFullPathName(), + "wav", currentSampleRate, 24, 2, + false, false, 0.0, false); + juce::AudioBuffer renderBuffer; + double renderSampleRate = 0.0; + const bool renderReadable = renderOk && readAudioFileForParity(combinedRenderFile, renderBuffer, renderSampleRate); + int nonFiniteCount = 0; + if (renderReadable) { - pluginCount = plugins.getArray()->size(); - for (const auto& plugin : *plugins.getArray()) + for (int channel = 0; channel < renderBuffer.getNumChannels(); ++channel) { - auto* pluginObj = plugin.getDynamicObject(); - if (pluginObj == nullptr) + const auto* samples = renderBuffer.getReadPointer(channel); + for (int sample = 0; sample < renderBuffer.getNumSamples(); ++sample) { - metadataConsistent = false; - continue; + if (!std::isfinite(samples[sample])) + ++nonFiniteCount; } - - const bool supportsDouble = static_cast(pluginObj->getProperty("supportsDoublePrecision")); - const juce::String pluginFormat = pluginObj->getProperty("pluginFormat").toString(); - if (pluginFormat.isEmpty()) - metadataConsistent = false; - if (supportsDouble) - ++doubleCapableCount; } } - else - { - metadataConsistent = false; - } - addSuite("plugin_capability_matrix", metadataConsistent, - "pluginCount=" + juce::String(pluginCount) - + ", doubleCapable=" + juce::String(doubleCapableCount)); + const float renderPeak = renderReadable + ? peakFromFloatBuffer(renderBuffer, renderBuffer.getNumSamples()) + : 0.0f; + const int scheduledInstrumentClips = instrumentTrack->getScheduledMIDIClipCount(); + const int scheduledInstrumentEvents = instrumentTrack->getScheduledMIDIEventCount(); + const int scheduledExternalEvents = externalTrack->getScheduledMIDIEventCount(); + const int maxBuiltEvents = instrumentTrack->getMaxBuiltMidiEventCount(); + const bool schedulerOk = scheduledInstrumentClips == 2 + && scheduledInstrumentEvents == 11 + && scheduledExternalEvents == 2 + && combinedMidi.getNumEvents() >= 8 + && hasCombinedProgram + && hasCombinedCC1 + && hasCombinedCC33 + && hasCombinedPitchBend + && hasCombinedChannelPressure + && hasCombinedPolyPressure + && hasCombinedNoteOn + && hasCombinedNoteOffRelease; + combinedRenderPass = schedulerOk + && renderOk + && renderReadable + && nonFiniteCount == 0 + && renderPeak > 1.0e-5f + && renderPeak < 0.75f + && maxBuiltEvents > 0; + combinedRenderDetail = "schedulerOk=" + juce::String(schedulerOk ? "true" : "false") + + ", renderOk=" + juce::String(renderOk ? "true" : "false") + + ", renderReadable=" + juce::String(renderReadable ? "true" : "false") + + ", renderPeak=" + juce::String(renderPeak, 8) + + ", nonFinite=" + juce::String(nonFiniteCount) + + ", instrumentClips=" + juce::String(scheduledInstrumentClips) + + ", instrumentEvents=" + juce::String(scheduledInstrumentEvents) + + ", externalEvents=" + juce::String(scheduledExternalEvents) + + ", directMidiEvents=" + juce::String(combinedMidi.getNumEvents()) + + ", maxBuiltEvents=" + juce::String(maxBuiltEvents) + + ", program=" + juce::String(hasCombinedProgram ? "true" : "false") + + ", cc14=" + juce::String((hasCombinedCC1 && hasCombinedCC33) ? "true" : "false") + + ", pitchBend=" + juce::String(hasCombinedPitchBend ? "true" : "false") + + ", channelPressure=" + juce::String(hasCombinedChannelPressure ? "true" : "false") + + ", polyPressure=" + juce::String(hasCombinedPolyPressure ? "true" : "false") + + ", releaseVelocity=" + juce::String(hasCombinedNoteOffRelease ? "true" : "false") + + ", renderSampleRate=" + juce::String(renderSampleRate, 2); + combinedRenderFile.deleteFile(); } else { - addSuite("plugin_capability_matrix", false, "Compatibility payload missing"); + combinedRenderDetail = "Temporary combined render tracks missing after addTrack"; } } else { - addSuite("release_guardrails", false, "Guardrail payload missing"); - addSuite("precision_benchmark_matrix", false, "Guardrail payload missing"); - addSuite("plugin_capability_matrix", false, "Guardrail payload missing"); + combinedRenderDetail = "Failed to create temporary combined render tracks"; } + if (combinedInstrumentAdded) + removeTrack(combinedInstrumentTrackId); + if (combinedExternalAdded) + removeTrack(combinedExternalTrackId); + addSuite("midi_combined_project_render_fixture", combinedRenderPass, combinedRenderDetail); + root->setProperty("overallPass", overallPass); root->setProperty("processingPrecision", getProcessingPrecision()); root->setProperty("suites", suites); @@ -7324,6 +8349,40 @@ juce::var AudioEngine::getPluginParameters(const juce::String& trackId, int fxIn return paramList; } +bool AudioEngine::setPluginParameter(const juce::String& trackId, int fxIndex, bool isInputFX, int paramIndex, float value) +{ + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return false; + + auto* track = it->second; + juce::AudioProcessor* processor = nullptr; + + if (isInputFX) + { + if (fxIndex >= 0 && fxIndex < track->getNumInputFX()) + processor = track->getInputFXProcessor(fxIndex); + } + else + { + if (fxIndex >= 0 && fxIndex < track->getNumTrackFX()) + processor = track->getTrackFXProcessor(fxIndex); + } + + if (processor == nullptr) + return false; + + const juce::ScopedLock processorLock(processor->getCallbackLock()); + auto& params = processor->getParameters(); + if (paramIndex < 0 || paramIndex >= params.size() || params[paramIndex] == nullptr) + return false; + + params[paramIndex]->setValueNotifyingHost(juce::jlimit(0.0f, 1.0f, value)); + return true; +} + void AudioEngine::removeTrackInputFX(const juce::String& trackId, int fxIndex) { // Phase 1: Close editor window and release resources BEFORE acquiring lock. @@ -8437,27 +9496,93 @@ juce::var AudioEngine::getTrackRoutingInfo(const juce::String& trackId) juce::var AudioEngine::getMidiDiagnostics() const { juce::Array tracksArray; + int midiTrackCount = 0; + int instrumentTrackCount = 0; + int scheduledTrackCount = 0; + int scheduledClipCount = 0; + int scheduledEventCount = 0; + int tracksWithMidiOutput = 0; + for (const auto& [trackId, track] : trackMap) { if (!track) continue; + const auto type = track->getTrackType(); + const int trackScheduledClips = track->getScheduledMIDIClipCount(); + const int trackScheduledEvents = track->getScheduledMIDIEventCount(); + if (type == TrackType::MIDI) + ++midiTrackCount; + else if (type == TrackType::Instrument) + ++instrumentTrackCount; + if (trackScheduledClips > 0 || trackScheduledEvents > 0) + ++scheduledTrackCount; + scheduledClipCount += trackScheduledClips; + scheduledEventCount += trackScheduledEvents; + if (track->getMIDIOutputDeviceName().isNotEmpty()) + ++tracksWithMidiOutput; + auto* trackObj = new juce::DynamicObject(); trackObj->setProperty("trackId", trackId); - trackObj->setProperty("trackType", track->getTrackType() == TrackType::Instrument + trackObj->setProperty("trackType", type == TrackType::Instrument ? "instrument" - : (track->getTrackType() == TrackType::MIDI ? "midi" : "audio")); + : (type == TrackType::MIDI ? "midi" : "audio")); trackObj->setProperty("midiOverflowCount", track->getMidiOverflowCount()); trackObj->setProperty("lastBuiltMidiEventCount", track->getLastBuiltMidiEventCount()); trackObj->setProperty("maxBuiltMidiEventCount", track->getMaxBuiltMidiEventCount()); + trackObj->setProperty("scheduledMIDIClipCount", trackScheduledClips); + trackObj->setProperty("scheduledMIDIEventCount", trackScheduledEvents); + trackObj->setProperty("hasInstrument", track->getInstrument() != nullptr); + trackObj->setProperty("instrumentName", track->getInstrument() != nullptr ? track->getInstrument()->getName() : juce::String()); + trackObj->setProperty("fallbackInstrumentActive", track->isUsingFallbackInstrument()); + const auto fallbackSamplerPath = track->getFallbackSamplerSamplePath(); + trackObj->setProperty("fallbackSamplerActive", track->hasFallbackSamplerSample()); + trackObj->setProperty("fallbackSamplerSamplePath", fallbackSamplerPath); + trackObj->setProperty("fallbackSamplerSourceType", fallbackSamplerPath.endsWithIgnoreCase(".sf2") + ? "soundfont" + : (fallbackSamplerPath.isNotEmpty() ? "audio" : "")); + trackObj->setProperty("midiInputDevice", track->getMIDIInputDevice()); + trackObj->setProperty("midiChannel", track->getMIDIChannel()); + trackObj->setProperty("midiOutputDevice", track->getMIDIOutputDeviceName()); + trackObj->setProperty("muted", track->getMute()); + trackObj->setProperty("midiOutputMutedSuppressed", + track->getMute() && track->getMIDIOutputDeviceName().isNotEmpty()); + trackObj->setProperty("acceptsMidi", track->acceptsMidi()); + trackObj->setProperty("producesMidi", track->producesMidi()); + trackObj->setProperty("inputFXCount", track->getNumInputFX()); + trackObj->setProperty("trackFXCount", track->getNumTrackFX()); trackObj->setProperty("realtimeFallbackReuseCount", track->getRealtimeFallbackReuseCount()); trackObj->setProperty("monitoring", track->getInputMonitoring()); trackObj->setProperty("recordArmed", track->getRecordArmed()); tracksArray.add(juce::var(trackObj)); } + juce::Array inputDevicesArray; + juce::Array openDevicesArray; + if (midiManager) + { + for (const auto& device : midiManager->getAvailableDevices()) + inputDevicesArray.add(device); + for (const auto& device : midiManager->getOpenDevices()) + openDevicesArray.add(device); + } + + juce::Array outputDevicesArray; + for (const auto& device : juce::MidiOutput::getAvailableDevices()) + outputDevicesArray.add(device.name); + auto* root = new juce::DynamicObject(); root->setProperty("tracks", tracksArray); + root->setProperty("inputDevices", inputDevicesArray); + root->setProperty("outputDevices", outputDevicesArray); + root->setProperty("openDevices", openDevicesArray); + root->setProperty("trackCount", static_cast(trackMap.size())); + root->setProperty("midiTrackCount", midiTrackCount); + root->setProperty("instrumentTrackCount", instrumentTrackCount); + root->setProperty("scheduledMIDITrackCount", scheduledTrackCount); + root->setProperty("scheduledMIDIClipCount", scheduledClipCount); + root->setProperty("scheduledMIDIEventCount", scheduledEventCount); + root->setProperty("tracksWithMidiOutput", tracksWithMidiOutput); root->setProperty("bufferSize", currentBlockSize); root->setProperty("sampleRate", currentSampleRate); root->setProperty("lateEventCount", midiLateEventCount.load(std::memory_order_relaxed)); @@ -8491,9 +9616,20 @@ juce::var AudioEngine::getAudioDebugSnapshot() const trackObj->setProperty("midiOverflowCount", track->getMidiOverflowCount()); trackObj->setProperty("lastBuiltMidiEventCount", track->getLastBuiltMidiEventCount()); trackObj->setProperty("maxBuiltMidiEventCount", track->getMaxBuiltMidiEventCount()); + trackObj->setProperty("scheduledMIDIClipCount", track->getScheduledMIDIClipCount()); + trackObj->setProperty("scheduledMIDIEventCount", track->getScheduledMIDIEventCount()); trackObj->setProperty("inputFXCount", track->getNumInputFX()); trackObj->setProperty("trackFXCount", track->getNumTrackFX()); trackObj->setProperty("isInstrument", track->getTrackType() == TrackType::Instrument); + trackObj->setProperty("hasInstrument", track->getInstrument() != nullptr); + trackObj->setProperty("instrumentName", track->getInstrument() != nullptr ? track->getInstrument()->getName() : juce::String()); + trackObj->setProperty("fallbackInstrumentActive", track->isUsingFallbackInstrument()); + const auto fallbackSamplerPath = track->getFallbackSamplerSamplePath(); + trackObj->setProperty("fallbackSamplerActive", track->hasFallbackSamplerSample()); + trackObj->setProperty("fallbackSamplerSamplePath", fallbackSamplerPath); + trackObj->setProperty("fallbackSamplerSourceType", fallbackSamplerPath.endsWithIgnoreCase(".sf2") + ? "soundfont" + : (fallbackSamplerPath.isNotEmpty() ? "audio" : "")); trackObj->setProperty("recordArmed", track->getRecordArmed()); trackObj->setProperty("inputMonitoring", track->getInputMonitoring()); } @@ -8974,6 +10110,7 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do int trackFxCount = 0; int sendCount = 0; int sidechainSourceCount = 0; + bool isInstrumentTrack = false; bool hasInstrument = false; bool hasARA = false; bool masterSendEnabled = true; @@ -8985,7 +10122,7 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do bool anySoloed = false; auto automationIsActive = [] (const AutomationList& automation) { - return automation.shouldPlayback() && automation.getNumPoints() > 0; + return automation.shouldPlaybackForRead() && automation.getNumPoints() > 0; }; { const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); @@ -9002,6 +10139,7 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do snap.soloed = track->getSolo(); snap.inputFxCount = track->getNumInputFX(); snap.trackFxCount = track->getNumTrackFX(); + snap.isInstrumentTrack = track->getTrackType() == TrackType::Instrument; snap.hasInstrument = track->getInstrument() != nullptr; snap.hasARA = track->hasActiveARA(); snap.masterSendEnabled = track->getMasterSendEnabled(); @@ -9015,10 +10153,13 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do || automationIsActive(track->getPreFXPanAutomation()) || automationIsActive(track->getPreFXWidthAutomation()) || automationIsActive(track->getTrimVolumeAutomation()) - || automationIsActive(track->getMuteAutomation()); + || automationIsActive(track->getMuteAutomation()) + || track->hasPluginAutomation() + || track->hasMIDIAutomation(); snap.requiresFullRender = snap.inputFxCount > 0 || snap.trackFxCount > 0 + || snap.isInstrumentTrack || snap.hasInstrument || snap.hasARA || snap.sendCount > 0 @@ -9032,6 +10173,7 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do "dB pan=" + juce::String(snap.pan) + " mute=" + juce::String(snap.muted ? "true" : "false") + " solo=" + juce::String(snap.soloed ? "true" : "false") + + " instrumentTrack=" + juce::String(snap.isInstrumentTrack ? "true" : "false") + " fx=" + juce::String(snap.inputFxCount + snap.trackFxCount) + " sends=" + juce::String(snap.sendCount) + " sidechains=" + juce::String(snap.sidechainSourceCount) + @@ -9200,30 +10342,69 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do juce::MemoryBlock savedState; }; std::vector pluginBackups; + std::vector renderPreparedTracks; - // Lambda to save state, prepare, and reset a processor for render - auto prepareProcessorForRender = [&](juce::AudioProcessor* proc) { + auto backupProcessorForRender = [&](juce::AudioProcessor* proc) { if (!proc) return; + const auto alreadyBackedUp = std::find_if(pluginBackups.begin(), pluginBackups.end(), + [proc](const PluginStateBackup& backup) { return backup.processor == proc; }); + if (alreadyBackedUp != pluginBackups.end()) + return; + // Save current state PluginStateBackup backup; backup.processor = proc; proc->getStateInformation(backup.savedState); pluginBackups.push_back(std::move(backup)); - // Re-prepare for render block size + }; + + // Lambda to save state, prepare, and reset a processor for render. + auto prepareProcessorForRender = [&](juce::AudioProcessor* proc) { + backupProcessorForRender(proc); + if (!proc) return; prepareHostedProcessorForPrecision(proc, actualSampleRate, blockSize, processingPrecisionMode); proc->reset(); }; - // Prepare track FX plugins + auto restorePreparedTracksForRealtime = [&]() + { + const double realtimeSampleRate = currentSampleRate > 0.0 ? currentSampleRate : actualSampleRate; + const int realtimeBlockSize = currentBlockSize > 0 ? currentBlockSize : 512; + for (auto* track : renderPreparedTracks) + { + if (track != nullptr) + track->prepareToPlay(realtimeSampleRate, realtimeBlockSize); + } + + for (auto& backup : pluginBackups) + { + if (backup.processor) + { + backup.processor->prepareToPlay(realtimeSampleRate, juce::jmax(realtimeBlockSize, 512)); + backup.processor->setStateInformation(backup.savedState.getData(), + (int)backup.savedState.getSize()); + backup.processor->reset(); + } + } + }; + + // Prepare complete track processors for the offline block size. This is not + // just plugin sample-rate setup: TrackProcessor owns expanded FX buffers, + // fallback-instrument state, channel strip EQ, PDC, and send buffers. If it + // remains sized for the realtime block while render uses 2048 samples, stem + // renders can turn into crackle/low garbage. for (const auto& snap : trackSnapshots) { auto it = trackMap.find(snap.id); if (it == trackMap.end() || !it->second) continue; auto* track = it->second; for (int fx = 0; fx < track->getNumInputFX(); ++fx) - prepareProcessorForRender(track->getInputFXProcessor(fx)); + backupProcessorForRender(track->getInputFXProcessor(fx)); for (int fx = 0; fx < track->getNumTrackFX(); ++fx) - prepareProcessorForRender(track->getTrackFXProcessor(fx)); + backupProcessorForRender(track->getTrackFXProcessor(fx)); + backupProcessorForRender(track->getInstrument()); + track->prepareToPlay(actualSampleRate, blockSize); + renderPreparedTracks.push_back(track); } DesiredFXStageSpec renderMasterSpec; @@ -9238,6 +10419,7 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do if (!renderMasterStage && !renderMasterSpec.slots.empty()) { logToDisk("renderProject: FAIL - could not build master stage: " + renderStageError); + restorePreparedTracksForRealtime(); isRendering = false; return false; } @@ -9305,6 +10487,27 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do const bool useRenderLagrangeSrc = getPitchEnvFlag("OPENSTUDIO_RENDER_USE_LAGRANGE_SRC", false); auto rtTracks = std::atomic_load_explicit(&realtimeTrackSnapshot, std::memory_order_acquire); + struct ScopedAutomationReadOverride + { + std::vector tracks; + ~ScopedAutomationReadOverride() + { + for (auto* track : tracks) + if (track != nullptr) + track->setForceAutomationReadForProcessing(false); + } + } scopedAutomationReadOverride; + + for (auto const& [trackId, track] : trackMap) + { + juce::ignoreUnused(trackId); + if (track != nullptr) + { + track->setForceAutomationReadForProcessing(true); + scopedAutomationReadOverride.tracks.push_back(track); + } + } + auto writeRenderRouteReport = [&]() { auto* root = new juce::DynamicObject(); @@ -9381,6 +10584,7 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do obj->setProperty("soloed", snap.soloed); obj->setProperty("inputFxCount", snap.inputFxCount); obj->setProperty("trackFxCount", snap.trackFxCount); + obj->setProperty("isInstrumentTrack", snap.isInstrumentTrack); obj->setProperty("hasInstrument", snap.hasInstrument); obj->setProperty("hasARA", snap.hasARA); obj->setProperty("sendCount", snap.sendCount); @@ -10082,17 +11286,7 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do if (!ffmpegOk) { logToDisk("renderProject: FAIL - FFmpeg post-processing failed"); - // Restore plugins before returning (clamp max-block to at least 512) - for (auto& backup : pluginBackups) - { - if (backup.processor) - { - backup.processor->prepareToPlay(currentSampleRate, juce::jmax(currentBlockSize, 512)); - backup.processor->setStateInformation(backup.savedState.getData(), - (int)backup.savedState.getSize()); - backup.processor->reset(); - } - } + restorePreparedTracksForRealtime(); isRendering = false; return false; } @@ -10102,16 +11296,7 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do // Re-prepare all FX plugins for the device's buffer size and restore their // saved state so real-time playback continues as if render never happened. // Clamp max-block to at least 512 (same rationale as track FX preparation). - for (auto& backup : pluginBackups) - { - if (backup.processor) - { - backup.processor->prepareToPlay(currentSampleRate, juce::jmax(currentBlockSize, 512)); - backup.processor->setStateInformation(backup.savedState.getData(), - (int)backup.savedState.getSize()); - backup.processor->reset(); - } - } + restorePreparedTracksForRealtime(); logToDisk("renderProject: Restored " + juce::String((int)pluginBackups.size()) + " FX plugins for real-time"); // Re-enable audio callback @@ -10341,6 +11526,46 @@ void AudioEngine::setAutomationPoints(const juce::String& trackId, const juce::S " automation points for track " + trackId + " param " + parameterId); } +void AudioEngine::replaceAutomationPointsInRange(const juce::String& trackId, const juce::String& parameterId, + double startTimeSeconds, double endTimeSeconds, + const juce::String& pointsJSON) +{ + AutomationList* list = nullptr; + if (trackId == "master") + { + if (parameterId == "volume") list = &masterVolumeAutomation; + else if (parameterId == "pan") list = &masterPanAutomation; + if (!list) + return; + } + else + { + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return; + auto target = it->second->resolveAutomationTarget(parameterId, true); + if (!target.has_value() || target->list == nullptr) + return; + list = target->list; + } + + auto parsed = juce::JSON::parse(pointsJSON); + if (!parsed.isArray()) + return; + + auto* arr = parsed.getArray(); + std::vector points; + points.reserve(static_cast(arr->size())); + for (const auto& item : *arr) + { + const double timeSec = item.getProperty("time", 0.0); + const float value = static_cast(static_cast(item.getProperty("value", 0.0))); + points.push_back({ timeSec, value }); + } + + list->replacePointsInRange(startTimeSeconds, endTimeSeconds, std::move(points)); +} + void AudioEngine::setAutomationMode(const juce::String& trackId, const juce::String& parameterId, const juce::String& modeStr) { @@ -10701,18 +11926,32 @@ juce::var AudioEngine::freezeTrack(const juce::String& trackId) clipList.push_back(c); if (clipList.empty()) { - resultObj->setProperty("success", false); - resultObj->setProperty("error", "No clips on track"); - return juce::var(resultObj); - } + const auto midiClips = track->getScheduledMIDIClipSnapshot(); + if (midiClips.empty()) + { + resultObj->setProperty("success", false); + resultObj->setProperty("error", "No clips on track"); + return juce::var(resultObj); + } - // Find the time range of all clips on this track - startTime = std::numeric_limits::max(); - endTime = 0.0; - for (const auto& clip : clipList) + startTime = std::numeric_limits::max(); + endTime = 0.0; + for (const auto& clip : midiClips) + { + startTime = std::min(startTime, clip.startTime); + endTime = std::max(endTime, clip.startTime + clip.duration); + } + } + else { - startTime = std::min(startTime, clip.startTime); - endTime = std::max(endTime, clip.startTime + clip.duration); + // Find the time range of all audio clips on this track + startTime = std::numeric_limits::max(); + endTime = 0.0; + for (const auto& clip : clipList) + { + startTime = std::min(startTime, clip.startTime); + endTime = std::max(endTime, clip.startTime + clip.duration); + } } // Add tail for FX (reverb, delay) @@ -10768,6 +12007,17 @@ juce::var AudioEngine::freezeTrack(const juce::String& trackId) juce::AudioBuffer trackBuffer(2, renderBlockSize); juce::int64 samplesRendered = 0; double samplePos = startTime * renderRate; + struct ScopedTrackAutomationReadOverride + { + TrackProcessor* track = nullptr; + ~ScopedTrackAutomationReadOverride() + { + if (track != nullptr) + track->setForceAutomationReadForProcessing(false); + } + } scopedAutomationRead; + track->setForceAutomationReadForProcessing(true); + scopedAutomationRead.track = track; while (samplesRendered < totalSamples) { @@ -11002,6 +12252,141 @@ juce::var AudioEngine::importMIDIFile(const juce::String& filePath) return juce::var(result); } +bool AudioEngine::exportProjectMIDI(const juce::String& outputPath, const juce::var& midiTracks, double bpm) +{ + auto* tracksArray = midiTracks.getArray(); + if (tracksArray == nullptr) + return false; + + if (bpm <= 0.0) + bpm = 120.0; + + auto getIntProperty = [] (juce::DynamicObject* obj, const char* name, int fallback, int minValue, int maxValue) + { + if (obj == nullptr || ! obj->hasProperty(name)) + return fallback; + + return juce::jlimit(minValue, maxValue, static_cast(obj->getProperty(name))); + }; + + auto addEventToSequence = [&] (juce::MidiMessageSequence& sequence, + juce::DynamicObject* eventObj, + double absoluteTimeSeconds) + { + if (eventObj == nullptr) + return; + + const juce::String eventType = eventObj->getProperty("type").toString(); + const double ticks = juce::jmax(0.0, absoluteTimeSeconds) * (bpm / 60.0) * 480.0; + const int channel = getIntProperty(eventObj, "channel", 1, 1, 16); + + if (eventType == "noteOn") + { + const int note = getIntProperty(eventObj, "note", 60, 0, 127); + const int velocity = getIntProperty(eventObj, "velocity", 100, 1, 127); + sequence.addEvent(juce::MidiMessage::noteOn(channel, note, static_cast(velocity)), ticks); + } + else if (eventType == "noteOff") + { + const int note = getIntProperty(eventObj, "note", 60, 0, 127); + const int velocity = getIntProperty(eventObj, + eventObj->hasProperty("releaseVelocity") ? "releaseVelocity" : "velocity", + 0, 0, 127); + sequence.addEvent(juce::MidiMessage::noteOff(channel, note, static_cast(velocity)), ticks); + } + else if (eventType == "cc") + { + const int controller = getIntProperty(eventObj, "controller", 0, 0, 127); + const int value = getIntProperty(eventObj, "value", 0, 0, 127); + sequence.addEvent(juce::MidiMessage::controllerEvent(channel, controller, value), ticks); + } + else if (eventType == "pitchBend") + { + const int value = getIntProperty(eventObj, "value", 8192, 0, 16383); + sequence.addEvent(juce::MidiMessage::pitchWheel(channel, value), ticks); + } + else if (eventType == "programChange") + { + const int program = getIntProperty(eventObj, "value", 0, 0, 127); + sequence.addEvent(juce::MidiMessage::programChange(channel, program), ticks); + } + else if (eventType == "channelPressure") + { + const int pressure = getIntProperty(eventObj, "value", 0, 0, 127); + sequence.addEvent(juce::MidiMessage::channelPressureChange(channel, pressure), ticks); + } + else if (eventType == "polyPressure") + { + const int note = getIntProperty(eventObj, "note", 60, 0, 127); + const int pressure = getIntProperty(eventObj, "value", 0, 0, 127); + sequence.addEvent(juce::MidiMessage::aftertouchChange(channel, note, pressure), ticks); + } + }; + + juce::MidiFile midiFile; + midiFile.setTicksPerQuarterNote(480); + int exportedTrackCount = 0; + + for (const auto& trackVar : *tracksArray) + { + auto* trackObj = trackVar.getDynamicObject(); + if (trackObj == nullptr) + continue; + + juce::MidiMessageSequence sequence; + sequence.addEvent(juce::MidiMessage::tempoMetaEvent(static_cast(60000000.0 / bpm)), 0.0); + int eventCount = 0; + + if (auto* clips = trackObj->getProperty("clips").getArray()) + { + for (const auto& clipVar : *clips) + { + auto* clipObj = clipVar.getDynamicObject(); + if (clipObj == nullptr) + continue; + + const double clipStart = static_cast(clipObj->getProperty("startTime")); + if (auto* events = clipObj->getProperty("events").getArray()) + { + for (const auto& eventVar : *events) + { + auto* eventObj = eventVar.getDynamicObject(); + if (eventObj == nullptr) + continue; + + const double timestamp = static_cast(eventObj->getProperty("timestamp")); + addEventToSequence(sequence, eventObj, clipStart + timestamp); + ++eventCount; + } + } + } + } + + if (eventCount > 0) + { + sequence.updateMatchedPairs(); + midiFile.addTrack(sequence); + ++exportedTrackCount; + } + } + + if (exportedTrackCount == 0) + return false; + + juce::File outFile(outputPath); + outFile.getParentDirectory().createDirectory(); + outFile.deleteFile(); + std::unique_ptr outputStream(outFile.createOutputStream()); + if (! outputStream) + return false; + + const bool success = midiFile.writeTo(*outputStream); + juce::Logger::writeToLog("exportProjectMIDI: " + outputPath + + " tracks=" + juce::String(exportedTrackCount) + + " success=" + juce::String(success ? "true" : "false")); + return success; +} + bool AudioEngine::exportMIDIFile(const juce::String& trackId, const juce::String& clipId, const juce::String& eventsJSON, const juce::String& outputPath, double clipTempo) @@ -11053,6 +12438,36 @@ bool AudioEngine::exportMIDIFile(const juce::String& trackId, const juce::String int channel = obj->hasProperty("channel") ? static_cast(obj->getProperty("channel")) : 1; sequence.addEvent(juce::MidiMessage::controllerEvent(channel, controller, value), ticks); } + else if (eventType == "pitchBend") + { + const int value = obj->hasProperty("value") ? static_cast(obj->getProperty("value")) : 8192; + const int channel = obj->hasProperty("channel") ? static_cast(obj->getProperty("channel")) : 1; + sequence.addEvent(juce::MidiMessage::pitchWheel(juce::jlimit(1, 16, channel), + juce::jlimit(0, 16383, value)), ticks); + } + else if (eventType == "programChange") + { + const int program = obj->hasProperty("value") ? static_cast(obj->getProperty("value")) : 0; + const int channel = obj->hasProperty("channel") ? static_cast(obj->getProperty("channel")) : 1; + sequence.addEvent(juce::MidiMessage::programChange(juce::jlimit(1, 16, channel), + juce::jlimit(0, 127, program)), ticks); + } + else if (eventType == "channelPressure") + { + const int pressure = obj->hasProperty("value") ? static_cast(obj->getProperty("value")) : 0; + const int channel = obj->hasProperty("channel") ? static_cast(obj->getProperty("channel")) : 1; + sequence.addEvent(juce::MidiMessage::channelPressureChange(juce::jlimit(1, 16, channel), + juce::jlimit(0, 127, pressure)), ticks); + } + else if (eventType == "polyPressure") + { + const int note = obj->hasProperty("note") ? static_cast(obj->getProperty("note")) : 60; + const int pressure = obj->hasProperty("value") ? static_cast(obj->getProperty("value")) : 0; + const int channel = obj->hasProperty("channel") ? static_cast(obj->getProperty("channel")) : 1; + sequence.addEvent(juce::MidiMessage::aftertouchChange(juce::jlimit(1, 16, channel), + juce::jlimit(0, 127, note), + juce::jlimit(0, 127, pressure)), ticks); + } } } @@ -13656,6 +15071,11 @@ juce::var AudioEngine::installAiTools(bool userConfirmedDownload) return stemSeparator.installAiTools(userConfirmedDownload); } +juce::var AudioEngine::installAiTools(const juce::String& optionsJSON) +{ + return stemSeparator.installAiTools(optionsJSON); +} + juce::var AudioEngine::resetAiTools() { return stemSeparator.resetAiTools(); diff --git a/Source/AudioEngine.h b/Source/AudioEngine.h index 2343c43..ff9ebe7 100644 --- a/Source/AudioEngine.h +++ b/Source/AudioEngine.h @@ -69,7 +69,8 @@ class AudioEngine : public juce::AudioIODeviceCallback, juce::AudioDeviceManager& getDeviceManager() { return deviceManager; } // Messaging - juce::String addTrack(const juce::String& explicitId = juce::String()); // Returns track ID, optional explicit ID for restore + juce::String addTrack(const juce::String& explicitId = juce::String(), + const juce::String& initialType = juce::String()); // Returns track ID, optional explicit ID/type for restore bool removeTrack(const juce::String& trackId); bool reorderTrack(const juce::String& trackId, int newPosition); int getTrackIndex(const juce::String& trackId) const; // For lookups @@ -204,7 +205,14 @@ class AudioEngine : public juce::AudioIODeviceCallback, void setTrackMIDIInput(const juce::String& trackId, const juce::String& deviceName, int channel); void setTrackMIDIClips(const juce::String& trackId, const juce::String& clipsJSON); bool sendMidiNote(const juce::String& trackId, int note, int velocity, bool isNoteOn); + juce::var getTrackMIDINoteActivity(const juce::String& trackId, int maxAgeMs = 1200) const; + bool panicMIDI(); bool loadInstrument(const juce::String& trackId, const juce::String& vstPath); + bool removeInstrument(const juce::String& trackId); + bool setTrackSamplerSample(const juce::String& trackId, const juce::String& samplePath, int rootNote); + bool clearTrackSamplerSample(const juce::String& trackId); + juce::String getInstrumentState(const juce::String& trackId); + bool setInstrumentState(const juce::String& trackId, const juce::String& base64State); void setProcessingPrecision(const juce::String& precisionMode); juce::String getProcessingPrecision() const; bool setTrackPluginPrecisionOverride(const juce::String& trackId, int fxIndex, bool isInputFX, const juce::String& mode); @@ -218,6 +226,7 @@ class AudioEngine : public juce::AudioIODeviceCallback, juce::var getTrackInputFX(const juce::String& trackId); juce::var getTrackFX(const juce::String& trackId); juce::var getPluginParameters(const juce::String& trackId, int fxIndex, bool isInputFX); + bool setPluginParameter(const juce::String& trackId, int fxIndex, bool isInputFX, int paramIndex, float value); void removeTrackInputFX(const juce::String& trackId, int fxIndex); void removeTrackFX(const juce::String& trackId, int fxIndex); void bypassTrackInputFX(const juce::String& trackId, int fxIndex, bool bypassed); @@ -346,6 +355,9 @@ class AudioEngine : public juce::AudioIODeviceCallback, // Set all automation points for a track parameter (bulk sync from frontend) void setAutomationPoints(const juce::String& trackId, const juce::String& parameterId, const juce::String& pointsJSON); + void replaceAutomationPointsInRange(const juce::String& trackId, const juce::String& parameterId, + double startTimeSeconds, double endTimeSeconds, + const juce::String& pointsJSON); // Set automation mode for a track parameter void setAutomationMode(const juce::String& trackId, const juce::String& parameterId, const juce::String& modeStr); @@ -414,6 +426,7 @@ class AudioEngine : public juce::AudioIODeviceCallback, // MIDI Import/Export (Phase 19.9) juce::var importMIDIFile(const juce::String& filePath); + bool exportProjectMIDI(const juce::String& outputPath, const juce::var& midiTracks, double bpm = 120.0); bool exportMIDIFile(const juce::String& trackId, const juce::String& clipId, const juce::String& eventsJSON, const juce::String& outputPath, double clipTempo); @@ -498,6 +511,7 @@ class AudioEngine : public juce::AudioIODeviceCallback, juce::var getAiToolsStatus(); juce::var refreshAiToolsStatus(); juce::var installAiTools(bool userConfirmedDownload); + juce::var installAiTools(const juce::String& optionsJSON); juce::var resetAiTools(); juce::var separateStemsAsync(const juce::String& trackId, const juce::String& clipId, const juce::String& optionsJSON); juce::var getStemSeparationProgress(); @@ -590,8 +604,8 @@ class AudioEngine : public juce::AudioIODeviceCallback, juce::MidiBuffer buildTrackMidiBlock(const juce::String& trackId, double blockStartTimeSeconds, int numSamples, double sampleRate, bool playing); - void queueAllNotesOffForTrack(TrackProcessor& track); - void queueAllNotesOffForAllTracks(); + void queueAllNotesOffForTrack(TrackProcessor& track, bool requestChase = true); + void queueAllNotesOffForAllTracks(bool requestChase = true); void applyProcessingPrecisionToTrack(TrackProcessor& track); void rebuildRealtimeProcessingSnapshots(); std::unique_ptr createProcessorForStageSlot(const DesiredFXStageSlot& slot, diff --git a/Source/AutomationList.cpp b/Source/AutomationList.cpp index 6a5f2be..4c40a47 100644 --- a/Source/AutomationList.cpp +++ b/Source/AutomationList.cpp @@ -3,73 +3,131 @@ AutomationList::AutomationList() = default; -// ============================================================================ -// Message-thread write API -// ============================================================================ +void AutomationList::publishPoints(std::shared_ptr newSnapshot) +{ + if (newSnapshot == nullptr) + newSnapshot = std::make_shared(); + + pointCount.store(static_cast(newSnapshot->size()), std::memory_order_release); + std::atomic_store_explicit(&pointsSnapshot, std::move(newSnapshot), std::memory_order_release); +} void AutomationList::setPoints(std::vector newPoints) { - // Sort by time before storing std::sort(newPoints.begin(), newPoints.end(), - [](const AutomationPoint& a, const AutomationPoint& b) { + [] (const AutomationPoint& a, const AutomationPoint& b) + { return a.timeSeconds < b.timeSeconds; }); - const juce::ScopedLock sl(lock); - points = std::move(newPoints); + const juce::ScopedLock sl(writerLock); + publishPoints(std::make_shared(std::move(newPoints))); } -void AutomationList::addPoint(double timeSeconds, float value) +void AutomationList::replacePointsInRange(double startTimeSeconds, double endTimeSeconds, + std::vector replacementPoints) { - const juce::ScopedLock sl(lock); + if (endTimeSeconds < startTimeSeconds) + std::swap(startTimeSeconds, endTimeSeconds); + + const juce::ScopedLock sl(writerLock); + + auto current = std::atomic_load_explicit(&pointsSnapshot, std::memory_order_acquire); + auto next = std::make_shared(); + if (current) + { + next->reserve(current->size() + replacementPoints.size()); + for (const auto& point : *current) + if (point.timeSeconds < startTimeSeconds || point.timeSeconds > endTimeSeconds) + next->push_back(point); + } + + for (const auto& point : replacementPoints) + next->push_back(point); - AutomationPoint pt { timeSeconds, value }; + std::sort(next->begin(), next->end(), + [] (const AutomationPoint& a, const AutomationPoint& b) + { + return a.timeSeconds < b.timeSeconds; + }); + publishPoints(std::static_pointer_cast(next)); +} - // Insert in sorted order - auto it = std::lower_bound(points.begin(), points.end(), pt, - [](const AutomationPoint& a, const AutomationPoint& b) { - return a.timeSeconds < b.timeSeconds; - }); - points.insert(it, pt); +void AutomationList::addPoint(double timeSeconds, float value) +{ + const juce::ScopedLock sl(writerLock); + + auto current = std::atomic_load_explicit(&pointsSnapshot, std::memory_order_acquire); + auto next = std::make_shared(current ? *current : PointList()); + AutomationPoint point { timeSeconds, value }; + auto insertPos = std::lower_bound(next->begin(), next->end(), point, + [] (const AutomationPoint& a, const AutomationPoint& b) + { + return a.timeSeconds < b.timeSeconds; + }); + next->insert(insertPos, point); + publishPoints(std::static_pointer_cast(next)); } void AutomationList::removePointsInRange(double startTimeSeconds, double endTimeSeconds) { - const juce::ScopedLock sl(lock); - points.erase( - std::remove_if(points.begin(), points.end(), - [startTimeSeconds, endTimeSeconds](const AutomationPoint& p) { - return p.timeSeconds >= startTimeSeconds && p.timeSeconds <= endTimeSeconds; - }), - points.end()); + const juce::ScopedLock sl(writerLock); + + auto current = std::atomic_load_explicit(&pointsSnapshot, std::memory_order_acquire); + auto next = std::make_shared(current ? *current : PointList()); + next->erase(std::remove_if(next->begin(), next->end(), + [startTimeSeconds, endTimeSeconds] (const AutomationPoint& point) + { + return point.timeSeconds >= startTimeSeconds + && point.timeSeconds <= endTimeSeconds; + }), + next->end()); + publishPoints(std::static_pointer_cast(next)); } void AutomationList::clear() { - const juce::ScopedLock sl(lock); - points.clear(); + const juce::ScopedLock sl(writerLock); + publishPoints(std::make_shared()); +} + +void AutomationList::setMode(AutomationMode newMode) +{ + mode.store(newMode, std::memory_order_release); + if (newMode != AutomationMode::Touch && newMode != AutomationMode::Latch) + resetTouchAndLatch(); + else if (newMode == AutomationMode::Touch) + latchActive.store(false, std::memory_order_release); +} + +void AutomationList::beginTouch() +{ + isTouching.store(true, std::memory_order_release); + if (mode.load(std::memory_order_acquire) == AutomationMode::Latch) + latchActive.store(true, std::memory_order_release); } -int AutomationList::getNumPoints() const +void AutomationList::endTouch() { - const juce::ScopedLock sl(lock); - return static_cast(points.size()); + isTouching.store(false, std::memory_order_release); } -// ============================================================================ -// Audio-thread read API -// ============================================================================ +void AutomationList::resetTouchAndLatch() +{ + isTouching.store(false, std::memory_order_release); + latchActive.store(false, std::memory_order_release); +} bool AutomationList::shouldPlayback() const { - auto m = mode.load(std::memory_order_relaxed); - switch (m) + switch (mode.load(std::memory_order_acquire)) { case AutomationMode::Read: return true; case AutomationMode::Touch: + return !isTouching.load(std::memory_order_acquire); case AutomationMode::Latch: - return !isTouching.load(std::memory_order_relaxed); + return !latchActive.load(std::memory_order_acquire); case AutomationMode::Write: case AutomationMode::Off: default: @@ -77,17 +135,21 @@ bool AutomationList::shouldPlayback() const } } +bool AutomationList::shouldPlaybackForRead() const +{ + return mode.load(std::memory_order_acquire) != AutomationMode::Off; +} + bool AutomationList::shouldRecord() const { - auto m = mode.load(std::memory_order_relaxed); - switch (m) + switch (mode.load(std::memory_order_acquire)) { case AutomationMode::Write: return true; case AutomationMode::Touch: - return isTouching.load(std::memory_order_relaxed); + return isTouching.load(std::memory_order_acquire); case AutomationMode::Latch: - return true; // Latch always records once activated + return latchActive.load(std::memory_order_acquire); case AutomationMode::Read: case AutomationMode::Off: default: @@ -95,10 +157,8 @@ bool AutomationList::shouldRecord() const } } -int AutomationList::findPointBefore(double timeSeconds) const +int AutomationList::findPointBefore(const PointList& points, double timeSeconds) { - // Binary search: find last point at or before timeSeconds - // points must be sorted by timeSeconds (guaranteed by setPoints/addPoint) if (points.empty()) return -1; @@ -108,7 +168,7 @@ int AutomationList::findPointBefore(double timeSeconds) const while (lo <= hi) { - int mid = lo + (hi - lo) / 2; + const int mid = lo + (hi - lo) / 2; if (points[static_cast(mid)].timeSeconds <= timeSeconds) { result = mid; @@ -125,81 +185,71 @@ int AutomationList::findPointBefore(double timeSeconds) const float AutomationList::eval(double timeSeconds) const { - const juce::ScopedTryLock sl(lock); - if (!sl.isLocked() || points.empty()) + auto snapshot = std::atomic_load_explicit(&pointsSnapshot, std::memory_order_acquire); + if (!snapshot || snapshot->empty()) return defaultValue.load(std::memory_order_relaxed); - int idx = findPointBefore(timeSeconds); - - // Before first point — hold first point's value + const auto& points = *snapshot; + const int idx = findPointBefore(points, timeSeconds); if (idx < 0) return points.front().value; - auto sz = static_cast(points.size()); - - // At or after last point — hold last point's value - if (idx >= sz - 1) + const auto size = static_cast(points.size()); + if (idx >= size - 1) return points.back().value; - // Between two points — interpolate const auto& p0 = points[static_cast(idx)]; const auto& p1 = points[static_cast(idx + 1)]; + const auto interp = interpolation.load(std::memory_order_acquire); - if (interpolation == AutomationInterpolation::Discrete) + if (interp == AutomationInterpolation::Discrete) return p0.value; - double dt = p1.timeSeconds - p0.timeSeconds; + const double dt = p1.timeSeconds - p0.timeSeconds; if (dt <= 0.0) return p0.value; - double t = (timeSeconds - p0.timeSeconds) / dt; // 0.0 to 1.0 + double fraction = (timeSeconds - p0.timeSeconds) / dt; + if (interp == AutomationInterpolation::Exponential) + fraction *= fraction; - if (interpolation == AutomationInterpolation::Exponential) - { - // Quadratic ease for smoother volume curves - t = t * t; - } - - return static_cast(p0.value + (p1.value - p0.value) * t); + return static_cast(p0.value + (p1.value - p0.value) * fraction); } void AutomationList::evalBlock(double startTimeSeconds, double sampleRate, int numSamples, float* outputBuffer) const { - juce::ignoreUnused(sampleRate); + if (outputBuffer == nullptr || numSamples <= 0) + return; - const juce::ScopedTryLock sl(lock); - if (!sl.isLocked() || points.empty()) + auto snapshot = std::atomic_load_explicit(&pointsSnapshot, std::memory_order_acquire); + if (!snapshot || snapshot->empty() || sampleRate <= 0.0) { - float def = defaultValue.load(std::memory_order_relaxed); + const float def = defaultValue.load(std::memory_order_relaxed); for (int i = 0; i < numSamples; ++i) outputBuffer[i] = def; return; } - auto sz = static_cast(points.size()); - - // Start search from the point before the block start - int idx = findPointBefore(startTimeSeconds); + const auto& points = *snapshot; + const auto size = static_cast(points.size()); + const auto interp = interpolation.load(std::memory_order_acquire); + int idx = findPointBefore(points, startTimeSeconds); for (int i = 0; i < numSamples; ++i) { - double t = startTimeSeconds + static_cast(i) / sampleRate; - - // Advance idx to the correct segment for this sample - while (idx < sz - 1 && points[static_cast(idx + 1)].timeSeconds <= t) + const double timeSeconds = startTimeSeconds + static_cast(i) / sampleRate; + while (idx < size - 1 && points[static_cast(idx + 1)].timeSeconds <= timeSeconds) ++idx; if (idx < 0) { - // Before first point outputBuffer[i] = points.front().value; } - else if (idx >= sz - 1) + else if (idx >= size - 1) { - // At/after last point — fill rest of buffer and break - float val = points.back().value; + const float value = points.back().value; for (int j = i; j < numSamples; ++j) - outputBuffer[j] = val; + outputBuffer[j] = value; break; } else @@ -207,25 +257,23 @@ void AutomationList::evalBlock(double startTimeSeconds, double sampleRate, int n const auto& p0 = points[static_cast(idx)]; const auto& p1 = points[static_cast(idx + 1)]; - if (interpolation == AutomationInterpolation::Discrete) + if (interp == AutomationInterpolation::Discrete) { outputBuffer[i] = p0.value; + continue; } - else + + const double dt = p1.timeSeconds - p0.timeSeconds; + if (dt <= 0.0) { - double dt = p1.timeSeconds - p0.timeSeconds; - if (dt <= 0.0) - { - outputBuffer[i] = p0.value; - } - else - { - double frac = (t - p0.timeSeconds) / dt; - if (interpolation == AutomationInterpolation::Exponential) - frac = frac * frac; - outputBuffer[i] = static_cast(p0.value + (p1.value - p0.value) * frac); - } + outputBuffer[i] = p0.value; + continue; } + + double fraction = (timeSeconds - p0.timeSeconds) / dt; + if (interp == AutomationInterpolation::Exponential) + fraction *= fraction; + outputBuffer[i] = static_cast(p0.value + (p1.value - p0.value) * fraction); } } } diff --git a/Source/AutomationList.h b/Source/AutomationList.h index cc21b2d..599c7ad 100644 --- a/Source/AutomationList.h +++ b/Source/AutomationList.h @@ -1,113 +1,88 @@ #pragma once #include -#include #include +#include +#include // Automation interpolation mode enum class AutomationInterpolation { - Discrete, // Step — hold value until next point + Discrete, // Step: hold value until next point Linear, // Linear interpolation between points - Exponential // Exponential curve (useful for volume) + Exponential // Quadratic ease, useful for volume curves }; // Automation playback/record mode enum class AutomationMode { - Off, // No automation (use manual fader value) - Read, // Play back recorded automation - Write, // Overwrite all automation while transport rolls - Touch, // Record only while touching a control - Latch // Record on touch, continue writing last value after release + Off, // No automation; use the manual/static value + Read, // Play stored automation + Write, // Overwrite armed automation while transport rolls + Touch, // Write while touching, then return to reading + Latch // Start writing on touch, then keep writing until transport stop }; -// A single automation point (timeline time + value) struct AutomationPoint { - double timeSeconds; // Absolute timeline position in seconds - float value; // Normalised 0.0–1.0 for most params, or raw dB for volume + double timeSeconds = 0.0; + float value = 0.0f; }; // Thread-safe automation data for a single parameter. -// Message thread writes the point list; audio thread evaluates via eval(). -// Uses ScopedTryLock pattern matching the rest of the codebase (REAPER-style). +// Message thread publishes immutable point snapshots; audio thread evaluates +// those snapshots without taking locks. class AutomationList { public: AutomationList(); ~AutomationList() = default; - // --- Message-thread API (write side) --- - - // Replace all points (bulk set from frontend JSON). - // Acquires lock — audio thread will output silence during very brief window. void setPoints(std::vector newPoints); - - // Add a single point (for recording). Keeps list sorted. + void replacePointsInRange(double startTimeSeconds, double endTimeSeconds, + std::vector replacementPoints); void addPoint(double timeSeconds, float value); - - // Remove points in a time range (for automation trim / delete) void removePointsInRange(double startTimeSeconds, double endTimeSeconds); - - // Clear all points void clear(); - // Get current point count - int getNumPoints() const; + int getNumPoints() const { return pointCount.load(std::memory_order_acquire); } - // Set the default value (used when no points exist or mode is Off) void setDefaultValue(float val) { defaultValue.store(val, std::memory_order_relaxed); } float getDefaultValue() const { return defaultValue.load(std::memory_order_relaxed); } - // Mode - void setMode(AutomationMode newMode) { mode.store(newMode, std::memory_order_relaxed); } + void setMode(AutomationMode newMode); AutomationMode getMode() const { return mode.load(std::memory_order_relaxed); } - // Interpolation style - void setInterpolation(AutomationInterpolation interp) { interpolation = interp; } - AutomationInterpolation getInterpolation() const { return interpolation; } + void setInterpolation(AutomationInterpolation interp) { interpolation.store(interp, std::memory_order_release); } + AutomationInterpolation getInterpolation() const { return interpolation.load(std::memory_order_acquire); } - // Touch state (set from message thread when user grabs/releases a fader) - void beginTouch() { isTouching.store(true, std::memory_order_relaxed); } - void endTouch() { isTouching.store(false, std::memory_order_relaxed); } + void beginTouch(); + void endTouch(); + void resetTouchAndLatch(); bool touching() const { return isTouching.load(std::memory_order_relaxed); } - // --- Audio-thread API (read side) --- - - // Evaluate automation value at a single sample position. - // Returns the interpolated value, or defaultValue if no points / mode is Off. - // Uses ScopedTryLock — returns defaultValue if lock is held (message thread writing). float eval(double timeSeconds) const; - - // Batch evaluate: fill outputBuffer with per-sample values for a block. - // startTimeSeconds = timeline position of first sample in block. - // Much more efficient than calling eval() per sample — uses cached search position. void evalBlock(double startTimeSeconds, double sampleRate, int numSamples, float* outputBuffer) const; - // Should the audio thread apply automation right now? - // Read mode: always. Touch: only when NOT touching. Latch: when NOT touching. - // Write mode: never (audio thread doesn't apply — it's being overwritten). bool shouldPlayback() const; - - // Should the message thread record automation right now? - // Write: always during playback. Touch: only while touching. Latch: while touching + after. + bool shouldPlaybackForRead() const; bool shouldRecord() const; private: - // Points — sorted by timeSeconds, protected by lock - std::vector points; - mutable juce::CriticalSection lock; + using PointList = std::vector; + + std::shared_ptr pointsSnapshot { std::make_shared() }; + mutable juce::CriticalSection writerLock; + std::atomic pointCount { 0 }; - // Atomic state (read from audio thread without lock) std::atomic mode { AutomationMode::Off }; std::atomic defaultValue { 0.0f }; std::atomic isTouching { false }; + std::atomic latchActive { false }; + std::atomic interpolation { AutomationInterpolation::Linear }; - AutomationInterpolation interpolation { AutomationInterpolation::Linear }; - - // Binary search helper — find index of last point at or before timeSeconds - int findPointBefore(double timeSeconds) const; + static int findPointBefore(const PointList& points, double timeSeconds); + void publishPoints(std::shared_ptr newSnapshot); JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AutomationList) }; diff --git a/Source/Main.cpp b/Source/Main.cpp index e8281c2..82613ec 100644 --- a/Source/Main.cpp +++ b/Source/Main.cpp @@ -7,6 +7,7 @@ #include #include +#include #if JUCE_WINDOWS #include @@ -464,6 +465,20 @@ int runHeadlessPitchRegressionJob(AudioEngine& audioEngine, const juce::String& + " objectiveGateStatus=" + juce::String(failed ? "fail" : "pass")); return failed ? 2 : 0; } + +int runHeadlessAutomatedRegressionSuite(AudioEngine& audioEngine, const juce::File& reportFile) +{ + auto result = audioEngine.runAutomatedRegressionSuite(); + const bool wroteReport = writeHeadlessResult(reportFile, result); + const bool overallPass = result.isObject() + && static_cast(result.getProperty("overallPass", false)); + + juce::Logger::writeToLog("[automatedRegression.headless] report=" + reportFile.getFullPathName() + + " wroteReport=" + juce::String(wroteReport ? "true" : "false") + + " overallPass=" + juce::String(overallPass ? "true" : "false")); + + return wroteReport && overallPass ? 0 : 2; +} } //============================================================================== @@ -488,6 +503,7 @@ class OpenStudioApplication : public juce::JUCEApplication OpenStudioLaunchState::setPendingProjectPath(commandLine); const auto startupSelfTestMode = commandLineHasFlag(commandLine, "--startup-self-test"); + const auto automatedRegressionHeadlessMode = commandLineHasFlag(commandLine, "--automated-regression-headless"); const auto startupSelfTestReportPath = getCommandLineOptionValue(commandLine, "--report"); const auto pitchRegressionHeadlessJobPath = getCommandLineOptionValue(commandLine, "--pitch-regression-headless"); const auto pitchRegressionJobPath = getCommandLineOptionValue(commandLine, "--pitch-regression"); @@ -524,6 +540,18 @@ class OpenStudioApplication : public juce::JUCEApplication return; } + if (automatedRegressionHeadlessMode) + { + const auto reportFile = startupSelfTestReportPath.isNotEmpty() + ? juce::File(startupSelfTestReportPath) + : getWritableStartupLogFile().getSiblingFile("OpenStudio_AutomatedRegression.json"); + + const auto exitCode = runHeadlessAutomatedRegressionSuite(audioEngine, reportFile); + setApplicationReturnValue(exitCode); + quit(); + return; + } + if (pitchRegressionHeadlessJobPath.isNotEmpty()) { const auto exitCode = runHeadlessPitchRegressionJob(audioEngine, pitchRegressionHeadlessJobPath); @@ -580,6 +608,7 @@ class OpenStudioApplication : public juce::JUCEApplication { juce::Logger::writeToLog("Application Check-out."); + midiEditorWindowManagers.clear(); mixerWindowManager = nullptr; mainWindow = nullptr; @@ -590,6 +619,12 @@ class OpenStudioApplication : public juce::JUCEApplication { if (mixerWindowManager != nullptr) mixerWindowManager->close(); + for (auto& entry : midiEditorWindowManagers) + if (entry.second != nullptr) + { + midiEditorWindowCloseReasons[entry.first] = "appQuit"; + entry.second->close(); + } quit(); } @@ -717,6 +752,34 @@ class OpenStudioApplication : public juce::JUCEApplication { return getMixerUISnapshot(); }; + callbacks.openMidiEditorWindow = [this](const juce::String& sessionId, const juce::var& bounds) + { + return openMidiEditorWindow(sessionId, bounds); + }; + callbacks.prewarmMidiEditorWindow = [this](const juce::String& sessionId, const juce::var& bounds) + { + return prewarmMidiEditorWindow(sessionId, bounds); + }; + callbacks.focusMidiEditorWindow = [this](const juce::String& sessionId) + { + return focusMidiEditorWindow(sessionId); + }; + callbacks.closeMidiEditorWindow = [this](const juce::String& sessionId, const juce::String& reason) + { + return closeMidiEditorWindow(sessionId, reason); + }; + callbacks.getMidiEditorWindowState = [this](const juce::String& sessionId) + { + return getMidiEditorWindowState(sessionId); + }; + callbacks.publishMidiEditorUISnapshot = [this](const juce::String& sessionId, const juce::var& snapshot) + { + publishMidiEditorUISnapshot(sessionId, snapshot); + }; + callbacks.getMidiEditorUISnapshot = [this](const juce::String& sessionId) + { + return getMidiEditorUISnapshot(sessionId); + }; return callbacks; } @@ -759,6 +822,117 @@ class OpenStudioApplication : public juce::JUCEApplication return latestMixerUISnapshot; } + juce::String normaliseMidiEditorSessionId(const juce::String& sessionId) const + { + const auto trimmed = sessionId.trim(); + return trimmed.isNotEmpty() ? trimmed : juce::String("default-midi-editor"); + } + + MixerWindowManager* getOrCreateMidiEditorWindowManager(const juce::String& sessionId) + { + const auto safeSessionId = normaliseMidiEditorSessionId(sessionId); + auto existing = midiEditorWindowManagers.find(safeSessionId); + if (existing != midiEditorWindowManagers.end()) + return existing->second.get(); + + auto manager = std::make_unique( + [this, safeSessionId]() + { + return std::make_unique(audioEngine, + appUpdater, + startupMode, + MainComponent::WindowRole::midiEditor, + createWindowCallbacks(), + juce::String(), + safeSessionId); + }, + [this, safeSessionId](const juce::Rectangle& bounds) + { + handleMidiEditorWindowClosed(safeSessionId, bounds); + }, + "MIDI Editor", + juce::Rectangle(140, 100, 1400, 850), + 900, + 560); + + auto* result = manager.get(); + midiEditorWindowManagers[safeSessionId] = std::move(manager); + return result; + } + + bool openMidiEditorWindow(const juce::String& sessionId, const juce::var& boundsValue) + { + if (auto* manager = getOrCreateMidiEditorWindowManager(sessionId)) + return manager->open(rectangleFromVar(boundsValue)); + + return false; + } + + bool prewarmMidiEditorWindow(const juce::String& sessionId, const juce::var& boundsValue) + { + if (auto* manager = getOrCreateMidiEditorWindowManager(sessionId)) + return manager->prewarm(rectangleFromVar(boundsValue)); + + return false; + } + + bool focusMidiEditorWindow(const juce::String& sessionId) + { + const auto safeSessionId = normaliseMidiEditorSessionId(sessionId); + auto existing = midiEditorWindowManagers.find(safeSessionId); + if (existing == midiEditorWindowManagers.end() || existing->second == nullptr) + return false; + + return existing->second->focus(); + } + + bool closeMidiEditorWindow(const juce::String& sessionId, const juce::String& reason) + { + const auto safeSessionId = normaliseMidiEditorSessionId(sessionId); + auto existing = midiEditorWindowManagers.find(safeSessionId); + if (existing == midiEditorWindowManagers.end() || existing->second == nullptr) + return false; + + const auto closeReason = reason.trim().isNotEmpty() ? reason.trim() : juce::String("close"); + midiEditorWindowCloseReasons[safeSessionId] = closeReason; + + if (closeReason == "dock") + return existing->second->hide(); + + return existing->second->close(); + } + + juce::var getMidiEditorWindowState(const juce::String& sessionId) const + { + const auto safeSessionId = normaliseMidiEditorSessionId(sessionId); + const auto existing = midiEditorWindowManagers.find(safeSessionId); + auto* obj = new juce::DynamicObject(); + obj->setProperty("isOpen", existing != midiEditorWindowManagers.end() + && existing->second != nullptr + && existing->second->isOpen()); + obj->setProperty("sessionId", safeSessionId); + return juce::var(obj); + } + + void publishMidiEditorUISnapshot(const juce::String& sessionId, const juce::var& snapshot) + { + const auto safeSessionId = normaliseMidiEditorSessionId(sessionId); + { + const juce::ScopedLock sl(midiEditorSnapshotLock); + latestMidiEditorUISnapshots[safeSessionId] = snapshot; + } + + MainComponent::broadcastEventToAll("midiEditorUISync", snapshot); + } + + juce::var getMidiEditorUISnapshot(const juce::String& sessionId) const + { + const auto safeSessionId = normaliseMidiEditorSessionId(sessionId); + const juce::ScopedLock sl(midiEditorSnapshotLock); + const auto existing = latestMidiEditorUISnapshots.find(safeSessionId); + return existing != latestMidiEditorUISnapshots.end() ? existing->second : juce::var(); + } + void handleMixerWindowClosed(const juce::Rectangle& bounds) { if (auto* component = mainWindow != nullptr ? mainWindow->getMainComponent() : nullptr) @@ -769,13 +943,33 @@ class OpenStudioApplication : public juce::JUCEApplication MainComponent::broadcastEventToRole(MainComponent::WindowRole::main, "mixerWindowClosed", juce::var(payload)); } + void handleMidiEditorWindowClosed(const juce::String& sessionId, const juce::Rectangle& bounds) + { + const auto reasonIt = midiEditorWindowCloseReasons.find(sessionId); + const auto reason = reasonIt != midiEditorWindowCloseReasons.end() + ? reasonIt->second + : juce::String("close"); + if (reasonIt != midiEditorWindowCloseReasons.end()) + midiEditorWindowCloseReasons.erase(reasonIt); + + auto* payload = new juce::DynamicObject(); + payload->setProperty("sessionId", sessionId); + payload->setProperty("reason", reason); + payload->setProperty("bounds", rectangleToVar(bounds)); + MainComponent::broadcastEventToRole(MainComponent::WindowRole::main, "midiEditorWindowClosed", juce::var(payload)); + } + AudioEngine audioEngine; AppUpdater appUpdater; MainComponent::StartupMode startupMode = MainComponent::StartupMode::normal; std::unique_ptr mainWindow; std::unique_ptr mixerWindowManager; + std::map> midiEditorWindowManagers; mutable juce::CriticalSection mixerSnapshotLock; juce::var latestMixerUISnapshot; + mutable juce::CriticalSection midiEditorSnapshotLock; + std::map latestMidiEditorUISnapshots; + std::map midiEditorWindowCloseReasons; }; START_JUCE_APPLICATION (OpenStudioApplication) diff --git a/Source/MainComponent.cpp b/Source/MainComponent.cpp index 1389e1b..ee454de 100644 --- a/Source/MainComponent.cpp +++ b/Source/MainComponent.cpp @@ -109,6 +109,51 @@ juce::File getStartupLogFile() return getExecutableDirectory().getChildFile("OpenStudio_Debug.log"); } +juce::File getRecentProjectsFile() +{ + auto appDataDir = juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory) + .getChildFile("OpenStudio"); + appDataDir.createDirectory(); + return appDataDir.getChildFile("recent_projects.json"); +} + +juce::var readRecentProjectsFile() +{ + juce::Array empty; + const auto file = getRecentProjectsFile(); + if (! file.existsAsFile()) + return juce::var(empty); + + const auto parsed = juce::JSON::parse(file.loadFileAsString()); + if (parsed.isArray()) + return parsed; + + return juce::var(empty); +} + +bool writeRecentProjectsFile(const juce::var& rawProjects) +{ + auto* projects = rawProjects.getArray(); + if (projects == nullptr) + return false; + + juce::StringArray seen; + juce::Array sanitized; + for (const auto& item : *projects) + { + const auto path = item.toString().trim(); + if (path.isEmpty() || seen.contains(path)) + continue; + + seen.add(path); + sanitized.add(path); + if (sanitized.size() >= 10) + break; + } + + return getRecentProjectsFile().replaceWithText(juce::JSON::toString(juce::var(sanitized), false)); +} + juce::String describeBrowserBackend(juce::WebBrowserComponent::Options::Backend backend) { switch (backend) @@ -507,7 +552,11 @@ juce::Rectangle getWindowRestoreBoundsFromPlacement(HWND hwnd) juce::String getWindowRoleQueryValue(MainComponent::WindowRole role) { - return role == MainComponent::WindowRole::mixer ? "mixer" : "main"; + if (role == MainComponent::WindowRole::mixer) + return "mixer"; + if (role == MainComponent::WindowRole::midiEditor) + return "midiEditor"; + return "main"; } juce::String getStartupModeQueryValue(MainComponent::StartupMode startupMode) @@ -530,7 +579,7 @@ juce::String getHostPlatformQueryValue() juce::String getWindowChromeQueryValue(MainComponent::WindowRole role) { - if (role == MainComponent::WindowRole::mixer) + if (role == MainComponent::WindowRole::mixer || role == MainComponent::WindowRole::midiEditor) return "native"; #if JUCE_MAC @@ -583,7 +632,8 @@ bool isLocalFrontendDevServerReachable() juce::String appendFrontendStartupQuery(const juce::String& baseUrl, MainComponent::WindowRole role, - MainComponent::StartupMode startupMode) + MainComponent::StartupMode startupMode, + const juce::String& windowInstanceId = {}) { juce::String url = baseUrl; const auto appendParameter = [&url](const juce::String& key, const juce::String& value) @@ -596,6 +646,8 @@ juce::String appendFrontendStartupQuery(const juce::String& baseUrl, appendParameter("startup", getStartupModeQueryValue(startupMode)); appendParameter("platform", getHostPlatformQueryValue()); appendParameter("windowChrome", getWindowChromeQueryValue(role)); + if (windowInstanceId.isNotEmpty()) + appendParameter("sessionId", windowInstanceId); appendParameter("cacheBust", juce::String(juce::Time::getCurrentTime().toMilliseconds())); return url; } @@ -747,11 +799,13 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, StartupMode startupModeIn, WindowRole roleIn, WindowCallbacks callbacksIn, - const juce::String& pitchRegressionJobPathIn) + const juce::String& pitchRegressionJobPathIn, + const juce::String& windowInstanceIdIn) : audioEngine(audioEngineIn), appUpdater(appUpdaterIn), startupMode(startupModeIn), windowRole(roleIn), + windowInstanceId(windowInstanceIdIn), windowCallbacks(std::move(callbacksIn)), webView (juce::WebBrowserComponent::Options() .withBackend (getPreferredBrowserBackend()) @@ -993,7 +1047,11 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, if (args.size() > 0 && args[0].isString()) { explicitId = args[0].toString(); } - juce::String trackId = audioEngine.addTrack(explicitId); + juce::String initialType = ""; + if (args.size() > 1 && args[1].isString()) { + initialType = args[1].toString(); + } + juce::String trackId = audioEngine.addTrack(explicitId, initialType); completion(trackId); }) .withNativeFunction ("removeTrack", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { @@ -1384,6 +1442,18 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, completion(juce::Array()); } }) + .withNativeFunction ("setPluginParameter", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() == 5 && args[0].isString()) { + juce::String trackId = args[0].toString(); + int fxIndex = static_cast(args[1]); + bool isInputFX = static_cast(args[2]); + int paramIndex = static_cast(args[3]); + float value = static_cast(static_cast(args[4])); + completion(audioEngine.setPluginParameter(trackId, fxIndex, isInputFX, paramIndex, value)); + } else { + completion(false); + } + }) .withNativeFunction ("removeTrackInputFX", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() == 2 && args[1].isInt()) { juce::String trackId = args[0].toString(); @@ -1872,6 +1942,43 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, completion(false); } }) + .withNativeFunction ("removeInstrument", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() == 1 && args[0].isString()) { + completion(audioEngine.removeInstrument(args[0].toString())); + } else { + completion(false); + } + }) + .withNativeFunction ("setTrackSamplerSample", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() == 3 && args[0].isString() && args[1].isString()) { + completion(audioEngine.setTrackSamplerSample(args[0].toString(), + args[1].toString(), + static_cast(args[2]))); + } else { + completion(false); + } + }) + .withNativeFunction ("clearTrackSamplerSample", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() == 1 && args[0].isString()) { + completion(audioEngine.clearTrackSamplerSample(args[0].toString())); + } else { + completion(false); + } + }) + .withNativeFunction ("getInstrumentState", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() == 1 && args[0].isString()) { + completion(audioEngine.getInstrumentState(args[0].toString())); + } else { + completion(juce::String()); + } + }) + .withNativeFunction ("setInstrumentState", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() == 2 && args[0].isString() && args[1].isString()) { + completion(audioEngine.setInstrumentState(args[0].toString(), args[1].toString())); + } else { + completion(false); + } + }) .withNativeFunction ("sendMidiNote", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() == 4 && args[0].isString()) { completion(audioEngine.sendMidiNote(args[0].toString(), @@ -1882,6 +1989,18 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, completion(false); } }) + .withNativeFunction ("getTrackMIDINoteActivity", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() >= 1 && args[0].isString()) { + const int maxAgeMs = args.size() >= 2 ? static_cast(args[1]) : 1200; + completion(audioEngine.getTrackMIDINoteActivity(args[0].toString(), maxAgeMs)); + } else { + completion(juce::Array()); + } + }) + .withNativeFunction ("panicMIDI", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::ignoreUnused(args); + completion(audioEngine.panicMIDI()); + }) .withNativeFunction ("getActiveRecordingMIDIPreviews", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() == 1) { completion(audioEngine.getActiveRecordingMIDIPreviews(args[0])); @@ -2113,6 +2232,19 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, completion(""); } }) + .withNativeFunction ("getRecentProjects", [] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::ignoreUnused(args); + completion(readRecentProjectsFile()); + }) + .withNativeFunction ("setRecentProjects", [] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() != 1) + { + completion(false); + return; + } + + completion(writeRecentProjectsFile(args[0])); + }) .withNativeFunction ("consumePendingLaunchProjectPath", [] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { juce::ignoreUnused(args); completion(OpenStudioLaunchState::consumePendingProjectPath()); @@ -2961,71 +3093,10 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, } }) .withNativeFunction ("exportProjectMIDI", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { - juce::ignoreUnused(this); - // Args: [filePath, midiTracksJson] - // midiTracksJson is an array of { name, clips: [{ startTime, duration, events: [{ type, timestamp, note, velocity }] }] } + // Args: [filePath, midiTracks] + // midiTracks is an array of { name, clips: [{ startTime, duration, events }] }. if (args.size() >= 2 && args[0].isString() && args[1].isArray()) { - juce::String filePath = args[0].toString(); - auto* tracksArray = args[1].getArray(); - - juce::MidiFile midiFile; - midiFile.setTicksPerQuarterNote(480); - - double bpm = 120.0; // Default BPM - - if (tracksArray) { - for (const auto& trackVar : *tracksArray) { - if (auto* trackObj = trackVar.getDynamicObject()) { - juce::MidiMessageSequence sequence; - - auto* clips = trackObj->getProperty("clips").getArray(); - if (clips) { - for (const auto& clipVar : *clips) { - if (auto* clipObj = clipVar.getDynamicObject()) { - double clipStart = (double)clipObj->getProperty("startTime"); - auto* events = clipObj->getProperty("events").getArray(); - if (events) { - for (const auto& eventVar : *events) { - if (auto* eventObj = eventVar.getDynamicObject()) { - juce::String eventType = eventObj->getProperty("type").toString(); - double timestamp = (double)eventObj->getProperty("timestamp"); - double absoluteTime = clipStart + timestamp; - double ticks = absoluteTime * bpm / 60.0 * 480.0; - - if (eventType == "noteOn") { - int note = (int)eventObj->getProperty("note"); - int velocity = (int)eventObj->getProperty("velocity"); - sequence.addEvent(juce::MidiMessage::noteOn(1, note, (juce::uint8)velocity), ticks); - } else if (eventType == "noteOff") { - int note = (int)eventObj->getProperty("note"); - sequence.addEvent(juce::MidiMessage::noteOff(1, note), ticks); - } else if (eventType == "cc") { - int controller = (int)eventObj->getProperty("controller"); - int value = (int)eventObj->getProperty("value"); - sequence.addEvent(juce::MidiMessage::controllerEvent(1, controller, value), ticks); - } - } - } - } - } - } - } - - sequence.updateMatchedPairs(); - midiFile.addTrack(sequence); - } - } - } - - juce::File outFile(filePath); - outFile.deleteFile(); - std::unique_ptr stream(outFile.createOutputStream()); - if (stream) { - bool success = midiFile.writeTo(*stream); - completion(success); - } else { - completion(false); - } + completion(audioEngine.exportProjectMIDI(args[0].toString(), args[1], 120.0)); } else { completion(false); } @@ -3967,16 +4038,29 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, const bool isNowMaximized = toggleDesktopPseudoMaximize(); completion(juce::var(isNowMaximized)); }) - .withNativeFunction ("closeWindow", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + .withNativeFunction ("closeWindow", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { juce::ignoreUnused(args); completion(juce::var()); if (isMainWindow()) { requestFrontendAppClose(); } + else if (windowRole == WindowRole::midiEditor && windowCallbacks.closeMidiEditorWindow) + { + auto closeMidiEditorWindow = windowCallbacks.closeMidiEditorWindow; + const auto sessionId = windowInstanceId.isNotEmpty() ? windowInstanceId : juce::String("default-midi-editor"); + juce::MessageManager::callAsync([closeMidiEditorWindow, sessionId]() + { + closeMidiEditorWindow(sessionId, "close"); + }); + } else if (windowCallbacks.closeMixerWindow) { - windowCallbacks.closeMixerWindow(); + auto closeMixerWindow = windowCallbacks.closeMixerWindow; + juce::MessageManager::callAsync([closeMixerWindow]() + { + closeMixerWindow(); + }); } }) .withNativeFunction ("quitApplication", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { @@ -4010,8 +4094,15 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, }) .withNativeFunction ("closeMixerWindow", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { juce::ignoreUnused(args); - const bool closed = windowCallbacks.closeMixerWindow ? windowCallbacks.closeMixerWindow() : false; - completion(juce::var(closed)); + auto closeMixerWindow = windowCallbacks.closeMixerWindow; + completion(juce::var(static_cast(closeMixerWindow))); + if (closeMixerWindow) + { + juce::MessageManager::callAsync([closeMixerWindow]() + { + closeMixerWindow(); + }); + } }) .withNativeFunction ("getMixerWindowState", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { juce::ignoreUnused(args); @@ -4032,6 +4123,91 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, else completion(juce::var()); }) + .withNativeFunction ("openMidiEditorWindow", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::String sessionId = windowInstanceId; + juce::var bounds; + if (args.size() > 0 && args[0].isString()) + sessionId = args[0].toString(); + if (args.size() > 1) + bounds = args[1]; + else if (args.size() > 0 && ! args[0].isString()) + bounds = args[0]; + if (sessionId.isEmpty()) + sessionId = "default-midi-editor"; + + const bool opened = windowCallbacks.openMidiEditorWindow ? windowCallbacks.openMidiEditorWindow(sessionId, bounds) : false; + completion(juce::var(opened)); + }) + .withNativeFunction ("prewarmMidiEditorWindow", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::String sessionId = windowInstanceId; + juce::var bounds; + if (args.size() > 0 && args[0].isString()) + sessionId = args[0].toString(); + if (args.size() > 1) + bounds = args[1]; + else if (args.size() > 0 && ! args[0].isString()) + bounds = args[0]; + if (sessionId.isEmpty()) + sessionId = "default-midi-editor"; + + const bool warmed = windowCallbacks.prewarmMidiEditorWindow ? windowCallbacks.prewarmMidiEditorWindow(sessionId, bounds) : false; + completion(juce::var(warmed)); + }) + .withNativeFunction ("focusMidiEditorWindow", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::String sessionId = args.size() > 0 && args[0].isString() ? args[0].toString() : windowInstanceId; + if (sessionId.isEmpty()) + sessionId = "default-midi-editor"; + + const bool focused = windowCallbacks.focusMidiEditorWindow ? windowCallbacks.focusMidiEditorWindow(sessionId) : false; + completion(juce::var(focused)); + }) + .withNativeFunction ("closeMidiEditorWindow", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::String sessionId = args.size() > 0 && args[0].isString() ? args[0].toString() : windowInstanceId; + juce::String reason = args.size() > 1 && args[1].isString() ? args[1].toString() : "close"; + if (sessionId.isEmpty()) + sessionId = "default-midi-editor"; + + const bool canClose = static_cast(windowCallbacks.closeMidiEditorWindow); + completion(juce::var(canClose)); + if (canClose) + { + auto callback = windowCallbacks.closeMidiEditorWindow; + juce::MessageManager::callAsync([callback, sessionId, reason]() + { + callback(sessionId, reason); + }); + } + }) + .withNativeFunction ("getMidiEditorWindowState", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::String sessionId = args.size() > 0 && args[0].isString() ? args[0].toString() : windowInstanceId; + if (sessionId.isEmpty()) + sessionId = "default-midi-editor"; + if (windowCallbacks.getMidiEditorWindowState) + completion(windowCallbacks.getMidiEditorWindowState(sessionId)); + else + completion(juce::var()); + }) + .withNativeFunction ("publishMidiEditorUISnapshot", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() > 1 && args[0].isString() && windowCallbacks.publishMidiEditorUISnapshot) + windowCallbacks.publishMidiEditorUISnapshot(args[0].toString(), args[1]); + else if (args.size() > 0 && windowCallbacks.publishMidiEditorUISnapshot) + windowCallbacks.publishMidiEditorUISnapshot(windowInstanceId.isNotEmpty() ? windowInstanceId : "default-midi-editor", args[0]); + completion(juce::var(true)); + }) + .withNativeFunction ("getMidiEditorUISnapshot", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::String sessionId = args.size() > 0 && args[0].isString() ? args[0].toString() : windowInstanceId; + if (sessionId.isEmpty()) + sessionId = "default-midi-editor"; + if (windowCallbacks.getMidiEditorUISnapshot) + completion(windowCallbacks.getMidiEditorUISnapshot(sessionId)); + else + completion(juce::var()); + }) + .withNativeFunction ("publishAppCommand", [] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + const juce::var payload = args.size() > 0 ? args[0] : juce::var(); + MainComponent::broadcastEventToRole(MainComponent::WindowRole::main, "appCommand", payload); + completion(juce::var(true)); + }) // ========== Automation (Phase 1.1) ========== .withNativeFunction ("setAutomationPoints", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() == 3) { @@ -4041,6 +4217,18 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, completion(false); } }) + .withNativeFunction ("replaceAutomationPointsInRange", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() == 5) { + audioEngine.replaceAutomationPointsInRange(args[0].toString(), + args[1].toString(), + static_cast(args[2]), + static_cast(args[3]), + args[4].toString()); + completion(true); + } else { + completion(false); + } + }) .withNativeFunction ("setAutomationMode", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() == 3) { audioEngine.setAutomationMode(args[0].toString(), args[1].toString(), args[2].toString()); @@ -4159,7 +4347,14 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, } }) .withNativeFunction ("exportMIDIFile", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { - if (args.size() >= 5) { + if (args.size() == 2 && args[0].isString()) { + auto tracks = args[1]; + if (args[1].isString()) + tracks = juce::JSON::parse(args[1].toString()); + + completion(tracks.isArray() + && audioEngine.exportProjectMIDI(args[0].toString(), tracks, 120.0)); + } else if (args.size() >= 5) { bool ok = audioEngine.exportMIDIFile( args[0].toString(), // trackId args[1].toString(), // clipId @@ -5405,6 +5600,12 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, completion(audioEngine.refreshAiToolsStatus()); }) .withNativeFunction ("installAiTools", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() > 0 && args[0].isString()) + { + completion(audioEngine.installAiTools(args[0].toString())); + return; + } + const bool userConfirmedDownload = args.size() > 0 && static_cast(args[0]); completion(audioEngine.installAiTools(userConfirmedDownload)); }) @@ -5603,7 +5804,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, #if JUCE_DEBUG if (isLocalFrontendDevServerReachable()) { - const auto frontendUrl = appendFrontendStartupQuery("http://localhost:5173", windowRole, startupMode); + const auto frontendUrl = appendFrontendStartupQuery("http://localhost:5173", windowRole, startupMode, windowInstanceId); juce::Logger::writeToLog("Loading frontend from localhost:5173"); beginFrontendStartupWatchdog(frontendUrl); webView.goToURL(frontendUrl); @@ -5962,7 +6163,13 @@ void MainComponent::requestFrontendAppClose() if (! isMainWindow()) { if (windowCallbacks.closeMixerWindow) - windowCallbacks.closeMixerWindow(); + { + auto closeMixerWindow = windowCallbacks.closeMixerWindow; + juce::MessageManager::callAsync([closeMixerWindow]() + { + closeMixerWindow(); + }); + } return; } @@ -6031,7 +6238,8 @@ bool MainComponent::loadPackagedFrontend() const auto frontendUrl = appendFrontendStartupQuery( juce::WebBrowserComponent::getResourceProviderRoot() + "index.html", windowRole, - startupMode); + startupMode, + windowInstanceId); beginFrontendStartupWatchdog(frontendUrl); webView.goToURL(frontendUrl); return true; diff --git a/Source/MainComponent.h b/Source/MainComponent.h index 80135a9..41f50bb 100644 --- a/Source/MainComponent.h +++ b/Source/MainComponent.h @@ -22,7 +22,8 @@ class MainComponent : public juce::Component, enum class WindowRole { main, - mixer + mixer, + midiEditor }; enum class FrontendStartupState @@ -50,6 +51,13 @@ class MainComponent : public juce::Component, std::function getMixerWindowState; std::function publishMixerUISnapshot; std::function getMixerUISnapshot; + std::function openMidiEditorWindow; + std::function prewarmMidiEditorWindow; + std::function focusMidiEditorWindow; + std::function closeMidiEditorWindow; + std::function getMidiEditorWindowState; + std::function publishMidiEditorUISnapshot; + std::function getMidiEditorUISnapshot; }; //============================================================================== @@ -58,7 +66,8 @@ class MainComponent : public juce::Component, StartupMode startupModeIn, WindowRole roleIn, WindowCallbacks callbacksIn = {}, - const juce::String& pitchRegressionJobPathIn = {}); + const juce::String& pitchRegressionJobPathIn = {}, + const juce::String& windowInstanceIdIn = {}); ~MainComponent() override; //============================================================================== @@ -105,6 +114,7 @@ class MainComponent : public juce::Component, AppUpdater& appUpdater; StartupMode startupMode = StartupMode::normal; WindowRole windowRole = WindowRole::main; + juce::String windowInstanceId; WindowCallbacks windowCallbacks; juce::File webuiDir; juce::WebBrowserComponent webView; diff --git a/Source/MixerWindowManager.cpp b/Source/MixerWindowManager.cpp index 55c3e3a..13daff8 100644 --- a/Source/MixerWindowManager.cpp +++ b/Source/MixerWindowManager.cpp @@ -3,14 +3,14 @@ namespace { -juce::Rectangle sanitiseMixerBounds(const juce::Rectangle& requested) +juce::Rectangle sanitiseWindowBounds(const juce::Rectangle& requested, + const juce::Rectangle& defaultBounds, + int minWidth, + int minHeight) { - constexpr int minWidth = 900; - constexpr int minHeight = 380; - auto bounds = requested; if (bounds.getWidth() <= 0 || bounds.getHeight() <= 0) - bounds = { 120, 120, 1280, 540 }; + bounds = defaultBounds; bounds.setWidth(juce::jmax(minWidth, bounds.getWidth())); bounds.setHeight(juce::jmax(minHeight, bounds.getHeight())); @@ -34,14 +34,14 @@ class MixerWindowManager::MixerWindow : public juce::DocumentWindow { public: MixerWindow(MixerWindowManager& ownerIn, std::unique_ptr content) - : juce::DocumentWindow("Mixer", + : juce::DocumentWindow(ownerIn.windowTitle, juce::Colours::black, juce::DocumentWindow::allButtons), owner(ownerIn) { setUsingNativeTitleBar(true); setResizable(true, true); - setResizeLimits(900, 380, 10000, 10000); + setResizeLimits(owner.minWidth, owner.minHeight, 10000, 10000); setContentOwned(content.release(), true); } @@ -55,9 +55,17 @@ class MixerWindowManager::MixerWindow : public juce::DocumentWindow }; MixerWindowManager::MixerWindowManager(ComponentFactory componentFactoryIn, - ClosedCallback closedCallbackIn) + ClosedCallback closedCallbackIn, + juce::String windowTitleIn, + juce::Rectangle defaultBoundsIn, + int minWidthIn, + int minHeightIn) : componentFactory(std::move(componentFactoryIn)), - closedCallback(std::move(closedCallbackIn)) + closedCallback(std::move(closedCallbackIn)), + windowTitle(std::move(windowTitleIn)), + defaultBounds(defaultBoundsIn), + minWidth(minWidthIn), + minHeight(minHeightIn) { } @@ -68,7 +76,7 @@ MixerWindowManager::~MixerWindowManager() bool MixerWindowManager::open(const juce::Rectangle& bounds) { - const auto targetBounds = sanitiseMixerBounds(bounds); + const auto targetBounds = sanitiseWindowBounds(bounds, defaultBounds, minWidth, minHeight); if (mixerWindow != nullptr) { @@ -104,6 +112,30 @@ bool MixerWindowManager::open(const juce::Rectangle& bounds) return true; } +bool MixerWindowManager::prewarm(const juce::Rectangle& bounds) +{ + const auto targetBounds = sanitiseWindowBounds(bounds, defaultBounds, minWidth, minHeight); + + if (mixerWindow != nullptr) + { + mixerWindow->setBounds(targetBounds); + mixerWindow->setVisible(false); + return true; + } + + if (!componentFactory) + return false; + + auto content = componentFactory(); + if (content == nullptr) + return false; + + mixerWindow = std::make_unique(*this, std::move(content)); + mixerWindow->setBounds(targetBounds); + mixerWindow->setVisible(false); + return true; +} + bool MixerWindowManager::close() { if (mixerWindow == nullptr) @@ -113,9 +145,33 @@ bool MixerWindowManager::close() return true; } +bool MixerWindowManager::focus() +{ + if (mixerWindow == nullptr) + return false; + + mixerWindow->setVisible(true); + mixerWindow->toFront(true); + return true; +} + +bool MixerWindowManager::hide() +{ + if (mixerWindow == nullptr) + return false; + + const auto bounds = mixerWindow->getBounds(); + mixerWindow->setVisible(false); + + if (closedCallback) + closedCallback(bounds); + + return true; +} + bool MixerWindowManager::isOpen() const { - return mixerWindow != nullptr; + return mixerWindow != nullptr && mixerWindow->isVisible(); } void MixerWindowManager::handleWindowClosed() diff --git a/Source/MixerWindowManager.h b/Source/MixerWindowManager.h index de686d8..6455150 100644 --- a/Source/MixerWindowManager.h +++ b/Source/MixerWindowManager.h @@ -13,10 +13,17 @@ class MixerWindowManager using ClosedCallback = std::function&)>; MixerWindowManager(ComponentFactory componentFactoryIn, - ClosedCallback closedCallbackIn); + ClosedCallback closedCallbackIn, + juce::String windowTitleIn = "Mixer", + juce::Rectangle defaultBoundsIn = { 120, 120, 1280, 540 }, + int minWidthIn = 900, + int minHeightIn = 380); ~MixerWindowManager(); bool open(const juce::Rectangle& bounds); + bool prewarm(const juce::Rectangle& bounds); + bool focus(); + bool hide(); bool close(); bool isOpen() const; @@ -27,6 +34,10 @@ class MixerWindowManager ComponentFactory componentFactory; ClosedCallback closedCallback; + juce::String windowTitle; + juce::Rectangle defaultBounds; + int minWidth = 900; + int minHeight = 380; std::unique_ptr mixerWindow; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(MixerWindowManager) diff --git a/Source/StemSeparator.cpp b/Source/StemSeparator.cpp index f89d1a4..94ba435 100644 --- a/Source/StemSeparator.cpp +++ b/Source/StemSeparator.cpp @@ -10,6 +10,11 @@ constexpr auto kStemModelName = "BS-Roformer-SW.ckpt"; constexpr auto kPinnedMusicGenerationModelId = "acestep-v15-xl-turbo"; constexpr auto kPinnedMusicGenerationModelRepoId = "ACE-Step/acestep-v15-xl-turbo"; constexpr auto kPinnedMusicGenerationSharedRepoId = "ACE-Step/Ace-Step1.5"; +constexpr auto kFeatureStemSeparation = "stemSeparation"; +constexpr auto kFeatureAudioGeneration = "audioGeneration"; +constexpr juce::int64 kMinStemSystemRamMb = 8 * 1024; +constexpr juce::int64 kMinAudioSystemRamMb = 16 * 1024; +constexpr juce::int64 kMinAudioGpuMemoryMb = 8 * 1024; constexpr auto kPythonHelpUrl = "https://www.python.org/downloads/"; constexpr auto kInstallSourceDownloadedRuntime = "downloadedRuntime"; constexpr auto kInstallSourceExternalPython = "externalPython"; @@ -62,6 +67,52 @@ juce::StringArray varToStringArray (const juce::var& value) return result; } +juce::String normaliseFeatureId (juce::String value) +{ + value = value.trim(); + if (value == "stem" || value == "stems" || value == "stem-separation" + || value == "stem_separation" || value == kFeatureStemSeparation) + return kFeatureStemSeparation; + + if (value == "audio" || value == "music" || value == "audio-generation" + || value == "audio_generation" || value == "music-generation" + || value == "musicGeneration" || value == kFeatureAudioGeneration) + return kFeatureAudioGeneration; + + return {}; +} + +void addFeatureIfMissing (juce::StringArray& features, const juce::String& featureId) +{ + if (featureId.isNotEmpty() && ! features.contains(featureId)) + features.add(featureId); +} + +juce::StringArray normaliseFeatureArray (const juce::var& value) +{ + juce::StringArray features; + + if (auto* array = value.getArray()) + { + for (const auto& entry : *array) + addFeatureIfMissing(features, normaliseFeatureId(entry.toString())); + } + else + { + juce::StringArray tokens; + tokens.addTokens(value.toString(), ",; \t\r\n", {}); + for (const auto& token : tokens) + addFeatureIfMissing(features, normaliseFeatureId(token)); + } + + return features; +} + +juce::StringArray defaultInstallFeatures() +{ + return { kFeatureStemSeparation }; +} + juce::String summariseDiagnosticLines (const juce::StringArray& lines) { if (lines.isEmpty()) @@ -652,6 +703,224 @@ bool StemSeparator::isExternalPythonFallbackEnabled() const #endif } +StemSeparator::InstallOptions StemSeparator::parseInstallOptions (const juce::String& optionsJson, bool legacyConfirmed) const +{ + InstallOptions options; + options.userConfirmedDownload = legacyConfirmed; + options.selectedFeatures = defaultInstallFeatures(); + + const auto trimmed = optionsJson.trim(); + if (trimmed.isEmpty()) + return options; + + const auto parsed = juce::JSON::parse(trimmed); + if (! parsed.isObject()) + return options; + + if (parsed.hasProperty("userConfirmedDownload")) + options.userConfirmedDownload = static_cast(parsed["userConfirmedDownload"]); + + if (parsed.hasProperty("requestedFeature")) + options.requestedFeature = normaliseFeatureId(parsed["requestedFeature"].toString()); + + if (parsed.hasProperty("selectedFeatures")) + options.selectedFeatures = normaliseFeatureArray(parsed["selectedFeatures"]); + + if (options.selectedFeatures.isEmpty() && options.requestedFeature.isNotEmpty()) + options.selectedFeatures.add(options.requestedFeature); + + if (options.selectedFeatures.isEmpty()) + options.selectedFeatures = defaultInstallFeatures(); + + return options; +} + +StemSeparator::HardwareStatus StemSeparator::probeHardwareStatus() const +{ + HardwareStatus hardware; + hardware.systemRamMb = static_cast(juce::SystemStats::getMemorySizeInMegabytes()); + + auto probeNvidia = [] (HardwareStatus& target) + { + juce::ChildProcess probe; + const auto command = "nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits"; + if (! probe.start(command) || ! probe.waitForProcessToFinish(5000)) + return; + + auto output = probe.readAllProcessOutput(); + if (probe.getExitCode() != 0) + return; + + juce::StringArray lines; + lines.addLines(output); + for (const auto& line : lines) + { + juce::StringArray parts; + parts.addTokens(line, ",", {}); + if (parts.size() < 2) + continue; + + const auto memoryMb = static_cast( + parts[parts.size() - 1].retainCharacters("0123456789.").getDoubleValue()); + if (memoryMb > target.gpuMemoryMb) + { + target.gpuBackend = "cuda"; + target.gpuName = parts[0].trim(); + target.gpuMemoryMb = memoryMb; + target.gpuMemoryDetected = true; + target.audioGenerationGpuSupported = true; + } + } + }; + + probeNvidia(hardware); + +#if JUCE_LINUX + if (hardware.gpuBackend == "none") + { + juce::ChildProcess probe; + if (probe.start("rocm-smi --showmeminfo vram") && probe.waitForProcessToFinish(5000) && probe.getExitCode() == 0) + { + const auto output = probe.readAllProcessOutput(); + juce::int64 bestMemoryMb = 0; + juce::String digits; + for (auto i = 0; i < output.length(); ++i) + { + const auto ch = output[i]; + if (juce::CharacterFunctions::isDigit(ch)) + { + digits += juce::String::charToString(ch); + continue; + } + + if (digits.isNotEmpty()) + { + auto value = digits.getLargeIntValue(); + if (value > 1024 * 1024) + value /= 1024 * 1024; + bestMemoryMb = juce::jmax(bestMemoryMb, value); + digits.clear(); + } + } + + if (bestMemoryMb >= kMinAudioGpuMemoryMb) + { + hardware.gpuBackend = "rocm"; + hardware.gpuName = "AMD ROCm GPU"; + hardware.gpuMemoryMb = bestMemoryMb; + hardware.gpuMemoryDetected = true; + hardware.audioGenerationGpuSupported = true; + } + } + } +#endif + + if (hardware.systemRamMb <= 0) + hardware.stemSeparationBlockReason = "system memory could not be detected"; + else if (hardware.systemRamMb < kMinStemSystemRamMb) + hardware.stemSeparationBlockReason = "at least 8 GB system RAM is required"; + + hardware.stemSeparationCompatible = hardware.stemSeparationBlockReason.isEmpty(); + + if (hardware.systemRamMb <= 0) + hardware.audioGenerationBlockReason = "system memory could not be detected"; + else if (hardware.systemRamMb < kMinAudioSystemRamMb) + hardware.audioGenerationBlockReason = "at least 16 GB system RAM is required"; + else if (! hardware.audioGenerationGpuSupported || hardware.gpuMemoryMb < kMinAudioGpuMemoryMb) + hardware.audioGenerationBlockReason = "supported GPU with at least 8 GB memory was not detected"; + + hardware.audioGenerationCompatible = hardware.audioGenerationBlockReason.isEmpty(); + return hardware; +} + +juce::var StemSeparator::hardwareStatusToVar (const HardwareStatus& hardware) +{ + auto requirements = std::make_unique(); + auto stemRequirements = std::make_unique(); + stemRequirements->setProperty("minSystemRamMb", static_cast(kMinStemSystemRamMb)); + requirements->setProperty(kFeatureStemSeparation, juce::var(stemRequirements.release())); + + auto audioRequirements = std::make_unique(); + audioRequirements->setProperty("minSystemRamMb", static_cast(kMinAudioSystemRamMb)); + audioRequirements->setProperty("minGpuMemoryMb", static_cast(kMinAudioGpuMemoryMb)); + juce::Array supportedGpuBackends; + supportedGpuBackends.add("cuda"); + supportedGpuBackends.add("rocm"); + audioRequirements->setProperty("supportedGpuBackends", supportedGpuBackends); + requirements->setProperty(kFeatureAudioGeneration, juce::var(audioRequirements.release())); + + auto obj = std::make_unique(); + obj->setProperty("schemaVersion", 1); + obj->setProperty("systemRamMb", static_cast(hardware.systemRamMb)); + obj->setProperty("systemRamGb", hardware.systemRamMb > 0 ? hardware.systemRamMb / 1024.0 : 0.0); + obj->setProperty("gpuBackend", hardware.gpuBackend); + obj->setProperty("gpuName", hardware.gpuName); + obj->setProperty("gpuMemoryMb", static_cast(hardware.gpuMemoryMb)); + obj->setProperty("gpuMemoryGb", hardware.gpuMemoryMb > 0 ? hardware.gpuMemoryMb / 1024.0 : 0.0); + obj->setProperty("gpuMemoryDetected", hardware.gpuMemoryDetected); + obj->setProperty("audioGenerationGpuSupported", hardware.audioGenerationGpuSupported); + obj->setProperty("requirements", juce::var(requirements.release())); + return juce::var(obj.release()); +} + +juce::var StemSeparator::buildFeatureStatusVar (const AiToolsStatus& status, const HardwareStatus& hardware) +{ + auto buildFeature = [&] (const juce::String& id, + const juce::String& label, + bool compatible, + const juce::String& blockReason, + bool ready, + bool requiresGpu, + juce::int64 minSystemRamMb, + juce::int64 minGpuMemoryMb = 0) + { + auto feature = std::make_unique(); + feature->setProperty("id", id); + feature->setProperty("label", label); + feature->setProperty("selected", status.selectedFeatures.contains(id)); + feature->setProperty("requested", status.requestedFeatures.contains(id) || status.requestedFeature == id); + feature->setProperty("installed", status.installedFeatures.contains(id) || ready); + feature->setProperty("ready", ready); + feature->setProperty("compatible", compatible); + feature->setProperty("blocked", ! compatible); + feature->setProperty("blockReason", blockReason); + feature->setProperty("requiresGpu", requiresGpu); + feature->setProperty("minSystemRamMb", static_cast(minSystemRamMb)); + if (minGpuMemoryMb > 0) + feature->setProperty("minGpuMemoryMb", static_cast(minGpuMemoryMb)); + feature->setProperty("message", compatible + ? label + " is compatible with this machine." + : "This machine does not meet " + label + " requirements: " + blockReason + "."); + return juce::var(feature.release()); + }; + + const auto stemReady = status.scriptAvailable && status.runtimeInstalled && status.modelInstalled; + const auto audioReady = status.musicGenerationReady + && status.musicGenerationLayoutValid + && status.musicGenerationPerformanceReady + && hasNativeMusicProfile(status); + + auto features = std::make_unique(); + features->setProperty(kFeatureStemSeparation, + buildFeature(kFeatureStemSeparation, + "Stem Separation", + hardware.stemSeparationCompatible, + hardware.stemSeparationBlockReason, + stemReady, + false, + kMinStemSystemRamMb)); + features->setProperty(kFeatureAudioGeneration, + buildFeature(kFeatureAudioGeneration, + "Audio Generation", + hardware.audioGenerationCompatible, + hardware.audioGenerationBlockReason, + audioReady, + true, + kMinAudioSystemRamMb, + kMinAudioGpuMemoryMb)); + return juce::var(features.release()); +} + StemSeparator::AiToolsStatus StemSeparator::buildAiToolsStatus (const juce::File& systemPython, const juce::File& script, const juce::File& installerScript, @@ -673,9 +942,16 @@ StemSeparator::AiToolsStatus StemSeparator::buildAiToolsStatus (const juce::File : juce::String(kInstallSourceDownloadedRuntime); status.helpUrl = status.requiresExternalPython ? juce::String(kPythonHelpUrl) : juce::String(); status.detailLogPath = getAiToolsInstallLogFile().getFullPathName(); + if (status.selectedFeatures.isEmpty()) + status.selectedFeatures = defaultInstallFeatures(); + const auto hardware = probeHardwareStatus(); + status.hardware = hardwareStatusToVar(hardware); if (status.installInProgress) + { + status.features = buildFeatureStatusVar(status, hardware); return status; + } status.available = status.scriptAvailable && status.runtimeInstalled && status.modelInstalled; @@ -713,6 +989,11 @@ StemSeparator::AiToolsStatus StemSeparator::buildAiToolsStatus (const juce::File status.activityLines.add(status.stepLabel); if (status.message != status.stepLabel) status.activityLines.add(status.message); + if (status.installedFeatures.isEmpty()) + status.installedFeatures.add(kFeatureStemSeparation); + if (musicGenerationFullyReady) + addFeatureIfMissing(status.installedFeatures, kFeatureAudioGeneration); + status.features = buildFeatureStatusVar(status, hardware); return status; } @@ -721,6 +1002,7 @@ StemSeparator::AiToolsStatus StemSeparator::buildAiToolsStatus (const juce::File status.available = false; status.installInProgress = false; status.terminalReason = status.errorCode; + status.features = buildFeatureStatusVar(status, hardware); if (status.message.isEmpty()) status.message = "OpenStudio could not confirm that AI Tools finished installing."; @@ -737,6 +1019,7 @@ StemSeparator::AiToolsStatus StemSeparator::buildAiToolsStatus (const juce::File status.errorCode = "installer_unavailable"; status.lastPhase = "installer_unavailable"; status.terminalReason = status.errorCode; + status.features = buildFeatureStatusVar(status, hardware); return status; } @@ -749,6 +1032,7 @@ StemSeparator::AiToolsStatus StemSeparator::buildAiToolsStatus (const juce::File status.errorCode = "runtime_manifest_missing"; status.lastPhase = "fetching_runtime_manifest"; status.terminalReason = status.errorCode; + status.features = buildFeatureStatusVar(status, hardware); return status; } @@ -763,6 +1047,7 @@ StemSeparator::AiToolsStatus StemSeparator::buildAiToolsStatus (const juce::File status.errorCode = "python_missing"; status.lastPhase = "pythonMissing"; status.terminalReason.clear(); + status.features = buildFeatureStatusVar(status, hardware); return status; } @@ -775,6 +1060,7 @@ StemSeparator::AiToolsStatus StemSeparator::buildAiToolsStatus (const juce::File status.errorCode.clear(); status.lastPhase = "runtimeMissing"; status.terminalReason.clear(); + status.features = buildFeatureStatusVar(status, hardware); return status; } @@ -785,6 +1071,7 @@ StemSeparator::AiToolsStatus StemSeparator::buildAiToolsStatus (const juce::File status.errorCode.clear(); status.lastPhase = "modelMissing"; status.terminalReason.clear(); + status.features = buildFeatureStatusVar(status, hardware); return status; } @@ -821,6 +1108,11 @@ StemSeparator::AiToolsStatus StemSeparator::buildInitialAiToolsStatus() const status.musicGenerationSharedRepoId = kPinnedMusicGenerationSharedRepoId; status.musicGenerationCheckpointRoot = getMusicGenerationCheckpointRoot().getFullPathName(); status.musicGenerationLayoutValid = false; + status.selectedFeatures = defaultInstallFeatures(); + status.requestedFeatures = status.selectedFeatures; + const auto hardware = probeHardwareStatus(); + status.hardware = hardwareStatusToVar(hardware); + status.features = buildFeatureStatusVar(status, hardware); status.fallbackAttempted = false; return status; } @@ -870,6 +1162,16 @@ StemSeparator::AiToolsStatus StemSeparator::getCachedAiToolsStatusSnapshot() con status.musicGenerationSharedRepoId = kPinnedMusicGenerationSharedRepoId; if (status.musicGenerationCheckpointRoot.isEmpty()) status.musicGenerationCheckpointRoot = getMusicGenerationCheckpointRoot().getFullPathName(); + if (status.selectedFeatures.isEmpty()) + status.selectedFeatures = defaultInstallFeatures(); + if (status.requestedFeatures.isEmpty()) + status.requestedFeatures = status.selectedFeatures; + if (status.hardware.isVoid() || status.hardware.isUndefined() || status.features.isVoid() || status.features.isUndefined()) + { + const auto hardware = probeHardwareStatus(); + status.hardware = hardwareStatusToVar(hardware); + status.features = buildFeatureStatusVar(status, hardware); + } return status; } @@ -973,6 +1275,17 @@ void StemSeparator::scheduleStatusRefresh() refreshedStatus.musicGenerationUnavailableProfiles = runtimeCapabilities.musicGenerationUnavailableProfiles; refreshedStatus.musicGenerationDefaultProfile = runtimeCapabilities.musicGenerationDefaultProfile; refreshedStatus.musicGenerationWarmSessionCapable = runtimeCapabilities.musicGenerationWarmSessionCapable; + refreshedStatus.installedFeatures.clear(); + if (refreshedStatus.scriptAvailable && refreshedStatus.runtimeInstalled && refreshedStatus.modelInstalled) + refreshedStatus.installedFeatures.add(kFeatureStemSeparation); + if (refreshedStatus.musicGenerationReady + && refreshedStatus.musicGenerationLayoutValid + && refreshedStatus.musicGenerationPerformanceReady + && hasNativeMusicProfile(refreshedStatus)) + refreshedStatus.installedFeatures.add(kFeatureAudioGeneration); + const auto refreshedHardware = probeHardwareStatus(); + refreshedStatus.hardware = hardwareStatusToVar(refreshedHardware); + refreshedStatus.features = buildFeatureStatusVar(refreshedStatus, refreshedHardware); const auto musicGenerationFullyReady = refreshedStatus.musicGenerationReady && refreshedStatus.musicGenerationLayoutValid && refreshedStatus.musicGenerationPerformanceReady @@ -1325,6 +1638,15 @@ juce::var StemSeparator::aiToolsStatusToVar(const AiToolsStatus& status) juce::Array availableProfiles; for (const auto& profile : status.musicGenerationAvailableProfiles) availableProfiles.add(profile); + juce::Array selectedFeatures; + for (const auto& feature : status.selectedFeatures) + selectedFeatures.add(feature); + juce::Array requestedFeatures; + for (const auto& feature : status.requestedFeatures) + requestedFeatures.add(feature); + juce::Array installedFeatures; + for (const auto& feature : status.installedFeatures) + installedFeatures.add(feature); obj->setProperty("state", status.state); obj->setProperty("progress", static_cast(status.progress)); obj->setProperty("stepIndex", status.stepIndex); @@ -1381,6 +1703,12 @@ juce::var StemSeparator::aiToolsStatusToVar(const AiToolsStatus& status) obj->setProperty("musicGenerationReady", status.musicGenerationReady); obj->setProperty("musicGenerationLayoutValid", status.musicGenerationLayoutValid); obj->setProperty("musicGenerationPerformanceReady", status.musicGenerationPerformanceReady); + obj->setProperty("selectedFeatures", selectedFeatures); + obj->setProperty("requestedFeatures", requestedFeatures); + obj->setProperty("installedFeatures", installedFeatures); + obj->setProperty("requestedFeature", status.requestedFeature); + obj->setProperty("hardware", status.hardware); + obj->setProperty("features", status.features); return juce::var(obj.release()); } @@ -1406,10 +1734,22 @@ juce::var StemSeparator::refreshAiToolsStatus() } juce::var StemSeparator::installAiTools (bool userConfirmedDownload) +{ + auto options = std::make_unique(); + options->setProperty("userConfirmedDownload", userConfirmedDownload); + juce::Array selectedFeatures; + selectedFeatures.add(kFeatureStemSeparation); + options->setProperty("selectedFeatures", selectedFeatures); + options->setProperty("requestedFeature", kFeatureStemSeparation); + return installAiTools(juce::JSON::toString(juce::var(options.release()), true)); +} + +juce::var StemSeparator::installAiTools (const juce::String& optionsJson) { if (! aiToolsInstallWorkInProgress.load()) scheduleStatusRefresh(); + const auto installOptions = parseInstallOptions(optionsJson, false); auto cachedStatus = getCachedAiToolsStatusSnapshot(); const bool musicGenerationFullyReady = cachedStatus.musicGenerationReady && cachedStatus.musicGenerationLayoutValid @@ -1417,10 +1757,20 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) && hasNativeMusicProfile(cachedStatus); auto result = std::make_unique(); - if (cachedStatus.available && musicGenerationFullyReady) + bool selectedFeaturesReady = true; + for (const auto& feature : installOptions.selectedFeatures) + { + const auto normalisedFeature = normaliseFeatureId(feature); + if (normalisedFeature == kFeatureStemSeparation) + selectedFeaturesReady = selectedFeaturesReady && cachedStatus.available; + else if (normalisedFeature == kFeatureAudioGeneration) + selectedFeaturesReady = selectedFeaturesReady && musicGenerationFullyReady; + } + + if (selectedFeaturesReady) { result->setProperty("started", false); - result->setProperty("message", "AI tools are already installed."); + result->setProperty("message", "Selected AI features are already installed."); result->setProperty("status", aiToolsStatusToVar(cachedStatus)); return juce::var(result.release()); } @@ -1433,7 +1783,7 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) return juce::var(result.release()); } - if (! userConfirmedDownload) + if (! installOptions.userConfirmedDownload) { result->setProperty("started", false); result->setProperty("error", "AI tools setup requires explicit confirmation before downloading runtime or model files."); @@ -1442,6 +1792,49 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) return juce::var(result.release()); } + const auto hardware = probeHardwareStatus(); + juce::StringArray installFeatures; + juce::StringArray blockedFeatures; + for (const auto& feature : installOptions.selectedFeatures) + { + const auto normalisedFeature = normaliseFeatureId(feature); + const auto compatible = (normalisedFeature == kFeatureStemSeparation && hardware.stemSeparationCompatible) + || (normalisedFeature == kFeatureAudioGeneration && hardware.audioGenerationCompatible); + if (compatible) + addFeatureIfMissing(installFeatures, normalisedFeature); + else + addFeatureIfMissing(blockedFeatures, normalisedFeature); + } + + if (installFeatures.isEmpty()) + { + AiToolsStatus blockedStatus = cachedStatus; + blockedStatus.state = "error"; + blockedStatus.progress = 0.0f; + blockedStatus.available = cachedStatus.available; + blockedStatus.installInProgress = false; + blockedStatus.selectedFeatures = installOptions.selectedFeatures; + blockedStatus.requestedFeatures = installOptions.selectedFeatures; + blockedStatus.requestedFeature = installOptions.requestedFeature; + blockedStatus.hardware = hardwareStatusToVar(hardware); + blockedStatus.errorCode = "hardware_requirement_unmet"; + blockedStatus.terminalReason = blockedStatus.errorCode; + blockedStatus.message = blockedFeatures.contains(kFeatureAudioGeneration) + ? "This machine does not meet Audio Generation requirements: " + hardware.audioGenerationBlockReason + "." + : "This machine does not meet Stem Separation requirements: " + hardware.stemSeparationBlockReason + "."; + blockedStatus.error = blockedStatus.message; + blockedStatus.features = buildFeatureStatusVar(blockedStatus, hardware); + result->setProperty("started", false); + result->setProperty("error", blockedStatus.message); + result->setProperty("status", aiToolsStatusToVar(blockedStatus)); + { + const juce::ScopedLock lock (aiToolsStatusLock); + lastAiToolsStatus = blockedStatus; + initialStatusPrepared = true; + } + return juce::var(result.release()); + } + const auto devFallbackEnabled = isExternalPythonFallbackEnabled(); aiToolsCancelRequested = false; aiToolsInstallWorkInProgress = true; @@ -1456,6 +1849,11 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) status.progress = 0.0f; status.available = false; status.installInProgress = true; + status.selectedFeatures = installFeatures; + status.requestedFeatures = installOptions.selectedFeatures; + status.requestedFeature = installOptions.requestedFeature; + status.hardware = hardwareStatusToVar(hardware); + status.features = buildFeatureStatusVar(status, hardware); status.message = devFallbackEnabled ? "Preparing AI tools installation..." : "Checking OpenStudio AI runtime downloads..."; @@ -1465,7 +1863,7 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) status.helpUrl = status.requiresExternalPython ? juce::String(kPythonHelpUrl) : juce::String(); }); - std::thread ([this, devFallbackEnabled] + std::thread ([this, devFallbackEnabled, installFeatures, requestedFeatures = installOptions.selectedFeatures, requestedFeature = installOptions.requestedFeature] { const auto installerScript = findInstallerScript(); const auto systemPython = findSystemPython(); @@ -1480,8 +1878,9 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) juce::String selectedBackendRequested; juce::File selectedBackendInstallPlanFile; bool fallbackAttempted = false; + const auto installFeatureArg = installFeatures.joinIntoString(","); - auto finishWithStatus = [this, &selectedRuntimeCandidate, &selectedBackendRequested, &sessionId, &fallbackAttempted] (const juce::String& state, + auto finishWithStatus = [this, &selectedRuntimeCandidate, &selectedBackendRequested, &sessionId, &fallbackAttempted, installFeatures, requestedFeatures, requestedFeature] (const juce::String& state, float progress, const juce::String& message, const juce::String& error, @@ -1524,12 +1923,18 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) status.requiresExternalPython = requiresExternalPython; status.installSource = installSource; status.helpUrl = requiresExternalPython ? juce::String(kPythonHelpUrl) : juce::String(); + status.selectedFeatures = installFeatures; + status.requestedFeatures = requestedFeatures; + status.requestedFeature = requestedFeature; status.runtimeCandidate = selectedRuntimeCandidate; status.backendRequested = selectedBackendRequested; status.installSessionId = sessionId; status.lastPhase = state; status.terminalReason = state == "error" ? errorCode : juce::String(); status.fallbackAttempted = fallbackAttempted; + const auto hardware = probeHardwareStatus(); + status.hardware = hardwareStatusToVar(hardware); + status.features = buildFeatureStatusVar(status, hardware); }); }; @@ -1548,7 +1953,7 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) devFallbackEnabled ? juce::String(kBuildRuntimeModeUnbundledDev) : juce::String(kBuildRuntimeModeDownloadedRuntime)); }; - auto updateStep = [this, &logFile, devFallbackEnabled, &selectedRuntimeCandidate, &selectedBackendRequested, &sessionId, &fallbackAttempted] (const juce::String& state, + auto updateStep = [this, &logFile, devFallbackEnabled, &selectedRuntimeCandidate, &selectedBackendRequested, &sessionId, &fallbackAttempted, installFeatures, requestedFeatures, requestedFeature] (const juce::String& state, float progress, const juce::String& message, const juce::String& installSource) @@ -1579,12 +1984,18 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) status.requiresExternalPython = devFallbackEnabled; status.installSource = installSource; status.helpUrl = devFallbackEnabled ? juce::String(kPythonHelpUrl) : juce::String(); + status.selectedFeatures = installFeatures; + status.requestedFeatures = requestedFeatures; + status.requestedFeature = requestedFeature; status.runtimeCandidate = selectedRuntimeCandidate; status.backendRequested = selectedBackendRequested; status.installSessionId = sessionId; status.lastPhase = state; status.terminalReason.clear(); status.fallbackAttempted = fallbackAttempted; + const auto hardware = probeHardwareStatus(); + status.hardware = hardwareStatusToVar(hardware); + status.features = buildFeatureStatusVar(status, hardware); }); }; @@ -1601,6 +2012,8 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) appendAiToolsLogLine("runtimeRoot=" + runtimeRoot.getFullPathName()); appendAiToolsLogLine("modelsDir=" + modelsDir.getFullPathName()); appendAiToolsLogLine("musicGenerationCheckpointRoot=" + musicGenerationCheckpointRoot.getFullPathName()); + appendAiToolsLogLine("selectedFeatures=" + installFeatureArg); + appendAiToolsLogLine("requestedFeatures=" + requestedFeatures.joinIntoString(",")); if (! installerScript.existsAsFile()) { @@ -1769,6 +2182,7 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) if (auto* windowsPlatformObject = platformNode.getDynamicObject()) { const auto likelyNvidia = isLikelyNvidiaWindowsMachine(); + const auto audioGenerationRequested = installFeatures.contains(kFeatureAudioGeneration); appendAiToolsLogLine("windowsHardwareClass=" + juce::String(likelyNvidia ? "nvidia" : "non-nvidia")); const auto baseNode = windowsPlatformObject->getProperty("base"); if (auto* backendsObject = windowsPlatformObject->getProperty("backends").getDynamicObject()) @@ -1789,7 +2203,7 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) baseNode, "cuda", cudaInstallPlan)); - if (! directmlNode.isVoid() && ! directmlNode.isUndefined()) + if (! audioGenerationRequested && ! directmlNode.isVoid() && ! directmlNode.isUndefined()) runtimeCandidates.add(buildCandidateFromNode("windows-base-x64:directml", "Windows base runtime + DirectML", likelyNvidia ? "Prepared as fallback if the CUDA backend install or probe fails." : "Selected because no NVIDIA hardware was detected.", @@ -1808,19 +2222,20 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) { if (likelyNvidia && ! cudaNode.isVoid() && ! cudaNode.isUndefined()) runtimeCandidates.add(buildCandidateFromNode("windows-cuda-x64", "Windows CUDA runtime", "Selected first because NVIDIA hardware was detected.", cudaNode)); - if (! directmlNode.isVoid() && ! directmlNode.isUndefined()) + if (! audioGenerationRequested && ! directmlNode.isVoid() && ! directmlNode.isUndefined()) runtimeCandidates.add(buildCandidateFromNode("windows-directml-x64", "Windows DirectML runtime", likelyNvidia ? "Prepared as fallback if CUDA validation fails." : "Selected because no NVIDIA hardware was detected.", directmlNode)); if (! likelyNvidia && ! cudaNode.isVoid() && ! cudaNode.isUndefined() && runtimeCandidates.isEmpty()) runtimeCandidates.add(buildCandidateFromNode("windows-cuda-x64", "Windows CUDA runtime", "Using CUDA runtime because it is the only published Windows candidate.", cudaNode)); } } - if (runtimeCandidates.isEmpty() && getPropertyString(platformNode, "url").isNotEmpty()) + if (runtimeCandidates.isEmpty() && ! audioGenerationRequested && getPropertyString(platformNode, "url").isNotEmpty()) runtimeCandidates.add(buildCandidateFromNode("windows-legacy", "Windows runtime", "Using legacy flat Windows runtime manifest entry.", platformNode)); } #else if (auto* linuxPlatformObject = platformNode.getDynamicObject()) { + const auto audioGenerationRequested = installFeatures.contains(kFeatureAudioGeneration); const auto architectureKey = getAiRuntimeArchitectureKey(); const auto architectureNode = linuxPlatformObject->getProperty(architectureKey); if (! architectureNode.isVoid() && ! architectureNode.isUndefined()) @@ -1853,12 +2268,13 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) rocmInstallPlan)); } - runtimeCandidates.add(buildCandidateFromNode("linux-" + architectureKey, - "Linux " + architectureKey, - runtimeCandidates.isEmpty() ? "Selected by current Linux architecture." : "Prepared as fallback if GPU backend setup fails before installation.", - architectureNode)); + if (! audioGenerationRequested) + runtimeCandidates.add(buildCandidateFromNode("linux-" + architectureKey, + "Linux " + architectureKey, + runtimeCandidates.isEmpty() ? "Selected by current Linux architecture." : "Prepared as fallback if GPU backend setup fails before installation.", + architectureNode)); } - else if (getPropertyString(platformNode, "url").isNotEmpty()) + else if (! audioGenerationRequested && getPropertyString(platformNode, "url").isNotEmpty()) { runtimeCandidates.add(buildCandidateFromNode("linux-legacy", "Linux runtime", @@ -1883,10 +2299,11 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) } else if (! platformNode.isVoid() && ! platformNode.isUndefined()) { - runtimeCandidates.add(buildCandidateFromNode("linux-legacy", - "Linux runtime", - "Using legacy flat Linux runtime manifest entry.", - platformNode)); + if (! installFeatures.contains(kFeatureAudioGeneration)) + runtimeCandidates.add(buildCandidateFromNode("linux-legacy", + "Linux runtime", + "Using legacy flat Linux runtime manifest entry.", + platformNode)); } #endif } @@ -1978,7 +2395,7 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) juce::String downloadError; if (! downloadFileWithProgress(juce::URL(runtimeUrl), runtimeArchive, - [this, &logFile, candidate] (float progress, juce::int64 downloaded, juce::int64 total) + [this, &logFile, candidate, installFeatures, requestedFeatures, requestedFeature] (float progress, juce::int64 downloaded, juce::int64 total) { updateCachedAiToolsStatus ([&] (AiToolsStatus& status) { @@ -2004,10 +2421,16 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) status.buildRuntimeMode = kBuildRuntimeModeDownloadedRuntime; status.requiresExternalPython = false; status.installSource = kInstallSourceDownloadedRuntime; - status.helpUrl.clear(); - status.runtimeCandidate = candidate.key; - status.backendRequested = candidate.backendRequested; - }); + status.helpUrl.clear(); + status.runtimeCandidate = candidate.key; + status.backendRequested = candidate.backendRequested; + status.selectedFeatures = installFeatures; + status.requestedFeatures = requestedFeatures; + status.requestedFeature = requestedFeature; + const auto hardware = probeHardwareStatus(); + status.hardware = hardwareStatusToVar(hardware); + status.features = buildFeatureStatusVar(status, hardware); + }); }, downloadError)) { if (aiToolsCancelRequested.load()) @@ -2174,6 +2597,7 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) + " --music-gen-model " + quoteCommandPart(kPinnedMusicGenerationModelId) + " --music-gen-checkpoint-root " + quoteCommandPart(musicGenerationCheckpointRoot.getFullPathName()) + " --log-path " + quoteCommandPart(logFile.getFullPathName()) + + " --features " + quoteCommandPart(installFeatureArg) + launchMode; if (selectedBackendRequested.isNotEmpty()) cmd += " --backend-requested " + quoteCommandPart(selectedBackendRequested); @@ -2184,6 +2608,7 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) appendAiToolsLogLine("runtimeRoot=" + runtimeRoot.getFullPathName()); appendAiToolsLogLine("installerScript=" + installerScript.getFullPathName()); appendAiToolsLogLine("backendRequested=" + selectedBackendRequested); + appendAiToolsLogLine("installerFeatures=" + installFeatureArg); if (selectedBackendInstallPlanFile.existsAsFile()) appendAiToolsLogLine("backendInstallPlan=" + selectedBackendInstallPlanFile.getFullPathName()); appendAiToolsLogLine("installerCommand=" + cmd); @@ -2192,6 +2617,7 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) { obj.setProperty("runtimeCandidate", selectedRuntimeCandidate); obj.setProperty("backendRequested", selectedBackendRequested); + obj.setProperty("selectedFeatures", installFeatureArg); obj.setProperty("command", cmd); })); @@ -2265,6 +2691,12 @@ juce::var StemSeparator::installAiTools (bool userConfirmedDownload) lastAiToolsStatus.lastPhase = installLastObservedPhase; lastAiToolsStatus.terminalReason.clear(); lastAiToolsStatus.fallbackAttempted = fallbackAttempted; + lastAiToolsStatus.selectedFeatures = installFeatures; + lastAiToolsStatus.requestedFeatures = requestedFeatures; + lastAiToolsStatus.requestedFeature = requestedFeature; + const auto hardware = probeHardwareStatus(); + lastAiToolsStatus.hardware = hardwareStatusToVar(hardware); + lastAiToolsStatus.features = buildFeatureStatusVar(lastAiToolsStatus, hardware); } startInstallMonitor(); @@ -2719,6 +3151,18 @@ StemSeparator::AiToolsStatus StemSeparator::parseInstallJsonLine(const juce::Str status.musicGenerationLayoutValid = static_cast(json["musicGenerationLayoutValid"]); if (json.hasProperty("musicGenerationPerformanceReady")) status.musicGenerationPerformanceReady = static_cast(json["musicGenerationPerformanceReady"]); + if (json.hasProperty("selectedFeatures")) + status.selectedFeatures = normaliseFeatureArray(json["selectedFeatures"]); + if (json.hasProperty("requestedFeatures")) + status.requestedFeatures = normaliseFeatureArray(json["requestedFeatures"]); + if (json.hasProperty("installedFeatures")) + status.installedFeatures = normaliseFeatureArray(json["installedFeatures"]); + if (json.hasProperty("requestedFeature")) + status.requestedFeature = normaliseFeatureId(json["requestedFeature"].toString()); + if (json.hasProperty("hardware")) + status.hardware = json["hardware"]; + if (json.hasProperty("features")) + status.features = json["features"]; if (status.lastPhase.isEmpty() && status.state.isNotEmpty()) status.lastPhase = status.state; @@ -2741,6 +3185,16 @@ StemSeparator::AiToolsStatus StemSeparator::parseInstallJsonLine(const juce::Str status.musicGenerationSharedRepoId = kPinnedMusicGenerationSharedRepoId; if (status.musicGenerationCheckpointRoot.isEmpty()) status.musicGenerationCheckpointRoot = getMusicGenerationCheckpointRoot().getFullPathName(); + if (status.selectedFeatures.isEmpty()) + status.selectedFeatures = defaultInstallFeatures(); + if (status.requestedFeatures.isEmpty()) + status.requestedFeatures = status.selectedFeatures; + if (status.hardware.isVoid() || status.hardware.isUndefined() || status.features.isVoid() || status.features.isUndefined()) + { + const auto hardware = probeHardwareStatus(); + status.hardware = hardwareStatusToVar(hardware); + status.features = buildFeatureStatusVar(status, hardware); + } return status; } diff --git a/Source/StemSeparator.h b/Source/StemSeparator.h index 037f09b..58fad0c 100644 --- a/Source/StemSeparator.h +++ b/Source/StemSeparator.h @@ -79,6 +79,12 @@ class StemSeparator bool musicGenerationReady = false; bool musicGenerationLayoutValid = false; bool musicGenerationPerformanceReady = true; + juce::StringArray selectedFeatures; + juce::StringArray requestedFeatures; + juce::StringArray installedFeatures; + juce::String requestedFeature; + juce::var hardware; + juce::var features; }; /** Check if Python environment and audio-separator are available. */ @@ -93,6 +99,9 @@ class StemSeparator /** Start installing the optional AI tools into the user's app-data directory after explicit user confirmation. */ juce::var installAiTools (bool userConfirmedDownload); + /** Start installing selected optional AI feature modules after explicit user confirmation. */ + juce::var installAiTools (const juce::String& optionsJson); + /** Remove installed AI runtime files, local models, and pinned ACE-Step caches. */ juce::var resetAiTools(); @@ -229,6 +238,39 @@ class StemSeparator bool musicGenerationWarmSessionCapable = false; }; + struct InstallOptions + { + bool userConfirmedDownload = false; + juce::StringArray selectedFeatures; + juce::String requestedFeature; + }; + + struct HardwareStatus + { + juce::int64 systemRamMb = 0; + juce::String gpuBackend { "none" }; + juce::String gpuName; + juce::int64 gpuMemoryMb = 0; + bool gpuMemoryDetected = false; + bool audioGenerationGpuSupported = false; + bool stemSeparationCompatible = false; + bool audioGenerationCompatible = false; + juce::String stemSeparationBlockReason; + juce::String audioGenerationBlockReason; + }; + + /** Parse feature-aware install options while preserving legacy bool behavior. */ + InstallOptions parseInstallOptions (const juce::String& optionsJson, bool legacyConfirmed) const; + + /** Probe local hardware for conservative AI feature gating. */ + HardwareStatus probeHardwareStatus() const; + + /** Convert hardware status to a UI/installer payload. */ + static juce::var hardwareStatusToVar (const HardwareStatus& hardware); + + /** Build per-feature compatibility and readiness status. */ + static juce::var buildFeatureStatusVar (const AiToolsStatus& status, const HardwareStatus& hardware); + /** Probe runtime capabilities via the helper script. */ RuntimeCapabilities probeRuntimeCapabilities (const juce::File& python, const juce::File& modelsDir, diff --git a/Source/TrackProcessor.cpp b/Source/TrackProcessor.cpp index f0088e7..2e23777 100644 --- a/Source/TrackProcessor.cpp +++ b/Source/TrackProcessor.cpp @@ -148,9 +148,26 @@ TrackProcessor::TrackProcessor() preFXWidthAutomation.setDefaultValue(0.0f); trimVolumeAutomation.setDefaultValue(0.0f); muteAutomation.setDefaultValue(0.0f); + midiVelocityScaleAutomation.setDefaultValue(1.0f); + midiPitchBendAutomation.setDefaultValue(0.0f); + midiChannelPressureAutomation.setDefaultValue(0.0f); std::atomic_store_explicit(&pluginAutomationSnapshot, std::make_shared(), std::memory_order_release); + std::atomic_store_explicit(&midiCCAutomationSnapshot, + std::make_shared(), + std::memory_order_release); + midiOutputResetBuffer.ensureSize(512); + for (size_t channel = 0; channel < midiNoteCurrentlyActive.size(); ++channel) + { + for (size_t note = 0; note < midiNoteCurrentlyActive[channel].size(); ++note) + { + midiNoteCurrentlyActive[channel][note].store(false, std::memory_order_relaxed); + midiNoteLastOnMs[channel][note].store(0, std::memory_order_relaxed); + midiNoteLastOffMs[channel][note].store(0, std::memory_order_relaxed); + midiNoteLastVelocity[channel][note].store(0, std::memory_order_relaxed); + } + } publishRealtimeStateSnapshots(); } @@ -158,22 +175,41 @@ TrackProcessor::~TrackProcessor() { } -std::optional> TrackProcessor::parsePluginAutomationParameterId(const juce::String& parameterId) +std::optional TrackProcessor::parsePluginAutomationParameterId(const juce::String& parameterId) const { if (!parameterId.startsWith("plugin_")) return std::nullopt; auto suffix = parameterId.substring(7); auto parts = juce::StringArray::fromTokens(suffix, "_", ""); - if (parts.size() != 2) + PluginAutomationParameterRef ref; + + if (parts.size() == 3 && (parts[0] == "input" || parts[0] == "track")) + { + ref.isInputFX = parts[0] == "input"; + ref.fxIndex = parts[1].getIntValue(); + ref.paramIndex = parts[2].getIntValue(); + } + else if (parts.size() == 2) + { + ref.fxIndex = parts[0].getIntValue(); + ref.paramIndex = parts[1].getIntValue(); + + const bool hasInputFx = ref.fxIndex >= 0 && ref.fxIndex < getNumInputFX(); + const bool hasTrackFx = ref.fxIndex >= 0 && ref.fxIndex < getNumTrackFX(); + if (hasInputFx == hasTrackFx) + return std::nullopt; + ref.isInputFX = hasInputFx; + } + else + { return std::nullopt; + } - const int fxIndex = parts[0].getIntValue(); - const int paramIndex = parts[1].getIntValue(); - if (fxIndex < 0 || paramIndex < 0) + if (ref.fxIndex < 0 || ref.paramIndex < 0) return std::nullopt; - return std::make_pair(fxIndex, paramIndex); + return ref; } std::shared_ptr TrackProcessor::findPluginAutomationRoute(const juce::String& parameterId) const @@ -200,21 +236,19 @@ std::shared_ptr TrackProcessor::getOrCrea if (!parsed.has_value()) return nullptr; - const auto [fxIndex, paramIndex] = *parsed; + const auto parsedRef = *parsed; auto route = std::make_shared(); route->parameterId = parameterId; - route->fxIndex = fxIndex; - route->paramIndex = paramIndex; - - const bool hasInputFx = fxIndex >= 0 && fxIndex < getNumInputFX(); - const bool hasTrackFx = fxIndex >= 0 && fxIndex < getNumTrackFX(); - if (!hasInputFx && !hasTrackFx) + route->isInputFX = parsedRef.isInputFX; + route->fxIndex = parsedRef.fxIndex; + route->paramIndex = parsedRef.paramIndex; + + const bool validRoute = route->isInputFX + ? route->fxIndex < getNumInputFX() + : route->fxIndex < getNumTrackFX(); + if (!validRoute) return nullptr; - // The current frontend lane id does not encode whether a plugin lives in the - // input or track chain, so keep resolution stable once the lane is created. - route->isInputFX = hasInputFx; - const juce::ScopedLock sl(pluginAutomationRouteLock); if (auto existing = findPluginAutomationRoute(parameterId)) return existing; @@ -230,6 +264,59 @@ std::shared_ptr TrackProcessor::getOrCrea return route; } +std::optional TrackProcessor::parseMIDICCAutomationParameterId(const juce::String& parameterId) +{ + if (!parameterId.startsWith("midi_cc_")) + return std::nullopt; + + const int controller = parameterId.substring(8).getIntValue(); + if (controller < 0 || controller > 127) + return std::nullopt; + return controller; +} + +std::shared_ptr TrackProcessor::findMIDICCAutomationRoute(const juce::String& parameterId) const +{ + auto snapshot = std::atomic_load_explicit(&midiCCAutomationSnapshot, std::memory_order_acquire); + if (!snapshot) + return nullptr; + + for (const auto& route : *snapshot) + if (route && route->parameterId == parameterId) + return route; + + return nullptr; +} + +std::shared_ptr TrackProcessor::getOrCreateMIDICCAutomationRoute(const juce::String& parameterId) +{ + if (auto existing = findMIDICCAutomationRoute(parameterId)) + return existing; + + auto controller = parseMIDICCAutomationParameterId(parameterId); + if (!controller.has_value()) + return nullptr; + + auto route = std::make_shared(); + route->parameterId = parameterId; + route->controller = *controller; + route->automation->setDefaultValue(0.0f); + + const juce::ScopedLock sl(midiAutomationRouteLock); + if (auto existing = findMIDICCAutomationRoute(parameterId)) + return existing; + + auto snapshot = std::atomic_load_explicit(&midiCCAutomationSnapshot, std::memory_order_acquire); + auto nextSnapshot = std::make_shared(); + if (snapshot) + *nextSnapshot = *snapshot; + nextSnapshot->push_back(route); + std::atomic_store_explicit(&midiCCAutomationSnapshot, + std::static_pointer_cast(nextSnapshot), + std::memory_order_release); + return route; +} + std::optional TrackProcessor::resolveAutomationTarget(const juce::String& parameterId, bool createIfNeeded) { @@ -282,6 +369,33 @@ std::optional TrackProcessor::resolveAutomatio target.list = &muteAutomation; return target; } + if (parameterId == "midi_velocity_scale") + { + target.kind = AutomationTarget::Kind::MIDIVelocityScale; + target.list = &midiVelocityScaleAutomation; + return target; + } + if (parameterId == "midi_pitch_bend") + { + target.kind = AutomationTarget::Kind::MIDIPitchBend; + target.list = &midiPitchBendAutomation; + return target; + } + if (parameterId == "midi_channel_pressure") + { + target.kind = AutomationTarget::Kind::MIDIChannelPressure; + target.list = &midiChannelPressureAutomation; + return target; + } + + auto midiCCRoute = createIfNeeded ? getOrCreateMIDICCAutomationRoute(parameterId) : findMIDICCAutomationRoute(parameterId); + if (midiCCRoute) + { + target.kind = AutomationTarget::Kind::MIDICC; + target.list = midiCCRoute->automation.get(); + target.midiCC = midiCCRoute->controller; + return target; + } auto route = createIfNeeded ? getOrCreatePluginAutomationRoute(parameterId) : findPluginAutomationRoute(parameterId); if (!route) @@ -312,6 +426,12 @@ float TrackProcessor::getAutomationDefaultValue(const AutomationTarget& target) return 0.0f; case AutomationTarget::Kind::Mute: return getMute() ? 1.0f : 0.0f; + case AutomationTarget::Kind::MIDIVelocityScale: + return 1.0f; + case AutomationTarget::Kind::MIDIPitchBend: + case AutomationTarget::Kind::MIDIChannelPressure: + case AutomationTarget::Kind::MIDICC: + return 0.0f; case AutomationTarget::Kind::PluginParameter: { const juce::AudioProcessor* processor = nullptr; @@ -340,6 +460,75 @@ float TrackProcessor::getAutomationDefaultValue(const AutomationTarget& target) } } +bool TrackProcessor::hasPluginAutomation() const +{ + auto snapshot = std::atomic_load_explicit(&pluginAutomationSnapshot, std::memory_order_acquire); + if (!snapshot) + return false; + + for (const auto& route : *snapshot) + if (route && route->automation && route->automation->getNumPoints() > 0 + && route->automation->shouldPlaybackForRead()) + return true; + + return false; +} + +bool TrackProcessor::hasMIDIAutomation() const +{ + if (midiVelocityScaleAutomation.shouldPlaybackForRead() && midiVelocityScaleAutomation.getNumPoints() > 0) + return true; + if (midiPitchBendAutomation.shouldPlaybackForRead() && midiPitchBendAutomation.getNumPoints() > 0) + return true; + if (midiChannelPressureAutomation.shouldPlaybackForRead() && midiChannelPressureAutomation.getNumPoints() > 0) + return true; + + auto snapshot = std::atomic_load_explicit(&midiCCAutomationSnapshot, std::memory_order_acquire); + if (!snapshot) + return false; + + for (const auto& route : *snapshot) + if (route && route->automation && route->automation->getNumPoints() > 0 + && route->automation->shouldPlaybackForRead()) + return true; + + return false; +} + +bool TrackProcessor::shouldApplyAutomation(const AutomationList& automation) const +{ + return forceAutomationReadDuringProcessing.load(std::memory_order_relaxed) + ? automation.shouldPlaybackForRead() + : automation.shouldPlayback(); +} + +void TrackProcessor::resetAutomationTouchState() +{ + volumeAutomation.resetTouchAndLatch(); + panAutomation.resetTouchAndLatch(); + widthAutomation.resetTouchAndLatch(); + preFXVolumeAutomation.resetTouchAndLatch(); + preFXPanAutomation.resetTouchAndLatch(); + preFXWidthAutomation.resetTouchAndLatch(); + trimVolumeAutomation.resetTouchAndLatch(); + muteAutomation.resetTouchAndLatch(); + midiVelocityScaleAutomation.resetTouchAndLatch(); + midiPitchBendAutomation.resetTouchAndLatch(); + midiChannelPressureAutomation.resetTouchAndLatch(); + + auto pluginSnapshot = std::atomic_load_explicit(&pluginAutomationSnapshot, std::memory_order_acquire); + if (pluginSnapshot) + for (const auto& route : *pluginSnapshot) + if (route && route->automation) + route->automation->resetTouchAndLatch(); + + auto midiSnapshot = std::atomic_load_explicit(&midiCCAutomationSnapshot, std::memory_order_acquire); + if (midiSnapshot) + for (const auto& route : *midiSnapshot) + if (route && route->automation) + route->automation->resetTouchAndLatch(); +} + void TrackProcessor::publishRealtimeStateSnapshots() { auto inputSnapshot = std::make_shared(inputFXPlugins.begin(), inputFXPlugins.end()); @@ -395,7 +584,7 @@ void TrackProcessor::applyPluginAutomationForProcessor(juce::AudioProcessor* pro if (param == nullptr) continue; - const float automatedValue = route->automation->shouldPlayback() + const float automatedValue = shouldApplyAutomation(*route->automation) ? route->automation->eval(blockTimeSeconds) : route->automation->getDefaultValue(); const float clampedValue = juce::jlimit(0.0f, 1.0f, automatedValue); @@ -612,7 +801,9 @@ void TrackProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) // Pre-allocate pre-fader buffer for send routing (2-channel stereo) preFaderBuffer.setSize(2, samplesPerBlock); + automationGainBuffer.setSize(8, samplesPerBlock); realtimeFallbackBuffer.setSize(2, samplesPerBlock); + midiOutputResetBuffer.ensureSize(512); } void TrackProcessor::releaseResources() @@ -670,8 +861,11 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc for (auto i = totalNumInputChannels; i < juce::jmin(totalNumOutputChannels, bufferChannels); ++i) buffer.clear (i, 0, buffer.getNumSamples()); - // Apply mute first - if muted, silence and return - if (isMuted.load() && !ignoreStaticMuteDuringProcessing.load(std::memory_order_relaxed)) + const bool muteAutomationCanUnmute = shouldApplyAutomation(muteAutomation) && muteAutomation.getNumPoints() > 0; + // Static mute may only short-circuit if no mute automation can unmute this block. + if (isMuted.load() + && !ignoreStaticMuteDuringProcessing.load(std::memory_order_relaxed) + && !muteAutomationCanUnmute) { buffer.clear(); currentRMS = 0.0f; @@ -947,25 +1141,35 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc logARAProcessDuration(isARAProcessor ? (juce::Time::getMillisecondCounterHiRes() - (processStartMs + processDurationMs)) : 0.0); } } + for (const auto metadata : midiMessages) + markActiveMIDINoteState(metadata.getMessage()); }; // ===== PRE-FX AUTOMATION ===== const int numSamps = buffer.getNumSamples(); const double processingSampleRate = juce::jmax(1.0, getSampleRate()); + if (automationGainBuffer.getNumChannels() < 8 || automationGainBuffer.getNumSamples() < numSamps) + automationGainBuffer.setSize(8, numSamps, false, false, true); const float staticPreFXVolDb = 0.0f; const float staticPreFXPan = 0.0f; const float staticPreFXWidth = 100.0f; - const bool preFXVolAutoActive = preFXVolumeAutomation.shouldPlayback() && preFXVolumeAutomation.getNumPoints() > 0; - const bool preFXPanAutoActive = preFXPanAutomation.shouldPlayback() && preFXPanAutomation.getNumPoints() > 0; - const bool preFXWidthAutoActive = preFXWidthAutomation.shouldPlayback() && preFXWidthAutomation.getNumPoints() > 0; + const bool preFXVolAutoActive = shouldApplyAutomation(preFXVolumeAutomation) && preFXVolumeAutomation.getNumPoints() > 0; + const bool preFXPanAutoActive = shouldApplyAutomation(preFXPanAutomation) && preFXPanAutomation.getNumPoints() > 0; + const bool preFXWidthAutoActive = shouldApplyAutomation(preFXWidthAutomation) && preFXWidthAutomation.getNumPoints() > 0; if (preFXVolAutoActive || preFXPanAutoActive) { + auto* preFXVolValues = automationGainBuffer.getWritePointer(0); + auto* preFXPanValues = automationGainBuffer.getWritePointer(1); + if (preFXVolAutoActive) + preFXVolumeAutomation.evalBlock(blockTimeSeconds, processingSampleRate, numSamps, preFXVolValues); + if (preFXPanAutoActive) + preFXPanAutomation.evalBlock(blockTimeSeconds, processingSampleRate, numSamps, preFXPanValues); + for (int i = 0; i < numSamps; ++i) { - const double timeSeconds = blockTimeSeconds + (static_cast(i) / processingSampleRate); - float volDb = preFXVolAutoActive ? preFXVolumeAutomation.eval(timeSeconds) : staticPreFXVolDb; - float pan = preFXPanAutoActive ? preFXPanAutomation.eval(timeSeconds) : staticPreFXPan; + float volDb = preFXVolAutoActive ? preFXVolValues[i] : staticPreFXVolDb; + float pan = preFXPanAutoActive ? preFXPanValues[i] : staticPreFXPan; volDb = juce::jlimit(-60.0f, 12.0f, volDb); pan = juce::jlimit(-1.0f, 1.0f, pan); @@ -984,12 +1188,13 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc { if (preFXWidthAutoActive) { + auto* preFXWidthValues = automationGainBuffer.getWritePointer(2); + preFXWidthAutomation.evalBlock(blockTimeSeconds, processingSampleRate, numSamps, preFXWidthValues); float* left = buffer.getWritePointer(0); float* right = buffer.getWritePointer(1); for (int i = 0; i < numSamps; ++i) { - const double timeSeconds = blockTimeSeconds + (static_cast(i) / processingSampleRate); - const float widthPercent = backendWidthToPercent(preFXWidthAutomation.eval(timeSeconds)); + const float widthPercent = backendWidthToPercent(preFXWidthValues[i]); const float widthFactor = widthPercent / 100.0f; const float mid = (left[i] + right[i]) * 0.5f; const float side = (left[i] - right[i]) * 0.5f; @@ -1029,7 +1234,13 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc // Instrument processing lives between input FX and track FX so that // instrument output can be post-processed by normal track FX. if (currentTrackType == TrackType::Instrument && instrumentSnapshot) + { safeProcessFX(instrumentSnapshot.get(), instrumentForceFloat, false, -1); + } + else if (currentTrackType == TrackType::Instrument) + { + renderFallbackInstrument(buffer, midiMessages, numSamps, processingSampleRate); + } // Process through track FX chain (with sidechain support) for (int fxIdx = 0; trackFXSnapshot && fxIdx < (int)trackFXSnapshot->size(); ++fxIdx) @@ -1252,17 +1463,18 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc } // ===== POST-FX WIDTH ===== - const bool widthAutoActive = widthAutomation.shouldPlayback() && widthAutomation.getNumPoints() > 0; + const bool widthAutoActive = shouldApplyAutomation(widthAutomation) && widthAutomation.getNumPoints() > 0; if (bufferChannels >= 2) { if (widthAutoActive) { + auto* widthValues = automationGainBuffer.getWritePointer(3); + widthAutomation.evalBlock(blockTimeSeconds, processingSampleRate, numSamps, widthValues); float* left = buffer.getWritePointer(0); float* right = buffer.getWritePointer(1); for (int i = 0; i < buffer.getNumSamples(); ++i) { - const double timeSeconds = blockTimeSeconds + (static_cast(i) / processingSampleRate); - const float widthPercent = backendWidthToPercent(widthAutomation.eval(timeSeconds)); + const float widthPercent = backendWidthToPercent(widthValues[i]); const float widthFactor = widthPercent / 100.0f; const float mid = (left[i] + right[i]) * 0.5f; const float side = (left[i] - right[i]) * 0.5f; @@ -1292,21 +1504,26 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc } // ===== AUTOMATION-AWARE FADER/TAIL APPLICATION ===== - bool volAutoActive = volumeAutomation.shouldPlayback() && volumeAutomation.getNumPoints() > 0; - bool panAutoActive = panAutomation.shouldPlayback() && panAutomation.getNumPoints() > 0; - bool trimAutoActive = trimVolumeAutomation.shouldPlayback() && trimVolumeAutomation.getNumPoints() > 0; - bool muteAutoActive = muteAutomation.shouldPlayback() && muteAutomation.getNumPoints() > 0; + bool volAutoActive = shouldApplyAutomation(volumeAutomation) && volumeAutomation.getNumPoints() > 0; + bool panAutoActive = shouldApplyAutomation(panAutomation) && panAutomation.getNumPoints() > 0; + bool trimAutoActive = shouldApplyAutomation(trimVolumeAutomation) && trimVolumeAutomation.getNumPoints() > 0; + bool muteAutoActive = shouldApplyAutomation(muteAutomation) && muteAutomation.getNumPoints() > 0; if (volAutoActive || panAutoActive) { float staticVolDB = trackVolumeDB.load(std::memory_order_relaxed); float staticPan = trackPan.load(std::memory_order_relaxed); + auto* volumeValues = automationGainBuffer.getWritePointer(4); + auto* panValues = automationGainBuffer.getWritePointer(5); + if (volAutoActive) + volumeAutomation.evalBlock(blockTimeSeconds, processingSampleRate, numSamps, volumeValues); + if (panAutoActive) + panAutomation.evalBlock(blockTimeSeconds, processingSampleRate, numSamps, panValues); for (int i = 0; i < numSamps; ++i) { - const double timeSeconds = blockTimeSeconds + (static_cast(i) / processingSampleRate); - float volDB = volAutoActive ? volumeAutomation.eval(timeSeconds) : staticVolDB; - float pan = panAutoActive ? panAutomation.eval(timeSeconds) : staticPan; + float volDB = volAutoActive ? volumeValues[i] : staticVolDB; + float pan = panAutoActive ? panValues[i] : staticPan; volDB = juce::jlimit(-60.0f, 12.0f, volDB); pan = juce::jlimit(-1.0f, 1.0f, pan); @@ -1336,10 +1553,11 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc if (trimAutoActive) { + auto* trimValues = automationGainBuffer.getWritePointer(6); + trimVolumeAutomation.evalBlock(blockTimeSeconds, processingSampleRate, numSamps, trimValues); for (int i = 0; i < numSamps; ++i) { - const double timeSeconds = blockTimeSeconds + (static_cast(i) / processingSampleRate); - const float trimDb = juce::jlimit(-60.0f, 12.0f, trimVolumeAutomation.eval(timeSeconds)); + const float trimDb = juce::jlimit(-60.0f, 12.0f, trimValues[i]); const float trimGain = juce::Decibels::decibelsToGain(trimDb); if (bufferChannels >= 1) buffer.setSample(0, i, buffer.getSample(0, i) * trimGain); @@ -1350,10 +1568,11 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc if (muteAutoActive) { + auto* muteValues = automationGainBuffer.getWritePointer(7); + muteAutomation.evalBlock(blockTimeSeconds, processingSampleRate, numSamps, muteValues); for (int i = 0; i < numSamps; ++i) { - const double timeSeconds = blockTimeSeconds + (static_cast(i) / processingSampleRate); - const bool muted = muteAutomation.eval(timeSeconds) > 0.5f; + const bool muted = muteValues[i] > 0.5f; if (!muted) continue; for (int ch = 0; ch < bufferChannels; ++ch) @@ -1975,12 +2194,459 @@ void TrackProcessor::setInstrument(std::unique_ptr pl preparePluginPreservingLayout(plugin.get(), sr, bs, resolvePluginPrecisionMode(processingPrecisionMode, instrumentForceFloatOverride.load(std::memory_order_acquire))); + fallbackInstrumentResetRequested.store(true, std::memory_order_release); instrumentPlugin = std::shared_ptr(std::move(plugin)); publishRealtimeStateSnapshots(); juce::Logger::writeToLog("TrackProcessor: Instrument plugin loaded"); } } +void TrackProcessor::clearInstrument() +{ + const juce::ScopedLock processorCallbackGuard(getCallbackLock()); + queueAllNotesOff(); + fallbackInstrumentResetRequested.store(true, std::memory_order_release); + instrumentPlugin.reset(); + instrumentForceFloatOverride.store(false, std::memory_order_release); + publishRealtimeStateSnapshots(); + juce::Logger::writeToLog("TrackProcessor: Instrument plugin removed"); +} + +bool TrackProcessor::loadFallbackSamplerSample(const juce::String& filePath, int rootNote) +{ + const juce::File sampleFile(filePath); + if (!sampleFile.existsAsFile()) + return false; + + if (sampleFile.getFileExtension().toLowerCase() == ".sf2") + { + juce::MemoryBlock soundFontData; + if (!sampleFile.loadFileAsData(soundFontData) || soundFontData.getSize() < 12) + return false; + + const auto* bytes = static_cast(soundFontData.getData()); + const size_t byteCount = soundFontData.getSize(); + + auto matchesFourCC = [] (const juce::uint8* ptr, const char* text) + { + return ptr[0] == static_cast(text[0]) + && ptr[1] == static_cast(text[1]) + && ptr[2] == static_cast(text[2]) + && ptr[3] == static_cast(text[3]); + }; + + auto readI16 = [] (const juce::uint8* ptr) -> int + { + const int raw = static_cast(ptr[0]) | (static_cast(ptr[1]) << 8); + return raw >= 32768 ? raw - 65536 : raw; + }; + + auto readU32 = [] (const juce::uint8* ptr) -> juce::uint32 + { + return static_cast(ptr[0]) + | (static_cast(ptr[1]) << 8) + | (static_cast(ptr[2]) << 16) + | (static_cast(ptr[3]) << 24); + }; + + struct SoundFontSampleHeader + { + juce::uint32 start = 0; + juce::uint32 end = 0; + juce::uint32 sampleRate = 44100; + int originalPitch = 60; + }; + + const juce::uint8* smplChunk = nullptr; + size_t smplChunkBytes = 0; + std::vector sampleHeaders; + + auto scanListChunks = [&] (const juce::uint8* listBytes, size_t listBytesCount) + { + for (size_t offset = 0; offset + 8 <= listBytesCount;) + { + const auto* chunk = listBytes + offset; + const auto chunkSize = static_cast(readU32(chunk + 4)); + const size_t chunkDataOffset = offset + 8; + if (chunkDataOffset > listBytesCount || chunkSize > listBytesCount - chunkDataOffset) + break; + + const auto* chunkData = listBytes + chunkDataOffset; + if (matchesFourCC(chunk, "smpl")) + { + smplChunk = chunkData; + smplChunkBytes = chunkSize; + } + else if (matchesFourCC(chunk, "shdr")) + { + constexpr size_t headerBytes = 46; + for (size_t headerOffset = 0; headerOffset + headerBytes <= chunkSize; headerOffset += headerBytes) + { + const auto* header = chunkData + headerOffset; + const auto start = readU32(header + 20); + const auto end = readU32(header + 24); + const auto sampleRate = readU32(header + 32); + const int originalPitch = static_cast(header[40]); + + if (end > start) + { + sampleHeaders.push_back({ + start, + end, + sampleRate > 0 ? sampleRate : static_cast(44100), + juce::jlimit(0, 127, originalPitch), + }); + } + } + } + + offset = chunkDataOffset + chunkSize + (chunkSize & 1u); + } + }; + + if (!matchesFourCC(bytes, "RIFF") || !matchesFourCC(bytes + 8, "sfbk")) + return false; + + for (size_t offset = 12; offset + 12 <= byteCount;) + { + const auto* chunk = bytes + offset; + const auto chunkSize = static_cast(readU32(chunk + 4)); + const size_t chunkDataOffset = offset + 8; + if (chunkDataOffset > byteCount || chunkSize > byteCount - chunkDataOffset) + break; + + const auto* chunkData = bytes + chunkDataOffset; + if (matchesFourCC(chunk, "LIST") && chunkSize >= 4) + scanListChunks(chunkData + 4, chunkSize - 4); + + offset = chunkDataOffset + chunkSize + (chunkSize & 1u); + } + + const auto smplSampleCount = smplChunkBytes / 2; + const SoundFontSampleHeader* selectedHeader = nullptr; + for (const auto& header : sampleHeaders) + { + if (header.end > header.start + 8 && header.end <= smplSampleCount) + { + selectedHeader = &header; + break; + } + } + + if (smplChunk == nullptr || selectedHeader == nullptr) + return false; + + const double sourceSampleRate = selectedHeader->sampleRate > 0 + ? static_cast(selectedHeader->sampleRate) + : 44100.0; + const auto availableSamples = static_cast(selectedHeader->end - selectedHeader->start); + const auto maxSamples = static_cast(juce::jmax(1.0, sourceSampleRate) * 60.0); + const int samplesToRead = static_cast(juce::jmax(1, juce::jmin(availableSamples, maxSamples))); + + auto sample = std::make_shared(); + sample->samples.setSize(1, samplesToRead); + sample->samples.clear(); + sample->sourceSampleRate = sourceSampleRate; + sample->rootNote = juce::jlimit(0, 127, rootNote >= 0 ? rootNote : selectedHeader->originalPitch); + sample->filePath = sampleFile.getFullPathName(); + + const auto startSample = static_cast(selectedHeader->start); + for (int sampleIndex = 0; sampleIndex < samplesToRead; ++sampleIndex) + { + const auto sourceOffset = (startSample + static_cast(sampleIndex)) * 2; + sample->samples.setSample(0, sampleIndex, + static_cast(readI16(smplChunk + sourceOffset)) / 32768.0f); + } + + const juce::ScopedLock processorCallbackGuard(getCallbackLock()); + std::atomic_store_explicit(&fallbackSamplerSample, + std::static_pointer_cast(sample), + std::memory_order_release); + clearFallbackInstrumentState(); + fallbackInstrumentResetRequested.store(true, std::memory_order_release); + juce::Logger::writeToLog("TrackProcessor: Loaded fallback SoundFont sample " + + sampleFile.getFullPathName()); + return true; + } + + juce::AudioFormatManager formatManager; + formatManager.registerBasicFormats(); + std::unique_ptr reader(formatManager.createReaderFor(sampleFile)); + if (reader == nullptr || reader->lengthInSamples <= 0 || reader->numChannels <= 0) + return false; + + const auto maxSamples = static_cast( + juce::jmax(1.0, reader->sampleRate) * 60.0); + const int samplesToRead = static_cast( + juce::jmin(reader->lengthInSamples, maxSamples)); + const int channelsToRead = juce::jlimit(1, 2, static_cast(reader->numChannels)); + + auto sample = std::make_shared(); + sample->samples.setSize(channelsToRead, samplesToRead); + sample->samples.clear(); + sample->sourceSampleRate = reader->sampleRate > 0.0 ? reader->sampleRate : 44100.0; + sample->rootNote = juce::jlimit(0, 127, rootNote); + sample->filePath = sampleFile.getFullPathName(); + + if (!reader->read(&sample->samples, 0, samplesToRead, 0, true, true)) + return false; + + const juce::ScopedLock processorCallbackGuard(getCallbackLock()); + std::atomic_store_explicit(&fallbackSamplerSample, + std::static_pointer_cast(sample), + std::memory_order_release); + clearFallbackInstrumentState(); + fallbackInstrumentResetRequested.store(true, std::memory_order_release); + juce::Logger::writeToLog("TrackProcessor: Loaded fallback sampler sample " + sampleFile.getFullPathName()); + return true; +} + +void TrackProcessor::clearFallbackSamplerSample() +{ + const juce::ScopedLock processorCallbackGuard(getCallbackLock()); + std::atomic_store_explicit(&fallbackSamplerSample, + std::shared_ptr(), + std::memory_order_release); + clearFallbackInstrumentState(); + fallbackInstrumentResetRequested.store(true, std::memory_order_release); +} + +bool TrackProcessor::hasFallbackSamplerSample() const +{ + auto sample = std::atomic_load_explicit(&fallbackSamplerSample, std::memory_order_acquire); + return sample != nullptr && sample->samples.getNumSamples() > 0; +} + +juce::String TrackProcessor::getFallbackSamplerSamplePath() const +{ + auto sample = std::atomic_load_explicit(&fallbackSamplerSample, std::memory_order_acquire); + return sample != nullptr ? sample->filePath : juce::String(); +} + +void TrackProcessor::clearFallbackInstrumentState() +{ + for (auto& channelNotes : fallbackInstrumentNoteActive) + channelNotes.fill(false); + for (auto& channelNotes : fallbackInstrumentNoteReleasing) + channelNotes.fill(false); + for (auto& channelPhases : fallbackInstrumentPhase) + channelPhases.fill(0.0f); + for (auto& channelVelocities : fallbackInstrumentVelocity) + channelVelocities.fill(0.0f); + for (auto& channelEnvelopes : fallbackInstrumentEnvelope) + channelEnvelopes.fill(0.0f); + for (auto& channelPositions : fallbackSamplerPosition) + channelPositions.fill(0.0); + for (auto& channelIncrements : fallbackSamplerIncrement) + channelIncrements.fill(0.0); + fallbackInstrumentPitchBend.fill(0.0f); + fallbackInstrumentModulation.fill(0.0f); + fallbackInstrumentModPhase.fill(0.0f); +} + +bool TrackProcessor::hasActiveFallbackInstrumentVoices() const +{ + for (size_t channel = 0; channel < fallbackInstrumentEnvelope.size(); ++channel) + { + for (size_t note = 0; note < fallbackInstrumentEnvelope[channel].size(); ++note) + { + if (fallbackInstrumentNoteActive[channel][note] + || fallbackInstrumentNoteReleasing[channel][note] + || fallbackInstrumentEnvelope[channel][note] > 0.0001f) + return true; + } + } + return false; +} + +void TrackProcessor::handleFallbackInstrumentMidi(const juce::MidiMessage& message, double sampleRate) +{ + const int channelIndex = juce::jlimit(0, 15, message.getChannel() > 0 ? message.getChannel() - 1 : 0); + + if (message.isNoteOn()) + { + auto samplerSample = std::atomic_load_explicit(&fallbackSamplerSample, std::memory_order_acquire); + const int note = juce::jlimit(0, 127, message.getNoteNumber()); + fallbackInstrumentNoteActive[static_cast(channelIndex)][static_cast(note)] = true; + fallbackInstrumentNoteReleasing[static_cast(channelIndex)][static_cast(note)] = false; + fallbackInstrumentVelocity[static_cast(channelIndex)][static_cast(note)] + = static_cast(message.getVelocity()) / 127.0f; + fallbackInstrumentPhase[static_cast(channelIndex)][static_cast(note)] = 0.0f; + fallbackSamplerPosition[static_cast(channelIndex)][static_cast(note)] = 0.0; + fallbackSamplerIncrement[static_cast(channelIndex)][static_cast(note)] + = samplerSample != nullptr && sampleRate > 0.0 + ? (samplerSample->sourceSampleRate / sampleRate) + * std::pow(2.0, (static_cast(note - samplerSample->rootNote)) / 12.0) + : 0.0; + return; + } + + if (message.isNoteOff()) + { + const int note = juce::jlimit(0, 127, message.getNoteNumber()); + fallbackInstrumentNoteReleasing[static_cast(channelIndex)][static_cast(note)] = true; + return; + } + + if (message.isAllNotesOff() || message.isAllSoundOff()) + { + for (int note = 0; note < 128; ++note) + fallbackInstrumentNoteReleasing[static_cast(channelIndex)][static_cast(note)] = true; + return; + } + + if (message.isController()) + { + const int controller = message.getControllerNumber(); + if (controller == 1) + { + fallbackInstrumentModulation[static_cast(channelIndex)] + = static_cast(message.getControllerValue()) / 127.0f; + return; + } + if (controller == 121) + { + fallbackInstrumentModulation[static_cast(channelIndex)] = 0.0f; + fallbackInstrumentPitchBend[static_cast(channelIndex)] = 0.0f; + return; + } + if (controller == 120 || controller == 123) + { + for (int note = 0; note < 128; ++note) + fallbackInstrumentNoteReleasing[static_cast(channelIndex)][static_cast(note)] = true; + } + return; + } + + if (message.isPitchWheel()) + { + const float normalized = (static_cast(message.getPitchWheelValue()) - 8192.0f) / 8192.0f; + fallbackInstrumentPitchBend[static_cast(channelIndex)] = juce::jlimit(-1.0f, 1.0f, normalized); + } +} + +void TrackProcessor::renderFallbackInstrument(juce::AudioBuffer& buffer, + const juce::MidiBuffer& midiMessages, + int numSamples, + double sampleRate) +{ + if (numSamples <= 0 || sampleRate <= 0.0 || buffer.getNumChannels() <= 0) + return; + + if (fallbackInstrumentResetRequested.exchange(false, std::memory_order_acq_rel)) + clearFallbackInstrumentState(); + + const int bufferChannels = buffer.getNumChannels(); + auto samplerSample = std::atomic_load_explicit(&fallbackSamplerSample, std::memory_order_acquire); + const bool useSampler = samplerSample != nullptr + && samplerSample->samples.getNumSamples() > 1 + && samplerSample->samples.getNumChannels() > 0; + const float attackStep = 1.0f / juce::jmax(1.0f, static_cast(sampleRate) * 0.004f); + const float releaseStep = 1.0f / juce::jmax(1.0f, static_cast(sampleRate) * 0.12f); + const float synthGain = useSampler ? 0.24f : 0.045f; + const float twoPi = juce::MathConstants::twoPi; + const float inversePi = 1.0f / juce::MathConstants::pi; + + auto renderSegment = [&] (int startSample, int endSample) + { + if (endSample <= startSample) + return; + + for (int sample = startSample; sample < endSample; ++sample) + { + float mixed = 0.0f; + for (size_t channel = 0; channel < fallbackInstrumentNoteActive.size(); ++channel) + { + const float modulation = juce::jlimit(0.0f, 1.0f, fallbackInstrumentModulation[channel]); + float& modulationPhase = fallbackInstrumentModPhase[channel]; + const float vibratoSemitones = std::sin(modulationPhase) * modulation * 0.35f; + modulationPhase += twoPi * (5.2f + modulation * 1.8f) / static_cast(sampleRate); + if (modulationPhase >= twoPi) + modulationPhase -= twoPi; + + const float pitchBendFactor = std::pow(2.0f, ((fallbackInstrumentPitchBend[channel] * 2.0f) + vibratoSemitones) / 12.0f); + for (size_t note = 0; note < fallbackInstrumentNoteActive[channel].size(); ++note) + { + const bool isActive = fallbackInstrumentNoteActive[channel][note]; + const bool isReleasing = fallbackInstrumentNoteReleasing[channel][note]; + float& envelope = fallbackInstrumentEnvelope[channel][note]; + if (!isActive && envelope <= 0.0f) + continue; + + if (isActive && !isReleasing) + envelope = juce::jmin(1.0f, envelope + attackStep); + else + envelope = juce::jmax(0.0f, envelope - releaseStep); + + if (envelope <= 0.0f) + { + fallbackInstrumentNoteActive[channel][note] = false; + fallbackInstrumentNoteReleasing[channel][note] = false; + fallbackInstrumentVelocity[channel][note] = 0.0f; + continue; + } + + if (useSampler) + { + double& samplePosition = fallbackSamplerPosition[channel][note]; + const int sourceLength = samplerSample->samples.getNumSamples(); + if (samplePosition >= static_cast(sourceLength - 1)) + { + fallbackInstrumentNoteActive[channel][note] = false; + fallbackInstrumentNoteReleasing[channel][note] = false; + fallbackInstrumentVelocity[channel][note] = 0.0f; + envelope = 0.0f; + continue; + } + + const int index = juce::jlimit(0, sourceLength - 2, static_cast(samplePosition)); + const float frac = static_cast(samplePosition - static_cast(index)); + const int sourceChannels = samplerSample->samples.getNumChannels(); + float sampleValue = 0.0f; + for (int sourceChannel = 0; sourceChannel < sourceChannels; ++sourceChannel) + { + const float a = samplerSample->samples.getSample(sourceChannel, index); + const float b = samplerSample->samples.getSample(sourceChannel, index + 1); + sampleValue += a + (b - a) * frac; + } + sampleValue /= static_cast(sourceChannels); + mixed += sampleValue * envelope * fallbackInstrumentVelocity[channel][note] * synthGain; + samplePosition += fallbackSamplerIncrement[channel][note] * static_cast(pitchBendFactor); + } + else + { + const float frequency = static_cast(juce::MidiMessage::getMidiNoteInHertz(static_cast(note))) * pitchBendFactor; + float& phase = fallbackInstrumentPhase[channel][note]; + const float sine = std::sin(phase); + const float triangle = 2.0f * inversePi * std::asin(sine); + const float secondHarmonic = std::sin(phase * 2.0f) * 0.16f; + const float thirdHarmonic = std::sin(phase * 3.0f) * 0.06f; + const float tone = (sine * 0.62f + triangle * 0.28f + secondHarmonic + thirdHarmonic) / 1.12f; + mixed += tone * envelope * fallbackInstrumentVelocity[channel][note] * synthGain; + phase += twoPi * frequency / static_cast(sampleRate); + if (phase >= twoPi) + phase -= twoPi; + } + } + } + + mixed = juce::jlimit(-0.75f, 0.75f, mixed); + for (int channel = 0; channel < bufferChannels; ++channel) + buffer.addSample(channel, sample, mixed); + } + }; + + int cursor = 0; + for (const auto metadata : midiMessages) + { + const int eventSample = juce::jlimit(0, numSamples, metadata.samplePosition); + renderSegment(cursor, eventSample); + handleFallbackInstrumentMidi(metadata.getMessage(), sampleRate); + cursor = eventSample; + } + renderSegment(cursor, numSamples); +} + bool TrackProcessor::enqueueMidiMessage(const juce::MidiMessage& message, int sampleOffset) { int writeIndex = midiQueueWriteIndex.load(std::memory_order_relaxed); @@ -2003,6 +2669,7 @@ void TrackProcessor::setScheduledMIDIClips(std::vector clips) { auto sharedClips = std::make_shared>(std::move(clips)); std::atomic_store_explicit(&scheduledMIDIClips, sharedClips, std::memory_order_release); + requestMIDIChase(); } void TrackProcessor::markActiveMIDINoteState(const juce::MidiMessage& message) @@ -2011,22 +2678,70 @@ void TrackProcessor::markActiveMIDINoteState(const juce::MidiMessage& message) return; const int channelIndex = juce::jlimit(0, 15, message.getChannel() - 1); + const auto nowMs = juce::Time::getMillisecondCounter(); if (message.isNoteOn()) { - activeMIDINotes[static_cast(channelIndex)][static_cast(message.getNoteNumber())] = true; + const int note = juce::jlimit(0, 127, message.getNoteNumber()); + activeMIDINotes[static_cast(channelIndex)][static_cast(note)] = true; + midiNoteCurrentlyActive[static_cast(channelIndex)][static_cast(note)].store(true, std::memory_order_relaxed); + midiNoteLastOnMs[static_cast(channelIndex)][static_cast(note)].store(nowMs, std::memory_order_relaxed); + midiNoteLastVelocity[static_cast(channelIndex)][static_cast(note)] + .store(juce::jlimit(0, 127, static_cast(message.getVelocity())), std::memory_order_relaxed); } else if (message.isNoteOff()) { - activeMIDINotes[static_cast(channelIndex)][static_cast(message.getNoteNumber())] = false; + const int note = juce::jlimit(0, 127, message.getNoteNumber()); + activeMIDINotes[static_cast(channelIndex)][static_cast(note)] = false; + midiNoteCurrentlyActive[static_cast(channelIndex)][static_cast(note)].store(false, std::memory_order_relaxed); + midiNoteLastOffMs[static_cast(channelIndex)][static_cast(note)].store(nowMs, std::memory_order_relaxed); } else if (message.isAllNotesOff() || message.isAllSoundOff()) { for (auto& noteActive : activeMIDINotes[static_cast(channelIndex)]) noteActive = false; + for (size_t note = 0; note < midiNoteCurrentlyActive[static_cast(channelIndex)].size(); ++note) + { + midiNoteCurrentlyActive[static_cast(channelIndex)][note].store(false, std::memory_order_relaxed); + midiNoteLastOffMs[static_cast(channelIndex)][note].store(nowMs, std::memory_order_relaxed); + } } } +std::vector TrackProcessor::getRecentMIDINoteActivity(juce::uint32 maxAgeMs) const +{ + std::vector result; + result.reserve(16); + const auto nowMs = juce::Time::getMillisecondCounter(); + const auto safeMaxAgeMs = juce::jmax(static_cast(1), maxAgeMs); + + for (size_t channel = 0; channel < midiNoteLastOnMs.size(); ++channel) + { + for (size_t note = 0; note < midiNoteLastOnMs[channel].size(); ++note) + { + const auto lastOnMs = midiNoteLastOnMs[channel][note].load(std::memory_order_relaxed); + const auto lastOffMs = midiNoteLastOffMs[channel][note].load(std::memory_order_relaxed); + const auto latestMs = juce::jmax(lastOnMs, lastOffMs); + if (latestMs == 0) + continue; + + const auto ageMs = nowMs - latestMs; + if (ageMs > safeMaxAgeMs) + continue; + + MIDINoteActivity activity; + activity.note = static_cast(note); + activity.channel = static_cast(channel) + 1; + activity.velocity = midiNoteLastVelocity[channel][note].load(std::memory_order_relaxed); + activity.active = midiNoteCurrentlyActive[channel][note].load(std::memory_order_relaxed); + activity.ageMs = ageMs; + result.push_back(activity); + } + } + + return result; +} + void TrackProcessor::appendScheduledMIDIToBuffer(juce::MidiBuffer& destination, double blockTimeSeconds, int numSamples, double sampleRate) const @@ -2059,6 +2774,110 @@ void TrackProcessor::appendScheduledMIDIToBuffer(juce::MidiBuffer& destination, } } +void TrackProcessor::appendScheduledMIDIChaseToBuffer(juce::MidiBuffer& destination, + double blockTimeSeconds, + double sampleRate) const +{ + auto clips = std::atomic_load_explicit(&scheduledMIDIClips, std::memory_order_acquire); + if (!clips || clips->empty() || sampleRate <= 0.0) + return; + + std::array, 16> activeNoteStarts {}; + std::array, 16> ccChase {}; + std::array pitchBendChase {}; + std::array pressureChase {}; + std::array programChase {}; + + auto sameNote = [] (const juce::MidiMessage& a, const juce::MidiMessage& b) + { + return a.getChannel() == b.getChannel() && a.getNoteNumber() == b.getNoteNumber(); + }; + + for (const auto& clip : *clips) + { + if (clip.events.empty()) + continue; + + const double clipEndTime = clip.startTime + clip.duration; + if (clip.startTime > blockTimeSeconds || clipEndTime <= blockTimeSeconds) + continue; + + for (size_t eventIndex = 0; eventIndex < clip.events.size(); ++eventIndex) + { + const auto& event = clip.events[eventIndex]; + const auto& message = event.message; + const double absoluteEventTime = clip.startTime + event.timestampSeconds; + if (absoluteEventTime >= blockTimeSeconds) + break; + + const int channelIndex = juce::jlimit(0, 15, message.getChannel() - 1); + if (message.isNoteOn()) + { + bool noteEndsAfterBlock = false; + for (size_t endIndex = eventIndex + 1; endIndex < clip.events.size(); ++endIndex) + { + const auto& endEvent = clip.events[endIndex]; + const auto& endMessage = endEvent.message; + if (!endMessage.isNoteOff() || !sameNote(message, endMessage)) + continue; + + const double absoluteEndTime = clip.startTime + endEvent.timestampSeconds; + noteEndsAfterBlock = absoluteEndTime > blockTimeSeconds; + break; + } + + if (noteEndsAfterBlock) + activeNoteStarts[static_cast(channelIndex)] + [static_cast(message.getNoteNumber())] = &event; + } + else if (message.isNoteOff()) + { + activeNoteStarts[static_cast(channelIndex)] + [static_cast(message.getNoteNumber())] = nullptr; + } + else if (message.isController()) + { + ccChase[static_cast(channelIndex)] + [static_cast(juce::jlimit(0, 127, message.getControllerNumber()))] = &event; + } + else if (message.isPitchWheel()) + { + pitchBendChase[static_cast(channelIndex)] = &event; + } + else if (message.isChannelPressure() || message.isAftertouch()) + { + pressureChase[static_cast(channelIndex)] = &event; + } + else if (message.isProgramChange()) + { + programChase[static_cast(channelIndex)] = &event; + } + } + } + + for (const auto* event : programChase) + if (event != nullptr) + destination.addEvent(event->message, 0); + + for (const auto* event : pitchBendChase) + if (event != nullptr) + destination.addEvent(event->message, 0); + + for (const auto* event : pressureChase) + if (event != nullptr) + destination.addEvent(event->message, 0); + + for (const auto& channelCCs : ccChase) + for (const auto* event : channelCCs) + if (event != nullptr) + destination.addEvent(event->message, 0); + + for (const auto& channelNotes : activeNoteStarts) + for (const auto* event : channelNotes) + if (event != nullptr) + destination.addEvent(event->message, 0); +} + void TrackProcessor::appendQueuedMIDIToBuffer(juce::MidiBuffer& destination, int numSamples) { int readIndex = midiQueueReadIndex.load(std::memory_order_relaxed); @@ -2075,11 +2894,128 @@ void TrackProcessor::appendQueuedMIDIToBuffer(juce::MidiBuffer& destination, int } } +void TrackProcessor::applyMIDIAutomationToBuffer(juce::MidiBuffer& destination, double blockTimeSeconds, + int numSamples, double sampleRate) +{ + if (numSamples <= 0 || sampleRate <= 0.0) + return; + + const bool velocityActive = shouldApplyAutomation(midiVelocityScaleAutomation) + && midiVelocityScaleAutomation.getNumPoints() > 0; + const bool pitchBendActive = shouldApplyAutomation(midiPitchBendAutomation) + && midiPitchBendAutomation.getNumPoints() > 0; + const bool channelPressureActive = shouldApplyAutomation(midiChannelPressureAutomation) + && midiChannelPressureAutomation.getNumPoints() > 0; + auto ccSnapshot = std::atomic_load_explicit(&midiCCAutomationSnapshot, std::memory_order_acquire); + const bool hasCCRoutedAutomation = ccSnapshot != nullptr && !ccSnapshot->empty(); + + if (!velocityActive && !pitchBendActive && !channelPressureActive && !hasCCRoutedAutomation) + return; + + juce::MidiBuffer transformed; + + if (velocityActive) + { + for (const auto metadata : destination) + { + auto message = metadata.getMessage(); + if (message.isNoteOn()) + { + const double eventTime = blockTimeSeconds + static_cast(metadata.samplePosition) / sampleRate; + const float scale = juce::jlimit(0.0f, 2.0f, midiVelocityScaleAutomation.eval(eventTime)); + const int velocity = juce::jlimit(1, 127, static_cast(std::round(message.getVelocity() * scale))); + message = juce::MidiMessage::noteOn(message.getChannel(), message.getNoteNumber(), static_cast(velocity)); + } + transformed.addEvent(message, metadata.samplePosition); + } + } + else + { + transformed = destination; + } + + auto addForConfiguredChannels = [this, &transformed] (auto createMessage) + { + const int configuredChannel = juce::jlimit(0, 16, midiChannel); + if (configuredChannel > 0) + { + transformed.addEvent(createMessage(configuredChannel), 0); + return; + } + + for (int channel = 1; channel <= 16; ++channel) + transformed.addEvent(createMessage(channel), 0); + }; + + if (pitchBendActive) + { + const float bend = juce::jlimit(-1.0f, 1.0f, midiPitchBendAutomation.eval(blockTimeSeconds)); + const int wheel = juce::jlimit(0, 16383, static_cast(std::round((bend + 1.0f) * 8191.5f))); + addForConfiguredChannels([wheel] (int channel) { return juce::MidiMessage::pitchWheel(channel, wheel); }); + } + + if (channelPressureActive) + { + const int pressure = juce::jlimit(0, 127, static_cast(std::round(midiChannelPressureAutomation.eval(blockTimeSeconds)))); + addForConfiguredChannels([pressure] (int channel) { return juce::MidiMessage::channelPressureChange(channel, pressure); }); + } + + if (ccSnapshot) + { + for (const auto& route : *ccSnapshot) + { + if (!route || !route->automation || route->controller < 0 || route->controller > 127) + continue; + if (!shouldApplyAutomation(*route->automation) || route->automation->getNumPoints() <= 0) + continue; + + const int controller = route->controller; + const int value = juce::jlimit(0, 127, static_cast(std::round(route->automation->eval(blockTimeSeconds)))); + addForConfiguredChannels([controller, value] (int channel) + { + return juce::MidiMessage::controllerEvent(channel, controller, value); + }); + } + } + + destination.swapWith(transformed); +} + bool TrackProcessor::hasQueuedMIDI() const { return midiQueueReadIndex.load(std::memory_order_acquire) != midiQueueWriteIndex.load(std::memory_order_acquire); } +bool TrackProcessor::hasScheduledMIDIClips() const +{ + auto clips = std::atomic_load_explicit(&scheduledMIDIClips, std::memory_order_acquire); + return clips && !clips->empty(); +} + +std::vector TrackProcessor::getScheduledMIDIClipSnapshot() const +{ + auto clips = std::atomic_load_explicit(&scheduledMIDIClips, std::memory_order_acquire); + return clips ? *clips : std::vector(); +} + +int TrackProcessor::getScheduledMIDIClipCount() const +{ + auto clips = std::atomic_load_explicit(&scheduledMIDIClips, std::memory_order_acquire); + return clips ? static_cast(clips->size()) : 0; +} + +int TrackProcessor::getScheduledMIDIEventCount() const +{ + auto clips = std::atomic_load_explicit(&scheduledMIDIClips, std::memory_order_acquire); + if (!clips) + return 0; + + int count = 0; + for (const auto& clip : *clips) + count += static_cast(clip.events.size()); + return count; +} + bool TrackProcessor::hasScheduledMIDIInBlock(double blockTimeSeconds, int numSamples, double sampleRate) const { auto clips = std::atomic_load_explicit(&scheduledMIDIClips, std::memory_order_acquire); @@ -2112,10 +3048,16 @@ void TrackProcessor::buildMidiBuffer(juce::MidiBuffer& destination, double block { destination.clear(); + appendQueuedMIDIToBuffer(destination, numSamples); + if (playing) + { + if (scheduledMIDIChaseRequested.exchange(false, std::memory_order_acq_rel)) + appendScheduledMIDIChaseToBuffer(destination, blockTimeSeconds, sampleRate); appendScheduledMIDIToBuffer(destination, blockTimeSeconds, numSamples, sampleRate); + } - appendQueuedMIDIToBuffer(destination, numSamples); + applyMIDIAutomationToBuffer(destination, blockTimeSeconds, numSamples, sampleRate); lastBuiltMidiEventCount.store(destination.getNumEvents(), std::memory_order_relaxed); int prevMax = maxBuiltMidiEventCount.load(std::memory_order_relaxed); @@ -2138,17 +3080,31 @@ bool TrackProcessor::needsProcessing(double blockTimeSeconds, int numSamples, && std::atomic_load_explicit(&realtimeInstrumentSnapshot, std::memory_order_acquire) != nullptr) return true; + if (trackType.load(std::memory_order_acquire) == TrackType::Instrument + && (hasActiveFallbackInstrumentVoices() + || fallbackInstrumentResetRequested.load(std::memory_order_acquire))) + return true; + if (hasQueuedMIDI()) return true; + if (playing && scheduledMIDIChaseRequested.load(std::memory_order_acquire) && hasScheduledMIDIClips()) + return true; + if (playing && hasScheduledMIDIInBlock(blockTimeSeconds, numSamples, sampleRate)) return true; + if (playing && hasMIDIAutomation()) + return true; + return false; } -void TrackProcessor::queueAllNotesOff() +void TrackProcessor::queueAllNotesOff(bool requestChase) { + if (requestChase) + requestMIDIChase(); + fallbackInstrumentResetRequested.store(true, std::memory_order_release); for (size_t channel = 0; channel < activeMIDINotes.size(); ++channel) { for (size_t note = 0; note < activeMIDINotes[channel].size(); ++note) @@ -2162,6 +3118,11 @@ void TrackProcessor::queueAllNotesOff() } enqueueMidiMessage(juce::MidiMessage::allNotesOff(static_cast(channel) + 1)); + enqueueMidiMessage(juce::MidiMessage::controllerEvent(static_cast(channel) + 1, 64, 0)); + enqueueMidiMessage(juce::MidiMessage::controllerEvent(static_cast(channel) + 1, 120, 0)); + enqueueMidiMessage(juce::MidiMessage::controllerEvent(static_cast(channel) + 1, 121, 0)); + enqueueMidiMessage(juce::MidiMessage::controllerEvent(static_cast(channel) + 1, 123, 0)); + enqueueMidiMessage(juce::MidiMessage::pitchWheel(static_cast(channel) + 1, 8192)); } } @@ -2409,13 +3370,38 @@ void TrackProcessor::setMIDIOutputDevice(const juce::String& deviceName) } } -void TrackProcessor::sendMIDIToOutput(const juce::MidiBuffer& buffer) +void TrackProcessor::sendMIDIToOutput(const juce::MidiBuffer& buffer, double sampleRate, bool resetMessagesOnly) { if (midiOutputDevice == nullptr || buffer.isEmpty()) return; - for (const auto metadata : buffer) - midiOutputDevice->sendMessageNow(metadata.getMessage()); + if (sampleRate <= 0.0) + sampleRate = getSampleRate() > 0.0 ? getSampleRate() : 44100.0; + + if (resetMessagesOnly) + { + midiOutputResetBuffer.clear(); + for (const auto metadata : buffer) + { + const auto message = metadata.getMessage(); + const bool isReset = message.isAllNotesOff() + || message.isAllSoundOff() + || (message.isController() + && (message.getControllerNumber() == 64 + || message.getControllerNumber() == 120 + || message.getControllerNumber() == 121 + || message.getControllerNumber() == 123)) + || (message.isPitchWheel() && message.getPitchWheelValue() == 8192); + if (isReset) + midiOutputResetBuffer.addEvent(message, metadata.samplePosition); + } + + if (!midiOutputResetBuffer.isEmpty()) + midiOutputDevice->sendBlockOfMessages(midiOutputResetBuffer, juce::Time::getMillisecondCounterHiRes(), sampleRate); + return; + } + + midiOutputDevice->sendBlockOfMessages(buffer, juce::Time::getMillisecondCounterHiRes(), sampleRate); } // ============================================================================= diff --git a/Source/TrackProcessor.h b/Source/TrackProcessor.h index a4dab27..2317e73 100644 --- a/Source/TrackProcessor.h +++ b/Source/TrackProcessor.h @@ -78,7 +78,11 @@ class TrackProcessor : public juce::AudioProcessor PreFXWidth, TrimVolume, Mute, - PluginParameter + PluginParameter, + MIDIVelocityScale, + MIDIPitchBend, + MIDIChannelPressure, + MIDICC }; Kind kind = Kind::Volume; @@ -86,6 +90,7 @@ class TrackProcessor : public juce::AudioProcessor bool isInputFX = false; int fxIndex = -1; int paramIndex = -1; + int midiCC = -1; }; TrackProcessor(); @@ -214,7 +219,11 @@ class TrackProcessor : public juce::AudioProcessor bool getSolo() const { return isSoloed.load(); } // Alias for compatibility // Track Type (Phase 2 - MIDI) - void setTrackType(TrackType newType) { trackType.store(newType, std::memory_order_release); } + void setTrackType(TrackType newType) + { + trackType.store(newType, std::memory_order_release); + fallbackInstrumentResetRequested.store(true, std::memory_order_release); + } TrackType getTrackType() const { return trackType.load(std::memory_order_acquire); } // MIDI Configuration (Phase 2) @@ -226,8 +235,27 @@ class TrackProcessor : public juce::AudioProcessor // Instrument plugin (Phase 2) void setInstrument(std::unique_ptr plugin, double callerSampleRate = 0.0, int callerBlockSize = 0); + void clearInstrument(); juce::AudioPluginInstance* getInstrument() const { return instrumentPlugin.get(); } std::shared_ptr getInstrumentShared() const { return instrumentPlugin; } + bool isUsingFallbackInstrument() const + { + return trackType.load(std::memory_order_acquire) == TrackType::Instrument + && std::atomic_load_explicit(&realtimeInstrumentSnapshot, std::memory_order_acquire) == nullptr; + } + bool loadFallbackSamplerSample(const juce::String& filePath, int rootNote); + void clearFallbackSamplerSample(); + bool hasFallbackSamplerSample() const; + juce::String getFallbackSamplerSamplePath() const; + struct MIDINoteActivity + { + int note = 0; + int channel = 1; + int velocity = 0; + bool active = false; + juce::uint32 ageMs = 0; + }; + std::vector getRecentMIDINoteActivity(juce::uint32 maxAgeMs) const; // MIDI intake / scheduling bool enqueueMidiMessage(const juce::MidiMessage& message, int sampleOffset = 0); @@ -235,12 +263,16 @@ class TrackProcessor : public juce::AudioProcessor void buildMidiBuffer(juce::MidiBuffer& destination, double blockTimeSeconds, int numSamples, double sampleRate, bool playing); bool needsProcessing(double blockTimeSeconds, int numSamples, double sampleRate, bool playing) const; - void queueAllNotesOff(); + void queueAllNotesOff(bool requestChase = true); + void requestMIDIChase() { scheduledMIDIChaseRequested.store(true, std::memory_order_release); } std::vector getSidechainSourceSnapshot() const; std::vector getRealtimeSendSnapshot() const; int getMidiOverflowCount() const { return midiQueueOverflowCount.load(std::memory_order_relaxed); } int getLastBuiltMidiEventCount() const { return lastBuiltMidiEventCount.load(std::memory_order_relaxed); } int getMaxBuiltMidiEventCount() const { return maxBuiltMidiEventCount.load(std::memory_order_relaxed); } + std::vector getScheduledMIDIClipSnapshot() const; + int getScheduledMIDIClipCount() const; + int getScheduledMIDIEventCount() const; int getRealtimeFallbackReuseCount() const { return realtimeFallbackReuseCount.load(std::memory_order_relaxed); } int getPluginBusySkipCount() const { return pluginBusySkipCount.load(std::memory_order_relaxed); } @@ -299,7 +331,7 @@ class TrackProcessor : public juce::AudioProcessor // Per-track MIDI Output void setMIDIOutputDevice(const juce::String& deviceName); juce::String getMIDIOutputDeviceName() const { return midiOutputDeviceName; } - void sendMIDIToOutput(const juce::MidiBuffer& buffer); + void sendMIDIToOutput(const juce::MidiBuffer& buffer, double sampleRate, bool resetMessagesOnly = false); // Automation AutomationList& getVolumeAutomation() { return volumeAutomation; } @@ -310,6 +342,9 @@ class TrackProcessor : public juce::AudioProcessor AutomationList& getPreFXWidthAutomation() { return preFXWidthAutomation; } AutomationList& getTrimVolumeAutomation() { return trimVolumeAutomation; } AutomationList& getMuteAutomation() { return muteAutomation; } + AutomationList& getMIDIVelocityScaleAutomation() { return midiVelocityScaleAutomation; } + AutomationList& getMIDIPitchBendAutomation() { return midiPitchBendAutomation; } + AutomationList& getMIDIChannelPressureAutomation() { return midiChannelPressureAutomation; } const AutomationList& getVolumeAutomation() const { return volumeAutomation; } const AutomationList& getPanAutomation() const { return panAutomation; } const AutomationList& getWidthAutomation() const { return widthAutomation; } @@ -318,8 +353,14 @@ class TrackProcessor : public juce::AudioProcessor const AutomationList& getPreFXWidthAutomation() const { return preFXWidthAutomation; } const AutomationList& getTrimVolumeAutomation() const { return trimVolumeAutomation; } const AutomationList& getMuteAutomation() const { return muteAutomation; } + const AutomationList& getMIDIVelocityScaleAutomation() const { return midiVelocityScaleAutomation; } + const AutomationList& getMIDIPitchBendAutomation() const { return midiPitchBendAutomation; } + const AutomationList& getMIDIChannelPressureAutomation() const { return midiChannelPressureAutomation; } + bool hasPluginAutomation() const; + bool hasMIDIAutomation() const; std::optional resolveAutomationTarget(const juce::String& parameterId, bool createIfNeeded); float getAutomationDefaultValue(const AutomationTarget& target) const; + void resetAutomationTouchState(); // Set the current timeline position for this block (called by AudioEngine // before processBlock so automation knows where it is on the timeline). @@ -328,6 +369,7 @@ class TrackProcessor : public juce::AudioProcessor blockStartTimeSeconds = timeSeconds; } void setIgnoreStaticMuteForProcessing(bool ignore) { ignoreStaticMuteDuringProcessing.store(ignore, std::memory_order_relaxed); } + void setForceAutomationReadForProcessing(bool forceRead) { forceAutomationReadDuringProcessing.store(forceRead, std::memory_order_relaxed); } bool tryProcessBlock(juce::AudioBuffer&, juce::MidiBuffer&); @@ -368,12 +410,34 @@ class TrackProcessor : public juce::AudioProcessor using PluginAutomationRouteSnapshot = std::vector>; + struct MIDICCAutomationRoute + { + juce::String parameterId; + int controller = -1; + std::shared_ptr automation = std::make_shared(); + }; + + using MIDICCAutomationRouteSnapshot = std::vector>; + + struct PluginAutomationParameterRef + { + bool isInputFX = false; + int fxIndex = -1; + int paramIndex = -1; + }; + void processBlockInternal(juce::AudioBuffer&, juce::MidiBuffer&); void publishRealtimeStateSnapshots(); std::shared_ptr getOrCreatePluginAutomationRoute(const juce::String& parameterId); std::shared_ptr findPluginAutomationRoute(const juce::String& parameterId) const; - static std::optional> parsePluginAutomationParameterId(const juce::String& parameterId); + std::optional parsePluginAutomationParameterId(const juce::String& parameterId) const; void applyPluginAutomationForProcessor(juce::AudioProcessor* proc, bool isInputFX, int fxIndex, double blockTimeSeconds); + std::shared_ptr getOrCreateMIDICCAutomationRoute(const juce::String& parameterId); + std::shared_ptr findMIDICCAutomationRoute(const juce::String& parameterId) const; + static std::optional parseMIDICCAutomationParameterId(const juce::String& parameterId); + void applyMIDIAutomationToBuffer(juce::MidiBuffer& destination, double blockTimeSeconds, + int numSamples, double sampleRate); + bool shouldApplyAutomation(const AutomationList& automation) const; // Current peak level (was named currentRMS but now holds peak — kept as-is // to avoid changing the public getRMSLevel() / resetRMS() API used by AudioEngine). @@ -471,13 +535,19 @@ class TrackProcessor : public juce::AudioProcessor AutomationList preFXWidthAutomation; AutomationList trimVolumeAutomation; AutomationList muteAutomation; + AutomationList midiVelocityScaleAutomation; + AutomationList midiPitchBendAutomation; + AutomationList midiChannelPressureAutomation; double blockStartTimeSeconds { 0.0 }; std::atomic ignoreStaticMuteDuringProcessing { false }; + std::atomic forceAutomationReadDuringProcessing { false }; // Pre-allocated buffer for per-sample automation gain (avoids alloc on audio thread) juce::AudioBuffer automationGainBuffer; juce::CriticalSection pluginAutomationRouteLock; std::shared_ptr pluginAutomationSnapshot; + juce::CriticalSection midiAutomationRouteLock; + std::shared_ptr midiCCAutomationSnapshot; // Plugin Delay Compensation (PDC) juce::dsp::DelayLine pdcDelayLine { 96000 }; // max 2 seconds at 48kHz @@ -520,6 +590,7 @@ class TrackProcessor : public juce::AudioProcessor // Per-track MIDI Output juce::String midiOutputDeviceName; std::unique_ptr midiOutputDevice; + juce::MidiBuffer midiOutputResetBuffer; juce::AudioBuffer realtimeFallbackBuffer; std::atomic realtimeFallbackReuseCount { 0 }; std::atomic pluginBusySkipCount { 0 }; @@ -537,6 +608,7 @@ class TrackProcessor : public juce::AudioProcessor std::atomic midiQueueOverflowCount { 0 }; std::atomic lastBuiltMidiEventCount { 0 }; std::atomic maxBuiltMidiEventCount { 0 }; + std::atomic scheduledMIDIChaseRequested { true }; std::shared_ptr> scheduledMIDIClips { std::make_shared>() @@ -567,13 +639,46 @@ class TrackProcessor : public juce::AudioProcessor std::make_shared() }; std::array, 16> activeMIDINotes {}; + std::array, 16> fallbackInstrumentNoteActive {}; + std::array, 16> fallbackInstrumentNoteReleasing {}; + std::array, 16> fallbackInstrumentPhase {}; + std::array, 16> fallbackInstrumentVelocity {}; + std::array, 16> fallbackInstrumentEnvelope {}; + std::array, 16> fallbackSamplerPosition {}; + std::array, 16> fallbackSamplerIncrement {}; + std::array fallbackInstrumentPitchBend {}; + std::array fallbackInstrumentModulation {}; + std::array fallbackInstrumentModPhase {}; + std::array, 128>, 16> midiNoteCurrentlyActive {}; + std::array, 128>, 16> midiNoteLastOnMs {}; + std::array, 128>, 16> midiNoteLastOffMs {}; + std::array, 128>, 16> midiNoteLastVelocity {}; + std::atomic fallbackInstrumentResetRequested { true }; + struct FallbackSamplerSample + { + juce::AudioBuffer samples; + double sourceSampleRate = 44100.0; + int rootNote = 60; + juce::String filePath; + }; + std::shared_ptr fallbackSamplerSample; ProcessingPrecisionMode processingPrecisionMode { ProcessingPrecisionMode::Float32 }; void markActiveMIDINoteState(const juce::MidiMessage& message); + void clearFallbackInstrumentState(); + bool hasActiveFallbackInstrumentVoices() const; + void handleFallbackInstrumentMidi(const juce::MidiMessage& message, double sampleRate); + void renderFallbackInstrument(juce::AudioBuffer& buffer, + const juce::MidiBuffer& midiMessages, + int numSamples, + double sampleRate); + void appendScheduledMIDIChaseToBuffer(juce::MidiBuffer& destination, double blockTimeSeconds, + double sampleRate) const; void appendScheduledMIDIToBuffer(juce::MidiBuffer& destination, double blockTimeSeconds, int numSamples, double sampleRate) const; void appendQueuedMIDIToBuffer(juce::MidiBuffer& destination, int numSamples); bool hasQueuedMIDI() const; + bool hasScheduledMIDIClips() const; bool hasScheduledMIDIInBlock(double blockTimeSeconds, int numSamples, double sampleRate) const; // ARA Plugin Hosting (Phase 9) diff --git a/WORKFLOWS.md b/WORKFLOWS.md index a506550..17d8c5c 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -15,6 +15,19 @@ python build.py dev --run **No more juggling terminals!** +## Manual Testing Handoff + +When Codex asks for manual testing, the handoff must be ready for this exact command: + +```bash +python build.py dev --run +``` + +Before that handoff: +- `cmake --build build --config Debug` must have completed after the latest changes. +- The current frontend must be built/copied so packaged assets are not stale if fallback is ever used. +- No pre-running Vite/npm/dev server should be required from the user. +- Any Codex-started Vite/npm/browser harness processes should be stopped first, and port `5173` should not be left occupied by a Codex-started process. **Run this to rebuild the backend:** diff --git a/build.py b/build.py index 6b2fd79..bd7f202 100644 --- a/build.py +++ b/build.py @@ -156,7 +156,7 @@ def start_vite_server(): frontend_dir = os.path.join(os.getcwd(), "frontend") print("\n--- Starting Vite Dev Server ---") vite_process = subprocess.Popen( - [get_npm_executable(), "run", "dev"], + [get_npm_executable(), "run", "dev", "--", "--host", "127.0.0.1", "--strictPort"], cwd=frontend_dir, shell=False ) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b49b8e8..9746c71 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState, Suspense } from "react"; import { useShallow } from "zustand/shallow"; -import { X } from "lucide-react"; +import { ExternalLink, GripHorizontal, X } from "lucide-react"; import { nativeBridge, type NativeGlobalShortcutEvent } from "./services/NativeBridge"; import { getGlobalShortcutConflicts } from "./store/actionRegistry"; import { @@ -11,10 +11,18 @@ import { getMasterTrackHeaderHeight, } from "./store/useDAWStore"; import { dispatchGlobalShortcut } from "./utils/globalShortcutDispatcher"; +import { + installModalContextMenuLeakGuard, + shouldSuppressWorkspaceContextMenu, +} from "./utils/modalEventGuards"; import { publishCurrentMixerUISnapshot, startMixerUISync, } from "./utils/mixerWindowSync"; +import { + publishMidiEditorSessionSnapshot, + startMidiEditorUISync, +} from "./utils/midiEditorWindowSync"; import { maybeRunPitchRegressionDriver } from "./utils/pitchRegressionDriver"; import { Button } from "./components/ui"; import { Timeline } from "./components/Timeline"; @@ -89,6 +97,8 @@ import { } from "@dnd-kit/sortable"; function App() { + const startupReadyReportedRef = useRef(false); + // Use useShallow to prevent re-renders when unrelated state changes (like currentTime) const { tracks, @@ -111,8 +121,13 @@ function App() { showPianoRoll, pianoRollTrackId, pianoRollClipId, + midiEditorSessions, + activeMidiEditorSessionId, + dockedMidiEditorSessionId, selectedClipIds, closePianoRoll, + lowerZoneHeight, + setLowerZoneHeight, showUndoHistory, showCommandPalette, showRegionMarkerManager, @@ -158,6 +173,7 @@ function App() { showStemSeparation, showAiToolsSetup, closeAiToolsSetup, + hydrateRecentProjects, } = useDAWStore( useShallow((state) => ({ tracks: state.tracks, @@ -180,8 +196,13 @@ function App() { showPianoRoll: state.showPianoRoll, pianoRollTrackId: state.pianoRollTrackId, pianoRollClipId: state.pianoRollClipId, + midiEditorSessions: state.midiEditorSessions, + activeMidiEditorSessionId: state.activeMidiEditorSessionId, + dockedMidiEditorSessionId: state.dockedMidiEditorSessionId, selectedClipIds: state.selectedClipIds, closePianoRoll: state.closePianoRoll, + lowerZoneHeight: state.lowerZoneHeight, + setLowerZoneHeight: state.setLowerZoneHeight, showUndoHistory: state.showUndoHistory, showCommandPalette: state.showCommandPalette, showRegionMarkerManager: state.showRegionMarkerManager, @@ -227,9 +248,37 @@ function App() { showStemSeparation: state.showStemSeparation, showAiToolsSetup: state.showAiToolsSetup, closeAiToolsSetup: state.closeAiToolsSetup, + hydrateRecentProjects: state.hydrateRecentProjects, })) ); + useEffect(() => { + let cancelled = false; + + const markAppReady = (detail: string) => { + if (cancelled || startupReadyReportedRef.current) { + return; + } + + startupReadyReportedRef.current = true; + window.dispatchEvent(new CustomEvent("openstudio:app-ready", { detail })); + }; + + void (async () => { + try { + await hydrateRecentProjects(); + markAppReady("main-app-hydrated"); + } catch (error) { + console.error("[startup] Failed to hydrate recent projects:", error); + markAppReady("main-app-hydration-failed"); + } + })(); + + return () => { + cancelled = true; + }; + }, [hydrateRecentProjects]); + // Compute visible tracks — hides children of collapsed folder tracks const visibleTracks = useMemo(() => { const collapsedFolderIds = new Set(); @@ -249,13 +298,71 @@ function App() { }); }, [tracks]); - // Ref for workspace wheel handling + // Ref for workspace wheel handling and lower-zone sizing const workspaceRef = useRef(null); + const dockedMidiEditorSession = useMemo( + () => midiEditorSessions.find((session) => session.sessionId === dockedMidiEditorSessionId) ?? null, + [dockedMidiEditorSessionId, midiEditorSessions], + ); + const activeMidiEditorSession = useMemo( + () => midiEditorSessions.find((session) => session.sessionId === activeMidiEditorSessionId) ?? null, + [activeMidiEditorSessionId, midiEditorSessions], + ); + const dockedPianoRollTrackId = dockedMidiEditorSession?.trackId ?? pianoRollTrackId; + const dockedPianoRollClipId = dockedMidiEditorSession?.clipId ?? pianoRollClipId; + const dockedPianoRollTrack = useMemo( + () => tracks.find((track) => track.id === dockedPianoRollTrackId) ?? null, + [dockedPianoRollTrackId, tracks], + ); + const dockedPianoRollClip = useMemo( + () => dockedPianoRollTrack?.midiClips.find((clip) => clip.id === dockedPianoRollClipId) ?? null, + [dockedPianoRollClipId, dockedPianoRollTrack], + ); + const dockedMidiEditorTitle = useMemo(() => { + if (!dockedPianoRollTrack || !dockedPianoRollClip) return "MIDI Editor"; + return `${dockedPianoRollTrack.name || "Track"} - ${dockedPianoRollClip.name || "MIDI Clip"}`; + }, [dockedPianoRollClip, dockedPianoRollTrack]); + + const pianoRollAdditionalClipIds = useMemo(() => { + if (!dockedPianoRollTrackId || !dockedPianoRollClipId || selectedClipIds.length <= 1) return []; + const pianoTrack = tracks.find((track) => track.id === dockedPianoRollTrackId); + if (!pianoTrack) return []; + const midiClipIds = new Set(pianoTrack.midiClips.map((clip) => clip.id)); + return selectedClipIds.filter((id) => id !== dockedPianoRollClipId && midiClipIds.has(id)); + }, [dockedPianoRollClipId, dockedPianoRollTrackId, selectedClipIds, tracks]); + + const beginLowerZoneResize = useCallback((event: React.MouseEvent) => { + event.preventDefault(); + const startY = event.clientY; + const startHeight = lowerZoneHeight; + document.body.style.cursor = "row-resize"; + document.body.style.userSelect = "none"; + + const onMove = (moveEvent: MouseEvent) => { + const workspaceHeight = workspaceRef.current?.getBoundingClientRect().height ?? window.innerHeight; + const availableHeight = Math.max(window.innerHeight, workspaceHeight + startHeight); + const maxHeight = Math.max(180, Math.round(availableHeight * 0.85)); + const nextHeight = Math.max(180, Math.min(maxHeight, startHeight - (moveEvent.clientY - startY))); + setLowerZoneHeight(nextHeight); + }; + const onUp = () => { + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }, [lowerZoneHeight, setLowerZoneHeight]); + // OS file drag-drop visual indicator const [isDraggingFiles, setIsDraggingFiles] = useState(false); const dragCounterRef = useRef(0); const hideMixerOnCloseRef = useRef(false); + const openedMidiEditorWindowsRef = useRef>(new Set()); + const prewarmedMidiEditorSessionRef = useRef(null); const isMixerDetached = detachedPanels.includes("mixer"); // Project loading state (separate selector to avoid unnecessary re-renders) @@ -292,6 +399,8 @@ function App() { && hasNativeMusicProfile, )); const [aiToolsInstallStatusStale, setAiToolsInstallStatusStale] = useState(false); + const [showAiToolsBackgroundInstallPopup, setShowAiToolsBackgroundInstallPopup] = useState(false); + const previousAiToolsInstallForPopupRef = useRef(aiToolsStatus.installInProgress); const aiToolsHasByteProgress = (aiToolsStatus.bytesTotal ?? 0) > 0 && (aiToolsStatus.bytesDownloaded ?? 0) >= 0; const aiToolsVisualProgressRatio = aiToolsHasByteProgress ? Math.max(0, Math.min((aiToolsStatus.bytesDownloaded ?? 0) / Math.max(aiToolsStatus.bytesTotal ?? 1, 1), 1)) @@ -299,6 +408,7 @@ function App() { const aiToolsVisualProgressPercent = Math.round(aiToolsVisualProgressRatio * 100); useEffect(() => startMixerUISync(), []); + useEffect(() => startMidiEditorUISync(), []); useEffect(() => { void refreshAiToolsStatus(true); @@ -344,6 +454,18 @@ function App() { return () => window.clearInterval(timer); }, [aiToolsStatus.installInProgress, aiToolsStatusLastUpdatedAt, refreshAiToolsStatus]); + useEffect(() => { + const wasInstalling = previousAiToolsInstallForPopupRef.current; + + if (!aiToolsStatus.installInProgress) { + setShowAiToolsBackgroundInstallPopup(false); + } else if (!wasInstalling && showAiToolsSetup) { + setShowAiToolsBackgroundInstallPopup(true); + } + + previousAiToolsInstallForPopupRef.current = aiToolsStatus.installInProgress; + }, [aiToolsStatus.installInProgress, showAiToolsSetup]); + useEffect(() => { const previousState = previousAiToolsStateRef.current; const previousInstallInProgress = previousAiToolsInstallInProgressRef.current; @@ -366,7 +488,6 @@ function App() { ), ); const installAttemptStates = new Set([ - "checking", "fetching_runtime_manifest", "downloading_runtime", "verifying_runtime_archive", @@ -381,12 +502,10 @@ function App() { const readyStateChanged = previousState !== currentState; const fullReadyChanged = previousFullReady !== currentFullReady; - if (readyStateChanged || fullReadyChanged) { + if (wasInstallAttempt && (readyStateChanged || fullReadyChanged)) { if (currentFullReady && (readyStateChanged || !previousFullReady)) { showToast("AI tools are ready", "success"); - if (wasInstallAttempt) { - closeAiToolsSetup(); - } + closeAiToolsSetup(); } else if (currentPartialReady && readyStateChanged) { showToast( (!hasNativeMusicProfile @@ -397,19 +516,13 @@ function App() { || "Stem separation is ready, but music generation is not fully ready yet.", "info", ); - if (wasInstallAttempt) { - openAiToolsSetup(); - } + openAiToolsSetup(); } else if (currentState === "error" && aiToolsStatus.error) { showToast(aiToolsStatus.error, "error"); - if (wasInstallAttempt) { - openAiToolsSetup(); - } + openAiToolsSetup(); } else if (currentState === "cancelled") { showToast("AI tools installation cancelled", "info"); - if (wasInstallAttempt) { - openAiToolsSetup(); - } + openAiToolsSetup(); } else if (currentState === "pythonMissing" && wasInstallAttempt) { openAiToolsSetup(); } @@ -467,6 +580,105 @@ function App() { return unsubscribe; }, []); + useEffect(() => { + const unsubscribe = nativeBridge.subscribe("midiEditorWindowClosed", (payload) => { + const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : ""; + const reason = typeof payload?.reason === "string" ? payload.reason : "close"; + if (sessionId) { + openedMidiEditorWindowsRef.current.delete(sessionId); + if (prewarmedMidiEditorSessionRef.current === sessionId && reason !== "dock") { + prewarmedMidiEditorSessionRef.current = null; + } + } + + if (reason === "dock" || reason === "warmDispose" || reason === "appQuit") { + useDAWStore.setState((current) => ({ + detachedPanels: current.midiEditorSessions.some((candidate) => candidate.mode === "windowed") + ? current.detachedPanels + : current.detachedPanels.filter((id) => id !== "midiEditor"), + })); + return; + } + + const state = useDAWStore.getState(); + const session = sessionId + ? state.midiEditorSessions.find((candidate) => candidate.sessionId === sessionId) + : null; + if (session && session.mode === "windowed") { + state.closeMidiEditorSession(sessionId); + return; + } + + useDAWStore.setState((current) => ({ + detachedPanels: current.midiEditorSessions.some((candidate) => candidate.mode === "windowed") + ? current.detachedPanels + : current.detachedPanels.filter((id) => id !== "midiEditor"), + })); + }); + + return unsubscribe; + }, []); + + useEffect(() => { + const previousSessionId = prewarmedMidiEditorSessionRef.current; + const previousSession = previousSessionId + ? midiEditorSessions.find((session) => session.sessionId === previousSessionId) + : null; + + if (previousSessionId && previousSessionId !== dockedMidiEditorSessionId) { + prewarmedMidiEditorSessionRef.current = null; + if (!previousSession || previousSession.mode !== "windowed") { + void nativeBridge.closeMidiEditorWindow(previousSessionId, "warmDispose"); + } + } + + if (!dockedMidiEditorSessionId || previousSessionId === dockedMidiEditorSessionId) { + return; + } + + prewarmedMidiEditorSessionRef.current = dockedMidiEditorSessionId; + void (async () => { + await publishMidiEditorSessionSnapshot(dockedMidiEditorSessionId); + await nativeBridge.prewarmMidiEditorWindow(dockedMidiEditorSessionId, { + x: 120, + y: 80, + width: 1400, + height: 850, + }); + })(); + }, [dockedMidiEditorSessionId, midiEditorSessions]); + + useEffect(() => { + midiEditorSessions + .filter((session) => session.mode === "windowed") + .forEach((session) => { + if (openedMidiEditorWindowsRef.current.has(session.sessionId)) { + return; + } + openedMidiEditorWindowsRef.current.add(session.sessionId); + void (async () => { + await publishMidiEditorSessionSnapshot(session.sessionId); + const opened = await nativeBridge.openMidiEditorWindow(session.sessionId, { + x: 120, + y: 80, + width: 1400, + height: 850, + }); + if (!opened) { + openedMidiEditorWindowsRef.current.delete(session.sessionId); + useDAWStore.getState().showToast("Unable to open the detached MIDI editor window.", "error"); + } + })(); + }); + }, [midiEditorSessions]); + + useEffect(() => { + if (!activeMidiEditorSession || activeMidiEditorSession.mode !== "windowed") { + return; + } + void nativeBridge.focusMidiEditorWindow(activeMidiEditorSession.sessionId); + }, [activeMidiEditorSession]); + useEffect(() => { const unsubscribe = nativeBridge.onAppCloseRequested(() => { void useDAWStore.getState().requestQuit(); @@ -475,6 +687,86 @@ function App() { return unsubscribe; }, []); + useEffect(() => { + const stopNativeRuntimeSurfaces = () => { + void nativeBridge.setTransportRecording(false); + void nativeBridge.setTransportPlaying(false); + void nativeBridge.closeAllPluginWindows(); + }; + + stopNativeRuntimeSurfaces(); + window.addEventListener("pagehide", stopNativeRuntimeSurfaces); + window.addEventListener("beforeunload", stopNativeRuntimeSurfaces); + return () => { + window.removeEventListener("pagehide", stopNativeRuntimeSurfaces); + window.removeEventListener("beforeunload", stopNativeRuntimeSurfaces); + stopNativeRuntimeSurfaces(); + }; + }, []); + + useEffect(() => { + const unsubscribe = nativeBridge.subscribe("appCommand", (payload) => { + if (!payload || typeof payload !== "object") { + return; + } + + const state = useDAWStore.getState(); + const command = typeof payload.command === "string" ? payload.command : ""; + const sessionId = typeof payload.sessionId === "string" ? payload.sessionId : ""; + const session = sessionId + ? state.midiEditorSessions.find((candidate) => candidate.sessionId === sessionId) + : null; + + if (command === "transport.toggle") { + if (state.transport.isRecording || state.transport.isPlaying) void state.stop(); + else void state.play(); + return; + } + + if (command === "transport.stop") { + void state.stop(); + return; + } + + if (command === "transport.record") { + void state.record(); + return; + } + + if (command === "transport.seek") { + const time = typeof payload.time === "number" ? payload.time : state.transport.currentTime; + void state.seekTo(Math.max(0, time)); + return; + } + + if (command === "transport.seekPreview") { + const time = typeof payload.time === "number" ? payload.time : state.transport.currentTime; + const clampedTime = Math.max(0, time); + state.setCurrentTime(clampedTime); + void nativeBridge.setTransportPosition(clampedTime); + return; + } + + if (command === "edit.undo") { + state.undo(); + return; + } + + if (command === "edit.redo") { + state.redo(); + return; + } + + if (command === "midi.quantize") { + const targetTrackId = session?.trackId || state.pianoRollTrackId || undefined; + const targetClipId = session?.clipId || state.pianoRollClipId || undefined; + state.quantizeSelectedMIDINotesUsingLast(targetTrackId, targetClipId); + } + }); + + return unsubscribe; + }, []); + const handleDetachMixer = useCallback(async () => { const state = useDAWStore.getState(); const mixerBounds = state.panelPositions.mixer; @@ -517,6 +809,17 @@ function App() { toggleMixer(); }, [isMixerDetached, toggleMixer]); + const handleDetachMidiEditor = useCallback(async () => { + const state = useDAWStore.getState(); + const sessionId = state.dockedMidiEditorSessionId || state.activeMidiEditorSessionId; + if (!sessionId) { + state.showToast("Open a MIDI clip before detaching the editor.", "error"); + return; + } + + state.popOutMidiEditorSession(sessionId); + }, []); + // Accessibility: UI font scale — apply to root element const uiFontScale = useDAWStore((state) => state.uiFontScale); useEffect(() => { @@ -560,6 +863,7 @@ function App() { // Update automation display values at ~30fps (every ~33ms) if (now - lastAutoUpdate > 33) { lastAutoUpdate = now; + currentState.recordAutomationWriteTick(now); currentState.updateAutomatedValues(); } @@ -603,7 +907,12 @@ function App() { return; } - if (!frontendPlaying) return; + if (!frontendPlaying) { + if (drift > 0.001) { + state.setCurrentTime(backendPos); + } + return; + } // Only correct if drift exceeds 30ms — avoids jitter from minor timing differences if (drift > 0.03) { @@ -739,13 +1048,15 @@ function App() { targetIsEditable: !!target && (target instanceof HTMLInputElement || + target instanceof HTMLSelectElement || target instanceof HTMLTextAreaElement || target.isContentEditable), preventDefault: () => e.preventDefault(), + stopPropagation: () => e.stopPropagation(), }); }; - window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keydown", handleKeyDown, true); const unsubscribeNativeShortcuts = nativeBridge.onNativeGlobalShortcut( (event: NativeGlobalShortcutEvent) => { void dispatchGlobalShortcut({ ...event, source: "pluginWindow" }); @@ -753,11 +1064,13 @@ function App() { ); return () => { - window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keydown", handleKeyDown, true); unsubscribeNativeShortcuts(); }; }, []); + useEffect(() => installModalContextMenuLeakGuard(), []); + // Handle audio files dropped from OS file explorer via HTML5 drag-and-drop. // WebView2 intercepts OLE drag-drop before JUCE can see it, so we handle it // entirely in JS: read file data → send to C++ to save to disk → import. @@ -852,7 +1165,7 @@ function App() { // Create a new track for this file (MIDI track for .mid/.midi, audio track otherwise) const { tracks, transport, importMedia } = useDAWStore.getState(); - const result = await nativeBridge.addTrack(); + const result = await nativeBridge.addTrack(undefined, isMidi ? "midi" : "audio"); const trackId = typeof result === "string" ? result : `${Date.now()}`; const trackName = file.name.replace(/\.[^.]+$/, ""); @@ -966,6 +1279,9 @@ function App() { const openTcpContextMenu = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); + if (shouldSuppressWorkspaceContextMenu(event.target)) { + return; + } setTcpContextMenu({ x: event.clientX, y: event.clientY, @@ -1272,6 +1588,63 @@ function App() { )} + {showPianoRoll && dockedMidiEditorSession && dockedPianoRollTrackId && dockedPianoRollClipId && ( +
+
+ +
+
+

{dockedMidiEditorTitle}

+
+ + +
+
+
+ Loading...
}> + + + +
+ )} + {/* Transport Bar (above Mixer like Reaper) */} @@ -1286,48 +1659,6 @@ function App() { )} - {/* Piano Roll Editor Modal */} - {showPianoRoll && pianoRollTrackId && pianoRollClipId && ( -
-
- {/* Header */} -
-

Piano Roll Editor

- -
- {/* Piano Roll Content */} -
- Loading...
}> - 1 - ? (() => { - const prTrack = tracks.find((t) => t.id === pianoRollTrackId); - if (!prTrack) return []; - const midiClipIdSet = new Set(prTrack.midiClips.map((c) => c.id)); - return selectedClipIds.filter( - (id) => id !== pianoRollClipId && midiClipIdSet.has(id), - ); - })() - : [] - } - /> - -
-
- - )} - {/* Pitch editor kept for standalone/modal use if needed in future */} {/* Undo History Panel */} @@ -1669,7 +2000,7 @@ function App() { {/* Project Loading Overlay */} {isProjectLoading && ( -
+

@@ -1681,7 +2012,7 @@ function App() { {/* File Drop Overlay — shown when dragging files from OS file explorer */} {isDraggingFiles && ( -

+
@@ -1773,7 +2106,7 @@ function App() { {/* Toast Notification */} {toastVisible && ( -
({ + tracks: state.tracks, + pianoRollTrackId: state.pianoRollTrackId, + pianoRollClipId: state.pianoRollClipId, + activeMidiEditorSessionId: state.activeMidiEditorSessionId, + selectedClipIds: state.selectedClipIds, + })), + ); + const sessionId = windowSessionId || activeMidiEditorSessionId || ""; + + useEffect(() => { + let cancelled = false; + let stopSync: (() => void) | undefined; + + void (async () => { + await hydrateMidiEditorUISnapshotFromNative(windowSessionId || undefined); + if (cancelled) return; + stopSync = startMidiEditorUISync(windowSessionId || undefined); + if (!cancelled) { + useDAWStore.setState((state) => ({ + detachedPanels: state.detachedPanels.includes("midiEditor") + ? state.detachedPanels + : [...state.detachedPanels, "midiEditor"], + })); + setHydrated(true); + } + })(); + + return () => { + cancelled = true; + stopSync?.(); + }; + }, []); + + useEffect(() => { + return startSharedTransportSync(); + }, []); + + useEffect(() => installModalContextMenuLeakGuard(), []); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + void dispatchGlobalShortcut({ + key: e.key, + code: e.code, + ctrlKey: e.ctrlKey, + shiftKey: e.shiftKey, + altKey: e.altKey, + metaKey: e.metaKey, + repeat: e.repeat, + source: "browser", + targetIsEditable: + !!target && + (target instanceof HTMLInputElement || + target instanceof HTMLSelectElement || + target instanceof HTMLTextAreaElement || + target.isContentEditable), + preventDefault: () => e.preventDefault(), + stopPropagation: () => e.stopPropagation(), + }); + }; + + window.addEventListener("keydown", handleKeyDown, true); + const unsubscribeNativeShortcuts = nativeBridge.onNativeGlobalShortcut( + (event: NativeGlobalShortcutEvent) => { + void dispatchGlobalShortcut({ ...event, source: "pluginWindow" }); + }, + ); + + return () => { + window.removeEventListener("keydown", handleKeyDown, true); + unsubscribeNativeShortcuts(); + }; + }, []); + + const additionalClipIds = useMemo(() => { + if (!pianoRollTrackId || !pianoRollClipId || selectedClipIds.length <= 1) return []; + const pianoTrack = tracks.find((track) => track.id === pianoRollTrackId); + if (!pianoTrack) return []; + const midiClipIds = new Set(pianoTrack.midiClips.map((clip) => clip.id)); + return selectedClipIds.filter((id) => id !== pianoRollClipId && midiClipIds.has(id)); + }, [pianoRollClipId, pianoRollTrackId, selectedClipIds, tracks]); + + const activeTrack = useMemo( + () => tracks.find((track) => track.id === pianoRollTrackId) ?? null, + [pianoRollTrackId, tracks], + ); + const activeClip = useMemo( + () => activeTrack?.midiClips.find((clip) => clip.id === pianoRollClipId) ?? null, + [activeTrack, pianoRollClipId], + ); + const title = activeTrack && activeClip + ? `${activeTrack.name || "Track"} - ${activeClip.name || "MIDI Clip"}` + : "MIDI Editor"; + + const handleDock = useCallback(async () => { + const targetSessionId = sessionId || useDAWStore.getState().activeMidiEditorSessionId; + if (!targetSessionId) return; + useDAWStore.getState().dockMidiEditorSession(targetSessionId); + await publishMidiEditorSessionSnapshot(targetSessionId); + await nativeBridge.closeMidiEditorWindow(targetSessionId, "dock"); + }, [sessionId]); + + const handleClose = useCallback(async () => { + const targetSessionId = sessionId || useDAWStore.getState().activeMidiEditorSessionId; + await nativeBridge.closeMidiEditorWindow(targetSessionId || undefined, "close"); + }, [sessionId]); + + if (!hydrated) { + return ( +
+ Loading MIDI editor... +
+ ); + } + + return ( +
+
+

{title}

+
+ + +
+
+
+ {pianoRollTrackId && pianoRollClipId ? ( + Loading...
}> + + + ) : ( +
+ No MIDI clip selected +
+ )} +
+
+ ); +} diff --git a/frontend/src/MixerWindowApp.tsx b/frontend/src/MixerWindowApp.tsx index c56ed5f..d48730c 100644 --- a/frontend/src/MixerWindowApp.tsx +++ b/frontend/src/MixerWindowApp.tsx @@ -3,14 +3,15 @@ import { MixerPanel } from "./components/MixerPanel"; import { nativeBridge, type NativeGlobalShortcutEvent } from "./services/NativeBridge"; import { useDAWStore } from "./store/useDAWStore"; import { dispatchGlobalShortcut } from "./utils/globalShortcutDispatcher"; +import { installModalContextMenuLeakGuard } from "./utils/modalEventGuards"; import { hydrateMixerUISnapshotFromNative, startMixerUISync, } from "./utils/mixerWindowSync"; +import { startSharedTransportSync } from "./utils/sharedTransportSync"; export default function MixerWindowApp() { const batchUpdateMeterLevels = useDAWStore((state) => state.batchUpdateMeterLevels); - const setCurrentTime = useDAWStore((state) => state.setCurrentTime); const [hydrated, setHydrated] = useState(false); useEffect(() => { @@ -42,38 +43,10 @@ export default function MixerWindowApp() { }, []); useEffect(() => { - const unsubscribe = nativeBridge.onTransportUpdate((data) => { - const state = useDAWStore.getState(); - const backendPos = data.position; - const frontendPos = state.transport.currentTime; - const drift = Math.abs(backendPos - frontendPos); - const backendPlaying = !!data.isPlaying; - const frontendPlaying = state.transport.isPlaying; - - if (backendPlaying !== frontendPlaying) { - useDAWStore.setState((current) => ({ - transport: { - ...current.transport, - isPlaying: backendPlaying, - isPaused: false, - isRecording: backendPlaying ? current.transport.isRecording : false, - currentTime: backendPos, - }, - })); - return; - } - - if (!frontendPlaying) { - return; - } - - if (drift > 0.03) { - setCurrentTime(backendPos); - } - }); + return startSharedTransportSync(); + }, []); - return unsubscribe; - }, [setCurrentTime]); + useEffect(() => installModalContextMenuLeakGuard(), []); useEffect(() => { nativeBridge.onMeterUpdate((data) => { @@ -110,13 +83,15 @@ export default function MixerWindowApp() { targetIsEditable: !!target && (target instanceof HTMLInputElement || + target instanceof HTMLSelectElement || target instanceof HTMLTextAreaElement || target.isContentEditable), preventDefault: () => e.preventDefault(), + stopPropagation: () => e.stopPropagation(), }); }; - window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keydown", handleKeyDown, true); const unsubscribeNativeShortcuts = nativeBridge.onNativeGlobalShortcut( (event: NativeGlobalShortcutEvent) => { void dispatchGlobalShortcut({ ...event, source: "pluginWindow" }); @@ -124,7 +99,7 @@ export default function MixerWindowApp() { ); return () => { - window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keydown", handleKeyDown, true); unsubscribeNativeShortcuts(); }; }, []); diff --git a/frontend/src/__tests__/aiToolsFeatureInstaller.test.ts b/frontend/src/__tests__/aiToolsFeatureInstaller.test.ts new file mode 100644 index 0000000..eb29985 --- /dev/null +++ b/frontend/src/__tests__/aiToolsFeatureInstaller.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import modalSource from "../components/AiToolsSetupModal.tsx?raw"; +import bridgeSource from "../services/NativeBridge.ts?raw"; +import storeSource from "../store/useDAWStore.ts?raw"; + +describe("AI feature installer contract", () => { + it("defaults the setup checklist to requested compatible features or stem separation", () => { + expect(modalSource).toContain("const AI_FEATURES: AiFeatureId[] = [\"stemSeparation\", \"audioGeneration\"]"); + expect(modalSource).toContain("function defaultSelectedFeatures"); + expect(modalSource).toContain("if (requestedFeature)"); + expect(modalSource).toContain("return stem.compatible && !stem.ready ? [\"stemSeparation\"] : []"); + }); + + it("keeps incompatible Audio Generation disabled while Stem Separation remains selectable", () => { + expect(modalSource).toContain("!feature.compatible"); + expect(modalSource).toContain("Incompatible features are disabled and will not be installed."); + expect(modalSource).toContain("supported GPU with at least 8 GB memory was not detected"); + expect(modalSource).toContain("CPU-only machines are supported."); + }); + + it("sends modular feature selections over the native bridge", () => { + expect(bridgeSource).toContain("export type AiFeatureId = \"stemSeparation\" | \"audioGeneration\""); + expect(bridgeSource).toContain("selectedFeatures?: AiFeatureId[]"); + expect(bridgeSource).toContain("requestedFeature?: AiFeatureId"); + expect(bridgeSource).toContain("JSON.stringify(options)"); + }); + + it("keeps old install calls as a stem-separation default in the store", () => { + expect(storeSource).toContain("options.requestedFeature ?? \"stemSeparation\""); + expect(storeSource).toContain("selectedFeatures: [\"stemSeparation\"]"); + }); +}); diff --git a/frontend/src/__tests__/automationParams.test.ts b/frontend/src/__tests__/automationParams.test.ts index 5849874..e39c127 100644 --- a/frontend/src/__tests__/automationParams.test.ts +++ b/frontend/src/__tests__/automationParams.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import { automationToBackend, interpolateAtTime, + getAutomationDefault, + pluginAutomationParamId, } from "../store/automationParams"; describe("automationToBackend", () => { @@ -9,12 +11,16 @@ describe("automationToBackend", () => { expect(automationToBackend("volume", 0)).toBe(-60); }); - it("converts volume 1.0 to +6 dB", () => { - expect(automationToBackend("volume", 1)).toBe(6); + it("converts volume 1.0 to +12 dB", () => { + expect(automationToBackend("volume", 1)).toBe(12); }); - it("converts volume 0.5 to midpoint (-27 dB)", () => { - expect(automationToBackend("volume", 0.5)).toBe(-27); + it("converts volume 0.5 to midpoint (-24 dB)", () => { + expect(automationToBackend("volume", 0.5)).toBe(-24); + }); + + it("maps the default volume lane to exactly 0 dB", () => { + expect(automationToBackend("volume", getAutomationDefault("volume"))).toBeCloseTo(0); }); it("converts pan 0.0 to -1.0 (full left)", () => { @@ -32,6 +38,18 @@ describe("automationToBackend", () => { it("returns raw value for unknown params", () => { expect(automationToBackend("plugin_0_3", 0.7)).toBe(0.7); }); + + it("builds namespaced plugin automation ids", () => { + expect(pluginAutomationParamId(true, 0, 3)).toBe("plugin_input_0_3"); + expect(pluginAutomationParamId(false, 2, 4)).toBe("plugin_track_2_4"); + }); + + it("converts MIDI automation params to backend ranges", () => { + expect(automationToBackend("midi_velocity_scale", 0.5)).toBe(1); + expect(automationToBackend("midi_pitch_bend", 0)).toBe(-1); + expect(automationToBackend("midi_pitch_bend", 1)).toBe(1); + expect(automationToBackend("midi_cc_74", 1)).toBe(127); + }); }); describe("interpolateAtTime", () => { diff --git a/frontend/src/__tests__/interactionSafetyGuards.test.ts b/frontend/src/__tests__/interactionSafetyGuards.test.ts new file mode 100644 index 0000000..0fe3556 --- /dev/null +++ b/frontend/src/__tests__/interactionSafetyGuards.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import appSource from "../App.tsx?raw"; +import mixerWindowSource from "../MixerWindowApp.tsx?raw"; +import midiWindowSource from "../MidiEditorWindowApp.tsx?raw"; +import contextMenuSource from "../components/ContextMenu.tsx?raw"; +import modalSource from "../components/ui/Modal/Modal.tsx?raw"; +import pianoRollSource from "../components/PianoRoll.tsx?raw"; +import shortcutSource from "../utils/globalShortcutDispatcher.ts?raw"; +import modalGuardSource from "../utils/modalEventGuards.ts?raw"; + +describe("interaction safety guards", () => { + it("lets Space stop active transport from focused inputs without stealing idle text entry", () => { + const editableBranchIndex = shortcutSource.indexOf("if (payload.targetIsEditable)"); + const customShortcutsIndex = shortcutSource.indexOf("const customShortcuts"); + + expect(editableBranchIndex).toBeGreaterThan(-1); + expect(customShortcutsIndex).toBeGreaterThan(-1); + expect(editableBranchIndex).toBeLessThan(customShortcutsIndex); + expect(shortcutSource).toContain( + "isPlainSpacebar(payload) && (state.transport.isRecording || state.transport.isPlaying)", + ); + expect(shortcutSource).toContain('publishDetachedCommand("transport.stop")'); + expect(shortcutSource).toContain("else state.stop()"); + expect(shortcutSource).toContain("return false;"); + }); + + it("captures keyboard shortcuts before focused controls stop propagation", () => { + for (const source of [appSource, mixerWindowSource, midiWindowSource]) { + expect(source).toContain("target instanceof HTMLSelectElement"); + expect(source).toContain("stopPropagation: () => e.stopPropagation()"); + expect(source).toContain('window.addEventListener("keydown", handleKeyDown, true)'); + expect(source).toContain('window.removeEventListener("keydown", handleKeyDown, true)'); + } + }); + + it("blocks workspace context menus while modal layers are open", () => { + expect(modalGuardSource).toContain("installModalContextMenuLeakGuard"); + expect(modalGuardSource).toContain('window.addEventListener("contextmenu", handleContextMenu, true)'); + expect(modalGuardSource).toContain("shouldSuppressWorkspaceContextMenu(event.target)"); + expect(modalSource).toContain('data-modal-root="true"'); + expect(modalSource).toContain("onContextMenu={guardModalContextMenu}"); + expect(contextMenuSource).toContain("shouldSuppressWorkspaceContextMenu(e.target)"); + expect(appSource).toContain("installModalContextMenuLeakGuard()"); + expect(mixerWindowSource).toContain("installModalContextMenuLeakGuard()"); + expect(midiWindowSource).toContain("installModalContextMenuLeakGuard()"); + }); + + it("keeps piano roll resize and modal context-menu paths guarded", () => { + expect(pianoRollSource).toContain("Math.max(1, containerRef.current.clientWidth)"); + expect(pianoRollSource).toContain("Math.max(1, containerRef.current.clientHeight)"); + expect(pianoRollSource).toContain("shouldSuppressWorkspaceContextMenu(event.evt.target)"); + expect(pianoRollSource).toContain('data-modal-root="true"'); + expect(pianoRollSource).toContain("onContextMenu={guardModalContextMenu}"); + }); +}); diff --git a/frontend/src/components/AITrackHeader.tsx b/frontend/src/components/AITrackHeader.tsx index 7e9659d..3cc1985 100644 --- a/frontend/src/components/AITrackHeader.tsx +++ b/frontend/src/components/AITrackHeader.tsx @@ -262,7 +262,7 @@ function getStatusMeta(track: Track) { return track.aiGenerationBackend ? `Last backend: ${track.aiGenerationBackend.toUpperCase()}` - : "Music generation idle"; + : "Audio Generation idle"; } export const AITrackHeader = React.memo(function AITrackHeader({ @@ -305,11 +305,17 @@ export const AITrackHeader = React.memo(function AITrackHeader({ || track.aiGenerationState === "generating"; const canStartMusicGeneration = workflow.available !== false - && aiToolsStatus.musicGenerationReady - && aiToolsStatus.musicGenerationLayoutValid - && (aiToolsStatus.musicGenerationPerformanceReady ?? true); + && Boolean( + aiToolsStatus.features?.audioGeneration?.ready + ?? ( + aiToolsStatus.musicGenerationReady + && aiToolsStatus.musicGenerationLayoutValid + && (aiToolsStatus.musicGenerationPerformanceReady ?? true) + ), + ); const musicGenerationBlockedMessage = - aiToolsStatus.musicGenerationPerformanceStatusMessage + aiToolsStatus.features?.audioGeneration?.message + || aiToolsStatus.musicGenerationPerformanceStatusMessage || aiToolsStatus.musicGenerationStatusMessage || (!aiToolsStatus.musicGenerationLayoutValid && aiToolsStatus.musicGenerationModelId @@ -317,7 +323,7 @@ export const AITrackHeader = React.memo(function AITrackHeader({ ? `Pinned ACE-Step native asset layout is not ready in ${aiToolsStatus.musicGenerationCheckpointRoot}.` : aiToolsStatus.error || aiToolsStatus.message - || "AI music generation is not ready yet."); + || "Audio Generation is not ready yet."); const stopPolling = () => { if (pollTimeoutRef.current !== null) { @@ -568,11 +574,7 @@ export const AITrackHeader = React.memo(function AITrackHeader({ return; } - if ( - !aiToolsStatus.musicGenerationReady - || !aiToolsStatus.musicGenerationLayoutValid - || !(aiToolsStatus.musicGenerationPerformanceReady ?? true) - ) { + if (!canStartMusicGeneration) { setAITrackGenerationState(track.id, "error", { progress: 0, error: musicGenerationBlockedMessage, @@ -583,7 +585,7 @@ export const AITrackHeader = React.memo(function AITrackHeader({ lmBackend: "", lmStage: "", }); - openAiToolsSetup(); + openAiToolsSetup("audioGeneration"); return; } diff --git a/frontend/src/components/AIWorkflowModal.tsx b/frontend/src/components/AIWorkflowModal.tsx index 9b2358d..0a5a79b 100644 --- a/frontend/src/components/AIWorkflowModal.tsx +++ b/frontend/src/components/AIWorkflowModal.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { type AiToolsStatus } from "../services/NativeBridge"; +import { type AiFeatureId, type AiToolsStatus } from "../services/NativeBridge"; import { type Track } from "../store/useDAWStore"; import { AI_WORKFLOWS, @@ -28,7 +28,7 @@ interface AIWorkflowModalProps { onClose: () => void; onGenerate: () => void | Promise; onCancel: () => void | Promise; - onOpenAiToolsSetup: () => void; + onOpenAiToolsSetup: (requestedFeature?: AiFeatureId) => void; onWorkflowChange: (workflowId: string) => void; onParamsChange: (params: Record) => void; } @@ -145,12 +145,16 @@ export function AIWorkflowModal({ track.aiGenerationState === "loading" || track.aiGenerationState === "generating"; const isMusicGenerationReady = Boolean( - aiToolsStatus.musicGenerationReady - && aiToolsStatus.musicGenerationLayoutValid - && (aiToolsStatus.musicGenerationPerformanceReady ?? true), + aiToolsStatus.features?.audioGeneration?.ready + ?? ( + aiToolsStatus.musicGenerationReady + && aiToolsStatus.musicGenerationLayoutValid + && (aiToolsStatus.musicGenerationPerformanceReady ?? true) + ), ); const musicGenerationBlockedMessage = !isMusicGenerationReady - ? (aiToolsStatus.musicGenerationPerformanceStatusMessage + ? (aiToolsStatus.features?.audioGeneration?.message + || aiToolsStatus.musicGenerationPerformanceStatusMessage || aiToolsStatus.musicGenerationStatusMessage || (!aiToolsStatus.musicGenerationLayoutValid && aiToolsStatus.musicGenerationModelId @@ -158,7 +162,7 @@ export function AIWorkflowModal({ ? `Pinned ACE-Step native asset layout is not ready in ${aiToolsStatus.musicGenerationCheckpointRoot}.` : aiToolsStatus.error || aiToolsStatus.message - || "AI music generation is not ready yet.")) + || "Audio Generation is not ready yet.")) : ""; const canSubmitGeneration = workflow.available !== false && isMusicGenerationReady && !isBusy; @@ -274,7 +278,7 @@ export function AIWorkflowModal({ {formatPhaseLabel(track.aiGenerationPhase)}

- {track.aiGenerationMessage || "Music generation is in progress."} + {track.aiGenerationMessage || "Audio Generation is in progress."}

@@ -489,8 +493,8 @@ export function AIWorkflowModal({ ) : ( <> {!isMusicGenerationReady ? ( - ) : null}
@@ -280,12 +405,87 @@ export default function AiToolsSetupModal() {
) : null} +
+ {AI_FEATURES.map((featureId) => { + const feature = featureStatuses[featureId]; + const copy = FEATURE_COPY[featureId]; + const selected = selectedFeatures.includes(featureId); + const disabled = Boolean(aiToolsStatus.installInProgress || feature.ready || !feature.compatible); + const statusLabel = feature.ready + ? "Ready" + : !feature.compatible + ? "Blocked" + : selected + ? "Selected" + : "Available"; + const statusClass = feature.ready + ? "text-green-400" + : !feature.compatible + ? "text-red-300" + : selected + ? "text-daw-accent" + : "text-daw-text-secondary"; + + return ( + + ); + })} +
+ + {selectedFeatures.length === 0 && !aiToolsStatus.installInProgress && !isInstallComplete ? ( +
+

+ Select a compatible feature to download. Incompatible features are disabled and will not be installed. +

+
+ ) : null} + {isInstallComplete ? (
-

AI Tools are ready

+

{completeTitle}

- The runtime, stem-separation model, and pinned ACE-Step files are installed for this OpenStudio session. - You can continue straight into stem separation or music generation now. + The selected AI feature modules are installed for this OpenStudio session. + You can continue straight into the compatible AI workflow now.

{aiToolsStatus.selectedBackend ? (

@@ -293,7 +493,7 @@ export default function AiToolsSetupModal() {

) : null}

- Music generation:{" "} + Audio Generation:{" "} {isMusicGenerationFullyReady ? "Ready" : "Installed, but degraded"} @@ -334,7 +534,7 @@ export default function AiToolsSetupModal() {

) : null}

- Music generation: {isMusicGenerationInstalled ? "Installed, but degraded" : "Not ready yet"} + Audio Generation: {isMusicGenerationInstalled ? "Installed, but degraded" : "Not ready yet"} {aiToolsStatus.aceStepVersion ? ` (ACE-Step ${aiToolsStatus.aceStepVersion})` : ""}

{aiToolsStatus.musicGenerationModelId ? ( @@ -669,7 +869,7 @@ export default function AiToolsSetupModal() { {isInstallComplete ? "AI Tools finished installing in this session. You can close this window and continue working." : isPartiallyReady - ? `Stem separation is ready in this session. ${musicGenerationBlockedMessage} Retry install to finish music generation, or close this window if you only need stem separation right now.` + ? `Stem separation is ready in this session. ${musicGenerationBlockedMessage} Select Audio Generation to finish that setup, or close this window if you only need stem separation right now.` : "OpenStudio keeps the app responsive while setup runs. This window now shows the live install activity, current phase, and long-download hints."}

@@ -705,20 +905,20 @@ export default function AiToolsSetupModal() { diff --git a/frontend/src/components/ChannelStrip.tsx b/frontend/src/components/ChannelStrip.tsx index d320fd7..cdfecd0 100644 --- a/frontend/src/components/ChannelStrip.tsx +++ b/frontend/src/components/ChannelStrip.tsx @@ -129,7 +129,7 @@ export const ChannelStrip = React.memo(function ChannelStrip({ const [showFXChain, setShowFXChain] = useState(false); const hasBypassableFx = track.inputFxCount + track.trackFxCount > 0; - const hasFx = hasBypassableFx || Boolean(track.instrumentPlugin); + const hasFx = hasBypassableFx || Boolean(track.instrumentPlugin) || (track.type === "instrument" && !track.instrumentPlugin); // Find the clip under the playhead for gain staging display. // Reads currentTime from a snapshot (not a selector) to avoid 60fps re-renders. diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index 2cb1113..9e7d0ac 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -3,6 +3,7 @@ import { createPortal } from "react-dom"; import { getRegisteredActions, ActionDef } from "../store/actionRegistry"; import { useDAWStore } from "../store/useDAWStore"; import { useShallow } from "zustand/react/shallow"; +import { guardModalContextMenu } from "../utils/modalEventGuards"; interface CommandPaletteProps { isOpen: boolean; @@ -109,7 +110,9 @@ export function CommandPalette({ isOpen, onClose }: CommandPaletteProps) { return createPortal(
{/* Backdrop */}
@@ -118,6 +121,7 @@ export function CommandPalette({ isOpen, onClose }: CommandPaletteProps) {
e.stopPropagation()} + onContextMenu={guardModalContextMenu} > {/* Search input */}
diff --git a/frontend/src/components/ContextMenu.tsx b/frontend/src/components/ContextMenu.tsx index 9afad14..bc64979 100644 --- a/frontend/src/components/ContextMenu.tsx +++ b/frontend/src/components/ContextMenu.tsx @@ -1,10 +1,12 @@ import React, { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { ChevronRight } from "lucide-react"; +import { guardModalContextMenu, shouldSuppressWorkspaceContextMenu } from "../utils/modalEventGuards"; export interface MenuItem { label: string; icon?: React.ReactNode; + swatchColor?: string; shortcut?: string; disabled?: boolean; divider?: boolean; @@ -77,11 +79,13 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { return createPortal(
{items.map((item, index) => { if (item.divider) { @@ -103,6 +107,12 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { >
{item.icon && {item.icon}} + {item.swatchColor && ( + + )} {item.label}
@@ -121,7 +131,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
{ @@ -132,7 +142,13 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { } }} > - {subItem.label} + {subItem.swatchColor && ( + + )} + {subItem.label}
))}
@@ -156,6 +172,9 @@ export function useContextMenu() { const showContextMenu = (e: React.MouseEvent, items: MenuItem[]) => { e.preventDefault(); e.stopPropagation(); + if (shouldSuppressWorkspaceContextMenu(e.target)) { + return; + } setContextMenu({ x: e.clientX, y: e.clientY, items }); }; diff --git a/frontend/src/components/CorrectPitchModal.tsx b/frontend/src/components/CorrectPitchModal.tsx index ec6ea95..c1da976 100644 --- a/frontend/src/components/CorrectPitchModal.tsx +++ b/frontend/src/components/CorrectPitchModal.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useShallow } from "zustand/shallow"; import { usePitchEditorStore } from "../store/pitchEditorStore"; +import { guardModalContextMenu } from "../utils/modalEventGuards"; const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; @@ -30,8 +31,12 @@ export function CorrectPitchModal() { }; return ( -
-
+
+

Correct Pitch

{/* Pitch Center slider */} diff --git a/frontend/src/components/EnvelopeManagerModal.tsx b/frontend/src/components/EnvelopeManagerModal.tsx index ae146c5..b0b55bd 100644 --- a/frontend/src/components/EnvelopeManagerModal.tsx +++ b/frontend/src/components/EnvelopeManagerModal.tsx @@ -4,7 +4,7 @@ import { ChevronDown, ChevronRight, Search } from "lucide-react"; import { useDAWStore, AutomationModeType } from "../store/useDAWStore"; import { nativeBridge } from "../services/NativeBridge"; import { Modal } from "./ui"; -import { getTrackAutomationParams, getMasterAutomationParams } from "../store/automationParams"; +import { getTrackAutomationParams, getMasterAutomationParams, pluginAutomationParamId } from "../store/automationParams"; interface PluginParam { index: number; @@ -104,7 +104,7 @@ export function EnvelopeManagerModal() { const [filter, setFilter] = useState(""); const [collapsedSections, setCollapsedSections] = useState>(new Set()); const [fxSlots, setFxSlots] = useState([]); - const [pluginParams, setPluginParams] = useState>(new Map()); + const [pluginParams, setPluginParams] = useState>(new Map()); const [loading, setLoading] = useState(false); // Fetch FX chain + plugin params on open @@ -137,7 +137,7 @@ export function EnvelopeManagerModal() { setFxSlots(allSlots); // Fetch all plugin parameters in parallel - const paramMap = new Map(); + const paramMap = new Map(); await Promise.all( allSlots.map(async (fx) => { try { @@ -146,9 +146,9 @@ export function EnvelopeManagerModal() { fx.index, fx.isInputFX, ); - paramMap.set(fx.index, params); + paramMap.set(pluginAutomationParamId(fx.isInputFX, fx.index, -1), params); } catch { - paramMap.set(fx.index, []); + paramMap.set(pluginAutomationParamId(fx.isInputFX, fx.index, -1), []); } }), ); @@ -189,11 +189,11 @@ export function EnvelopeManagerModal() { // Per-plugin sections for (const fx of fxSlots) { - const params = pluginParams.get(fx.index) || []; + const params = pluginParams.get(pluginAutomationParamId(fx.isInputFX, fx.index, -1)) || []; const fxCategory = fx.isInputFX ? `Input FX: ${fx.name}` : `FX: ${fx.name}`; for (const param of params) { - const paramId = `plugin_${fx.index}_${param.index}`; + const paramId = pluginAutomationParamId(fx.isInputFX, fx.index, param.index); const lane = automationLanes.find((l) => l.param === paramId); rows.push({ paramId, diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 3f4cede..4b1bcd8 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -29,6 +29,9 @@ export class ErrorBoundary extends React.Component< error, info.componentStack, ); + void nativeBridge.setTransportRecording(false); + void nativeBridge.setTransportPlaying(false); + void nativeBridge.closeAllPluginWindows(); this.props.onError?.(error, info); } @@ -119,7 +122,12 @@ export class ErrorBoundary extends React.Component< {this.state.error?.stack && "\n\n" + this.state.error.stack}
@@ -869,7 +1124,11 @@ export function FXChainPanel({ size="sm" disabled={!presetName.trim() || fxSlots.length === 0} onClick={async () => { - await saveFXChainPreset(trackId, presetName.trim(), chainType); + await saveFXChainPreset( + trackId, + presetName.trim(), + chainType, + ); setPresetName(""); }} title="Save current FX chain as preset" @@ -926,6 +1185,16 @@ export function FXChainPanel({
)} + {showMidiFxControls && currentTrack && ( +
+
+ + MIDI FX +
+ +
+ )} +
{loading ? (
@@ -933,11 +1202,58 @@ export function FXChainPanel({
) : loadedPluginCount === 0 ? (
-

No plugins loaded

-

Add plugins from the browser →

+

+ {chainType === "track" && currentTrack?.type === "midi" + ? "No instrument loaded" + : "No plugins loaded"} +

+

+ {chainType === "track" && currentTrack?.type === "midi" + ? "Load an instrument to hear MIDI clips." + : "Add plugins from the browser →"} +

) : ( <> + {hasFallbackInstrument && ( +
+
+
+ +
+
+
+ +
+
+ {fallbackInstrumentDisplayName} +
+
+ + Built-in + + +
+
+ )} {hasLoadedInstrument && (
{instrumentDisplayName}
@@ -982,379 +1301,627 @@ export function FXChainPanel({ > +
)} {fxSlots.map((fx, index) => { - const isS13FX = fx.type === "s13fx"; - const isBuiltIn = fx.type === "builtin"; - return ( -
-
handleDragStart(index)} - onDragOver={handleDragOver} - onDrop={(e) => handleDrop(e, index)} - onClick={() => { - if (isS13FX) handleToggleS13FXSliders(fx.index); - else if (isBuiltIn && fx.name.includes("Pitch Correct")) setExpandedPitchCorrector(expandedPitchCorrector === fx.index ? null : fx.index); - else handleOpenEditor(fx.index); - }} - > -
- -
- { e.stopPropagation(); handleToggleBypass(fx.index); }} - onClick={(e) => e.stopPropagation()} - title={bypassedFx.has(fx.index) ? "Enable plugin" : "Bypass plugin"} - aria-label={bypassedFx.has(fx.index) ? `Enable ${fx.name}` : `Bypass ${fx.name}`} - className="fx-bypass-checkbox" - style={{ accentColor: "#22c55e", width: 14, height: 14, cursor: "pointer", flexShrink: 0 }} - /> -
-
- {isS13FX ? : index + 1} + const isS13FX = fx.type === "s13fx"; + const isBuiltIn = fx.type === "builtin"; + return ( +
+
handleDragStart(index)} + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(e, index)} + onClick={() => { + if (isS13FX) handleToggleS13FXSliders(fx.index); + else if ( + isBuiltIn && + fx.name.includes("Pitch Correct") + ) + setExpandedPitchCorrector( + expandedPitchCorrector === fx.index + ? null + : fx.index, + ); + else handleOpenEditor(fx.index); + }} + > +
+
+ { + e.stopPropagation(); + handleToggleBypass(fx.index); + }} + onClick={(e) => e.stopPropagation()} + title={ + bypassedFx.has(fx.index) + ? "Enable plugin" + : "Bypass plugin" + } + aria-label={ + bypassedFx.has(fx.index) + ? `Enable ${fx.name}` + : `Bypass ${fx.name}` + } + className="fx-bypass-checkbox" + style={{ + accentColor: "#22c55e", + width: 14, + height: 14, + cursor: "pointer", + flexShrink: 0, + }} + />
- {fx.name} +
+ {isS13FX ? ( + + ) : ( + index + 1 + )} +
+
+ {fx.name} +
-
- {isS13FX && ( - <> - + + + )} + {/* A/B Comparison Toggle */} + {!isS13FX && + (() => { + const abKey = `${trackId}-${fx.index}`; + const abState = pluginABStates[abKey]; + const activeSlot = abState?.active || "a"; + return ( + + ); + })()} + {!isS13FX && ( + + )} + {/* Parameters button */} + {!isS13FX && ( + - - )} - {/* A/B Comparison Toggle */} - {!isS13FX && (() => { - const abKey = `${trackId}-${fx.index}`; - const abState = pluginABStates[abKey]; - const activeSlot = abState?.active || "a"; - return ( + + + )} + {/* Presets button */} + {!isS13FX && ( - ); - })()} - {!isS13FX && ( - - )} - {/* Parameters button */} - {!isS13FX && ( - - )} - {/* Presets button */} - {!isS13FX && ( - - )} - -
- - {/* Sidechain Source Selector */} - {chainType !== "master" && sidechainTrackOptions.length > 0 && ( -
- - SC - - {sidechainSources[fx.index] && ( - - SC - - )} + +
- )} - {/* Plugin Parameter Automation List */} - {!isS13FX && expandedParamsFx === fx.index && ( -
-
Automatable Parameters
- {paramsLoading ? ( -
Loading parameters...
- ) : pluginParams.length === 0 ? ( -
No parameters available
- ) : ( -
- {pluginParams.map((param) => { - const isLearning = midiLearnActive?.fxIndex === fx.index && midiLearnActive?.paramIndex === param.index; - return ( -
- - {param.name} - - - {param.text} - - {/* MIDI Learn button */} - {isLearning ? ( - - ) : ( - - )} - {chainType !== "master" && ( - - )} -
- ); - })} + {/* Sidechain Source Selector */} + {chainType !== "master" && + sidechainTrackOptions.length > 0 && ( +
+ + + SC + + + {sidechainSources[fx.index] && ( + + SC + + )}
)} -
- )} - {/* Plugin Presets Browser */} - {!isS13FX && expandedPresetsFx === fx.index && ( -
-
Plugin Presets
- {/* Save Preset */} -
- setSavePresetName(e.target.value)} - onClick={(e) => e.stopPropagation()} - className="flex-1 bg-neutral-800 border border-neutral-600 rounded px-1.5 py-0.5 text-[11px] text-white placeholder-neutral-500 outline-none focus:border-purple-500" - /> - -
- {/* Preset List */} - {presetsLoading ? ( -
Loading presets...
- ) : pluginPresetsList.length === 0 ? ( -
No presets available
- ) : ( -
- {pluginPresetsList.map((preset, idx) => ( - - ))} + {/* Plugin Parameter Automation List */} + {!isS13FX && expandedParamsFx === fx.index && ( +
+
+ Automatable Parameters
- )} -
- )} + {paramsLoading ? ( +
+ Loading parameters... +
+ ) : pluginParams.length === 0 ? ( +
+ No parameters available +
+ ) : ( +
+ {pluginParams.map((param) => { + const isLearning = + midiLearnActive?.fxIndex === fx.index && + midiLearnActive?.paramIndex === param.index; + return ( +
+ + {param.name} + + + {param.text} + + { + if (chainType !== "master") { + beginAutomationParamTouch( + trackId, + pluginAutomationParamId( + chainType === "input", + fx.index, + param.index, + ), + ); + } + }} + onPointerUp={() => { + if (chainType !== "master") { + endAutomationParamTouch( + trackId, + pluginAutomationParamId( + chainType === "input", + fx.index, + param.index, + ), + ); + } + }} + onPointerCancel={() => { + if (chainType !== "master") { + endAutomationParamTouch( + trackId, + pluginAutomationParamId( + chainType === "input", + fx.index, + param.index, + ), + ); + } + }} + onBlur={() => { + if (chainType !== "master") { + endAutomationParamTouch( + trackId, + pluginAutomationParamId( + chainType === "input", + fx.index, + param.index, + ), + ); + } + }} + onChange={(event) => { + void handlePluginParamChange( + fx.index, + param.index, + Number(event.currentTarget.value), + ); + }} + title={`Set ${param.name}`} + /> + {/* MIDI Learn button */} + {isLearning ? ( + + ) : ( + + )} + {chainType !== "master" && ( + + )} +
+ ); + })} +
+ )} +
+ )} - {/* S13FX inline sliders + advanced graph */} - {isS13FX && expandedS13FX === fx.index && s13fxSliders.length > 0 && (() => { - const advancedType = fx.pluginPath?.match(/(\w+)_advanced\.jsfx/)?.[1]; - const hasGraph = !!advancedType && ["eq", "compressor", "gate", "delay", "reverb", "saturation", "chorus"].includes(advancedType); - const graphProps = { sliders: s13fxSliders, onSliderChange: handleS13FXSliderChange, width: 340, height: 180 }; - return ( -
- {/* Advanced parametric graph */} - {advancedType === "eq" && } - {advancedType === "compressor" && } - {advancedType === "gate" && } - {advancedType === "delay" && } - {advancedType === "reverb" && } - {advancedType === "saturation" && } - {advancedType === "chorus" && } - - {/* Raw sliders toggle when graph is present */} - {hasGraph && ( + {/* Plugin Presets Browser */} + {!isS13FX && expandedPresetsFx === fx.index && ( +
+
+ Plugin Presets +
+ {/* Save Preset */} +
+ + setSavePresetName(e.target.value) + } + onClick={(e) => e.stopPropagation()} + className="flex-1 bg-neutral-800 border border-neutral-600 rounded px-1.5 py-0.5 text-[11px] text-white placeholder-neutral-500 outline-none focus:border-purple-500" + /> +
+ {/* Preset List */} + {presetsLoading ? ( +
+ Loading presets... +
+ ) : pluginPresetsList.length === 0 ? ( +
+ No presets available +
+ ) : ( +
+ {pluginPresetsList.map((preset, idx) => ( + + ))} +
)} +
+ )} - {/* Raw sliders (always shown if no graph, toggleable if graph present) */} - {(!hasGraph || showRawSliders) && s13fxSliders.map((slider) => ( -
- - {slider.name} - - {slider.isEnum && slider.enumNames ? ( - - ) : ( - handleS13FXSliderChange(slider.index, Number(e.target.value))} - className="flex-1 accent-lime-500" - /> + {showRawSliders ? ( + + ) : ( + + )} + {showRawSliders ? "Hide" : "Show"} raw + sliders + )} - - {slider.value.toFixed(slider.inc >= 1 ? 0 : 2)} - + + {/* Raw sliders (always shown if no graph, toggleable if graph present) */} + {(!hasGraph || showRawSliders) && + s13fxSliders.map((slider) => ( +
+ + {slider.name} + + {slider.isEnum && slider.enumNames ? ( + + ) : ( + + handleS13FXSliderChange( + slider.index, + Number(e.target.value), + ) + } + className="flex-1 accent-lime-500" + /> + )} + + {slider.value.toFixed( + slider.inc >= 1 ? 0 : 2, + )} + +
+ ))}
- ))} -
- ); - })()} - - {/* Pitch Corrector inline panel */} - {isBuiltIn && fx.name.includes("Pitch Correct") && expandedPitchCorrector === fx.index && ( -
- setExpandedPitchCorrector(null)} - onOpenGraphicalEditor={() => { - // Find the first selected audio clip on this track to analyze - const track = tracks.find(t => t.id === trackId); - const clip = track?.clips?.[0]; - if (clip) { - openPitchEditor(trackId, clip.id, fx.index); - } - }} - /> -
- )} -
- ); + ); + })()} + + {/* Pitch Corrector inline panel */} + {isBuiltIn && + fx.name.includes("Pitch Correct") && + expandedPitchCorrector === fx.index && ( +
+ setExpandedPitchCorrector(null)} + onOpenGraphicalEditor={() => { + // Find the first selected audio clip on this track to analyze + const track = tracks.find( + (t) => t.id === trackId, + ); + const clip = track?.clips?.[0]; + if (clip) { + openPitchEditor(trackId, clip.id, fx.index); + } + }} + /> +
+ )} +
+ ); })} )} @@ -1372,11 +1939,12 @@ export function FXChainPanel({ setSearchTerm(e.target.value)} className="flex-1" + fullWidth={true} aria-label="Search available plugins" /> updateDialog({ semitones: event.target.value })} + /> + + )} + + {dialog.type === "velocity" && ( + <> + + + + )} + + {dialog.type === "time" && ( + <> + + + + )} + + {dialog.error && ( +
{dialog.error}
+ )} +
+ + +
+ +
+ )} + + ); +} diff --git a/frontend/src/components/MainToolbar.tsx b/frontend/src/components/MainToolbar.tsx index 8170e34..9496cf4 100644 --- a/frontend/src/components/MainToolbar.tsx +++ b/frontend/src/components/MainToolbar.tsx @@ -64,8 +64,6 @@ export function MainToolbar({ toggleMuteTool, showPitchEditor, aiToolsStatus, - installAiTools, - reopenStemSeparation, openAiToolsSetup, } = useDAWStore( useShallow((s) => ({ @@ -91,8 +89,6 @@ export function MainToolbar({ toggleMuteTool: s.toggleMuteTool, showPitchEditor: s.showPitchEditor, aiToolsStatus: s.aiToolsStatus, - installAiTools: s.installAiTools, - reopenStemSeparation: s.reopenStemSeparation, openAiToolsSetup: s.openAiToolsSetup, })), ); @@ -109,22 +105,8 @@ export function MainToolbar({ const effectiveCanRedo = showPitchEditor ? peRedoStack.length > 0 : canRedo; const hasArmedTracks = tracks.some((t) => t.armed); - const handleAiToolsClick = async () => { - if (aiToolsStatus.installInProgress) { - reopenStemSeparation(); - return; - } - - if (aiToolsStatus.available) { - return; - } - - if (aiToolsStatus.state === "pythonMissing" || aiToolsStatus.state === "error") { - openAiToolsSetup(); - return; - } - - await installAiTools(); + const handleAiToolsClick = () => { + openAiToolsSetup(); }; const aiToolsTitle = aiToolsStatus.available diff --git a/frontend/src/components/MenuBar.tsx b/frontend/src/components/MenuBar.tsx index 86abbd5..d1d7d8a 100644 --- a/frontend/src/components/MenuBar.tsx +++ b/frontend/src/components/MenuBar.tsx @@ -864,6 +864,13 @@ export function MenuBar() { }, ], }, + { + label: "MIDI Panic", + shortcut: shortcut("midi.panic", "Ctrl+Alt+P"), + onClick: () => { + void nativeBridge.panicMIDI(); + }, + }, { label: "Ripple Editing", submenu: [ @@ -998,7 +1005,7 @@ export function MenuBar() { return (
diff --git a/frontend/src/components/MetronomeSettings.tsx b/frontend/src/components/MetronomeSettings.tsx index 08a3f15..f168d6e 100644 --- a/frontend/src/components/MetronomeSettings.tsx +++ b/frontend/src/components/MetronomeSettings.tsx @@ -4,6 +4,7 @@ import { useDAWStore } from "../store/useDAWStore"; import { useShallow } from "zustand/shallow"; import classNames from "classnames"; import { Button, TimeSignatureInput, Slider } from "./ui"; +import { guardModalContextMenu } from "../utils/modalEventGuards"; interface MetronomeSettingsProps { isOpen: boolean; @@ -64,12 +65,16 @@ export function MetronomeSettings({ isOpen, onClose }: MetronomeSettingsProps) { }; return createPortal( -
+
{/* Backdrop */} -
+
{/* Modal */} -
+
{/* Header */}

diff --git a/frontend/src/components/PianoRoll.css b/frontend/src/components/PianoRoll.css index b9608e8..2bc0d8e 100644 --- a/frontend/src/components/PianoRoll.css +++ b/frontend/src/components/PianoRoll.css @@ -3,23 +3,843 @@ flex-direction: column; width: 100%; height: 100%; - background: #1a1a1a; + background: + linear-gradient(180deg, #181a1f 0%, #111318 100%); overflow: hidden; + color: #d6dbe3; } -.piano-roll-toolbar { +.piano-roll-key-strip { + position: relative; display: flex; align-items: center; + flex-wrap: nowrap; + gap: 10px; + min-height: 46px; + padding: 6px 10px; + box-sizing: border-box; + background: #20242b; + border-bottom: 1px solid #3a424d; + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.45); + overflow: visible; + z-index: 30; +} + +.piano-roll-key-strip[data-responsive-mode="scroll"] { + overflow-x: auto; +} + +[data-tooltip] { + position: relative; +} + +[data-tooltip]:hover::after, +[data-tooltip]:focus-visible::after, +[data-tooltip]:focus-within::after { + position: absolute; + z-index: 500; + bottom: calc(100% + 8px); + left: 50%; + max-width: 240px; + padding: 5px 8px; + border: 1px solid #46515f; + border-radius: 5px; + background: #0f141b; + color: #e7eef6; + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); + content: attr(data-tooltip); + font-size: 11px; + font-weight: 600; + line-height: 1.25; + pointer-events: none; + text-align: center; + transform: translateX(-50%); + white-space: normal; +} + +.piano-roll-key-strip [data-tooltip]:hover::after, +.piano-roll-key-strip [data-tooltip]:focus-visible::after, +.piano-roll-key-strip [data-tooltip]:focus-within::after { + top: calc(100% + 8px); + bottom: auto; +} + +.piano-roll-tool-cluster, +.piano-roll-key-strip-group { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.piano-roll-tool-cluster { + padding: 3px; + background: #151920; + border: 1px solid #303743; + border-radius: 6px; +} + +.piano-roll-icon-tool, +.piano-roll-strip-command, +.piano-roll-mini-icon { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + border: 1px solid #39424f; + background: #252b34; + color: #c6d1dc; + cursor: pointer; +} + +.piano-roll-icon-tool { + width: 28px; + height: 28px; + border-radius: 5px; +} + +.piano-roll-icon-tool:hover, +.piano-roll-strip-command:hover, +.piano-roll-mini-icon:hover { + background: #313946; + border-color: #526171; + color: #f3f8fc; +} + +.piano-roll-icon-tool[data-active="true"], +.piano-roll-strip-command[data-active="true"] { + background: #1f6f86; + border-color: #63c5df; + color: #f8fdff; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12); +} + +.piano-roll-key-strip-group { + min-height: 32px; + padding: 3px 6px; + background: #171b22; + border: 1px solid #303743; + border-radius: 6px; + color: #9fb0c0; +} + +.piano-roll-key-strip-spacer { + flex: 1 1 auto; + min-width: 10px; +} + +.piano-roll-toolbar-more-wrap { + position: relative; + z-index: 20; + flex: 0 0 auto; +} + +.piano-roll-toolbar-more-button { + white-space: nowrap; +} + +.piano-roll-toolbar-overflow-menu { + position: absolute; + z-index: 700; + top: calc(100% + 7px); + right: 0; + display: flex; + flex-direction: column; + gap: 7px; + min-width: 250px; + max-width: min(430px, calc(100vw - 24px)); + padding: 8px; + border: 1px solid #3a4452; + border-radius: 7px; + background: #111820; + box-shadow: 0 16px 34px rgba(0, 0, 0, 0.55); +} + +.piano-roll-toolbar-overflow-menu::before { + position: absolute; + top: -6px; + right: 18px; + width: 10px; + height: 10px; + border-top: 1px solid #3a4452; + border-left: 1px solid #3a4452; + background: #111820; + content: ""; + transform: rotate(45deg); +} + +.piano-roll-toolbar-overflow-menu .piano-roll-tool-cluster, +.piano-roll-toolbar-overflow-menu .piano-roll-key-strip-group { + width: 100%; + box-sizing: border-box; + justify-content: flex-start; flex-wrap: wrap; +} + +.piano-roll-toolbar-overflow-menu .piano-roll-clip-group { + align-items: center; +} + +.piano-roll-toolbar-measurer { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + gap: inherit; + width: max-content; + height: 0; + overflow: hidden; + pointer-events: none; + visibility: hidden; +} + +.piano-roll-info-line { + display: grid; + grid-template-columns: repeat(8, minmax(76px, 1fr)); + gap: 1px; + min-height: 38px; + border-bottom: 1px solid #303743; + background: #11151b; +} + +.piano-roll-info-line > div { + display: grid; + grid-template-rows: 15px 1fr; + min-width: 0; + padding: 4px 8px; + border-right: 1px solid #303743; + box-sizing: border-box; +} + +.piano-roll-info-line span { + color: #8b98a7; + font-size: 10px; + line-height: 13px; +} + +.piano-roll-info-line strong { + overflow: hidden; + color: #e4edf6; + font-size: 12px; + font-weight: 750; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.piano-roll-compact-select, +.piano-roll-compact-number, +.piano-roll-field-grid select, +.piano-roll-field-grid input { + height: 26px; + min-width: 0; + box-sizing: border-box; + padding: 2px 7px; + background: #11151b; + border: 1px solid #37404c; + border-radius: 5px; + color: #dde6ef; + font-size: 11px; + outline: none; +} + +.piano-roll-compact-select:focus, +.piano-roll-compact-number:focus, +.piano-roll-field-grid select:focus, +.piano-roll-field-grid input:focus { + border-color: #64c7e8; + box-shadow: 0 0 0 1px rgba(100, 199, 232, 0.2); +} + +.piano-roll-compact-number { + width: 48px; + text-align: right; +} + +.piano-roll-lane-select { + max-width: 150px; +} + +.piano-roll-strip-slider { + width: 92px; + accent-color: #64c7e8; +} + +.piano-roll-strip-command { + min-width: 28px; + height: 26px; + gap: 4px; + padding: 0 7px; + border-radius: 5px; + font-size: 11px; + font-weight: 700; +} + +.piano-roll-strip-command:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.piano-roll-strip-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + height: 26px; + padding: 0 6px; + color: #bac7d4; + font-size: 11px; +} + +.piano-roll-strip-toggle input { + accent-color: #58c3a3; +} + +.piano-roll-strip-readout { + color: #90a0ae; + font-size: 11px; + white-space: nowrap; +} + +.piano-roll-workspace { + display: grid; + min-height: 0; + flex: 1 1 auto; +} + +.piano-roll-left-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + border-right: 1px solid #303743; + background: #1b2028; +} + +.piano-roll-timeline-gutter { + min-width: 0; + border-right: 1px solid #303743; + background: #111318; +} + +.piano-roll-editor-pane { + position: relative; + z-index: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + background: #111318; +} + +.piano-roll-ruler { + position: relative; + flex: 0 0 auto; + overflow: hidden; + border-bottom: 1px solid #303743; + background: #151a21; + color: #a9b8c8; +} + +.piano-roll-ruler::after { + position: absolute; + inset: 0; + border-top: 1px solid rgba(255, 255, 255, 0.04); + content: ""; + pointer-events: none; +} + +.piano-roll-ruler-tick { + position: absolute; + bottom: 0; + width: 1px; + height: 10px; + background: rgba(214, 224, 235, 0.24); +} + +.piano-roll-ruler-tick-bar { + top: 0; + bottom: auto; + height: 26px; + background: rgba(214, 224, 235, 0.42); +} + +.piano-roll-ruler-tick span, +.piano-roll-ruler-tick small { + position: absolute; + top: 3px; + left: 5px; + color: #c7d7e6; + font-size: 10px; + line-height: 1; + white-space: nowrap; +} + +.piano-roll-ruler-tick small { + top: 7px; + color: #7f8fa1; + font-size: 9px; +} + +.piano-roll-ruler-playhead { + position: absolute; + top: 0; + left: -5px; + width: 10px; + height: 100%; + border-left: 1px solid #4cc9f0; + border-right: 1px solid rgba(76, 201, 240, 0.45); + background: rgba(76, 201, 240, 0.18); + box-shadow: 0 0 8px rgba(76, 201, 240, 0.45); + pointer-events: none; + transition: opacity 80ms linear; +} + +.piano-roll-stage-row { + display: flex; + min-width: 0; + min-height: 0; +} + +.piano-roll-stage-wrap { + min-width: 0; + min-height: 0; +} + +.piano-roll-vertical-scroll { + flex: 0 0 16px; + width: 16px; + overflow-x: hidden; + overflow-y: auto; + border-left: 1px solid rgba(255, 255, 255, 0.11); + background: #101318; + scrollbar-color: #5f6874 #151a20; + scrollbar-width: thin; +} + +.piano-roll-vertical-scroll::-webkit-scrollbar { + width: 12px; +} + +.piano-roll-vertical-scroll::-webkit-scrollbar-track { + background: #151a20; +} + +.piano-roll-vertical-scroll::-webkit-scrollbar-thumb { + border: 2px solid #151a20; + border-radius: 8px; + background: #5f6874; +} + +.piano-roll-vertical-scroll::-webkit-scrollbar-thumb:hover { + background: #7b8794; +} + +.piano-roll-status-strip { + display: flex; + align-items: center; + gap: 14px; + height: 30px; + box-sizing: border-box; + padding: 0 10px; + border-top: 1px solid #2c333d; + background: #181c23; + color: #95a3b2; + font-size: 11px; + white-space: nowrap; + overflow: hidden; +} + +.piano-roll-inspector { + position: relative; + z-index: 3; + min-width: 0; + overflow: auto; + border-right: 1px solid #303743; + background: #1b2028; +} + +.piano-roll-sidebar { + overscroll-behavior: contain; +} + +.piano-roll-piano-key-strip { + position: relative; + z-index: 2; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + border-left: 1px solid #0d1015; + background: #20252d; + overflow: hidden; +} + +.piano-roll-key-ruler-spacer { + flex: 0 0 auto; + border-bottom: 1px solid #303743; + background: #151a21; +} + +.piano-roll-key-viewport { + position: relative; + flex: 0 0 auto; + overflow: hidden; + border-bottom: 1px solid #2b323c; + background: #b8bec3; +} + +.piano-roll-piano-key { + position: absolute; + right: 0; + display: flex; + align-items: center; + justify-content: flex-end; + box-sizing: border-box; + padding: 0 3px; + border: 0; + border-bottom: 1px solid rgba(20, 24, 29, 0.35); + color: #1e252c; + cursor: pointer; + font-size: 8px; + line-height: 1; + overflow: hidden; + text-align: right; +} + +.piano-roll-piano-key-white { + background: linear-gradient(180deg, #f3f6f7 0%, #d8dde0 100%); +} + +.piano-roll-piano-key-black { + border-bottom-color: rgba(255, 255, 255, 0.1); + background: linear-gradient(180deg, #111318 0%, #050608 100%); + color: #e5edf6; +} + +.piano-roll-piano-key-active { + background: linear-gradient(180deg, #6ce1ff 0%, #1b8fb0 100%); + color: #071117; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6), 0 0 10px rgba(100, 199, 232, 0.65); +} + +.piano-roll-piano-key span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.piano-roll-key-lane-spacer { + flex: 0 0 auto; + display: flex; + align-items: flex-start; + justify-content: flex-end; + box-sizing: border-box; + padding: 4px 4px 0 2px; + border-bottom: 1px solid #2b323c; + background: #14181f; + color: #8190a0; + font-size: 9px; + line-height: 1.2; + overflow: hidden; + text-align: right; +} + +.piano-roll-key-scroll-spacer { + flex: 0 0 auto; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: #101318; +} + +.piano-roll-key-status-spacer { + flex: 0 0 auto; + background: #181c23; +} + +.piano-roll-inspector-section { + padding: 10px; + border-bottom: 1px solid #303743; +} + +.piano-roll-section-title { + display: flex; + align-items: center; + justify-content: space-between; gap: 8px; + margin-bottom: 8px; + color: #f3f7fb; +} + +.piano-roll-source-section { + background: #171c23; +} + +.piano-roll-source-clip { + min-width: 0; + overflow: hidden; + color: #9fb0c0; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.piano-roll-source-grid { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 6px 8px; + align-items: center; +} + +.piano-roll-source-grid label, +.piano-roll-source-grid span { + color: #8f9dac; + font-size: 11px; +} + +.piano-roll-source-grid input { + height: 26px; + min-width: 0; box-sizing: border-box; - min-height: 40px; - padding: 6px 12px; - background: rgba(0, 0, 0, 0.3); - border-bottom: 1px solid rgba(255, 255, 255, 0.1); + padding: 2px 7px; + border: 1px solid #37404c; + border-radius: 5px; + background: #11151b; + color: #dde6ef; + font-size: 11px; + text-align: right; +} + +.piano-roll-source-grid strong { + color: #dde6ef; + font-size: 11px; + font-weight: 700; +} + +.piano-roll-source-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + margin-top: 8px; +} + +.piano-roll-source-actions button { + min-height: 28px; + padding: 0 7px; + border: 1px solid #39424f; + border-radius: 5px; + background: #252b34; + color: #d5dee8; + font-size: 11px; + font-weight: 700; + cursor: pointer; +} + +.piano-roll-source-actions button:hover { + background: #313946; + border-color: #526171; +} + +.piano-roll-source-actions button[data-active="true"] { + border-color: #58c3a3; + background: #172720; + color: #ebfff7; +} + +.piano-roll-panel-title { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +.piano-roll-mini-icon { + width: 24px; + height: 22px; + border-radius: 4px; +} + +.piano-roll-info-grid, +.piano-roll-field-grid { + display: grid; + grid-template-columns: 84px minmax(0, 1fr); + gap: 6px 8px; + align-items: center; +} + +.piano-roll-info-grid span, +.piano-roll-field-grid label { + color: #8f9dac; + font-size: 11px; +} + +.piano-roll-info-grid strong { + overflow: hidden; + color: #dde6ef; + font-size: 11px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.piano-roll-inspector-empty { + padding: 8px; + border: 1px solid #303743; + border-radius: 6px; + background: #141820; + color: #92a0af; + font-size: 11px; + line-height: 1.4; +} + +.piano-roll-lane-stack { + display: flex; + flex-direction: column; + gap: 5px; +} + +.piano-roll-lane-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 74px 64px 18px; + align-items: center; + gap: 6px; + min-height: 30px; + padding: 4px 6px; + border: 1px solid #303743; + border-radius: 6px; + background: #141820; + color: #c8d3de; + font-size: 11px; + text-align: left; + cursor: pointer; +} + +.piano-roll-lane-row[data-active="true"] { + border-color: #58c3a3; + background: #172720; +} + +.piano-roll-lane-row input { + width: 74px; + accent-color: #58c3a3; +} + +.piano-roll-lane-row select { + width: 64px; + height: 22px; + min-width: 0; + border: 1px solid #37404c; + border-radius: 4px; + background: #10151d; + color: #d8e2ec; + font-size: 10px; +} + +.piano-roll-lane-row span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.piano-roll-lane-remove { + display: inline-flex; + justify-content: center; + color: #d18b8b; +} + +.piano-roll-lane-add-grid, +.piano-roll-command-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + margin-top: 8px; +} + +.piano-roll-lane-add-grid button, +.piano-roll-command-grid button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + min-height: 28px; + border: 1px solid #39424f; + border-radius: 5px; + background: #252b34; + color: #d5dee8; + font-size: 11px; + font-weight: 700; + cursor: pointer; +} + +.piano-roll-lane-add-grid button:hover, +.piano-roll-command-grid button:hover { + background: #313946; + border-color: #526171; +} + +.piano-roll-command-grid button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +@media (max-width: 1100px) { + .piano-roll-key-strip { + gap: 6px; + } + + .piano-roll-tool-cluster { + max-width: none; + overflow-x: visible; + } + + .piano-roll-clip-group { + max-width: 220px; + } + + .piano-roll-info-line { + grid-template-columns: repeat(4, minmax(72px, 1fr)); + } +} + +.piano-roll-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + box-sizing: border-box; + min-height: 48px; + padding: 7px 10px; + background: #20242b; + border-bottom: 1px solid #343a44; + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.35); overflow: visible; } +.piano-roll-toolbar button { + min-height: 28px; + border-radius: 5px; + border-color: #3a424d; + background: #282e36; + color: #dce3ed; + font-size: 11px; + font-weight: 600; +} + +.piano-roll-toolbar button:hover { + background: #333b45; + border-color: #4b5664; +} + +.piano-roll-toolbar button[data-active="true"], +.piano-roll-toolbar button[aria-pressed="true"] { + background: #2a6f8f; + border-color: #53b6d6; + color: #f6fbff; +} + .tool-btn { padding: 6px 12px; background: rgba(255, 255, 255, 0.05); @@ -44,21 +864,23 @@ .toolbar-divider { width: 1px; - height: 20px; - background: rgba(255, 255, 255, 0.1); - margin: 0 4px; + height: 24px; + background: #3a424d; + margin: 0 3px; } .piano-roll-toolbar label { - font-size: 11px; - color: #aaa; - margin-left: 8px; + font-size: 10px; + color: #9aa5b3; + margin-left: 4px; + text-transform: uppercase; + white-space: nowrap; } .zoom-slider { - width: 112px; + width: 92px; height: 4px; - background: rgba(255, 255, 255, 0.1); + background: #3a424d; border-radius: 2px; outline: none; cursor: pointer; @@ -68,7 +890,8 @@ appearance: none; width: 12px; height: 12px; - background: #4cc9f0; + background: #64c7e8; + border: 1px solid #d8f5ff; border-radius: 50%; cursor: pointer; } @@ -88,23 +911,65 @@ justify-content: center; width: 100%; height: 100%; - color: #666; + color: #7a8493; font-size: 14px; } /* Dropdown selects in the toolbar (Scale, Root, CC) */ .piano-roll-select { - padding: 2px 6px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.15); - border-radius: 4px; - color: #ccc; + height: 26px; + padding: 2px 7px; + background: #171a20; + border: 1px solid #3a424d; + border-radius: 5px; + color: #d6dbe3; font-size: 11px; cursor: pointer; outline: none; min-width: 60px; } +.piano-roll-clip-select { + max-width: 180px; +} + +.piano-roll-number { + width: 52px; + height: 26px; + box-sizing: border-box; + padding: 2px 6px; + background: #171a20; + border: 1px solid #3a424d; + border-radius: 5px; + color: #d6dbe3; + font-size: 11px; + outline: none; +} + +.piano-roll-selection-count { + color: #aaa; + font-size: 11px; + white-space: nowrap; +} + +.piano-roll-checkbox { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 0 !important; + min-height: 26px; + padding: 0 5px; + border: 1px solid #343a44; + border-radius: 5px; + background: #171a20; +} + +.piano-roll-checkbox input { + width: 13px; + height: 13px; + accent-color: #4cc9f0; +} + .piano-roll-horizontal-scroll { height: 16px; overflow-x: auto; @@ -138,18 +1003,18 @@ top: 100%; left: 0; z-index: 100; - min-width: 200px; - padding: 4px 0; - background: #2a2a2a; - border: 1px solid #444; - border-radius: 4px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + min-width: 240px; + padding: 5px; + background: #20242b; + border: 1px solid #3d4652; + border-radius: 6px; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.5); } .piano-roll-transform-separator { height: 1px; - margin: 4px 0; - background: #444; + margin: 5px 0; + background: #3a424d; } .piano-roll-step-octave, @@ -176,10 +1041,11 @@ .piano-roll-transform-item { display: block; width: 100%; - padding: 6px 12px; - background: none; + padding: 7px 9px; + background: transparent; border: none; - color: #ccc; + border-radius: 4px; + color: #d6dbe3; font-size: 12px; text-align: left; cursor: pointer; @@ -187,6 +1053,68 @@ } .piano-roll-transform-item:hover { - background: rgba(76, 201, 240, 0.15); + background: #2b4350; color: #fff; } + +.piano-roll-modal-backdrop { + position: absolute; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + background: rgba(6, 8, 12, 0.58); + backdrop-filter: blur(2px); +} + +.piano-roll-transform-dialog { + display: grid; + grid-template-columns: 132px 1fr; + gap: 8px 10px; + min-width: 360px; + max-width: min(520px, calc(100vw - 32px)); + padding: 16px; + background: #20242b; + border: 1px solid #46515f; + border-radius: 8px; + color: #d6dbe3; + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.55); +} + +.piano-roll-transform-dialog-title { + grid-column: 1 / -1; + margin-bottom: 4px; + font-size: 13px; + font-weight: 600; + color: #f3f7fb; +} + +.piano-roll-transform-dialog label { + align-self: center; + font-size: 11px; + color: #aaa; +} + +.piano-roll-transform-dialog input { + min-width: 0; + height: 28px; + padding: 4px 7px; + background: #151920; + border: 1px solid #3d4652; + border-radius: 5px; + color: #edf3fa; + font-size: 12px; +} + +.piano-roll-controller-dialog { + min-width: 380px; +} + +.piano-roll-transform-dialog-actions { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 6px; +} diff --git a/frontend/src/components/PianoRoll.tsx b/frontend/src/components/PianoRoll.tsx index 13ef475..b18e999 100644 --- a/frontend/src/components/PianoRoll.tsx +++ b/frontend/src/components/PianoRoll.tsx @@ -2,8 +2,93 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"; import { Stage, Layer, Rect, Line, Text, Group } from "react-konva"; import { useShallow } from "zustand/react/shallow"; import { nativeBridge } from "../services/NativeBridge"; -import { useDAWStore, MIDIEvent, MIDICCEvent } from "../store/useDAWStore"; +import { + DEFAULT_PIANO_ROLL_VISIBLE_LANES, + useDAWStore, + MIDIEvent, + MIDICCEvent, + type PianoRollVisibleLane, +} from "../store/useDAWStore"; +import { + DEFAULT_PITCH_BEND_RANGE_SEMITONES, + MIDI_PITCH_BEND_CENTER, + MIDI_PITCH_BEND_MAX, + type ControllerInterpolationMode, + type ControllerLFOShape, + clamp7Bit, + combine14BitCCValue, + generateControllerLFOEvents, + generateControllerLineEvents, + laneValueToPitchBend, + pitchBendToLaneValue, + pitchBendValueToLaneFraction, + semitonesToPitchBendValueWithRange, + split14BitCCValue, + snapPitchBendValueToSemitoneWithRange, + thinControllerEvents, + transformControllerEvents, +} from "../utils/midiControllerLanes"; +import { getMIDIClipSourceLoopLength } from "../utils/midiClipSerialization"; +import { + applyNoteMetadataValueToEvents, + noteIdFor, + noteMetadataLaneMax, + noteMetadataLaneName, + noteMetadataValueForPair, + parseMIDINotePairs, + rebuildMIDIEventsForNotes, + sortMIDIEvents, + type MIDINotePair, +} from "../utils/midiNotes"; +import { + CC_PRESETS, + CHANNEL_PRESSURE_LANE, + CHANCE_LANE, + NOTE_OFF_VELOCITY_LANE, + PITCH_BEND_LANE, + POLY_PRESSURE_LANE, + PROGRAM_CHANGE_LANE, + VELOCITY_VARIANCE_LANE, + noteMetadataLaneTypeForLane, + scalarMIDIEventName, + scalarMIDIEventTypeForLane, +} from "../utils/pianoRollLanes"; +import { + gestureKindForHit, + hitTestPianoRoll, + type PianoRollGestureSession, +} from "../utils/pianoRollInteraction"; +import { + midiSourceTimeToProjectX, + pianoRollRulerTimeFromX, + projectXToMidiSourceTime, +} from "../utils/timelineGeometry"; +import { + guardModalContextMenu, + shouldSuppressWorkspaceContextMenu, +} from "../utils/modalEventGuards"; +import { windowRole, windowSessionId } from "../utils/windowEnvironment"; +import { + getNoteNameFromPitch, + isNoteInScale, + NOTE_NAMES, + NOTES_PER_OCTAVE, + SCALE_DISPLAY_NAMES, +} from "../utils/pianoRollPitch"; +import { + velocityColor, + velocityStrokeColor, +} from "../utils/pianoRollVelocity"; import { Button } from "./ui"; +import { ContextMenu, type MenuItem } from "./ContextMenu"; +import { PianoRollControllerLaneSection } from "./PianoRollControllerLaneSection"; +import { PianoRollInspectorSummary } from "./PianoRollInspectorSummary"; +import { PianoRollInfoLine } from "./PianoRollInfoLine"; +import { PianoRollLaneEditorSection } from "./PianoRollLaneEditorSection"; +import { PianoRollNoteInspectorSection } from "./PianoRollNoteInspectorSection"; +import { PianoRollPitchBendSection } from "./PianoRollPitchBendSection"; +import { PianoRollStatusStrip } from "./PianoRollStatusStrip"; +import { PianoRollToolbar } from "./PianoRollToolbar"; import "./PianoRoll.css"; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -12,20 +97,13 @@ type KonvaEvent = any; interface PianoRollProps { readonly clipId: string; readonly trackId: string; + readonly sessionId?: string; readonly additionalClipIds?: string[]; + readonly isDetached?: boolean; + readonly onDetach?: () => void; } -interface NotePair { - noteOn: MIDIEvent; - noteOff: MIDIEvent; - noteNumber: number; - velocity: number; - startTime: number; - duration: number; - pitchBend?: number; - pressure?: number; - slide?: number; -} +type NotePair = MIDINotePair; interface MultiClipNotePair extends NotePair { clipId: string; @@ -59,9 +137,80 @@ interface VelocityEditState { } interface CCDrawState { + lane: "cc" | "pitchBend" | "midiEvent" | "noteMetadata"; originalCCEvents: MIDICCEvent[]; + originalEvents: MIDIEvent[]; +} + +interface MarqueeState { + startX: number; + startY: number; + currentX: number; + currentY: number; + mode: "replace" | "add" | "toggle"; +} + +interface RangeDragState { + startX: number; + startY: number; + currentX: number; + currentY: number; +} + +interface LaneResizeState { + laneId: string; + laneKind: PianoRollVisibleLane["kind"]; + startY: number; + originalHeight: number; +} + +interface PanDragState { + startX: number; + startY: number; + originalScrollX: number; + originalScrollY: number; +} + +interface LoopBoundaryDragState { + edge: "start" | "end"; + startX: number; + currentX: number; + initialLoopOffset: number; + initialLoopLength: number; } +interface ControllerLaneClipboard { + sourceLabel: string; + duration: number; + points: Array<{ time: number; valueFraction: number }>; +} + +type QuantizeMode = "start" | "ends" | "both" | "length"; +type QuantizeGroovePreset = "straight" | "swingLight" | "swingHeavy" | "laidBack16" | "push16"; + +type TransformDialogState = + | { type: "quantize"; value: number; strength: number; mode: QuantizeMode; swing: number; groovePreset: QuantizeGroovePreset; tupletDivisions: number; catchRangeMs: number; safeRangeMs: number; randomizeMs: number; fixedLength: number; moveControllers: boolean } + | { type: "humanize"; timingMs: number; velocity: number } + | { type: "velocity"; value: number } + | { type: "randomVelocity"; amount: number } + | { type: "length"; value: number } + | { type: "mirror"; centerNote: number }; + +type ControllerTransformDialogState = + | { type: "line"; interpolation: ControllerInterpolationMode; curve: number; startValue: number; endValue: number } + | { type: "lfo"; shape: ControllerLFOShape; rateHz: number; centerValue: number; depth: number } + | { type: "thin"; tolerance: number } + | { type: "transform"; timeScalePercent: number; valueScalePercent: number; valueOffset: number; tilt: number }; + +type PianoRollContextMenuState = { + x: number; + y: number; + kind: "note" | "grid" | "range"; + noteId?: string; + noteNumber?: number; + time?: number; +} | null; + const MULTI_CLIP_TINTS = [ null, "#ff6b9d", @@ -90,277 +239,246 @@ const KEY_TO_NOTE: Record = { b: 11, }; -const NOTES_PER_OCTAVE = 12; const TOTAL_NOTES = 128; const NOTE_HEIGHT = 12; -const PIANO_WIDTH = 60; +const PIANO_WIDTH = 0; +const PIANO_KEY_STRIP_MIN_WIDTH = 56; +const PIANO_KEY_STRIP_MAX_WIDTH = 86; const GRID_SNAP = 0.25; const VELOCITY_LANE_HEIGHT = 60; const CC_LANE_HEIGHT = 80; const LANE_DIVIDER_HEIGHT = 1; const TOOLBAR_HEIGHT = 40; +const INFO_LINE_HEIGHT = 38; +const RULER_HEIGHT = 26; +const STATUS_STRIP_HEIGHT = 30; const HORIZONTAL_SCROLLBAR_HEIGHT = 16; +const VERTICAL_SCROLLBAR_WIDTH = 16; +const TIMELINE_DIVIDER_WIDTH = 6; const NOTE_EDGE_HIT_WIDTH = 7; const AUDITION_DURATION_MS = 180; const AUDITION_THROTTLE_MS = 120; -const MIN_ZOOM = 50; -const MAX_ZOOM = 240; -const WHEEL_ZOOM_SENSITIVITY = 0.002; - -const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; - -const SCALE_DEFINITIONS: Record = { - chromatic: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], - major: [0, 2, 4, 5, 7, 9, 11], - minor: [0, 2, 3, 5, 7, 8, 10], - dorian: [0, 2, 3, 5, 7, 9, 10], - mixolydian: [0, 2, 4, 5, 7, 9, 10], - pentatonic_major: [0, 2, 4, 7, 9], - pentatonic_minor: [0, 3, 5, 7, 10], - blues: [0, 3, 5, 6, 7, 10], -}; - -const SCALE_DISPLAY_NAMES: Record = { - chromatic: "Chromatic", - major: "Major", - minor: "Minor", - dorian: "Dorian", - mixolydian: "Mixolydian", - pentatonic_major: "Pentatonic Major", - pentatonic_minor: "Pentatonic Minor", - blues: "Blues", -}; -const CC_PRESETS = [ - { cc: 1, name: "CC#1 Modulation" }, - { cc: 7, name: "CC#7 Volume" }, - { cc: 10, name: "CC#10 Pan" }, - { cc: 11, name: "CC#11 Expression" }, - { cc: 64, name: "CC#64 Sustain" }, -]; +function PianoRollPlayheadLine({ + pixelsPerSecond, + scrollX, + stageHeight, + stageWidth, +}: { + pixelsPerSecond: number; + scrollX: number; + stageHeight: number; + stageWidth: number; +}) { + const lineRef = useRef(null); + const ppsRef = useRef(pixelsPerSecond); + const scrollXRef = useRef(scrollX); + ppsRef.current = pixelsPerSecond; + scrollXRef.current = scrollX; -function clamp(value: number, min: number, max: number): number { - return Math.max(min, Math.min(max, value)); -} + useEffect(() => { + const update = (time: number) => { + const x = time * ppsRef.current - scrollXRef.current; + const visible = x >= 0 && x <= stageWidth; + if (!lineRef.current) return; + lineRef.current.visible(visible); + if (visible) { + lineRef.current.points([x, 0, x, stageHeight]); + } + lineRef.current.getLayer()?.batchDraw(); + }; -function sortEvents(events: MIDIEvent[]): MIDIEvent[] { - return events - .map((event) => ({ ...event })) - .sort((a, b) => { - if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp; - if (a.type === b.type) return 0; - return a.type === "noteOff" ? 1 : -1; + update(useDAWStore.getState().transport.currentTime); + const unsubscribe = useDAWStore.subscribe((state) => { + update(state.transport.currentTime); }); -} - -function noteIdFor(clipId: string, timestamp: number, noteNumber: number): string { - return `${clipId}:${timestamp.toFixed(6)}:${noteNumber}`; -} - -function getNoteNameFromPitch(pitch: number): string { - const noteName = NOTE_NAMES[((pitch % NOTES_PER_OCTAVE) + NOTES_PER_OCTAVE) % NOTES_PER_OCTAVE]; - const octave = Math.floor(pitch / 12) - 2; - return `${noteName}${octave}`; -} + return unsubscribe; + }, [stageHeight, stageWidth]); -function velocityColor(velocity: number): string { - const v = clamp(velocity, 0, 127); - const t = v / 127; - let r: number; - let g: number; - let b: number; - if (t < 0.25) { - const s = t / 0.25; - r = 60; - g = Math.round(100 + 155 * s); - b = 240; - } else if (t < 0.5) { - const s = (t - 0.25) / 0.25; - r = Math.round(60 + 40 * s); - g = 255; - b = Math.round(240 - 140 * s); - } else if (t < 0.75) { - const s = (t - 0.5) / 0.25; - r = Math.round(100 + 155 * s); - g = 255; - b = Math.round(100 - 100 * s); - } else { - const s = (t - 0.75) / 0.25; - r = 255; - g = Math.round(255 - 200 * s); - b = 0; - } - return `rgb(${r}, ${g}, ${b})`; + const initialTime = useDAWStore.getState().transport.currentTime; + const initialX = initialTime * pixelsPerSecond - scrollX; + return ( + = 0 && initialX <= stageWidth} + /> + ); } -function velocityStrokeColor(velocity: number): string { - const t = clamp(velocity, 0, 127) / 127; - if (t < 0.5) return "#3b82f6"; - if (t < 0.75) return "#22c55e"; - return "#ef4444"; -} +function PianoRollRulerPlayhead({ + pixelsPerSecond, + scrollX, + width, +}: { + pixelsPerSecond: number; + scrollX: number; + width: number; +}) { + const markerRef = useRef(null); + const ppsRef = useRef(pixelsPerSecond); + const scrollXRef = useRef(scrollX); + ppsRef.current = pixelsPerSecond; + scrollXRef.current = scrollX; -function isNoteInScale(noteNumber: number, scaleRoot: number, scaleType: string): boolean { - if (scaleType === "chromatic") return true; - const intervals = SCALE_DEFINITIONS[scaleType]; - if (!intervals) return true; - const degree = ((noteNumber % 12) - scaleRoot + 12) % 12; - return intervals.includes(degree); -} + useEffect(() => { + const update = (time: number) => { + const x = time * ppsRef.current - scrollXRef.current; + const visible = x >= 0 && x <= width; + if (!markerRef.current) return; + markerRef.current.style.transform = `translateX(${x}px)`; + markerRef.current.style.opacity = visible ? "1" : "0"; + }; -function parseNotePairs(events?: MIDIEvent[]): NotePair[] { - if (!events) return []; - const pairs: NotePair[] = []; - const usedNoteOffs = new Set(); - - for (const noteOn of events.filter((event) => event.type === "noteOn")) { - if (noteOn.note === undefined) continue; - let noteOff: MIDIEvent | undefined; - let noteOffIndex = -1; - events.forEach((event, index) => { - if ( - usedNoteOffs.has(index) || - event.type !== "noteOff" || - event.note !== noteOn.note || - event.timestamp <= noteOn.timestamp - ) { - return; - } - if (!noteOff || event.timestamp < noteOff.timestamp) { - noteOff = event; - noteOffIndex = index; - } + update(useDAWStore.getState().transport.currentTime); + const unsubscribe = useDAWStore.subscribe((state) => { + update(state.transport.currentTime); }); - if (!noteOff || noteOffIndex < 0) continue; - usedNoteOffs.add(noteOffIndex); - pairs.push({ - noteOn, - noteOff, - noteNumber: noteOn.note, - velocity: noteOn.velocity || 80, - startTime: noteOn.timestamp, - duration: Math.max(0.01, noteOff.timestamp - noteOn.timestamp), - pitchBend: noteOn.pitchBend, - pressure: noteOn.pressure, - slide: noteOn.slide, - }); - } + return unsubscribe; + }, [width]); - return pairs; + const initialX = useDAWStore.getState().transport.currentTime * pixelsPerSecond - scrollX; + return ( +
= 0 && initialX <= width ? 1 : 0, + }} + aria-hidden="true" + /> + ); } -function transformSelectedEvents( - events: MIDIEvent[], - clipId: string, - noteIds: string[], - transform: (pair: NotePair) => NotePair | null, -): { events: MIDIEvent[]; nextIds: string[]; auditionPair?: NotePair } { - const selected = new Set(noteIds); - const pairs = parseNotePairs(events); - const consumed = new Set(); - const additions: MIDIEvent[] = []; - const nextIds: string[] = []; - let auditionPair: NotePair | undefined; - - pairs.forEach((pair) => { - const id = noteIdFor(clipId, pair.startTime, pair.noteNumber); - if (!selected.has(id)) return; - consumed.add(pair.noteOn); - consumed.add(pair.noteOff); - const nextPair = transform(pair); - if (!nextPair) return; - - const startTime = Math.max(0, nextPair.startTime); - const duration = Math.max(0.01, nextPair.duration); - const noteNumber = clamp(Math.round(nextPair.noteNumber), 0, 127); - const velocity = clamp(Math.round(nextPair.velocity), 1, 127); - const nextNoteOn = { - ...pair.noteOn, - timestamp: startTime, - type: "noteOn" as const, - note: noteNumber, - velocity, - }; - const nextNoteOff = { - ...pair.noteOff, - timestamp: startTime + duration, - type: "noteOff" as const, - note: noteNumber, - velocity: 0, - }; +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} - additions.push(nextNoteOn, nextNoteOff); - nextIds.push(noteIdFor(clipId, startTime, noteNumber)); - auditionPair ||= { - ...nextPair, - noteOn: nextNoteOn, - noteOff: nextNoteOff, - startTime, - duration, - noteNumber, - velocity, - }; - }); +const sortEvents = sortMIDIEvents; - return { - events: sortEvents([...events.filter((event) => !consumed.has(event)), ...additions]), - nextIds, - auditionPair, - }; +function parseNotePairs(events?: MIDIEvent[]): NotePair[] { + return parseMIDINotePairs(events || []); } -export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRollProps) { +export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], isDetached = false, onDetach }: PianoRollProps) { const containerRef = useRef(null); const toolbarRef = useRef(null); const scrollbarRef = useRef(null); + const verticalScrollbarRef = useRef(null); const auditionRef = useRef<{ note: number | null; timeoutId: number | null; lastAt: number }>({ note: null, timeoutId: null, lastAt: 0, }); + const previewNoteTimeoutsRef = useRef>(new Map()); + const lastRevealedPreviewNoteAtRef = useRef>(new Map()); + const keyDragRef = useRef<{ active: boolean; visited: Set }>({ active: false, visited: new Set() }); const latestDragAuditionRef = useRef<{ noteNumber: number; velocity: number } | null>(null); + const gestureSessionRef = useRef(null); + const ccDrawStateRef = useRef(null); + const panDragRef = useRef(null); const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); const [toolbarHeight, setToolbarHeight] = useState(TOOLBAR_HEIGHT); - const [tool, setTool] = useState<"draw" | "select" | "erase">("draw"); - const [zoom, setZoom] = useState(100); - const [scrollX, setScrollX] = useState(0); const [scrollY, setScrollY] = useState(TOTAL_NOTES * NOTE_HEIGHT / 2 - 300); - const [selectedNoteIds, setSelectedNoteIds] = useState([]); + const [sourceLengthDraft, setSourceLengthDraft] = useState(""); const [stepInputOctave, setStepInputOctave] = useState(4); const [selectedCC, setSelectedCC] = useState(1); + const [cc14BitMode, setCC14BitMode] = useState(false); + const [polyPressureNote, setPolyPressureNote] = useState(60); + const [snapPitchBendSemitones, setSnapPitchBendSemitones] = useState(false); + const [velocityLaneHeight, setVelocityLaneHeight] = useState(VELOCITY_LANE_HEIGHT); + const [ccLaneHeight, setCCLaneHeight] = useState(CC_LANE_HEIGHT); + const [showGhostMIDIClips, setShowGhostMIDIClips] = useState(true); + const [showSelectedMIDIClipRefs, setShowSelectedMIDIClipRefs] = useState(true); const [showTransformMenu, setShowTransformMenu] = useState(false); + const [contextMenu, setContextMenu] = useState(null); + const [marqueeState, setMarqueeState] = useState(null); + const [rangeDragState, setRangeDragState] = useState(null); + const [transformDialog, setTransformDialog] = useState(null); + const [controllerDialog, setControllerDialog] = useState(null); const [dragState, setDragState] = useState(null); const [drawingState, setDrawingState] = useState(null); const [velocityEdit, setVelocityEdit] = useState(null); const [ccDrawState, setCCDrawState] = useState(null); + const [loopBoundaryDrag, setLoopBoundaryDrag] = useState(null); + const [laneResizeState, setLaneResizeState] = useState(null); + const [panDragState, setPanDragState] = useState(null); + const [activePreviewNotes, setActivePreviewNotes] = useState>(() => new Set()); + const [controllerLaneClipboard, setControllerLaneClipboard] = useState(null); + const controllerPromptQueueRef = useRef([]); + const controllerLineModeOverrideRef = useRef(null); const transformMenuRef = useRef(null); const { track, tempo, + timeSignature, scaleRoot, scaleType, stepInputEnabled, stepInputSize, stepInputPosition, + selectedNoteIds, + midiNoteClipboard, + midiRangeClipboard, + midiEditRange, + pianoRollEditCursorTime, + activeMidiTool, + pianoRollVisibleLanes, + pianoRollActiveLaneId, + pianoRollInsertVelocity, + pianoRollAuditionEnabled, + timelinePixelsPerSecond, + timelineScrollX, + timelineScrollY, + isTransportPlaying, + tcpWidth, } = useDAWStore( useShallow((state) => ({ track: state.tracks.find((candidate) => candidate.id === trackId), tempo: state.transport.tempo, + timeSignature: state.timeSignature, scaleRoot: state.pianoRollScaleRoot, scaleType: state.pianoRollScaleType, stepInputEnabled: state.stepInputEnabled, stepInputSize: state.stepInputSize, stepInputPosition: state.stepInputPosition, + selectedNoteIds: state.selectedNoteIds, + midiNoteClipboard: state.midiNoteClipboard, + midiRangeClipboard: state.midiRangeClipboard, + midiEditRange: state.midiEditRange, + pianoRollEditCursorTime: state.pianoRollEditCursorTime, + activeMidiTool: state.activeMidiTool, + pianoRollVisibleLanes: state.pianoRollVisibleLanes, + pianoRollActiveLaneId: state.pianoRollActiveLaneId, + pianoRollInsertVelocity: state.pianoRollInsertVelocity, + pianoRollAuditionEnabled: state.pianoRollAuditionEnabled, + timelinePixelsPerSecond: state.pixelsPerSecond, + timelineScrollX: state.scrollX, + timelineScrollY: state.scrollY, + isTransportPlaying: state.transport.isPlaying, + tcpWidth: state.tcpWidth, })), ); + const tool = activeMidiTool; + const pitchBendRangeUp = clamp(track?.midiPitchBendRangeUp ?? DEFAULT_PITCH_BEND_RANGE_SEMITONES, 1, 24); + const pitchBendRangeDown = clamp(track?.midiPitchBendRangeDown ?? pitchBendRangeUp, 1, 24); + const pitchBendRangeLinked = track?.midiPitchBendRangeLinked ?? true; const { toggleStepInput, setStepInputSize, advanceStepInput, setStepInputPosition, + setTrackMidiPitchBendRange, updateMIDINoteVelocity, updateMIDICCEvents, commitMIDICCEvents, @@ -369,18 +487,69 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll addMIDINote, removeMIDINotes, moveMIDINotes, + resizeMIDINote, + setSelectedNoteIds, + selectAllMIDINotes, + copySelectedMIDINotes, + cutSelectedMIDINotes, + copyMIDIRange, + cutMIDIRange, + deleteMIDIRange, + pasteMIDIRange, + duplicateMIDIRange, + repeatMIDISelection, + pasteMIDINotes, + duplicateSelectedMIDINotes, + invertMIDISelection, + selectMIDINotesByPitch, + selectMIDINotesInRange, + quantizeSelectedMIDINotes, + quantizeSelectedMIDINotesUsingLast, + resetMIDIQuantize, + freezeMIDIQuantize, + humanizeSelectedMIDINotes, + setSelectedMIDINoteVelocity, + scaleSelectedMIDINoteVelocity, + randomizeSelectedMIDINoteVelocity, + setSelectedMIDINoteLength, + legatoSelectedMIDINotes, + reverseSelectedMIDINotes, + invertSelectedMIDINotePitches, + mirrorSelectedMIDINotePitches, + snapSelectedMIDINotesToScale, + toggleSelectedMIDINoteMute, + insertMIDIChord, + cropMIDIClipToSelectedNotes, + setMIDIEditRange, + clearMIDIEditRange, + setActiveMidiTool, + setPianoRollVisibleLanes, + setPianoRollActiveLane, + updatePianoRollVisibleLane, + addPianoRollVisibleLane, + removePianoRollVisibleLane, + setPianoRollEditCursorTime, + setPianoRollInsertVelocity, + setPianoRollAuditionEnabled, setPianoRollScaleRoot, setPianoRollScaleType, + setTimelineZoom, + setTimelineScroll, + seekTo, + updateMidiEditorSession, + setMIDIClipSourceWindow, transposeMIDINotes, scaleMIDINoteVelocity, reverseMIDINotes, invertMIDINotes, + openPianoRoll, } = useDAWStore( useShallow((state) => ({ toggleStepInput: state.toggleStepInput, setStepInputSize: state.setStepInputSize, advanceStepInput: state.advanceStepInput, setStepInputPosition: state.setStepInputPosition, + setTrackMidiPitchBendRange: state.setTrackMidiPitchBendRange, updateMIDINoteVelocity: state.updateMIDINoteVelocity, updateMIDICCEvents: state.updateMIDICCEvents, commitMIDICCEvents: state.commitMIDICCEvents, @@ -389,38 +558,191 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll addMIDINote: state.addMIDINote, removeMIDINotes: state.removeMIDINotes, moveMIDINotes: state.moveMIDINotes, + resizeMIDINote: state.resizeMIDINote, + setSelectedNoteIds: state.setSelectedNoteIds, + selectAllMIDINotes: state.selectAllMIDINotes, + copySelectedMIDINotes: state.copySelectedMIDINotes, + cutSelectedMIDINotes: state.cutSelectedMIDINotes, + copyMIDIRange: state.copyMIDIRange, + cutMIDIRange: state.cutMIDIRange, + deleteMIDIRange: state.deleteMIDIRange, + pasteMIDIRange: state.pasteMIDIRange, + duplicateMIDIRange: state.duplicateMIDIRange, + repeatMIDISelection: state.repeatMIDISelection, + pasteMIDINotes: state.pasteMIDINotes, + duplicateSelectedMIDINotes: state.duplicateSelectedMIDINotes, + invertMIDISelection: state.invertMIDISelection, + selectMIDINotesByPitch: state.selectMIDINotesByPitch, + selectMIDINotesInRange: state.selectMIDINotesInRange, + quantizeSelectedMIDINotes: state.quantizeSelectedMIDINotes, + quantizeSelectedMIDINotesUsingLast: state.quantizeSelectedMIDINotesUsingLast, + resetMIDIQuantize: state.resetMIDIQuantize, + freezeMIDIQuantize: state.freezeMIDIQuantize, + humanizeSelectedMIDINotes: state.humanizeSelectedMIDINotes, + setSelectedMIDINoteVelocity: state.setSelectedMIDINoteVelocity, + scaleSelectedMIDINoteVelocity: state.scaleSelectedMIDINoteVelocity, + randomizeSelectedMIDINoteVelocity: state.randomizeSelectedMIDINoteVelocity, + setSelectedMIDINoteLength: state.setSelectedMIDINoteLength, + legatoSelectedMIDINotes: state.legatoSelectedMIDINotes, + reverseSelectedMIDINotes: state.reverseSelectedMIDINotes, + invertSelectedMIDINotePitches: state.invertSelectedMIDINotePitches, + mirrorSelectedMIDINotePitches: state.mirrorSelectedMIDINotePitches, + snapSelectedMIDINotesToScale: state.snapSelectedMIDINotesToScale, + toggleSelectedMIDINoteMute: state.toggleSelectedMIDINoteMute, + insertMIDIChord: state.insertMIDIChord, + cropMIDIClipToSelectedNotes: state.cropMIDIClipToSelectedNotes, + setMIDIEditRange: state.setMIDIEditRange, + clearMIDIEditRange: state.clearMIDIEditRange, + setActiveMidiTool: state.setActiveMidiTool, + setPianoRollVisibleLanes: state.setPianoRollVisibleLanes, + setPianoRollActiveLane: state.setPianoRollActiveLane, + updatePianoRollVisibleLane: state.updatePianoRollVisibleLane, + addPianoRollVisibleLane: state.addPianoRollVisibleLane, + removePianoRollVisibleLane: state.removePianoRollVisibleLane, + setPianoRollEditCursorTime: state.setPianoRollEditCursorTime, + setPianoRollInsertVelocity: state.setPianoRollInsertVelocity, + setPianoRollAuditionEnabled: state.setPianoRollAuditionEnabled, setPianoRollScaleRoot: state.setPianoRollScaleRoot, setPianoRollScaleType: state.setPianoRollScaleType, + setTimelineZoom: state.setZoom, + setTimelineScroll: state.setScroll, + seekTo: state.seekTo, + updateMidiEditorSession: state.updateMidiEditorSession, + setMIDIClipSourceWindow: state.setMIDIClipSourceWindow, transposeMIDINotes: state.transposeMIDINotes, scaleMIDINoteVelocity: state.scaleMIDINoteVelocity, reverseMIDINotes: state.reverseMIDINotes, invertMIDINotes: state.invertMIDINotes, + openPianoRoll: state.openPianoRoll, })), ); + const setTool = setActiveMidiTool; + const visibleLanes = useMemo( + () => (pianoRollVisibleLanes?.length ? pianoRollVisibleLanes : DEFAULT_PIANO_ROLL_VISIBLE_LANES), + [pianoRollVisibleLanes], + ); + const activeLane = visibleLanes.find((lane) => lane.id === pianoRollActiveLaneId) ?? visibleLanes[0]; + const isVelocityLaneActive = activeLane?.kind === "velocity"; + const activeControllerLane = isVelocityLaneActive ? undefined : activeLane; const clip = track?.midiClips.find((candidate) => candidate.id === clipId); const clipEvents = clip?.events; const clipCCEvents = clip?.ccEvents; - const clipDuration = clip?.duration ?? 0; + const clipDuration = clip ? getMIDIClipSourceLoopLength(clip) : 0; const clipStartTime = clip?.startTime ?? 0; const beatsPerSecond = tempo / 60; - const pixelsPerSecond = zoom * beatsPerSecond; + const pixelsPerSecond = timelinePixelsPerSecond; + const scrollX = timelineScrollX - clipStartTime * pixelsPerSecond; const stepDurationSeconds = stepInputSize / beatsPerSecond; const snapDuration = GRID_SNAP / beatsPerSecond; - const bottomLanesHeight = VELOCITY_LANE_HEIGHT + CC_LANE_HEIGHT + LANE_DIVIDER_HEIGHT * 2; - const stageHeight = Math.max(0, dimensions.height - toolbarHeight - HORIZONTAL_SCROLLBAR_HEIGHT); + const sidebarWidth = tcpWidth; + const pianoKeyStripWidth = clamp( + Math.round(sidebarWidth * 0.32), + PIANO_KEY_STRIP_MIN_WIDTH, + PIANO_KEY_STRIP_MAX_WIDTH, + ); + const stageWidth = Math.max(1, dimensions.width - sidebarWidth - TIMELINE_DIVIDER_WIDTH - VERTICAL_SCROLLBAR_WIDTH); + const activeLaneHeight = isVelocityLaneActive ? velocityLaneHeight : ccLaneHeight; + const bottomLanesHeight = activeLaneHeight + LANE_DIVIDER_HEIGHT; + const stageHeight = Math.max( + 1, + dimensions.height - toolbarHeight - INFO_LINE_HEIGHT - RULER_HEIGHT - HORIZONTAL_SCROLLBAR_HEIGHT - STATUS_STRIP_HEIGHT, + ); const noteGridHeight = Math.max(NOTE_HEIGHT * 4, stageHeight - bottomLanesHeight); const velocityLaneY = noteGridHeight; - const ccLaneY = velocityLaneY + VELOCITY_LANE_HEIGHT + LANE_DIVIDER_HEIGHT; - const visibleGridWidth = Math.max(1, dimensions.width - PIANO_WIDTH); + const ccLaneY = noteGridHeight; + const visibleGridWidth = Math.max(1, stageWidth - PIANO_WIDTH); + const isCC14BitMode = cc14BitMode && selectedCC >= 0 && selectedCC <= 31; + const selectedScalarMIDIEventType = scalarMIDIEventTypeForLane(selectedCC); + const selectedScalarMIDIEventLabel = selectedScalarMIDIEventType + ? scalarMIDIEventName(selectedScalarMIDIEventType, polyPressureNote) + : ""; + const selectedNoteMetadataLaneType = noteMetadataLaneTypeForLane(selectedCC); + const selectedNoteMetadataLaneLabel = selectedNoteMetadataLaneType + ? noteMetadataLaneName(selectedNoteMetadataLaneType) + : ""; + const selectedNoteMetadataLaneMax = selectedNoteMetadataLaneType + ? noteMetadataLaneMax(selectedNoteMetadataLaneType) + : 127; + const controllerLaneLabel = isVelocityLaneActive + ? "Velocity" + : selectedCC === PITCH_BEND_LANE + ? "Pitch Bend" + : selectedScalarMIDIEventType + ? selectedScalarMIDIEventLabel + : selectedNoteMetadataLaneType + ? selectedNoteMetadataLaneLabel + : isCC14BitMode + ? `14-bit CC#${selectedCC}/${selectedCC + 32}` + : `CC#${selectedCC}`; + const selectedScalarMIDIEventMatches = useCallback((event: MIDIEvent) => { + if (!selectedScalarMIDIEventType || event.type !== selectedScalarMIDIEventType) return false; + return selectedScalarMIDIEventType !== "polyPressure" || event.note === polyPressureNote; + }, [polyPressureNote, selectedScalarMIDIEventType]); + const makeSelectedScalarMIDIEvent = useCallback((timestamp: number, value: number): MIDIEvent => ({ + type: selectedScalarMIDIEventType ?? "channelPressure", + timestamp, + value: clamp7Bit(value), + ...(selectedScalarMIDIEventType === "polyPressure" ? { note: polyPressureNote } : {}), + }), [polyPressureNote, selectedScalarMIDIEventType]); + + const selectControllerLane = useCallback((lane: PianoRollVisibleLane) => { + setPianoRollActiveLane(lane.id); + if (lane.kind === "velocity") return; + if (lane.kind === "noteOffVelocity") { + setSelectedCC(NOTE_OFF_VELOCITY_LANE); + setCC14BitMode(false); + return; + } + if (lane.kind === "chance") { + setSelectedCC(CHANCE_LANE); + setCC14BitMode(false); + return; + } + if (lane.kind === "velocityVariance") { + setSelectedCC(VELOCITY_VARIANCE_LANE); + setCC14BitMode(false); + return; + } + if (lane.kind === "pitchBend") { + setSelectedCC(PITCH_BEND_LANE); + setCC14BitMode(false); + return; + } + if (lane.kind === "programBank") { + setSelectedCC(PROGRAM_CHANGE_LANE); + setCC14BitMode(false); + return; + } + if (lane.kind === "channelPressure") { + setSelectedCC(CHANNEL_PRESSURE_LANE); + setCC14BitMode(false); + return; + } + if (lane.kind === "polyPressure") { + setSelectedCC(POLY_PRESSURE_LANE); + setCC14BitMode(false); + return; + } + if (lane.kind === "cc14") { + setSelectedCC(clamp(lane.cc ?? 1, 0, 31)); + setCC14BitMode(true); + return; + } + if (lane.kind === "cc7") { + setSelectedCC(clamp(lane.cc ?? 1, 0, 127)); + setCC14BitMode(false); + } + }, [setPianoRollActiveLane]); const notePairs = useMemo(() => parseNotePairs(clipEvents), [clipEvents]); + const trackMIDIClipOptions = useMemo(() => track?.midiClips ?? [], [track]); const additionalClips = useMemo(() => { - if (!track || additionalClipIds.length === 0) return []; + if (!track || !showSelectedMIDIClipRefs || additionalClipIds.length === 0) return []; return additionalClipIds .map((id) => track.midiClips.find((candidate) => candidate.id === id)) .filter((candidate): candidate is NonNullable => candidate != null); - }, [track, additionalClipIds]); + }, [track, additionalClipIds, showSelectedMIDIClipRefs]); const additionalClipNotePairs: MultiClipNotePair[] = useMemo(() => { const allPairs: MultiClipNotePair[] = []; @@ -439,21 +761,121 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll return allPairs; }, [additionalClips, clipStartTime]); - const ccEventsForLane: MIDICCEvent[] = useMemo(() => { + const controllerEventsForLane: Array<{ time: number; value: number; rawValue?: number }> = useMemo(() => { + if (selectedNoteMetadataLaneType) { + const maxValue = noteMetadataLaneMax(selectedNoteMetadataLaneType); + return notePairs + .map((pair) => { + const rawValue = noteMetadataValueForPair(pair, selectedNoteMetadataLaneType); + return { + time: pair.startTime, + value: clamp((rawValue / maxValue) * 127, 0, 127), + rawValue, + }; + }) + .sort((a, b) => a.time - b.time); + } + if (selectedCC === PITCH_BEND_LANE) { + return (clipEvents || []) + .filter((event) => event.type === "pitchBend") + .map((event) => { + const rawValue = event.value ?? event.pitchBend ?? MIDI_PITCH_BEND_CENTER; + return { + time: event.timestamp, + value: pitchBendToLaneValue(rawValue), + rawValue, + }; + }) + .sort((a, b) => a.time - b.time); + } + if (selectedScalarMIDIEventType) { + return (clipEvents || []) + .filter(selectedScalarMIDIEventMatches) + .map((event) => ({ + time: event.timestamp, + value: clamp7Bit(event.value ?? 0), + rawValue: event.value ?? 0, + })) + .sort((a, b) => a.time - b.time); + } + if (!clipCCEvents) return []; - return clipCCEvents.filter((event) => event.cc === selectedCC); - }, [clipCCEvents, selectedCC]); + if (isCC14BitMode) { + const lsbCC = selectedCC + 32; + return clipCCEvents + .filter((event) => event.cc === selectedCC) + .map((event) => { + const lsb = clipCCEvents.find((candidate) => + candidate.cc === lsbCC && Math.abs(candidate.time - event.time) < 0.000001, + ); + const rawValue = combine14BitCCValue(event.value, lsb?.value ?? 0); + return { + time: event.time, + value: clamp(Math.round((rawValue / MIDI_PITCH_BEND_MAX) * 127), 0, 127), + rawValue, + }; + }) + .sort((a, b) => a.time - b.time); + } + return clipCCEvents + .filter((event) => event.cc === selectedCC) + .map((event) => ({ time: event.time, value: event.value })) + .sort((a, b) => a.time - b.time); + }, [clipCCEvents, clipEvents, isCC14BitMode, notePairs, selectedCC, selectedNoteMetadataLaneType, selectedScalarMIDIEventMatches, selectedScalarMIDIEventType]); const contentDuration = useMemo(() => { const noteEnd = notePairs.reduce((max, pair) => Math.max(max, pair.startTime + pair.duration), 0); const ccEnd = (clipCCEvents || []).reduce((max, event) => Math.max(max, event.time), 0); const drawEnd = drawingState ? Math.max(drawingState.startTime, drawingState.endTime) : 0; - return Math.max(clipDuration, noteEnd, ccEnd, drawEnd, stepInputPosition + stepDurationSeconds, 1); - }, [clipDuration, notePairs, clipCCEvents, drawingState, stepInputPosition, stepDurationSeconds]); + const visibleItemEnd = clip?.duration ?? 0; + return Math.max(clipDuration, visibleItemEnd, noteEnd, ccEnd, drawEnd, stepInputPosition + stepDurationSeconds, 1); + }, [clip?.duration, clipDuration, notePairs, clipCCEvents, drawingState, stepInputPosition, stepDurationSeconds]); + + const eventContentLength = useMemo(() => { + const noteEnd = notePairs.reduce((max, pair) => Math.max(max, pair.startTime + pair.duration), 0); + const ccEnd = (clipCCEvents || []).reduce((max, event) => Math.max(max, event.time), 0); + const scalarEventEnd = (clipEvents || []).reduce((max, event) => Math.max(max, event.timestamp), 0); + return Math.max(0.01, noteEnd, ccEnd, scalarEventEnd); + }, [clipCCEvents, clipEvents, notePairs]); - const contentWidth = Math.max(visibleGridWidth, contentDuration * pixelsPerSecond); + const sourceLength = Math.max(0.01, clip?.sourceLength || clip?.loopLength || clipDuration || 0.01); + const contentWidth = Math.max(visibleGridWidth, (clipStartTime + contentDuration) * pixelsPerSecond); const maxScrollX = Math.max(0, contentWidth - visibleGridWidth); const maxScrollY = Math.max(0, TOTAL_NOTES * NOTE_HEIGHT - noteGridHeight); + const formatSeconds = useCallback((value: number) => value.toFixed(3), []); + const applySourceLength = useCallback((nextLength: number, description: string) => { + if (!clip) return; + const length = Math.max(0.01, Number.isFinite(nextLength) ? nextLength : sourceLength); + setSourceLengthDraft(formatSeconds(length)); + setMIDIClipSourceWindow(clipId, { + sourceLength: length, + loopLength: length, + }, description); + }, [clip, clipId, formatSeconds, setMIDIClipSourceWindow, sourceLength]); + + const commitSourceLengthDraft = useCallback(() => { + const parsed = Number.parseFloat(sourceLengthDraft); + if (!Number.isFinite(parsed)) { + setSourceLengthDraft(formatSeconds(sourceLength)); + return; + } + applySourceLength(parsed, "Edit MIDI source length"); + }, [applySourceLength, formatSeconds, sourceLength, sourceLengthDraft]); + + useEffect(() => { + setSourceLengthDraft(formatSeconds(sourceLength)); + }, [clipId, formatSeconds, sourceLength]); + + useEffect(() => { + if (!sessionId) return; + updateMidiEditorSession(sessionId, { + scrollY, + ...(isDetached ? { + windowPixelsPerSecond: timelinePixelsPerSecond, + windowScrollX: timelineScrollX, + } : {}), + }); + }, [isDetached, scrollY, sessionId, timelinePixelsPerSecond, timelineScrollX, updateMidiEditorSession]); const stopAudition = useCallback(() => { const current = auditionRef.current; @@ -468,6 +890,7 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll }, [trackId]); const auditionNote = useCallback((note: number, velocity = 90, options?: { throttle?: boolean; durationMs?: number }) => { + if (!pianoRollAuditionEnabled) return; const now = performance.now(); if (options?.throttle && now - auditionRef.current.lastAt < AUDITION_THROTTLE_MS) return; auditionRef.current.lastAt = now; @@ -479,7 +902,61 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll auditionRef.current.timeoutId = window.setTimeout(() => { stopAudition(); }, options?.durationMs ?? AUDITION_DURATION_MS); - }, [stopAudition, trackId]); + }, [pianoRollAuditionEnabled, stopAudition, trackId]); + + const auditionDraggedPianoKey = useCallback((noteNumber: number) => { + const safeNote = clamp(Math.round(noteNumber), 0, 127); + if (keyDragRef.current.visited.has(safeNote)) return; + keyDragRef.current.visited.add(safeNote); + auditionNote(safeNote, pianoRollInsertVelocity, { durationMs: 360 }); + }, [auditionNote, pianoRollInsertVelocity]); + + const beginPianoKeyDrag = useCallback((noteNumber: number) => { + keyDragRef.current = { active: true, visited: new Set() }; + auditionDraggedPianoKey(noteNumber); + }, [auditionDraggedPianoKey]); + + const maybeAuditionPianoKeyDuringDrag = useCallback((noteNumber: number) => { + if (!keyDragRef.current.active) return; + auditionDraggedPianoKey(noteNumber); + }, [auditionDraggedPianoKey]); + + const noteFromPianoKeyPointer = useCallback((event: React.PointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + const row = Math.floor((event.clientY - rect.top + scrollY) / NOTE_HEIGHT); + return clamp(TOTAL_NOTES - 1 - row, 0, TOTAL_NOTES - 1); + }, [scrollY]); + + const beginPianoKeyPointerDrag = useCallback((event: React.PointerEvent) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + beginPianoKeyDrag(noteFromPianoKeyPointer(event)); + }, [beginPianoKeyDrag, noteFromPianoKeyPointer]); + + const updatePianoKeyPointerDrag = useCallback((event: React.PointerEvent) => { + if (!keyDragRef.current.active) return; + maybeAuditionPianoKeyDuringDrag(noteFromPianoKeyPointer(event)); + }, [maybeAuditionPianoKeyDuringDrag, noteFromPianoKeyPointer]); + + const endPianoKeyDrag = useCallback(() => { + if (!keyDragRef.current.active) return; + keyDragRef.current.active = false; + keyDragRef.current.visited.clear(); + stopAudition(); + }, [stopAudition]); + + useEffect(() => { + if (!pianoRollAuditionEnabled) stopAudition(); + }, [pianoRollAuditionEnabled, stopAudition]); + + useEffect(() => { + window.addEventListener("pointerup", endPianoKeyDrag); + window.addEventListener("pointercancel", endPianoKeyDrag); + return () => { + window.removeEventListener("pointerup", endPianoKeyDrag); + window.removeEventListener("pointercancel", endPianoKeyDrag); + }; + }, [endPianoKeyDrag]); const getNoteY = useCallback((noteNumber: number) => { return (TOTAL_NOTES - 1 - noteNumber) * NOTE_HEIGHT - scrollY; @@ -494,14 +971,98 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll }, [snapDuration]); const getTimeFromX = useCallback((x: number) => { - return Math.max(0, (x - PIANO_WIDTH + scrollX) / pixelsPerSecond); - }, [pixelsPerSecond, scrollX]); + return projectXToMidiSourceTime(x - PIANO_WIDTH, { + pixelsPerSecond, + scrollX: timelineScrollX, + clipStartTime, + }); + }, [clipStartTime, pixelsPerSecond, timelineScrollX]); + + const pianoRollHitNotes = useMemo(() => notePairs.map((pair) => ({ + id: noteIdFor(clipId, pair.startTime, pair.noteNumber), + x: PIANO_WIDTH + midiSourceTimeToProjectX(pair.startTime, { + pixelsPerSecond, + scrollX: timelineScrollX, + clipStartTime, + }), + y: getNoteY(pair.noteNumber), + width: Math.max(4, pair.duration * pixelsPerSecond), + height: NOTE_HEIGHT, + })), [clipId, clipStartTime, getNoteY, notePairs, pixelsPerSecond, timelineScrollX]); + + const pianoRollHitLanes = useMemo(() => { + if (!activeLane) return []; + const activeLaneIsVelocity = activeLane.kind === "velocity"; + const lanes: Array<{ + id: string; + y: number; + height: number; + headerWidth: number; + resizeHandleHeight: number; + kind: "velocity" | "controller"; + }> = [{ + id: activeLane.id, + y: noteGridHeight, + height: activeLaneIsVelocity ? velocityLaneHeight : ccLaneHeight, + headerWidth: PIANO_WIDTH, + resizeHandleHeight: 6, + kind: activeLaneIsVelocity ? "velocity" : "controller", + }]; + return lanes; + }, [activeLane, ccLaneHeight, noteGridHeight, velocityLaneHeight]); + + const pianoRollControllerHitEvents = useMemo(() => { + if (!activeControllerLane) return []; + const laneId = activeControllerLane.id; + return controllerEventsForLane.map((event, index) => ({ + laneId, + eventId: `${laneId}-${event.time.toFixed(6)}-${index}`, + x: PIANO_WIDTH + event.time * pixelsPerSecond - scrollX, + y: ccLaneY + ccLaneHeight * (1 - event.value / 127), + radius: 6, + })); + }, [activeControllerLane, ccLaneHeight, ccLaneY, controllerEventsForLane, pixelsPerSecond, scrollX]); + + const loopBoundaryStartX = clip?.loopEnabled + ? PIANO_WIDTH + (clip.loopOffset ?? clip.offset ?? 0) * pixelsPerSecond - scrollX + : undefined; + const loopBoundaryEndX = clip?.loopEnabled + ? PIANO_WIDTH + ((clip.loopOffset ?? clip.offset ?? 0) + (clip.loopLength ?? clip.sourceLength ?? clipDuration)) * pixelsPerSecond - scrollX + : undefined; + const previewLoopBoundaryStartX = loopBoundaryDrag?.edge === "start" ? loopBoundaryDrag.currentX : loopBoundaryStartX; + const previewLoopBoundaryEndX = loopBoundaryDrag?.edge === "end" ? loopBoundaryDrag.currentX : loopBoundaryEndX; const getPointer = (event: KonvaEvent) => { const stage = event.target.getStage(); return stage?.getPointerPosition() ?? null; }; + const rangeFromRect = useCallback((rect: RangeDragState) => { + const left = Math.min(rect.startX, rect.currentX); + const right = Math.max(rect.startX, rect.currentX); + const top = Math.min(rect.startY, rect.currentY); + const bottom = Math.max(rect.startY, rect.currentY); + const startTime = Math.max(0, snapTime(getTimeFromX(left))); + const endTime = Math.max(0, snapTime(getTimeFromX(right))); + return { + startTime, + endTime, + minNote: Math.min(getNoteFromY(top), getNoteFromY(bottom)), + maxNote: Math.max(getNoteFromY(top), getNoteFromY(bottom)), + includeCC: true, + }; + }, [clipDuration, getNoteFromY, getTimeFromX, snapTime]); + + const pointInsideEditRange = useCallback((x: number, y: number) => { + if (!midiEditRange) return false; + const time = getTimeFromX(x); + const note = getNoteFromY(y); + return time >= midiEditRange.startTime + && time <= midiEditRange.endTime + && note >= midiEditRange.minNote + && note <= midiEditRange.maxNote; + }, [getNoteFromY, getTimeFromX, midiEditRange]); + const getLatestClipEvents = useCallback(() => { const latestTrack = useDAWStore.getState().tracks.find((candidate) => candidate.id === trackId); const latestClip = latestTrack?.midiClips.find((candidate) => candidate.id === clipId); @@ -518,8 +1079,8 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll const updateDimensions = () => { if (!containerRef.current) return; setDimensions({ - width: containerRef.current.clientWidth, - height: containerRef.current.clientHeight, + width: Math.max(1, containerRef.current.clientWidth), + height: Math.max(1, containerRef.current.clientHeight), }); setToolbarHeight(toolbarRef.current?.offsetHeight || TOOLBAR_HEIGHT); }; @@ -529,6 +1090,9 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(updateDimensions) : null; + if (containerRef.current && resizeObserver) { + resizeObserver.observe(containerRef.current); + } if (toolbarRef.current && resizeObserver) { resizeObserver.observe(toolbarRef.current); } @@ -539,8 +1103,10 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll }, []); useEffect(() => { - setScrollX((previous) => clamp(previous, 0, maxScrollX)); - }, [maxScrollX]); + if (timelineScrollX > maxScrollX) { + setTimelineScroll(maxScrollX, timelineScrollY); + } + }, [maxScrollX, setTimelineScroll, timelineScrollX, timelineScrollY]); useEffect(() => { setScrollY((previous) => clamp(previous, 0, maxScrollY)); @@ -548,40 +1114,141 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll useEffect(() => { const scrollbar = scrollbarRef.current; - if (scrollbar && Math.abs(scrollbar.scrollLeft - scrollX) > 1) { - scrollbar.scrollLeft = scrollX; + if (scrollbar && Math.abs(scrollbar.scrollLeft - timelineScrollX) > 1) { + scrollbar.scrollLeft = timelineScrollX; + } + }, [timelineScrollX]); + + useEffect(() => { + const scrollbar = verticalScrollbarRef.current; + if (scrollbar && Math.abs(scrollbar.scrollTop - scrollY) > 1) { + scrollbar.scrollTop = scrollY; + } + }, [scrollY]); + + const revealPreviewNote = useCallback((note: number) => { + const safeNote = clamp(Math.round(note), 0, 127); + const noteTop = (TOTAL_NOTES - 1 - safeNote) * NOTE_HEIGHT; + const noteBottom = noteTop + NOTE_HEIGHT; + const verticalPadding = NOTE_HEIGHT * 2; + setScrollY((previous) => { + const visibleTop = previous + verticalPadding; + const visibleBottom = previous + noteGridHeight - verticalPadding; + if (noteTop >= visibleTop && noteBottom <= visibleBottom) return previous; + return clamp(noteTop - noteGridHeight * 0.45, 0, maxScrollY); + }); + }, [maxScrollY, noteGridHeight]); + + const showPreviewNote = useCallback(( + rawNote: number, + velocity = 96, + isNoteOn = true, + options?: { reveal?: boolean }, + ) => { + const note = clamp(Math.round(rawNote), 0, 127); + const isOn = isNoteOn && velocity > 0; + const existingTimeout = previewNoteTimeoutsRef.current.get(note); + if (existingTimeout !== undefined) { + window.clearTimeout(existingTimeout); + previewNoteTimeoutsRef.current.delete(note); } - }, [scrollX]); + setActivePreviewNotes((previous) => { + const next = new Set(previous); + if (isOn) next.add(note); + else next.delete(note); + return next; + }); + if (isOn) { + if (options?.reveal) revealPreviewNote(note); + const timeoutId = window.setTimeout(() => { + previewNoteTimeoutsRef.current.delete(note); + setActivePreviewNotes((previous) => { + if (!previous.has(note)) return previous; + const next = new Set(previous); + next.delete(note); + return next; + }); + }, 1800); + previewNoteTimeoutsRef.current.set(note, timeoutId); + } + }, [revealPreviewNote]); + + useEffect(() => { + const handlePreviewNote = (event: Event) => { + const detail = (event as CustomEvent).detail as { + trackId?: string; + note?: number; + velocity?: number; + isNoteOn?: boolean; + } | undefined; + if (!detail || detail.trackId !== trackId || typeof detail.note !== "number") return; + const isOn = detail.isNoteOn !== false && (detail.velocity ?? 0) > 0; + showPreviewNote(detail.note, detail.velocity ?? 0, isOn); + }; + + window.addEventListener("openstudio-midi-note-preview", handlePreviewNote); + return () => { + window.removeEventListener("openstudio-midi-note-preview", handlePreviewNote); + previewNoteTimeoutsRef.current.forEach((timeoutId) => window.clearTimeout(timeoutId)); + previewNoteTimeoutsRef.current.clear(); + }; + }, [showPreviewNote, trackId]); + + useEffect(() => { + if (!trackId || isTransportPlaying) return; + let cancelled = false; + const pollNoteActivity = async () => { + const activities = await nativeBridge.getTrackMIDINoteActivity(trackId, 1200).catch(() => []); + if (cancelled) return; + const now = performance.now(); + for (const activity of activities) { + if (typeof activity.note !== "number") continue; + const note = clamp(Math.round(activity.note), 0, 127); + const ageMs = typeof activity.ageMs === "number" ? activity.ageMs : 1200; + const lastRevealAt = lastRevealedPreviewNoteAtRef.current.get(note) ?? 0; + const shouldReveal = activity.active !== false && ageMs < 260 && now - lastRevealAt > 280; + if (shouldReveal) { + lastRevealedPreviewNoteAtRef.current.set(note, now); + } + showPreviewNote(note, activity.velocity ?? 96, activity.active !== false, { reveal: shouldReveal }); + } + }; + void pollNoteActivity(); + const intervalId = window.setInterval(() => { + void pollNoteActivity(); + }, 80); + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, [isTransportPlaying, showPreviewNote, trackId]); useEffect(() => { const container = containerRef.current; if (!container) return; const handleWheel = (event: WheelEvent) => { + if ((event.target as HTMLElement | null)?.closest(".piano-roll-sidebar")) { + return; + } event.preventDefault(); if (event.ctrlKey || event.metaKey) { - event.stopPropagation(); const rect = container.getBoundingClientRect(); - const cursorGridX = clamp(event.clientX - rect.left - PIANO_WIDTH, 0, visibleGridWidth); - const timeAtCursor = (cursorGridX + scrollX) / pixelsPerSecond; - const nextZoom = clamp( - zoom * Math.exp(-event.deltaY * WHEEL_ZOOM_SENSITIVITY), - MIN_ZOOM, - MAX_ZOOM, - ); - const nextPixelsPerSecond = nextZoom * beatsPerSecond; - const nextContentWidth = Math.max(visibleGridWidth, contentDuration * nextPixelsPerSecond); + const cursorGridX = clamp(event.clientX - rect.left - sidebarWidth - TIMELINE_DIVIDER_WIDTH - PIANO_WIDTH, 0, visibleGridWidth); + const projectTimeAtCursor = (timelineScrollX + cursorGridX) / pixelsPerSecond; + const factor = Math.exp(-event.deltaY * 0.0015); + const nextPixelsPerSecond = clamp(pixelsPerSecond * factor, 1, 1000); + const nextContentWidth = Math.max(visibleGridWidth, (clipStartTime + contentDuration) * nextPixelsPerSecond); const nextMaxScrollX = Math.max(0, nextContentWidth - visibleGridWidth); - - setZoom(nextZoom); - setScrollX(clamp(timeAtCursor * nextPixelsPerSecond - cursorGridX, 0, nextMaxScrollX)); + const nextScrollX = clamp(projectTimeAtCursor * nextPixelsPerSecond - cursorGridX, 0, nextMaxScrollX); + setTimelineZoom(nextPixelsPerSecond); + setTimelineScroll(nextScrollX, timelineScrollY); return; } - const horizontalIntent = Math.abs(event.deltaX) > Math.abs(event.deltaY) || event.shiftKey; if (horizontalIntent) { const delta = event.deltaX + (event.shiftKey ? event.deltaY : 0); - setScrollX((previous) => clamp(previous + delta, 0, maxScrollX)); + setTimelineScroll(clamp(timelineScrollX + delta, 0, maxScrollX), timelineScrollY); } else { setScrollY((previous) => clamp(previous + event.deltaY, 0, maxScrollY)); } @@ -589,7 +1256,19 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll container.addEventListener("wheel", handleWheel, { passive: false }); return () => container.removeEventListener("wheel", handleWheel); - }, [beatsPerSecond, contentDuration, maxScrollX, maxScrollY, pixelsPerSecond, scrollX, visibleGridWidth, zoom]); + }, [ + clipStartTime, + contentDuration, + maxScrollX, + maxScrollY, + pixelsPerSecond, + setTimelineScroll, + setTimelineZoom, + sidebarWidth, + timelineScrollX, + timelineScrollY, + visibleGridWidth, + ]); useEffect(() => { if (!showTransformMenu) return; @@ -616,6 +1295,7 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; const target = event.target as HTMLElement; if (["INPUT", "SELECT", "TEXTAREA"].includes(target.tagName)) return; @@ -626,21 +1306,125 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll && KEY_TO_NOTE[key] !== undefined; if (isStepInputNoteKey) return; - if (key === "d") { + const hasShortcutModifier = event.ctrlKey || event.metaKey || event.altKey; + + if (!hasShortcutModifier && key === "d") { event.preventDefault(); setTool("draw"); return; } - if (key === "v") { + if (!hasShortcutModifier && key === "v") { event.preventDefault(); setTool("select"); return; } - if (key === "e") { + if (!hasShortcutModifier && key === "e") { event.preventDefault(); setTool("erase"); return; } + if (!hasShortcutModifier && key === "t") { + event.preventDefault(); + setTool("trim"); + return; + } + if (!hasShortcutModifier && key === "b") { + event.preventDefault(); + setTool("split"); + return; + } + if (!hasShortcutModifier && key === "g") { + event.preventDefault(); + setTool("glue"); + return; + } + if (!hasShortcutModifier && key === "m") { + event.preventDefault(); + setTool("mute"); + return; + } + if (!hasShortcutModifier && key === "y") { + event.preventDefault(); + setTool("velocity"); + return; + } + if (!hasShortcutModifier && key === "l") { + event.preventDefault(); + setTool("line"); + return; + } + if (!hasShortcutModifier && key === "z") { + event.preventDefault(); + setTool("zoom"); + return; + } + if (!hasShortcutModifier && key === "h") { + event.preventDefault(); + setTool("pan"); + return; + } + if (!hasShortcutModifier && key === "r" && event.shiftKey) { + event.preventDefault(); + const repeatingRange = !!midiEditRange; + const nextIds = repeatMIDISelection(trackId, clipId); + if (!repeatingRange && nextIds.length > 0) setSelectedNoteIds(nextIds); + return; + } + if (!hasShortcutModifier && key === "r") { + event.preventDefault(); + setTool("range"); + return; + } + if (!hasShortcutModifier && key === "q") { + event.preventDefault(); + const nextIds = quantizeSelectedMIDINotesUsingLast(trackId, clipId); + if (nextIds.length > 0) setSelectedNoteIds(nextIds); + return; + } + + if ((event.ctrlKey || event.metaKey) && key === "a") { + event.preventDefault(); + selectAllMIDINotes(); + return; + } + if ((event.ctrlKey || event.metaKey) && key === "c") { + event.preventDefault(); + if (midiEditRange) copyMIDIRange(trackId, clipId); + else copySelectedMIDINotes(trackId, clipId); + return; + } + if ((event.ctrlKey || event.metaKey) && key === "x") { + event.preventDefault(); + if (midiEditRange) cutMIDIRange(trackId, clipId); + else cutSelectedMIDINotes(trackId, clipId); + stopAudition(); + return; + } + if ((event.ctrlKey || event.metaKey) && key === "v") { + event.preventDefault(); + const pastingRange = midiRangeClipboard.rangeLength > 0; + const nextIds = pastingRange + ? pasteMIDIRange(trackId, clipId) + : pasteMIDINotes(trackId, clipId); + if (!pastingRange && nextIds.length > 0) setSelectedNoteIds(nextIds); + return; + } + if ((event.ctrlKey || event.metaKey) && key === "d" && (selectedNoteIds.length > 0 || midiEditRange)) { + event.preventDefault(); + const duplicatingRange = !!midiEditRange; + const nextIds = duplicatingRange + ? duplicateMIDIRange(trackId, clipId) + : duplicateSelectedMIDINotes(trackId, clipId); + if (!duplicatingRange && nextIds.length > 0) setSelectedNoteIds(nextIds); + return; + } + + if ((key === "delete" || key === "backspace") && midiEditRange) { + event.preventDefault(); + deleteMIDIRange(trackId, clipId); + stopAudition(); + return; + } if ((key === "delete" || key === "backspace") && selectedNoteIds.length > 0) { event.preventDefault(); @@ -675,10 +1459,25 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll }, [ auditionNote, clipId, + copyMIDIRange, + copySelectedMIDINotes, + cutMIDIRange, + cutSelectedMIDINotes, + deleteMIDIRange, + duplicateMIDIRange, + duplicateSelectedMIDINotes, getLatestClipEvents, + midiEditRange, + midiRangeClipboard.rangeLength, moveMIDINotes, + pasteMIDIRange, + pasteMIDINotes, + quantizeSelectedMIDINotesUsingLast, removeMIDINotes, + repeatMIDISelection, + selectAllMIDINotes, selectedNoteIds, + setSelectedNoteIds, snapDuration, stepDurationSeconds, stepInputEnabled, @@ -690,6 +1489,7 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll if (!stepInputEnabled) return; const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; const target = event.target as HTMLElement; if (["INPUT", "SELECT", "TEXTAREA"].includes(target.tagName)) return; if (selectedNoteIds.length > 0) return; @@ -708,13 +1508,13 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll if (key === "arrowleft") { event.preventDefault(); setStepInputPosition(Math.max(0, stepInputPosition - stepDurationSeconds)); - setScrollX((previous) => clamp(previous - stepDurationSeconds * pixelsPerSecond, 0, maxScrollX)); + setTimelineScroll(clamp(timelineScrollX - stepDurationSeconds * pixelsPerSecond, 0, maxScrollX), timelineScrollY); return; } if (key === "arrowright") { event.preventDefault(); advanceStepInput(); - setScrollX((previous) => clamp(previous + stepDurationSeconds * pixelsPerSecond, 0, maxScrollX)); + setTimelineScroll(clamp(timelineScrollX + stepDurationSeconds * pixelsPerSecond, 0, maxScrollX), timelineScrollY); return; } @@ -724,9 +1524,9 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll const noteNumber = (stepInputOctave + 2) * 12 + semitone + (event.shiftKey ? 1 : 0); if (noteNumber < 0 || noteNumber > 127) return; - const newId = addMIDINote(trackId, clipId, stepInputPosition, noteNumber, stepDurationSeconds, 80); + const newId = addMIDINote(trackId, clipId, stepInputPosition, noteNumber, stepDurationSeconds, pianoRollInsertVelocity); setSelectedNoteIds(newId ? [newId] : []); - auditionNote(noteNumber, 80); + auditionNote(noteNumber, pianoRollInsertVelocity); advanceStepInput(); }; @@ -738,80 +1538,1216 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll auditionNote, clipId, maxScrollX, + pianoRollInsertVelocity, pixelsPerSecond, selectedNoteIds.length, + setTimelineScroll, setStepInputPosition, stepDurationSeconds, stepInputEnabled, stepInputOctave, stepInputPosition, + timelineScrollX, + timelineScrollY, trackId, ]); const handleScrollbarScroll = useCallback(() => { const scrollbar = scrollbarRef.current; if (!scrollbar) return; - setScrollX(clamp(scrollbar.scrollLeft, 0, maxScrollX)); - }, [maxScrollX]); + setTimelineScroll(clamp(scrollbar.scrollLeft, 0, maxScrollX), timelineScrollY); + }, [maxScrollX, setTimelineScroll, timelineScrollY]); + + const handleVerticalScrollbarScroll = useCallback(() => { + const scrollbar = verticalScrollbarRef.current; + if (!scrollbar) return; + setScrollY(clamp(scrollbar.scrollTop, 0, maxScrollY)); + }, [maxScrollY]); const getVelocityFromLaneY = useCallback((y: number) => { - const relY = clamp(y - velocityLaneY, 0, VELOCITY_LANE_HEIGHT); - return clamp(Math.round(127 * (1 - relY / VELOCITY_LANE_HEIGHT)), 1, 127); - }, [velocityLaneY]); + const relY = clamp(y - velocityLaneY, 0, velocityLaneHeight); + return clamp(Math.round(127 * (1 - relY / velocityLaneHeight)), 1, 127); + }, [velocityLaneHeight, velocityLaneY]); const upsertCCEvent = useCallback((eventX: number, eventY: number, existingEvents: MIDICCEvent[]) => { - const time = Math.min(clipDuration, snapTime(getTimeFromX(eventX))); - const relY = clamp(eventY - ccLaneY, 0, CC_LANE_HEIGHT); - const value = clamp(Math.round(127 * (1 - relY / CC_LANE_HEIGHT)), 0, 127); + const time = snapTime(getTimeFromX(eventX)); + const relY = clamp(eventY - ccLaneY, 0, ccLaneHeight); + if (isCC14BitMode) { + const rawValue = clamp(Math.round(MIDI_PITCH_BEND_MAX * (1 - relY / ccLaneHeight)), 0, MIDI_PITCH_BEND_MAX); + const split = split14BitCCValue(rawValue); + const lsbCC = selectedCC + 32; + const filtered = existingEvents.filter( + (event) => !((event.cc === selectedCC || event.cc === lsbCC) && Math.abs(event.time - time) < snapDuration * 0.5), + ); + return [ + ...filtered, + { cc: selectedCC, time, value: split.msb }, + { cc: lsbCC, time, value: split.lsb }, + ].sort((a, b) => a.time - b.time || a.cc - b.cc); + } + const value = clamp(Math.round(127 * (1 - relY / ccLaneHeight)), 0, 127); const filtered = existingEvents.filter( (event) => !(event.cc === selectedCC && Math.abs(event.time - time) < snapDuration * 0.5), ); return [...filtered, { cc: selectedCC, time, value }].sort((a, b) => a.time - b.time); - }, [ccLaneY, clipDuration, getTimeFromX, selectedCC, snapDuration, snapTime]); - - const handleVelocityMouseDown = useCallback((event: KonvaEvent) => { - const pos = getPointer(event); - if (!pos || pos.y < velocityLaneY || pos.y >= velocityLaneY + VELOCITY_LANE_HEIGHT) return; - - const pair = notePairs.find((candidate) => { - const barX = PIANO_WIDTH + candidate.startTime * pixelsPerSecond - scrollX; - const barWidth = Math.max(4, candidate.duration * pixelsPerSecond); - return pos.x >= barX && pos.x <= barX + barWidth; - }); - if (!pair) return; - - const id = noteIdFor(clipId, pair.startTime, pair.noteNumber); - const velocity = getVelocityFromLaneY(pos.y); - setSelectedNoteIds([id]); - setVelocityEdit({ - noteId: id, - timestamp: pair.startTime, - noteNumber: pair.noteNumber, - originalEvents: getLatestClipEvents(), - }); - updateMIDINoteVelocity(trackId, clipId, pair.startTime, pair.noteNumber, velocity, { transient: true }); - auditionNote(pair.noteNumber, velocity, { throttle: true }); + }, [ccLaneHeight, ccLaneY, getTimeFromX, isCC14BitMode, selectedCC, snapDuration, snapTime]); + + const upsertPitchBendEvent = useCallback((eventX: number, eventY: number, existingEvents: MIDIEvent[]) => { + const time = snapTime(getTimeFromX(eventX)); + const relY = clamp(eventY - ccLaneY, 0, ccLaneHeight); + const laneValue = clamp(Math.round(127 * (1 - relY / ccLaneHeight)), 0, 127); + const rawValue = laneValueToPitchBend(laneValue); + const value = snapPitchBendSemitones + ? snapPitchBendValueToSemitoneWithRange(rawValue, pitchBendRangeUp, pitchBendRangeDown) + : rawValue; + const filtered = existingEvents.filter( + (event) => !(event.type === "pitchBend" && Math.abs(event.timestamp - time) < snapDuration * 0.5), + ); + return sortEvents([...filtered, { type: "pitchBend", timestamp: time, value }]); + }, [ccLaneHeight, ccLaneY, getTimeFromX, pitchBendRangeDown, pitchBendRangeUp, snapDuration, snapPitchBendSemitones, snapTime]); + + const upsertScalarMIDIEvent = useCallback((eventX: number, eventY: number, existingEvents: MIDIEvent[]) => { + if (!selectedScalarMIDIEventType) return existingEvents; + const time = snapTime(getTimeFromX(eventX)); + const relY = clamp(eventY - ccLaneY, 0, ccLaneHeight); + const value = clamp7Bit(127 * (1 - relY / ccLaneHeight)); + const filtered = existingEvents.filter( + (event) => !(selectedScalarMIDIEventMatches(event) && Math.abs(event.timestamp - time) < snapDuration * 0.5), + ); + return sortEvents([...filtered, makeSelectedScalarMIDIEvent(time, value)]); }, [ - auditionNote, - clipId, - getLatestClipEvents, - getVelocityFromLaneY, - notePairs, - pixelsPerSecond, - scrollX, - trackId, - updateMIDINoteVelocity, - velocityLaneY, + ccLaneHeight, + ccLaneY, + getTimeFromX, + makeSelectedScalarMIDIEvent, + selectedScalarMIDIEventMatches, + selectedScalarMIDIEventType, + snapDuration, + snapTime, ]); - const handleCCMouseDown = useCallback((event: KonvaEvent) => { - const pos = getPointer(event); - if (!pos || pos.y < ccLaneY || pos.y >= ccLaneY + CC_LANE_HEIGHT) return; + const getNoteMetadataValueFromLaneY = useCallback((y: number) => { + if (!selectedNoteMetadataLaneType) return 0; + const relY = clamp(y - ccLaneY, 0, ccLaneHeight); + return clamp( + Math.round(selectedNoteMetadataLaneMax * (1 - relY / ccLaneHeight)), + 0, + selectedNoteMetadataLaneMax, + ); + }, [ + ccLaneHeight, + ccLaneY, + selectedNoteMetadataLaneMax, + selectedNoteMetadataLaneType, + ]); + + const upsertNoteMetadataLaneValue = useCallback((eventX: number, eventY: number, existingEvents: MIDIEvent[]) => { + if (!selectedNoteMetadataLaneType) return existingEvents; + const time = getTimeFromX(eventX); + const value = getNoteMetadataValueFromLaneY(eventY); + const pairs = parseNotePairs(existingEvents); + const pair = pairs.find((candidate) => { + const start = candidate.startTime; + const end = candidate.startTime + candidate.duration; + return time >= start - 0.000001 && time <= end + 0.000001; + }) ?? pairs + .map((candidate) => ({ + candidate, + distance: Math.min( + Math.abs(time - candidate.startTime), + Math.abs(time - (candidate.startTime + candidate.duration)), + ), + })) + .sort((a, b) => a.distance - b.distance)[0]?.candidate; + + if (!pair) return existingEvents; + return sortEvents(applyNoteMetadataValueToEvents(existingEvents, pair, selectedNoteMetadataLaneType, value)); + }, [ + getNoteMetadataValueFromLaneY, + getTimeFromX, + selectedNoteMetadataLaneType, + ]); + + const getControllerTransformRange = useCallback(() => { + const maxEnd = Math.max(contentDuration, clipDuration, snapDuration); + const start = midiEditRange + ? Math.max(0, Math.min(midiEditRange.startTime, midiEditRange.endTime)) + : 0; + const rawEnd = midiEditRange + ? Math.max(midiEditRange.startTime, midiEditRange.endTime) + : maxEnd; + const end = Math.min(Math.max(rawEnd, start + snapDuration), maxEnd); + return { startTime: start, endTime: Math.max(end, start + snapDuration) }; + }, [clipDuration, contentDuration, midiEditRange, snapDuration]); + + const promptNumber = useCallback((message: string, fallback: number, min: number, max: number) => { + const queued = controllerPromptQueueRef.current.shift(); + if (queued !== undefined) { + return Number.isFinite(queued) ? clamp(queued, min, max) : fallback; + } + void message; + return clamp(fallback, min, max); + }, []); + + const applyControllerLine = useCallback(() => { + const range = getControllerTransformRange(); + const stepSeconds = Math.max(0.005, Math.min(snapDuration, 1 / 64)); + const modeInput = controllerLineModeOverrideRef.current ?? "ramp"; + controllerLineModeOverrideRef.current = null; + if (modeInput === null) return; + const mode = modeInput.trim().toLowerCase(); + const interpolation: ControllerInterpolationMode = mode.startsWith("s") + ? "step" + : mode.startsWith("p") + ? "parabola" + : mode.startsWith("c") + ? "curve" + : "linear"; + const curve = interpolation === "curve" + ? promptNumber("Curve amount (-1 slow, 0 parabola, 1 fast)", 0.5, -0.99, 0.99) + : 0; + if (curve === null) return; + + if (isVelocityLaneActive) { + const startValue = promptNumber("Start velocity value", 1, 1, 127); + if (startValue === null) return; + const endValue = promptNumber("End velocity value", 127, 1, 127); + if (endValue === null) return; + + const oldEvents = getLatestClipEvents(); + const duration = Math.max(0.000001, range.endTime - range.startTime); + const nextEvents = sortEvents(oldEvents.map((event) => { + if (event.type !== "noteOn" || event.timestamp < range.startTime || event.timestamp > range.endTime) { + return event; + } + + const t = clamp((event.timestamp - range.startTime) / duration, 0, 1); + const shaped = interpolation === "step" + ? (t >= 1 ? 1 : 0) + : interpolation === "parabola" + ? t * t + : interpolation === "curve" + ? Math.pow(t, Math.pow(2, -curve * 4)) + : t; + const velocity = clamp(Math.round(startValue + (endValue - startValue) * shaped), 1, 127); + return { ...event, velocity }; + })); + commitMIDIClipEvents(trackId, clipId, oldEvents, nextEvents, "Generate MIDI velocity line"); + setShowTransformMenu(false); + return; + } + + if (selectedCC === PITCH_BEND_LANE) { + const startSemitones = promptNumber("Start pitch bend in semitones", 0, -pitchBendRangeDown, pitchBendRangeUp); + if (startSemitones === null) return; + const endSemitones = promptNumber("End pitch bend in semitones", 0, -pitchBendRangeDown, pitchBendRangeUp); + if (endSemitones === null) return; + + const oldEvents = getLatestClipEvents(); + const generated = generateControllerLineEvents({ + ...range, + startValue: semitonesToPitchBendValueWithRange(startSemitones, pitchBendRangeUp, pitchBendRangeDown), + endValue: semitonesToPitchBendValueWithRange(endSemitones, pitchBendRangeUp, pitchBendRangeDown), + stepSeconds, + valueMin: 0, + valueMax: MIDI_PITCH_BEND_MAX, + interpolation, + curve, + }).map((point) => ({ + type: "pitchBend" as const, + timestamp: point.time, + value: point.value, + })); + const nextEvents = sortEvents([ + ...oldEvents.filter((event) => !(event.type === "pitchBend" && event.timestamp >= range.startTime && event.timestamp <= range.endTime)), + ...generated, + ]); + commitMIDIClipEvents(trackId, clipId, oldEvents, nextEvents, "Generate MIDI pitch bend line"); + setShowTransformMenu(false); + return; + } + if (selectedScalarMIDIEventType) { + const label = selectedScalarMIDIEventLabel; + const startValue = promptNumber(`Start ${label} value`, 0, 0, 127); + if (startValue === null) return; + const endValue = promptNumber(`End ${label} value`, 127, 0, 127); + if (endValue === null) return; + + const oldEvents = getLatestClipEvents(); + const generated = generateControllerLineEvents({ + ...range, + startValue, + endValue, + stepSeconds, + interpolation, + curve, + }).map((point) => makeSelectedScalarMIDIEvent(point.time, point.value)); + const nextEvents = sortEvents([ + ...oldEvents.filter((event) => !(selectedScalarMIDIEventMatches(event) && event.timestamp >= range.startTime && event.timestamp <= range.endTime)), + ...generated, + ]); + commitMIDIClipEvents(trackId, clipId, oldEvents, nextEvents, `Generate MIDI ${label} line`); + setShowTransformMenu(false); + return; + } + + if (selectedNoteMetadataLaneType) { + const label = selectedNoteMetadataLaneLabel; + const maxValue = noteMetadataLaneMax(selectedNoteMetadataLaneType); + const startValue = promptNumber(`Start ${label} value`, 0, 0, maxValue); + if (startValue === null) return; + const endValue = promptNumber(`End ${label} value`, maxValue, 0, maxValue); + if (endValue === null) return; + + const oldEvents = getLatestClipEvents(); + const duration = Math.max(0.000001, range.endTime - range.startTime); + const nextEvents = parseNotePairs(oldEvents) + .filter((pair) => pair.startTime < range.endTime && pair.startTime + pair.duration > range.startTime) + .reduce((events, pair) => { + const t = clamp((pair.startTime - range.startTime) / duration, 0, 1); + const shaped = interpolation === "step" + ? (t >= 1 ? 1 : 0) + : interpolation === "parabola" + ? t * t + : interpolation === "curve" + ? Math.pow(t, Math.pow(2, -curve * 4)) + : t; + const value = Math.round(startValue + (endValue - startValue) * shaped); + return applyNoteMetadataValueToEvents(events, pair, selectedNoteMetadataLaneType, value); + }, oldEvents); + commitMIDIClipEvents(trackId, clipId, oldEvents, sortEvents(nextEvents), `Generate MIDI ${label} line`); + setShowTransformMenu(false); + return; + } + + const maxCCValue = isCC14BitMode ? MIDI_PITCH_BEND_MAX : 127; + const startValue = promptNumber(`Start CC#${selectedCC} value`, 0, 0, maxCCValue); + if (startValue === null) return; + const endValue = promptNumber(`End CC#${selectedCC} value`, maxCCValue, 0, maxCCValue); + if (endValue === null) return; + + const oldCCEvents = getLatestCCEvents(); + const generatedPoints = generateControllerLineEvents({ + ...range, + startValue, + endValue, + stepSeconds, + valueMax: maxCCValue, + interpolation, + curve, + }); + const generated = isCC14BitMode + ? generatedPoints.flatMap((point) => { + const split = split14BitCCValue(point.value); + return [ + { cc: selectedCC, time: point.time, value: split.msb }, + { cc: selectedCC + 32, time: point.time, value: split.lsb }, + ]; + }) + : generatedPoints.map((point) => ({ + cc: selectedCC, + time: point.time, + value: clamp7Bit(point.value), + })); + const nextCCEvents = [ + ...oldCCEvents.filter((event) => { + const laneMatch = isCC14BitMode + ? event.cc === selectedCC || event.cc === selectedCC + 32 + : event.cc === selectedCC; + return !(laneMatch && event.time >= range.startTime && event.time <= range.endTime); + }), + ...generated, + ].sort((a, b) => a.time - b.time || a.cc - b.cc); + updateMIDICCEvents(trackId, clipId, nextCCEvents, { + oldCCEvents, + description: `Generate MIDI CC#${selectedCC} line`, + }); + setShowTransformMenu(false); + }, [ + clipId, + commitMIDIClipEvents, + getControllerTransformRange, + getLatestCCEvents, + getLatestClipEvents, + isVelocityLaneActive, + pitchBendRangeDown, + pitchBendRangeUp, + promptNumber, + isCC14BitMode, + makeSelectedScalarMIDIEvent, + selectedCC, + selectedNoteMetadataLaneLabel, + selectedNoteMetadataLaneType, + selectedScalarMIDIEventLabel, + selectedScalarMIDIEventMatches, + selectedScalarMIDIEventType, + snapDuration, + trackId, + updateMIDICCEvents, + ]); + + const applyControllerLFO = useCallback((shape: ControllerLFOShape) => { + const range = getControllerTransformRange(); + const rateHz = promptNumber("LFO rate in Hz", 2, 0.01, 40); + if (rateHz === null) return; + const stepSeconds = Math.max(0.005, Math.min(snapDuration, 1 / 64)); + + if (selectedCC === PITCH_BEND_LANE) { + const maxDepthSemitones = Math.min(pitchBendRangeUp, pitchBendRangeDown); + const depthSemitones = promptNumber("Pitch bend LFO depth in semitones", Math.min(1, maxDepthSemitones), 0, maxDepthSemitones); + if (depthSemitones === null) return; + const depthValue = Math.abs(semitonesToPitchBendValueWithRange(depthSemitones, pitchBendRangeUp, pitchBendRangeDown) - MIDI_PITCH_BEND_CENTER); + const oldEvents = getLatestClipEvents(); + const generated = generateControllerLFOEvents({ + ...range, + centerValue: MIDI_PITCH_BEND_CENTER, + depth: depthValue, + rateHz, + shape, + stepSeconds, + valueMin: 0, + valueMax: MIDI_PITCH_BEND_MAX, + }).map((point) => ({ + type: "pitchBend" as const, + timestamp: point.time, + value: point.value, + })); + const nextEvents = sortEvents([ + ...oldEvents.filter((event) => !(event.type === "pitchBend" && event.timestamp >= range.startTime && event.timestamp <= range.endTime)), + ...generated, + ]); + commitMIDIClipEvents(trackId, clipId, oldEvents, nextEvents, `Generate MIDI pitch bend ${shape} LFO`); + setShowTransformMenu(false); + return; + } + if (selectedScalarMIDIEventType) { + const label = selectedScalarMIDIEventLabel; + const centerValue = promptNumber(`${label} LFO center`, 64, 0, 127); + if (centerValue === null) return; + const depth = promptNumber(`${label} LFO depth`, 32, 0, 127); + if (depth === null) return; + const oldEvents = getLatestClipEvents(); + const generated = generateControllerLFOEvents({ + ...range, + centerValue, + depth, + rateHz, + shape, + stepSeconds, + }).map((point) => makeSelectedScalarMIDIEvent(point.time, point.value)); + const nextEvents = sortEvents([ + ...oldEvents.filter((event) => !(selectedScalarMIDIEventMatches(event) && event.timestamp >= range.startTime && event.timestamp <= range.endTime)), + ...generated, + ]); + commitMIDIClipEvents(trackId, clipId, oldEvents, nextEvents, `Generate MIDI ${label} ${shape} LFO`); + setShowTransformMenu(false); + return; + } + + const maxCCValue = isCC14BitMode ? MIDI_PITCH_BEND_MAX : 127; + const centerValue = promptNumber(`CC#${selectedCC} LFO center`, isCC14BitMode ? 8192 : 64, 0, maxCCValue); + if (centerValue === null) return; + const depth = promptNumber(`CC#${selectedCC} LFO depth`, isCC14BitMode ? 4096 : 32, 0, maxCCValue); + if (depth === null) return; + const oldCCEvents = getLatestCCEvents(); + const generatedPoints = generateControllerLFOEvents({ + ...range, + centerValue, + depth, + rateHz, + shape, + stepSeconds, + valueMax: maxCCValue, + }); + const generated = isCC14BitMode + ? generatedPoints.flatMap((point) => { + const split = split14BitCCValue(point.value); + return [ + { cc: selectedCC, time: point.time, value: split.msb }, + { cc: selectedCC + 32, time: point.time, value: split.lsb }, + ]; + }) + : generatedPoints.map((point) => ({ + cc: selectedCC, + time: point.time, + value: clamp7Bit(point.value), + })); + const nextCCEvents = [ + ...oldCCEvents.filter((event) => { + const laneMatch = isCC14BitMode + ? event.cc === selectedCC || event.cc === selectedCC + 32 + : event.cc === selectedCC; + return !(laneMatch && event.time >= range.startTime && event.time <= range.endTime); + }), + ...generated, + ].sort((a, b) => a.time - b.time || a.cc - b.cc); + updateMIDICCEvents(trackId, clipId, nextCCEvents, { + oldCCEvents, + description: `Generate MIDI CC#${selectedCC} ${shape} LFO`, + }); + setShowTransformMenu(false); + }, [ + clipId, + commitMIDIClipEvents, + getControllerTransformRange, + getLatestCCEvents, + getLatestClipEvents, + pitchBendRangeDown, + pitchBendRangeUp, + promptNumber, + isCC14BitMode, + makeSelectedScalarMIDIEvent, + selectedCC, + selectedScalarMIDIEventLabel, + selectedScalarMIDIEventMatches, + selectedScalarMIDIEventType, + snapDuration, + trackId, + updateMIDICCEvents, + ]); + + const thinCurrentControllerLane = useCallback(() => { + const range = getControllerTransformRange(); + + if (selectedCC === PITCH_BEND_LANE) { + const toleranceCents = promptNumber("Pitch bend thinning tolerance in cents", 5, 0, 1200); + if (toleranceCents === null) return; + const toleranceValue = Math.max( + 1, + Math.abs(semitonesToPitchBendValueWithRange(toleranceCents / 100, pitchBendRangeUp, pitchBendRangeDown) - MIDI_PITCH_BEND_CENTER), + ); + const oldEvents = getLatestClipEvents(); + const editablePoints = oldEvents + .filter((event) => event.type === "pitchBend" && event.timestamp >= range.startTime && event.timestamp <= range.endTime) + .map((event) => ({ + time: event.timestamp, + value: event.value ?? event.pitchBend ?? MIDI_PITCH_BEND_CENTER, + })); + if (editablePoints.length <= 2) { + setShowTransformMenu(false); + return; + } + + const thinned = thinControllerEvents(editablePoints, toleranceValue).map((point) => ({ + type: "pitchBend" as const, + timestamp: point.time, + value: point.value, + })); + const nextEvents = sortEvents([ + ...oldEvents.filter((event) => !(event.type === "pitchBend" && event.timestamp >= range.startTime && event.timestamp <= range.endTime)), + ...thinned, + ]); + commitMIDIClipEvents(trackId, clipId, oldEvents, nextEvents, "Thin MIDI pitch bend data"); + setShowTransformMenu(false); + return; + } + if (selectedScalarMIDIEventType) { + const label = selectedScalarMIDIEventLabel; + const tolerance = promptNumber(`${label} thinning tolerance`, 2, 0, 127); + if (tolerance === null) return; + const oldEvents = getLatestClipEvents(); + const editablePoints = oldEvents + .filter((event) => selectedScalarMIDIEventMatches(event) && event.timestamp >= range.startTime && event.timestamp <= range.endTime) + .map((event) => ({ + time: event.timestamp, + value: event.value ?? 0, + })); + if (editablePoints.length <= 2) { + setShowTransformMenu(false); + return; + } + + const thinned = thinControllerEvents(editablePoints, tolerance) + .map((point) => makeSelectedScalarMIDIEvent(point.time, point.value)); + const nextEvents = sortEvents([ + ...oldEvents.filter((event) => !(selectedScalarMIDIEventMatches(event) && event.timestamp >= range.startTime && event.timestamp <= range.endTime)), + ...thinned, + ]); + commitMIDIClipEvents(trackId, clipId, oldEvents, nextEvents, `Thin MIDI ${label} data`); + setShowTransformMenu(false); + return; + } + + const maxCCValue = isCC14BitMode ? MIDI_PITCH_BEND_MAX : 127; + const tolerance = promptNumber(`CC#${selectedCC} thinning tolerance`, isCC14BitMode ? 128 : 2, 0, maxCCValue); + if (tolerance === null) return; + const oldCCEvents = getLatestCCEvents(); + const editablePoints = isCC14BitMode + ? oldCCEvents + .filter((event) => event.cc === selectedCC && event.time >= range.startTime && event.time <= range.endTime) + .map((event) => { + const lsb = oldCCEvents.find((candidate) => + candidate.cc === selectedCC + 32 && Math.abs(candidate.time - event.time) < 0.000001, + ); + return { + time: event.time, + value: combine14BitCCValue(event.value, lsb?.value ?? 0), + }; + }) + : oldCCEvents + .filter((event) => event.cc === selectedCC && event.time >= range.startTime && event.time <= range.endTime) + .map((event) => ({ + time: event.time, + value: event.value, + })); + if (editablePoints.length <= 2) { + setShowTransformMenu(false); + return; + } + + const thinned = isCC14BitMode + ? thinControllerEvents(editablePoints, tolerance).flatMap((point) => { + const split = split14BitCCValue(point.value); + return [ + { cc: selectedCC, time: point.time, value: split.msb }, + { cc: selectedCC + 32, time: point.time, value: split.lsb }, + ]; + }) + : thinControllerEvents(editablePoints, tolerance).map((point) => ({ + cc: selectedCC, + time: point.time, + value: clamp7Bit(point.value), + })); + const nextCCEvents = [ + ...oldCCEvents.filter((event) => { + const laneMatch = isCC14BitMode + ? event.cc === selectedCC || event.cc === selectedCC + 32 + : event.cc === selectedCC; + return !(laneMatch && event.time >= range.startTime && event.time <= range.endTime); + }), + ...thinned, + ].sort((a, b) => a.time - b.time || a.cc - b.cc); + updateMIDICCEvents(trackId, clipId, nextCCEvents, { + oldCCEvents, + description: `Thin MIDI CC#${selectedCC} data`, + }); + setShowTransformMenu(false); + }, [ + clipId, + commitMIDIClipEvents, + getControllerTransformRange, + getLatestCCEvents, + getLatestClipEvents, + pitchBendRangeDown, + pitchBendRangeUp, + promptNumber, + isCC14BitMode, + makeSelectedScalarMIDIEvent, + selectedCC, + selectedScalarMIDIEventLabel, + selectedScalarMIDIEventMatches, + selectedScalarMIDIEventType, + trackId, + updateMIDICCEvents, + ]); + + const transformCurrentControllerLane = useCallback(() => { + const range = getControllerTransformRange(); + + const transformPoints = ( + points: Array<{ time: number; value: number }>, + maxValue: number, + valueAnchor: number, + ) => { + if (points.length === 0) return null; + const timeScalePercent = promptNumber("Time scale percent", 100, 1, 400); + if (timeScalePercent === null) return null; + const valueScalePercent = promptNumber("Value scale percent", 100, 0, 400); + if (valueScalePercent === null) return null; + const valueOffset = promptNumber("Value offset", 0, -maxValue, maxValue); + if (valueOffset === null) return null; + const tilt = promptNumber("Tilt amount", 0, -maxValue, maxValue); + if (tilt === null) return null; + return transformControllerEvents(points, { + timeAnchor: range.startTime, + timeScale: timeScalePercent / 100, + valueAnchor, + valueScale: valueScalePercent / 100, + valueOffset, + tilt, + valueMin: 0, + valueMax: maxValue, + }); + }; + + if (selectedCC === PITCH_BEND_LANE) { + const oldEvents = getLatestClipEvents(); + const editablePoints = oldEvents + .filter((event) => event.type === "pitchBend" && event.timestamp >= range.startTime && event.timestamp <= range.endTime) + .map((event) => ({ + time: event.timestamp, + value: event.value ?? event.pitchBend ?? MIDI_PITCH_BEND_CENTER, + })); + const transformed = transformPoints(editablePoints, MIDI_PITCH_BEND_MAX, MIDI_PITCH_BEND_CENTER); + if (transformed === null) { + setShowTransformMenu(false); + return; + } + + const nextEvents = sortEvents([ + ...oldEvents.filter((event) => !(event.type === "pitchBend" && event.timestamp >= range.startTime && event.timestamp <= range.endTime)), + ...transformed.map((point) => ({ + type: "pitchBend" as const, + timestamp: point.time, + value: point.value, + })), + ]); + commitMIDIClipEvents(trackId, clipId, oldEvents, nextEvents, "Transform MIDI pitch bend lane"); + setShowTransformMenu(false); + return; + } + + if (selectedScalarMIDIEventType) { + const label = selectedScalarMIDIEventLabel; + const oldEvents = getLatestClipEvents(); + const editablePoints = oldEvents + .filter((event) => selectedScalarMIDIEventMatches(event) && event.timestamp >= range.startTime && event.timestamp <= range.endTime) + .map((event) => ({ + time: event.timestamp, + value: event.value ?? 0, + })); + const transformed = transformPoints(editablePoints, 127, 64); + if (transformed === null) { + setShowTransformMenu(false); + return; + } + + const nextEvents = sortEvents([ + ...oldEvents.filter((event) => !(selectedScalarMIDIEventMatches(event) && event.timestamp >= range.startTime && event.timestamp <= range.endTime)), + ...transformed.map((point) => makeSelectedScalarMIDIEvent(point.time, point.value)), + ]); + commitMIDIClipEvents(trackId, clipId, oldEvents, nextEvents, `Transform MIDI ${label} lane`); + setShowTransformMenu(false); + return; + } + + const maxCCValue = isCC14BitMode ? MIDI_PITCH_BEND_MAX : 127; + const oldCCEvents = getLatestCCEvents(); + const editablePoints = isCC14BitMode + ? oldCCEvents + .filter((event) => event.cc === selectedCC && event.time >= range.startTime && event.time <= range.endTime) + .map((event) => { + const lsb = oldCCEvents.find((candidate) => + candidate.cc === selectedCC + 32 && Math.abs(candidate.time - event.time) < 0.000001, + ); + return { + time: event.time, + value: combine14BitCCValue(event.value, lsb?.value ?? 0), + }; + }) + : oldCCEvents + .filter((event) => event.cc === selectedCC && event.time >= range.startTime && event.time <= range.endTime) + .map((event) => ({ + time: event.time, + value: event.value, + })); + const transformed = transformPoints(editablePoints, maxCCValue, isCC14BitMode ? MIDI_PITCH_BEND_CENTER : 64); + if (transformed === null) { + setShowTransformMenu(false); + return; + } + + const transformedEvents = isCC14BitMode + ? transformed.flatMap((point) => { + const split = split14BitCCValue(point.value); + return [ + { cc: selectedCC, time: point.time, value: split.msb }, + { cc: selectedCC + 32, time: point.time, value: split.lsb }, + ]; + }) + : transformed.map((point) => ({ + cc: selectedCC, + time: point.time, + value: clamp7Bit(point.value), + })); + const nextCCEvents = [ + ...oldCCEvents.filter((event) => { + const laneMatch = isCC14BitMode + ? event.cc === selectedCC || event.cc === selectedCC + 32 + : event.cc === selectedCC; + return !(laneMatch && event.time >= range.startTime && event.time <= range.endTime); + }), + ...transformedEvents, + ].sort((a, b) => a.time - b.time || a.cc - b.cc); + updateMIDICCEvents(trackId, clipId, nextCCEvents, { + oldCCEvents, + description: `Transform MIDI ${isCC14BitMode ? `14-bit CC#${selectedCC}/${selectedCC + 32}` : `CC#${selectedCC}`} lane`, + }); + setShowTransformMenu(false); + }, [ + clipId, + commitMIDIClipEvents, + getControllerTransformRange, + getLatestCCEvents, + getLatestClipEvents, + isCC14BitMode, + makeSelectedScalarMIDIEvent, + promptNumber, + selectedCC, + selectedScalarMIDIEventLabel, + selectedScalarMIDIEventMatches, + selectedScalarMIDIEventType, + trackId, + updateMIDICCEvents, + ]); + + const openControllerLineDialog = useCallback(() => { + const endValue = selectedNoteMetadataLaneType + ? noteMetadataLaneMax(selectedNoteMetadataLaneType) + : selectedCC === PITCH_BEND_LANE + ? 0 + : (isCC14BitMode ? MIDI_PITCH_BEND_MAX : 127); + setShowTransformMenu(false); + setControllerDialog({ + type: "line", + interpolation: "linear", + curve: 0.5, + startValue: selectedCC === PITCH_BEND_LANE ? 0 : 0, + endValue, + }); + }, [isCC14BitMode, selectedCC, selectedNoteMetadataLaneType]); + + const openControllerLFODialog = useCallback((shape: ControllerLFOShape) => { + if (selectedNoteMetadataLaneType) return; + const maxDepthSemitones = Math.min(pitchBendRangeUp, pitchBendRangeDown); + setShowTransformMenu(false); + setControllerDialog({ + type: "lfo", + shape, + rateHz: 2, + centerValue: selectedCC === PITCH_BEND_LANE ? 0 : (isCC14BitMode ? MIDI_PITCH_BEND_CENTER : 64), + depth: selectedCC === PITCH_BEND_LANE ? Math.min(1, maxDepthSemitones) : (isCC14BitMode ? 4096 : 32), + }); + }, [isCC14BitMode, pitchBendRangeDown, pitchBendRangeUp, selectedCC, selectedNoteMetadataLaneType]); + + const openControllerThinDialog = useCallback(() => { + if (selectedNoteMetadataLaneType) return; + setShowTransformMenu(false); + setControllerDialog({ + type: "thin", + tolerance: selectedCC === PITCH_BEND_LANE ? 5 : (isCC14BitMode ? 128 : 2), + }); + }, [isCC14BitMode, selectedCC, selectedNoteMetadataLaneType]); + + const openControllerTransformDialog = useCallback(() => { + if (selectedNoteMetadataLaneType) return; + setShowTransformMenu(false); + setControllerDialog({ + type: "transform", + timeScalePercent: 100, + valueScalePercent: 100, + valueOffset: 0, + tilt: 0, + }); + }, [selectedNoteMetadataLaneType]); + + const submitControllerDialog = useCallback(() => { + if (!controllerDialog) return; + + if (controllerDialog.type === "line") { + controllerLineModeOverrideRef.current = controllerDialog.interpolation; + controllerPromptQueueRef.current = controllerDialog.interpolation === "curve" + ? [controllerDialog.curve, controllerDialog.startValue, controllerDialog.endValue] + : [controllerDialog.startValue, controllerDialog.endValue]; + applyControllerLine(); + } else if (controllerDialog.type === "lfo") { + controllerPromptQueueRef.current = [ + controllerDialog.rateHz, + ...(selectedCC === PITCH_BEND_LANE + ? [controllerDialog.depth] + : [controllerDialog.centerValue, controllerDialog.depth]), + ]; + applyControllerLFO(controllerDialog.shape); + } else if (controllerDialog.type === "thin") { + controllerPromptQueueRef.current = [controllerDialog.tolerance]; + thinCurrentControllerLane(); + } else { + controllerPromptQueueRef.current = [ + controllerDialog.timeScalePercent, + controllerDialog.valueScalePercent, + controllerDialog.valueOffset, + controllerDialog.tilt, + ]; + transformCurrentControllerLane(); + } + + controllerPromptQueueRef.current = []; + controllerLineModeOverrideRef.current = null; + setControllerDialog(null); + }, [ + applyControllerLFO, + applyControllerLine, + controllerDialog, + selectedCC, + thinCurrentControllerLane, + transformCurrentControllerLane, + ]); + + const copyCurrentControllerLane = useCallback(() => { + const range = getControllerTransformRange(); + + if (selectedCC === PITCH_BEND_LANE) { + const points = getLatestClipEvents() + .filter((event) => event.type === "pitchBend" && event.timestamp >= range.startTime && event.timestamp <= range.endTime) + .map((event) => ({ + time: event.timestamp - range.startTime, + valueFraction: clamp((event.value ?? event.pitchBend ?? MIDI_PITCH_BEND_CENTER) / MIDI_PITCH_BEND_MAX, 0, 1), + })); + if (points.length > 0) { + setControllerLaneClipboard({ + sourceLabel: "Pitch Bend", + duration: Math.max(snapDuration, range.endTime - range.startTime), + points, + }); + } + setShowTransformMenu(false); + return; + } + + if (selectedScalarMIDIEventType) { + const label = selectedScalarMIDIEventLabel; + const points = getLatestClipEvents() + .filter((event) => selectedScalarMIDIEventMatches(event) && event.timestamp >= range.startTime && event.timestamp <= range.endTime) + .map((event) => ({ + time: event.timestamp - range.startTime, + valueFraction: clamp((event.value ?? 0) / 127, 0, 1), + })); + if (points.length > 0) { + setControllerLaneClipboard({ + sourceLabel: label, + duration: Math.max(snapDuration, range.endTime - range.startTime), + points, + }); + } + setShowTransformMenu(false); + return; + } + + if (selectedNoteMetadataLaneType) { + const maxValue = noteMetadataLaneMax(selectedNoteMetadataLaneType); + const points = parseNotePairs(getLatestClipEvents()) + .filter((pair) => pair.startTime < range.endTime && pair.startTime + pair.duration > range.startTime) + .map((pair) => ({ + time: Math.max(0, pair.startTime - range.startTime), + valueFraction: clamp(noteMetadataValueForPair(pair, selectedNoteMetadataLaneType) / maxValue, 0, 1), + })); + if (points.length > 0) { + setControllerLaneClipboard({ + sourceLabel: selectedNoteMetadataLaneLabel, + duration: Math.max(snapDuration, range.endTime - range.startTime), + points, + }); + } + setShowTransformMenu(false); + return; + } + + const oldCCEvents = getLatestCCEvents(); + if (isCC14BitMode) { + const points = oldCCEvents + .filter((event) => event.cc === selectedCC && event.time >= range.startTime && event.time <= range.endTime) + .map((event) => { + const lsb = oldCCEvents.find((candidate) => + candidate.cc === selectedCC + 32 && Math.abs(candidate.time - event.time) < 0.000001, + ); + return { + time: event.time - range.startTime, + valueFraction: combine14BitCCValue(event.value, lsb?.value ?? 0) / MIDI_PITCH_BEND_MAX, + }; + }); + if (points.length > 0) { + setControllerLaneClipboard({ + sourceLabel: `14-bit CC#${selectedCC}/${selectedCC + 32}`, + duration: Math.max(snapDuration, range.endTime - range.startTime), + points, + }); + } + setShowTransformMenu(false); + return; + } + + const points = oldCCEvents + .filter((event) => event.cc === selectedCC && event.time >= range.startTime && event.time <= range.endTime) + .map((event) => ({ + time: event.time - range.startTime, + valueFraction: clamp(event.value / 127, 0, 1), + })); + if (points.length > 0) { + setControllerLaneClipboard({ + sourceLabel: `CC#${selectedCC}`, + duration: Math.max(snapDuration, range.endTime - range.startTime), + points, + }); + } + setShowTransformMenu(false); + }, [ + getControllerTransformRange, + getLatestCCEvents, + getLatestClipEvents, + isCC14BitMode, + selectedNoteMetadataLaneLabel, + selectedNoteMetadataLaneType, + selectedCC, + selectedScalarMIDIEventLabel, + selectedScalarMIDIEventMatches, + selectedScalarMIDIEventType, + snapDuration, + ]); + + const pasteControllerLaneClipboard = useCallback(() => { + if (!controllerLaneClipboard) return; + const range = getControllerTransformRange(); + const pasteStart = range.startTime; + const pasteEnd = pasteStart + controllerLaneClipboard.duration; + + if (selectedCC === PITCH_BEND_LANE) { + const oldEvents = getLatestClipEvents(); + const pasted = controllerLaneClipboard.points.map((point) => ({ + type: "pitchBend" as const, + timestamp: pasteStart + point.time, + value: clamp(Math.round(point.valueFraction * MIDI_PITCH_BEND_MAX), 0, MIDI_PITCH_BEND_MAX), + })); + const nextEvents = sortEvents([ + ...oldEvents.filter((event) => !(event.type === "pitchBend" && event.timestamp >= pasteStart && event.timestamp <= pasteEnd)), + ...pasted, + ]); + commitMIDIClipEvents(trackId, clipId, oldEvents, nextEvents, `Paste controller lane to pitch bend`); + setShowTransformMenu(false); + return; + } + + if (selectedScalarMIDIEventType) { + const oldEvents = getLatestClipEvents(); + const label = selectedScalarMIDIEventLabel; + const pasted = controllerLaneClipboard.points + .map((point) => makeSelectedScalarMIDIEvent(pasteStart + point.time, point.valueFraction * 127)); + const nextEvents = sortEvents([ + ...oldEvents.filter((event) => !(selectedScalarMIDIEventMatches(event) && event.timestamp >= pasteStart && event.timestamp <= pasteEnd)), + ...pasted, + ]); + commitMIDIClipEvents(trackId, clipId, oldEvents, nextEvents, `Paste controller lane to MIDI ${label}`); + setShowTransformMenu(false); + return; + } + + if (selectedNoteMetadataLaneType) { + const oldEvents = getLatestClipEvents(); + const maxValue = noteMetadataLaneMax(selectedNoteMetadataLaneType); + const pairs = parseNotePairs(oldEvents); + const nextEvents = controllerLaneClipboard.points.reduce((events, point) => { + const targetTime = pasteStart + point.time; + const pair = pairs.find((candidate) => + targetTime >= candidate.startTime - 0.000001 + && targetTime <= candidate.startTime + candidate.duration + 0.000001, + ); + if (!pair) return events; + return applyNoteMetadataValueToEvents(events, pair, selectedNoteMetadataLaneType, point.valueFraction * maxValue); + }, oldEvents); + commitMIDIClipEvents(trackId, clipId, oldEvents, sortEvents(nextEvents), `Paste controller lane to MIDI ${selectedNoteMetadataLaneLabel}`); + setShowTransformMenu(false); + return; + } + + const oldCCEvents = getLatestCCEvents(); + const pasted = isCC14BitMode + ? controllerLaneClipboard.points.flatMap((point) => { + const split = split14BitCCValue(point.valueFraction * MIDI_PITCH_BEND_MAX); + const time = pasteStart + point.time; + return [ + { cc: selectedCC, time, value: split.msb }, + { cc: selectedCC + 32, time, value: split.lsb }, + ]; + }) + : controllerLaneClipboard.points.map((point) => ({ + cc: selectedCC, + time: pasteStart + point.time, + value: clamp7Bit(point.valueFraction * 127), + })); + const nextCCEvents = [ + ...oldCCEvents.filter((event) => { + const laneMatch = isCC14BitMode + ? event.cc === selectedCC || event.cc === selectedCC + 32 + : event.cc === selectedCC; + return !(laneMatch && event.time >= pasteStart && event.time <= pasteEnd); + }), + ...pasted, + ].sort((a, b) => a.time - b.time || a.cc - b.cc); + updateMIDICCEvents(trackId, clipId, nextCCEvents, { + oldCCEvents, + description: `Paste controller lane to ${isCC14BitMode ? `14-bit CC#${selectedCC}/${selectedCC + 32}` : `CC#${selectedCC}`}`, + }); + setShowTransformMenu(false); + }, [ + clipId, + commitMIDIClipEvents, + controllerLaneClipboard, + getControllerTransformRange, + getLatestCCEvents, + getLatestClipEvents, + isCC14BitMode, + makeSelectedScalarMIDIEvent, + selectedCC, + selectedNoteMetadataLaneLabel, + selectedNoteMetadataLaneType, + selectedScalarMIDIEventLabel, + selectedScalarMIDIEventMatches, + selectedScalarMIDIEventType, + trackId, + updateMIDICCEvents, + ]); + + const clearCurrentControllerLane = useCallback(() => { + const range = getControllerTransformRange(); + if (selectedCC === PITCH_BEND_LANE) { + const oldEvents = getLatestClipEvents(); + const nextEvents = sortEvents(oldEvents.filter( + (event) => !(event.type === "pitchBend" && event.timestamp >= range.startTime && event.timestamp <= range.endTime), + )); + commitMIDIClipEvents(trackId, clipId, oldEvents, nextEvents, "Clear MIDI pitch bend lane"); + setShowTransformMenu(false); + return; + } + if (selectedScalarMIDIEventType) { + const oldEvents = getLatestClipEvents(); + const nextEvents = sortEvents(oldEvents.filter( + (event) => !(selectedScalarMIDIEventMatches(event) && event.timestamp >= range.startTime && event.timestamp <= range.endTime), + )); + commitMIDIClipEvents(trackId, clipId, oldEvents, nextEvents, `Clear MIDI ${selectedScalarMIDIEventLabel} lane`); + setShowTransformMenu(false); + return; + } + if (selectedNoteMetadataLaneType) { + const oldEvents = getLatestClipEvents(); + const defaultValue = selectedNoteMetadataLaneType === "chance" ? 100 : 0; + const nextEvents = parseNotePairs(oldEvents) + .filter((pair) => pair.startTime < range.endTime && pair.startTime + pair.duration > range.startTime) + .reduce( + (events, pair) => applyNoteMetadataValueToEvents(events, pair, selectedNoteMetadataLaneType, defaultValue), + oldEvents, + ); + commitMIDIClipEvents(trackId, clipId, oldEvents, sortEvents(nextEvents), `Clear MIDI ${selectedNoteMetadataLaneLabel} lane`); + setShowTransformMenu(false); + return; + } + + const oldCCEvents = getLatestCCEvents(); + const nextCCEvents = oldCCEvents.filter((event) => { + const laneMatch = isCC14BitMode + ? event.cc === selectedCC || event.cc === selectedCC + 32 + : event.cc === selectedCC; + return !(laneMatch && event.time >= range.startTime && event.time <= range.endTime); + }); + updateMIDICCEvents(trackId, clipId, nextCCEvents, { + oldCCEvents, + description: `Clear MIDI ${isCC14BitMode ? `14-bit CC#${selectedCC}/${selectedCC + 32}` : `CC#${selectedCC}`} lane`, + }); + setShowTransformMenu(false); + }, [ + clipId, + commitMIDIClipEvents, + getControllerTransformRange, + getLatestCCEvents, + getLatestClipEvents, + isCC14BitMode, + selectedCC, + selectedNoteMetadataLaneLabel, + selectedNoteMetadataLaneType, + selectedScalarMIDIEventLabel, + selectedScalarMIDIEventMatches, + selectedScalarMIDIEventType, + trackId, + updateMIDICCEvents, + ]); + + const handleVelocityMouseDown = useCallback((event: KonvaEvent) => { + const pos = getPointer(event); + if (!pos || pos.y < velocityLaneY || pos.y >= velocityLaneY + velocityLaneHeight) return; + + const pair = notePairs.find((candidate) => { + const barX = PIANO_WIDTH + candidate.startTime * pixelsPerSecond - scrollX; + const barWidth = Math.max(4, candidate.duration * pixelsPerSecond); + return pos.x >= barX && pos.x <= barX + barWidth; + }); + if (!pair) return; + + const id = noteIdFor(clipId, pair.startTime, pair.noteNumber); + const velocity = getVelocityFromLaneY(pos.y); + setSelectedNoteIds([id]); + setVelocityEdit({ + noteId: id, + timestamp: pair.startTime, + noteNumber: pair.noteNumber, + originalEvents: getLatestClipEvents(), + }); + updateMIDINoteVelocity(trackId, clipId, pair.startTime, pair.noteNumber, velocity, { transient: true }); + auditionNote(pair.noteNumber, velocity, { throttle: true }); + }, [ + auditionNote, + clipId, + getLatestClipEvents, + getVelocityFromLaneY, + notePairs, + pixelsPerSecond, + scrollX, + trackId, + updateMIDINoteVelocity, + velocityLaneHeight, + velocityLaneY, + ]); + + const handleCCMouseDown = useCallback((event: KonvaEvent) => { + const pos = getPointer(event); + if (!pos || pos.y < ccLaneY || pos.y >= ccLaneY + ccLaneHeight) return; + const originalEvents = getLatestClipEvents(); const originalCCEvents = getLatestCCEvents(); + + if (selectedNoteMetadataLaneType) { + const newEvents = upsertNoteMetadataLaneValue(pos.x, pos.y, originalEvents); + const nextDrawState: CCDrawState = { lane: "noteMetadata", originalCCEvents, originalEvents }; + ccDrawStateRef.current = nextDrawState; + setCCDrawState(nextDrawState); + previewMIDIClipEvents(trackId, clipId, newEvents); + return; + } + if (selectedCC === PITCH_BEND_LANE) { + const newEvents = upsertPitchBendEvent(pos.x, pos.y, originalEvents); + const nextDrawState: CCDrawState = { lane: "pitchBend", originalCCEvents, originalEvents }; + ccDrawStateRef.current = nextDrawState; + setCCDrawState(nextDrawState); + previewMIDIClipEvents(trackId, clipId, newEvents); + return; + } + if (selectedScalarMIDIEventType) { + const newEvents = upsertScalarMIDIEvent(pos.x, pos.y, originalEvents); + const nextDrawState: CCDrawState = { lane: "midiEvent", originalCCEvents, originalEvents }; + ccDrawStateRef.current = nextDrawState; + setCCDrawState(nextDrawState); + previewMIDIClipEvents(trackId, clipId, newEvents); + return; + } + const newEvents = upsertCCEvent(pos.x, pos.y, originalCCEvents); - setCCDrawState({ originalCCEvents }); + const nextDrawState: CCDrawState = { lane: "cc", originalCCEvents, originalEvents }; + ccDrawStateRef.current = nextDrawState; + setCCDrawState(nextDrawState); updateMIDICCEvents(trackId, clipId, newEvents, { transient: true }); - }, [ccLaneY, clipId, getLatestCCEvents, trackId, updateMIDICCEvents, upsertCCEvent]); + }, [ + ccLaneHeight, + ccLaneY, + clipId, + getLatestCCEvents, + getLatestClipEvents, + previewMIDIClipEvents, + selectedCC, + selectedNoteMetadataLaneType, + selectedScalarMIDIEventType, + trackId, + updateMIDICCEvents, + upsertCCEvent, + upsertNoteMetadataLaneValue, + upsertPitchBendEvent, + upsertScalarMIDIEvent, + ]); const updateDragPreview = useCallback((event: KonvaEvent) => { if (!dragState) return; @@ -822,18 +2758,16 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll const pointerNote = getNoteFromY(pos.y); const deltaTime = pointerTime - dragState.startPointerTime; const deltaNote = pointerNote - dragState.startPointerNote; - const clipLimit = Math.max(0.01, clipDuration); - const { events: nextEvents, nextIds, auditionPair } = transformSelectedEvents( + const { events: nextEvents, nextIds, auditionPair } = rebuildMIDIEventsForNotes( dragState.originalEvents, clipId, dragState.noteIds, (pair) => { if (dragState.mode === "move") { - const maxStart = Math.max(0, clipLimit - pair.duration); return { ...pair, - startTime: clamp(pair.startTime + deltaTime, 0, maxStart), + startTime: Math.max(0, pair.startTime + deltaTime), noteNumber: clamp(pair.noteNumber + deltaNote, 0, 127), }; } @@ -848,7 +2782,7 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll }; } - const nextEnd = clamp(pointerTime, pair.startTime + snapDuration, clipLimit); + const nextEnd = Math.max(pair.startTime + snapDuration, pointerTime); return { ...pair, duration: nextEnd - pair.startTime, @@ -882,15 +2816,166 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll event.cancelBubble = true; const nativeEvent = event.evt as MouseEvent; const id = noteIdFor(clipId, pair.startTime, pair.noteNumber); + const pos = getPointer(event); + const noteX = PIANO_WIDTH + pair.startTime * pixelsPerSecond - scrollX; + const noteWidth = Math.max(4, pair.duration * pixelsPerSecond); + const relX = pos ? pos.x - noteX : noteWidth / 2; + const edge = relX <= NOTE_EDGE_HIT_WIDTH + ? "start" + : noteWidth - relX <= NOTE_EDGE_HIT_WIDTH + ? "end" + : "body"; + const gestureKind = gestureKindForHit(tool, { kind: "note", noteId: id, edge }); + if (gestureKind && pos) { + gestureSessionRef.current = { + kind: gestureKind, + tool, + target: { kind: "note", noteId: id, edge }, + startX: pos.x, + startY: pos.y, + currentX: pos.x, + currentY: pos.y, + }; + } if (tool === "erase") { removeMIDINotes(trackId, clipId, [id]); - setSelectedNoteIds((previous) => previous.filter((noteId) => noteId !== id)); + setSelectedNoteIds(selectedNoteIds.filter((noteId) => noteId !== id)); + stopAudition(); + return; + } + + if (tool === "mute") { + setSelectedNoteIds([id]); + toggleSelectedMIDINoteMute(trackId, clipId); stopAudition(); return; } + if (tool === "glue") { + const nextSelection = selectedNoteIds.includes(id) ? selectedNoteIds : [id]; + setSelectedNoteIds(nextSelection); + const oldEvents = getLatestClipEvents(); + const selected = new Set(nextSelection); + const pairsToGlue = parseNotePairs(oldEvents).filter((candidate) => + selected.has(noteIdFor(clipId, candidate.startTime, candidate.noteNumber)), + ); + const groups = new Map(); + pairsToGlue.forEach((candidate) => { + const key = `${candidate.noteNumber}:${candidate.channel ?? 1}`; + groups.set(key, [...(groups.get(key) || []), candidate]); + }); + const consumed = new Set(); + const additions: MIDIEvent[] = []; + const nextIds: string[] = []; + + groups.forEach((group) => { + if (group.length < 2) return; + const sortedGroup = [...group].sort((a, b) => a.startTime - b.startTime); + sortedGroup.forEach((candidate) => { + consumed.add(candidate.noteOn); + consumed.add(candidate.noteOff); + }); + const first = sortedGroup[0]; + const last = sortedGroup[sortedGroup.length - 1]; + const startTime = first.startTime; + const endTime = Math.max(...sortedGroup.map((candidate) => candidate.startTime + candidate.duration)); + const releaseVelocity = last.releaseVelocity ?? last.noteOff.releaseVelocity ?? last.noteOff.velocity ?? 0; + additions.push( + { ...first.noteOn, timestamp: startTime }, + { + ...last.noteOff, + timestamp: endTime, + note: first.noteNumber, + channel: first.channel ?? 1, + velocity: releaseVelocity, + releaseVelocity, + }, + ); + nextIds.push(noteIdFor(clipId, startTime, first.noteNumber)); + }); + + if (additions.length > 0) { + commitMIDIClipEvents( + trackId, + clipId, + oldEvents, + sortEvents([...oldEvents.filter((event) => !consumed.has(event)), ...additions]), + "Glue MIDI notes", + ); + setSelectedNoteIds(nextIds); + return; + } + legatoSelectedMIDINotes(trackId, clipId); + return; + } + + if (tool === "split") { + if (!pos) return; + const splitTime = snapTime(getTimeFromX(pos.x)); + const noteEnd = pair.startTime + pair.duration; + if (splitTime <= pair.startTime + 0.01 || splitTime >= noteEnd - 0.01) return; + const oldEvents = getLatestClipEvents(); + const pairChannel = pair.channel ?? 1; + const nextEvents = sortEvents([ + ...oldEvents.filter((event) => { + if (event.note !== pair.noteNumber || (event.channel ?? 1) !== pairChannel) return true; + if (event.type === "noteOn" && Math.abs(event.timestamp - pair.startTime) < 0.000001) return false; + if (event.type === "noteOff" && Math.abs(event.timestamp - noteEnd) < 0.000001) return false; + return true; + }), + { ...pair.noteOn, timestamp: pair.startTime }, + { ...pair.noteOff, timestamp: splitTime, velocity: pair.releaseVelocity ?? pair.noteOff.velocity ?? 0 }, + { ...pair.noteOn, timestamp: splitTime }, + { ...pair.noteOff, timestamp: noteEnd, velocity: pair.releaseVelocity ?? pair.noteOff.velocity ?? 0 }, + ]); + commitMIDIClipEvents(trackId, clipId, oldEvents, nextEvents, "Split MIDI note"); + setSelectedNoteIds([ + noteIdFor(clipId, pair.startTime, pair.noteNumber), + noteIdFor(clipId, splitTime, pair.noteNumber), + ]); + auditionNote(pair.noteNumber, pair.velocity); + return; + } + + if (tool === "range") { + if (!pos) return; + setSelectedNoteIds([]); + setRangeDragState({ + startX: pos.x, + startY: pos.y, + currentX: pos.x, + currentY: pos.y, + }); + return; + } + const modifier = nativeEvent.ctrlKey || nativeEvent.metaKey || nativeEvent.shiftKey; + if ( + tool === "select" + && (nativeEvent.ctrlKey || nativeEvent.metaKey) + && selectedNoteIds.includes(id) + && selectedNoteIds.length > 0 + ) { + if (!pos) return; + const nextIds = duplicateSelectedMIDINotes(trackId, clipId); + if (nextIds.length === 0) return; + setSelectedNoteIds(nextIds); + setDragState({ + mode: "move", + noteIds: nextIds, + originalEvents: getLatestClipEvents(), + startPointerTime: snapTime(getTimeFromX(pos.x)), + startPointerNote: pair.noteNumber, + activeNoteId: nextIds[0], + }); + latestDragAuditionRef.current = { + noteNumber: pair.noteNumber, + velocity: pair.velocity, + }; + return; + } + let nextSelection = selectedNoteIds; if (modifier) { nextSelection = selectedNoteIds.includes(id) @@ -903,13 +2988,9 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll setSelectedNoteIds(nextSelection); auditionNote(pair.noteNumber, pair.velocity); - if (tool !== "select" || !nextSelection.includes(id)) return; + if ((tool !== "select" && tool !== "trim") || !nextSelection.includes(id)) return; - const pos = getPointer(event); if (!pos) return; - const noteX = PIANO_WIDTH + pair.startTime * pixelsPerSecond - scrollX; - const noteWidth = Math.max(4, pair.duration * pixelsPerSecond); - const relX = pos.x - noteX; const mode: DragMode = relX <= NOTE_EDGE_HIT_WIDTH ? "resize-start" @@ -932,66 +3013,323 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll }, [ auditionNote, clipId, + duplicateSelectedMIDINotes, + commitMIDIClipEvents, getLatestClipEvents, getTimeFromX, + legatoSelectedMIDINotes, pixelsPerSecond, removeMIDINotes, scrollX, selectedNoteIds, + setSelectedNoteIds, + setRangeDragState, snapTime, stopAudition, tool, + toggleSelectedMIDINoteMute, trackId, ]); + const handleNoteContextMenu = useCallback((event: KonvaEvent, pair: NotePair) => { + event.evt.preventDefault(); + event.cancelBubble = true; + if (shouldSuppressWorkspaceContextMenu(event.evt.target)) return; + const id = noteIdFor(clipId, pair.startTime, pair.noteNumber); + if (!selectedNoteIds.includes(id)) { + setSelectedNoteIds([id]); + } + setPianoRollEditCursorTime(pair.startTime); + setContextMenu({ + x: event.evt.clientX, + y: event.evt.clientY, + kind: "note", + noteId: id, + noteNumber: pair.noteNumber, + time: pair.startTime, + }); + }, [ + clipId, + selectedNoteIds, + setPianoRollEditCursorTime, + setSelectedNoteIds, + ]); + const handleStageMouseDown = useCallback((event: KonvaEvent) => { const pos = getPointer(event); if (!pos) return; + const hitTarget = hitTestPianoRoll(pos.x, pos.y, { + pianoWidth: PIANO_WIDTH, + noteGridHeight, + velocityLaneY, + velocityLaneHeight: isVelocityLaneActive ? velocityLaneHeight : 0, + controllerLaneY: ccLaneY, + controllerLaneHeight: activeControllerLane ? ccLaneHeight : 0, + noteEdgeHitWidth: NOTE_EDGE_HIT_WIDTH, + timeFromX: getTimeFromX, + noteFromY: getNoteFromY, + notes: pianoRollHitNotes, + lanes: pianoRollHitLanes, + controllerEvents: pianoRollControllerHitEvents, + loopStartX: loopBoundaryStartX, + loopEndX: loopBoundaryEndX, + loopBoundaryHitWidth: 6, + }); + + if (hitTarget.kind === "lane-header") { + const lane = visibleLanes.find((candidate) => candidate.id === hitTarget.laneId) + ?? (hitTarget.laneId === "velocity" ? visibleLanes.find((candidate) => candidate.kind === "velocity") : undefined); + if (lane) selectControllerLane(lane); + return; + } + + if (hitTarget.kind === "lane-resize") { + const lane = visibleLanes.find((candidate) => candidate.id === hitTarget.laneId) + ?? (hitTarget.laneId === "velocity" ? visibleLanes.find((candidate) => candidate.kind === "velocity") : undefined); + if (!lane) return; + setLaneResizeState({ + laneId: lane.id, + laneKind: lane.kind, + startY: pos.y, + originalHeight: lane.kind === "velocity" ? velocityLaneHeight : ccLaneHeight, + }); + return; + } + + if (hitTarget.kind === "loop-boundary" && clip?.loopEnabled) { + const currentLoopOffset = clip.loopOffset ?? clip.offset ?? 0; + const currentLoopLength = clip.loopLength ?? clip.sourceLength ?? clipDuration; + setLoopBoundaryDrag({ + edge: hitTarget.edge, + startX: pos.x, + currentX: pos.x, + initialLoopOffset: currentLoopOffset, + initialLoopLength: currentLoopLength, + }); + return; + } + + if ( + (hitTarget.kind === "controller-lane" || hitTarget.kind === "controller-node" || hitTarget.kind === "controller-segment") + && hitTarget.laneId + ) { + const lane = visibleLanes.find((candidate) => candidate.id === hitTarget.laneId); + if (lane && lane.id !== activeLane?.id) selectControllerLane(lane); + } + + const gestureKind = gestureKindForHit(tool, hitTarget); + if (gestureKind) { + gestureSessionRef.current = { + kind: gestureKind, + tool, + target: hitTarget, + startX: pos.x, + startY: pos.y, + currentX: pos.x, + currentY: pos.y, + }; + } + + if (tool === "zoom") { + const nativeEvent = event.evt as MouseEvent; + const direction = nativeEvent.altKey ? -1 : 1; + const cursorGridX = clamp(pos.x - PIANO_WIDTH, 0, visibleGridWidth); + const projectTimeAtCursor = (cursorGridX + timelineScrollX) / pixelsPerSecond; + const nextPixelsPerSecond = clamp(pixelsPerSecond * (direction > 0 ? 1.18 : 0.85), 1, 1000); + const nextContentWidth = Math.max(visibleGridWidth, (clipStartTime + contentDuration) * nextPixelsPerSecond); + const nextMaxScrollX = Math.max(0, nextContentWidth - visibleGridWidth); + setTimelineZoom(nextPixelsPerSecond); + setTimelineScroll(clamp(projectTimeAtCursor * nextPixelsPerSecond - cursorGridX, 0, nextMaxScrollX), timelineScrollY); + return; + } + + if (tool === "pan") { + const nextPanDrag = { + startX: pos.x, + startY: pos.y, + originalScrollX: timelineScrollX, + originalScrollY: scrollY, + }; + panDragRef.current = nextPanDrag; + setPanDragState(nextPanDrag); + return; + } + + const isControllerTarget = hitTarget.kind === "controller-lane" + || hitTarget.kind === "controller-node" + || hitTarget.kind === "controller-segment"; - if (pos.y >= ccLaneY && pos.y < ccLaneY + CC_LANE_HEIGHT) { + if (tool === "line" && isControllerTarget) { + openControllerLineDialog(); + return; + } + + if (isControllerTarget) { handleCCMouseDown(event); return; } - if (pos.y >= velocityLaneY && pos.y < velocityLaneY + VELOCITY_LANE_HEIGHT) { + if (hitTarget.kind === "velocity-lane") { handleVelocityMouseDown(event); return; } - if (pos.x < PIANO_WIDTH || pos.y >= noteGridHeight) return; + const isNoteGridCaptureTarget = hitTarget.kind === "grid" + || hitTarget.kind === "note" + || hitTarget.kind === "loop-boundary"; + if (!isNoteGridCaptureTarget) return; - const time = Math.min(clipDuration, snapTime(getTimeFromX(pos.x))); - const note = getNoteFromY(pos.y); + const time = snapTime(hitTarget.kind === "grid" ? hitTarget.time : getTimeFromX(pos.x)); + const note = hitTarget.kind === "grid" ? hitTarget.noteNumber : getNoteFromY(pos.y); + setPianoRollEditCursorTime(time); + + const nativeEvent = event.evt as MouseEvent; + const selectionModifier = nativeEvent.shiftKey || nativeEvent.ctrlKey || nativeEvent.metaKey; - if (tool === "draw") { + if (tool === "range") { setSelectedNoteIds([]); + setRangeDragState({ + startX: pos.x, + startY: pos.y, + currentX: pos.x, + currentY: pos.y, + }); + return; + } + + if (tool === "draw" && !selectionModifier) { + setSelectedNoteIds([]); + clearMIDIEditRange(); setDrawingState({ startTime: time, - endTime: Math.min(clipDuration, time + snapDuration), + endTime: time + snapDuration, noteNumber: note, - velocity: 80, + velocity: pianoRollInsertVelocity, }); return; } - if (tool === "select") { - setSelectedNoteIds([]); + if (tool === "select" || selectionModifier) { + const mode = nativeEvent.shiftKey + ? "toggle" + : nativeEvent.ctrlKey || nativeEvent.metaKey + ? "add" + : "replace"; + clearMIDIEditRange(); + if (mode === "replace") setSelectedNoteIds([]); + setMarqueeState({ + startX: pos.x, + startY: pos.y, + currentX: pos.x, + currentY: pos.y, + mode, + }); } }, [ + ccLaneHeight, ccLaneY, + clearMIDIEditRange, + clip, clipDuration, + contentDuration, + clipStartTime, + activeLane, + activeControllerLane, getNoteFromY, getTimeFromX, handleCCMouseDown, handleVelocityMouseDown, + isVelocityLaneActive, + loopBoundaryEndX, + loopBoundaryStartX, + openControllerLineDialog, noteGridHeight, + pianoRollControllerHitEvents, + pianoRollHitLanes, + pianoRollHitNotes, + pianoRollInsertVelocity, + pixelsPerSecond, + scrollY, + selectControllerLane, + setTimelineScroll, + setTimelineZoom, snapDuration, snapTime, + stageWidth, + setPianoRollEditCursorTime, + setLoopBoundaryDrag, + setSelectedNoteIds, tool, + visibleLanes, + visibleGridWidth, + velocityLaneHeight, velocityLaneY, + timelineScrollX, + timelineScrollY, + ]); + + const handleStageContextMenu = useCallback((event: KonvaEvent) => { + const pos = getPointer(event); + if (!pos || pos.x < PIANO_WIDTH || pos.y >= noteGridHeight) return; + event.evt.preventDefault(); + event.cancelBubble = true; + if (shouldSuppressWorkspaceContextMenu(event.evt.target)) return; + const time = snapTime(getTimeFromX(pos.x)); + const note = getNoteFromY(pos.y); + setPianoRollEditCursorTime(time); + const inRange = pointInsideEditRange(pos.x, pos.y); + setContextMenu({ + x: event.evt.clientX, + y: event.evt.clientY, + kind: inRange ? "range" : "grid", + noteNumber: note, + time, + }); + }, [ + clipDuration, + getNoteFromY, + getTimeFromX, + noteGridHeight, + pointInsideEditRange, + setPianoRollEditCursorTime, + snapTime, ]); const handleStageMouseMove = useCallback((event: KonvaEvent) => { const pos = getPointer(event); if (!pos) return; + const stage = event.target?.getStage?.(); + const setStageCursor = (cursor: string) => { + const container = stage?.container?.(); + if (container) container.style.cursor = cursor; + }; + + if (laneResizeState) { + setStageCursor("row-resize"); + const minHeight = laneResizeState.laneKind === "velocity" ? 40 : 48; + const maxHeight = laneResizeState.laneKind === "velocity" ? 140 : 180; + const nextHeight = clamp(laneResizeState.originalHeight + pos.y - laneResizeState.startY, minHeight, maxHeight); + updatePianoRollVisibleLane(laneResizeState.laneId, { height: nextHeight }); + if (laneResizeState.laneKind === "velocity") { + setVelocityLaneHeight(nextHeight); + } else { + setCCLaneHeight(nextHeight); + } + return; + } + + if (loopBoundaryDrag) { + setStageCursor("ew-resize"); + const nextX = clamp(pos.x, PIANO_WIDTH, stageWidth); + setLoopBoundaryDrag((current) => current ? { ...current, currentX: nextX } : null); + setPianoRollEditCursorTime(snapTime(getTimeFromX(nextX))); + return; + } + + const activePanDrag = panDragRef.current || panDragState; + if (activePanDrag) { + setTimelineScroll(clamp(activePanDrag.originalScrollX - (pos.x - activePanDrag.startX), 0, maxScrollX), timelineScrollY); + setScrollY(clamp(activePanDrag.originalScrollY - (pos.y - activePanDrag.startY), 0, maxScrollY)); + return; + } if (dragState) { updateDragPreview(event); @@ -1003,13 +3341,27 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll if (!current) return null; return { ...current, - endTime: Math.min(clipDuration, Math.max(0, snapTime(getTimeFromX(pos.x)))), + endTime: Math.max(0, snapTime(getTimeFromX(pos.x))), noteNumber: getNoteFromY(pos.y), }; }); return; } + if (marqueeState) { + setMarqueeState((current) => + current ? { ...current, currentX: pos.x, currentY: pos.y } : null, + ); + return; + } + + if (rangeDragState) { + setRangeDragState((current) => + current ? { ...current, currentX: pos.x, currentY: pos.y } : null, + ); + return; + } + if (velocityEdit) { const velocity = getVelocityFromLaneY(pos.y); updateMIDINoteVelocity(trackId, clipId, velocityEdit.timestamp, velocityEdit.noteNumber, velocity, { transient: true }); @@ -1017,34 +3369,143 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll return; } - if (ccDrawState) { - const currentEvents = getLatestCCEvents(); - const nextEvents = upsertCCEvent(pos.x, pos.y, currentEvents); - updateMIDICCEvents(trackId, clipId, nextEvents, { transient: true }); + const activeCCDrawState = ccDrawStateRef.current; + if (activeCCDrawState) { + const nativeEvent = event.evt as MouseEvent; + if ((nativeEvent.buttons & 1) !== 1) { + ccDrawStateRef.current = null; + setCCDrawState(null); + return; + } + if (activeCCDrawState.lane === "noteMetadata") { + const nextEvents = upsertNoteMetadataLaneValue(pos.x, pos.y, getLatestClipEvents()); + previewMIDIClipEvents(trackId, clipId, nextEvents); + } else if (activeCCDrawState.lane === "pitchBend") { + const nextEvents = upsertPitchBendEvent(pos.x, pos.y, getLatestClipEvents()); + previewMIDIClipEvents(trackId, clipId, nextEvents); + } else if (activeCCDrawState.lane === "midiEvent") { + const nextEvents = upsertScalarMIDIEvent(pos.x, pos.y, getLatestClipEvents()); + previewMIDIClipEvents(trackId, clipId, nextEvents); + } else { + const currentEvents = getLatestCCEvents(); + const nextEvents = upsertCCEvent(pos.x, pos.y, currentEvents); + updateMIDICCEvents(trackId, clipId, nextEvents, { transient: true }); + } + return; } + + const hoverTarget = hitTestPianoRoll(pos.x, pos.y, { + pianoWidth: PIANO_WIDTH, + noteGridHeight, + velocityLaneY, + velocityLaneHeight: isVelocityLaneActive ? velocityLaneHeight : 0, + controllerLaneY: ccLaneY, + controllerLaneHeight: activeControllerLane ? ccLaneHeight : 0, + noteEdgeHitWidth: NOTE_EDGE_HIT_WIDTH, + timeFromX: getTimeFromX, + noteFromY: getNoteFromY, + notes: pianoRollHitNotes, + lanes: pianoRollHitLanes, + controllerEvents: pianoRollControllerHitEvents, + loopStartX: loopBoundaryStartX, + loopEndX: loopBoundaryEndX, + loopBoundaryHitWidth: 6, + }); + setStageCursor(hoverTarget.kind === "loop-boundary" && clip?.loopEnabled ? "ew-resize" : ""); }, [ + activeControllerLane, auditionNote, + ccLaneHeight, + ccLaneY, ccDrawState, + clip, clipDuration, clipId, dragState, drawingState, getLatestCCEvents, + getLatestClipEvents, getNoteFromY, getTimeFromX, getVelocityFromLaneY, + isVelocityLaneActive, + laneResizeState, + loopBoundaryDrag, + loopBoundaryEndX, + loopBoundaryStartX, + marqueeState, + maxScrollX, + maxScrollY, + noteGridHeight, + panDragState, + pianoRollControllerHitEvents, + pianoRollHitLanes, + pianoRollHitNotes, + previewMIDIClipEvents, + rangeDragState, snapTime, + setTimelineScroll, + setPianoRollEditCursorTime, + stageWidth, + timelineScrollY, trackId, updateDragPreview, updateMIDICCEvents, updateMIDINoteVelocity, + updatePianoRollVisibleLane, upsertCCEvent, + upsertNoteMetadataLaneValue, + upsertPitchBendEvent, + upsertScalarMIDIEvent, velocityEdit, + velocityLaneHeight, + velocityLaneY, ]); + const handleStageMouseLeave = useCallback((event: KonvaEvent) => { + const stage = event.target?.getStage?.(); + const container = stage?.container?.(); + if (container) container.style.cursor = ""; + }, []); + const handleStageMouseUp = useCallback(() => { - const hadEdit = dragState || drawingState || velocityEdit || ccDrawState; + const activeCCDrawState = ccDrawStateRef.current; + const hadEdit = dragState || drawingState || velocityEdit || activeCCDrawState; if (hadEdit) stopAudition(); + gestureSessionRef.current = null; + if (loopBoundaryDrag && clip) { + const loopOffset = loopBoundaryDrag.initialLoopOffset; + const loopLength = Math.max(snapDuration, loopBoundaryDrag.initialLoopLength); + const loopEnd = loopOffset + loopLength; + const sourceTime = Math.max(0, snapTime(getTimeFromX(loopBoundaryDrag.currentX))); + + if (loopBoundaryDrag.edge === "start") { + const nextOffset = clamp(sourceTime, 0, Math.max(0, loopEnd - snapDuration)); + setMIDIClipSourceWindow(clipId, { + loopOffset: nextOffset, + loopLength: Math.max(snapDuration, loopEnd - nextOffset), + }, "Edit MIDI source loop start"); + } else { + const nextEnd = Math.max(loopOffset + snapDuration, sourceTime); + setMIDIClipSourceWindow(clipId, { + sourceLength: nextEnd, + loopLength: Math.max(snapDuration, nextEnd - loopOffset), + }, "Edit MIDI source loop end"); + } + setLoopBoundaryDrag(null); + return; + } + if (laneResizeState) { + setLaneResizeState(null); + return; + } + + const activePanDrag = panDragRef.current || panDragState; + if (activePanDrag) { + panDragRef.current = null; + setPanDragState(null); + return; + } if (dragState) { const finalEvents = getLatestClipEvents(); @@ -1062,12 +3523,54 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll const start = Math.min(drawingState.startTime, drawingState.endTime); const end = Math.max(drawingState.startTime, drawingState.endTime); const duration = Math.max(snapDuration, end - start || snapDuration); - const newId = addMIDINote(trackId, clipId, start, drawingState.noteNumber, Math.min(duration, clipDuration - start), drawingState.velocity); + const newId = addMIDINote(trackId, clipId, start, drawingState.noteNumber, duration, drawingState.velocity); setSelectedNoteIds(newId ? [newId] : []); auditionNote(drawingState.noteNumber, drawingState.velocity); setDrawingState(null); } + if (marqueeState) { + const left = Math.min(marqueeState.startX, marqueeState.currentX); + const right = Math.max(marqueeState.startX, marqueeState.currentX); + const top = Math.min(marqueeState.startY, marqueeState.currentY); + const bottom = Math.max(marqueeState.startY, marqueeState.currentY); + if (right - left > 3 || bottom - top > 3) { + selectMIDINotesInRange( + clipId, + { + startTime: Math.max(0, getTimeFromX(left)), + endTime: Math.max(0, getTimeFromX(right)), + minNote: Math.min(getNoteFromY(top), getNoteFromY(bottom)), + maxNote: Math.max(getNoteFromY(top), getNoteFromY(bottom)), + }, + marqueeState.mode, + ); + } + setMarqueeState(null); + } + + if (rangeDragState) { + const left = Math.min(rangeDragState.startX, rangeDragState.currentX); + const right = Math.max(rangeDragState.startX, rangeDragState.currentX); + const top = Math.min(rangeDragState.startY, rangeDragState.currentY); + const bottom = Math.max(rangeDragState.startY, rangeDragState.currentY); + if (right - left > 3 || bottom - top > 3) { + const range = rangeFromRect(rangeDragState); + setMIDIEditRange(range); + selectMIDINotesInRange( + clipId, + { + startTime: range.startTime, + endTime: range.endTime, + minNote: range.minNote, + maxNote: range.maxNote, + }, + "replace", + ); + } + setRangeDragState(null); + } + if (velocityEdit) { const finalPair = parseNotePairs(getLatestClipEvents()).find((pair) => noteIdFor(clipId, pair.startTime, pair.noteNumber) === velocityEdit.noteId @@ -1078,14 +3581,25 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll setVelocityEdit(null); } - if (ccDrawState) { - commitMIDICCEvents(trackId, clipId, ccDrawState.originalCCEvents, getLatestCCEvents(), "Draw MIDI CC"); + if (activeCCDrawState) { + if (activeCCDrawState.lane === "noteMetadata") { + const label = selectedNoteMetadataLaneLabel || "note metadata"; + commitMIDIClipEvents(trackId, clipId, activeCCDrawState.originalEvents, getLatestClipEvents(), `Draw MIDI ${label} lane`); + } else if (activeCCDrawState.lane === "pitchBend") { + commitMIDIClipEvents(trackId, clipId, activeCCDrawState.originalEvents, getLatestClipEvents(), "Draw MIDI pitch bend"); + } else if (activeCCDrawState.lane === "midiEvent") { + commitMIDIClipEvents(trackId, clipId, activeCCDrawState.originalEvents, getLatestClipEvents(), "Draw MIDI event lane"); + } else { + commitMIDICCEvents(trackId, clipId, activeCCDrawState.originalCCEvents, getLatestCCEvents(), "Draw MIDI CC"); + } + ccDrawStateRef.current = null; setCCDrawState(null); } }, [ addMIDINote, auditionNote, ccDrawState, + clip, clipDuration, clipId, commitMIDICCEvents, @@ -1094,7 +3608,20 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll drawingState, getLatestCCEvents, getLatestClipEvents, + getNoteFromY, + getTimeFromX, + laneResizeState, + loopBoundaryDrag, + marqueeState, + panDragState, + rangeDragState, + rangeFromRect, + selectMIDINotesInRange, + selectedNoteMetadataLaneLabel, + setMIDIEditRange, + setMIDIClipSourceWindow, snapDuration, + snapTime, stopAudition, trackId, velocityEdit, @@ -1109,55 +3636,22 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll return
No MIDI clip selected
; } - const renderPianoKeyboard = () => { - const keys = []; - const firstRow = Math.max(0, Math.floor(scrollY / NOTE_HEIGHT) - 1); - const lastRow = Math.min(TOTAL_NOTES, Math.ceil((scrollY + noteGridHeight) / NOTE_HEIGHT) + 1); - for (let row = firstRow; row < lastRow; row += 1) { - const noteNumber = TOTAL_NOTES - 1 - row; - const y = row * NOTE_HEIGHT - scrollY; - const noteName = NOTE_NAMES[noteNumber % NOTES_PER_OCTAVE]; - const isBlackKey = noteName.includes("#"); - const isC = noteName === "C"; - keys.push( - - - {isC && ( - - )} - , - ); - } - return keys; - }; - const renderGrid = () => { const elements: React.ReactNode[] = []; const beatInterval = 1 / beatsPerSecond; const firstRow = Math.max(0, Math.floor(scrollY / NOTE_HEIGHT) - 1); const lastRow = Math.min(TOTAL_NOTES, Math.ceil((scrollY + noteGridHeight) / NOTE_HEIGHT) + 1); - - let beatIndex = 0; - for (let time = 0; time < contentDuration; time += beatInterval) { - const x = PIANO_WIDTH + time * pixelsPerSecond - scrollX; + const visibleProjectStart = Math.max(0, timelineScrollX / pixelsPerSecond - beatInterval); + const visibleProjectEnd = (timelineScrollX + stageWidth) / pixelsPerSecond + beatInterval; + const projectContentEnd = clipStartTime + contentDuration; + + const firstBeatIndex = Math.max(0, Math.floor(visibleProjectStart / beatInterval)); + const lastBeatIndex = Math.ceil(Math.min(visibleProjectEnd, projectContentEnd) / beatInterval); + for (let beatIndex = firstBeatIndex; beatIndex < lastBeatIndex; beatIndex += 1) { + const projectTime = beatIndex * beatInterval; + const x = projectTime * pixelsPerSecond - timelineScrollX; const width = beatInterval * pixelsPerSecond; - if (x + width < PIANO_WIDTH || x > dimensions.width) { - beatIndex += 1; + if (x + width < PIANO_WIDTH || x > stageWidth) { continue; } if (beatIndex % 2 === 1) { @@ -1166,7 +3660,7 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll key={`beat-shade-${beatIndex}`} x={Math.max(PIANO_WIDTH, x)} y={0} - width={Math.min(width, dimensions.width - x)} + width={Math.min(width, stageWidth - x)} height={noteGridHeight} fill="#ffffff" opacity={0.035} @@ -1174,7 +3668,6 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll />, ); } - beatIndex += 1; } for (let row = firstRow; row <= lastRow; row += 1) { @@ -1217,7 +3710,7 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll elements.push( dimensions.width) continue; - const isBeat = Math.abs(time % beatInterval) < 0.001; + const divisionInterval = beatInterval * GRID_SNAP; + const firstDivisionIndex = Math.max(0, Math.floor(visibleProjectStart / divisionInterval)); + const lastDivisionIndex = Math.ceil(Math.min(visibleProjectEnd, projectContentEnd) / divisionInterval); + for (let divisionIndex = firstDivisionIndex; divisionIndex <= lastDivisionIndex; divisionIndex += 1) { + const projectTime = divisionIndex * divisionInterval; + const x = projectTime * pixelsPerSecond - timelineScrollX; + if (x < PIANO_WIDTH || x > stageWidth) continue; + const isBeat = divisionIndex % Math.round(1 / GRID_SNAP) === 0; elements.push( { + if (previewLoopBoundaryStartX === undefined || previewLoopBoundaryEndX === undefined) return null; + const markerColor = "rgba(100,199,232,0.78)"; + const dragFill = loopBoundaryDrag ? "rgba(100,199,232,0.12)" : "rgba(100,199,232,0.05)"; + return ( + + + + + + + + ); + }; + + const handleAdditionalClipNoteMouseDown = useCallback((event: KonvaEvent, pair: MultiClipNotePair) => { + event.cancelBubble = true; + const localStartTime = Math.max(0, pair.startTime - pair.timeOffset); + openPianoRoll(trackId, pair.clipId); + useDAWStore.getState().setSelectedNoteIds([ + noteIdFor(pair.clipId, localStartTime, pair.noteNumber), + ]); + }, [openPianoRoll, trackId]); + const renderGhostNotes = () => { + if (!showGhostMIDIClips) return []; const ghostElements: React.ReactNode[] = []; const editingClipIds = new Set([clipId, ...additionalClipIds]); track.midiClips @@ -1255,7 +3777,7 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll const x = PIANO_WIDTH + adjustedTime * pixelsPerSecond - scrollX; const y = getNoteY(pair.noteNumber); const width = pair.duration * pixelsPerSecond; - if (x + width < PIANO_WIDTH || x > dimensions.width || y + NOTE_HEIGHT < 0 || y > noteGridHeight) return; + if (x + width < PIANO_WIDTH || x > stageWidth || y + NOTE_HEIGHT < 0 || y > noteGridHeight) return; ghostElements.push( dimensions.width || y + NOTE_HEIGHT < 0 || y > noteGridHeight) return null; + if (x + width < PIANO_WIDTH || x > stageWidth || y + NOTE_HEIGHT < 0 || y > noteGridHeight) return null; const selected = selectedNoteIds.includes(id); const fillColor = velocityColor(pair.velocity); const strokeColor = selected ? "#ffffff" : velocityStrokeColor(pair.velocity); const showName = width > 40; + const muted = !!pair.muted; return ( - handleNoteMouseDown(event, pair)}> + handleNoteMouseDown(event, pair)} + onContextMenu={(event) => handleNoteContextMenu(event, pair)} + > dimensions.width || y + NOTE_HEIGHT < 0 || y > noteGridHeight) return null; + if (x + width < PIANO_WIDTH || x > stageWidth || y + NOTE_HEIGHT < 0 || y > noteGridHeight) return null; const tintColor = MULTI_CLIP_TINTS[pair.clipIndex % MULTI_CLIP_TINTS.length] || "#ff6b9d"; return ( - + handleAdditionalClipNoteMouseDown(event, pair)} + > {width > 40 && ( @@ -1396,10 +3925,53 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll ); }; + const renderMarqueeSelection = () => { + if (!marqueeState) return null; + const x = Math.min(marqueeState.startX, marqueeState.currentX); + const y = Math.min(marqueeState.startY, marqueeState.currentY); + const width = Math.abs(marqueeState.currentX - marqueeState.startX); + const height = Math.abs(marqueeState.currentY - marqueeState.startY); + return ( + + ); + }; + + const renderRangeSelection = () => { + const activeRange = rangeDragState ? rangeFromRect(rangeDragState) : midiEditRange; + if (!activeRange || activeRange.endTime <= activeRange.startTime) return null; + const x = PIANO_WIDTH + activeRange.startTime * pixelsPerSecond - scrollX; + const width = Math.max(1, (activeRange.endTime - activeRange.startTime) * pixelsPerSecond); + const topY = getNoteY(activeRange.maxNote); + const bottomY = getNoteY(activeRange.minNote) + NOTE_HEIGHT; + return ( + + ); + }; + const renderStepInputCursor = () => { if (!stepInputEnabled) return null; const cursorX = PIANO_WIDTH + stepInputPosition * pixelsPerSecond - scrollX; - if (cursorX < PIANO_WIDTH || cursorX > dimensions.width) return null; + if (cursorX < PIANO_WIDTH || cursorX > stageWidth) return null; return ( @@ -1410,22 +3982,22 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll const renderVelocityLane = () => { const elements: React.ReactNode[] = [ - , - , + , + , , ]; [0.25, 0.5, 0.75].forEach((frac) => { - const y = velocityLaneY + VELOCITY_LANE_HEIGHT * (1 - frac); - elements.push(); + const y = velocityLaneY + velocityLaneHeight * (1 - frac); + elements.push(); }); notePairs.forEach((pair) => { const x = PIANO_WIDTH + pair.startTime * pixelsPerSecond - scrollX; const width = Math.max(4, pair.duration * pixelsPerSecond - 1); - if (x + width < PIANO_WIDTH || x > dimensions.width) return; - const barHeight = (pair.velocity / 127) * (VELOCITY_LANE_HEIGHT - 4); - const y = velocityLaneY + VELOCITY_LANE_HEIGHT - barHeight - 2; + if (x + width < PIANO_WIDTH || x > stageWidth) return; + const barHeight = (pair.velocity / 127) * (velocityLaneHeight - 4); + const y = velocityLaneY + velocityLaneHeight - barHeight - 2; elements.push( , ); }); - elements.push(); + elements.push(); return elements; }; const renderCCLane = () => { const elements: React.ReactNode[] = []; const ccPreset = CC_PRESETS.find((preset) => preset.cc === selectedCC); + const laneLabel = isCC14BitMode + ? `14-bit CC#${selectedCC}/${selectedCC + 32}` + : selectedNoteMetadataLaneType + ? selectedNoteMetadataLaneLabel + : selectedScalarMIDIEventType + ? selectedScalarMIDIEventLabel + : (ccPreset?.name || `CC#${selectedCC}`); elements.push( - , - , - , + , + , + , ); [0.25, 0.5, 0.75].forEach((frac) => { - const y = ccLaneY + CC_LANE_HEIGHT * (1 - frac); - elements.push(); + const y = ccLaneY + ccLaneHeight * (1 - frac); + elements.push(); }); + if (selectedCC === PITCH_BEND_LANE) { + const y = ccLaneY + ccLaneHeight * 0.5; + elements.push(); + const upRange = Math.max(1, Math.round(pitchBendRangeUp)); + const downRange = Math.max(1, Math.round(pitchBendRangeDown)); + const spacing = ccLaneHeight / (upRange + downRange); + for (let semitone = -downRange; semitone <= upRange; semitone += 1) { + if (semitone === 0) continue; + const value = semitonesToPitchBendValueWithRange(semitone, upRange, downRange); + const gridY = ccLaneY + ccLaneHeight * (1 - pitchBendValueToLaneFraction(value)); + elements.push( + , + ); + if (spacing >= 8 || semitone === -downRange || semitone === upRange) { + const label = `${semitone > 0 ? "+" : ""}${semitone}`; + elements.push( + , + ); + } + } + } + + if (selectedNoteMetadataLaneType) { + const maxValue = noteMetadataLaneMax(selectedNoteMetadataLaneType); + notePairs.forEach((pair) => { + const x = PIANO_WIDTH + pair.startTime * pixelsPerSecond - scrollX; + const width = Math.max(4, pair.duration * pixelsPerSecond - 1); + if (x + width < PIANO_WIDTH || x > stageWidth) return; + const rawValue = noteMetadataValueForPair(pair, selectedNoteMetadataLaneType); + const barHeight = (rawValue / maxValue) * (ccLaneHeight - 4); + const y = ccLaneY + ccLaneHeight - barHeight - 2; + const id = noteIdFor(clipId, pair.startTime, pair.noteNumber); + const selected = selectedNoteIds.includes(id); + elements.push( + { + konvaEvent.cancelBubble = true; + handleCCMouseDown(konvaEvent); + }} + />, + ); + }); + elements.push( + { + konvaEvent.cancelBubble = true; + handleCCMouseDown(konvaEvent); + }} + />, + ); + return elements; + } const linePoints: number[] = []; - ccEventsForLane.forEach((event) => { + controllerEventsForLane.forEach((event) => { const x = PIANO_WIDTH + event.time * pixelsPerSecond - scrollX; - const y = ccLaneY + CC_LANE_HEIGHT * (1 - event.value / 127); + const y = ccLaneY + ccLaneHeight * (1 - event.value / 127); linePoints.push(x, y); }); if (linePoints.length >= 4) { elements.push(); } - ccEventsForLane.forEach((event, index) => { + controllerEventsForLane.forEach((event, index) => { const x = PIANO_WIDTH + event.time * pixelsPerSecond - scrollX; - if (x < PIANO_WIDTH || x > dimensions.width) return; - const barHeight = (event.value / 127) * (CC_LANE_HEIGHT - 4); - const y = ccLaneY + CC_LANE_HEIGHT - barHeight - 2; + if (x < PIANO_WIDTH || x > stageWidth) return; + const barHeight = (event.value / 127) * (ccLaneHeight - 4); + const y = ccLaneY + ccLaneHeight - barHeight - 2; elements.push( , - , + { + konvaEvent.cancelBubble = true; + handleCCMouseDown(konvaEvent); + }} + />, ); }); - elements.push(); + elements.push(); return elements; }; + const renderActiveControllerLane = () => { + return isVelocityLaneActive ? renderVelocityLane() : renderCCLane(); + }; + + const getRulerTimeFromClientX = useCallback((clientX: number, bypassSnap: boolean) => { + const ruler = containerRef.current?.querySelector(".piano-roll-ruler") as HTMLDivElement | null; + const rect = ruler?.getBoundingClientRect(); + const x = rect ? clientX - rect.left : 0; + return pianoRollRulerTimeFromX(x, { + pixelsPerSecond, + scrollX: timelineScrollX, + snapSeconds: snapDuration, + snapEnabled: true, + bypassSnap, + }); + }, [pixelsPerSecond, snapDuration, timelineScrollX]); + + const handleRulerPointerDown = useCallback((event: any) => { + if (event.button !== 0) return; + event.preventDefault(); + const bypassSnap = event.ctrlKey || event.metaKey; + const targetSessionId = sessionId || windowSessionId || undefined; + const publishPreviewSeek = (time: number) => { + useDAWStore.getState().setCurrentTime(time); + if (isDetached || windowRole !== "main") { + void nativeBridge.publishAppCommand({ + command: "transport.seekPreview", + time, + sessionId: targetSessionId, + }); + } + }; + const publishFinalSeek = (time: number) => { + if (isDetached || windowRole !== "main") { + void nativeBridge.publishAppCommand({ + command: "transport.seek", + time, + sessionId: targetSessionId, + }); + } else { + void seekTo(time); + } + }; + + let latestTime = getRulerTimeFromClientX(event.clientX, bypassSnap); + publishPreviewSeek(latestTime); + + const handlePointerMove = (moveEvent: PointerEvent) => { + latestTime = getRulerTimeFromClientX(moveEvent.clientX, moveEvent.ctrlKey || moveEvent.metaKey); + publishPreviewSeek(latestTime); + }; + const handlePointerUp = () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + publishFinalSeek(latestTime); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerUp); + }, [getRulerTimeFromClientX, isDetached, seekTo, sessionId]); + + const renderRulerMarks = () => { + const marks: React.ReactNode[] = []; + const secondsPerBeat = 60 / Math.max(1, tempo); + const beatsPerBar = Math.max(1, timeSignature.numerator || 4); + const secondsPerBar = secondsPerBeat * beatsPerBar; + const pixelsPerBeat = secondsPerBeat * pixelsPerSecond; + const visibleStart = Math.max(0, timelineScrollX / pixelsPerSecond - secondsPerBar); + const visibleEnd = (timelineScrollX + stageWidth) / pixelsPerSecond + secondsPerBar; + const firstBeat = Math.floor(visibleStart / secondsPerBeat); + const lastBeat = Math.ceil(visibleEnd / secondsPerBeat); + const labelEveryBar = pixelsPerBeat * beatsPerBar < 70 ? 2 : 1; + + for (let beat = firstBeat; beat <= lastBeat; beat += 1) { + const projectTime = beat * secondsPerBeat; + const x = projectTime * pixelsPerSecond - timelineScrollX; + if (x < -80 || x > stageWidth + 80) continue; + const isBar = beat % beatsPerBar === 0; + const bar = Math.floor(beat / beatsPerBar) + 1; + const beatInBar = ((beat % beatsPerBar) + beatsPerBar) % beatsPerBar + 1; + const showLabel = isBar && (bar - 1) % labelEveryBar === 0; + marks.push( +
+ {showLabel && {bar}} + {!showLabel && pixelsPerBeat >= 42 && {beatInBar}} +
, + ); + } + + return marks; + }; + + const renderPianoKeys = () => { + const keys: React.ReactNode[] = []; + const firstRow = Math.max(0, Math.floor(scrollY / NOTE_HEIGHT) - 1); + const lastRow = Math.min(TOTAL_NOTES - 1, Math.ceil((scrollY + noteGridHeight) / NOTE_HEIGHT) + 1); + + for (let row = firstRow; row <= lastRow; row += 1) { + const noteNumber = TOTAL_NOTES - 1 - row; + const y = row * NOTE_HEIGHT - scrollY; + const noteName = NOTE_NAMES[noteNumber % NOTES_PER_OCTAVE]; + const isBlackKey = noteName.includes("#"); + const isC = noteName === "C"; + const isActive = activePreviewNotes.has(noteNumber); + keys.push( + , + ); + } + + return keys; + }; + + const selectedPairs = notePairs.filter((pair) => + selectedNoteIds.includes(noteIdFor(clipId, pair.startTime, pair.noteNumber)), + ); + const selectedMuted = selectedPairs.length > 0 && selectedPairs.every((pair) => pair.noteOn.muted || pair.noteOff.muted); + const inspectedNotePair = selectedPairs.length === 1 ? selectedPairs[0] : null; + const inspectedChanceRaw = inspectedNotePair?.probability ?? inspectedNotePair?.chance ?? 1; + const inspectedChancePercent = clamp( + Math.round((inspectedChanceRaw > 1 ? inspectedChanceRaw / 100 : inspectedChanceRaw) * 100), + 0, + 100, + ); + const mixed = ""; + const commonSelectedValue = useCallback((values: T[], fallback: T | ""): T | "" => { + if (values.length === 0) return fallback; + const [first] = values; + return values.every((value) => Object.is(value, first)) ? first : mixed; + }, []); + const selectedVelocityValue = commonSelectedValue(selectedPairs.map((pair) => pair.velocity), mixed); + const selectedChannelValue = commonSelectedValue(selectedPairs.map((pair) => pair.channel ?? 1), mixed); + const selectedReleaseVelocityValue = commonSelectedValue( + selectedPairs.map((pair) => pair.releaseVelocity ?? pair.noteOff.velocity ?? 0), + mixed, + ); + const selectedChanceValue = commonSelectedValue( + selectedPairs.map((pair) => { + const raw = pair.probability ?? pair.chance ?? 1; + return clamp(Math.round((raw > 1 ? raw / 100 : raw) * 100), 0, 100); + }), + mixed, + ); + const selectedVarianceValue = commonSelectedValue(selectedPairs.map((pair) => pair.velocityVariance ?? 0), mixed); + const selectedPlayCountValue = commonSelectedValue(selectedPairs.map((pair) => pair.playCount ?? 0), mixed); + const selectedCentOffsetValue = commonSelectedValue(selectedPairs.map((pair) => pair.centOffset ?? 0), mixed); + + const addLane = useCallback((kind: PianoRollVisibleLane["kind"], cc?: number) => { + const nextId = `${kind}-${cc ?? Date.now()}`; + const labelByKind: Record = { + velocity: "Velocity", + noteOffVelocity: "Note-Off Velocity", + chance: "Chance", + velocityVariance: "Velocity Variance", + pitchBend: "Pitch Bend", + programBank: "Program / Bank", + channelPressure: "Channel Pressure", + polyPressure: "Poly Pressure", + cc7: `CC#${cc ?? 1}`, + cc14: `14-bit CC#${cc ?? 1}/${(cc ?? 1) + 32}`, + }; + const lane: PianoRollVisibleLane = { + id: nextId, + kind, + label: labelByKind[kind], + height: kind === "velocity" ? 72 : 88, + cc, + interpolation: kind === "velocity" ? "step" : "linear", + }; + addPianoRollVisibleLane(lane); + selectControllerLane(lane); + }, [addPianoRollVisibleLane, selectControllerLane]); + + useEffect(() => { + if (!activeLane) return; + if (activeLane.kind === "velocity") { + setVelocityLaneHeight(clamp(activeLane.height || VELOCITY_LANE_HEIGHT, 40, 140)); + return; + } + selectControllerLane(activeLane); + setCCLaneHeight(clamp(activeLane.height || CC_LANE_HEIGHT, 48, 180)); + }, [activeLane, selectControllerLane]); + + useEffect(() => { + if (selectedCC === POLY_PRESSURE_LANE && inspectedNotePair) { + setPolyPressureNote(inspectedNotePair.noteNumber); + } + }, [inspectedNotePair, selectedCC]); + + useEffect(() => { + ccDrawStateRef.current = null; + setCCDrawState(null); + }, [selectedCC]); + + const editInspectedNote = useCallback((field: "note" | "start" | "duration" | "velocity", value: number) => { + if (!inspectedNotePair) return; + const id = noteIdFor(clipId, inspectedNotePair.startTime, inspectedNotePair.noteNumber); + if (field === "note") { + const nextNote = clamp(Math.round(value), 0, 127); + const ids = moveMIDINotes(trackId, clipId, [id], 0, nextNote - inspectedNotePair.noteNumber); + setSelectedNoteIds(ids); + return; + } + if (field === "start") { + const nextStart = Math.max(0, value); + const ids = moveMIDINotes(trackId, clipId, [id], nextStart - inspectedNotePair.startTime, 0); + setSelectedNoteIds(ids); + return; + } + if (field === "duration") { + const ids = resizeMIDINote(trackId, clipId, id, inspectedNotePair.startTime, Math.max(0.01, value)); + setSelectedNoteIds(ids); + return; + } + setSelectedMIDINoteVelocity(trackId, clipId, clamp(Math.round(value), 1, 127)); + }, [ + clipId, + inspectedNotePair, + moveMIDINotes, + resizeMIDINote, + setSelectedMIDINoteVelocity, + setSelectedNoteIds, + trackId, + ]); + + const editInspectedNoteMetadata = useCallback(( + field: "channel" | "releaseVelocity" | "chance" | "playCount" | "velocityVariance" | "centOffset", + value: number, + ) => { + if (selectedPairs.length === 0) return; + + const oldEvents = getLatestClipEvents(); + const nextEvents = sortEvents(oldEvents.map((event) => { + const matchedPair = selectedPairs.find((pair) => { + const noteChannel = pair.channel ?? 1; + const sameNote = event.note === pair.noteNumber && (event.channel ?? 1) === noteChannel; + if (!sameNote) return false; + if (event.type === "noteOn") return Math.abs(event.timestamp - pair.startTime) < 0.000001; + if (event.type === "noteOff") return Math.abs(event.timestamp - (pair.startTime + pair.duration)) < 0.000001; + return false; + }); + if (!matchedPair) return event; + + if (event.type === "noteOn") { + if (field === "channel") return { ...event, channel: clamp(Math.round(value), 1, 16) }; + if (field === "chance") { + const nextEvent: MIDIEvent = { ...event, probability: clamp(value / 100, 0, 1) }; + delete nextEvent.chance; + return nextEvent; + } + if (field === "playCount") return { ...event, playCount: Math.max(0, Math.round(value)) }; + if (field === "velocityVariance") return { ...event, velocityVariance: clamp(Math.round(value), 0, 127) }; + if (field === "centOffset") return { ...event, centOffset: clamp(value, -100, 100) }; + } + + if (event.type === "noteOff") { + if (field === "channel") return { ...event, channel: clamp(Math.round(value), 1, 16) }; + if (field === "releaseVelocity") { + const releaseVelocity = clamp(Math.round(value), 0, 127); + return { ...event, velocity: releaseVelocity, releaseVelocity }; + } + } + + return event; + })); + + commitMIDIClipEvents(trackId, clipId, oldEvents, nextEvents, "Edit MIDI note metadata"); + }, [ + clipId, + commitMIDIClipEvents, + getLatestClipEvents, + selectedPairs, + trackId, + ]); + + const openTransformDialog = (dialog: TransformDialogState) => { + setTransformDialog(dialog); + setContextMenu(null); + }; + + const buildPianoRollContextMenuItems = (menu: NonNullable): MenuItem[] => { + const hasSelection = selectedNoteIds.length > 0; + const hasRange = !!midiEditRange; + const hasRangeClipboard = midiRangeClipboard.rangeLength > 0; + const pasteTime = menu.time ?? 0; + const selectedPitch = menu.noteNumber ?? selectedPairs[0]?.noteNumber ?? 60; + return [ + ...(menu.kind === "grid" || menu.kind === "range" + ? [ + { + label: "Paste Here", + shortcut: "Ctrl+V", + disabled: !midiNoteClipboard.notes.length && !hasRangeClipboard, + onClick: () => { + if (hasRangeClipboard) pasteMIDIRange(trackId, clipId, pasteTime); + else pasteMIDINotes(trackId, clipId, pasteTime); + }, + }, + { + label: "Insert Note", + onClick: () => { + const id = addMIDINote(trackId, clipId, pasteTime, selectedPitch, snapDuration, pianoRollInsertVelocity); + if (id) setSelectedNoteIds([id]); + }, + }, + { + label: "Insert Chord", + submenu: [ + { label: "Diatonic Triad", onClick: () => insertMIDIChord(trackId, clipId, pasteTime, selectedPitch, "diatonic") }, + { label: "Major", onClick: () => insertMIDIChord(trackId, clipId, pasteTime, selectedPitch, "major") }, + { label: "Minor", onClick: () => insertMIDIChord(trackId, clipId, pasteTime, selectedPitch, "minor") }, + { label: "Power", onClick: () => insertMIDIChord(trackId, clipId, pasteTime, selectedPitch, "power") }, + ], + }, + { divider: true, label: "" }, + { + label: "Select Notes in Region", + onClick: () => selectMIDINotesInRange(clipId, { + startTime: Math.max(0, pasteTime - snapDuration), + endTime: Math.max(0, pasteTime + snapDuration), + minNote: selectedPitch - 6, + maxNote: selectedPitch + 6, + }), + }, + { label: "Cut Region", disabled: !hasRange && !hasSelection, onClick: () => hasRange ? cutMIDIRange(trackId, clipId) : cutSelectedMIDINotes(trackId, clipId) }, + { label: "Copy Region", disabled: !hasRange && !hasSelection, onClick: () => hasRange ? copyMIDIRange(trackId, clipId) : copySelectedMIDINotes(trackId, clipId) }, + { + label: "Delete Region", + disabled: !hasRange && !hasSelection, + onClick: () => { + if (hasRange) { + deleteMIDIRange(trackId, clipId); + } else { + removeMIDINotes(trackId, clipId, selectedNoteIds); + setSelectedNoteIds([]); + } + }, + }, + { label: "Duplicate Region", disabled: !hasRange, onClick: () => duplicateMIDIRange(trackId, clipId) }, + { + label: "Repeat Region", + disabled: !hasRange, + onClick: () => { + repeatMIDISelection(trackId, clipId); + }, + }, + { + label: "Crop Clip to Region", + disabled: selectedPairs.length === 0, + onClick: () => cropMIDIClipToSelectedNotes(trackId, clipId), + }, + { + label: "Set Loop to Region", + disabled: selectedPairs.length === 0, + onClick: () => { + const start = Math.min(...selectedPairs.map((pair) => pair.startTime)); + const end = Math.max(...selectedPairs.map((pair) => pair.startTime + pair.duration)); + useDAWStore.getState().setLoopRegion(clipStartTime + start, clipStartTime + end); + }, + }, + ] + : [ + { label: "Cut Notes", shortcut: "Ctrl+X", disabled: !hasSelection, onClick: () => cutSelectedMIDINotes(trackId, clipId) }, + { label: "Copy Notes", shortcut: "Ctrl+C", disabled: !hasSelection, onClick: () => copySelectedMIDINotes(trackId, clipId) }, + { label: "Paste Notes", shortcut: "Ctrl+V", disabled: !midiNoteClipboard.notes.length && !hasRangeClipboard, onClick: () => hasRangeClipboard ? pasteMIDIRange(trackId, clipId, pasteTime) : pasteMIDINotes(trackId, clipId, pasteTime) }, + { label: "Duplicate Notes", disabled: !hasSelection, onClick: () => duplicateSelectedMIDINotes(trackId, clipId) }, + { + label: "Delete Notes", + shortcut: "Del", + disabled: !hasSelection, + onClick: () => { + removeMIDINotes(trackId, clipId, selectedNoteIds); + setSelectedNoteIds([]); + }, + }, + { divider: true, label: "" }, + { label: "Select All Notes", shortcut: "Ctrl+A", onClick: selectAllMIDINotes }, + { label: "Invert Selection", onClick: () => invertMIDISelection(clipId) }, + { label: "Select Same Pitch", onClick: () => selectMIDINotesByPitch(clipId, menu.noteNumber) }, + ]), + { divider: true, label: "" }, + { label: "Quantize...", disabled: !hasSelection, onClick: () => openTransformDialog({ type: "quantize", value: snapDuration, strength: 1, mode: "start", swing: 0, groovePreset: "straight", tupletDivisions: 1, catchRangeMs: 0, safeRangeMs: 0, randomizeMs: 0, fixedLength: snapDuration, moveControllers: true }) }, + { label: "Humanize...", disabled: !hasSelection, onClick: () => openTransformDialog({ type: "humanize", timingMs: 10, velocity: 5 }) }, + { + label: "Transpose", + disabled: !hasSelection, + submenu: [ + { label: "Up Semitone", onClick: () => { const ids = moveMIDINotes(trackId, clipId, selectedNoteIds, 0, 1); setSelectedNoteIds(ids); } }, + { label: "Down Semitone", onClick: () => { const ids = moveMIDINotes(trackId, clipId, selectedNoteIds, 0, -1); setSelectedNoteIds(ids); } }, + { label: "Up Octave", onClick: () => { const ids = moveMIDINotes(trackId, clipId, selectedNoteIds, 0, 12); setSelectedNoteIds(ids); } }, + { label: "Down Octave", onClick: () => { const ids = moveMIDINotes(trackId, clipId, selectedNoteIds, 0, -12); setSelectedNoteIds(ids); } }, + ], + }, + { + label: "Velocity", + disabled: !hasSelection, + submenu: [ + { label: "Set...", onClick: () => openTransformDialog({ type: "velocity", value: 80 }) }, + { label: "+10%", onClick: () => scaleSelectedMIDINoteVelocity(trackId, clipId, 1.1) }, + { label: "-10%", onClick: () => scaleSelectedMIDINoteVelocity(trackId, clipId, 0.9) }, + { label: "Randomize...", onClick: () => openTransformDialog({ type: "randomVelocity", amount: 8 }) }, + ], + }, + { label: "Set Length...", disabled: !hasSelection, onClick: () => openTransformDialog({ type: "length", value: snapDuration }) }, + { label: "Legato", disabled: !hasSelection, onClick: () => legatoSelectedMIDINotes(trackId, clipId) }, + { label: "Reverse Timing", disabled: !hasSelection, onClick: () => reverseSelectedMIDINotes(trackId, clipId) }, + { label: "Invert Pitches", disabled: !hasSelection, onClick: () => invertSelectedMIDINotePitches(trackId, clipId) }, + { label: "Mirror Around Note...", disabled: !hasSelection, onClick: () => openTransformDialog({ type: "mirror", centerNote: selectedPitch }) }, + { label: selectedMuted ? "Unmute Notes" : "Mute Notes", disabled: !hasSelection, onClick: () => toggleSelectedMIDINoteMute(trackId, clipId) }, + { label: "Note Properties...", disabled: !hasSelection, onClick: () => openTransformDialog({ type: "velocity", value: selectedPairs[0]?.velocity ?? 80 }) }, + ]; + }; + + const applyTransformDialog = () => { + if (!transformDialog) return; + if (transformDialog.type === "quantize") { + quantizeSelectedMIDINotes(trackId, clipId, transformDialog.value, transformDialog.strength, { + mode: transformDialog.mode, + swing: transformDialog.swing, + groovePreset: transformDialog.groovePreset, + tupletDivisions: transformDialog.tupletDivisions, + catchRangeMs: transformDialog.catchRangeMs, + safeRangeMs: transformDialog.safeRangeMs, + randomizeMs: transformDialog.randomizeMs, + fixedLength: transformDialog.mode === "length" ? transformDialog.fixedLength : undefined, + moveControllers: transformDialog.moveControllers, + }); + } else if (transformDialog.type === "humanize") { + humanizeSelectedMIDINotes(trackId, clipId, { timingMs: transformDialog.timingMs, velocity: transformDialog.velocity }); + } else if (transformDialog.type === "velocity") { + setSelectedMIDINoteVelocity(trackId, clipId, transformDialog.value); + } else if (transformDialog.type === "randomVelocity") { + randomizeSelectedMIDINoteVelocity(trackId, clipId, transformDialog.amount); + } else if (transformDialog.type === "length") { + setSelectedMIDINoteLength(trackId, clipId, transformDialog.value); + } else if (transformDialog.type === "mirror") { + mirrorSelectedMIDINotePitches(trackId, clipId, transformDialog.centerNote); + } + setTransformDialog(null); + }; + return (
-
+ snapSelectedMIDINotesToScale(trackId, clipId, scaleRoot, scaleType)} + auditionEnabled={pianoRollAuditionEnabled} + onAuditionEnabledChange={setPianoRollAuditionEnabled} + insertVelocity={pianoRollInsertVelocity} + onInsertVelocityChange={setPianoRollInsertVelocity} + stepInputEnabled={stepInputEnabled} + onToggleStepInput={toggleStepInput} + stepInputSize={stepInputSize} + stepSizeOptions={STEP_SIZE_OPTIONS} + onStepInputSizeChange={setStepInputSize} + clipOptions={trackMIDIClipOptions} + activeClipId={clipId} + onActiveClipChange={(nextClipId) => openPianoRoll(trackId, nextClipId)} + showSelectedMIDIClipRefs={showSelectedMIDIClipRefs} + onShowSelectedMIDIClipRefsChange={setShowSelectedMIDIClipRefs} + showGhostMIDIClips={showGhostMIDIClips} + onShowGhostMIDIClipsChange={setShowGhostMIDIClips} + visibleLanes={visibleLanes} + activeLaneId={activeLane?.id} + onActiveLaneChange={(laneId) => { + const lane = visibleLanes.find((candidate) => candidate.id === laneId); + if (lane) selectControllerLane(lane); + }} + onQuantizeLast={() => { + const nextIds = quantizeSelectedMIDINotesUsingLast(trackId, clipId); + if (nextIds.length > 0) setSelectedNoteIds(nextIds); + }} + onOpenQuantizeDialog={() => openTransformDialog({ + type: "quantize", + value: snapDuration, + strength: 1, + mode: "start", + swing: 0, + groovePreset: "straight", + tupletDivisions: 1, + catchRangeMs: 0, + safeRangeMs: 0, + randomizeMs: 0, + fixedLength: snapDuration, + moveControllers: true, + })} + onResetQuantize={() => resetMIDIQuantize(trackId, clipId)} + onFreezeQuantize={() => freezeMIDIQuantize(trackId, clipId)} + onDetach={!isDetached ? onDetach : undefined} + /> + + 1 ? "Notes" : "Clip"} + startLabel={inspectedNotePair ? inspectedNotePair.startTime.toFixed(3) : (pianoRollEditCursorTime?.toFixed(3) ?? "--")} + lengthLabel={inspectedNotePair ? inspectedNotePair.duration.toFixed(3) : clipDuration.toFixed(3)} + valueLabel={selectedVelocityValue || "--"} + channelLabel={selectedChannelValue || "--"} + chanceLabel={selectedChanceValue || "--"} + laneLabel={controllerLaneLabel} + curveLabel={activeLane?.interpolation ?? "linear"} + /> + +