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..80da129 100644 --- a/Source/AudioEngine.cpp +++ b/Source/AudioEngine.cpp @@ -6,6 +6,8 @@ #include "PitchAnalyzer.h" #include "PitchResynthesizer.h" #include "CrashDiagnostics.h" +#include +#include namespace { @@ -3644,7 +3646,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 +3667,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 +3931,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 +4191,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 +4397,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 +4419,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 +4474,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 +4703,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 +4714,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 +4772,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 +4802,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 +4846,7 @@ void AudioEngine::setTrackMIDIClips(const juce::String& trackId, const juce::Str } } + queueAllNotesOffForTrack(*it->second); it->second->setScheduledMIDIClips(std::move(clips)); } @@ -4840,6 +4893,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 +4966,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 +5224,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 +5684,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 +5785,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 @@ -6247,6 +6443,13 @@ juce::var AudioEngine::runReleaseGuardrails() return juce::var(root); } +static juce::var describeBuiltInProcessor(juce::AudioProcessor* processor, + const juce::String& chainType, + int fxIndex); +static bool setBuiltInProcessorParam(juce::AudioProcessor* processor, + const juce::String& paramId, + float value); + juce::var AudioEngine::runAutomatedRegressionSuite() { auto releaseGuardrails = runReleaseGuardrails(); @@ -6365,138 +6568,1791 @@ juce::var AudioEngine::runAutomatedRegressionSuite() addSuite("plugin_capability_matrix", false, "Guardrail payload missing"); } - root->setProperty("overallPass", overallPass); - root->setProperty("processingPrecision", getProcessingPrecision()); - root->setProperty("suites", suites); - root->setProperty("releaseGuardrails", releaseGuardrails); - return juce::var(root); -} - -bool AudioEngine::addTrackInputFX(const juce::String& trackId, const juce::String& pluginPath, bool openEditor) -{ - // Load plugin BEFORE acquiring the lock (can take hundreds of ms). - // Use actual device sample rate so the plugin initialises at the correct rate. - // IMPORTANT: Use at least 512 for the max-block-size hint — ASIO buffers can be - // as small as 32 samples, but prepareToPlay(sr, 32) forces plugins like Amplitube - // to resize their internal DSP (FFT/convolution/cab sim) for tiny blocks, producing - // crackling and distortion. The plugin can still process 32-sample blocks fine when - // prepared with a larger maximumExpectedSamplesPerBlock. - double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; - int bs = getSafeHostedPluginBlockSize(currentBlockSize); - OpenStudioCrashDiagnostics::recordBreadcrumb("add_track_input_fx_begin", - "trackId=" + trackId + " path=" + pluginPath + " sr=" + juce::String(sr) + " block=" + juce::String(bs)); - auto plugin = pluginManager.loadPluginFromFile(pluginPath, sr, bs); - if (!plugin) + auto bufferFiniteAndBounded = [] (const juce::AudioBuffer& buffer, float peakLimit) { - OpenStudioCrashDiagnostics::recordBreadcrumb("add_track_input_fx_failed", "trackId=" + trackId + " path=" + pluginPath); - return false; - } - - // Provide tempo/position info to the plugin - plugin->setPlayHead (this); + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + const auto* samples = buffer.getReadPointer(ch); + for (int sample = 0; sample < buffer.getNumSamples(); ++sample) + { + if (!std::isfinite(samples[sample]) || std::abs(samples[sample]) > peakLimit) + return false; + } + } + return true; + }; - bool success = false; - int fxIndex = -1; + auto fillDspFixture = [] (juce::AudioBuffer& buffer, double sampleRate) { - // Hold the callback lock while modifying the FX node vectors - // (audio thread iterates these in processBlock) - const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); - auto it = trackMap.find(trackId); - if (it != trackMap.end() && it->second) + for (int sample = 0; sample < buffer.getNumSamples(); ++sample) { - success = it->second->addInputFX(std::move(plugin), sr, bs); - if (success) - fxIndex = it->second->getNumInputFX() - 1; + const float t = static_cast(sample / sampleRate); + const float sine = std::sin(juce::MathConstants::twoPi * 220.0f * t) * 0.24f; + const float high = std::sin(juce::MathConstants::twoPi * 2200.0f * t) * 0.08f; + const float impulse = sample == 0 ? 0.55f : 0.0f; + buffer.setSample(0, sample, sine + high + impulse); + if (buffer.getNumChannels() > 1) + buffer.setSample(1, sample, sine * 0.82f - high * 0.35f - impulse * 0.4f); } - } + }; - if (success && fxIndex >= 0) { - if (openEditor) - openPluginEditor(trackId, fxIndex, true); - juce::Logger::writeToLog("AudioEngine: Added input FX" + juce::String(openEditor ? " and opened editor" : "")); - OpenStudioCrashDiagnostics::recordBreadcrumb("add_track_input_fx_success", - "trackId=" + trackId + " fxIndex=" + juce::String(fxIndex) + " block=" + juce::String(bs)); - recalculatePDC(); - } - return success; -} + const double fixtureSampleRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + const int fixtureBlockSize = juce::jmax(512, currentBlockSize); + bool builtInSmokePass = true; + juce::StringArray builtInSmokeDetails; -bool AudioEngine::addTrackFX(const juce::String& trackId, const juce::String& pluginPath, bool openEditor) -{ - // Load plugin BEFORE acquiring the lock (can take hundreds of ms). - // Use actual device sample rate so the plugin initialises at the correct rate. - // IMPORTANT: Clamp max-block-size to at least 512 — same rationale as addTrackInputFX. - double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; - int bs = getSafeHostedPluginBlockSize(currentBlockSize); + auto runEffectSmoke = [&] (const juce::String& name, juce::AudioProcessor& processor) + { + juce::AudioBuffer fixtureBuffer(2, fixtureBlockSize); + fillDspFixture(fixtureBuffer, fixtureSampleRate); + juce::MidiBuffer fixtureMidi; + processor.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + processor.processBlock(fixtureBuffer, fixtureMidi); + const float peak = peakFromFloatBuffer(fixtureBuffer, fixtureBuffer.getNumSamples()); + const bool pass = bufferFiniteAndBounded(fixtureBuffer, 3.0f) && peak <= 3.0f; + builtInSmokePass = builtInSmokePass && pass; + builtInSmokeDetails.add(name + ": pass=" + juce::String(pass ? "true" : "false") + + ", peak=" + juce::String(peak, 6)); + processor.releaseResources(); + }; - logToDisk("AudioEngine::addTrackFX DIAGNOSTIC"); - logToDisk(" currentSampleRate=" + juce::String(currentSampleRate) + - " currentBlockSize=" + juce::String(currentBlockSize)); - logToDisk(" creating plugin at sr=" + juce::String(sr) + " bs=" + juce::String(bs)); + auto runInstrumentSmoke = [&] (const juce::String& name, juce::AudioProcessor& processor, int noteNumber) + { + juce::AudioBuffer fixtureBuffer(2, fixtureBlockSize); + fixtureBuffer.clear(); + juce::MidiBuffer fixtureMidi; + fixtureMidi.addEvent(juce::MidiMessage::noteOn(1, noteNumber, static_cast(108)), 0); + processor.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + processor.processBlock(fixtureBuffer, fixtureMidi); + const float peak = peakFromFloatBuffer(fixtureBuffer, fixtureBuffer.getNumSamples()); + const bool pass = bufferFiniteAndBounded(fixtureBuffer, 2.0f) && peak > 1.0e-5f && peak <= 2.0f; + builtInSmokePass = builtInSmokePass && pass; + builtInSmokeDetails.add(name + ": pass=" + juce::String(pass ? "true" : "false") + + ", peak=" + juce::String(peak, 6)); + processor.releaseResources(); + }; - OpenStudioCrashDiagnostics::recordBreadcrumb("add_track_fx_begin", - "trackId=" + trackId + " path=" + pluginPath + " sr=" + juce::String(sr) + " block=" + juce::String(bs)); - auto plugin = pluginManager.loadPluginFromFile(pluginPath, sr, bs); - if (!plugin) - { - OpenStudioCrashDiagnostics::recordBreadcrumb("add_track_fx_failed", "trackId=" + trackId + " path=" + pluginPath); - return false; - } + S13EQ eq; + eq.bands[2].dynamicEnabled.store(1.0f); + eq.bands[2].dynamicRange.store(-4.0f); + eq.bands[2].dynamicThreshold.store(-36.0f); + eq.stereoMode.store(1.0f); + runEffectSmoke("eq_dynamic_mid", eq); + + S13Compressor compressor; + compressor.threshold.store(-24.0f); + compressor.ratio.store(4.0f); + compressor.detectorMode.store(2.0f); + runEffectSmoke("compressor", compressor); + + S13Gate gate; + gate.threshold.store(-42.0f); + runEffectSmoke("gate", gate); + + S13Limiter limiter; + limiter.threshold.store(-6.0f); + limiter.ceiling.store(-0.5f); + runEffectSmoke("limiter", limiter); + + S13Delay delay; + delay.feedback.store(0.32f); + delay.ducking.store(0.25f); + runEffectSmoke("delay", delay); + + S13Reverb reverb; + reverb.algorithm.store(2.0f); + reverb.decayTime.store(2.4f); + runEffectSmoke("reverb", reverb); + + S13Chorus chorus; + chorus.characterMode.store(1.0f); + runEffectSmoke("chorus_ensemble", chorus); + + S13Saturator saturator; + saturator.satType.store(6.0f); + saturator.oversampleMode.store(2.0f); + runEffectSmoke("saturator", saturator); + + S13BasicSynthInstrument synth; + runInstrumentSmoke("basic_synth", synth, 60); + + S13PianoInstrument piano; + runInstrumentSmoke("piano", piano, 64); + + S13DrumInstrument drums; + drums.mapPreset.store(1.0f); + juce::AudioBuffer drumBuffer(2, fixtureBlockSize); + drumBuffer.clear(); + juce::MidiBuffer drumMidi; + drumMidi.addEvent(juce::MidiMessage::controllerEvent(1, 4, 96), 0); + drumMidi.addEvent(juce::MidiMessage::noteOn(1, 22, static_cast(112)), 0); + drums.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + drums.processBlock(drumBuffer, drumMidi); + const float drumPeak = peakFromFloatBuffer(drumBuffer, drumBuffer.getNumSamples()); + const bool drumPass = bufferFiniteAndBounded(drumBuffer, 2.0f) && drumPeak > 1.0e-5f && drumPeak <= 2.0f; + builtInSmokePass = builtInSmokePass && drumPass; + builtInSmokeDetails.add("drums_roland_td_hat: pass=" + juce::String(drumPass ? "true" : "false") + + ", peak=" + juce::String(drumPeak, 6)); + drums.releaseResources(); + + addSuite("built_in_plugin_dsp_smoke_fixture", builtInSmokePass, builtInSmokeDetails.joinIntoString("; ")); + } + + { + const double fixtureSampleRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + constexpr int fixtureBlockSize = 64; + const std::array chordNotes { 36, 43, 48, 52, 55, 60, 64 }; + + S13PianoInstrument piano; + piano.outputGain.store(-15.0f); + piano.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + + bool finite = true; + float peak = 0.0f; + const double startMs = juce::Time::getMillisecondCounterHiRes(); + for (int block = 0; block < 48; ++block) + { + juce::AudioBuffer buffer(2, fixtureBlockSize); + buffer.clear(); + juce::MidiBuffer midi; + if (block == 0) + { + for (const int note : chordNotes) + midi.addEvent(juce::MidiMessage::noteOn(1, note, static_cast(127)), 0); + } + else if (block == 24) + { + for (const int note : chordNotes) + midi.addEvent(juce::MidiMessage::noteOff(1, note), 0); + } - logToDisk(" plugin created: " + plugin->getName() + - " inCh=" + juce::String(plugin->getTotalNumInputChannels()) + - " outCh=" + juce::String(plugin->getTotalNumOutputChannels()) + - " pluginSr=" + juce::String(plugin->getSampleRate()) + - " pluginBs=" + juce::String(plugin->getBlockSize())); + piano.processBlock(buffer, midi); + finite = finite && bufferFiniteAndBounded(buffer, 1.05f); + peak = juce::jmax(peak, peakFromFloatBuffer(buffer, buffer.getNumSamples())); + } + const double elapsedMs = juce::Time::getMillisecondCounterHiRes() - startMs; + piano.releaseResources(); + + const bool pianoHighVelocityPass = finite && peak > 1.0e-5f && peak <= 1.05f; + addSuite("piano_high_velocity_headroom_fixture", pianoHighVelocityPass, + "peak=" + juce::String(peak, 6) + + ", avg64SampleBlockMs=" + juce::String(elapsedMs / 48.0, 4)); + + TrackProcessor track; + track.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + track.setTrackType(TrackType::Instrument); + const bool fxAdded = track.addTrackFX(std::make_unique(), + fixtureSampleRate, + fixtureBlockSize); + const bool livenessPass = fxAdded + && track.needsProcessing(0.0, fixtureBlockSize, fixtureSampleRate, false); + track.releaseResources(); + addSuite("built_in_fx_instrument_liveness_fixture", livenessPass, + "fxAdded=" + juce::String(fxAdded ? "true" : "false") + + ", needsProcessingWhenStopped=" + + juce::String(livenessPass ? "true" : "false")); + } + + { + const double fixtureSampleRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + const int fixtureBlockSize = juce::jmax(512, currentBlockSize); + auto runCompressorHPFProbe = [&] (float hpfFreq) + { + S13Compressor compressor; + compressor.threshold.store(-28.0f); + compressor.ratio.store(8.0f); + compressor.attack.store(1.0f); + compressor.release.store(90.0f); + compressor.sidechainHPF.store(hpfFreq); + compressor.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + juce::MidiBuffer midi; + juce::AudioBuffer probe(2, fixtureBlockSize); + for (int block = 0; block < 8; ++block) + { + for (int sample = 0; sample < fixtureBlockSize; ++sample) + { + const double absoluteSample = static_cast(block * fixtureBlockSize + sample); + const float t = static_cast(absoluteSample / fixtureSampleRate); + const float value = std::sin(juce::MathConstants::twoPi * 60.0f * t) * 0.75f; + probe.setSample(0, sample, value); + probe.setSample(1, sample, value); + } + compressor.processBlock(probe, midi); + } + const float gr = compressor.getCurrentGainReduction(); + compressor.releaseResources(); + return gr; + }; - // Provide tempo/position info to the plugin - plugin->setPlayHead (this); + const float lowHpfGR = runCompressorHPFProbe(20.0f); + const float highHpfGR = runCompressorHPFProbe(500.0f); + const bool hpfPass = std::isfinite(lowHpfGR) + && std::isfinite(highHpfGR) + && lowHpfGR < -1.0f + && highHpfGR > lowHpfGR + 1.0f; + addSuite("compressor_sidechain_hpf_fixture", hpfPass, + "gr20Hz=" + juce::String(lowHpfGR, 4) + + ", gr500Hz=" + juce::String(highHpfGR, 4)); + } + + { + const double fixtureSampleRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + const int fixtureBlockSize = juce::jmax(512, currentBlockSize); + + S13EQ eq; + eq.bands[1].enabled.store(1.0f); + eq.bands[1].type.store(static_cast(S13EQ::FilterType::Bell)); + eq.bands[1].freq.store(1000.0f); + eq.bands[1].gain.store(6.0f); + eq.bands[1].q.store(1.0f); + eq.outputGain.store(0.0f); + eq.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + const std::vector frequencies { 100.0f, 1000.0f, 10000.0f }; + const auto response = eq.getMagnitudeResponse(frequencies); + const bool eqCurvePass = response.size() == frequencies.size() + && response[1] > 4.5f + && response[1] < 7.5f + && std::abs(response[0]) < 2.0f + && std::abs(response[2]) < 2.5f; + addSuite("built_in_eq_curve_fixture", eqCurvePass, + "100Hz=" + juce::String(response.size() > 0 ? response[0] : -999.0f, 4) + + ", 1kHz=" + juce::String(response.size() > 1 ? response[1] : -999.0f, 4) + + ", 10kHz=" + juce::String(response.size() > 2 ? response[2] : -999.0f, 4)); + eq.releaseResources(); + } + + { + const double fixtureSampleRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + const int fixtureBlockSize = juce::jmax(512, currentBlockSize); + juce::MidiBuffer midi; + + auto fillSine = [] (juce::AudioBuffer& buffer, double sampleRate, double frequency, float gain, int blockIndex) + { + for (int sample = 0; sample < buffer.getNumSamples(); ++sample) + { + const double absoluteSample = static_cast(blockIndex * buffer.getNumSamples() + sample); + const float value = std::sin(juce::MathConstants::twoPi * static_cast(frequency * absoluteSample / sampleRate)) * gain; + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + buffer.setSample(ch, sample, value); + } + }; - bool success = false; - int fxIndex = -1; - { - // Hold the callback lock while modifying the FX node vectors - // (audio thread iterates these in processBlock) - const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); - auto it = trackMap.find(trackId); - if (it != trackMap.end() && it->second) + S13Compressor compressor; + compressor.threshold.store(-24.0f); + compressor.ratio.store(6.0f); + compressor.attack.store(1.0f); + compressor.release.store(120.0f); + compressor.detectorMode.store(2.0f); + compressor.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + float compressorPeak = 0.0f; + for (int block = 0; block < 8; ++block) { - success = it->second->addTrackFX(std::move(plugin), sr, bs); - if (success) - fxIndex = it->second->getNumTrackFX() - 1; + juce::AudioBuffer buffer(2, fixtureBlockSize); + fillSine(buffer, fixtureSampleRate, 440.0, 0.82f, block); + compressor.processBlock(buffer, midi); + compressorPeak = juce::jmax(compressorPeak, peakFromFloatBuffer(buffer, buffer.getNumSamples())); } - } - - if (success && fxIndex >= 0) - { - recalculatePDC(); - - // Try ARA initialization for any VST3 plugin. ARA factory creation - // succeeds for ARA plugins and fails gracefully for non-ARA ones. - // Don't rely on hasARAExtension — cached scan data may lack the flag. - // - // IMPORTANT: For ARA plugins, do NOT open the editor here. - // The frontend must add clips to the ARA document BEFORE the editor opens, - // otherwise the editor launches with an empty document and won't show notes. - // Non-ARA plugins open the editor immediately via the callback. - auto trackIdCopy = trackId; - int fxIndexCopy = fxIndex; - bool openEditorCopy = openEditor; - - auto it2 = trackMap.find(trackId); - if (it2 != trackMap.end() && it2->second) + const float compressorGR = compressor.getCurrentGainReduction(); + compressor.releaseResources(); + + S13Gate gate; + gate.threshold.store(-26.0f); + gate.range.store(-60.0f); + gate.attackMs.store(0.1f); + gate.releaseMs.store(35.0f); + gate.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + juce::AudioBuffer gateBuffer(2, fixtureBlockSize); + fillSine(gateBuffer, fixtureSampleRate, 440.0, 0.01f, 0); + gate.processBlock(gateBuffer, midi); + const float gatePeak = peakFromFloatBuffer(gateBuffer, gateBuffer.getNumSamples()); + const float gateGR = gate.getGainReductionDB(); + gate.releaseResources(); + + S13Limiter limiter; + limiter.threshold.store(-6.0f); + limiter.ceiling.store(-1.0f); + limiter.lookaheadMs.store(5.0f); + limiter.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + float limiterPeak = 0.0f; + for (int block = 0; block < 8; ++block) { - logToDisk(" Trying ARA init for plugin at index " + juce::String(fxIndex)); - it2->second->initializeARA(fxIndex, currentSampleRate > 0 ? currentSampleRate : 44100.0, - getSafeHostedPluginBlockSize(currentBlockSize), - [this, trackIdCopy, fxIndexCopy, openEditorCopy] (bool araSuccess, bool pluginSupportsARA, const juce::String& errorMessage) { - if (araSuccess) - { - logToDisk(" ARA initialized OK for FX " + juce::String(fxIndexCopy) - + " — editor will be opened by frontend"); - // Re-propagate AudioEngine playhead to all plugins on this track. - // ARA init may have changed the plugin's playhead reference; + juce::AudioBuffer buffer(2, fixtureBlockSize); + fillSine(buffer, fixtureSampleRate, 1000.0, 1.35f, block); + limiter.processBlock(buffer, midi); + limiterPeak = juce::jmax(limiterPeak, peakFromFloatBuffer(buffer, buffer.getNumSamples())); + } + const float limiterGR = limiter.getGainReductionDB(); + limiter.releaseResources(); + + const bool dynamicsPass = std::isfinite(compressorGR) + && std::isfinite(gateGR) + && std::isfinite(limiterGR) + && compressorGR < -1.0f + && compressorPeak < 0.82f + && gatePeak < 0.006f + && gateGR < -6.0f + && limiterPeak <= 0.94f + && limiterGR < -1.0f; + addSuite("built_in_dynamics_gain_fixture", dynamicsPass, + "compressorGR=" + juce::String(compressorGR, 4) + + ", compressorPeak=" + juce::String(compressorPeak, 4) + + ", gateGR=" + juce::String(gateGR, 4) + + ", gatePeak=" + juce::String(gatePeak, 4) + + ", limiterGR=" + juce::String(limiterGR, 4) + + ", limiterPeak=" + juce::String(limiterPeak, 4)); + } + + { + const double fixtureSampleRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + const int fixtureBlockSize = juce::jmax(512, currentBlockSize); + S13Limiter limiter; + limiter.threshold.store(-3.0f); + limiter.ceiling.store(-1.0f); + limiter.lookaheadMs.store(5.0f); + limiter.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + juce::MidiBuffer midi; + float peak = 0.0f; + for (int block = 0; block < 8; ++block) + { + juce::AudioBuffer buffer(2, fixtureBlockSize); + for (int sample = 0; sample < fixtureBlockSize; ++sample) + { + const float sign = (sample & 1) == 0 ? 1.0f : -1.0f; + buffer.setSample(0, sample, sign * 1.22f); + buffer.setSample(1, sample, -sign * 1.18f); + } + limiter.processBlock(buffer, midi); + peak = juce::jmax(peak, peakFromFloatBuffer(buffer, buffer.getNumSamples())); + } + limiter.releaseResources(); + const bool limiterPass = peak <= 0.94f && std::isfinite(peak); + addSuite("limiter_true_peak_ceiling_fixture", limiterPass, + "peak=" + juce::String(peak, 6)); + } + + { + const double fixtureSampleRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + const int fixtureBlockSize = juce::jmax(512, currentBlockSize); + S13Delay delay; + delay.tempoSync.store(1.0f); + delay.syncNoteL.store(4.0f); // 1/16 at default 120 BPM = 125 ms target + delay.syncNoteR.store(4.0f); + delay.mix.store(1.0f); + delay.feedback.store(0.0f); + delay.delayMode.store(0.0f); + delay.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + juce::MidiBuffer midi; + int peakSample = -1; + float peak = 0.0f; + const int blocksToRender = static_cast(std::ceil(fixtureSampleRate * 0.45 / static_cast(fixtureBlockSize))); + for (int block = 0; block < blocksToRender; ++block) + { + juce::AudioBuffer buffer(2, fixtureBlockSize); + buffer.clear(); + if (block == 0) + { + buffer.setSample(0, 0, 1.0f); + buffer.setSample(1, 0, 1.0f); + } + delay.processBlock(buffer, midi); + for (int sample = 0; sample < fixtureBlockSize; ++sample) + { + const float value = std::abs(buffer.getSample(0, sample)); + if (value > peak) + { + peak = value; + peakSample = block * fixtureBlockSize + sample; + } + } + } + delay.releaseResources(); + const double peakMs = peakSample >= 0 ? (static_cast(peakSample) / fixtureSampleRate) * 1000.0 : -1.0; + const bool delayPass = peak > 0.2f && peakMs >= 100.0 && peakMs <= 300.0; + addSuite("delay_tempo_timing_fixture", delayPass, + "peak=" + juce::String(peak, 6) + ", peakMs=" + juce::String(peakMs, 3)); + } + + { + const double fixtureSampleRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + const int fixtureBlockSize = juce::jmax(512, currentBlockSize); + S13Reverb reverb; + reverb.dryLevel.store(0.0f); + reverb.wetLevel.store(1.0f); + reverb.earlyLevel.store(0.4f); + reverb.decayTime.store(1.5f); + reverb.roomSize.store(0.6f); + reverb.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + juce::MidiBuffer midi; + double firstHalfEnergy = 0.0; + double secondHalfEnergy = 0.0; + float peakAfter100ms = 0.0f; + const int blocksToRender = static_cast(std::ceil(fixtureSampleRate * 2.0 / static_cast(fixtureBlockSize))); + for (int block = 0; block < blocksToRender; ++block) + { + juce::AudioBuffer buffer(2, fixtureBlockSize); + buffer.clear(); + if (block == 0) + { + buffer.setSample(0, 0, 1.0f); + buffer.setSample(1, 0, 1.0f); + } + reverb.processBlock(buffer, midi); + for (int sample = 0; sample < fixtureBlockSize; ++sample) + { + const int absoluteSample = block * fixtureBlockSize + sample; + const float value = (std::abs(buffer.getSample(0, sample)) + std::abs(buffer.getSample(1, sample))) * 0.5f; + if (absoluteSample > static_cast(fixtureSampleRate * 0.1)) + peakAfter100ms = juce::jmax(peakAfter100ms, value); + const double square = static_cast(value) * static_cast(value); + if (absoluteSample < static_cast(fixtureSampleRate)) + firstHalfEnergy += square; + else + secondHalfEnergy += square; + } + } + reverb.releaseResources(); + const bool reverbPass = std::isfinite(firstHalfEnergy) + && std::isfinite(secondHalfEnergy) + && peakAfter100ms > 1.0e-5f + && secondHalfEnergy < firstHalfEnergy * 1.15; + addSuite("reverb_tail_decay_fixture", reverbPass, + "peakAfter100ms=" + juce::String(peakAfter100ms, 8) + + ", firstHalfEnergy=" + juce::String(firstHalfEnergy, 8) + + ", secondHalfEnergy=" + juce::String(secondHalfEnergy, 8)); + } + + { + const double fixtureSampleRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + const int fixtureBlockSize = juce::jmax(512, currentBlockSize); + S13Limiter limiter; + limiter.threshold.store(0.0f); + limiter.ceiling.store(0.0f); + limiter.lookaheadMs.store(6.0f); + limiter.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + juce::MidiBuffer midi; + int firstNonZeroSample = -1; + float peak = 0.0f; + const int expectedLatencySamples = static_cast(std::round(fixtureSampleRate * 0.006)); + const int blocksToRender = static_cast(std::ceil(fixtureSampleRate * 0.08 / static_cast(fixtureBlockSize))); + for (int block = 0; block < blocksToRender; ++block) + { + juce::AudioBuffer buffer(2, fixtureBlockSize); + buffer.clear(); + if (block == 0) + { + buffer.setSample(0, 0, 0.35f); + buffer.setSample(1, 0, 0.35f); + } + limiter.processBlock(buffer, midi); + for (int sample = 0; sample < fixtureBlockSize; ++sample) + { + const float value = std::abs(buffer.getSample(0, sample)); + peak = juce::jmax(peak, value); + if (firstNonZeroSample < 0 && value > 1.0e-5f) + firstNonZeroSample = block * fixtureBlockSize + sample; + } + } + limiter.releaseResources(); + + const bool latencyPass = firstNonZeroSample >= 0 + && std::abs(firstNonZeroSample - expectedLatencySamples) <= 2 + && peak > 0.25f + && peak <= 0.36f; + addSuite("built_in_latency_tail_fixture", latencyPass, + "limiterFirstNonZero=" + juce::String(firstNonZeroSample) + + ", expectedLatencySamples=" + juce::String(expectedLatencySamples) + + ", peak=" + juce::String(peak, 6)); + } + + { + const double fixtureSampleRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + const int fixtureBlockSize = juce::jmax(512, currentBlockSize); + S13Delay delay; + delay.tempoSync.store(0.0f); + delay.delayTimeL.store(85.0f); + delay.delayTimeR.store(85.0f); + delay.feedback.store(0.18f); + delay.mix.store(1.0f); + delay.lpfFreq.store(18000.0f); + delay.hpfFreq.store(20.0f); + delay.delayMode.store(0.0f); + delay.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + juce::MidiBuffer midi; + bool finite = true; + bool hasSignal = false; + float peak = 0.0f; + float maxAdjacentStep = 0.0f; + float previousSample = 0.0f; + bool hasPreviousSample = false; + const int blocksToRender = static_cast(std::ceil(fixtureSampleRate * 0.55 / static_cast(fixtureBlockSize))); + for (int block = 0; block < blocksToRender; ++block) + { + if (block == 12) + { + delay.delayTimeL.store(620.0f); + delay.delayTimeR.store(620.0f); + } + + juce::AudioBuffer buffer(2, fixtureBlockSize); + for (int sample = 0; sample < fixtureBlockSize; ++sample) + { + const double absoluteSample = static_cast(block * fixtureBlockSize + sample); + const float value = std::sin(juce::MathConstants::twoPi * static_cast(330.0 * absoluteSample / fixtureSampleRate)) * 0.28f; + buffer.setSample(0, sample, value); + buffer.setSample(1, sample, value); + } + + delay.processBlock(buffer, midi); + finite = finite && bufferFiniteAndBounded(buffer, 1.5f); + peak = juce::jmax(peak, peakFromFloatBuffer(buffer, buffer.getNumSamples())); + hasSignal = hasSignal || peak > 1.0e-5f; + for (int sample = 0; sample < fixtureBlockSize; ++sample) + { + const float currentSampleValue = buffer.getSample(0, sample); + if (hasPreviousSample) + maxAdjacentStep = juce::jmax(maxAdjacentStep, std::abs(currentSampleValue - previousSample)); + previousSample = currentSampleValue; + hasPreviousSample = true; + } + } + delay.releaseResources(); + + const bool smoothingPass = finite + && hasSignal + && peak <= 1.2f + && maxAdjacentStep < 0.72f; + addSuite("built_in_parameter_smoothing_fixture", smoothingPass, + "finite=" + juce::String(finite ? "true" : "false") + + ", peak=" + juce::String(peak, 6) + + ", maxAdjacentStep=" + juce::String(maxAdjacentStep, 6)); + } + + { + const double fixtureSampleRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + const int fixtureBlockSize = juce::jmax(512, currentBlockSize); + S13PianoInstrument piano; + piano.releaseMs.store(160.0f); + piano.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + juce::MidiBuffer midi; + juce::AudioBuffer buffer(2, fixtureBlockSize); + + midi.addEvent(juce::MidiMessage::controllerEvent(1, 64, 127), 0); + midi.addEvent(juce::MidiMessage::noteOn(1, 60, static_cast(112)), 0); + buffer.clear(); + piano.processBlock(buffer, midi); + const float attackPeak = peakFromFloatBuffer(buffer, buffer.getNumSamples()); + + midi.clear(); + midi.addEvent(juce::MidiMessage::noteOff(1, 60), 0); + buffer.clear(); + piano.processBlock(buffer, midi); + const float sustainedPeak = peakFromFloatBuffer(buffer, buffer.getNumSamples()); + + midi.clear(); + midi.addEvent(juce::MidiMessage::controllerEvent(1, 64, 0), 0); + float releaseTailPeak = 0.0f; + for (int block = 0; block < 40; ++block) + { + buffer.clear(); + piano.processBlock(buffer, midi); + midi.clear(); + releaseTailPeak = peakFromFloatBuffer(buffer, buffer.getNumSamples()); + } + piano.releaseResources(); + + const bool pianoPass = attackPeak > 1.0e-5f + && sustainedPeak > 1.0e-5f + && releaseTailPeak < sustainedPeak * 0.6f; + addSuite("piano_sustain_voice_cleanup_fixture", pianoPass, + "attackPeak=" + juce::String(attackPeak, 6) + + ", sustainedPeak=" + juce::String(sustainedPeak, 6) + + ", releaseTailPeak=" + juce::String(releaseTailPeak, 6)); + } + + { + const double fixtureSampleRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + const int fixtureBlockSize = juce::jmax(512, currentBlockSize); + S13PitchCorrector pitch; + const bool setOk = setBuiltInProcessorParam(&pitch, "mix", 0.42f) + && setBuiltInProcessorParam(&pitch, "key", 7.0f) + && setBuiltInProcessorParam(&pitch, "scale", 1.0f) + && setBuiltInProcessorParam(&pitch, "retuneSpeed", 23.0f) + && setBuiltInProcessorParam(&pitch, "transpose", 2.0f) + && setBuiltInProcessorParam(&pitch, "midiOutputEnabled", 1.0f) + && setBuiltInProcessorParam(&pitch, "midiOutputChannel", 2.0f); + + auto schema = describeBuiltInProcessor(&pitch, "track", 3); + bool schemaOk = false; + int parameterCount = 0; + if (auto* schemaObj = schema.getDynamicObject()) + { + const auto params = schemaObj->getProperty("parameters"); + bool hasMix = false; + bool hasScale = false; + bool hasRetune = false; + bool hasMidiOut = false; + if (params.isArray()) + { + parameterCount = params.getArray()->size(); + for (const auto& param : *params.getArray()) + { + if (auto* paramObj = param.getDynamicObject()) + { + const auto id = paramObj->getProperty("id").toString(); + hasMix = hasMix || id == "mix"; + hasScale = hasScale || id == "scale"; + hasRetune = hasRetune || id == "retuneSpeed"; + hasMidiOut = hasMidiOut || id == "midiOutputEnabled"; + } + } + } + schemaOk = schemaObj->getProperty("category").toString() == "Pitch" + && schemaObj->getProperty("chain").toString() == "track" + && static_cast(schemaObj->getProperty("fxIndex")) == 3 + && hasMix + && hasScale + && hasRetune + && hasMidiOut; + } + + pitch.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + pitch.bypass.store(1.0f); + juce::AudioBuffer buffer(2, fixtureBlockSize); + for (int sample = 0; sample < fixtureBlockSize; ++sample) + { + const float t = static_cast(static_cast(sample) / fixtureSampleRate); + const float left = std::sin(juce::MathConstants::twoPi * 220.0f * t) * 0.25f; + const float right = std::sin(juce::MathConstants::twoPi * 330.0f * t) * 0.18f; + buffer.setSample(0, sample, left); + buffer.setSample(1, sample, right); + } + juce::AudioBuffer original; + original.makeCopyOf(buffer); + juce::MidiBuffer midi; + pitch.processBlock(buffer, midi); + float maxDiff = 0.0f; + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + for (int sample = 0; sample < buffer.getNumSamples(); ++sample) + maxDiff = juce::jmax(maxDiff, std::abs(buffer.getSample(ch, sample) - original.getSample(ch, sample))); + pitch.releaseResources(); + + const bool pitchPass = setOk + && schemaOk + && maxDiff <= 1.0e-7f + && pitch.getMapper().getKey() == 7 + && pitch.getMapper().getScale() == PitchMapper::Scale::Major + && pitch.getMapper().getTranspose() == 2 + && pitch.midiOutputEnabled.load() >= 0.5f + && std::abs(pitch.midiOutputChannel.load() - 2.0f) <= 0.01f; + addSuite("pitch_correct_schema_bypass_fixture", pitchPass, + "setOk=" + juce::String(setOk ? "true" : "false") + + ", schemaOk=" + juce::String(schemaOk ? "true" : "false") + + ", parameterCount=" + juce::String(parameterCount) + + ", maxBypassDiff=" + juce::String(maxDiff, 9)); + } + + { + const double fixtureSampleRate = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + const int fixtureBlockSize = juce::jmax(512, currentBlockSize); + + struct SynthProbe + { + float peak = 0.0f; + float estimatedHz = 0.0f; + float releaseTailPeak = 0.0f; + bool finite = true; + }; + + auto estimatePositiveCrossingHz = [] (const std::vector& samples, double sampleRate, double startSec, double endSec) + { + const int start = juce::jlimit(1, static_cast(samples.size()) - 1, static_cast(startSec * sampleRate)); + const int end = juce::jlimit(start + 1, static_cast(samples.size()), static_cast(endSec * sampleRate)); + int crossings = 0; + for (int i = start; i < end; ++i) + if (samples[static_cast(i - 1)] <= 0.0f && samples[static_cast(i)] > 0.0f) + ++crossings; + const double duration = static_cast(end - start) / sampleRate; + return duration > 0.0 ? static_cast(static_cast(crossings) / duration) : 0.0f; + }; + + auto renderSynthProbe = [&] (int pitchWheelValue, int modWheelValue, bool releaseNote) + { + S13BasicSynthInstrument synth; + synth.detuneCents.store(0.0f); + synth.subLevel.store(0.0f); + synth.noiseLevel.store(0.0f); + synth.releaseMs.store(95.0f); + synth.outputGain.store(-12.0f); + synth.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + + SynthProbe probe; + std::vector captured; + const int totalBlocks = static_cast(std::ceil(fixtureSampleRate * 0.45 / static_cast(fixtureBlockSize))); + captured.reserve(static_cast(totalBlocks * fixtureBlockSize)); + const int noteOffSample = static_cast(fixtureSampleRate * 0.18); + juce::MidiBuffer midi; + for (int block = 0; block < totalBlocks; ++block) + { + juce::AudioBuffer buffer(2, fixtureBlockSize); + buffer.clear(); + midi.clear(); + if (block == 0) + { + midi.addEvent(juce::MidiMessage::pitchWheel(1, pitchWheelValue), 0); + midi.addEvent(juce::MidiMessage::controllerEvent(1, 1, modWheelValue), 0); + midi.addEvent(juce::MidiMessage::noteOn(1, 69, static_cast(112)), 0); + } + if (releaseNote) + { + const int blockStart = block * fixtureBlockSize; + if (noteOffSample >= blockStart && noteOffSample < blockStart + fixtureBlockSize) + midi.addEvent(juce::MidiMessage::noteOff(1, 69), noteOffSample - blockStart); + } + + synth.processBlock(buffer, midi); + probe.peak = juce::jmax(probe.peak, peakFromFloatBuffer(buffer, buffer.getNumSamples())); + for (int sample = 0; sample < buffer.getNumSamples(); ++sample) + { + const float value = buffer.getSample(0, sample); + probe.finite = probe.finite && std::isfinite(value); + captured.push_back(value); + } + } + synth.releaseResources(); + probe.estimatedHz = estimatePositiveCrossingHz(captured, fixtureSampleRate, 0.08, 0.16); + const int tailStart = juce::jlimit(0, static_cast(captured.size()), static_cast(fixtureSampleRate * 0.36)); + for (int i = tailStart; i < static_cast(captured.size()); ++i) + probe.releaseTailPeak = juce::jmax(probe.releaseTailPeak, std::abs(captured[static_cast(i)])); + return probe; + }; + + const auto normal = renderSynthProbe(8192, 0, true); + const auto bent = renderSynthProbe(16383, 0, false); + const auto modulated = renderSynthProbe(8192, 127, false); + const bool synthPass = normal.finite + && bent.finite + && modulated.finite + && normal.peak > 1.0e-5f + && normal.estimatedHz > 400.0f + && normal.estimatedHz < 480.0f + && bent.estimatedHz > normal.estimatedHz * 1.08f + && bent.estimatedHz < normal.estimatedHz * 1.17f + && modulated.peak > 1.0e-5f + && normal.releaseTailPeak < normal.peak * 0.35f; + addSuite("basic_synth_midi_pitch_fixture", synthPass, + "normalHz=" + juce::String(normal.estimatedHz, 3) + + ", bentHz=" + juce::String(bent.estimatedHz, 3) + + ", normalPeak=" + juce::String(normal.peak, 6) + + ", modPeak=" + juce::String(modulated.peak, 6) + + ", releaseTailPeak=" + juce::String(normal.releaseTailPeak, 6)); + } + + 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":"noteOn","timestamp":0.25,"note":64,"velocity":0.8,"channel":3},{"type":"noteOff","timestamp":0.35,"note":64,"velocity":0,"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; + bool hasExportScaledVelocity = false; + bool roundTripImportOk = false; + bool roundTripHasNote = 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.isNoteOn() && message.getChannel() == 3 && message.getNoteNumber() == 64 + && message.getVelocity() >= 101 && message.getVelocity() <= 103) + hasExportScaledVelocity = 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; + } + } + } + + const auto roundTrip = importMIDIFile(midiExportFile.getFullPathName()); + if (auto* roundTripObj = roundTrip.getDynamicObject()) + { + if (auto* importedTracks = roundTripObj->getProperty("tracks").getArray()) + { + roundTripImportOk = static_cast(roundTripObj->getProperty("success")) && importedTracks->size() > 0; + for (const auto& importedTrackVar : *importedTracks) + { + auto* importedTrackObj = importedTrackVar.getDynamicObject(); + if (importedTrackObj == nullptr) + continue; + + if (auto* importedEvents = importedTrackObj->getProperty("events").getArray()) + { + for (const auto& importedEventVar : *importedEvents) + { + auto* importedEventObj = importedEventVar.getDynamicObject(); + if (importedEventObj == nullptr) + continue; + + if (importedEventObj->getProperty("type").toString() == "noteOn" + && static_cast(importedEventObj->getProperty("note")) == 60) + { + roundTripHasNote = true; + } + } + } + } + } + } + } + + midiExportPass = midiExportOk + && midiReadable + && midiTrackCount > 0 + && hasExportNote + && hasExportNoteOff + && hasExportBankMSB + && hasExportBankLSB + && hasExportCC1 + && hasExportCC33 + && hasExportPitch + && hasExportProgram + && hasExportChannelPressure + && hasExportPolyPressure + && hasExportScaledVelocity + && roundTripImportOk + && roundTripHasNote; + 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") + + ", scaledVelocity=" + juce::String(hasExportScaledVelocity ? "true" : "false") + + ", roundTripImport=" + juce::String(roundTripImportOk ? "true" : "false") + + ", roundTripNote=" + juce::String(roundTripHasNote ? "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) + { + 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; + } + + 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) + { + for (int channel = 0; channel < renderBuffer.getNumChannels(); ++channel) + { + const auto* samples = renderBuffer.getReadPointer(channel); + for (int sample = 0; sample < renderBuffer.getNumSamples(); ++sample) + { + if (!std::isfinite(samples[sample])) + ++nonFiniteCount; + } + } + } + + 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 + { + combinedRenderDetail = "Temporary combined render tracks missing after addTrack"; + } + } + else + { + 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); + root->setProperty("releaseGuardrails", releaseGuardrails); + return juce::var(root); +} + +bool AudioEngine::addTrackInputFX(const juce::String& trackId, const juce::String& pluginPath, bool openEditor) +{ + // Load plugin BEFORE acquiring the lock (can take hundreds of ms). + // Use actual device sample rate so the plugin initialises at the correct rate. + // IMPORTANT: Use at least 512 for the max-block-size hint — ASIO buffers can be + // as small as 32 samples, but prepareToPlay(sr, 32) forces plugins like Amplitube + // to resize their internal DSP (FFT/convolution/cab sim) for tiny blocks, producing + // crackling and distortion. The plugin can still process 32-sample blocks fine when + // prepared with a larger maximumExpectedSamplesPerBlock. + double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; + int bs = getSafeHostedPluginBlockSize(currentBlockSize); + OpenStudioCrashDiagnostics::recordBreadcrumb("add_track_input_fx_begin", + "trackId=" + trackId + " path=" + pluginPath + " sr=" + juce::String(sr) + " block=" + juce::String(bs)); + auto plugin = pluginManager.loadPluginFromFile(pluginPath, sr, bs); + if (!plugin) + { + OpenStudioCrashDiagnostics::recordBreadcrumb("add_track_input_fx_failed", "trackId=" + trackId + " path=" + pluginPath); + return false; + } + + // Provide tempo/position info to the plugin + plugin->setPlayHead (this); + + bool success = false; + int fxIndex = -1; + { + // Hold the callback lock while modifying the FX node vectors + // (audio thread iterates these in processBlock) + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + { + success = it->second->addInputFX(std::move(plugin), sr, bs); + if (success) + fxIndex = it->second->getNumInputFX() - 1; + } + } + + if (success && fxIndex >= 0) + { + if (openEditor) + openPluginEditor(trackId, fxIndex, true); + juce::Logger::writeToLog("AudioEngine: Added input FX" + juce::String(openEditor ? " and opened editor" : "")); + OpenStudioCrashDiagnostics::recordBreadcrumb("add_track_input_fx_success", + "trackId=" + trackId + " fxIndex=" + juce::String(fxIndex) + " block=" + juce::String(bs)); + recalculatePDC(); + } + return success; +} + +bool AudioEngine::addTrackFX(const juce::String& trackId, const juce::String& pluginPath, bool openEditor) +{ + // Load plugin BEFORE acquiring the lock (can take hundreds of ms). + // Use actual device sample rate so the plugin initialises at the correct rate. + // IMPORTANT: Clamp max-block-size to at least 512 — same rationale as addTrackInputFX. + double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; + int bs = getSafeHostedPluginBlockSize(currentBlockSize); + + logToDisk("AudioEngine::addTrackFX DIAGNOSTIC"); + logToDisk(" currentSampleRate=" + juce::String(currentSampleRate) + + " currentBlockSize=" + juce::String(currentBlockSize)); + logToDisk(" creating plugin at sr=" + juce::String(sr) + " bs=" + juce::String(bs)); + + OpenStudioCrashDiagnostics::recordBreadcrumb("add_track_fx_begin", + "trackId=" + trackId + " path=" + pluginPath + " sr=" + juce::String(sr) + " block=" + juce::String(bs)); + auto plugin = pluginManager.loadPluginFromFile(pluginPath, sr, bs); + if (!plugin) + { + OpenStudioCrashDiagnostics::recordBreadcrumb("add_track_fx_failed", "trackId=" + trackId + " path=" + pluginPath); + return false; + } + + logToDisk(" plugin created: " + plugin->getName() + + " inCh=" + juce::String(plugin->getTotalNumInputChannels()) + + " outCh=" + juce::String(plugin->getTotalNumOutputChannels()) + + " pluginSr=" + juce::String(plugin->getSampleRate()) + + " pluginBs=" + juce::String(plugin->getBlockSize())); + + // Provide tempo/position info to the plugin + plugin->setPlayHead (this); + + bool success = false; + int fxIndex = -1; + { + // Hold the callback lock while modifying the FX node vectors + // (audio thread iterates these in processBlock) + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + { + success = it->second->addTrackFX(std::move(plugin), sr, bs); + if (success) + fxIndex = it->second->getNumTrackFX() - 1; + } + } + + if (success && fxIndex >= 0) + { + recalculatePDC(); + + // Try ARA initialization for any VST3 plugin. ARA factory creation + // succeeds for ARA plugins and fails gracefully for non-ARA ones. + // Don't rely on hasARAExtension — cached scan data may lack the flag. + // + // IMPORTANT: For ARA plugins, do NOT open the editor here. + // The frontend must add clips to the ARA document BEFORE the editor opens, + // otherwise the editor launches with an empty document and won't show notes. + // Non-ARA plugins open the editor immediately via the callback. + auto trackIdCopy = trackId; + int fxIndexCopy = fxIndex; + bool openEditorCopy = openEditor; + + auto it2 = trackMap.find(trackId); + if (it2 != trackMap.end() && it2->second) + { + logToDisk(" Trying ARA init for plugin at index " + juce::String(fxIndex)); + it2->second->initializeARA(fxIndex, currentSampleRate > 0 ? currentSampleRate : 44100.0, + getSafeHostedPluginBlockSize(currentBlockSize), + [this, trackIdCopy, fxIndexCopy, openEditorCopy] (bool araSuccess, bool pluginSupportsARA, const juce::String& errorMessage) { + if (araSuccess) + { + logToDisk(" ARA initialized OK for FX " + juce::String(fxIndexCopy) + + " — editor will be opened by frontend"); + // Re-propagate AudioEngine playhead to all plugins on this track. + // ARA init may have changed the plugin's playhead reference; // ensure all plugins use AudioEngine's full-featured playhead. auto araIt = trackMap.find(trackIdCopy); if (araIt != trackMap.end()) @@ -6516,35 +8372,694 @@ bool AudioEngine::addTrackFX(const juce::String& trackId, const juce::String& pl } }); } - else + else + { + logToDisk(" Track not found for ARA check, opening editor directly"); + if (openEditor) + openPluginEditor(trackId, fxIndex, false); + } + + logToDisk(" addTrackFX complete (ARA check pending)"); + OpenStudioCrashDiagnostics::recordBreadcrumb("add_track_fx_success", + "trackId=" + trackId + " fxIndex=" + juce::String(fxIndex) + " block=" + juce::String(bs)); + } + return success; +} + +//============================================================================== +// Built-in Effects (Phase 4.3) + +static std::unique_ptr createBuiltInEffect(const juce::String& name) +{ + if (name == "Studio13 Basic Synth" || name == "OpenStudio Basic Synth") return std::make_unique(); + if (name == "Studio13 Piano" || name == "OpenStudio Piano") return std::make_unique(); + if (name == "Studio13 Drums" || name == "OpenStudio Drums") return std::make_unique(); + if (name == "S13 EQ" || name == "OpenStudio EQ") return std::make_unique(); + if (name == "S13 Compressor" || name == "OpenStudio Compressor") return std::make_unique(); + if (name == "S13 Gate" || name == "OpenStudio Gate") return std::make_unique(); + if (name == "S13 Limiter" || name == "OpenStudio Limiter") return std::make_unique(); + if (name == "S13 Delay" || name == "OpenStudio Delay") return std::make_unique(); + if (name == "S13 Reverb" || name == "OpenStudio Reverb") return std::make_unique(); + if (name == "S13 Chorus" || name == "OpenStudio Chorus") return std::make_unique(); + if (name == "S13 Saturator" || name == "OpenStudio Saturator") return std::make_unique(); + if (name == "S13 Pitch Correct" || name == "OpenStudio Pitch Correct") return std::make_unique(); + return nullptr; +} + +static juce::var makeBuiltInParam(const juce::String& id, + const juce::String& label, + const juce::String& type, + float value, + float minValue, + float maxValue, + float defaultValue, + const juce::String& unit = {}, + const juce::String& graphRole = {}, + juce::Array enumOptions = {}) +{ + auto* obj = new juce::DynamicObject(); + obj->setProperty("id", id); + obj->setProperty("label", label); + obj->setProperty("type", type); + obj->setProperty("value", value); + obj->setProperty("min", minValue); + obj->setProperty("max", maxValue); + obj->setProperty("defaultValue", defaultValue); + obj->setProperty("unit", unit); + obj->setProperty("automatable", true); + if (graphRole.isNotEmpty()) + obj->setProperty("graphRole", graphRole); + if (!enumOptions.isEmpty()) + obj->setProperty("enumOptions", enumOptions); + return juce::var(obj); +} + +static juce::Array makeEnumOptions(std::initializer_list names) +{ + juce::Array options; + int index = 0; + for (auto* name : names) + { + auto* option = new juce::DynamicObject(); + option->setProperty("value", static_cast(index)); + option->setProperty("label", juce::String(name)); + options.add(juce::var(option)); + ++index; + } + return options; +} + +static juce::Array makeFloatVarArray(const std::vector& values) +{ + juce::Array result; + for (float value : values) + result.add(value); + return result; +} + +static juce::var makeBuiltInSchemaObject(const juce::String& name, + const juce::String& category, + const juce::String& chainType, + int fxIndex, + const juce::Array& params) +{ + auto* root = new juce::DynamicObject(); + root->setProperty("schemaVersion", 1); + root->setProperty("name", name); + root->setProperty("category", category); + root->setProperty("chain", chainType); + root->setProperty("fxIndex", fxIndex); + root->setProperty("parameters", params); + return juce::var(root); +} + +static void addToggleParam(juce::Array& params, + const juce::String& id, + const juce::String& label, + float value, + float defaultValue, + const juce::String& graphRole = {}) +{ + params.add(makeBuiltInParam(id, label, "toggle", value, 0.0f, 1.0f, defaultValue, {}, graphRole)); +} + +static void addContinuousParam(juce::Array& params, + const juce::String& id, + const juce::String& label, + float value, + float minValue, + float maxValue, + float defaultValue, + const juce::String& unit = {}, + const juce::String& graphRole = {}) +{ + params.add(makeBuiltInParam(id, label, "continuous", value, minValue, maxValue, defaultValue, unit, graphRole)); +} + +static void addEnumParam(juce::Array& params, + const juce::String& id, + const juce::String& label, + float value, + float defaultValue, + const juce::Array& enumOptions, + const juce::String& graphRole = {}) +{ + params.add(makeBuiltInParam(id, label, "enum", value, 0.0f, + static_cast(juce::jmax(0, enumOptions.size() - 1)), + defaultValue, {}, graphRole, enumOptions)); +} + +static bool storeClamped(std::atomic& target, float value, float minValue, float maxValue) +{ + target.store(juce::jlimit(minValue, maxValue, value), std::memory_order_relaxed); + return true; +} + +static juce::var describeFallbackInstrument(TrackProcessor* track, const juce::String& chainType, int fxIndex) +{ + juce::Array params; + if (track == nullptr) + return makeBuiltInSchemaObject("OpenStudio Basic Synth", "Instrument", chainType, fxIndex, params); + + const auto instrumentModes = makeEnumOptions({ "Basic Synth", "Piano", "Drums" }); + const auto drumKits = makeEnumOptions({ "Studio", "Rock", "Electronic" }); + const int mode = juce::jlimit(0, 2, static_cast(std::round(track->getFallbackInstrumentParam("instrumentMode")))); + addEnumParam(params, "instrumentMode", "Instrument", static_cast(mode), 0.0f, instrumentModes, "instrument"); + addContinuousParam(params, "attackMs", "Attack", track->getFallbackInstrumentParam("attackMs"), 0.5f, 2000.0f, 8.0f, "ms", "envelope"); + addContinuousParam(params, "releaseMs", "Release", track->getFallbackInstrumentParam("releaseMs"), 5.0f, 5000.0f, 180.0f, "ms", "envelope"); + addContinuousParam(params, "brightness", "Brightness", track->getFallbackInstrumentParam("brightness"), 0.0f, 1.0f, 0.62f, {}, "tone"); + addContinuousParam(params, "detuneCents", "Detune", track->getFallbackInstrumentParam("detuneCents"), 0.0f, 35.0f, 7.0f, "ct", "oscillator"); + addContinuousParam(params, "subLevel", "Sub", track->getFallbackInstrumentParam("subLevel"), 0.0f, 0.8f, 0.18f, {}, "oscillator"); + addContinuousParam(params, "noiseLevel", "Air", track->getFallbackInstrumentParam("noiseLevel"), 0.0f, 0.25f, 0.015f, {}, "oscillator"); + addContinuousParam(params, "pianoTone", "Piano Tone", track->getFallbackInstrumentParam("pianoTone"), 0.0f, 1.0f, 0.58f, {}, "piano"); + addContinuousParam(params, "pianoBody", "Piano Body", track->getFallbackInstrumentParam("pianoBody"), 0.0f, 1.0f, 0.72f, {}, "piano"); + addEnumParam(params, "drumKit", "Drum Kit", track->getFallbackInstrumentParam("drumKit"), 0.0f, drumKits, "drums"); + addContinuousParam(params, "drumTuning", "Drum Tuning", track->getFallbackInstrumentParam("drumTuning"), -12.0f, 12.0f, 0.0f, "st", "drums"); + addContinuousParam(params, "drumAmbience", "Drum Room", track->getFallbackInstrumentParam("drumAmbience"), 0.0f, 1.0f, 0.18f, {}, "drums"); + addContinuousParam(params, "outputGainDb", "Output", track->getFallbackInstrumentParam("outputGainDb"), -36.0f, 0.0f, -15.0f, "dB", "output"); + + const juce::String name = track->hasFallbackSamplerSample() + ? "OpenStudio Basic Sampler" + : (mode == 1 ? "OpenStudio Piano" : (mode == 2 ? "OpenStudio Drums" : "OpenStudio Basic Synth")); + return makeBuiltInSchemaObject(name, + "Instrument", chainType, fxIndex, params); +} + +static juce::var describeBuiltInProcessor(juce::AudioProcessor* processor, + const juce::String& chainType, + int fxIndex) +{ + juce::Array params; + if (processor == nullptr) + return makeBuiltInSchemaObject({}, {}, chainType, fxIndex, params); + + if (auto* synth = dynamic_cast(processor)) + { + addContinuousParam(params, "attackMs", "Attack", synth->attackMs.load(), 0.5f, 2000.0f, 8.0f, "ms", "envelope"); + addContinuousParam(params, "releaseMs", "Release", synth->releaseMs.load(), 5.0f, 5000.0f, 180.0f, "ms", "envelope"); + addContinuousParam(params, "brightness", "Brightness", synth->brightness.load(), 0.0f, 1.0f, 0.62f, {}, "tone"); + addContinuousParam(params, "detuneCents", "Detune", synth->detuneCents.load(), 0.0f, 35.0f, 7.0f, "ct", "oscillator"); + addContinuousParam(params, "subLevel", "Sub", synth->subLevel.load(), 0.0f, 0.8f, 0.18f, {}, "oscillator"); + addContinuousParam(params, "noiseLevel", "Air", synth->noiseLevel.load(), 0.0f, 0.25f, 0.015f, {}, "oscillator"); + addContinuousParam(params, "outputGain", "Output", synth->outputGain.load(), -36.0f, 0.0f, -15.0f, "dB", "output"); + return makeBuiltInSchemaObject(synth->getName(), "Instrument", chainType, fxIndex, params); + } + + if (auto* piano = dynamic_cast(processor)) + { + addEnumParam(params, "model", "Model", piano->model.load(), 0.0f, makeEnumOptions({ "Studio Grand", "Bright Upright", "Soft Felt" }), "character"); + addContinuousParam(params, "tone", "Tone", piano->tone.load(), 0.0f, 1.0f, 0.58f, {}, "tone"); + addContinuousParam(params, "body", "Body", piano->body.load(), 0.0f, 1.0f, 0.72f, {}, "body"); + addContinuousParam(params, "hammer", "Hammer", piano->hammer.load(), 0.0f, 1.0f, 0.42f, {}, "character"); + addContinuousParam(params, "resonance", "Resonance", piano->resonance.load(), 0.0f, 1.0f, 0.38f, {}, "body"); + addContinuousParam(params, "stereoWidth", "Width", piano->stereoWidth.load(), 0.0f, 1.0f, 0.62f, {}, "width"); + addContinuousParam(params, "releaseMs", "Release", piano->releaseMs.load(), 80.0f, 5000.0f, 950.0f, "ms", "envelope"); + addContinuousParam(params, "outputGain", "Output", piano->outputGain.load(), -36.0f, 0.0f, -15.0f, "dB", "output"); + return makeBuiltInSchemaObject(piano->getName(), "Instrument", chainType, fxIndex, params); + } + + if (auto* drums = dynamic_cast(processor)) + { + addEnumParam(params, "kit", "Kit", drums->kit.load(), 0.0f, makeEnumOptions({ "Studio", "Rock", "Electronic" }), "drums"); + addEnumParam(params, "mapPreset", "MIDI Map", drums->mapPreset.load(), 0.0f, makeEnumOptions({ "GM", "Roland TD" }), "drums"); + addContinuousParam(params, "tuning", "Tuning", drums->tuning.load(), -12.0f, 12.0f, 0.0f, "st", "drums"); + addContinuousParam(params, "ambience", "Room", drums->ambience.load(), 0.0f, 1.0f, 0.18f, {}, "space"); + addContinuousParam(params, "hihatTightness", "Hat Tightness", drums->hihatTightness.load(), 0.0f, 1.0f, 0.65f, {}, "drums"); + addContinuousParam(params, "punch", "Punch", drums->punch.load(), 0.0f, 1.0f, 0.55f, {}, "character"); + addContinuousParam(params, "stereoWidth", "Width", drums->stereoWidth.load(), 0.0f, 1.0f, 0.7f, {}, "width"); + addContinuousParam(params, "velocityCurve", "Velocity Curve", drums->velocityCurve.load(), -1.0f, 1.0f, 0.0f, {}, "drums"); + addContinuousParam(params, "outputGain", "Output", drums->outputGain.load(), -36.0f, 0.0f, -10.0f, "dB", "output"); + return makeBuiltInSchemaObject(drums->getName(), "Instrument", chainType, fxIndex, params); + } + + if (auto* eq = dynamic_cast(processor)) + { + const auto types = makeEnumOptions({ "Bell", "Low Shelf", "High Shelf", "Low Cut", "High Cut", "Notch", "Band Pass" }); + const auto slopes = makeEnumOptions({ "6 dB/oct", "12 dB/oct", "24 dB/oct", "48 dB/oct" }); + for (int band = 0; band < S13EQ::numBands; ++band) + { + const auto prefix = "band" + juce::String(band) + "."; + const auto bandLabel = "Band " + juce::String(band + 1) + " "; + addToggleParam(params, prefix + "enabled", bandLabel + "On", eq->bands[band].enabled.load(), band == 0 || band == S13EQ::numBands - 1 ? 0.0f : 1.0f, "eqBand"); + addEnumParam(params, prefix + "type", bandLabel + "Type", eq->bands[band].type.load(), 0.0f, types, "eqBand"); + addContinuousParam(params, prefix + "freq", bandLabel + "Freq", eq->bands[band].freq.load(), 20.0f, 20000.0f, 1000.0f, "Hz", "eqBand"); + addContinuousParam(params, prefix + "gain", bandLabel + "Gain", eq->bands[band].gain.load(), -30.0f, 30.0f, 0.0f, "dB", "eqBand"); + addContinuousParam(params, prefix + "q", bandLabel + "Q", eq->bands[band].q.load(), 0.1f, 30.0f, 1.0f, {}, "eqBand"); + addEnumParam(params, prefix + "slope", bandLabel + "Slope", eq->bands[band].slope.load(), 1.0f, slopes, "eqBand"); + addToggleParam(params, prefix + "dynamicEnabled", bandLabel + "Dyn", eq->bands[band].dynamicEnabled.load(), 0.0f, "dynamic"); + addContinuousParam(params, prefix + "dynamicThreshold", bandLabel + "Dyn Thresh", eq->bands[band].dynamicThreshold.load(), -80.0f, 0.0f, -24.0f, "dB", "dynamic"); + addContinuousParam(params, prefix + "dynamicRange", bandLabel + "Dyn Range", eq->bands[band].dynamicRange.load(), -24.0f, 24.0f, 0.0f, "dB", "dynamic"); + addContinuousParam(params, prefix + "dynamicAttack", bandLabel + "Dyn Attack", eq->bands[band].dynamicAttack.load(), 0.2f, 250.0f, 10.0f, "ms", "dynamic"); + addContinuousParam(params, prefix + "dynamicRelease", bandLabel + "Dyn Release", eq->bands[band].dynamicRelease.load(), 5.0f, 2000.0f, 150.0f, "ms", "dynamic"); + } + addContinuousParam(params, "outputGain", "Output", eq->outputGain.load(), -12.0f, 12.0f, 0.0f, "dB", "output"); + addToggleParam(params, "autoGain", "Auto Gain", eq->autoGain.load(), 0.0f, "output"); + addEnumParam(params, "auditionBand", "Audition", eq->auditionBand.load(), 0.0f, + makeEnumOptions({ "Off", "Band 1", "Band 2", "Band 3", "Band 4", "Band 5", "Band 6", "Band 7", "Band 8" }), "eqBand"); + addEnumParam(params, "stereoMode", "Processing", eq->stereoMode.load(), 0.0f, makeEnumOptions({ "Stereo", "Mid", "Side" }), "routing"); + + auto schema = makeBuiltInSchemaObject(eq->getName(), "EQ", chainType, fxIndex, params); + if (auto* schemaObject = schema.getDynamicObject()) { - logToDisk(" Track not found for ARA check, opening editor directly"); - if (openEditor) - openPluginEditor(trackId, fxIndex, false); + std::vector frequencies; + frequencies.reserve(96); + for (int i = 0; i < 96; ++i) + { + const float t = static_cast(i) / 95.0f; + frequencies.push_back(20.0f * std::pow(1000.0f, t)); + } + + auto response = eq->getMagnitudeResponse(frequencies); + auto spectrum = eq->getSpectrumData(); + std::vector dynamicGains; + dynamicGains.reserve(S13EQ::numBands); + for (int band = 0; band < S13EQ::numBands; ++band) + dynamicGains.push_back(eq->getBandDynamicGainDB(band)); + const double sr = eq->getSampleRate() > 0.0 ? eq->getSampleRate() : 44100.0; + std::vector preSpectrum; + std::vector postSpectrum; + preSpectrum.reserve(frequencies.size()); + postSpectrum.reserve(frequencies.size()); + for (float freq : frequencies) + { + const int bin = juce::jlimit(0, S13EQ::fftSize / 2 - 1, + static_cast(std::round((static_cast(freq) / (sr * 0.5)) * static_cast(S13EQ::fftSize / 2 - 1)))); + preSpectrum.push_back(spectrum.ready ? spectrum.preEQ[static_cast(bin)] : -100.0f); + postSpectrum.push_back(spectrum.ready ? spectrum.postEQ[static_cast(bin)] : -100.0f); + } + + juce::DynamicObject::Ptr viz = new juce::DynamicObject(); + viz->setProperty("frequencies", makeFloatVarArray(frequencies)); + viz->setProperty("responseDb", makeFloatVarArray(response)); + viz->setProperty("spectrumPreDb", makeFloatVarArray(preSpectrum)); + viz->setProperty("spectrumPostDb", makeFloatVarArray(postSpectrum)); + viz->setProperty("dynamicGainDb", makeFloatVarArray(dynamicGains)); + viz->setProperty("spectrumReady", spectrum.ready); + schemaObject->setProperty("visualization", viz.get()); + } + return schema; + } + + if (auto* compressor = dynamic_cast(processor)) + { + addContinuousParam(params, "threshold", "Threshold", compressor->threshold.load(), -60.0f, 0.0f, 0.0f, "dB", "dynamics"); + addContinuousParam(params, "ratio", "Ratio", compressor->ratio.load(), 1.0f, 20.0f, 1.0f, ":1", "dynamics"); + addContinuousParam(params, "attack", "Attack", compressor->attack.load(), 0.1f, 100.0f, 10.0f, "ms", "dynamics"); + addContinuousParam(params, "release", "Release", compressor->release.load(), 10.0f, 2000.0f, 100.0f, "ms", "dynamics"); + addContinuousParam(params, "knee", "Knee", compressor->knee.load(), 0.0f, 24.0f, 0.0f, "dB", "dynamics"); + addContinuousParam(params, "makeupGain", "Makeup", compressor->makeupGain.load(), 0.0f, 36.0f, 0.0f, "dB", "output"); + addContinuousParam(params, "mix", "Mix", compressor->mix.load(), 0.0f, 1.0f, 1.0f, {}, "mix"); + addEnumParam(params, "style", "Style", compressor->style.load(), 0.0f, makeEnumOptions({ "Clean", "Punch", "Opto", "FET", "VCA" }), "character"); + addToggleParam(params, "autoMakeup", "Auto Makeup", compressor->autoMakeup.load(), 0.0f, "output"); + addToggleParam(params, "autoRelease", "Auto Release", compressor->autoRelease.load(), 0.0f, "dynamics"); + addContinuousParam(params, "sidechainHPF", "SC HPF", compressor->sidechainHPF.load(), 20.0f, 500.0f, 20.0f, "Hz", "sidechain"); + addContinuousParam(params, "lookaheadMs", "Lookahead", compressor->lookaheadMs.load(), 0.0f, 20.0f, 0.0f, "ms", "dynamics"); + addEnumParam(params, "detectorMode", "Detector", compressor->detectorMode.load(), 0.0f, makeEnumOptions({ "Peak", "RMS", "Auto" }), "detection"); + addContinuousParam(params, "stereoLink", "Stereo Link", compressor->stereoLink.load(), 0.0f, 1.0f, 1.0f, {}, "detection"); + auto schema = makeBuiltInSchemaObject(compressor->getName(), "Dynamics", chainType, fxIndex, params); + if (auto* schemaObject = schema.getDynamicObject()) + { + juce::DynamicObject::Ptr viz = new juce::DynamicObject(); + viz->setProperty("gainReductionDb", compressor->getCurrentGainReduction()); + viz->setProperty("inputLevelDb", compressor->getInputLevel()); + viz->setProperty("outputLevelDb", compressor->getOutputLevel()); + schemaObject->setProperty("visualization", viz.get()); + } + return schema; + } + + if (auto* gate = dynamic_cast(processor)) + { + addContinuousParam(params, "threshold", "Threshold", gate->threshold.load(), -80.0f, 0.0f, -40.0f, "dB", "dynamics"); + addContinuousParam(params, "attackMs", "Attack", gate->attackMs.load(), 0.01f, 50.0f, 1.0f, "ms", "dynamics"); + addContinuousParam(params, "holdMs", "Hold", gate->holdMs.load(), 0.0f, 500.0f, 50.0f, "ms", "dynamics"); + addContinuousParam(params, "releaseMs", "Release", gate->releaseMs.load(), 5.0f, 2000.0f, 50.0f, "ms", "dynamics"); + addContinuousParam(params, "range", "Range", gate->range.load(), -80.0f, 0.0f, -80.0f, "dB", "dynamics"); + addContinuousParam(params, "hysteresis", "Hysteresis", gate->hysteresis.load(), 0.0f, 20.0f, 0.0f, "dB", "dynamics"); + addContinuousParam(params, "sidechainHPF", "SC HPF", gate->sidechainHPF.load(), 20.0f, 2000.0f, 20.0f, "Hz", "sidechain"); + addContinuousParam(params, "sidechainLPF", "SC LPF", gate->sidechainLPF.load(), 200.0f, 20000.0f, 20000.0f, "Hz", "sidechain"); + addContinuousParam(params, "mix", "Mix", gate->mix.load(), 0.0f, 1.0f, 1.0f, {}, "mix"); + addEnumParam(params, "detectorMode", "Detector", gate->detectorMode.load(), 0.0f, makeEnumOptions({ "Peak", "RMS", "Auto" }), "detection"); + auto schema = makeBuiltInSchemaObject(gate->getName(), "Dynamics", chainType, fxIndex, params); + if (auto* schemaObject = schema.getDynamicObject()) + { + juce::DynamicObject::Ptr viz = new juce::DynamicObject(); + viz->setProperty("gainReductionDb", gate->getGainReductionDB()); + viz->setProperty("gateOpen", gate->isGateOpen()); + schemaObject->setProperty("visualization", viz.get()); + } + return schema; + } + + if (auto* limiter = dynamic_cast(processor)) + { + addContinuousParam(params, "threshold", "Threshold", limiter->threshold.load(), -20.0f, 0.0f, -1.0f, "dB", "dynamics"); + addContinuousParam(params, "releaseMs", "Release", limiter->releaseMs.load(), 10.0f, 500.0f, 100.0f, "ms", "dynamics"); + addContinuousParam(params, "ceiling", "Ceiling", limiter->ceiling.load(), -3.0f, 0.0f, 0.0f, "dB", "output"); + addContinuousParam(params, "lookaheadMs", "Lookahead", limiter->lookaheadMs.load(), 0.0f, 20.0f, 5.0f, "ms", "dynamics"); + auto schema = makeBuiltInSchemaObject(limiter->getName(), "Dynamics", chainType, fxIndex, params); + if (auto* schemaObject = schema.getDynamicObject()) + { + juce::DynamicObject::Ptr viz = new juce::DynamicObject(); + viz->setProperty("gainReductionDb", limiter->getGainReductionDB()); + schemaObject->setProperty("visualization", viz.get()); } + return schema; + } + + if (auto* delay = dynamic_cast(processor)) + { + addContinuousParam(params, "delayTimeL", "Delay L", delay->delayTimeL.load(), 1.0f, 2000.0f, 250.0f, "ms", "time"); + addContinuousParam(params, "delayTimeR", "Delay R", delay->delayTimeR.load(), 1.0f, 2000.0f, 250.0f, "ms", "time"); + addContinuousParam(params, "feedback", "Feedback", delay->feedback.load(), 0.0f, 0.95f, 0.4f, {}, "feedback"); + addContinuousParam(params, "crossFeed", "Crossfeed", delay->crossFeed.load(), 0.0f, 0.95f, 0.0f, {}, "feedback"); + addContinuousParam(params, "mix", "Mix", delay->mix.load(), 0.0f, 1.0f, 0.5f, {}, "mix"); + addToggleParam(params, "pingPong", "Ping Pong", delay->pingPong.load(), 0.0f, "time"); + addToggleParam(params, "tempoSync", "Sync", delay->tempoSync.load(), 0.0f, "time"); + addEnumParam(params, "syncNoteL", "Note L", delay->syncNoteL.load(), 0.0f, makeEnumOptions({ "1/1", "1/2", "1/4", "1/8", "1/16", "1/4T", "1/8T", "1/4D", "1/8D" }), "time"); + addEnumParam(params, "syncNoteR", "Note R", delay->syncNoteR.load(), 0.0f, makeEnumOptions({ "1/1", "1/2", "1/4", "1/8", "1/16", "1/4T", "1/8T", "1/4D", "1/8D" }), "time"); + addContinuousParam(params, "lpfFreq", "LPF", delay->lpfFreq.load(), 200.0f, 20000.0f, 20000.0f, "Hz", "tone"); + addContinuousParam(params, "hpfFreq", "HPF", delay->hpfFreq.load(), 20.0f, 2000.0f, 20.0f, "Hz", "tone"); + addContinuousParam(params, "fbSaturation", "Saturation", delay->fbSaturation.load(), 0.0f, 1.0f, 0.0f, {}, "character"); + addContinuousParam(params, "stereoWidth", "Width", delay->stereoWidth.load(), 0.0f, 2.0f, 1.0f, {}, "width"); + addEnumParam(params, "delayMode", "Mode", delay->delayMode.load(), 0.0f, makeEnumOptions({ "Digital", "Tape", "Analog" }), "character"); + addContinuousParam(params, "ducking", "Ducking", delay->ducking.load(), 0.0f, 1.0f, 0.0f, {}, "dynamics"); + return makeBuiltInSchemaObject(delay->getName(), "Delay", chainType, fxIndex, params); + } + + if (auto* reverb = dynamic_cast(processor)) + { + addEnumParam(params, "algorithm", "Algorithm", reverb->algorithm.load(), 0.0f, makeEnumOptions({ "Room", "Hall", "Plate", "Chamber", "Shimmer" }), "space"); + addContinuousParam(params, "roomSize", "Size", reverb->roomSize.load(), 0.0f, 1.0f, 0.5f, {}, "space"); + addContinuousParam(params, "damping", "Damping", reverb->damping.load(), 0.0f, 1.0f, 0.5f, {}, "tone"); + addContinuousParam(params, "wetLevel", "Wet", reverb->wetLevel.load(), 0.0f, 1.0f, 0.33f, {}, "mix"); + addContinuousParam(params, "dryLevel", "Dry", reverb->dryLevel.load(), 0.0f, 1.0f, 0.7f, {}, "mix"); + addContinuousParam(params, "width", "Width", reverb->width.load(), 0.0f, 1.0f, 1.0f, {}, "width"); + addToggleParam(params, "freezeMode", "Freeze", reverb->freezeMode.load(), 0.0f, "space"); + addContinuousParam(params, "preDelay", "Pre-delay", reverb->preDelay.load(), 0.0f, 500.0f, 0.0f, "ms", "time"); + addContinuousParam(params, "diffusion", "Diffusion", reverb->diffusion.load(), 0.0f, 1.0f, 0.5f, {}, "space"); + addContinuousParam(params, "lowCut", "Low Cut", reverb->lowCut.load(), 20.0f, 500.0f, 20.0f, "Hz", "tone"); + addContinuousParam(params, "highCut", "High Cut", reverb->highCut.load(), 1000.0f, 20000.0f, 20000.0f, "Hz", "tone"); + addContinuousParam(params, "earlyLevel", "Early", reverb->earlyLevel.load(), 0.0f, 1.0f, 0.5f, {}, "space"); + addContinuousParam(params, "decayTime", "Decay", reverb->decayTime.load(), 0.1f, 20.0f, 2.0f, "s", "space"); + return makeBuiltInSchemaObject(reverb->getName(), "Reverb", chainType, fxIndex, params); + } + + if (auto* chorus = dynamic_cast(processor)) + { + addEnumParam(params, "mode", "Mode", chorus->mode.load(), 0.0f, makeEnumOptions({ "Chorus", "Flanger", "Phaser" }), "modulation"); + addContinuousParam(params, "rate", "Rate", chorus->rate.load(), 0.01f, 20.0f, 1.0f, "Hz", "modulation"); + addContinuousParam(params, "depth", "Depth", chorus->depth.load(), 0.0f, 1.0f, 0.5f, {}, "modulation"); + addContinuousParam(params, "fbAmount", "Feedback", chorus->fbAmount.load(), -1.0f, 1.0f, 0.0f, {}, "feedback"); + addContinuousParam(params, "mix", "Mix", chorus->mix.load(), 0.0f, 1.0f, 0.5f, {}, "mix"); + addContinuousParam(params, "voices", "Voices", chorus->voices.load(), 1.0f, 6.0f, 2.0f, {}, "modulation"); + addEnumParam(params, "lfoShape", "LFO", chorus->lfoShape.load(), 0.0f, makeEnumOptions({ "Sine", "Triangle", "Square", "S&H" }), "modulation"); + addContinuousParam(params, "spread", "Spread", chorus->spread.load(), 0.0f, 1.0f, 0.5f, {}, "width"); + addEnumParam(params, "characterMode", "Character", chorus->characterMode.load(), 0.0f, makeEnumOptions({ "Clean", "Ensemble", "BBD" }), "character"); + addContinuousParam(params, "highCut", "High Cut", chorus->highCut.load(), 200.0f, 20000.0f, 20000.0f, "Hz", "tone"); + addContinuousParam(params, "lowCut", "Low Cut", chorus->lowCut.load(), 20.0f, 2000.0f, 20.0f, "Hz", "tone"); + addToggleParam(params, "tempoSync", "Sync", chorus->tempoSync.load(), 0.0f, "modulation"); + return makeBuiltInSchemaObject(chorus->getName(), "Modulation", chainType, fxIndex, params); + } + + if (auto* saturator = dynamic_cast(processor)) + { + addEnumParam(params, "satType", "Type", saturator->satType.load(), 0.0f, makeEnumOptions({ "Tape", "Tube", "Transistor", "Clip", "Crush", "Console", "Transformer", "Foldback" }), "character"); + addContinuousParam(params, "drive", "Drive", saturator->drive.load(), 0.0f, 30.0f, 6.0f, "dB", "drive"); + addContinuousParam(params, "mix", "Mix", saturator->mix.load(), 0.0f, 1.0f, 1.0f, {}, "mix"); + addContinuousParam(params, "toneFreq", "Tone", saturator->toneFreq.load(), 200.0f, 20000.0f, 20000.0f, "Hz", "tone"); + addContinuousParam(params, "lowCutFreq", "Low Cut", saturator->lowCutFreq.load(), 20.0f, 1000.0f, 20.0f, "Hz", "tone"); + addContinuousParam(params, "outputGain", "Output", saturator->outputGain.load(), -12.0f, 0.0f, 0.0f, "dB", "output"); + addContinuousParam(params, "asymmetry", "Bias", saturator->asymmetry.load(), -1.0f, 1.0f, 0.0f, {}, "character"); + addEnumParam(params, "oversampleMode", "Oversampling", saturator->oversampleMode.load(), 1.0f, makeEnumOptions({ "Off", "2x", "4x" }), "quality"); + return makeBuiltInSchemaObject(saturator->getName(), "Saturation", chainType, fxIndex, params); + } + + if (auto* pitch = dynamic_cast(processor)) + { + auto& mapper = pitch->getMapper(); + addToggleParam(params, "bypass", "Bypass", pitch->bypass.load(), 0.0f, "mix"); + addContinuousParam(params, "mix", "Mix", pitch->mix.load(), 0.0f, 1.0f, 1.0f, {}, "mix"); + addContinuousParam(params, "sensitivity", "Sensitivity", pitch->sensitivity.load(), 0.02f, 0.35f, 0.15f, {}, "detection"); + addContinuousParam(params, "minFreqParam", "Min Freq", pitch->minFreqParam.load(), 40.0f, 400.0f, 80.0f, "Hz", "detection"); + addContinuousParam(params, "maxFreqParam", "Max Freq", pitch->maxFreqParam.load(), 400.0f, 4000.0f, 1000.0f, "Hz", "detection"); + addEnumParam(params, "key", "Key", static_cast(mapper.getKey()), 0.0f, makeEnumOptions({ "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" }), "scale"); + addEnumParam(params, "scale", "Scale", static_cast(mapper.getScale()), 0.0f, + makeEnumOptions({ "Chromatic", "Major", "Natural Minor", "Harmonic Minor", "Melodic Minor", "Pentatonic Major", "Pentatonic Minor", "Blues", "Dorian", "Mixolydian", "Lydian", "Phrygian", "Locrian", "Whole Tone", "Diminished", "Custom" }), "scale"); + addContinuousParam(params, "retuneSpeed", "Retune", mapper.getRetuneSpeed(), 0.0f, 400.0f, 50.0f, "ms", "correction"); + addContinuousParam(params, "humanize", "Humanize", mapper.getHumanize(), 0.0f, 100.0f, 0.0f, "%", "correction"); + addContinuousParam(params, "transpose", "Transpose", static_cast(mapper.getTranspose()), -24.0f, 24.0f, 0.0f, "st", "correction"); + addContinuousParam(params, "correctionStrength", "Strength", mapper.getCorrectionStrength(), 0.0f, 1.0f, 1.0f, {}, "correction"); + addToggleParam(params, "formantCorrection", "Formants", mapper.getFormantCorrection() ? 1.0f : 0.0f, 0.0f, "formant"); + addContinuousParam(params, "formantShift", "Formant Shift", mapper.getFormantShift(), -12.0f, 12.0f, 0.0f, "st", "formant"); + addToggleParam(params, "midiOutputEnabled", "MIDI Out", pitch->midiOutputEnabled.load(), 0.0f, "midi"); + addContinuousParam(params, "midiOutputChannel", "MIDI Ch", pitch->midiOutputChannel.load(), 1.0f, 16.0f, 1.0f, {}, "midi"); + auto schema = makeBuiltInSchemaObject(pitch->getName(), "Pitch", chainType, fxIndex, params); + if (auto* schemaObject = schema.getDynamicObject()) + { + const auto pitchData = pitch->getCurrentPitchData(); + const auto history = pitch->getPitchHistory(96); + std::vector detectedMidi; + std::vector correctedMidi; + std::vector confidence; + detectedMidi.reserve(history.size()); + correctedMidi.reserve(history.size()); + confidence.reserve(history.size()); + for (const auto& frame : history) + { + detectedMidi.push_back(frame.detectedMidi); + correctedMidi.push_back(frame.correctedMidi); + confidence.push_back(frame.confidence); + } - logToDisk(" addTrackFX complete (ARA check pending)"); - OpenStudioCrashDiagnostics::recordBreadcrumb("add_track_fx_success", - "trackId=" + trackId + " fxIndex=" + juce::String(fxIndex) + " block=" + juce::String(bs)); + juce::DynamicObject::Ptr viz = new juce::DynamicObject(); + viz->setProperty("detectedHz", pitchData.detectedHz); + viz->setProperty("correctedHz", pitchData.correctedHz); + viz->setProperty("confidence", pitchData.confidence); + viz->setProperty("centsDeviation", pitchData.centsDeviation); + viz->setProperty("noteName", pitchData.noteName); + viz->setProperty("historyDetectedMidi", makeFloatVarArray(detectedMidi)); + viz->setProperty("historyCorrectedMidi", makeFloatVarArray(correctedMidi)); + viz->setProperty("historyConfidence", makeFloatVarArray(confidence)); + schemaObject->setProperty("visualization", viz.get()); + } + return schema; } - return success; -} -//============================================================================== -// Built-in Effects (Phase 4.3) + return makeBuiltInSchemaObject(processor->getName(), "Built-in", chainType, fxIndex, params); +} -static std::unique_ptr createBuiltInEffect(const juce::String& name) +static bool setBuiltInProcessorParam(juce::AudioProcessor* processor, const juce::String& paramId, float value) { - if (name == "S13 EQ" || name == "OpenStudio EQ") return std::make_unique(); - if (name == "S13 Compressor" || name == "OpenStudio Compressor") return std::make_unique(); - if (name == "S13 Gate" || name == "OpenStudio Gate") return std::make_unique(); - if (name == "S13 Limiter" || name == "OpenStudio Limiter") return std::make_unique(); - if (name == "S13 Delay" || name == "OpenStudio Delay") return std::make_unique(); - if (name == "S13 Reverb" || name == "OpenStudio Reverb") return std::make_unique(); - if (name == "S13 Chorus" || name == "OpenStudio Chorus") return std::make_unique(); - if (name == "S13 Saturator" || name == "OpenStudio Saturator") return std::make_unique(); - if (name == "S13 Pitch Correct" || name == "OpenStudio Pitch Correct") return std::make_unique(); - return nullptr; + if (processor == nullptr) + return false; + + if (auto* synth = dynamic_cast(processor)) + { + if (paramId == "attackMs") return storeClamped(synth->attackMs, value, 0.5f, 2000.0f); + if (paramId == "releaseMs") return storeClamped(synth->releaseMs, value, 5.0f, 5000.0f); + if (paramId == "brightness") return storeClamped(synth->brightness, value, 0.0f, 1.0f); + if (paramId == "detuneCents") return storeClamped(synth->detuneCents, value, 0.0f, 35.0f); + if (paramId == "subLevel") return storeClamped(synth->subLevel, value, 0.0f, 0.8f); + if (paramId == "noiseLevel") return storeClamped(synth->noiseLevel, value, 0.0f, 0.25f); + if (paramId == "outputGain") return storeClamped(synth->outputGain, value, -36.0f, 0.0f); + return false; + } + + if (auto* piano = dynamic_cast(processor)) + { + if (paramId == "model") return storeClamped(piano->model, value, 0.0f, 2.0f); + if (paramId == "tone") return storeClamped(piano->tone, value, 0.0f, 1.0f); + if (paramId == "body") return storeClamped(piano->body, value, 0.0f, 1.0f); + if (paramId == "hammer") return storeClamped(piano->hammer, value, 0.0f, 1.0f); + if (paramId == "resonance") return storeClamped(piano->resonance, value, 0.0f, 1.0f); + if (paramId == "stereoWidth") return storeClamped(piano->stereoWidth, value, 0.0f, 1.0f); + if (paramId == "releaseMs") return storeClamped(piano->releaseMs, value, 80.0f, 5000.0f); + if (paramId == "outputGain") return storeClamped(piano->outputGain, value, -36.0f, 0.0f); + return false; + } + + if (auto* drums = dynamic_cast(processor)) + { + if (paramId == "kit") return storeClamped(drums->kit, value, 0.0f, 2.0f); + if (paramId == "mapPreset") return storeClamped(drums->mapPreset, value, 0.0f, 1.0f); + if (paramId == "tuning") return storeClamped(drums->tuning, value, -12.0f, 12.0f); + if (paramId == "ambience") return storeClamped(drums->ambience, value, 0.0f, 1.0f); + if (paramId == "hihatTightness") return storeClamped(drums->hihatTightness, value, 0.0f, 1.0f); + if (paramId == "punch") return storeClamped(drums->punch, value, 0.0f, 1.0f); + if (paramId == "stereoWidth") return storeClamped(drums->stereoWidth, value, 0.0f, 1.0f); + if (paramId == "velocityCurve") return storeClamped(drums->velocityCurve, value, -1.0f, 1.0f); + if (paramId == "outputGain") return storeClamped(drums->outputGain, value, -36.0f, 0.0f); + return false; + } + + if (auto* eq = dynamic_cast(processor)) + { + if (paramId == "outputGain") return storeClamped(eq->outputGain, value, -12.0f, 12.0f); + if (paramId == "autoGain") return storeClamped(eq->autoGain, value, 0.0f, 1.0f); + if (paramId == "auditionBand") return storeClamped(eq->auditionBand, value, 0.0f, 8.0f); + if (paramId == "stereoMode") return storeClamped(eq->stereoMode, value, 0.0f, 2.0f); + if (paramId.startsWith("band")) + { + const int dot = paramId.indexOfChar('.'); + const int band = paramId.substring(4, dot).getIntValue(); + if (band < 0 || band >= S13EQ::numBands || dot < 0) + return false; + const auto field = paramId.substring(dot + 1); + auto& params = eq->bands[band]; + if (field == "enabled") return storeClamped(params.enabled, value, 0.0f, 1.0f); + if (field == "type") return storeClamped(params.type, value, 0.0f, 6.0f); + if (field == "freq") return storeClamped(params.freq, value, 20.0f, 20000.0f); + if (field == "gain") return storeClamped(params.gain, value, -30.0f, 30.0f); + if (field == "q") return storeClamped(params.q, value, 0.1f, 30.0f); + if (field == "slope") return storeClamped(params.slope, value, 0.0f, 3.0f); + if (field == "dynamicEnabled") return storeClamped(params.dynamicEnabled, value, 0.0f, 1.0f); + if (field == "dynamicThreshold") return storeClamped(params.dynamicThreshold, value, -80.0f, 0.0f); + if (field == "dynamicRange") return storeClamped(params.dynamicRange, value, -24.0f, 24.0f); + if (field == "dynamicAttack") return storeClamped(params.dynamicAttack, value, 0.2f, 250.0f); + if (field == "dynamicRelease") return storeClamped(params.dynamicRelease, value, 5.0f, 2000.0f); + } + return false; + } + + if (auto* compressor = dynamic_cast(processor)) + { + if (paramId == "threshold") return storeClamped(compressor->threshold, value, -60.0f, 0.0f); + if (paramId == "ratio") return storeClamped(compressor->ratio, value, 1.0f, 20.0f); + if (paramId == "attack") return storeClamped(compressor->attack, value, 0.1f, 100.0f); + if (paramId == "release") return storeClamped(compressor->release, value, 10.0f, 2000.0f); + if (paramId == "knee") return storeClamped(compressor->knee, value, 0.0f, 24.0f); + if (paramId == "makeupGain") return storeClamped(compressor->makeupGain, value, 0.0f, 36.0f); + if (paramId == "mix") return storeClamped(compressor->mix, value, 0.0f, 1.0f); + if (paramId == "style") return storeClamped(compressor->style, value, 0.0f, 4.0f); + if (paramId == "autoMakeup") return storeClamped(compressor->autoMakeup, value, 0.0f, 1.0f); + if (paramId == "autoRelease") return storeClamped(compressor->autoRelease, value, 0.0f, 1.0f); + if (paramId == "sidechainHPF") return storeClamped(compressor->sidechainHPF, value, 20.0f, 500.0f); + if (paramId == "lookaheadMs") return storeClamped(compressor->lookaheadMs, value, 0.0f, 20.0f); + if (paramId == "detectorMode") return storeClamped(compressor->detectorMode, value, 0.0f, 2.0f); + if (paramId == "stereoLink") return storeClamped(compressor->stereoLink, value, 0.0f, 1.0f); + return false; + } + + if (auto* gate = dynamic_cast(processor)) + { + if (paramId == "threshold") return storeClamped(gate->threshold, value, -80.0f, 0.0f); + if (paramId == "attackMs") return storeClamped(gate->attackMs, value, 0.01f, 50.0f); + if (paramId == "holdMs") return storeClamped(gate->holdMs, value, 0.0f, 500.0f); + if (paramId == "releaseMs") return storeClamped(gate->releaseMs, value, 5.0f, 2000.0f); + if (paramId == "range") return storeClamped(gate->range, value, -80.0f, 0.0f); + if (paramId == "hysteresis") return storeClamped(gate->hysteresis, value, 0.0f, 20.0f); + if (paramId == "sidechainHPF") return storeClamped(gate->sidechainHPF, value, 20.0f, 2000.0f); + if (paramId == "sidechainLPF") return storeClamped(gate->sidechainLPF, value, 200.0f, 20000.0f); + if (paramId == "mix") return storeClamped(gate->mix, value, 0.0f, 1.0f); + if (paramId == "detectorMode") return storeClamped(gate->detectorMode, value, 0.0f, 2.0f); + return false; + } + + if (auto* limiter = dynamic_cast(processor)) + { + if (paramId == "threshold") return storeClamped(limiter->threshold, value, -20.0f, 0.0f); + if (paramId == "releaseMs") return storeClamped(limiter->releaseMs, value, 10.0f, 500.0f); + if (paramId == "ceiling") return storeClamped(limiter->ceiling, value, -3.0f, 0.0f); + if (paramId == "lookaheadMs") return storeClamped(limiter->lookaheadMs, value, 0.0f, 20.0f); + return false; + } + + if (auto* delay = dynamic_cast(processor)) + { + if (paramId == "delayTimeL") return storeClamped(delay->delayTimeL, value, 1.0f, 2000.0f); + if (paramId == "delayTimeR") return storeClamped(delay->delayTimeR, value, 1.0f, 2000.0f); + if (paramId == "feedback") return storeClamped(delay->feedback, value, 0.0f, 0.95f); + if (paramId == "crossFeed") return storeClamped(delay->crossFeed, value, 0.0f, 0.95f); + if (paramId == "mix") return storeClamped(delay->mix, value, 0.0f, 1.0f); + if (paramId == "pingPong") return storeClamped(delay->pingPong, value, 0.0f, 1.0f); + if (paramId == "tempoSync") return storeClamped(delay->tempoSync, value, 0.0f, 1.0f); + if (paramId == "syncNoteL") return storeClamped(delay->syncNoteL, value, 0.0f, 8.0f); + if (paramId == "syncNoteR") return storeClamped(delay->syncNoteR, value, 0.0f, 8.0f); + if (paramId == "lpfFreq") return storeClamped(delay->lpfFreq, value, 200.0f, 20000.0f); + if (paramId == "hpfFreq") return storeClamped(delay->hpfFreq, value, 20.0f, 2000.0f); + if (paramId == "fbSaturation") return storeClamped(delay->fbSaturation, value, 0.0f, 1.0f); + if (paramId == "stereoWidth") return storeClamped(delay->stereoWidth, value, 0.0f, 2.0f); + if (paramId == "delayMode") return storeClamped(delay->delayMode, value, 0.0f, 2.0f); + if (paramId == "ducking") return storeClamped(delay->ducking, value, 0.0f, 1.0f); + return false; + } + + if (auto* reverb = dynamic_cast(processor)) + { + if (paramId == "algorithm") return storeClamped(reverb->algorithm, value, 0.0f, 4.0f); + if (paramId == "roomSize") return storeClamped(reverb->roomSize, value, 0.0f, 1.0f); + if (paramId == "damping") return storeClamped(reverb->damping, value, 0.0f, 1.0f); + if (paramId == "wetLevel") return storeClamped(reverb->wetLevel, value, 0.0f, 1.0f); + if (paramId == "dryLevel") return storeClamped(reverb->dryLevel, value, 0.0f, 1.0f); + if (paramId == "width") return storeClamped(reverb->width, value, 0.0f, 1.0f); + if (paramId == "freezeMode") return storeClamped(reverb->freezeMode, value, 0.0f, 1.0f); + if (paramId == "preDelay") return storeClamped(reverb->preDelay, value, 0.0f, 500.0f); + if (paramId == "diffusion") return storeClamped(reverb->diffusion, value, 0.0f, 1.0f); + if (paramId == "lowCut") return storeClamped(reverb->lowCut, value, 20.0f, 500.0f); + if (paramId == "highCut") return storeClamped(reverb->highCut, value, 1000.0f, 20000.0f); + if (paramId == "earlyLevel") return storeClamped(reverb->earlyLevel, value, 0.0f, 1.0f); + if (paramId == "decayTime") return storeClamped(reverb->decayTime, value, 0.1f, 20.0f); + return false; + } + + if (auto* chorus = dynamic_cast(processor)) + { + if (paramId == "mode") return storeClamped(chorus->mode, value, 0.0f, 2.0f); + if (paramId == "rate") return storeClamped(chorus->rate, value, 0.01f, 20.0f); + if (paramId == "depth") return storeClamped(chorus->depth, value, 0.0f, 1.0f); + if (paramId == "fbAmount") return storeClamped(chorus->fbAmount, value, -1.0f, 1.0f); + if (paramId == "mix") return storeClamped(chorus->mix, value, 0.0f, 1.0f); + if (paramId == "voices") return storeClamped(chorus->voices, value, 1.0f, 6.0f); + if (paramId == "lfoShape") return storeClamped(chorus->lfoShape, value, 0.0f, 3.0f); + if (paramId == "spread") return storeClamped(chorus->spread, value, 0.0f, 1.0f); + if (paramId == "characterMode") return storeClamped(chorus->characterMode, value, 0.0f, 2.0f); + if (paramId == "highCut") return storeClamped(chorus->highCut, value, 200.0f, 20000.0f); + if (paramId == "lowCut") return storeClamped(chorus->lowCut, value, 20.0f, 2000.0f); + if (paramId == "tempoSync") return storeClamped(chorus->tempoSync, value, 0.0f, 1.0f); + return false; + } + + if (auto* saturator = dynamic_cast(processor)) + { + if (paramId == "satType") return storeClamped(saturator->satType, value, 0.0f, 7.0f); + if (paramId == "drive") return storeClamped(saturator->drive, value, 0.0f, 30.0f); + if (paramId == "mix") return storeClamped(saturator->mix, value, 0.0f, 1.0f); + if (paramId == "toneFreq") return storeClamped(saturator->toneFreq, value, 200.0f, 20000.0f); + if (paramId == "lowCutFreq") return storeClamped(saturator->lowCutFreq, value, 20.0f, 1000.0f); + if (paramId == "outputGain") return storeClamped(saturator->outputGain, value, -12.0f, 0.0f); + if (paramId == "asymmetry") return storeClamped(saturator->asymmetry, value, -1.0f, 1.0f); + if (paramId == "oversampleMode") + { + const bool ok = storeClamped(saturator->oversampleMode, value, 0.0f, 2.0f); + saturator->setOversamplingEnabled(value >= 0.5f); + return ok; + } + return false; + } + + if (auto* pitch = dynamic_cast(processor)) + { + auto& mapper = pitch->getMapper(); + if (paramId == "bypass") return storeClamped(pitch->bypass, value, 0.0f, 1.0f); + if (paramId == "mix") return storeClamped(pitch->mix, value, 0.0f, 1.0f); + if (paramId == "sensitivity") return storeClamped(pitch->sensitivity, value, 0.02f, 0.35f); + if (paramId == "minFreqParam") return storeClamped(pitch->minFreqParam, value, 40.0f, 400.0f); + if (paramId == "maxFreqParam") return storeClamped(pitch->maxFreqParam, value, 400.0f, 4000.0f); + if (paramId == "key") { mapper.setKey(juce::jlimit(0, 11, static_cast(std::round(value)))); return true; } + if (paramId == "scale") { mapper.setScale(static_cast(juce::jlimit(0, 15, static_cast(std::round(value))))); return true; } + if (paramId == "retuneSpeed") { mapper.setRetuneSpeed(juce::jlimit(0.0f, 400.0f, value)); return true; } + if (paramId == "humanize") { mapper.setHumanize(juce::jlimit(0.0f, 100.0f, value)); return true; } + if (paramId == "transpose") { mapper.setTranspose(juce::jlimit(-24, 24, static_cast(std::round(value)))); return true; } + if (paramId == "correctionStrength") { mapper.setCorrectionStrength(juce::jlimit(0.0f, 1.0f, value)); return true; } + if (paramId == "formantCorrection") { mapper.setFormantCorrection(value >= 0.5f); return true; } + if (paramId == "formantShift") { mapper.setFormantShift(juce::jlimit(-12.0f, 12.0f, value)); return true; } + if (paramId == "midiOutputEnabled") return storeClamped(pitch->midiOutputEnabled, value, 0.0f, 1.0f); + if (paramId == "midiOutputChannel") return storeClamped(pitch->midiOutputChannel, value, 1.0f, 16.0f); + return false; + } + + return false; } bool AudioEngine::addTrackBuiltInFX(const juce::String& trackId, const juce::String& effectName, bool isInputFX) @@ -6612,6 +9127,7 @@ bool AudioEngine::addMasterBuiltInFX(const juce::String& effectName) slot.slotId = nextMasterStageSlotId++; slot.name = plugin->getName(); slot.type = "builtin"; + slot.pluginPath = plugin->getName(); slot.pluginFormat = "Built-in"; slot.serializedState = serialiseProcessorStateToBase64(plugin.get()); specCopy.slots.push_back(std::move(slot)); @@ -6626,14 +9142,35 @@ bool AudioEngine::addMasterBuiltInFX(const juce::String& effectName) juce::var AudioEngine::getAvailableBuiltInFX() { juce::Array list; - const char* names[] = { "OpenStudio EQ", "OpenStudio Compressor", "OpenStudio Gate", "OpenStudio Limiter", - "OpenStudio Delay", "OpenStudio Reverb", "OpenStudio Chorus", "OpenStudio Saturator", - "OpenStudio Pitch Correct" }; - for (auto& n : names) + struct BuiltInDescriptor + { + const char* name; + const char* category; + bool isInstrument; + int instrumentMode; + }; + const BuiltInDescriptor descriptors[] = { + { "OpenStudio Basic Synth", "Built-in Instrument", true, 0 }, + { "OpenStudio Piano", "Built-in Instrument", true, 1 }, + { "OpenStudio Drums", "Built-in Instrument", true, 2 }, + { "OpenStudio EQ", "Built-in", false, -1 }, + { "OpenStudio Compressor", "Built-in", false, -1 }, + { "OpenStudio Gate", "Built-in", false, -1 }, + { "OpenStudio Limiter", "Built-in", false, -1 }, + { "OpenStudio Delay", "Built-in", false, -1 }, + { "OpenStudio Reverb", "Built-in", false, -1 }, + { "OpenStudio Chorus", "Built-in", false, -1 }, + { "OpenStudio Saturator", "Built-in", false, -1 }, + { "OpenStudio Pitch Correct", "Built-in", false, -1 } + }; + for (const auto& descriptor : descriptors) { juce::DynamicObject::Ptr obj = new juce::DynamicObject(); - obj->setProperty("name", juce::String(n)); - obj->setProperty("category", "Built-in"); + obj->setProperty("name", juce::String(descriptor.name)); + obj->setProperty("category", juce::String(descriptor.category)); + obj->setProperty("isInstrument", descriptor.isInstrument); + if (descriptor.isInstrument) + obj->setProperty("instrumentMode", descriptor.instrumentMode); list.add(juce::var(obj.get())); } return juce::var(list); @@ -7191,9 +9728,13 @@ juce::var AudioEngine::getTrackInputFX(const juce::String& trackId) || dynamic_cast(processorPtr) || dynamic_cast(processorPtr) || dynamic_cast(processorPtr) - || dynamic_cast(processorPtr)) + || dynamic_cast(processorPtr) + || dynamic_cast(processorPtr) + || dynamic_cast(processorPtr) + || dynamic_cast(processorPtr)) { fxInfo->setProperty("type", "builtin"); + fxInfo->setProperty("pluginPath", processor->getName()); } else if (auto* s13fx = dynamic_cast(processorPtr)) { @@ -7250,36 +9791,260 @@ juce::var AudioEngine::getTrackFX(const juce::String& trackId) || dynamic_cast(processorPtr) || dynamic_cast(processorPtr) || dynamic_cast(processorPtr) - || dynamic_cast(processorPtr)) + || dynamic_cast(processorPtr) + || dynamic_cast(processorPtr) + || dynamic_cast(processorPtr) + || dynamic_cast(processorPtr)) { fxInfo->setProperty("type", "builtin"); + fxInfo->setProperty("pluginPath", processor->getName()); } else if (auto* s13fx = dynamic_cast(processorPtr)) { - fxInfo->setProperty("type", "s13fx"); - fxInfo->setProperty("pluginPath", s13fx->getScriptPath()); + fxInfo->setProperty("type", "s13fx"); + fxInfo->setProperty("pluginPath", s13fx->getScriptPath()); + } + else if (auto* pluginInstance = dynamic_cast(processorPtr)) + { + auto desc = pluginInstance->getPluginDescription(); + fxInfo->setProperty("type", desc.pluginFormatName == "CLAP" ? "clap" : "vst3"); + fxInfo->setProperty("pluginPath", desc.fileOrIdentifier); + } + fxList.add(juce::var(fxInfo.get())); + } + } + + return fxList; +} + +juce::var AudioEngine::getPluginParameters(const juce::String& trackId, int fxIndex, bool isInputFX) +{ + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + juce::Array paramList; + + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return paramList; + + 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) + return paramList; + + // Use the modern JUCE parameter API — skip internal MIDI CC parameters + // (plugins like Amplitube expose CC 0-127 x 16 channels = 2048 internal params) + const juce::ScopedLock processorLock(processor->getCallbackLock()); + const auto& params = processor->getParameters(); + for (int i = 0; i < params.size(); ++i) + { + auto* param = params[i]; + auto name = param->getName(128); + + // Filter out MIDI CC / internal mapping parameters (Reaper hides these too) + auto nameLower = name.toLowerCase(); + if (nameLower.startsWith("midi cc") || nameLower.startsWith("cc #") + || nameLower.contains("midi cc ") || nameLower.contains("midi ch")) + continue; + + juce::DynamicObject::Ptr paramInfo = new juce::DynamicObject(); + paramInfo->setProperty("index", i); + paramInfo->setProperty("name", name); + paramInfo->setProperty("value", param->getValue()); + paramInfo->setProperty("text", param->getCurrentValueAsText()); + paramList.add(juce::var(paramInfo.get())); + } + + return paramList; +} + +juce::var AudioEngine::getBuiltInPluginSchema(const juce::String& trackId, const juce::String& chainType, int fxIndex) +{ + if (chainType == "instrument") + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto it = trackMap.find(trackId); + return describeFallbackInstrument(it != trackMap.end() ? it->second : nullptr, chainType, fxIndex); + } + + if (chainType == "master") + { + int slotId = 0; + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + if (const auto* slot = findDesiredStageSlot(desiredMasterStageSpec, fxIndex)) + slotId = slot->slotId; + } + + auto activeStage = std::atomic_load_explicit(&realtimeMasterFXSnapshot, std::memory_order_acquire); + if (const auto* activeSlot = findActiveStageSlot(activeStage, slotId)) + return describeBuiltInProcessor(activeSlot->processor.get(), chainType, fxIndex); + + return describeBuiltInProcessor(nullptr, chainType, fxIndex); + } + + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return describeBuiltInProcessor(nullptr, chainType, fxIndex); + + auto* processor = chainType == "input" + ? it->second->getInputFXProcessor(fxIndex) + : it->second->getTrackFXProcessor(fxIndex); + return describeBuiltInProcessor(processor, chainType, fxIndex); +} + +juce::var AudioEngine::getBuiltInPluginState(const juce::String& trackId, const juce::String& chainType, int fxIndex) +{ + const auto schema = getBuiltInPluginSchema(trackId, chainType, fxIndex); + auto* root = new juce::DynamicObject(); + root->setProperty("schemaVersion", 1); + root->setProperty("chain", chainType); + root->setProperty("fxIndex", fxIndex); + + juce::DynamicObject* schemaObject = schema.getDynamicObject(); + if (schemaObject != nullptr) + { + root->setProperty("name", schemaObject->getProperty("name")); + juce::DynamicObject::Ptr values = new juce::DynamicObject(); + const auto paramsVar = schemaObject->getProperty("parameters"); + if (auto* params = paramsVar.getArray()) + { + for (const auto& paramVar : *params) + { + if (auto* param = paramVar.getDynamicObject()) + values->setProperty(param->getProperty("id").toString(), param->getProperty("value")); + } + } + root->setProperty("values", values.get()); + } + + return juce::var(root); +} + +bool AudioEngine::setBuiltInPluginParam(const juce::String& trackId, const juce::String& chainType, int fxIndex, + const juce::String& paramId, float value) +{ + if (chainType == "instrument") + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto it = trackMap.find(trackId); + return it != trackMap.end() && it->second != nullptr + ? it->second->setFallbackInstrumentParam(paramId, value) + : false; + } + + if (chainType == "master") + { + int slotId = 0; + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + if (const auto* slot = findDesiredStageSlot(desiredMasterStageSpec, fxIndex)) + slotId = slot->slotId; + } + + if (slotId == 0) + return false; + + juce::String serializedState; + auto activeStage = std::atomic_load_explicit(&realtimeMasterFXSnapshot, std::memory_order_acquire); + if (const auto* activeSlot = findActiveStageSlot(activeStage, slotId)) + { + if (!activeSlot->processor) + return false; + + { + const juce::ScopedLock processorLock(activeSlot->processor->getCallbackLock()); + if (!setBuiltInProcessorParam(activeSlot->processor.get(), paramId, value)) + return false; + serializedState = serialiseProcessorStateToBase64(activeSlot->processor.get()); + } + } + else + { + return false; + } + + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + if (auto* slot = findDesiredStageSlot(desiredMasterStageSpec, fxIndex)) + slot->serializedState = serializedState; + return true; + } + + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return false; + + auto* processor = chainType == "input" + ? it->second->getInputFXProcessor(fxIndex) + : it->second->getTrackFXProcessor(fxIndex); + if (processor == nullptr) + return false; + + const juce::ScopedLock processorLock(processor->getCallbackLock()); + return setBuiltInProcessorParam(processor, paramId, value); +} + +bool AudioEngine::setBuiltInPluginState(const juce::String& trackId, const juce::String& chainType, int fxIndex, + const juce::String& stateJSON) +{ + const auto parsed = juce::JSON::parse(stateJSON); + if (parsed.isVoid()) + return false; + + bool anyApplied = false; + if (auto* stateObject = parsed.getDynamicObject()) + { + const auto valuesVar = stateObject->getProperty("values"); + if (auto* valuesObject = valuesVar.getDynamicObject()) + { + const auto& properties = valuesObject->getProperties(); + for (int i = 0; i < properties.size(); ++i) + { + const auto name = properties.getName(i).toString(); + const auto value = static_cast(static_cast(properties.getValueAt(i))); + anyApplied = setBuiltInPluginParam(trackId, chainType, fxIndex, name, value) || anyApplied; } - else if (auto* pluginInstance = dynamic_cast(processorPtr)) + } + + const auto parametersVar = stateObject->getProperty("parameters"); + if (auto* parameters = parametersVar.getArray()) + { + for (const auto& paramVar : *parameters) { - auto desc = pluginInstance->getPluginDescription(); - fxInfo->setProperty("type", desc.pluginFormatName == "CLAP" ? "clap" : "vst3"); - fxInfo->setProperty("pluginPath", desc.fileOrIdentifier); + if (auto* param = paramVar.getDynamicObject()) + { + const auto id = param->getProperty("id").toString(); + const auto value = static_cast(static_cast(param->getProperty("value"))); + if (id.isNotEmpty()) + anyApplied = setBuiltInPluginParam(trackId, chainType, fxIndex, id, value) || anyApplied; + } } - fxList.add(juce::var(fxInfo.get())); } } - return fxList; + return anyApplied; } -juce::var AudioEngine::getPluginParameters(const juce::String& trackId, int fxIndex, bool isInputFX) +bool AudioEngine::setPluginParameter(const juce::String& trackId, int fxIndex, bool isInputFX, int paramIndex, float value) { const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); - juce::Array paramList; auto it = trackMap.find(trackId); if (it == trackMap.end() || !it->second) - return paramList; + return false; auto* track = it->second; juce::AudioProcessor* processor = nullptr; @@ -7295,33 +10060,16 @@ juce::var AudioEngine::getPluginParameters(const juce::String& trackId, int fxIn processor = track->getTrackFXProcessor(fxIndex); } - if (!processor) - return paramList; + if (processor == nullptr) + return false; - // Use the modern JUCE parameter API — skip internal MIDI CC parameters - // (plugins like Amplitube expose CC 0-127 x 16 channels = 2048 internal params) const juce::ScopedLock processorLock(processor->getCallbackLock()); - const auto& params = processor->getParameters(); - for (int i = 0; i < params.size(); ++i) - { - auto* param = params[i]; - auto name = param->getName(128); - - // Filter out MIDI CC / internal mapping parameters (Reaper hides these too) - auto nameLower = name.toLowerCase(); - if (nameLower.startsWith("midi cc") || nameLower.startsWith("cc #") - || nameLower.contains("midi cc ") || nameLower.contains("midi ch")) - continue; - - juce::DynamicObject::Ptr paramInfo = new juce::DynamicObject(); - paramInfo->setProperty("index", i); - paramInfo->setProperty("name", name); - paramInfo->setProperty("value", param->getValue()); - paramInfo->setProperty("text", param->getCurrentValueAsText()); - paramList.add(juce::var(paramInfo.get())); - } + auto& params = processor->getParameters(); + if (paramIndex < 0 || paramIndex >= params.size() || params[paramIndex] == nullptr) + return false; - return paramList; + params[paramIndex]->setValueNotifyingHost(juce::jlimit(0.0f, 1.0f, value)); + return true; } void AudioEngine::removeTrackInputFX(const juce::String& trackId, int fxIndex) @@ -8437,27 +11185,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 +11305,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 +11799,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 +11811,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 +11828,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 +11842,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 +11862,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 +12031,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 +12108,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 +12176,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 +12273,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 +12975,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 +12985,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 +13215,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 +13615,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 +13696,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) { @@ -10931,6 +13870,7 @@ juce::var AudioEngine::importMIDIFile(const juce::String& filePath) midiFile.convertTimestampTicksToSeconds(); auto* result = new juce::DynamicObject(); + result->setProperty("success", true); result->setProperty("numTracks", midiFile.getNumTracks()); result->setProperty("ticksPerQuarterNote", midiFile.getTimeFormat()); @@ -10943,17 +13883,26 @@ juce::var AudioEngine::importMIDIFile(const juce::String& filePath) auto* trackObj = new juce::DynamicObject(); juce::Array eventsArray; + juce::String trackName; + int channelCounts[17] {}; for (int i = 0; i < midiTrack->getNumEvents(); ++i) { const auto* holder = midiTrack->getEventPointer(i); const auto& msg = holder->message; + if (msg.isTrackNameEvent()) + { + trackName = msg.getTextFromTextMetaEvent(); + continue; + } + auto* evtObj = new juce::DynamicObject(); evtObj->setProperty("timestamp", msg.getTimeStamp()); if (msg.isNoteOn()) { + ++channelCounts[msg.getChannel()]; evtObj->setProperty("type", "noteOn"); evtObj->setProperty("note", msg.getNoteNumber()); evtObj->setProperty("velocity", msg.getVelocity()); @@ -10961,6 +13910,7 @@ juce::var AudioEngine::importMIDIFile(const juce::String& filePath) } else if (msg.isNoteOff()) { + ++channelCounts[msg.getChannel()]; evtObj->setProperty("type", "noteOff"); evtObj->setProperty("note", msg.getNoteNumber()); evtObj->setProperty("velocity", 0); @@ -10968,6 +13918,7 @@ juce::var AudioEngine::importMIDIFile(const juce::String& filePath) } else if (msg.isController()) { + ++channelCounts[msg.getChannel()]; evtObj->setProperty("type", "cc"); evtObj->setProperty("controller", msg.getControllerNumber()); evtObj->setProperty("value", msg.getControllerValue()); @@ -10975,6 +13926,7 @@ juce::var AudioEngine::importMIDIFile(const juce::String& filePath) } else if (msg.isPitchWheel()) { + ++channelCounts[msg.getChannel()]; evtObj->setProperty("type", "pitchBend"); evtObj->setProperty("value", msg.getPitchWheelValue()); evtObj->setProperty("channel", msg.getChannel()); @@ -10992,16 +13944,180 @@ juce::var AudioEngine::importMIDIFile(const juce::String& filePath) eventsArray.add(juce::var(evtObj)); } + int dominantChannel = 0; + for (int channel = 1; channel <= 16; ++channel) + if (channelCounts[channel] > channelCounts[dominantChannel]) + dominantChannel = channel; + + trackObj->setProperty("name", trackName.isNotEmpty() ? trackName : "MIDI Track " + juce::String(t + 1)); + trackObj->setProperty("channel", dominantChannel); trackObj->setProperty("events", eventsArray); tracksArray.add(juce::var(trackObj)); } result->setProperty("tracks", tracksArray); - juce::Logger::writeToLog("importMIDIFile: " + filePath + " - " + - juce::String(midiFile.getNumTracks()) + " tracks"); return juce::var(result); } +bool AudioEngine::exportProjectMIDI(const juce::String& outputPath, const juce::var& midiTracks, double bpm) +{ + auto tracksVar = midiTracks; + if (tracksVar.isString()) + tracksVar = juce::JSON::parse(tracksVar.toString()); + + auto* tracksArray = tracksVar.getArray(); + if (tracksArray == nullptr) + return false; + + if (bpm <= 0.0) + bpm = 120.0; + + auto getDoubleProperty = [] (juce::DynamicObject* obj, const char* name, double fallback) + { + if (obj == nullptr || ! obj->hasProperty(name)) + return fallback; + + return static_cast(obj->getProperty(name)); + }; + + 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, juce::roundToInt(static_cast(obj->getProperty(name)))); + }; + + auto getVelocityProperty = [&] (juce::DynamicObject* obj, const char* name, int fallback, int minValue) + { + auto value = getDoubleProperty(obj, name, static_cast(fallback)); + if (value > 0.0 && value <= 1.0) + value *= 127.0; + + return juce::jlimit(minValue, 127, juce::roundToInt(value)); + }; + + 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 = getVelocityProperty(eventObj, "velocity", 100, 1); + 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 = getVelocityProperty(eventObj, + eventObj->hasProperty("releaseVelocity") ? "releaseVelocity" : "velocity", + 0, 0); + 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); + const juce::String trackName = trackObj->getProperty("name").toString(); + if (trackName.isNotEmpty()) + sequence.addEvent(juce::MidiMessage::textMetaEvent(0x03, trackName), 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); + if (! outFile.hasFileExtension(".mid") && ! outFile.hasFileExtension(".midi")) + outFile = outFile.withFileExtension(".mid"); + + outFile.getParentDirectory().createDirectory(); + outFile.deleteFile(); + std::unique_ptr outputStream(outFile.createOutputStream()); + if (! outputStream) + return false; + + return midiFile.writeTo(*outputStream); +} + bool AudioEngine::exportMIDIFile(const juce::String& trackId, const juce::String& clipId, const juce::String& eventsJSON, const juce::String& outputPath, double clipTempo) @@ -11010,6 +14126,28 @@ bool AudioEngine::exportMIDIFile(const juce::String& trackId, const juce::String if (clipTempo <= 0.0) clipTempo = 120.0; + auto getDoubleProperty = [] (juce::DynamicObject* obj, const char* name, double fallback) + { + if (obj == nullptr || ! obj->hasProperty(name)) + return fallback; + + return static_cast(obj->getProperty(name)); + }; + + auto getIntProperty = [&] (juce::DynamicObject* obj, const char* name, int fallback, int minValue, int maxValue) + { + return juce::jlimit(minValue, maxValue, juce::roundToInt(getDoubleProperty(obj, name, static_cast(fallback)))); + }; + + auto getVelocityProperty = [&] (juce::DynamicObject* obj, const char* name, int fallback, int minValue) + { + auto value = getDoubleProperty(obj, name, static_cast(fallback)); + if (value > 0.0 && value <= 1.0) + value *= 127.0; + + return juce::jlimit(minValue, 127, juce::roundToInt(value)); + }; + juce::MidiFile midiFile; midiFile.setTicksPerQuarterNote(480); @@ -11035,24 +14173,52 @@ bool AudioEngine::exportMIDIFile(const juce::String& trackId, const juce::String if (eventType == "noteOn") { - int note = static_cast(obj->getProperty("note")); - int velocity = static_cast(obj->getProperty("velocity")); - int channel = obj->hasProperty("channel") ? static_cast(obj->getProperty("channel")) : 1; + const int note = getIntProperty(obj, "note", 60, 0, 127); + const int velocity = getVelocityProperty(obj, "velocity", 100, 1); + const int channel = getIntProperty(obj, "channel", 1, 1, 16); sequence.addEvent(juce::MidiMessage::noteOn(channel, note, static_cast(velocity)), ticks); } else if (eventType == "noteOff") { - int note = static_cast(obj->getProperty("note")); - int channel = obj->hasProperty("channel") ? static_cast(obj->getProperty("channel")) : 1; - sequence.addEvent(juce::MidiMessage::noteOff(channel, note), ticks); + const int note = getIntProperty(obj, "note", 60, 0, 127); + const int velocity = getVelocityProperty(obj, + obj->hasProperty("releaseVelocity") ? "releaseVelocity" : "velocity", + 0, 0); + const int channel = getIntProperty(obj, "channel", 1, 1, 16); + sequence.addEvent(juce::MidiMessage::noteOff(channel, note, static_cast(velocity)), ticks); } else if (eventType == "cc") { - int controller = static_cast(obj->getProperty("controller")); - int value = static_cast(obj->getProperty("value")); - int channel = obj->hasProperty("channel") ? static_cast(obj->getProperty("channel")) : 1; + const int controller = getIntProperty(obj, "controller", 0, 0, 127); + const int value = getIntProperty(obj, "value", 0, 0, 127); + const int channel = getIntProperty(obj, "channel", 1, 1, 16); sequence.addEvent(juce::MidiMessage::controllerEvent(channel, controller, value), ticks); } + else if (eventType == "pitchBend") + { + const int value = getIntProperty(obj, "value", 8192, 0, 16383); + const int channel = getIntProperty(obj, "channel", 1, 1, 16); + sequence.addEvent(juce::MidiMessage::pitchWheel(channel, value), ticks); + } + else if (eventType == "programChange") + { + const int program = getIntProperty(obj, "value", 0, 0, 127); + const int channel = getIntProperty(obj, "channel", 1, 1, 16); + sequence.addEvent(juce::MidiMessage::programChange(channel, program), ticks); + } + else if (eventType == "channelPressure") + { + const int pressure = getIntProperty(obj, "value", 0, 0, 127); + const int channel = getIntProperty(obj, "channel", 1, 1, 16); + sequence.addEvent(juce::MidiMessage::channelPressureChange(channel, pressure), ticks); + } + else if (eventType == "polyPressure") + { + const int note = getIntProperty(obj, "note", 60, 0, 127); + const int pressure = getIntProperty(obj, "value", 0, 0, 127); + const int channel = getIntProperty(obj, "channel", 1, 1, 16); + sequence.addEvent(juce::MidiMessage::aftertouchChange(channel, note, pressure), ticks); + } } } @@ -11060,14 +14226,16 @@ bool AudioEngine::exportMIDIFile(const juce::String& trackId, const juce::String midiFile.addTrack(sequence); juce::File outFile(outputPath); + if (! outFile.hasFileExtension(".mid") && ! outFile.hasFileExtension(".midi")) + outFile = outFile.withFileExtension(".mid"); + + outFile.getParentDirectory().createDirectory(); outFile.deleteFile(); std::unique_ptr outputStream(outFile.createOutputStream()); if (!outputStream) return false; - bool success = midiFile.writeTo(*outputStream); - juce::Logger::writeToLog("exportMIDIFile: " + outputPath + " success=" + juce::String(success ? "true" : "false")); - return success; + return midiFile.writeTo(*outputStream); } // ============================================================================= @@ -13656,6 +16824,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..e3e6e81 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,13 @@ 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); + juce::var getBuiltInPluginSchema(const juce::String& trackId, const juce::String& chainType, int fxIndex); + juce::var getBuiltInPluginState(const juce::String& trackId, const juce::String& chainType, int fxIndex); + bool setBuiltInPluginParam(const juce::String& trackId, const juce::String& chainType, int fxIndex, + const juce::String& paramId, float value); + bool setBuiltInPluginState(const juce::String& trackId, const juce::String& chainType, int fxIndex, + const juce::String& stateJSON); 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 +361,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 +432,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 +517,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 +610,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/BuiltInEffects.cpp b/Source/BuiltInEffects.cpp index 6551968..8cf0328 100644 --- a/Source/BuiltInEffects.cpp +++ b/Source/BuiltInEffects.cpp @@ -36,6 +36,19 @@ juce::AudioProcessorEditor* S13Compressor::createEditor() { return new S13Compre juce::AudioProcessorEditor* S13Gate::createEditor() { return new S13GateEditor(*this); } juce::AudioProcessorEditor* S13Limiter::createEditor() { return new S13LimiterEditor(*this); } +static void sanitizeBuiltInBuffer(juce::AudioBuffer& buffer, float limit) +{ + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + auto* samples = buffer.getWritePointer(ch); + for (int sample = 0; sample < buffer.getNumSamples(); ++sample) + { + const float value = samples[sample]; + samples[sample] = std::isfinite(value) ? juce::jlimit(-limit, limit, value) : 0.0f; + } + } +} + //============================================================================== // S13EQ -- 8-band parametric EQ //============================================================================== @@ -51,6 +64,13 @@ S13EQ::S13EQ() bands[i].gain.store(0.0f); bands[i].q.store(1.0f); bands[i].slope.store(static_cast(FilterSlope::dB12)); + bands[i].dynamicEnabled.store(0.0f); + bands[i].dynamicThreshold.store(-24.0f); + bands[i].dynamicRange.store(0.0f); + bands[i].dynamicAttack.store(10.0f); + bands[i].dynamicRelease.store(150.0f); + dynamicEnvelope[static_cast(i)] = 0.0f; + dynamicGainDB[static_cast(i)].store(0.0f); } // First band defaults to low cut (off), last to high cut (off) bands[0].type.store(static_cast(FilterType::LowCut)); @@ -78,13 +98,25 @@ void S13EQ::prepareToPlay(double sampleRate, int samplesPerBlock) cachedSampleRate = sampleRate; juce::dsp::ProcessSpec spec { sampleRate, static_cast(samplesPerBlock), 2u }; + juce::dsp::ProcessSpec monoSpec { sampleRate, static_cast(samplesPerBlock), 1u }; for (int b = 0; b < numBands; ++b) + { for (int s = 0; s < maxStagesPerBand; ++s) bandFilters[b][s].prepare(spec); + for (int ch = 0; ch < 2; ++ch) + dynamicDetectorFilters[b][ch].prepare(monoSpec); + dynamicEnvelope[static_cast(b)] = 0.0f; + dynamicGainDB[static_cast(b)].store(0.0f, std::memory_order_relaxed); + cachedBandStates[static_cast(b)].valid = false; + cachedDynamicDetectorStates[static_cast(b)].valid = false; + } oversampler = std::make_unique>( 2, 1, juce::dsp::Oversampling::filterHalfBandFIREquiripple, false); oversampler->initProcessing(static_cast(samplesPerBlock)); + msScratch.setSize(1, samplesPerBlock, false, false, true); + msScratch.clear(); + lastProcessingMode = 0; fftWritePos = 0; fftBlockCounter = 0; @@ -94,8 +126,19 @@ void S13EQ::prepareToPlay(double sampleRate, int samplesPerBlock) void S13EQ::releaseResources() { for (int b = 0; b < numBands; ++b) + { for (int s = 0; s < maxStagesPerBand; ++s) bandFilters[b][s].reset(); + for (int ch = 0; ch < 2; ++ch) + dynamicDetectorFilters[b][ch].reset(); + dynamicEnvelope[static_cast(b)] = 0.0f; + dynamicGainDB[static_cast(b)].store(0.0f, std::memory_order_relaxed); + cachedBandStates[static_cast(b)].valid = false; + cachedDynamicDetectorStates[static_cast(b)].valid = false; + } + smoothedAutoGainDB = 0.0f; + msScratch.setSize(0, 0); + lastProcessingMode = 0; } void S13EQ::updateBand(int b) @@ -107,7 +150,9 @@ void S13EQ::updateBand(int b) const auto type = static_cast(static_cast(bands[b].type.load())); const auto slope = static_cast(static_cast(bands[b].slope.load())); const float freq = juce::jlimit(20.0f, nyquist, bands[b].freq.load()); - const float gainDB = juce::jlimit(-30.0f, 30.0f, bands[b].gain.load()); + const float baseGainDB = bands[b].gain.load(std::memory_order_relaxed); + const float dynamicDB = dynamicGainDB[static_cast(b)].load(std::memory_order_relaxed); + const float gainDB = juce::jlimit(-30.0f, 30.0f, baseGainDB + dynamicDB); const float q = juce::jlimit(0.1f, 30.0f, bands[b].q.load()); const float gainFactor = juce::Decibels::decibelsToGain(gainDB); @@ -164,16 +209,176 @@ void S13EQ::updateBand(int b) void S13EQ::updateFilters() { for (int b = 0; b < numBands; ++b) + { + auto& cached = cachedBandStates[static_cast(b)]; + const float enabled = bands[b].enabled.load(std::memory_order_relaxed) >= 0.5f ? 1.0f : 0.0f; + const int type = static_cast(bands[b].type.load(std::memory_order_relaxed)); + const float freq = bands[b].freq.load(std::memory_order_relaxed); + const float baseGain = bands[b].gain.load(std::memory_order_relaxed); + const float dynamicGain = dynamicGainDB[static_cast(b)].load(std::memory_order_relaxed); + const float gain = juce::jlimit(-30.0f, 30.0f, baseGain + dynamicGain); + const float q = bands[b].q.load(std::memory_order_relaxed); + const int slope = static_cast(bands[b].slope.load(std::memory_order_relaxed)); + + const bool changed = !cached.valid + || cached.enabled != enabled + || cached.type != type + || cached.slope != slope + || std::abs(cached.freq - freq) > 0.01f + || std::abs(cached.gain - gain) > 0.02f + || std::abs(cached.q - q) > 0.001f; + if (!changed) + continue; + updateBand(b); + cached.valid = true; + cached.enabled = enabled; + cached.type = type; + cached.freq = freq; + cached.gain = gain; + cached.q = q; + cached.slope = slope; + } +} + +void S13EQ::updateDynamicBands(const juce::AudioBuffer& buffer) +{ + const int numSamples = buffer.getNumSamples(); + const int numChannels = juce::jmin(2, buffer.getNumChannels()); + const double sr = cachedSampleRate > 0.0 ? cachedSampleRate : 44100.0; + + if (numSamples <= 0 || numChannels <= 0) + return; + + const float nyquist = static_cast(sr * 0.5) - 1.0f; + for (int b = 0; b < numBands; ++b) + { + const size_t bandIndex = static_cast(b); + const float rangeDB = juce::jlimit(-24.0f, 24.0f, bands[b].dynamicRange.load(std::memory_order_relaxed)); + const bool dynamicOn = bands[b].dynamicEnabled.load(std::memory_order_relaxed) >= 0.5f && std::abs(rangeDB) > 0.01f; + if (!dynamicOn) + { + dynamicEnvelope[bandIndex] *= 0.85f; + const float current = dynamicGainDB[bandIndex].load(std::memory_order_relaxed); + dynamicGainDB[bandIndex].store(current * 0.85f, std::memory_order_relaxed); + continue; + } + + const float freq = juce::jlimit(20.0f, nyquist, bands[b].freq.load(std::memory_order_relaxed)); + const float q = juce::jlimit(0.1f, 30.0f, bands[b].q.load(std::memory_order_relaxed)); + auto& detectorCache = cachedDynamicDetectorStates[bandIndex]; + const bool detectorChanged = !detectorCache.valid + || std::abs(detectorCache.freq - freq) > 0.01f + || std::abs(detectorCache.q - q) > 0.001f; + if (detectorChanged) + { + auto detectorCoeffs = juce::dsp::IIR::Coefficients::makeBandPass(sr, freq, q); + for (int ch = 0; ch < 2; ++ch) + dynamicDetectorFilters[b][ch].coefficients = detectorCoeffs; + detectorCache.valid = true; + detectorCache.freq = freq; + detectorCache.q = q; + } + + float sumSquares = 0.0f; + for (int i = 0; i < numSamples; ++i) + { + for (int ch = 0; ch < numChannels; ++ch) + { + const float filtered = dynamicDetectorFilters[b][ch].processSample(buffer.getSample(ch, i)); + sumSquares += filtered * filtered; + } + } + + const float blockLevel = std::sqrt(sumSquares / static_cast(numSamples * numChannels)); + const float attackMs = juce::jlimit(0.2f, 250.0f, bands[b].dynamicAttack.load(std::memory_order_relaxed)); + const float releaseMs = juce::jlimit(5.0f, 2000.0f, bands[b].dynamicRelease.load(std::memory_order_relaxed)); + const float attackCoeff = std::exp(-static_cast(numSamples) / (attackMs * 0.001f * static_cast(sr))); + const float releaseCoeff = std::exp(-static_cast(numSamples) / (releaseMs * 0.001f * static_cast(sr))); + const float levelCoeff = blockLevel > dynamicEnvelope[bandIndex] ? attackCoeff : releaseCoeff; + dynamicEnvelope[bandIndex] = levelCoeff * dynamicEnvelope[bandIndex] + (1.0f - levelCoeff) * blockLevel; + + const float levelDB = juce::Decibels::gainToDecibels(dynamicEnvelope[bandIndex], -100.0f); + const float thresholdDB = juce::jlimit(-80.0f, 0.0f, bands[b].dynamicThreshold.load(std::memory_order_relaxed)); + const float activity = juce::jlimit(0.0f, 1.0f, (levelDB - thresholdDB) / 18.0f); + const float targetDynamicDB = rangeDB * activity; + const float currentDynamicDB = dynamicGainDB[bandIndex].load(std::memory_order_relaxed); + const float gainCoeff = std::abs(targetDynamicDB) > std::abs(currentDynamicDB) ? attackCoeff : releaseCoeff; + const float nextDynamicDB = gainCoeff * currentDynamicDB + (1.0f - gainCoeff) * targetDynamicDB; + dynamicGainDB[bandIndex].store(juce::jlimit(-24.0f, 24.0f, nextDynamicDB), std::memory_order_relaxed); + } +} + +float S13EQ::estimateAutoGainCompensationDB() const +{ + static constexpr int probeCount = 16; + const std::array probeFrequencies { + 31.5, 45.0, 63.0, 90.0, 125.0, 180.0, 250.0, 355.0, + 500.0, 710.0, 1000.0, 1400.0, 2000.0, 4000.0, 8000.0, 16000.0 + }; + + std::array responseDB {}; + const int auditionIndex = juce::jlimit(-1, numBands - 1, + static_cast(std::round(auditionBand.load(std::memory_order_relaxed))) - 1); + for (int b = 0; b < numBands; ++b) + { + if (auditionIndex >= 0 && b != auditionIndex) + continue; + if (auditionIndex < 0 && bands[b].enabled.load(std::memory_order_relaxed) < 0.5f) + continue; + + for (int stage = 0; stage < activeStages[b]; ++stage) + { + auto coeffs = bandFilters[b][stage].state; + if (coeffs == nullptr) + continue; + + std::array magnitudes {}; + coeffs->getMagnitudeForFrequencyArray(probeFrequencies.data(), + magnitudes.data(), + probeFrequencies.size(), + cachedSampleRate); + for (int i = 0; i < probeCount; ++i) + responseDB[static_cast(i)] += juce::Decibels::gainToDecibels(static_cast(magnitudes[static_cast(i)]), -48.0f); + } + } + + float weightedSum = 0.0f; + float weightTotal = 0.0f; + for (int i = 0; i < probeCount; ++i) + { + const float frequency = static_cast(probeFrequencies[static_cast(i)]); + const float presenceWeight = frequency >= 125.0f && frequency <= 8000.0f ? 1.0f : 0.45f; + weightedSum += responseDB[static_cast(i)] * presenceWeight; + weightTotal += presenceWeight; + } + + if (weightTotal <= 0.0f) + return 0.0f; + + return juce::jlimit(-9.0f, 9.0f, -weightedSum / weightTotal); } void S13EQ::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) { juce::ignoreUnused(midi); juce::ScopedNoDenormals noDenormals; + updateDynamicBands(buffer); updateFilters(); const int numSamples = buffer.getNumSamples(); + const int numChannels = buffer.getNumChannels(); + const int requestedMode = juce::jlimit(0, 2, static_cast(std::round(stereoMode.load(std::memory_order_relaxed)))); + const bool useMidSideMode = requestedMode > 0 + && numChannels >= 2 + && msScratch.getNumSamples() >= numSamples; + if (requestedMode != lastProcessingMode) + { + for (int b = 0; b < numBands; ++b) + for (int s = 0; s < maxStagesPerBand; ++s) + bandFilters[b][s].reset(); + lastProcessingMode = requestedMode; + } // Capture pre-EQ samples for spectrum { @@ -185,13 +390,40 @@ void S13EQ::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& mid } } + if (useMidSideMode) + { + auto* left = buffer.getWritePointer(0); + auto* right = buffer.getWritePointer(1); + auto* scratch = msScratch.getWritePointer(0); + for (int i = 0; i < numSamples; ++i) + { + const float mid = (left[i] + right[i]) * 0.5f; + const float side = (left[i] - right[i]) * 0.5f; + if (requestedMode == 1) + { + left[i] = mid; + scratch[i] = side; + } + else + { + left[i] = side; + scratch[i] = mid; + } + } + } + // Process each enabled band juce::dsp::AudioBlock block(buffer); - juce::dsp::ProcessContextReplacing context(block); + auto processingBlock = useMidSideMode ? block.getSingleChannelBlock(0) : block; + juce::dsp::ProcessContextReplacing context(processingBlock); + const int auditionIndex = juce::jlimit(-1, numBands - 1, + static_cast(std::round(auditionBand.load(std::memory_order_relaxed))) - 1); for (int b = 0; b < numBands; ++b) { - if (bands[b].enabled.load() < 0.5f) + if (auditionIndex >= 0 && b != auditionIndex) + continue; + if (auditionIndex < 0 && bands[b].enabled.load() < 0.5f) continue; for (int s = 0; s < activeStages[b]; ++s) bandFilters[b][s].process(context); @@ -199,8 +431,43 @@ void S13EQ::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& mid // Output gain float outGainDB = juce::jlimit(-12.0f, 12.0f, outputGain.load()); + if (autoGain.load(std::memory_order_relaxed) >= 0.5f) + { + const float targetAutoGainDB = estimateAutoGainCompensationDB(); + smoothedAutoGainDB += (targetAutoGainDB - smoothedAutoGainDB) * 0.08f; + outGainDB += smoothedAutoGainDB; + } + else + { + smoothedAutoGainDB *= 0.92f; + outGainDB += smoothedAutoGainDB; + } + outGainDB = juce::jlimit(-18.0f, 18.0f, outGainDB); if (std::abs(outGainDB) > 0.01f) - buffer.applyGain(juce::Decibels::decibelsToGain(outGainDB)); + { + const float outGain = juce::Decibels::decibelsToGain(outGainDB); + if (useMidSideMode) + buffer.applyGain(0, 0, numSamples, outGain); + else + buffer.applyGain(outGain); + } + + if (useMidSideMode) + { + auto* left = buffer.getWritePointer(0); + auto* right = buffer.getWritePointer(1); + auto* scratch = msScratch.getWritePointer(0); + for (int i = 0; i < numSamples; ++i) + { + const float target = left[i]; + const float stored = scratch[i]; + const float mid = requestedMode == 1 ? target : stored; + const float side = requestedMode == 1 ? stored : target; + left[i] = mid + side; + right[i] = mid - side; + } + } + sanitizeBuiltInBuffer(buffer, 2.5f); // Capture post-EQ samples { @@ -249,9 +516,12 @@ S13EQ::SpectrumData S13EQ::getSpectrumData() const std::vector S13EQ::getMagnitudeResponse(const std::vector& frequencies) const { std::vector response(frequencies.size(), 0.0f); + const int auditionIndex = juce::jlimit(-1, numBands - 1, + static_cast(std::round(auditionBand.load(std::memory_order_relaxed))) - 1); for (int b = 0; b < numBands; ++b) { - if (bands[b].enabled.load() < 0.5f) continue; + if (auditionIndex >= 0 && b != auditionIndex) continue; + if (auditionIndex < 0 && bands[b].enabled.load() < 0.5f) continue; for (int s = 0; s < activeStages[b]; ++s) { auto coeffs = bandFilters[b][s].state; @@ -269,11 +539,20 @@ std::vector S13EQ::getMagnitudeResponse(const std::vector& frequen return response; } +float S13EQ::getBandDynamicGainDB(int bandIndex) const +{ + if (bandIndex < 0 || bandIndex >= numBands) + return 0.0f; + return dynamicGainDB[static_cast(bandIndex)].load(std::memory_order_relaxed); +} + void S13EQ::getStateInformation(juce::MemoryBlock& destData) { juce::ValueTree state("S13EQ"); state.setProperty("outputGain", outputGain.load(), nullptr); state.setProperty("autoGain", autoGain.load(), nullptr); + state.setProperty("auditionBand", auditionBand.load(), nullptr); + state.setProperty("stereoMode", stereoMode.load(), nullptr); for (int i = 0; i < numBands; ++i) { juce::String p = "band" + juce::String(i) + "_"; @@ -283,6 +562,11 @@ void S13EQ::getStateInformation(juce::MemoryBlock& destData) state.setProperty(p + "gain", bands[i].gain.load(), nullptr); state.setProperty(p + "q", bands[i].q.load(), nullptr); state.setProperty(p + "slope", bands[i].slope.load(), nullptr); + state.setProperty(p + "dynamicEnabled", bands[i].dynamicEnabled.load(), nullptr); + state.setProperty(p + "dynamicThreshold", bands[i].dynamicThreshold.load(), nullptr); + state.setProperty(p + "dynamicRange", bands[i].dynamicRange.load(), nullptr); + state.setProperty(p + "dynamicAttack", bands[i].dynamicAttack.load(), nullptr); + state.setProperty(p + "dynamicRelease", bands[i].dynamicRelease.load(), nullptr); } juce::MemoryOutputStream stream(destData, false); state.writeToStream(stream); @@ -295,6 +579,8 @@ void S13EQ::setStateInformation(const void* data, int sizeInBytes) outputGain.store(static_cast(state.getProperty("outputGain", 0.0f))); autoGain.store(static_cast(state.getProperty("autoGain", 0.0f))); + auditionBand.store(static_cast(state.getProperty("auditionBand", 0.0f))); + stereoMode.store(static_cast(state.getProperty("stereoMode", 0.0f))); for (int i = 0; i < numBands; ++i) { juce::String p = "band" + juce::String(i) + "_"; @@ -304,6 +590,11 @@ void S13EQ::setStateInformation(const void* data, int sizeInBytes) bands[i].gain.store(static_cast(state.getProperty(p + "gain", 0.0f))); bands[i].q.store(static_cast(state.getProperty(p + "q", 1.0f))); bands[i].slope.store(static_cast(state.getProperty(p + "slope", 1.0f))); + bands[i].dynamicEnabled.store(static_cast(state.getProperty(p + "dynamicEnabled", 0.0f))); + bands[i].dynamicThreshold.store(static_cast(state.getProperty(p + "dynamicThreshold", -24.0f))); + bands[i].dynamicRange.store(static_cast(state.getProperty(p + "dynamicRange", 0.0f))); + bands[i].dynamicAttack.store(static_cast(state.getProperty(p + "dynamicAttack", 10.0f))); + bands[i].dynamicRelease.store(static_cast(state.getProperty(p + "dynamicRelease", 150.0f))); } updateFilters(); } @@ -357,6 +648,7 @@ void S13Compressor::prepareToPlay(double sampleRate, int samplesPerBlock) { cachedSampleRate = sampleRate; envelopeLevel = 0.0f; + rmsEnvelopeLevel = 0.0f; currentGainLin = 1.0f; smoothedMakeup.reset(sampleRate, 0.02); @@ -383,6 +675,7 @@ void S13Compressor::prepareToPlay(double sampleRate, int samplesPerBlock) void S13Compressor::releaseResources() { envelopeLevel = 0.0f; + rmsEnvelopeLevel = 0.0f; currentGainLin = 1.0f; } @@ -409,7 +702,13 @@ void S13Compressor::processBlock(juce::AudioBuffer& buffer, juce::MidiBuf getStyleBallistics(atkMs, relMs); const float srf = static_cast(cachedSampleRate); const float attackCoeff = std::exp(-1.0f / (atkMs * 0.001f * srf)); - const float releaseCoeff = std::exp(-1.0f / (relMs * 0.001f * srf)); + const float releaseScale = autoRelease.load(std::memory_order_relaxed) >= 0.5f + ? juce::jlimit(0.65f, 3.5f, 1.0f + std::abs(gainReductionDB.load(std::memory_order_relaxed)) / 12.0f) + : 1.0f; + const float releaseCoeff = std::exp(-1.0f / (relMs * releaseScale * 0.001f * srf)); + const float rmsCoeff = std::exp(-1.0f / (0.025f * srf)); + const int detector = juce::jlimit(0, 2, static_cast(std::round(detectorMode.load(std::memory_order_relaxed)))); + const float link = juce::jlimit(0.0f, 1.0f, stereoLink.load(std::memory_order_relaxed)); const float mixWet = juce::jlimit(0.0f, 1.0f, mix.load()); const float mixDry = 1.0f - mixWet; @@ -434,7 +733,15 @@ void S13Compressor::processBlock(juce::AudioBuffer& buffer, juce::MidiBuf float scL = scHPF_L.processSample(dryL); float scR = dataR ? scHPF_R.processSample(dryR) : scL; - float scLevel = juce::jmax(std::abs(scL), std::abs(scR)); + const float linkedPeak = juce::jmax(std::abs(scL), std::abs(scR)); + const float averagePeak = (std::abs(scL) + std::abs(scR)) * 0.5f; + const float peakLevel = averagePeak + (linkedPeak - averagePeak) * link; + const float rmsInput = dataR ? (scL * scL + scR * scR) * 0.5f : scL * scL; + rmsEnvelopeLevel = rmsCoeff * rmsEnvelopeLevel + (1.0f - rmsCoeff) * rmsInput; + const float rmsLevel = std::sqrt(juce::jmax(0.0f, rmsEnvelopeLevel)); + const float scLevel = detector == 1 + ? rmsLevel + : (detector == 2 ? juce::jmax(rmsLevel, peakLevel * 0.72f) : peakLevel); if (scLevel > envelopeLevel) envelopeLevel = attackCoeff * envelopeLevel + (1.0f - attackCoeff) * scLevel; @@ -460,8 +767,12 @@ void S13Compressor::processBlock(juce::AudioBuffer& buffer, juce::MidiBuf if (dataR) dataR[i] = dryR * mixDry + wetR * mixWet; } - smoothedMakeup.setTargetValue(juce::Decibels::decibelsToGain(juce::jlimit(0.0f, 36.0f, makeupGain.load()))); + const float targetMakeupDB = autoMakeup.load(std::memory_order_relaxed) >= 0.5f + ? juce::jlimit(0.0f, 18.0f, std::abs(peakGR) * 0.5f) + : juce::jlimit(0.0f, 36.0f, makeupGain.load(std::memory_order_relaxed)); + smoothedMakeup.setTargetValue(juce::Decibels::decibelsToGain(targetMakeupDB)); gainReductionDB.store(peakGR); + sanitizeBuiltInBuffer(buffer, 2.5f); float outputPeak = 0.0f; for (int ch = 0; ch < numChannels; ++ch) @@ -484,6 +795,8 @@ void S13Compressor::getStateInformation(juce::MemoryBlock& destData) state.setProperty("autoRelease", autoRelease.load(), nullptr); state.setProperty("sidechainHPF", sidechainHPF.load(), nullptr); state.setProperty("lookahead", lookaheadMs.load(), nullptr); + state.setProperty("detectorMode", detectorMode.load(), nullptr); + state.setProperty("stereoLink", stereoLink.load(), nullptr); juce::MemoryOutputStream stream(destData, false); state.writeToStream(stream); } @@ -505,6 +818,8 @@ void S13Compressor::setStateInformation(const void* data, int sizeInBytes) autoRelease.store(static_cast(state.getProperty("autoRelease", 0.0f))); sidechainHPF.store(static_cast(state.getProperty("sidechainHPF", 20.0f))); lookaheadMs.store(static_cast(state.getProperty("lookahead", 0.0f))); + detectorMode.store(static_cast(state.getProperty("detectorMode", 0.0f))); + stereoLink.store(static_cast(state.getProperty("stereoLink", 1.0f))); } //============================================================================== @@ -517,8 +832,11 @@ void S13Gate::prepareToPlay(double sampleRate, int samplesPerBlock) { cachedSampleRate = sampleRate; envelopeLevel = 0.0f; + rmsEnvelopeLevel = 0.0f; holdCounter = 0; currentGain = 0.0f; + lastSidechainHPF = -1.0f; + lastSidechainLPF = -1.0f; auto hpfCoeffs = juce::dsp::IIR::Coefficients::makeHighPass(sampleRate, 20.0f); scHPF_L.coefficients = hpfCoeffs; scHPF_R.coefficients = hpfCoeffs; @@ -537,6 +855,7 @@ void S13Gate::prepareToPlay(double sampleRate, int samplesPerBlock) void S13Gate::releaseResources() { envelopeLevel = 0.0f; + rmsEnvelopeLevel = 0.0f; holdCounter = 0; currentGain = 0.0f; } @@ -557,12 +876,20 @@ void S13Gate::updateCoefficients() rangeGain = juce::Decibels::decibelsToGain(juce::jlimit(-80.0f, 0.0f, range.load())); float hpfFreq = juce::jlimit(20.0f, 2000.0f, sidechainHPF.load()); - scHPF_L.coefficients = juce::dsp::IIR::Coefficients::makeHighPass(sr, hpfFreq); - scHPF_R.coefficients = scHPF_L.coefficients; + if (lastSidechainHPF < 0.0f || std::abs(hpfFreq - lastSidechainHPF) > 1.0f) + { + lastSidechainHPF = hpfFreq; + scHPF_L.coefficients = juce::dsp::IIR::Coefficients::makeHighPass(sr, hpfFreq); + scHPF_R.coefficients = scHPF_L.coefficients; + } float lpfFreq = juce::jlimit(200.0f, 20000.0f, sidechainLPF.load()); - scLPF_L.coefficients = juce::dsp::IIR::Coefficients::makeLowPass(sr, lpfFreq); - scLPF_R.coefficients = scLPF_L.coefficients; + if (lastSidechainLPF < 0.0f || std::abs(lpfFreq - lastSidechainLPF) > 8.0f) + { + lastSidechainLPF = lpfFreq; + scLPF_L.coefficients = juce::dsp::IIR::Coefficients::makeLowPass(sr, lpfFreq); + scLPF_R.coefficients = scLPF_L.coefficients; + } } void S13Gate::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) @@ -578,18 +905,28 @@ void S13Gate::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& m const float envAttack = 0.9995f; const float envRelease = 0.9999f; + const float rmsCoeff = std::exp(-1.0f / (0.018f * static_cast(juce::jmax(1.0, cachedSampleRate)))); + const int detector = juce::jlimit(0, 2, static_cast(std::round(detectorMode.load(std::memory_order_relaxed)))); float peakGR = 0.0f; for (int i = 0; i < numSamples; ++i) { - float inputLevel = 0.0f; + float peakLevel = 0.0f; + float rmsSum = 0.0f; for (int ch = 0; ch < numChannels; ++ch) { float s = buffer.getSample(ch, i); s = (ch == 0) ? scLPF_L.processSample(scHPF_L.processSample(s)) : scLPF_R.processSample(scHPF_R.processSample(s)); - inputLevel = juce::jmax(inputLevel, std::abs(s)); + peakLevel = juce::jmax(peakLevel, std::abs(s)); + rmsSum += s * s; } + rmsEnvelopeLevel = rmsCoeff * rmsEnvelopeLevel + + (1.0f - rmsCoeff) * (rmsSum / static_cast(juce::jmax(1, numChannels))); + const float rmsLevel = std::sqrt(juce::jmax(0.0f, rmsEnvelopeLevel)); + const float inputLevel = detector == 1 + ? rmsLevel + : (detector == 2 ? juce::jmax(rmsLevel, peakLevel * 0.7f) : peakLevel); if (inputLevel > envelopeLevel) envelopeLevel = envAttack * envelopeLevel + (1.0f - envAttack) * inputLevel; @@ -622,6 +959,7 @@ void S13Gate::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& m buffer.setSample(ch, i, d * mixDry + d * currentGain * mixWet); } } + sanitizeBuiltInBuffer(buffer, 2.5f); gainReductionDB.store(peakGR); } @@ -637,6 +975,7 @@ void S13Gate::getStateInformation(juce::MemoryBlock& destData) state.setProperty("sidechainHPF", sidechainHPF.load(), nullptr); state.setProperty("sidechainLPF", sidechainLPF.load(), nullptr); state.setProperty("mix", mix.load(), nullptr); + state.setProperty("detectorMode", detectorMode.load(), nullptr); juce::MemoryOutputStream stream(destData, false); state.writeToStream(stream); } @@ -655,6 +994,7 @@ void S13Gate::setStateInformation(const void* data, int sizeInBytes) sidechainHPF.store(static_cast(state.getProperty("sidechainHPF", 20.0f))); sidechainLPF.store(static_cast(state.getProperty("sidechainLPF", 20000.0f))); mix.store(static_cast(state.getProperty("mix", 1.0f))); + detectorMode.store(static_cast(state.getProperty("detectorMode", 0.0f))); updateCoefficients(); } @@ -676,67 +1016,141 @@ void S13Limiter::prepareToPlay(double sampleRate, int samplesPerBlock) smoothedCeiling.reset(sampleRate, 0.02); smoothedCeiling.setCurrentAndTargetValue(juce::Decibels::decibelsToGain(ceiling.load())); + const int maxLookaheadSamples = static_cast(std::ceil(sampleRate * 0.02)) + juce::jmax(samplesPerBlock, 1) + 8; + lookaheadBuffer.setSize(2, juce::jmax(16, maxLookaheadSamples), false, false, true); + truePeakScratch.setSize(2, juce::jmax(1, samplesPerBlock), false, false, true); + lookaheadBuffer.clear(); + truePeakScratch.clear(); + lookaheadWriteIndex = 0; + gainEnvelope = 1.0f; + previousDetectorSample.fill(0.0f); + oversampler = std::make_unique>( - 2, 1, juce::dsp::Oversampling::filterHalfBandFIREquiripple, false); + 2, 2, juce::dsp::Oversampling::filterHalfBandFIREquiripple, false); oversampler->initProcessing(static_cast(samplesPerBlock)); } -void S13Limiter::releaseResources() { limiter.reset(); } +void S13Limiter::releaseResources() +{ + limiter.reset(); + lookaheadBuffer.clear(); + truePeakScratch.clear(); + lookaheadWriteIndex = 0; + gainEnvelope = 1.0f; + previousDetectorSample.fill(0.0f); +} void S13Limiter::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) { juce::ignoreUnused(midi); juce::ScopedNoDenormals noDenormals; - float inputPeak = 0.0f; - for (int ch = 0; ch < buffer.getNumChannels(); ++ch) - inputPeak = juce::jmax(inputPeak, buffer.getMagnitude(ch, 0, buffer.getNumSamples())); - - limiter.setThreshold(juce::jlimit(-20.0f, 0.0f, threshold.load())); - limiter.setRelease(juce::jlimit(10.0f, 500.0f, releaseMs.load())); - - juce::dsp::AudioBlock block(buffer); - juce::dsp::ProcessContextReplacing context(block); - limiter.process(context); - - float ceilingDB = juce::jlimit(-3.0f, 0.0f, ceiling.load()); - float targetCeiling = juce::Decibels::decibelsToGain(ceilingDB); - smoothedCeiling.setTargetValue(targetCeiling); - const int numSamples = buffer.getNumSamples(); const int numChannels = buffer.getNumChannels(); + if (numSamples <= 0 || numChannels <= 0) + return; - if (smoothedCeiling.isSmoothing()) + float inputPeak = 0.0f; + for (int ch = 0; ch < numChannels; ++ch) + inputPeak = juce::jmax(inputPeak, buffer.getMagnitude(ch, 0, numSamples)); + float truePeakScale = 1.0f; + if (oversampler != nullptr + && numChannels <= truePeakScratch.getNumChannels() + && numSamples <= truePeakScratch.getNumSamples()) { - for (int i = 0; i < numSamples; ++i) + truePeakScratch.clear(); + for (int ch = 0; ch < numChannels; ++ch) + truePeakScratch.copyFrom(ch, 0, buffer, ch, 0, numSamples); + + juce::dsp::AudioBlock scratchBlock(truePeakScratch); + auto blockToScan = scratchBlock.getSubBlock(0, static_cast(numSamples)); + auto oversampledBlock = oversampler->processSamplesUp(blockToScan); + float oversampledPeak = 0.0f; + for (size_t ch = 0; ch < oversampledBlock.getNumChannels(); ++ch) { - float g = smoothedCeiling.getNextValue(); - for (int ch = 0; ch < numChannels; ++ch) - buffer.setSample(ch, i, buffer.getSample(ch, i) * g); + auto* channelData = oversampledBlock.getChannelPointer(ch); + for (size_t sample = 0; sample < oversampledBlock.getNumSamples(); ++sample) + oversampledPeak = juce::jmax(oversampledPeak, std::abs(channelData[sample])); } - } - else - { - float g = smoothedCeiling.getTargetValue(); - if (std::abs(g - 1.0f) > 0.0001f) - for (int ch = 0; ch < numChannels; ++ch) - buffer.applyGain(ch, 0, numSamples, g); + oversampler->processSamplesDown(blockToScan); + if (inputPeak > 1.0e-6f && oversampledPeak > inputPeak) + truePeakScale = juce::jlimit(1.0f, 3.0f, oversampledPeak / inputPeak); } - // Hard clip - for (int ch = 0; ch < numChannels; ++ch) + const float srf = static_cast(juce::jmax(1.0, cachedSampleRate)); + const float thresholdGain = juce::Decibels::decibelsToGain(juce::jlimit(-20.0f, 0.0f, threshold.load(std::memory_order_relaxed))); + const float ceilingDB = juce::jlimit(-3.0f, 0.0f, ceiling.load(std::memory_order_relaxed)); + const float targetCeiling = juce::Decibels::decibelsToGain(ceilingDB); + const float limitGain = juce::jmin(thresholdGain, targetCeiling); + const float releaseCoeff = std::exp(-1.0f / (juce::jlimit(10.0f, 500.0f, releaseMs.load(std::memory_order_relaxed)) * 0.001f * srf)); + const int ringSize = lookaheadBuffer.getNumSamples(); + const int delaySamples = juce::jlimit(0, ringSize > 1 ? ringSize - 1 : 0, + static_cast(std::round(juce::jlimit(0.0f, 20.0f, lookaheadMs.load(std::memory_order_relaxed)) * 0.001f * srf))); + smoothedCeiling.setTargetValue(targetCeiling); + + float peakGain = 1.0f; + for (int i = 0; i < numSamples; ++i) { - float* d = buffer.getWritePointer(ch); - juce::FloatVectorOperations::clip(d, d, -targetCeiling, targetCeiling, numSamples); + float detectorPeak = 0.0f; + for (int ch = 0; ch < numChannels; ++ch) + { + const float sample = buffer.getSample(ch, i); + const int detectorChannel = juce::jmin(ch, static_cast(previousDetectorSample.size()) - 1); + const float previous = previousDetectorSample[static_cast(detectorChannel)]; + const float midpointEstimate = std::abs((sample + previous) * 0.5f) + std::abs(sample - previous) * 0.25f; + detectorPeak = juce::jmax(detectorPeak, std::abs(sample), midpointEstimate); + previousDetectorSample[static_cast(detectorChannel)] = sample; + } + detectorPeak *= truePeakScale; + + const float targetGain = detectorPeak > limitGain && detectorPeak > 1.0e-8f + ? juce::jlimit(0.0f, 1.0f, limitGain / detectorPeak) + : 1.0f; + + if (targetGain < gainEnvelope) + gainEnvelope = targetGain; + else + gainEnvelope = releaseCoeff * gainEnvelope + (1.0f - releaseCoeff) * targetGain; + peakGain = juce::jmin(peakGain, gainEnvelope); + + for (int ch = 0; ch < numChannels; ++ch) + lookaheadBuffer.setSample(ch, lookaheadWriteIndex, buffer.getSample(ch, i)); + + int readIndex = lookaheadWriteIndex - delaySamples; + if (readIndex < 0) + readIndex += ringSize; + + const float ceilingForSample = smoothedCeiling.getNextValue(); + const float ceilingScale = gainEnvelope < 0.9999f + ? ceilingForSample / juce::jmax(1.0e-6f, limitGain) + : 1.0f; + for (int ch = 0; ch < numChannels; ++ch) + { + float limited = lookaheadBuffer.getSample(ch, readIndex) * gainEnvelope * ceilingScale; + const float absLimited = std::abs(limited); + const float kneeStart = ceilingForSample * 0.98f; + if (absLimited > kneeStart) + { + const float sign = limited >= 0.0f ? 1.0f : -1.0f; + const float kneeWidth = juce::jmax(ceilingForSample - kneeStart, 1.0e-6f); + const float x = juce::jmax(0.0f, (absLimited - kneeStart) / kneeWidth); + const float curved = kneeStart + kneeWidth * (1.0f - 1.0f / (1.0f + x)); + limited = sign * juce::jmin(ceilingForSample, curved); + } + buffer.setSample(ch, i, limited); + } + + lookaheadWriteIndex = (lookaheadWriteIndex + 1) % ringSize; } // GR metering + sanitizeBuiltInBuffer(buffer, 1.25f); float outputPeak = 0.0f; for (int ch = 0; ch < numChannels; ++ch) outputPeak = juce::jmax(outputPeak, buffer.getMagnitude(ch, 0, numSamples)); float inDB = juce::Decibels::gainToDecibels(inputPeak, -100.0f); float outDB = juce::Decibels::gainToDecibels(outputPeak, -100.0f); - gainReductionDB.store(outDB - inDB); + gainReductionDB.store(juce::jmin(outDB - inDB, juce::Decibels::gainToDecibels(peakGain, -100.0f))); } void S13Limiter::getStateInformation(juce::MemoryBlock& destData) diff --git a/Source/BuiltInEffects.h b/Source/BuiltInEffects.h index 49aa82a..e2a4809 100644 --- a/Source/BuiltInEffects.h +++ b/Source/BuiltInEffects.h @@ -94,11 +94,18 @@ class S13EQ : public S13BuiltInEffect std::atomic gain { 0.0f }; // dB (-30 to +30) std::atomic q { 1.0f }; // 0.1 to 30.0 std::atomic slope { 1.0f }; // FilterSlope as float (for cut/shelf) + std::atomic dynamicEnabled { 0.0f }; // 0 = static, 1 = dynamic + std::atomic dynamicThreshold { -24.0f }; // detector threshold dB + std::atomic dynamicRange { 0.0f }; // dB added above threshold + std::atomic dynamicAttack { 10.0f }; // ms + std::atomic dynamicRelease { 150.0f }; // ms }; std::array bands; std::atomic outputGain { 0.0f }; // dB (-12 to +12) std::atomic autoGain { 0.0f }; // 0 = off, 1 = on + std::atomic auditionBand { 0.0f }; // 0 = off, 1-8 = solo/audition band + std::atomic stereoMode { 0.0f }; // 0 = stereo, 1 = mid, 2 = side // Spectrum analyzer data static constexpr int fftOrder = 11; // 2048-point FFT @@ -114,6 +121,7 @@ class S13EQ : public S13BuiltInEffect // Get magnitude response at given frequencies (for drawing the EQ curve) std::vector getMagnitudeResponse(const std::vector& frequencies) const; + float getBandDynamicGainDB(int bandIndex) const; private: static constexpr int maxStagesPerBand = 4; // for 48 dB/oct cascaded biquads @@ -122,12 +130,40 @@ class S13EQ : public S13BuiltInEffect StereoIIR bandFilters[numBands][maxStagesPerBand]; int activeStages[numBands] = {}; + juce::dsp::IIR::Filter dynamicDetectorFilters[numBands][2]; + std::array dynamicEnvelope {}; + std::array, numBands> dynamicGainDB {}; + juce::AudioBuffer msScratch; + int lastProcessingMode = 0; + + struct CachedBandState + { + bool valid = false; + float enabled = -1.0f; + int type = -1; + float freq = -1.0f; + float gain = -999.0f; + float q = -1.0f; + int slope = -1; + }; + std::array cachedBandStates {}; + + struct CachedDynamicDetectorState + { + bool valid = false; + float freq = -1.0f; + float q = -1.0f; + }; + std::array cachedDynamicDetectorStates {}; double cachedSampleRate = 44100.0; + float smoothedAutoGainDB = 0.0f; void updateFilters(); void updateBand(int bandIndex); + void updateDynamicBands(const juce::AudioBuffer& buffer); int getNumStagesForSlope(FilterSlope slope) const; + float estimateAutoGainCompensationDB() const; // FFT for spectrum analyzer juce::dsp::FFT fft { fftOrder }; @@ -185,6 +221,8 @@ class S13Compressor : public S13BuiltInEffect std::atomic autoRelease { 0.0f }; // 0 = off, 1 = on std::atomic sidechainHPF { 20.0f }; // 20-500 Hz std::atomic lookaheadMs { 0.0f }; // 0-20 ms + std::atomic detectorMode { 0.0f }; // 0=Peak, 1=RMS, 2=Auto + std::atomic stereoLink { 1.0f }; // 0=average detector, 1=linked peak detector // Metering float getCurrentGainReduction() const { return gainReductionDB.load(); } @@ -193,6 +231,7 @@ class S13Compressor : public S13BuiltInEffect private: float envelopeLevel = 0.0f; + float rmsEnvelopeLevel = 0.0f; float currentGainLin = 1.0f; juce::SmoothedValue smoothedMakeup; @@ -243,11 +282,13 @@ class S13Gate : public S13BuiltInEffect std::atomic sidechainHPF { 20.0f }; // 20-2000 Hz std::atomic sidechainLPF { 20000.0f }; // 200-20000 Hz std::atomic mix { 1.0f }; // 0-1 + std::atomic detectorMode { 0.0f }; // 0=Peak, 1=RMS, 2=Auto bool isGateOpen() const { return gateOpen.load(); } private: float envelopeLevel = 0.0f; + float rmsEnvelopeLevel = 0.0f; int holdCounter = 0; float currentGain = 0.0f; std::atomic gateOpen { false }; @@ -263,6 +304,8 @@ class S13Gate : public S13BuiltInEffect juce::dsp::IIR::Filter scLPF_L, scLPF_R; double cachedSampleRate = 44100.0; + float lastSidechainHPF = -1.0f; + float lastSidechainLPF = -1.0f; void updateCoefficients(); @@ -299,5 +342,11 @@ class S13Limiter : public S13BuiltInEffect juce::SmoothedValue smoothedCeiling; double cachedSampleRate = 44100.0; + juce::AudioBuffer lookaheadBuffer; + juce::AudioBuffer truePeakScratch; + int lookaheadWriteIndex = 0; + float gainEnvelope = 1.0f; + std::array previousDetectorSample {}; + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(S13Limiter) }; diff --git a/Source/BuiltInEffects2.cpp b/Source/BuiltInEffects2.cpp index 3f9e9d6..8fbb7d3 100644 --- a/Source/BuiltInEffects2.cpp +++ b/Source/BuiltInEffects2.cpp @@ -11,6 +11,123 @@ juce::AudioProcessorEditor* S13Saturator::createEditor() { return new S13Saturat // ============================================================================ namespace { + float builtinNoise(int age, int note) + { + const float x = static_cast(age * 1103515245u + note * 12345u); + return std::sin(x * 0.0000137f) * std::sin(x * 0.000091f); + } + + float drumBaseFrequency(int note) + { + switch (note) + { + case 35: + case 36: return 52.0f; + case 37: + case 38: + case 40: return 190.0f; + case 41: return 82.0f; + case 43: return 98.0f; + case 45: return 123.0f; + case 47: return 146.0f; + case 48: return 164.0f; + case 50: return 196.0f; + default: return 440.0f; + } + } + + float drumDecaySeconds(int note, float pedalClosed) + { + switch (note) + { + case 35: + case 36: return 0.42f; + case 37: + case 38: + case 40: return 0.24f; + case 42: return 0.045f + pedalClosed * 0.03f; + case 44: return 0.08f; + case 46: return 0.18f + (1.0f - pedalClosed) * 0.62f; + case 49: + case 52: + case 55: + case 57: return 1.55f; + case 51: + case 53: + case 59: return 1.15f; + default: return note >= 41 && note <= 50 ? 0.42f : 0.28f; + } + } + + float drumPanPosition(int note) + { + switch (note) + { + case 35: + case 36: return 0.0f; + case 37: + case 38: + case 40: return -0.08f; + case 41: return 0.34f; + case 43: return 0.22f; + case 45: return 0.05f; + case 47: + case 48: + case 50: return -0.22f; + case 42: + case 44: + case 46: return 0.46f; + case 49: + case 55: + case 57: return -0.58f; + case 51: + case 52: + case 53: + case 59: return 0.54f; + default: return 0.0f; + } + } + + void sanitizeBuiltInBuffer(juce::AudioBuffer& buffer, float limit) + { + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + auto* samples = buffer.getWritePointer(ch); + for (int sample = 0; sample < buffer.getNumSamples(); ++sample) + { + const float value = samples[sample]; + samples[sample] = std::isfinite(value) ? juce::jlimit(-limit, limit, value) : 0.0f; + } + } + } + + struct BuiltInMidiVoiceRef + { + size_t channel = 0; + size_t note = 0; + }; + + constexpr size_t kBuiltInMidiVoiceSlots = 16u * 128u; + + float softLimitInstrumentBus(float value) + { + value = juce::jlimit(-4.0f, 4.0f, value); + return std::tanh(value * 1.05f) * 0.96f; + } + + float nyquistFade(float frequency, float sampleRate) + { + const float fadeStart = sampleRate * 0.38f; + const float fadeEnd = sampleRate * 0.48f; + if (frequency <= fadeStart) + return 1.0f; + if (frequency >= fadeEnd) + return 0.0f; + + const float normalized = (frequency - fadeStart) / juce::jmax(1.0f, fadeEnd - fadeStart); + return 1.0f - normalized; + } + void saveParamsToMemory(juce::MemoryBlock& destData, const juce::String& typeName, const std::vector>& params) @@ -77,6 +194,10 @@ void S13Delay::prepareToPlay(double sampleRate, int samplesPerBlock) feedbackSampleL = 0.0f; feedbackSampleR = 0.0f; + smoothedDelaySamplesL = static_cast(0.25 * cachedSampleRate); + smoothedDelaySamplesR = static_cast(0.25 * cachedSampleRate); + duckEnvelope = 0.0f; + modulationPhase = 0.0f; } float S13Delay::syncNoteToMs(float noteIndex, double bpm) @@ -86,19 +207,19 @@ float S13Delay::syncNoteToMs(float noteIndex, double bpm) const float quarterMs = static_cast(60000.0 / bpm); - // 0=1/4, 1=1/8, 2=1/16, 3=1/4d, 4=1/8d, 5=1/16d, 6=1/4t, 7=1/8t, 8=1/16t + // Matches the React schema: 1/1, 1/2, 1/4, 1/8, 1/16, 1/4T, 1/8T, 1/4D, 1/8D const int idx = juce::jlimit(0, 8, static_cast(noteIndex)); static const float baseMultipliers[] = { + 4.0f, // 1/1 + 2.0f, // 1/2 1.0f, // 1/4 0.5f, // 1/8 0.25f, // 1/16 - 1.5f, // 1/4 dotted - 0.75f, // 1/8 dotted - 0.375f, // 1/16 dotted 2.0f / 3.0f, // 1/4 triplet 1.0f / 3.0f, // 1/8 triplet - 1.0f / 6.0f // 1/16 triplet + 1.5f, // 1/4 dotted + 0.75f // 1/8 dotted }; return quarterMs * baseMultipliers[idx]; @@ -107,6 +228,7 @@ float S13Delay::syncNoteToMs(float noteIndex, double bpm) void S13Delay::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) { juce::ignoreUnused(midi); + juce::ScopedNoDenormals noDenormals; const int numSamples = buffer.getNumSamples(); const int numChannels = buffer.getNumChannels(); @@ -169,6 +291,11 @@ void S13Delay::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& const float satAmount = juce::jlimit(0.0f, 1.0f, fbSaturation.load()); const float widthVal = juce::jlimit(0.0f, 2.0f, stereoWidth.load()); const int modeVal = juce::jlimit(0, 2, static_cast(delayMode.load())); + const float duckAmount = juce::jlimit(0.0f, 1.0f, ducking.load()); + const float delaySmoothingCoeff = 1.0f - std::exp(-1.0f / (0.025f * static_cast(juce::jmax(1.0, cachedSampleRate)))); + const float duckAttack = 1.0f - std::exp(-1.0f / (0.008f * static_cast(juce::jmax(1.0, cachedSampleRate)))); + const float duckRelease = 1.0f - std::exp(-1.0f / (0.180f * static_cast(juce::jmax(1.0, cachedSampleRate)))); + const float modulationInc = 0.37f * juce::MathConstants::twoPi / static_cast(juce::jmax(1.0, cachedSampleRate)); auto* dataL = buffer.getWritePointer(0); auto* dataR = (numChannels >= 2) ? buffer.getWritePointer(1) : nullptr; @@ -177,10 +304,28 @@ void S13Delay::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& { const float inL = dataL[i]; const float inR = dataR ? dataR[i] : inL; + const float inputLevel = juce::jmax(std::abs(inL), std::abs(inR)); + const float duckCoeff = inputLevel > duckEnvelope ? duckAttack : duckRelease; + duckEnvelope += (inputLevel - duckEnvelope) * duckCoeff; + const float duckGain = 1.0f - duckAmount * juce::jlimit(0.0f, 0.82f, duckEnvelope * 1.35f); + + smoothedDelaySamplesL += (delaySamplesL - smoothedDelaySamplesL) * delaySmoothingCoeff; + smoothedDelaySamplesR += (delaySamplesR - smoothedDelaySamplesR) * delaySmoothingCoeff; + float modulatedDelayL = smoothedDelaySamplesL; + float modulatedDelayR = smoothedDelaySamplesR; + if (modeVal == 1) + { + const float wowDepth = static_cast(0.0018 * cachedSampleRate) * (0.25f + satAmount * 0.75f); + modulatedDelayL += std::sin(modulationPhase) * wowDepth; + modulatedDelayR += std::sin(modulationPhase + 1.73f) * wowDepth; + modulationPhase += modulationInc; + if (modulationPhase >= juce::MathConstants::twoPi) + modulationPhase -= juce::MathConstants::twoPi; + } // Read from delay lines - delayLineL.setDelay(delaySamplesL); - delayLineR.setDelay(delaySamplesR); + delayLineL.setDelay(juce::jlimit(1.0f, static_cast(maxDelaySamples - 1), modulatedDelayL)); + delayLineR.setDelay(juce::jlimit(1.0f, static_cast(maxDelaySamples - 1), modulatedDelayR)); const float delayedL = delayLineL.popSample(0); const float delayedR = delayLineR.popSample(0); @@ -246,10 +391,11 @@ void S13Delay::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& } // Mix dry + wet - dataL[i] = inL * dry + wetL * wet; + dataL[i] = inL * dry + wetL * wet * duckGain; if (dataR) - dataR[i] = inR * dry + wetR * wet; + dataR[i] = inR * dry + wetR * wet * duckGain; } + sanitizeBuiltInBuffer(buffer, 2.5f); } void S13Delay::releaseResources() @@ -262,6 +408,7 @@ void S13Delay::releaseResources() feedbackHPF_R.reset(); feedbackSampleL = 0.0f; feedbackSampleR = 0.0f; + duckEnvelope = 0.0f; } void S13Delay::getStateInformation(juce::MemoryBlock& destData) @@ -280,7 +427,8 @@ void S13Delay::getStateInformation(juce::MemoryBlock& destData) { "hpfFreq", hpfFreq.load() }, { "fbSaturation", fbSaturation.load() }, { "stereoWidth", stereoWidth.load() }, - { "delayMode", delayMode.load() } + { "delayMode", delayMode.load() }, + { "ducking", ducking.load() } }); } @@ -304,6 +452,7 @@ void S13Delay::setStateInformation(const void* data, int sizeInBytes) fbSaturation = static_cast((double)tree.getProperty("fbSaturation", 0.0)); stereoWidth = static_cast((double)tree.getProperty("stereoWidth", 1.0)); delayMode = static_cast((double)tree.getProperty("delayMode", 0.0)); + ducking = static_cast((double)tree.getProperty("ducking", 0.0)); } bool S13Delay::isBusesLayoutSupported(const BusesLayout& layouts) const @@ -364,11 +513,30 @@ void S13Reverb::prepareToPlay(double sampleRate, int samplesPerBlock) auto hcCoeffs = juce::dsp::IIR::Coefficients::makeLowPass(sampleRate, 20000.0f); wetHighCutL.coefficients = hcCoeffs; wetHighCutR.coefficients = hcCoeffs; + lastLowCut = 20.0f; + lastHighCut = 20000.0f; + + const int scratchChannels = juce::jmax(2, getTotalNumOutputChannels()); + const int scratchSamples = juce::jmax(samplesPerBlock, 16384); + dryBuffer.setSize(scratchChannels, scratchSamples, false, false, true); + earlyOutputBuffer.setSize(scratchChannels, scratchSamples, false, false, true); + earlyReflectionBuffer.setSize(2, juce::jmax(samplesPerBlock + 8, static_cast(std::ceil(sampleRate * 0.12))), false, false, true); + earlyReflectionBuffer.clear(); + earlyReflectionWriteIndex = 0; + lateTankBuffer.setSize(lateLineCount, juce::jmax(samplesPerBlock + 8, static_cast(std::ceil(sampleRate * 2.5))), false, false, true); + lateTankBuffer.clear(); + lateTankWriteIndex = 0; + lateDampingState.fill(0.0f); + shimmerSmootherL = 0.0f; + shimmerSmootherR = 0.0f; + for (size_t line = 0; line < lateModPhase.size(); ++line) + lateModPhase[line] = static_cast(line) * 0.77f; } void S13Reverb::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) { juce::ignoreUnused(midi); + juce::ScopedNoDenormals noDenormals; const int numSamples = buffer.getNumSamples(); const int numChannels = buffer.getNumChannels(); @@ -383,14 +551,22 @@ void S13Reverb::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& const float dryLvl = juce::jlimit(0.0f, 1.0f, dryLevel.load()); const float earlyLvl = juce::jlimit(0.0f, 1.0f, earlyLevel.load()); - // Update tone filters - auto lcCoeffs = juce::dsp::IIR::Coefficients::makeHighPass(cachedSampleRate, lc); - wetLowCutL.coefficients = lcCoeffs; - wetLowCutR.coefficients = lcCoeffs; + // Update tone filters only when needed. + if (std::abs(lc - lastLowCut) > 1.0f) + { + lastLowCut = lc; + auto lcCoeffs = juce::dsp::IIR::Coefficients::makeHighPass(cachedSampleRate, lc); + wetLowCutL.coefficients = lcCoeffs; + wetLowCutR.coefficients = lcCoeffs; + } - auto hcCoeffs = juce::dsp::IIR::Coefficients::makeLowPass(cachedSampleRate, hc); - wetHighCutL.coefficients = hcCoeffs; - wetHighCutR.coefficients = hcCoeffs; + if (std::abs(hc - lastHighCut) > 8.0f) + { + lastHighCut = hc; + auto hcCoeffs = juce::dsp::IIR::Coefficients::makeLowPass(cachedSampleRate, hc); + wetHighCutL.coefficients = hcCoeffs; + wetHighCutR.coefficients = hcCoeffs; + } // Get algorithm index for parameter adjustments const int algo = juce::jlimit(0, 4, static_cast(algorithm.load())); @@ -428,20 +604,61 @@ void S13Reverb::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& break; } - // Update reverb parameters - juce::dsp::Reverb::Parameters params; - params.roomSize = roomSizeAdj; - params.damping = dampingAdj; - params.wetLevel = 1.0f; // We handle wet/dry ourselves - params.dryLevel = 0.0f; - params.width = widthAdj; - params.freezeMode = freezeMode.load() >= 0.5f ? 1.0f : 0.0f; - reverb.setParameters(params); - - // Store dry signal - juce::AudioBuffer dryBuffer(numChannels, numSamples); + // Store dry signal in buffers allocated during prepareToPlay; never resize in the audio callback. + if (dryBuffer.getNumChannels() < numChannels + || dryBuffer.getNumSamples() < numSamples + || earlyOutputBuffer.getNumChannels() < numChannels + || earlyOutputBuffer.getNumSamples() < numSamples) + { + buffer.clear(); + return; + } for (int ch = 0; ch < numChannels; ++ch) dryBuffer.copyFrom(ch, 0, buffer, ch, 0, numSamples); + earlyOutputBuffer.clear(); + + if (earlyReflectionBuffer.getNumSamples() > 0) + { + const int ringSize = earlyReflectionBuffer.getNumSamples(); + const float diffusionValue = juce::jlimit(0.0f, 1.0f, diffusion.load()); + const float tapScale = earlyLvl * (0.18f + diffusionValue * 0.22f); + const std::array tapMs { + algo == static_cast(Algorithm::Room) ? 5.7f : 9.3f, + algo == static_cast(Algorithm::Plate) ? 12.1f : 17.9f, + algo == static_cast(Algorithm::Chamber) ? 23.7f : 29.1f, + algo == static_cast(Algorithm::Hall) ? 43.0f : 37.0f, + algo == static_cast(Algorithm::Shimmer) ? 71.0f : 53.0f + }; + const std::array tapGain { 0.72f, -0.48f, 0.36f, -0.27f, 0.19f }; + + for (int i = 0; i < numSamples; ++i) + { + const float inL = dryBuffer.getSample(0, i); + const float inR = numChannels >= 2 ? dryBuffer.getSample(1, i) : inL; + earlyReflectionBuffer.setSample(0, earlyReflectionWriteIndex, inL); + earlyReflectionBuffer.setSample(1, earlyReflectionWriteIndex, inR); + + float earlyL = 0.0f; + float earlyR = 0.0f; + for (size_t tap = 0; tap < tapMs.size(); ++tap) + { + int offset = static_cast(std::round(tapMs[tap] * 0.001f * static_cast(cachedSampleRate))); + offset = juce::jlimit(1, ringSize - 1, offset); + int readIndex = earlyReflectionWriteIndex - offset; + if (readIndex < 0) + readIndex += ringSize; + + const float gain = tapGain[tap] * tapScale; + earlyL += earlyReflectionBuffer.getSample(0, readIndex) * gain; + earlyR += earlyReflectionBuffer.getSample(1, readIndex) * gain * (tap % 2 == 0 ? -0.82f : 0.92f); + } + + earlyOutputBuffer.setSample(0, i, earlyL); + if (numChannels >= 2) + earlyOutputBuffer.setSample(1, i, earlyR); + earlyReflectionWriteIndex = (earlyReflectionWriteIndex + 1) % ringSize; + } + } // Apply pre-delay to input if (preDelaySamples > 0.5f) @@ -467,10 +684,103 @@ void S13Reverb::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& } } - // Process reverb (now buffer contains pre-delayed signal -> reverb processes it -> buffer = wet only) - juce::dsp::AudioBlock block(buffer); - juce::dsp::ProcessContextReplacing context(block); - reverb.process(context); + // Native late tail: 8-line feedback delay network with algorithm-specific + // delay spacing and a Householder feedback matrix for dense, stable tails. + if (lateTankBuffer.getNumSamples() > 0) + { + const int ringSize = lateTankBuffer.getNumSamples(); + const bool freeze = freezeMode.load(std::memory_order_relaxed) >= 0.5f; + const float diffusionValue = juce::jlimit(0.0f, 1.0f, diffusion.load(std::memory_order_relaxed)); + const float decaySeconds = juce::jlimit(0.1f, 20.0f, decayTime.load(std::memory_order_relaxed)); + const float roomScale = 0.72f + roomSizeAdj * 1.35f; + const float lineFeedback = freeze + ? 0.997f + : juce::jlimit(0.35f, 0.965f, + std::exp(-3.0f * (0.055f + roomSizeAdj * 0.11f) / decaySeconds)); + const float dampCoeff = juce::jlimit(0.012f, 0.42f, 0.36f - dampingAdj * 0.30f); + const float inputGain = juce::jlimit(0.06f, 0.42f, 0.16f + diffusionValue * 0.18f); + const float modDepthSamples = (static_cast(algo) == Algorithm::Plate ? 1.8f : 4.5f) * (0.2f + diffusionValue * 0.8f); + const float modRate = (static_cast(algo) == Algorithm::Plate ? 0.19f : 0.11f) + * juce::MathConstants::twoPi / static_cast(juce::jmax(1.0, cachedSampleRate)); + const std::array baseDelayMs { + 29.7f, 37.1f, 41.9f, 53.3f, 61.7f, 71.9f, 83.9f, 97.1f + }; + const float algorithmScale = static_cast(algo) == Algorithm::Room ? 0.58f + : static_cast(algo) == Algorithm::Hall ? 1.35f + : static_cast(algo) == Algorithm::Plate ? 0.82f + : static_cast(algo) == Algorithm::Chamber ? 0.74f + : 1.52f; + + auto readLateLine = [&] (int line, float delaySamplesToRead) -> float + { + delaySamplesToRead = juce::jlimit(1.0f, static_cast(ringSize - 2), delaySamplesToRead); + float readPosition = static_cast(lateTankWriteIndex) - delaySamplesToRead; + while (readPosition < 0.0f) + readPosition += static_cast(ringSize); + const int indexA = static_cast(readPosition) % ringSize; + const int indexB = (indexA + 1) % ringSize; + const float frac = readPosition - std::floor(readPosition); + const float a = lateTankBuffer.getSample(line, indexA); + const float b = lateTankBuffer.getSample(line, indexB); + return a + (b - a) * frac; + }; + + auto* outL = buffer.getWritePointer(0); + auto* outR = numChannels >= 2 ? buffer.getWritePointer(1) : nullptr; + for (int sample = 0; sample < numSamples; ++sample) + { + std::array tankRead {}; + float readSum = 0.0f; + for (int line = 0; line < lateLineCount; ++line) + { + const float lineDelayMs = baseDelayMs[static_cast(line)] * algorithmScale * roomScale; + const float modulatedSamples = std::sin(lateModPhase[static_cast(line)]) * modDepthSamples; + const float delaySamplesToRead = lineDelayMs * 0.001f * static_cast(cachedSampleRate) + modulatedSamples; + float read = readLateLine(line, delaySamplesToRead); + lateDampingState[static_cast(line)] += (read - lateDampingState[static_cast(line)]) * dampCoeff; + read = lateDampingState[static_cast(line)]; + tankRead[static_cast(line)] = read; + readSum += read; + + lateModPhase[static_cast(line)] += modRate * (1.0f + static_cast(line) * 0.07f); + if (lateModPhase[static_cast(line)] >= juce::MathConstants::twoPi) + lateModPhase[static_cast(line)] -= juce::MathConstants::twoPi; + } + + const float inL = buffer.getSample(0, sample); + const float inR = numChannels >= 2 ? buffer.getSample(1, sample) : inL; + const float monoIn = (inL + inR) * 0.5f; + shimmerSmootherL += (std::abs(tankRead[2]) - shimmerSmootherL) * 0.018f; + shimmerSmootherR += (std::abs(tankRead[5]) - shimmerSmootherR) * 0.018f; + const float shimmerAmount = static_cast(algo) == Algorithm::Shimmer ? 0.17f : 0.0f; + + for (int line = 0; line < lateLineCount; ++line) + { + const float householder = (readSum * (2.0f / static_cast(lateLineCount))) - tankRead[static_cast(line)]; + const float lineSign = (line & 1) == 0 ? 1.0f : -1.0f; + const float shimmer = shimmerAmount * (lineSign > 0.0f ? shimmerSmootherL : shimmerSmootherR); + const float write = monoIn * inputGain * lineSign + (householder + shimmer) * lineFeedback; + lateTankBuffer.setSample(line, lateTankWriteIndex, juce::jlimit(-1.8f, 1.8f, write)); + } + + float lateL = tankRead[0] - tankRead[2] + tankRead[4] - tankRead[6] + + (tankRead[1] - tankRead[5]) * 0.55f; + float lateR = tankRead[1] - tankRead[3] + tankRead[5] - tankRead[7] + + (tankRead[0] - tankRead[4]) * 0.55f; + lateL *= 0.22f; + lateR *= 0.22f; + + const float mid = (lateL + lateR) * 0.5f; + const float side = (lateL - lateR) * 0.5f * (0.35f + widthAdj * 1.35f); + outL[sample] = mid + side; + if (outR != nullptr) + outR[sample] = mid - side; + else + outL[sample] = mid; + + lateTankWriteIndex = (lateTankWriteIndex + 1) % ringSize; + } + } // Apply tone filters to wet signal { @@ -491,16 +801,21 @@ void S13Reverb::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& } } - // Mix: dry * dryLevel + wet * wetLevel * earlyLevel blend + const float lateLevel = wetLvl * (0.55f + juce::jlimit(0.0f, 1.0f, diffusion.load()) * 0.45f); + const float earlyMixLevel = juce::jlimit(0.0f, 1.0f, earlyLvl); + + // Mix: dry + late tail + independent early reflection taps. for (int ch = 0; ch < numChannels; ++ch) { auto* out = buffer.getWritePointer(ch); const auto* dryData = dryBuffer.getReadPointer(ch); + const auto* earlyData = earlyOutputBuffer.getReadPointer(ch); for (int i = 0; i < numSamples; ++i) { - out[i] = dryData[i] * dryLvl + out[i] * wetLvl * earlyLvl; + out[i] = dryData[i] * dryLvl + out[i] * lateLevel + earlyData[i] * earlyMixLevel; } } + sanitizeBuiltInBuffer(buffer, 2.5f); } void S13Reverb::releaseResources() @@ -512,6 +827,14 @@ void S13Reverb::releaseResources() wetLowCutR.reset(); wetHighCutL.reset(); wetHighCutR.reset(); + earlyReflectionBuffer.clear(); + earlyOutputBuffer.clear(); + earlyReflectionWriteIndex = 0; + lateTankBuffer.clear(); + lateTankWriteIndex = 0; + lateDampingState.fill(0.0f); + shimmerSmootherL = 0.0f; + shimmerSmootherR = 0.0f; } void S13Reverb::getStateInformation(juce::MemoryBlock& destData) @@ -606,6 +929,19 @@ void S13Chorus::prepareToPlay(double sampleRate, int samplesPerBlock) feedbackState[0] = 0.0f; feedbackState[1] = 0.0f; + + wetLowCutL.reset(); + wetLowCutR.reset(); + wetHighCutL.reset(); + wetHighCutR.reset(); + lastLowCut = juce::jlimit(20.0f, 2000.0f, lowCut.load()); + lastHighCut = juce::jlimit(200.0f, 20000.0f, highCut.load()); + auto lowCutCoeffs = juce::dsp::IIR::Coefficients::makeHighPass(sampleRate, lastLowCut); + wetLowCutL.coefficients = lowCutCoeffs; + wetLowCutR.coefficients = lowCutCoeffs; + auto highCutCoeffs = juce::dsp::IIR::Coefficients::makeLowPass(sampleRate, lastHighCut); + wetHighCutL.coefficients = highCutCoeffs; + wetHighCutR.coefficients = highCutCoeffs; } float S13Chorus::getLFOValue(float phase, LFOShape shape) const @@ -639,6 +975,7 @@ float S13Chorus::getLFOValue(float phase, LFOShape shape) const void S13Chorus::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) { juce::ignoreUnused(midi); + juce::ScopedNoDenormals noDenormals; const int numSamples = buffer.getNumSamples(); const int numChannels = buffer.getNumChannels(); @@ -647,14 +984,52 @@ void S13Chorus::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& const auto currentMode = static_cast(juce::jlimit(0, 2, static_cast(mode.load()))); const auto currentLFOShape = static_cast(juce::jlimit(0, 3, static_cast(lfoShape.load()))); - const float lfoRate = juce::jlimit(0.01f, 20.0f, rate.load()); + const int characterIndex = juce::jlimit(0, 2, static_cast(std::round(characterMode.load(std::memory_order_relaxed)))); + float lfoRate = juce::jlimit(0.01f, 20.0f, rate.load()); + if (tempoSync.load() >= 0.5f) + { + double bpm = 120.0; + if (auto* ph = getPlayHead()) + { + auto pos = ph->getPosition(); + if (pos.hasValue()) + if (auto bpmVal = pos->getBpm()) + bpm = *bpmVal; + } + + const int syncIndex = juce::jlimit(0, 5, static_cast(std::round(rate.load()))); + const float barsPerCycle[] { 4.0f, 2.0f, 1.0f, 0.5f, 0.25f, 0.125f }; + lfoRate = static_cast((bpm / 60.0) / (barsPerCycle[syncIndex] * 4.0)); + } const float lfoDepth = juce::jlimit(0.0f, 1.0f, depth.load()); const float fb = juce::jlimit(-1.0f, 1.0f, fbAmount.load()); const float wet = juce::jlimit(0.0f, 1.0f, mix.load()); const float dry = 1.0f - wet; - const int numVoices = juce::jlimit(1, maxVoices, static_cast(voices.load())); + const int baseVoices = juce::jlimit(1, maxVoices, static_cast(voices.load())); + const int numVoices = characterIndex == 1 && currentMode == Mode::Chorus ? juce::jmax(baseVoices, 4) : baseVoices; const float spreadVal = juce::jlimit(0.0f, 1.0f, spread.load()); + const float currentLowCut = juce::jlimit(20.0f, 2000.0f, lowCut.load()); + if (std::abs(currentLowCut - lastLowCut) > 1.0f) + { + lastLowCut = currentLowCut; + auto coeffs = juce::dsp::IIR::Coefficients::makeHighPass(cachedSampleRate, currentLowCut); + wetLowCutL.coefficients = coeffs; + wetLowCutR.coefficients = coeffs; + } + + const float requestedHighCut = juce::jlimit(200.0f, 20000.0f, highCut.load()); + const float currentHighCut = characterIndex == 2 ? juce::jmin(requestedHighCut, 7200.0f) + : (characterIndex == 1 ? juce::jmin(requestedHighCut, 12000.0f) + : requestedHighCut); + if (std::abs(currentHighCut - lastHighCut) > 8.0f) + { + lastHighCut = currentHighCut; + auto coeffs = juce::dsp::IIR::Coefficients::makeLowPass(cachedSampleRate, currentHighCut); + wetHighCutL.coefficients = coeffs; + wetHighCutR.coefficients = coeffs; + } + const float phaseInc = lfoRate * juce::MathConstants::twoPi / static_cast(cachedSampleRate); @@ -663,38 +1038,57 @@ void S13Chorus::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& auto* dataL = buffer.getWritePointer(0); auto* dataR = (numChannels >= 2) ? buffer.getWritePointer(1) : nullptr; + auto applyCharacter = [&] (float value, int sampleIndex, int channelIndex) + { + if (characterIndex == 1) + return std::tanh(value * 1.05f) * 0.98f; + if (characterIndex == 2) + return std::tanh(value * 1.18f) * 0.9f + + builtinNoise(sampleIndex, 400 + channelIndex) * 0.0014f; + return value; + }; + if (currentMode == Mode::Phaser) { // Phaser mode: modulated all-pass filters - const int numStages = juce::jlimit(2, maxPhaserStages, numVoices * 2); - const float minFreq = 200.0f; - const float maxFreq = 4000.0f; + const int baseStages = juce::jlimit(2, maxPhaserStages, numVoices * 2); + const int numStages = characterIndex == 1 ? juce::jmax(baseStages, 8) + : (characterIndex == 2 ? juce::jmin(baseStages, 6) : baseStages); + const float minFreq = characterIndex == 2 ? 120.0f : 200.0f; + const float maxFreq = characterIndex == 2 ? 2500.0f : 4000.0f; for (int i = 0; i < numSamples; ++i) { const float inL = dataL[i]; const float inR = dataR ? dataR[i] : inL; - // LFO modulates the all-pass center frequency + // LFO modulates the all-pass center frequency. const float lfoVal = getLFOValue(lfoPhase[0], currentLFOShape); const float modFreq = minFreq + (maxFreq - minFreq) * (lfoVal * lfoDepth * 0.5f + 0.5f); - - // Update all-pass coefficients - auto apCoeffs = juce::dsp::IIR::Coefficients::makeAllPass(cachedSampleRate, modFreq); + const float warped = std::tan(juce::MathConstants::pi * modFreq / static_cast(cachedSampleRate)); + const float allPassCoeff = (warped - 1.0f) / (warped + 1.0f); float procL = inL + feedbackState[0] * fb; float procR = inR + feedbackState[1] * fb; for (int s = 0; s < numStages; ++s) { - allpassL[s].coefficients = apCoeffs; - allpassR[s].coefficients = apCoeffs; - procL = allpassL[s].processSample(procL); - procR = allpassR[s].processSample(procR); + const auto stateIndex = static_cast(s); + const float outL = -allPassCoeff * procL + phaserStateL[stateIndex]; + phaserStateL[stateIndex] = procL + allPassCoeff * outL; + procL = outL; + + const float outR = -allPassCoeff * procR + phaserStateR[stateIndex]; + phaserStateR[stateIndex] = procR + allPassCoeff * outR; + procR = outR; } feedbackState[0] = procL; feedbackState[1] = procR; + procL = applyCharacter(procL, i, 0); + procR = applyCharacter(procR, i, 1); + procL = wetHighCutL.processSample(wetLowCutL.processSample(procL)); + procR = wetHighCutR.processSample(wetLowCutR.processSample(procR)); dataL[i] = inL * dry + procL * wet; if (dataR) @@ -722,6 +1116,16 @@ void S13Chorus::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& centerDelaySamples = static_cast(0.007 * cachedSampleRate); depthSamples = static_cast(0.013 * cachedSampleRate) * lfoDepth; } + if (characterIndex == 1 && currentMode == Mode::Chorus) + { + centerDelaySamples *= 1.22f; + depthSamples *= 1.18f; + } + else if (characterIndex == 2) + { + centerDelaySamples *= 1.08f; + depthSamples *= 0.82f; + } for (int i = 0; i < numSamples; ++i) { @@ -734,7 +1138,11 @@ void S13Chorus::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& for (int v = 0; v < numVoices; ++v) { const float lfoVal = getLFOValue(lfoPhase[v], currentLFOShape); - const float delaySamples = centerDelaySamples + depthSamples * lfoVal; + const float ensembleOffset = characterIndex == 1 + ? std::sin(lfoPhase[v] * 0.37f + static_cast(v) * 1.61f) * 0.18f + : 0.0f; + const float delaySamples = juce::jlimit(1.0f, static_cast(maxChorusDelaySamples - 2), + centerDelaySamples + depthSamples * (lfoVal + ensembleOffset)); // Push input + feedback delayLines[0][v].pushSample(0, inL + feedbackState[0] * fb); @@ -751,7 +1159,11 @@ void S13Chorus::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& // Stereo spread: offset phase for right channel const float phaseOffset = juce::MathConstants::pi * spreadVal * static_cast(v % 2); const float lfoValR = getLFOValue(lfoPhase[v] + phaseOffset, currentLFOShape); - const float delaySamplesR = centerDelaySamples + depthSamples * lfoValR; + const float ensembleOffsetR = characterIndex == 1 + ? std::sin(lfoPhase[v] * 0.41f + static_cast(v) * 1.37f + phaseOffset) * 0.18f + : 0.0f; + const float delaySamplesR = juce::jlimit(1.0f, static_cast(maxChorusDelaySamples - 2), + centerDelaySamples + depthSamples * (lfoValR + ensembleOffsetR)); delayLines[1][v].setDelay(delaySamplesR); const float outR = delayLines[1][v].popSample(0); wetR += outR; @@ -765,6 +1177,10 @@ void S13Chorus::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& wetL *= voiceGain; wetR *= voiceGain; + wetL = applyCharacter(wetL, i, 0); + wetR = applyCharacter(wetR, i, 1); + wetL = wetHighCutL.processSample(wetLowCutL.processSample(wetL)); + wetR = wetHighCutR.processSample(wetLowCutR.processSample(wetR)); feedbackState[0] = wetL; feedbackState[1] = wetR; @@ -774,6 +1190,7 @@ void S13Chorus::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& dataR[i] = inR * dry + wetR * wet; } } + sanitizeBuiltInBuffer(buffer, 2.5f); } void S13Chorus::releaseResources() @@ -787,6 +1204,14 @@ void S13Chorus::releaseResources() allpassL[s].reset(); allpassR[s].reset(); } + phaserStateL.fill(0.0f); + phaserStateR.fill(0.0f); + phaserStateL.fill(0.0f); + phaserStateR.fill(0.0f); + wetLowCutL.reset(); + wetLowCutR.reset(); + wetHighCutL.reset(); + wetHighCutR.reset(); feedbackState[0] = 0.0f; feedbackState[1] = 0.0f; @@ -805,7 +1230,8 @@ void S13Chorus::getStateInformation(juce::MemoryBlock& destData) { "spread", spread.load() }, { "highCut", highCut.load() }, { "lowCut", lowCut.load() }, - { "tempoSync", tempoSync.load() } + { "tempoSync", tempoSync.load() }, + { "characterMode", characterMode.load() } }); } @@ -826,6 +1252,7 @@ void S13Chorus::setStateInformation(const void* data, int sizeInBytes) highCut = static_cast((double)tree.getProperty("highCut", 20000.0)); lowCut = static_cast((double)tree.getProperty("lowCut", 20.0)); tempoSync = static_cast((double)tree.getProperty("tempoSync", 0.0)); + characterMode = static_cast((double)tree.getProperty("characterMode", 0.0)); } bool S13Chorus::isBusesLayoutSupported(const BusesLayout& layouts) const @@ -856,17 +1283,33 @@ void S13Saturator::prepareToPlay(double sampleRate, int samplesPerBlock) toneFilterL.reset(); toneFilterR.reset(); + lowCutFilterL.reset(); + lowCutFilterR.reset(); lastToneFreq = juce::jlimit(200.0f, 20000.0f, toneFreq.load()); auto coeffs = juce::dsp::IIR::Coefficients::makeLowPass(sampleRate, lastToneFreq); toneFilterL.coefficients = coeffs; toneFilterR.coefficients = coeffs; - - // Initialize oversampling based on mode - const int osMode = juce::jlimit(0, 2, static_cast(oversampleMode.load())); - const int osFactor = (osMode == 0) ? 0 : (osMode == 1 ? 1 : 2); // 0=off, 1=2x, 2=4x - oversampler = std::make_unique>( - 2, osFactor, juce::dsp::Oversampling::filterHalfBandFIREquiripple, false); - oversampler->initProcessing(static_cast(samplesPerBlock)); + lastLowCutFreq = juce::jlimit(20.0f, 1000.0f, lowCutFreq.load()); + auto lowCutCoeffs = juce::dsp::IIR::Coefficients::makeHighPass(sampleRate, lastLowCutFreq); + lowCutFilterL.coefficients = lowCutCoeffs; + lowCutFilterR.coefficients = lowCutCoeffs; + + oversampler2x = std::make_unique>( + 2, 1, juce::dsp::Oversampling::filterHalfBandFIREquiripple, false); + oversampler4x = std::make_unique>( + 2, 2, juce::dsp::Oversampling::filterHalfBandFIREquiripple, false); + oversampler2x->initProcessing(static_cast(samplesPerBlock)); + oversampler4x->initProcessing(static_cast(samplesPerBlock)); + + smoothedDriveGain.reset(sampleRate, 0.02); + smoothedMix.reset(sampleRate, 0.02); + smoothedOutputGain.reset(sampleRate, 0.02); + const float initialDriveDB = juce::jlimit(0.0f, 30.0f, drive.load()); + const float initialAutoCompDB = -initialDriveDB * 0.42f; + smoothedDriveGain.setCurrentAndTargetValue(juce::Decibels::decibelsToGain(initialDriveDB)); + smoothedMix.setCurrentAndTargetValue(juce::jlimit(0.0f, 1.0f, mix.load())); + smoothedOutputGain.setCurrentAndTargetValue( + juce::Decibels::decibelsToGain(juce::jlimit(-12.0f, 0.0f, outputGain.load()) + initialAutoCompDB)); } float S13Saturator::processSample(float input, float driveLinear, SatType type, float asym) const @@ -925,6 +1368,31 @@ float S13Saturator::processSample(float input, float driveLinear, SatType type, float y = std::round(x * levels) / levels; return juce::jlimit(-1.0f, 1.0f, y); } + + case SatType::Console: + { + // Console: subtle soft knee with restrained odd harmonics. + const float knee = x / (1.0f + 0.28f * std::abs(x)); + return std::tanh(knee * 1.15f) * 0.92f; + } + + case SatType::Transformer: + { + // Transformer: rounded low-mid weight with asymmetric magnetic push. + const float biased = x + asym * 0.18f; + const float magnetic = std::tanh(biased * 0.95f) + 0.08f * std::sin(biased * 2.0f); + return magnetic * 0.9f; + } + + case SatType::Foldback: + { + // Foldback: controlled creative distortion, level bounded for safety. + float y = x; + const float threshold = 0.78f; + if (std::abs(y) > threshold) + y = std::abs(std::fmod(y - threshold, threshold * 4.0f) - threshold * 2.0f) - threshold; + return juce::jlimit(-1.0f, 1.0f, y); + } } return std::tanh(x); // fallback @@ -933,6 +1401,7 @@ float S13Saturator::processSample(float input, float driveLinear, SatType type, void S13Saturator::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) { juce::ignoreUnused(midi); + juce::ScopedNoDenormals noDenormals; const int numSamples = buffer.getNumSamples(); const int numChannels = buffer.getNumChannels(); @@ -948,27 +1417,41 @@ void S13Saturator::processBlock(juce::AudioBuffer& buffer, juce::MidiBuff toneFilterL.coefficients = coeffs; toneFilterR.coefficients = coeffs; } + const float currentLowCutFreq = juce::jlimit(20.0f, 1000.0f, lowCutFreq.load()); + if (std::abs(currentLowCutFreq - lastLowCutFreq) > 1.0f) + { + lastLowCutFreq = currentLowCutFreq; + auto coeffs = juce::dsp::IIR::Coefficients::makeHighPass(cachedSampleRate, currentLowCutFreq); + lowCutFilterL.coefficients = coeffs; + lowCutFilterR.coefficients = coeffs; + } const float driveDB = juce::jlimit(0.0f, 30.0f, drive.load()); - const float driveLinear = juce::Decibels::decibelsToGain(driveDB); - const float wet = juce::jlimit(0.0f, 1.0f, mix.load()); - const float dry = 1.0f - wet; const float outGainDB = juce::jlimit(-12.0f, 0.0f, outputGain.load()); - const float outGainLinear = juce::Decibels::decibelsToGain(outGainDB); - const auto type = static_cast(juce::jlimit(0, 4, static_cast(satType.load()))); + const float autoCompDB = -driveDB * 0.42f; + smoothedDriveGain.setTargetValue(juce::Decibels::decibelsToGain(driveDB)); + smoothedMix.setTargetValue(juce::jlimit(0.0f, 1.0f, mix.load())); + smoothedOutputGain.setTargetValue(juce::Decibels::decibelsToGain(outGainDB + autoCompDB)); + const auto type = static_cast(juce::jlimit(0, 7, static_cast(satType.load()))); const float asym = juce::jlimit(-1.0f, 1.0f, asymmetry.load()); + const int osMode = juce::jlimit(0, 2, static_cast(std::round(oversampleMode.load()))); - auto applySaturation = [&](juce::AudioBuffer& buf) + auto applySaturation = [&](auto& audioBlock) { - const int ns = buf.getNumSamples(); - auto* dL = buf.getWritePointer(0); - auto* dR = (buf.getNumChannels() >= 2) ? buf.getWritePointer(1) : nullptr; + const int ns = static_cast(audioBlock.getNumSamples()); + auto* dL = audioBlock.getChannelPointer(0); + auto* dR = audioBlock.getNumChannels() >= 2 ? audioBlock.getChannelPointer(1) : nullptr; for (int i = 0; i < ns; ++i) { + const float driveLinear = smoothedDriveGain.getNextValue(); + const float wet = smoothedMix.getNextValue(); + const float dry = 1.0f - wet; + const float outGainLinear = smoothedOutputGain.getNextValue(); const float inL = dL[i]; float satL = processSample(inL, driveLinear, type, asym); satL = toneFilterL.processSample(satL); + satL = lowCutFilterL.processSample(satL); satL *= outGainLinear; dL[i] = inL * dry + satL * wet; @@ -977,6 +1460,7 @@ void S13Saturator::processBlock(juce::AudioBuffer& buffer, juce::MidiBuff const float inR = dR[i]; float satR = processSample(inR, driveLinear, type, asym); satR = toneFilterR.processSample(satR); + satR = lowCutFilterR.processSample(satR); satR *= outGainLinear; dR[i] = inR * dry + satR * wet; } @@ -984,36 +1468,43 @@ void S13Saturator::processBlock(juce::AudioBuffer& buffer, juce::MidiBuff }; // Oversampling path - if (oversamplingEnabled && oversampler) + if (oversamplingEnabled && osMode == 1 && oversampler2x) { juce::dsp::AudioBlock block(buffer); - auto oversampledBlock = oversampler->processSamplesUp(block); - - juce::AudioBuffer osBuffer(static_cast(oversampledBlock.getNumChannels()), - static_cast(oversampledBlock.getNumSamples())); - for (int ch = 0; ch < static_cast(oversampledBlock.getNumChannels()); ++ch) - osBuffer.copyFrom(ch, 0, oversampledBlock.getChannelPointer(static_cast(ch)), - static_cast(oversampledBlock.getNumSamples())); - - applySaturation(osBuffer); - - for (int ch = 0; ch < static_cast(oversampledBlock.getNumChannels()); ++ch) - juce::FloatVectorOperations::copy(oversampledBlock.getChannelPointer(static_cast(ch)), - osBuffer.getReadPointer(ch), - static_cast(oversampledBlock.getNumSamples())); - - oversampler->processSamplesDown(block); + auto oversampledBlock = oversampler2x->processSamplesUp(block); + applySaturation(oversampledBlock); + oversampler2x->processSamplesDown(block); + } + else if (oversamplingEnabled && osMode == 2 && oversampler4x) + { + juce::dsp::AudioBlock block(buffer); + auto oversampledBlock = oversampler4x->processSamplesUp(block); + applySaturation(oversampledBlock); + oversampler4x->processSamplesDown(block); } else { - applySaturation(buffer); + juce::dsp::AudioBlock block(buffer); + applySaturation(block); } + sanitizeBuiltInBuffer(buffer, 2.5f); } void S13Saturator::releaseResources() { toneFilterL.reset(); toneFilterR.reset(); + lowCutFilterL.reset(); + lowCutFilterR.reset(); + if (oversampler2x) + oversampler2x->reset(); + if (oversampler4x) + oversampler4x->reset(); + smoothedDriveGain.setCurrentAndTargetValue(juce::Decibels::decibelsToGain(juce::jlimit(0.0f, 30.0f, drive.load()))); + smoothedMix.setCurrentAndTargetValue(juce::jlimit(0.0f, 1.0f, mix.load())); + const float driveDB = juce::jlimit(0.0f, 30.0f, drive.load()); + smoothedOutputGain.setCurrentAndTargetValue( + juce::Decibels::decibelsToGain(juce::jlimit(-12.0f, 0.0f, outputGain.load()) - driveDB * 0.42f)); } void S13Saturator::getStateInformation(juce::MemoryBlock& destData) @@ -1023,6 +1514,7 @@ void S13Saturator::getStateInformation(juce::MemoryBlock& destData) { "drive", drive.load() }, { "mix", mix.load() }, { "toneFreq", toneFreq.load() }, + { "lowCutFreq", lowCutFreq.load() }, { "outputGain", outputGain.load() }, { "asymmetry", asymmetry.load() }, { "oversampleMode", oversampleMode.load() } @@ -1039,11 +1531,776 @@ void S13Saturator::setStateInformation(const void* data, int sizeInBytes) drive = static_cast((double)tree.getProperty("drive", 6.0)); mix = static_cast((double)tree.getProperty("mix", 1.0)); toneFreq = static_cast((double)tree.getProperty("toneFreq", 20000.0)); + lowCutFreq = static_cast((double)tree.getProperty("lowCutFreq", 20.0)); outputGain = static_cast((double)tree.getProperty("outputGain", 0.0)); asymmetry = static_cast((double)tree.getProperty("asymmetry", 0.0)); oversampleMode = static_cast((double)tree.getProperty("oversampleMode", 1.0)); } +// ============================================================================ +// S13BasicSynthInstrument +// ============================================================================ + +static float synthPolyBlep(float phase, float phaseDelta) +{ + if (phaseDelta <= 0.0f) + return 0.0f; + + if (phase < phaseDelta) + { + const float t = phase / phaseDelta; + return t + t - t * t - 1.0f; + } + + if (phase > 1.0f - phaseDelta) + { + const float t = (phase - 1.0f) / phaseDelta; + return t * t + t + t + 1.0f; + } + + return 0.0f; +} + +static float synthSaw(float phase, float phaseDelta) +{ + return (2.0f * phase - 1.0f) - synthPolyBlep(phase, phaseDelta); +} + +static float synthSquare(float phase, float phaseDelta) +{ + float value = phase < 0.5f ? 1.0f : -1.0f; + value += synthPolyBlep(phase, phaseDelta); + float fallingPhase = phase - 0.5f; + if (fallingPhase < 0.0f) + fallingPhase += 1.0f; + value -= synthPolyBlep(fallingPhase, phaseDelta); + return value; +} + +S13BasicSynthInstrument::S13BasicSynthInstrument() + : AudioProcessor(BusesProperties() + .withInput("Input", juce::AudioChannelSet::stereo(), true) + .withOutput("Output", juce::AudioChannelSet::stereo(), true)) +{ +} + +void S13BasicSynthInstrument::prepareToPlay(double sampleRate, int samplesPerBlock) +{ + juce::ignoreUnused(samplesPerBlock); + cachedSampleRate = sampleRate > 0.0 ? sampleRate : 44100.0; + clearVoices(); +} + +void S13BasicSynthInstrument::releaseResources() +{ + clearVoices(); +} + +void S13BasicSynthInstrument::clearVoices() +{ + for (auto& notes : active) notes.fill(false); + for (auto& notes : releasing) notes.fill(false); + for (auto& notes : phaseA) notes.fill(0.0f); + for (auto& notes : phaseB) notes.fill(0.0f); + for (auto& notes : phaseSub) notes.fill(0.0f); + for (auto& notes : velocity) notes.fill(0.0f); + for (auto& notes : envelope) notes.fill(0.0f); + for (auto& notes : filterState) notes.fill(0.0f); + for (auto& notes : ageSamples) notes.fill(0); + pitchBendSemitones.fill(0.0f); + modWheel.fill(0.0f); +} + +void S13BasicSynthInstrument::handleMidi(const juce::MidiMessage& message) +{ + const int channel = juce::jlimit(0, 15, message.getChannel() > 0 ? message.getChannel() - 1 : 0); + if (message.isPitchWheel()) + { + const float normalized = (static_cast(message.getPitchWheelValue()) - 8192.0f) / 8192.0f; + pitchBendSemitones[static_cast(channel)] = juce::jlimit(-2.0f, 2.0f, normalized * 2.0f); + return; + } + if (message.isController() && message.getControllerNumber() == 1) + { + modWheel[static_cast(channel)] = static_cast(message.getControllerValue()) / 127.0f; + return; + } + + if (message.isNoteOn()) + { + const int note = juce::jlimit(0, 127, message.getNoteNumber()); + active[static_cast(channel)][static_cast(note)] = true; + releasing[static_cast(channel)][static_cast(note)] = false; + phaseA[static_cast(channel)][static_cast(note)] = 0.0f; + phaseB[static_cast(channel)][static_cast(note)] = 0.25f; + phaseSub[static_cast(channel)][static_cast(note)] = 0.0f; + velocity[static_cast(channel)][static_cast(note)] = message.getFloatVelocity(); + envelope[static_cast(channel)][static_cast(note)] = 0.0f; + filterState[static_cast(channel)][static_cast(note)] = 0.0f; + ageSamples[static_cast(channel)][static_cast(note)] = 0; + } + else if (message.isNoteOff()) + { + const int note = juce::jlimit(0, 127, message.getNoteNumber()); + releasing[static_cast(channel)][static_cast(note)] = true; + } + else if (message.isAllNotesOff() || message.isAllSoundOff()) + { + for (auto& notes : releasing[static_cast(channel)]) + notes = true; + } +} + +void S13BasicSynthInstrument::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) +{ + juce::ScopedNoDenormals noDenormals; + const int numSamples = buffer.getNumSamples(); + const int numChannels = buffer.getNumChannels(); + if (numSamples <= 0 || numChannels <= 0) + return; + + buffer.clear(); + const float sr = static_cast(juce::jmax(1.0, cachedSampleRate)); + const float attackStep = 1.0f / juce::jmax(1.0f, sr * attackMs.load(std::memory_order_relaxed) * 0.001f); + const float releaseStep = 1.0f / juce::jmax(1.0f, sr * releaseMs.load(std::memory_order_relaxed) * 0.001f); + const float bright = juce::jlimit(0.0f, 1.0f, brightness.load(std::memory_order_relaxed)); + const float detune = std::pow(2.0f, juce::jlimit(0.0f, 35.0f, detuneCents.load(std::memory_order_relaxed)) / 1200.0f); + const float sub = juce::jlimit(0.0f, 0.8f, subLevel.load(std::memory_order_relaxed)); + const float noise = juce::jlimit(0.0f, 0.25f, noiseLevel.load(std::memory_order_relaxed)); + const float outGain = juce::Decibels::decibelsToGain(juce::jlimit(-36.0f, 0.0f, outputGain.load(std::memory_order_relaxed))); + const float filterCoeff = juce::jlimit(0.015f, 0.55f, 0.04f + bright * bright * 0.46f); + const float twoPi = juce::MathConstants::twoPi; + std::array voiceRefs {}; + + auto render = [&] (int start, int end) + { + int voiceCount = 0; + for (size_t channel = 0; channel < active.size(); ++channel) + { + for (size_t note = 0; note < active[channel].size(); ++note) + { + if (active[channel][note] || envelope[channel][note] > 0.0f) + voiceRefs[static_cast(voiceCount++)] = { channel, note }; + } + } + + for (int sample = start; sample < end; ++sample) + { + float mixed = 0.0f; + for (int voiceIndex = 0; voiceIndex < voiceCount;) + { + const auto voiceRef = voiceRefs[static_cast(voiceIndex)]; + const size_t channel = voiceRef.channel; + const size_t note = voiceRef.note; + + if (!active[channel][note] && envelope[channel][note] <= 0.0f) + { + voiceRefs[static_cast(voiceIndex)] = voiceRefs[static_cast(--voiceCount)]; + continue; + } + + if (active[channel][note] && !releasing[channel][note]) + envelope[channel][note] = juce::jmin(1.0f, envelope[channel][note] + attackStep); + else + envelope[channel][note] = juce::jmax(0.0f, envelope[channel][note] - releaseStep); + + if (envelope[channel][note] <= 0.0f) + { + active[channel][note] = false; + releasing[channel][note] = false; + velocity[channel][note] = 0.0f; + voiceRefs[static_cast(voiceIndex)] = voiceRefs[static_cast(--voiceCount)]; + continue; + } + + const float freq = static_cast(juce::MidiMessage::getMidiNoteInHertz(static_cast(note))); + const float bendFactor = std::pow(2.0f, pitchBendSemitones[channel] / 12.0f); + const float mod = juce::jlimit(0.0f, 1.0f, modWheel[channel]); + const float vibrato = std::sin(twoPi * (static_cast(ageSamples[channel][note]) / sr) * 5.4f) * mod * 0.018f; + const float modulatedFreq = freq * bendFactor * (1.0f + vibrato); + const float deltaA = juce::jmin(0.45f, modulatedFreq / sr / detune); + const float deltaB = juce::jmin(0.45f, modulatedFreq * detune / sr); + const float deltaSub = juce::jmin(0.45f, modulatedFreq * 0.5f / sr); + const float saw = synthSaw(phaseA[channel][note], deltaA); + const float square = synthSquare(phaseB[channel][note], deltaB); + const float subOsc = std::sin(twoPi * phaseSub[channel][note]); + const float transient = builtinNoise(ageSamples[channel][note], static_cast(note)) + * noise * std::exp(-static_cast(ageSamples[channel][note]) / (sr * 0.25f)); + float voice = saw * 0.58f + square * (0.22f + (bright + mod * 0.25f) * 0.18f) + subOsc * sub + transient; + + filterState[channel][note] += filterCoeff * (voice - filterState[channel][note]); + voice = filterState[channel][note] * envelope[channel][note] * velocity[channel][note] * outGain; + mixed += voice; + + phaseA[channel][note] += deltaA; + phaseB[channel][note] += deltaB; + phaseSub[channel][note] += deltaSub; + if (phaseA[channel][note] >= 1.0f) phaseA[channel][note] -= std::floor(phaseA[channel][note]); + if (phaseB[channel][note] >= 1.0f) phaseB[channel][note] -= std::floor(phaseB[channel][note]); + if (phaseSub[channel][note] >= 1.0f) phaseSub[channel][note] -= std::floor(phaseSub[channel][note]); + ++ageSamples[channel][note]; + ++voiceIndex; + } + + mixed = softLimitInstrumentBus(mixed); + for (int ch = 0; ch < numChannels; ++ch) + buffer.addSample(ch, sample, mixed); + } + }; + + int cursor = 0; + for (const auto metadata : midi) + { + const int eventSample = juce::jlimit(0, numSamples, metadata.samplePosition); + render(cursor, eventSample); + handleMidi(metadata.getMessage()); + cursor = eventSample; + } + render(cursor, numSamples); + sanitizeBuiltInBuffer(buffer, 2.5f); +} + +void S13BasicSynthInstrument::getStateInformation(juce::MemoryBlock& destData) +{ + saveParamsToMemory(destData, "S13BasicSynthInstrument", { + { "attackMs", attackMs.load() }, + { "releaseMs", releaseMs.load() }, + { "brightness", brightness.load() }, + { "detuneCents", detuneCents.load() }, + { "subLevel", subLevel.load() }, + { "noiseLevel", noiseLevel.load() }, + { "outputGain", outputGain.load() } + }); +} + +void S13BasicSynthInstrument::setStateInformation(const void* data, int sizeInBytes) +{ + auto tree = loadParamsFromMemory(data, sizeInBytes, "S13BasicSynthInstrument"); + if (!tree.isValid()) + return; + + attackMs = static_cast((double)tree.getProperty("attackMs", 8.0)); + releaseMs = static_cast((double)tree.getProperty("releaseMs", 180.0)); + brightness = static_cast((double)tree.getProperty("brightness", 0.62)); + detuneCents = static_cast((double)tree.getProperty("detuneCents", 7.0)); + subLevel = static_cast((double)tree.getProperty("subLevel", 0.18)); + noiseLevel = static_cast((double)tree.getProperty("noiseLevel", 0.015)); + outputGain = static_cast((double)tree.getProperty("outputGain", -15.0)); +} + +bool S13BasicSynthInstrument::isBusesLayoutSupported(const BusesLayout& layouts) const +{ + const auto& mainOut = layouts.getMainOutputChannelSet(); + return mainOut == juce::AudioChannelSet::mono() || mainOut == juce::AudioChannelSet::stereo(); +} + +// ============================================================================ +// S13PianoInstrument +// ============================================================================ + +S13PianoInstrument::S13PianoInstrument() + : AudioProcessor(BusesProperties() + .withInput("Input", juce::AudioChannelSet::stereo(), true) + .withOutput("Output", juce::AudioChannelSet::stereo(), true)) +{ +} + +void S13PianoInstrument::prepareToPlay(double sampleRate, int samplesPerBlock) +{ + juce::ignoreUnused(samplesPerBlock); + cachedSampleRate = sampleRate > 0.0 ? sampleRate : 44100.0; + clearVoices(); +} + +void S13PianoInstrument::releaseResources() +{ + clearVoices(); +} + +void S13PianoInstrument::clearVoices() +{ + for (auto& notes : active) notes.fill(false); + for (auto& notes : releasing) notes.fill(false); + for (auto& notes : sustained) notes.fill(false); + for (auto& notes : phase) notes.fill(0.0f); + for (auto& notes : velocity) notes.fill(0.0f); + for (auto& notes : envelope) notes.fill(0.0f); + for (auto& notes : ageSamples) notes.fill(0); + sustainPedal.fill(false); +} + +void S13PianoInstrument::handleMidi(const juce::MidiMessage& message) +{ + const int channel = juce::jlimit(0, 15, message.getChannel() > 0 ? message.getChannel() - 1 : 0); + if (message.isController() && message.getControllerNumber() == 64) + { + const bool pedalDown = message.getControllerValue() >= 64; + sustainPedal[static_cast(channel)] = pedalDown; + if (!pedalDown) + { + for (int note = 0; note < 128; ++note) + { + if (sustained[static_cast(channel)][static_cast(note)]) + { + sustained[static_cast(channel)][static_cast(note)] = false; + releasing[static_cast(channel)][static_cast(note)] = true; + } + } + } + return; + } + + if (message.isNoteOn()) + { + const int note = juce::jlimit(0, 127, message.getNoteNumber()); + active[static_cast(channel)][static_cast(note)] = true; + releasing[static_cast(channel)][static_cast(note)] = false; + sustained[static_cast(channel)][static_cast(note)] = false; + phase[static_cast(channel)][static_cast(note)] = 0.0f; + velocity[static_cast(channel)][static_cast(note)] = message.getFloatVelocity(); + envelope[static_cast(channel)][static_cast(note)] = 0.0f; + ageSamples[static_cast(channel)][static_cast(note)] = 0; + } + else if (message.isNoteOff()) + { + const int note = juce::jlimit(0, 127, message.getNoteNumber()); + if (sustainPedal[static_cast(channel)]) + sustained[static_cast(channel)][static_cast(note)] = true; + else + releasing[static_cast(channel)][static_cast(note)] = true; + } + else if (message.isAllNotesOff() || message.isAllSoundOff()) + { + for (auto& notes : releasing[static_cast(channel)]) + notes = true; + for (auto& notes : sustained[static_cast(channel)]) + notes = false; + } +} + +void S13PianoInstrument::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) +{ + juce::ScopedNoDenormals noDenormals; + const int numSamples = buffer.getNumSamples(); + const int numChannels = buffer.getNumChannels(); + if (numSamples <= 0 || numChannels <= 0) + return; + + buffer.clear(); + const float toneValue = juce::jlimit(0.0f, 1.0f, tone.load(std::memory_order_relaxed)); + const float bodyValue = juce::jlimit(0.0f, 1.0f, body.load(std::memory_order_relaxed)); + const float hammerValue = juce::jlimit(0.0f, 1.0f, hammer.load(std::memory_order_relaxed)); + const float resonanceValue = juce::jlimit(0.0f, 1.0f, resonance.load(std::memory_order_relaxed)); + const float widthValue = juce::jlimit(0.0f, 1.0f, stereoWidth.load(std::memory_order_relaxed)); + const int modelIndex = juce::jlimit(0, 2, static_cast(std::round(model.load(std::memory_order_relaxed)))); + const float sr = static_cast(juce::jmax(1.0, cachedSampleRate)); + const float attackStep = 1.0f / juce::jmax(1.0f, sr * 0.0038f); + const float releaseStep = 1.0f / juce::jmax(1.0f, sr * releaseMs.load(std::memory_order_relaxed) * 0.001f); + const float outGain = juce::Decibels::decibelsToGain(juce::jlimit(-36.0f, 0.0f, outputGain.load(std::memory_order_relaxed))) * 0.78f; + const float twoPi = juce::MathConstants::twoPi; + std::array voiceRefs {}; + + auto render = [&] (int start, int end) + { + int voiceCount = 0; + for (size_t channel = 0; channel < active.size(); ++channel) + { + for (size_t note = 0; note < active[channel].size(); ++note) + { + if (active[channel][note] || envelope[channel][note] > 0.0f) + voiceRefs[static_cast(voiceCount++)] = { channel, note }; + } + } + + for (int sample = start; sample < end; ++sample) + { + float mixedL = 0.0f; + float mixedR = 0.0f; + for (int voiceIndex = 0; voiceIndex < voiceCount;) + { + const auto voiceRef = voiceRefs[static_cast(voiceIndex)]; + const size_t channel = voiceRef.channel; + const size_t note = voiceRef.note; + + if (!active[channel][note] && envelope[channel][note] <= 0.0f) + { + voiceRefs[static_cast(voiceIndex)] = voiceRefs[static_cast(--voiceCount)]; + continue; + } + + if (active[channel][note] && !releasing[channel][note]) + envelope[channel][note] = juce::jmin(1.0f, envelope[channel][note] + attackStep); + else + envelope[channel][note] = juce::jmax(0.0f, envelope[channel][note] - releaseStep); + + if (envelope[channel][note] <= 0.0f) + { + active[channel][note] = false; + releasing[channel][note] = false; + sustained[channel][note] = false; + velocity[channel][note] = 0.0f; + voiceRefs[static_cast(voiceIndex)] = voiceRefs[static_cast(--voiceCount)]; + continue; + } + + const float freq = static_cast(juce::MidiMessage::getMidiNoteInHertz(static_cast(note))); + const float ageSec = static_cast(ageSamples[channel][note]) / sr; + const float noteBright = juce::jlimit(0.35f, 1.35f, 0.72f + (static_cast(note) - 60.0f) * 0.008f); + const float modelTone = modelIndex == 1 ? 1.22f : (modelIndex == 2 ? 0.72f : 1.0f); + const float pedalLength = sustainPedal[channel] ? 1.0f + resonanceValue * 0.8f : 1.0f; + const float decay = std::exp(-ageSec / ((0.85f + bodyValue * 2.6f + (1.0f - noteBright) * 0.4f) * pedalLength)); + const float p = phase[channel][note]; + const float strike = builtinNoise(ageSamples[channel][note], static_cast(note)) + * std::exp(-ageSec / 0.012f) * (0.012f + hammerValue * 0.045f) * modelTone; + const float fundamental = std::sin(twoPi * p) * (0.82f + bodyValue * 0.28f); + const float partial2 = std::sin(twoPi * p * 2.003f) * (0.24f + toneValue * 0.20f * modelTone) + * std::exp(-ageSec / 1.1f) * nyquistFade(freq * 2.003f, sr); + const float partial3 = std::sin(twoPi * p * 3.011f) * (0.13f + toneValue * 0.15f * modelTone) + * std::exp(-ageSec / 0.74f) * nyquistFade(freq * 3.011f, sr); + const float partial5 = std::sin(twoPi * p * 5.031f) * (0.04f + toneValue * 0.08f * modelTone) + * std::exp(-ageSec / 0.42f) * nyquistFade(freq * 5.031f, sr); + const float soundboard = std::sin(twoPi * p * 1.497f + 0.7f) * resonanceValue * 0.09f + * std::exp(-ageSec / 3.8f) * nyquistFade(freq * 1.497f, sr); + const float feltDamping = modelIndex == 2 ? 0.72f : 1.0f; + const float voice = (fundamental + partial2 + partial3 + partial5 + soundboard + strike) + * decay * envelope[channel][note] * velocity[channel][note] * outGain * feltDamping; + const float pan = juce::jlimit(-0.82f, 0.82f, (static_cast(note) - 60.0f) / 36.0f * widthValue); + const float leftGain = std::sqrt(0.5f * (1.0f - pan)); + const float rightGain = std::sqrt(0.5f * (1.0f + pan)); + mixedL += voice * leftGain; + mixedR += voice * rightGain; + + phase[channel][note] += freq / sr; + if (phase[channel][note] >= 1.0f) + phase[channel][note] -= std::floor(phase[channel][note]); + ++ageSamples[channel][note]; + ++voiceIndex; + } + + mixedL = softLimitInstrumentBus(mixedL); + mixedR = softLimitInstrumentBus(mixedR); + if (numChannels == 1) + { + buffer.addSample(0, sample, (mixedL + mixedR) * 0.707f); + } + else + { + buffer.addSample(0, sample, mixedL); + buffer.addSample(1, sample, mixedR); + for (int ch = 2; ch < numChannels; ++ch) + buffer.addSample(ch, sample, (mixedL + mixedR) * 0.5f); + } + } + }; + + int cursor = 0; + for (const auto metadata : midi) + { + const int eventSample = juce::jlimit(0, numSamples, metadata.samplePosition); + render(cursor, eventSample); + handleMidi(metadata.getMessage()); + cursor = eventSample; + } + render(cursor, numSamples); + sanitizeBuiltInBuffer(buffer, 2.5f); +} + +void S13PianoInstrument::getStateInformation(juce::MemoryBlock& destData) +{ + saveParamsToMemory(destData, "S13PianoInstrument", { + { "tone", tone.load() }, + { "body", body.load() }, + { "hammer", hammer.load() }, + { "releaseMs", releaseMs.load() }, + { "outputGain", outputGain.load() }, + { "resonance", resonance.load() }, + { "stereoWidth", stereoWidth.load() }, + { "model", model.load() } + }); +} + +void S13PianoInstrument::setStateInformation(const void* data, int sizeInBytes) +{ + auto tree = loadParamsFromMemory(data, sizeInBytes, "S13PianoInstrument"); + if (!tree.isValid()) + return; + + tone = static_cast((double)tree.getProperty("tone", 0.58)); + body = static_cast((double)tree.getProperty("body", 0.72)); + hammer = static_cast((double)tree.getProperty("hammer", 0.42)); + releaseMs = static_cast((double)tree.getProperty("releaseMs", 950.0)); + outputGain = static_cast((double)tree.getProperty("outputGain", -15.0)); + resonance = static_cast((double)tree.getProperty("resonance", 0.38)); + stereoWidth = static_cast((double)tree.getProperty("stereoWidth", 0.62)); + model = static_cast((double)tree.getProperty("model", 0.0)); +} + +bool S13PianoInstrument::isBusesLayoutSupported(const BusesLayout& layouts) const +{ + const auto& mainOut = layouts.getMainOutputChannelSet(); + return mainOut == juce::AudioChannelSet::mono() || mainOut == juce::AudioChannelSet::stereo(); +} + +// ============================================================================ +// S13DrumInstrument +// ============================================================================ + +S13DrumInstrument::S13DrumInstrument() + : AudioProcessor(BusesProperties() + .withInput("Input", juce::AudioChannelSet::stereo(), true) + .withOutput("Output", juce::AudioChannelSet::stereo(), true)) +{ + hihatPedal.fill(0.65f); +} + +void S13DrumInstrument::prepareToPlay(double sampleRate, int samplesPerBlock) +{ + juce::ignoreUnused(samplesPerBlock); + cachedSampleRate = sampleRate > 0.0 ? sampleRate : 44100.0; + clearVoices(); +} + +void S13DrumInstrument::releaseResources() +{ + clearVoices(); +} + +void S13DrumInstrument::clearVoices() +{ + for (auto& notes : active) notes.fill(false); + for (auto& notes : phase) notes.fill(0.0f); + for (auto& notes : velocity) notes.fill(0.0f); + for (auto& notes : ageSamples) notes.fill(0); +} + +int S13DrumInstrument::mapIncomingNote(int note) const +{ + const int preset = juce::jlimit(0, 1, static_cast(std::round(mapPreset.load(std::memory_order_relaxed)))); + if (preset == 0) + return note; + + switch (note) + { + case 22: return 42; // TD closed hi-hat edge + case 26: return 46; // TD open hi-hat edge + case 47: return 45; // mid tom rim -> mid tom + case 50: return 48; // high tom rim -> high tom + case 58: return 43; // low tom rim -> floor tom + case 55: return 49; // crash edge -> crash + case 52: return 57; // second crash/china edge -> crash 2 + case 59: return 51; // ride edge -> ride + case 53: return 51; // ride bell -> ride family voice + default: return note; + } +} + +void S13DrumInstrument::handleMidi(const juce::MidiMessage& message) +{ + const int channel = juce::jlimit(0, 15, message.getChannel() > 0 ? message.getChannel() - 1 : 0); + if (message.isController() && message.getControllerNumber() == 4) + { + hihatPedal[static_cast(channel)] = static_cast(message.getControllerValue()) / 127.0f; + return; + } + + if (message.isNoteOn()) + { + const int note = juce::jlimit(0, 127, mapIncomingNote(message.getNoteNumber())); + const float curve = juce::jlimit(-1.0f, 1.0f, velocityCurve.load(std::memory_order_relaxed)); + const float exponent = juce::jmap(curve, -1.0f, 1.0f, 1.65f, 0.62f); + const float curvedVelocity = std::pow(juce::jlimit(0.0f, 1.0f, message.getFloatVelocity()), exponent); + active[static_cast(channel)][static_cast(note)] = true; + phase[static_cast(channel)][static_cast(note)] = 0.0f; + velocity[static_cast(channel)][static_cast(note)] = curvedVelocity; + ageSamples[static_cast(channel)][static_cast(note)] = 0; + + if (note == 42 || note == 44) + active[static_cast(channel)][46] = false; + } + else if (message.isAllNotesOff() || message.isAllSoundOff()) + { + active[static_cast(channel)].fill(false); + } +} + +void S13DrumInstrument::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) +{ + juce::ScopedNoDenormals noDenormals; + const int numSamples = buffer.getNumSamples(); + const int numChannels = buffer.getNumChannels(); + if (numSamples <= 0 || numChannels <= 0) + return; + + buffer.clear(); + const int kitIndex = juce::jlimit(0, 2, static_cast(std::round(kit.load(std::memory_order_relaxed)))); + const float tune = std::pow(2.0f, juce::jlimit(-12.0f, 12.0f, tuning.load(std::memory_order_relaxed)) / 12.0f); + const float room = juce::jlimit(0.0f, 1.0f, ambience.load(std::memory_order_relaxed)); + const float tightness = juce::jlimit(0.0f, 1.0f, hihatTightness.load(std::memory_order_relaxed)); + const float punchValue = juce::jlimit(0.0f, 1.0f, punch.load(std::memory_order_relaxed)); + const float widthValue = juce::jlimit(0.0f, 1.0f, stereoWidth.load(std::memory_order_relaxed)); + const float gain = juce::Decibels::decibelsToGain(juce::jlimit(-36.0f, 0.0f, outputGain.load(std::memory_order_relaxed))); + const float twoPi = juce::MathConstants::twoPi; + std::array voiceRefs {}; + + auto render = [&] (int start, int end) + { + int voiceCount = 0; + for (size_t channel = 0; channel < active.size(); ++channel) + { + for (size_t note = 0; note < active[channel].size(); ++note) + { + if (active[channel][note]) + voiceRefs[static_cast(voiceCount++)] = { channel, note }; + } + } + + for (int sample = start; sample < end; ++sample) + { + float mixedL = 0.0f; + float mixedR = 0.0f; + for (int voiceIndex = 0; voiceIndex < voiceCount;) + { + const auto voiceRef = voiceRefs[static_cast(voiceIndex)]; + const size_t channel = voiceRef.channel; + const size_t note = voiceRef.note; + const float pedalClosed = juce::jlimit(0.0f, 1.0f, hihatPedal[channel] * tightness); + + if (!active[channel][note]) + { + voiceRefs[static_cast(voiceIndex)] = voiceRefs[static_cast(--voiceCount)]; + continue; + } + + const int midiNote = static_cast(note); + const float ageSec = static_cast(ageSamples[channel][note]) / static_cast(cachedSampleRate); + const float velocityValue = velocity[channel][note]; + const float decay = drumDecaySeconds(midiNote, pedalClosed) * (0.82f + velocityValue * 0.42f); + const float env = std::exp(-ageSec / decay); + if (env < 0.0002f) + { + active[channel][note] = false; + velocity[channel][note] = 0.0f; + voiceRefs[static_cast(voiceIndex)] = voiceRefs[static_cast(--voiceCount)]; + continue; + } + + const float noise = builtinNoise(ageSamples[channel][note], midiNote); + const float sweep = (midiNote == 35 || midiNote == 36) ? std::exp(-ageSec / 0.035f) * 72.0f : 0.0f; + const float freq = juce::jlimit(20.0f, 8000.0f, drumBaseFrequency(midiNote) * tune + sweep); + phase[channel][note] += freq / static_cast(cachedSampleRate); + if (phase[channel][note] >= 1.0f) + phase[channel][note] -= std::floor(phase[channel][note]); + + float drum = 0.0f; + if (midiNote == 35 || midiNote == 36) + { + const float body = std::sin(twoPi * phase[channel][note]) * std::exp(-ageSec / (kitIndex == 1 ? 0.52f : 0.36f)); + const float click = noise * std::exp(-ageSec / 0.012f) * (kitIndex == 2 ? 0.38f : 0.18f) * (0.7f + punchValue * 0.8f); + drum = body * 1.18f + click; + } + else if (midiNote == 37 || midiNote == 38 || midiNote == 40) + { + const float snap = noise * std::exp(-ageSec / (kitIndex == 1 ? 0.22f : 0.16f)); + const float body = std::sin(twoPi * phase[channel][note]) * std::exp(-ageSec / 0.12f); + drum = snap * (kitIndex == 2 ? 0.95f : 0.72f) * (0.75f + punchValue * 0.65f) + body * 0.34f; + } + else if (midiNote == 42 || midiNote == 44 || midiNote == 46 || midiNote == 22 || midiNote == 26) + { + const float metal = std::sin(twoPi * phase[channel][note] * 7.1f) * 0.24f + + std::sin(twoPi * phase[channel][note] * 11.7f) * 0.18f; + drum = (noise * 0.78f + metal) * env; + } + else if (midiNote == 49 || midiNote == 51 || midiNote == 52 || midiNote == 53 + || midiNote == 55 || midiNote == 57 || midiNote == 59) + { + const float shimmer = std::sin(twoPi * phase[channel][note] * 5.3f) * 0.15f + + std::sin(twoPi * phase[channel][note] * 9.7f) * 0.12f; + drum = (noise * 0.64f + shimmer) * env; + } + else + { + const float body = std::sin(twoPi * phase[channel][note]) * env; + drum = body * 0.9f + noise * 0.08f * std::exp(-ageSec / 0.04f); + } + + const float roomTail = std::sin(twoPi * phase[channel][note] * 0.37f + static_cast(midiNote)) + * room * 0.08f * std::exp(-ageSec / 0.9f); + const float voice = (drum + roomTail) * velocityValue * gain * 0.85f; + const float pan = drumPanPosition(midiNote) * widthValue; + const float leftGain = std::sqrt(0.5f * (1.0f - pan)); + const float rightGain = std::sqrt(0.5f * (1.0f + pan)); + mixedL += voice * leftGain; + mixedR += voice * rightGain; + ++ageSamples[channel][note]; + ++voiceIndex; + } + + mixedL = softLimitInstrumentBus(mixedL); + mixedR = softLimitInstrumentBus(mixedR); + if (numChannels == 1) + { + buffer.addSample(0, sample, (mixedL + mixedR) * 0.707f); + } + else + { + buffer.addSample(0, sample, mixedL); + buffer.addSample(1, sample, mixedR); + for (int ch = 2; ch < numChannels; ++ch) + buffer.addSample(ch, sample, (mixedL + mixedR) * 0.5f); + } + } + }; + + int cursor = 0; + for (const auto metadata : midi) + { + const int eventSample = juce::jlimit(0, numSamples, metadata.samplePosition); + render(cursor, eventSample); + handleMidi(metadata.getMessage()); + cursor = eventSample; + } + render(cursor, numSamples); + sanitizeBuiltInBuffer(buffer, 2.5f); +} + +void S13DrumInstrument::getStateInformation(juce::MemoryBlock& destData) +{ + saveParamsToMemory(destData, "S13DrumInstrument", { + { "kit", kit.load() }, + { "tuning", tuning.load() }, + { "ambience", ambience.load() }, + { "outputGain", outputGain.load() }, + { "hihatTightness", hihatTightness.load() }, + { "mapPreset", mapPreset.load() }, + { "punch", punch.load() }, + { "stereoWidth", stereoWidth.load() }, + { "velocityCurve", velocityCurve.load() } + }); +} + +void S13DrumInstrument::setStateInformation(const void* data, int sizeInBytes) +{ + auto tree = loadParamsFromMemory(data, sizeInBytes, "S13DrumInstrument"); + if (!tree.isValid()) + return; + + kit = static_cast((double)tree.getProperty("kit", 0.0)); + tuning = static_cast((double)tree.getProperty("tuning", 0.0)); + ambience = static_cast((double)tree.getProperty("ambience", 0.18)); + outputGain = static_cast((double)tree.getProperty("outputGain", -10.0)); + hihatTightness = static_cast((double)tree.getProperty("hihatTightness", 0.65)); + mapPreset = static_cast((double)tree.getProperty("mapPreset", 0.0)); + punch = static_cast((double)tree.getProperty("punch", 0.55)); + stereoWidth = static_cast((double)tree.getProperty("stereoWidth", 0.7)); + velocityCurve = static_cast((double)tree.getProperty("velocityCurve", 0.0)); +} + +bool S13DrumInstrument::isBusesLayoutSupported(const BusesLayout& layouts) const +{ + const auto& mainOut = layouts.getMainOutputChannelSet(); + return mainOut == juce::AudioChannelSet::mono() || mainOut == juce::AudioChannelSet::stereo(); +} + bool S13Saturator::isBusesLayoutSupported(const BusesLayout& layouts) const { const auto& mainOut = layouts.getMainOutputChannelSet(); diff --git a/Source/BuiltInEffects2.h b/Source/BuiltInEffects2.h index 6cfc534..04fe1c2 100644 --- a/Source/BuiltInEffects2.h +++ b/Source/BuiltInEffects2.h @@ -27,6 +27,7 @@ class S13Delay : public juce::AudioProcessor std::atomic fbSaturation { 0.0f }; // 0-1 feedback saturation amount std::atomic stereoWidth { 1.0f }; // 0-2 stereo width std::atomic delayMode { 0.0f }; // 0=Digital, 1=Tape, 2=Analog + std::atomic ducking { 0.0f }; // 0-1 input ducking amount // AudioProcessor overrides void prepareToPlay(double sampleRate, int samplesPerBlock) override; @@ -65,6 +66,10 @@ class S13Delay : public juce::AudioProcessor float feedbackSampleL = 0.0f; float feedbackSampleR = 0.0f; + float smoothedDelaySamplesL = 0.0f; + float smoothedDelaySamplesR = 0.0f; + float duckEnvelope = 0.0f; + float modulationPhase = 0.0f; double cachedSampleRate = 44100.0; float lastLPFFreq = 20000.0f; @@ -132,6 +137,7 @@ class S13Reverb : public juce::AudioProcessor private: juce::dsp::Reverb reverb; + static constexpr int lateLineCount = 8; // Pre-delay juce::dsp::DelayLine preDelayLineL { 48000 }; @@ -140,7 +146,19 @@ class S13Reverb : public juce::AudioProcessor // Tone filters on wet signal juce::dsp::IIR::Filter wetLowCutL, wetLowCutR; juce::dsp::IIR::Filter wetHighCutL, wetHighCutR; - + float lastLowCut = 20.0f; + float lastHighCut = 20000.0f; + + juce::AudioBuffer dryBuffer; + juce::AudioBuffer earlyReflectionBuffer; + juce::AudioBuffer earlyOutputBuffer; + int earlyReflectionWriteIndex = 0; + juce::AudioBuffer lateTankBuffer; + int lateTankWriteIndex = 0; + std::array lateDampingState {}; + std::array lateModPhase {}; + float shimmerSmootherL = 0.0f; + float shimmerSmootherR = 0.0f; double cachedSampleRate = 44100.0; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(S13Reverb) @@ -171,6 +189,7 @@ class S13Chorus : public juce::AudioProcessor std::atomic highCut { 20000.0f }; // 200-20000 Hz wet signal std::atomic lowCut { 20.0f }; // 20-2000 Hz wet signal std::atomic tempoSync { 0.0f }; // 0 = off, 1 = on + std::atomic characterMode { 0.0f }; // 0=Clean, 1=Ensemble, 2=BBD // AudioProcessor overrides void prepareToPlay(double sampleRate, int samplesPerBlock) override; @@ -211,6 +230,12 @@ class S13Chorus : public juce::AudioProcessor static constexpr int maxPhaserStages = 12; juce::dsp::IIR::Filter allpassL[maxPhaserStages]; juce::dsp::IIR::Filter allpassR[maxPhaserStages]; + std::array phaserStateL {}; + std::array phaserStateR {}; + juce::dsp::IIR::Filter wetLowCutL, wetLowCutR; + juce::dsp::IIR::Filter wetHighCutL, wetHighCutR; + float lastLowCut = 20.0f; + float lastHighCut = 20000.0f; double cachedSampleRate = 44100.0; @@ -229,13 +254,14 @@ class S13Saturator : public juce::AudioProcessor S13Saturator(); ~S13Saturator() override = default; - enum class SatType : int { Tape = 0, Tube, Transistor, Clip, Crush }; + enum class SatType : int { Tape = 0, Tube, Transistor, Clip, Crush, Console, Transformer, Foldback }; // Parameters std::atomic satType { 0.0f }; // SatType as float std::atomic drive { 6.0f }; // 0-30 dB std::atomic mix { 1.0f }; // 0-1 std::atomic toneFreq { 20000.0f }; // 200-20000 Hz post-sat LPF + std::atomic lowCutFreq { 20.0f }; // 20-1000 Hz post-sat HPF std::atomic outputGain { 0.0f }; // -12 to 0 dB std::atomic asymmetry { 0.0f }; // -1 to 1 (asymmetric clipping) std::atomic oversampleMode { 1.0f }; // 0=off, 1=2x, 2=4x @@ -272,14 +298,201 @@ class S13Saturator : public juce::AudioProcessor private: juce::dsp::IIR::Filter toneFilterL, toneFilterR; + juce::dsp::IIR::Filter lowCutFilterL, lowCutFilterR; double cachedSampleRate = 44100.0; float lastToneFreq = 20000.0f; + float lastLowCutFreq = 20.0f; - std::unique_ptr> oversampler; + std::unique_ptr> oversampler2x; + std::unique_ptr> oversampler4x; bool oversamplingEnabled = false; + juce::SmoothedValue smoothedDriveGain; + juce::SmoothedValue smoothedMix; + juce::SmoothedValue smoothedOutputGain; // Saturation functions per type float processSample(float input, float driveLinear, SatType type, float asym) const; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(S13Saturator) }; + +// ============================================================================ +// S13BasicSynthInstrument -- Built-in polyphonic subtractive synth +// ============================================================================ +class S13BasicSynthInstrument : public juce::AudioProcessor +{ +public: + S13BasicSynthInstrument(); + ~S13BasicSynthInstrument() override = default; + + std::atomic attackMs { 8.0f }; + std::atomic releaseMs { 180.0f }; + std::atomic brightness { 0.62f }; + std::atomic detuneCents { 7.0f }; + std::atomic subLevel { 0.18f }; + std::atomic noiseLevel { 0.015f }; + std::atomic outputGain { -15.0f }; + + void prepareToPlay(double sampleRate, int samplesPerBlock) override; + void processBlock(juce::AudioBuffer&, juce::MidiBuffer&) override; + void releaseResources() override; + + const juce::String getName() const override { return "OpenStudio Basic Synth"; } + bool hasEditor() const override { return true; } + juce::AudioProcessorEditor* createEditor() override { return nullptr; } + bool acceptsMidi() const override { return true; } + bool producesMidi() const override { return false; } + bool isMidiEffect() const override { return false; } + double getTailLengthSeconds() const override { return 2.0; } + + int getNumPrograms() override { return 1; } + int getCurrentProgram() override { return 0; } + void setCurrentProgram(int) override {} + const juce::String getProgramName(int) override { return {}; } + void changeProgramName(int, const juce::String&) override {} + + void getStateInformation(juce::MemoryBlock& destData) override; + void setStateInformation(const void* data, int sizeInBytes) override; + bool isBusesLayoutSupported(const BusesLayout& layouts) const override; + + bool isS13BuiltIn() const { return true; } + bool isS13BuiltInInstrument() const { return true; } + +private: + std::array, 16> active {}; + std::array, 16> releasing {}; + std::array, 16> phaseA {}; + std::array, 16> phaseB {}; + std::array, 16> phaseSub {}; + std::array, 16> velocity {}; + std::array, 16> envelope {}; + std::array, 16> filterState {}; + std::array, 16> ageSamples {}; + std::array pitchBendSemitones {}; + std::array modWheel {}; + double cachedSampleRate = 44100.0; + + void clearVoices(); + void handleMidi(const juce::MidiMessage& message); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(S13BasicSynthInstrument) +}; + +// ============================================================================ +// S13PianoInstrument -- Built-in MIDI piano instrument +// ============================================================================ +class S13PianoInstrument : public juce::AudioProcessor +{ +public: + S13PianoInstrument(); + ~S13PianoInstrument() override = default; + + std::atomic tone { 0.58f }; + std::atomic body { 0.72f }; + std::atomic hammer { 0.42f }; + std::atomic releaseMs { 950.0f }; + std::atomic outputGain { -15.0f }; + std::atomic resonance { 0.38f }; + std::atomic stereoWidth { 0.62f }; + std::atomic model { 0.0f }; // 0=Studio Grand, 1=Bright Upright, 2=Soft Felt + + void prepareToPlay(double sampleRate, int samplesPerBlock) override; + void processBlock(juce::AudioBuffer&, juce::MidiBuffer&) override; + void releaseResources() override; + + const juce::String getName() const override { return "OpenStudio Piano"; } + bool hasEditor() const override { return true; } + juce::AudioProcessorEditor* createEditor() override { return nullptr; } + bool acceptsMidi() const override { return true; } + bool producesMidi() const override { return false; } + bool isMidiEffect() const override { return false; } + double getTailLengthSeconds() const override { return 4.0; } + + int getNumPrograms() override { return 1; } + int getCurrentProgram() override { return 0; } + void setCurrentProgram(int) override {} + const juce::String getProgramName(int) override { return {}; } + void changeProgramName(int, const juce::String&) override {} + + void getStateInformation(juce::MemoryBlock& destData) override; + void setStateInformation(const void* data, int sizeInBytes) override; + bool isBusesLayoutSupported(const BusesLayout& layouts) const override; + + bool isS13BuiltIn() const { return true; } + bool isS13BuiltInInstrument() const { return true; } + +private: + std::array, 16> active {}; + std::array, 16> releasing {}; + std::array, 16> sustained {}; + std::array, 16> phase {}; + std::array, 16> velocity {}; + std::array, 16> envelope {}; + std::array, 16> ageSamples {}; + std::array sustainPedal {}; + double cachedSampleRate = 44100.0; + + void clearVoices(); + void handleMidi(const juce::MidiMessage& message); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(S13PianoInstrument) +}; + +// ============================================================================ +// S13DrumInstrument -- Built-in MIDI drum instrument with GM/e-drum mapping +// ============================================================================ +class S13DrumInstrument : public juce::AudioProcessor +{ +public: + S13DrumInstrument(); + ~S13DrumInstrument() override = default; + + std::atomic kit { 0.0f }; // 0=Studio, 1=Rock, 2=Electronic + std::atomic tuning { 0.0f }; // semitones + std::atomic ambience { 0.18f }; + std::atomic outputGain { -10.0f }; + std::atomic hihatTightness { 0.65f }; + std::atomic mapPreset { 0.0f }; // 0=GM, 1=Roland TD + std::atomic punch { 0.55f }; + std::atomic stereoWidth { 0.7f }; + std::atomic velocityCurve { 0.0f }; // -1=soft, 0=linear, 1=hard + + void prepareToPlay(double sampleRate, int samplesPerBlock) override; + void processBlock(juce::AudioBuffer&, juce::MidiBuffer&) override; + void releaseResources() override; + + const juce::String getName() const override { return "OpenStudio Drums"; } + bool hasEditor() const override { return true; } + juce::AudioProcessorEditor* createEditor() override { return nullptr; } + bool acceptsMidi() const override { return true; } + bool producesMidi() const override { return false; } + bool isMidiEffect() const override { return false; } + double getTailLengthSeconds() const override { return 2.0; } + + int getNumPrograms() override { return 1; } + int getCurrentProgram() override { return 0; } + void setCurrentProgram(int) override {} + const juce::String getProgramName(int) override { return {}; } + void changeProgramName(int, const juce::String&) override {} + + void getStateInformation(juce::MemoryBlock& destData) override; + void setStateInformation(const void* data, int sizeInBytes) override; + bool isBusesLayoutSupported(const BusesLayout& layouts) const override; + + bool isS13BuiltIn() const { return true; } + bool isS13BuiltInInstrument() const { return true; } + +private: + std::array, 16> active {}; + std::array, 16> phase {}; + std::array, 16> velocity {}; + std::array, 16> ageSamples {}; + std::array hihatPedal {}; + double cachedSampleRate = 44100.0; + + void clearVoices(); + void handleMidi(const juce::MidiMessage& message); + int mapIncomingNote(int note) const; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(S13DrumInstrument) +}; 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..ee745d6 100644 --- a/Source/MainComponent.cpp +++ b/Source/MainComponent.cpp @@ -2,16 +2,262 @@ #include "ApplicationLaunchState.h" #include #include +#include #include #include #include #include +#include #if JUCE_WINDOWS #ifndef NOMINMAX #define NOMINMAX #endif #include +#include +#include +#endif + +#if JUCE_WINDOWS +class MainComponent::ExternalMediaDropTarget final : public IDropTarget +{ +public: + explicit ExternalMediaDropTarget(MainComponent& ownerIn) + : owner(ownerIn) + { + } + + ~ExternalMediaDropTarget() + { + for (auto hwnd : registeredWindows) + if (hwnd != nullptr && ::IsWindow(hwnd)) + ::RevokeDragDrop(hwnd); + + if (calledOleInitialize) + ::OleUninitialize(); + } + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, void** object) override + { + if (object == nullptr) + return E_POINTER; + + if (iid == IID_IUnknown || iid == IID_IDropTarget) + { + *object = static_cast(this); + AddRef(); + return S_OK; + } + + *object = nullptr; + return E_NOINTERFACE; + } + + ULONG STDMETHODCALLTYPE AddRef() override + { + return static_cast(refCount.fetch_add(1, std::memory_order_relaxed) + 1); + } + + ULONG STDMETHODCALLTYPE Release() override + { + const auto next = refCount.fetch_sub(1, std::memory_order_relaxed) - 1; + return static_cast(next); + } + + HRESULT STDMETHODCALLTYPE DragEnter(IDataObject* dataObject, DWORD, POINTL point, DWORD* effect) override + { + lastFiles = extractFiles(dataObject); + if (lastFiles.isEmpty()) + { + activeDragId.clear(); + setEffect(effect, DROPEFFECT_NONE); + return S_OK; + } + + activeDragId = "native-drop-" + juce::String(++dragCounter); + foregroundRequestedThisDrag = false; + requestForegroundOnce(); + owner.emitExternalMediaDropTargetEvent("externalMediaDragEnter", buildPayload(activeDragId, lastFiles, point)); + setEffect(effect, DROPEFFECT_COPY); + return S_OK; + } + + HRESULT STDMETHODCALLTYPE DragOver(DWORD, POINTL point, DWORD* effect) override + { + if (activeDragId.isNotEmpty()) + { + owner.emitExternalMediaDropTargetEvent("externalMediaDragMove", buildPayload(activeDragId, lastFiles, point)); + setEffect(effect, DROPEFFECT_COPY); + } + else + { + setEffect(effect, DROPEFFECT_NONE); + } + + return S_OK; + } + + HRESULT STDMETHODCALLTYPE DragLeave() override + { + if (activeDragId.isNotEmpty()) + owner.emitExternalMediaDropTargetEvent("externalMediaDragLeave", buildPayload(activeDragId, juce::StringArray(), POINTL{})); + + activeDragId.clear(); + lastFiles.clear(); + foregroundRequestedThisDrag = false; + return S_OK; + } + + HRESULT STDMETHODCALLTYPE Drop(IDataObject* dataObject, DWORD, POINTL point, DWORD* effect) override + { + auto files = extractFiles(dataObject); + if (files.isEmpty()) + files = lastFiles; + + if (activeDragId.isNotEmpty() && ! files.isEmpty()) + { + owner.emitExternalMediaDropTargetEvent("externalMediaDrop", buildPayload(activeDragId, files, point)); + setEffect(effect, DROPEFFECT_COPY); + } + else + { + setEffect(effect, DROPEFFECT_NONE); + } + + activeDragId.clear(); + lastFiles.clear(); + foregroundRequestedThisDrag = false; + return S_OK; + } + + void registerWindow(HWND hwnd) + { + if (hwnd == nullptr || registeredWindows.count(hwnd) != 0) + return; + + auto result = ::RegisterDragDrop(hwnd, this); + if (result == CO_E_NOTINITIALIZED && ! calledOleInitialize) + { + if (SUCCEEDED(::OleInitialize(nullptr))) + { + calledOleInitialize = true; + result = ::RegisterDragDrop(hwnd, this); + } + } + + if (result == DRAGDROP_E_ALREADYREGISTERED) + { + ::RevokeDragDrop(hwnd); + result = ::RegisterDragDrop(hwnd, this); + } + + if (SUCCEEDED(result)) + { + registeredWindows.insert(hwnd); + juce::Logger::writeToLog("External media drop target registered"); + } + } + +private: + static void setEffect(DWORD* effect, DWORD value) + { + if (effect != nullptr) + *effect = value; + } + + static juce::StringArray extractFiles(IDataObject* dataObject) + { + juce::StringArray files; + if (dataObject == nullptr) + return files; + + FORMATETC format {}; + format.cfFormat = CF_HDROP; + format.dwAspect = DVASPECT_CONTENT; + format.lindex = -1; + format.tymed = TYMED_HGLOBAL; + + STGMEDIUM medium {}; + if (FAILED(dataObject->GetData(&format, &medium))) + return files; + + auto dropHandle = reinterpret_cast(medium.hGlobal); + const auto count = ::DragQueryFileW(dropHandle, 0xFFFFFFFF, nullptr, 0); + for (UINT i = 0; i < count; ++i) + { + const auto length = ::DragQueryFileW(dropHandle, i, nullptr, 0); + if (length == 0) + continue; + + std::wstring path; + path.resize(static_cast(length) + 1); + if (::DragQueryFileW(dropHandle, i, path.data(), length + 1) > 0) + files.add(juce::String(path.c_str())); + } + + ::ReleaseStgMedium(&medium); + return files; + } + + juce::Point toClientPoint(POINTL point) const + { + if (auto* peer = owner.getPeer()) + { + auto hwnd = static_cast(peer->getNativeHandle()); + POINT nativePoint { static_cast(point.x), static_cast(point.y) }; + if (hwnd != nullptr && ::ScreenToClient(hwnd, &nativePoint) != 0) + return { static_cast(nativePoint.x), static_cast(nativePoint.y) }; + } + + return { static_cast(point.x), static_cast(point.y) }; + } + + void requestForegroundOnce() + { + if (foregroundRequestedThisDrag) + return; + + foregroundRequestedThisDrag = true; + owner.bringMainWindowToFrontForExternalMediaDrag(); + } + + juce::var buildPayload(const juce::String& dragId, const juce::StringArray& files, POINTL point) const + { + auto* payload = new juce::DynamicObject(); + const auto clientPoint = toClientPoint(point); + payload->setProperty("dragId", dragId); + payload->setProperty("clientX", clientPoint.x); + payload->setProperty("clientY", clientPoint.y); + payload->setProperty("nativeClientX", clientPoint.x); + payload->setProperty("nativeClientY", clientPoint.y); + payload->setProperty("screenX", static_cast(point.x)); + payload->setProperty("screenY", static_cast(point.y)); + payload->setProperty("deviceScaleFactor", owner.getDesktopScaleFactor()); + + juce::Array fileArray; + for (const auto& path : files) + { + juce::File file(path); + auto* item = new juce::DynamicObject(); + item->setProperty("path", file.getFullPathName()); + item->setProperty("name", file.getFileName()); + item->setProperty("extension", file.getFileExtension().toLowerCase()); + item->setProperty("size", static_cast(file.getSize())); + fileArray.add(juce::var(item)); + } + payload->setProperty("files", fileArray); + return juce::var(payload); + } + + MainComponent& owner; + std::atomic refCount { 1 }; + std::set registeredWindows; + juce::String activeDragId; + juce::StringArray lastFiles; + uint64_t dragCounter = 0; + bool calledOleInitialize = false; + bool foregroundRequestedThisDrag = false; +}; #endif namespace @@ -44,6 +290,119 @@ static void logAudioBridge(const juce::String& message) juce::Logger::writeToLog("[audio.bridge] " + message); } +static juce::var buildMediaInfoResult(const juce::File& mediaFile, + const juce::File& resultFile, + juce::AudioFormatReader& reader) +{ + auto* result = new juce::DynamicObject(); + const auto duration = reader.sampleRate > 0.0 ? reader.lengthInSamples / reader.sampleRate : 0.0; + result->setProperty("filePath", resultFile.getFullPathName()); + result->setProperty("duration", duration); + result->setProperty("sampleRate", static_cast(reader.sampleRate)); + result->setProperty("numChannels", static_cast(reader.numChannels)); + result->setProperty("format", mediaFile.getFileExtension().toUpperCase().trimCharactersAtStart(".")); + return juce::var(result); +} + +static juce::var probeReadableMediaFile(const juce::String& filePath) +{ + juce::File mediaFile(filePath); + if (! mediaFile.existsAsFile()) + return juce::var(); + + juce::AudioFormatManager formatManager; + formatManager.registerBasicFormats(); + + std::unique_ptr reader(formatManager.createReaderFor(mediaFile)); + if (reader == nullptr) + return juce::var(); + + return buildMediaInfoResult(mediaFile, mediaFile, *reader); +} + +static juce::var buildWaveformPreviewPayload(const juce::String& requestId, + const juce::String& filePath, + int maxPoints) +{ + juce::File audioFile(filePath); + if (! audioFile.existsAsFile()) + return juce::var(); + + juce::AudioFormatManager formatManager; + formatManager.registerBasicFormats(); + std::unique_ptr reader(formatManager.createReaderFor(audioFile)); + if (reader == nullptr || reader->lengthInSamples <= 0 || reader->numChannels <= 0) + return juce::var(); + + const int channels = juce::jlimit(1, 8, static_cast(reader->numChannels)); + const int pointCount = juce::jlimit(64, 4096, juce::jmin(maxPoints, static_cast(reader->lengthInSamples))); + const auto samplesPerPoint = std::max( + 1, (reader->lengthInSamples + pointCount - 1) / pointCount); + juce::Array peaks; + peaks.ensureStorageAllocated(1 + pointCount * channels * 2); + peaks.add(channels); + + std::vector mins(static_cast(pointCount * channels), 0.0f); + std::vector maxs(static_cast(pointCount * channels), 0.0f); + std::vector touched(static_cast(pointCount * channels), 0); + + constexpr int chunkSize = 65536; + juce::AudioBuffer buffer(channels, chunkSize); + juce::int64 samplePos = 0; + while (samplePos < reader->lengthInSamples) + { + const int samplesToRead = static_cast( + std::min(chunkSize, reader->lengthInSamples - samplePos)); + buffer.clear(); + if (! reader->read(&buffer, 0, samplesToRead, samplePos, true, true)) + break; + + for (int s = 0; s < samplesToRead; ++s) + { + const auto absoluteSample = samplePos + s; + const int pointIndex = juce::jlimit(0, pointCount - 1, static_cast(absoluteSample / samplesPerPoint)); + for (int ch = 0; ch < channels; ++ch) + { + const auto index = static_cast(pointIndex * channels + ch); + const auto value = buffer.getSample(ch, s); + if (touched[index] == 0) + { + mins[index] = value; + maxs[index] = value; + touched[index] = 1; + } + else + { + mins[index] = juce::jmin(mins[index], value); + maxs[index] = juce::jmax(maxs[index], value); + } + } + } + + samplePos += samplesToRead; + } + + for (int point = 0; point < pointCount; ++point) + { + for (int ch = 0; ch < channels; ++ch) + { + const auto index = static_cast(point * channels + ch); + peaks.add(juce::var(touched[index] != 0 ? mins[index] : 0.0f)); + peaks.add(juce::var(touched[index] != 0 ? maxs[index] : 0.0f)); + } + } + + auto* payload = new juce::DynamicObject(); + payload->setProperty("requestId", requestId); + payload->setProperty("filePath", audioFile.getFullPathName()); + payload->setProperty("duration", reader->sampleRate > 0.0 ? reader->lengthInSamples / reader->sampleRate : 0.0); + payload->setProperty("sampleRate", static_cast(reader->sampleRate)); + payload->setProperty("numChannels", channels); + payload->setProperty("complete", true); + payload->setProperty("peaks", peaks); + return juce::var(payload); +} + juce::WebBrowserComponent::Options::Backend getPreferredBrowserBackend() { #if JUCE_WINDOWS @@ -109,6 +468,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) @@ -468,6 +872,9 @@ juce::String getDefaultFileFilter(const juce::String& defaultPath, const juce::S if (lowerPath.endsWith(".ostheme") || lowerPath.endsWith(".s13theme")) return "*.ostheme;*.s13theme;*.json"; + if (lowerPath.endsWith(".mid") || lowerPath.endsWith(".midi")) + return "*.mid;*.midi"; + if (projectDialog) return "*.osproj;*.s13"; @@ -485,6 +892,10 @@ juce::String getPreferredExtension(const juce::String& defaultPath, const juce:: if (lowerPath.endsWith(".ostheme") || lowerFilter.contains(".ostheme")) return ".ostheme"; + if (lowerPath.endsWith(".mid") || lowerPath.endsWith(".midi") + || lowerFilter.contains(".mid") || lowerFilter.contains(".midi")) + return ".mid"; + return ".osproj"; } @@ -507,7 +918,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 +945,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 +998,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 +1012,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 +1165,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 +1413,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 +1808,47 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, completion(juce::Array()); } }) + .withNativeFunction ("getBuiltInPluginSchema", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() >= 3 && args[0].isString()) { + completion(audioEngine.getBuiltInPluginSchema(args[0].toString(), args[1].toString(), static_cast(args[2]))); + } else { + completion(juce::var()); + } + }) + .withNativeFunction ("getBuiltInPluginState", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() >= 3 && args[0].isString()) { + completion(audioEngine.getBuiltInPluginState(args[0].toString(), args[1].toString(), static_cast(args[2]))); + } else { + completion(juce::var()); + } + }) + .withNativeFunction ("setBuiltInPluginParam", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() >= 5 && args[0].isString()) { + completion(audioEngine.setBuiltInPluginParam(args[0].toString(), args[1].toString(), static_cast(args[2]), + args[3].toString(), static_cast(static_cast(args[4])))); + } else { + completion(false); + } + }) + .withNativeFunction ("setBuiltInPluginState", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() >= 4 && args[0].isString()) { + completion(audioEngine.setBuiltInPluginState(args[0].toString(), args[1].toString(), static_cast(args[2]), args[3].toString())); + } else { + completion(false); + } + }) + .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 +2337,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 +2384,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 +2627,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()); @@ -2186,6 +2713,70 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, completion(false); } }) + .withNativeFunction ("probeMediaFile", [] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() >= 1 && args[0].isString()) + completion(probeReadableMediaFile(args[0].toString())); + else + completion(juce::var()); + }) + .withNativeFunction ("requestWaveformPreview", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() < 3 || ! args[0].isString() || ! args[1].isString()) + { + completion(false); + return; + } + + const auto filePath = args[0].toString(); + const auto requestId = args[1].toString(); + const int maxPoints = juce::jlimit(64, 4096, static_cast(args[2])); + +#if JUCE_WINDOWS + { + const juce::ScopedLock sl(waveformPreviewRequestLock); + cancelledWaveformPreviewRequests.erase(requestId); + } +#endif + + juce::Component::SafePointer safeThis(this); + mediaPreviewPool.addJob([safeThis, filePath, requestId, maxPoints]() + { + if (safeThis == nullptr) + return; + +#if JUCE_WINDOWS + if (safeThis->isWaveformPreviewRequestCancelled(requestId)) + return; +#endif + + auto payload = buildWaveformPreviewPayload(requestId, filePath, maxPoints); + if (payload.isVoid()) + return; + +#if JUCE_WINDOWS + if (safeThis == nullptr || safeThis->isWaveformPreviewRequestCancelled(requestId)) + return; +#endif + + safeThis->emitFrontendEvent("waveformPreviewReady", payload); + }); + + completion(true); + }) + .withNativeFunction ("cancelWaveformPreview", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() >= 1 && args[0].isString()) + { +#if JUCE_WINDOWS + const juce::ScopedLock sl(waveformPreviewRequestLock); + cancelledWaveformPreviewRequests.insert(args[0].toString()); +#else + juce::ignoreUnused(args); +#endif + completion(true); + return; + } + + completion(false); + }) .withNativeFunction ("importMediaFile", [] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { // Read audio file metadata (duration, sample rate, channels, format). // For video files that JUCE can't read directly, attempts FFmpeg extraction. @@ -2961,71 +3552,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 +4497,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 +4553,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 +4582,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 +4676,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 +4806,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 @@ -4172,6 +4826,72 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, completion(false); } }) + .withNativeFunction ("prepareExternalMIDIFileDrag", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + auto* resultObj = new juce::DynamicObject(); + auto complete = [&completion, resultObj] (bool success, const juce::String& filePath, const juce::String& error = {}) { + resultObj->setProperty("success", success); + resultObj->setProperty("filePath", filePath); + if (error.isNotEmpty()) + resultObj->setProperty("error", error); + completion(juce::var(resultObj)); + }; + + if (! isMainWindow()) + { + complete(false, {}, "External MIDI drag is only available from the main window."); + return; + } + + if (args.size() < 2 || ! args[0].isString()) + { + complete(false, {}, "Missing MIDI export data."); + return; + } + + auto tracks = args[1]; + if (tracks.isString()) + tracks = juce::JSON::parse(tracks.toString()); + + if (! tracks.isArray()) + { + complete(false, {}, "Invalid MIDI export data."); + return; + } + + auto suggestedName = juce::File::createLegalFileName( + juce::File(args[0].toString()).getFileNameWithoutExtension()); + if (suggestedName.isEmpty()) + suggestedName = "Studio13 MIDI Clip"; + + auto dragDir = juce::File::getSpecialLocation(juce::File::tempDirectory) + .getChildFile("Studio13") + .getChildFile("MIDI Drag Exports"); + dragDir.createDirectory(); + + auto outputFile = dragDir.getNonexistentChildFile(suggestedName, ".mid", false); + const bool ok = audioEngine.exportProjectMIDI(outputFile.getFullPathName(), tracks, 120.0); + complete(ok, + ok ? outputFile.getFullPathName() : juce::String(), + ok ? juce::String() : "Failed to export MIDI drag file."); + }) + .withNativeFunction ("beginExternalFileDrag", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (! isMainWindow() || args.size() < 1 || ! args[0].isString()) + { + completion(false); + return; + } + + const juce::File file(args[0].toString()); + if (! file.existsAsFile()) + { + completion(false); + return; + } + + juce::StringArray files; + files.add(file.getFullPathName()); + completion(juce::DragAndDropContainer::performExternalDragDropOfFiles(files, false, this)); + }) // Plugin Presets (Phase 19.14) .withNativeFunction ("getPluginPresets", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() >= 3) { @@ -5405,6 +6125,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,15 +6329,15 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, #if JUCE_DEBUG if (isLocalFrontendDevServerReachable()) { - const auto frontendUrl = appendFrontendStartupQuery("http://localhost:5173", windowRole, startupMode); - juce::Logger::writeToLog("Loading frontend from localhost:5173"); + const auto frontendUrl = appendFrontendStartupQuery("http://127.0.0.1:5173", windowRole, startupMode, windowInstanceId); + juce::Logger::writeToLog("Loading frontend from 127.0.0.1:5173"); beginFrontendStartupWatchdog(frontendUrl); webView.goToURL(frontendUrl); loadedFrontend = true; } else { - juce::Logger::writeToLog("localhost:5173 is unreachable; falling back to the packaged frontend."); + juce::Logger::writeToLog("127.0.0.1:5173 is unreachable; falling back to the packaged frontend."); } #endif @@ -5646,6 +6372,23 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, activeInstances.add(this); } +#if JUCE_WINDOWS + if (isMainWindow()) + { + juce::Component::SafePointer safeThis(this); + juce::Timer::callAfterDelay(750, [safeThis]() + { + if (safeThis != nullptr) + safeThis->installExternalMediaDropTarget(); + }); + juce::Timer::callAfterDelay(2500, [safeThis]() + { + if (safeThis != nullptr) + safeThis->installExternalMediaDropTarget(); + }); + } +#endif + initializePitchRegressionJob(pitchRegressionJobPathIn); if (isMainWindow() && pitchRegressionJob.isVoid()) @@ -5953,16 +6696,87 @@ bool MainComponent::completePitchRegressionJob(const juce::var& result) MainComponent::~MainComponent() { stopTimer(); + mediaPreviewPool.removeAllJobs(true, 2000); +#if JUCE_WINDOWS + externalMediaDropTarget.reset(); +#endif const juce::ScopedLock sl(instanceListLock); activeInstances.removeFirstMatchingValue(this); } +#if JUCE_WINDOWS +void MainComponent::emitExternalMediaDropTargetEvent(const juce::String& eventId, const juce::var& payload) +{ + emitFrontendEvent(eventId, payload); +} + +void MainComponent::bringMainWindowToFrontForExternalMediaDrag() +{ + if (! isMainWindow()) + return; + + auto* peer = getPeer(); + if (peer == nullptr) + return; + + auto hwnd = static_cast(peer->getNativeHandle()); + if (hwnd == nullptr || ! ::IsWindow(hwnd)) + return; + + if (::IsIconic(hwnd)) + ::ShowWindow(hwnd, SW_RESTORE); + + ::SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW); + ::BringWindowToTop(hwnd); + ::SetForegroundWindow(hwnd); +} + +void MainComponent::installExternalMediaDropTarget() +{ + if (! isMainWindow()) + return; + + auto* peer = getPeer(); + if (peer == nullptr) + return; + + if (externalMediaDropTarget == nullptr) + externalMediaDropTarget = std::make_unique(*this); + + auto hwnd = static_cast(peer->getNativeHandle()); + externalMediaDropTarget->registerWindow(hwnd); + if (hwnd != nullptr) + { + ::EnumChildWindows(hwnd, [](HWND child, LPARAM param) -> BOOL + { + auto* self = reinterpret_cast(param); + if (self != nullptr && self->externalMediaDropTarget != nullptr) + self->externalMediaDropTarget->registerWindow(child); + return TRUE; + }, reinterpret_cast(this)); + } +} + +bool MainComponent::isWaveformPreviewRequestCancelled(const juce::String& requestId) const +{ + const juce::ScopedLock sl(waveformPreviewRequestLock); + return cancelledWaveformPreviewRequests.count(requestId) != 0; +} +#endif + void MainComponent::requestFrontendAppClose() { if (! isMainWindow()) { if (windowCallbacks.closeMixerWindow) - windowCallbacks.closeMixerWindow(); + { + auto closeMixerWindow = windowCallbacks.closeMixerWindow; + juce::MessageManager::callAsync([closeMixerWindow]() + { + closeMixerWindow(); + }); + } return; } @@ -6031,7 +6845,8 @@ bool MainComponent::loadPackagedFrontend() const auto frontendUrl = appendFrontendStartupQuery( juce::WebBrowserComponent::getResourceProviderRoot() + "index.html", windowRole, - startupMode); + startupMode, + windowInstanceId); beginFrontendStartupWatchdog(frontendUrl); webView.goToURL(frontendUrl); return true; @@ -6048,9 +6863,15 @@ bool MainComponent::tryFallbackToPackagedFrontendAfterLocalTimeout() attemptedPackagedFrontendFallbackAfterLocalTimeout = true; +#if JUCE_DEBUG + juce::Logger::writeToLog("Frontend startup timed out while using local Vite; " + "not retrying with packaged frontend in Debug mode."); + return false; +#else juce::Logger::writeToLog("Frontend startup timed out while using localhost:5173; " "retrying with the packaged frontend."); return loadPackagedFrontend(); +#endif } void MainComponent::beginFrontendStartupWatchdog(const juce::String& targetUrl) diff --git a/Source/MainComponent.h b/Source/MainComponent.h index 80135a9..f5ba40b 100644 --- a/Source/MainComponent.h +++ b/Source/MainComponent.h @@ -3,6 +3,7 @@ #include #include "AudioEngine.h" #include "AppUpdater.h" +#include //============================================================================== /* @@ -22,7 +23,8 @@ class MainComponent : public juce::Component, enum class WindowRole { main, - mixer + mixer, + midiEditor }; enum class FrontendStartupState @@ -50,6 +52,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 +67,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; //============================================================================== @@ -73,6 +83,11 @@ class MainComponent : public juce::Component, static juce::var buildStartupSelfTestReport(); static bool writeStartupSelfTestReport(const juce::File& reportFile); +#if JUCE_WINDOWS + void emitExternalMediaDropTargetEvent(const juce::String& eventId, const juce::var& payload); + void bringMainWindowToFrontForExternalMediaDrag(); +#endif + private: juce::Rectangle getDesktopWorkAreaForCurrentWindow() const; bool isWindowPseudoMaximized() const; @@ -98,6 +113,10 @@ class MainComponent : public juce::Component, juce::var buildStartupDiagnostics() const; void initializePitchRegressionJob(const juce::String& pitchRegressionJobPathIn); bool completePitchRegressionJob(const juce::var& result); +#if JUCE_WINDOWS + void installExternalMediaDropTarget(); + bool isWaveformPreviewRequestCancelled(const juce::String& requestId) const; +#endif //============================================================================== // Your private member variables go here... @@ -105,6 +124,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; @@ -131,6 +151,7 @@ class MainComponent : public juce::Component, juce::ThreadPool previewSegmentPool { 2 }; juce::ThreadPool noteRenderPool { 1 }; juce::ThreadPool fullClipHQPool { 1 }; + juce::ThreadPool mediaPreviewPool { 2 }; juce::CriticalSection pitchCorrectionJobLock; juce::String activePreviewRequestGroup; juce::String activeNoteRenderRequestGroup; @@ -154,6 +175,12 @@ class MainComponent : public juce::Component, bool pitchRegressionJobCompleted = false; juce::CriticalSection pitchRegressionNativeResultLock; juce::var lastPitchRegressionNativeResult; +#if JUCE_WINDOWS + class ExternalMediaDropTarget; + std::unique_ptr externalMediaDropTarget; + mutable juce::CriticalSection waveformPreviewRequestLock; + std::set cancelledWaveformPreviewRequests; +#endif static juce::CriticalSection instanceListLock; static juce::Array activeInstances; 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/PeakCache.cpp b/Source/PeakCache.cpp index 8dabb20..5ba53d0 100644 --- a/Source/PeakCache.cpp +++ b/Source/PeakCache.cpp @@ -241,11 +241,12 @@ bool PeakCache::buildPeaks(const juce::File& audioFile, CacheEntry& entry) entry.totalSamples = totalSamples; entry.levels.resize(NUM_LEVELS); - // Build finest level (stride 64) first by reading the entire file in chunks + // Build the finest level once, then derive coarser levels from it. The old + // path updated every mip level for every decoded sample, multiplying import + // peak-build CPU by NUM_LEVELS. const int CHUNK_SIZE = 65536; // Read 64K samples at a time juce::AudioBuffer readBuffer(numChannels, CHUNK_SIZE); - // Pre-allocate all levels for (int lvl = 0; lvl < NUM_LEVELS; ++lvl) { auto& level = entry.levels[static_cast(lvl)]; @@ -255,23 +256,27 @@ bool PeakCache::buildPeaks(const juce::File& audioFile, CacheEntry& entry) level.data.resize(static_cast(level.numPeaks) * static_cast(numChannels) * 2, 0.0f); } - // Single pass through the file: compute all mipmap levels simultaneously + auto& fineLevel = entry.levels[0]; juce::int64 samplesRead = 0; + int fineSampleCount = 0; + int finePeakIndex = 0; + std::vector chMin(static_cast(numChannels), 0.0f); + std::vector chMax(static_cast(numChannels), 0.0f); - // Per-level accumulators - struct LevelAcc + auto flushFinePeak = [&]() { - int sampleCount = 0; - int peakIndex = 0; - std::vector chMin; - std::vector chMax; + if (finePeakIndex >= fineLevel.numPeaks) + return; + + const int dataIdx = finePeakIndex * numChannels * 2; + for (int ch = 0; ch < numChannels; ++ch) + { + fineLevel.data[static_cast(dataIdx + ch * 2)] = chMin[static_cast(ch)]; + fineLevel.data[static_cast(dataIdx + ch * 2 + 1)] = chMax[static_cast(ch)]; + } + ++finePeakIndex; + fineSampleCount = 0; }; - std::vector accs(NUM_LEVELS); - for (int lvl = 0; lvl < NUM_LEVELS; ++lvl) - { - accs[static_cast(lvl)].chMin.assign(static_cast(numChannels), 0.0f); - accs[static_cast(lvl)].chMax.assign(static_cast(numChannels), 0.0f); - } while (samplesRead < totalSamples) { @@ -283,67 +288,64 @@ bool PeakCache::buildPeaks(const juce::File& audioFile, CacheEntry& entry) // Process each sample for (int s = 0; s < samplesToRead; ++s) { - for (int lvl = 0; lvl < NUM_LEVELS; ++lvl) + if (fineSampleCount == 0) { - auto& acc = accs[static_cast(lvl)]; - auto& level = entry.levels[static_cast(lvl)]; - - // Initialize min/max on first sample of each peak window - if (acc.sampleCount == 0) + for (int ch = 0; ch < numChannels; ++ch) { - for (int ch = 0; ch < numChannels; ++ch) - { - float val = readBuffer.getSample(ch, s); - acc.chMin[static_cast(ch)] = val; - acc.chMax[static_cast(ch)] = val; - } + const float val = readBuffer.getSample(ch, s); + chMin[static_cast(ch)] = val; + chMax[static_cast(ch)] = val; } - else - { - for (int ch = 0; ch < numChannels; ++ch) - { - float val = readBuffer.getSample(ch, s); - if (val < acc.chMin[static_cast(ch)]) acc.chMin[static_cast(ch)] = val; - if (val > acc.chMax[static_cast(ch)]) acc.chMax[static_cast(ch)] = val; - } - } - - acc.sampleCount++; - - // Flush peak when stride reached - if (acc.sampleCount >= LEVEL_STRIDES[lvl]) + } + else + { + for (int ch = 0; ch < numChannels; ++ch) { - if (acc.peakIndex < level.numPeaks) - { - int dataIdx = acc.peakIndex * numChannels * 2; - for (int ch = 0; ch < numChannels; ++ch) - { - level.data[static_cast(dataIdx + ch * 2)] = acc.chMin[static_cast(ch)]; - level.data[static_cast(dataIdx + ch * 2 + 1)] = acc.chMax[static_cast(ch)]; - } - } - acc.peakIndex++; - acc.sampleCount = 0; + const float val = readBuffer.getSample(ch, s); + if (val < chMin[static_cast(ch)]) chMin[static_cast(ch)] = val; + if (val > chMax[static_cast(ch)]) chMax[static_cast(ch)] = val; } } + + ++fineSampleCount; + if (fineSampleCount >= fineLevel.stride) + flushFinePeak(); } samplesRead += samplesToRead; } - // Flush any remaining partial windows - for (int lvl = 0; lvl < NUM_LEVELS; ++lvl) + if (fineSampleCount > 0) + flushFinePeak(); + + for (int lvl = 1; lvl < NUM_LEVELS; ++lvl) { - auto& acc = accs[static_cast(lvl)]; auto& level = entry.levels[static_cast(lvl)]; + const int finePeaksPerCoarsePeak = juce::jmax(1, level.stride / fineLevel.stride); - if (acc.sampleCount > 0 && acc.peakIndex < level.numPeaks) + for (int peak = 0; peak < level.numPeaks; ++peak) { - int dataIdx = acc.peakIndex * numChannels * 2; + const int fineStart = peak * finePeaksPerCoarsePeak; + const int fineEnd = juce::jmin(fineStart + finePeaksPerCoarsePeak, fineLevel.numPeaks); + if (fineStart >= fineEnd) + break; + + const int dataIdx = peak * numChannels * 2; for (int ch = 0; ch < numChannels; ++ch) { - level.data[static_cast(dataIdx + ch * 2)] = acc.chMin[static_cast(ch)]; - level.data[static_cast(dataIdx + ch * 2 + 1)] = acc.chMax[static_cast(ch)]; + float minVal = 0.0f; + float maxVal = 0.0f; + for (int finePeak = fineStart; finePeak < fineEnd; ++finePeak) + { + const int fineDataIdx = finePeak * numChannels * 2 + ch * 2; + const float mn = fineLevel.data[static_cast(fineDataIdx)]; + const float mx = fineLevel.data[static_cast(fineDataIdx + 1)]; + if (finePeak == fineStart || mn < minVal) minVal = mn; + if (finePeak == fineStart || mx > maxVal) maxVal = mx; + } + + level.data[static_cast(dataIdx + ch * 2)] = minVal; + level.data[static_cast(dataIdx + ch * 2 + 1)] = maxVal; } } } diff --git a/Source/PlaybackEngine.cpp b/Source/PlaybackEngine.cpp index 10b4252..1735e82 100644 --- a/Source/PlaybackEngine.cpp +++ b/Source/PlaybackEngine.cpp @@ -163,7 +163,6 @@ void PlaybackEngine::preloadReader(const juce::File& file) std::unique_ptr newReader(formatManager.createReaderFor(file)); if (newReader) { - preloadAudioData(file, *newReader); readers[filePath] = std::move(newReader); readerAccessTimes[filePath] = juce::Time::currentTimeMillis(); evictOldReaders(); 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..778891d 100644 --- a/Source/TrackProcessor.cpp +++ b/Source/TrackProcessor.cpp @@ -1,10 +1,26 @@ #include "TrackProcessor.h" +#include + // Maximum channel count for the pre-allocated FX processing buffer. // Must be large enough for multi-output instruments (e.g. Komplete Kontrol = 32 out). static constexpr int kMaxFXChannels = 64; static constexpr int kMinimumHostedPluginBlockSize = 512; +static bool isBuiltInInstrumentProcessor(const juce::AudioProcessor* processor) +{ + if (processor == nullptr) + return false; + + const auto name = processor->getName(); + return name == "OpenStudio Piano" + || name == "OpenStudio Drums" + || name == "OpenStudio Basic Synth" + || name == "Studio13 Piano" + || name == "Studio13 Drums" + || name == "Studio13 Basic Synth"; +} + // Debug logging — always active for FX diagnostics static void logToDisk(const juce::String& msg) { @@ -22,6 +38,100 @@ static int getSafeHostedPluginBlockSize(int requestedBlockSize) requestedBlockSize > 0 ? requestedBlockSize : kMinimumHostedPluginBlockSize); } +static float polyBlep(float phase, float phaseDelta) +{ + if (phaseDelta <= 0.0f) + return 0.0f; + + if (phase < phaseDelta) + { + const float t = phase / phaseDelta; + return t + t - t * t - 1.0f; + } + + if (phase > 1.0f - phaseDelta) + { + const float t = (phase - 1.0f) / phaseDelta; + return t * t + t + t + 1.0f; + } + + return 0.0f; +} + +static float polyBlepSaw(float phase, float phaseDelta) +{ + return (2.0f * phase - 1.0f) - polyBlep(phase, phaseDelta); +} + +static float polyBlepSquare(float phase, float phaseDelta, float pulseWidth) +{ + const float clampedPulseWidth = juce::jlimit(0.08f, 0.92f, pulseWidth); + float value = phase < clampedPulseWidth ? 1.0f : -1.0f; + value += polyBlep(phase, phaseDelta); + + float fallingPhase = phase - clampedPulseWidth; + if (fallingPhase < 0.0f) + fallingPhase += 1.0f; + value -= polyBlep(fallingPhase, phaseDelta); + return value; +} + +static float cubicInterpolate(float a, float b, float c, float d, float frac) +{ + const float p = (d - c) - (a - b); + const float q = (a - b) - p; + const float r = c - a; + return ((p * frac + q) * frac + r) * frac + b; +} + +static float fastNoise(int sampleAge, int note) +{ + const float x = static_cast(sampleAge * 1103515245u + note * 12345u); + return std::sin(x * 0.0000137f) * std::sin(x * 0.000091f); +} + +static float getDrumBaseFrequency(int note) +{ + switch (note) + { + case 35: + case 36: return 52.0f; + case 37: + case 38: + case 40: return 190.0f; + case 41: return 82.0f; + case 43: return 98.0f; + case 45: return 123.0f; + case 47: return 146.0f; + case 48: return 164.0f; + case 50: return 196.0f; + default: return 440.0f; + } +} + +static float getDrumDecaySeconds(int note) +{ + switch (note) + { + case 35: + case 36: return 0.42f; + case 37: + case 38: + case 40: return 0.24f; + case 42: return 0.055f; + case 44: return 0.09f; + case 46: return 0.62f; + case 49: + case 52: + case 55: + case 57: return 1.55f; + case 51: + case 53: + case 59: return 1.15f; + default: return note >= 41 && note <= 50 ? 0.42f : 0.28f; + } +} + static void computePanLawGains(PanLaw panLaw, float pan, float volumeGain, float& leftGain, float& rightGain) { @@ -148,9 +258,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 +285,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 +346,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 +374,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 +479,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 +536,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 +570,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 +694,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 +911,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() @@ -660,6 +961,18 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc auto sendSnapshot = std::atomic_load_explicit(&realtimeSendSnapshot, std::memory_order_acquire); const bool instrumentForceFloat = instrumentForceFloatOverride.load(std::memory_order_acquire); const double blockTimeSeconds = this->blockStartTimeSeconds; + bool hasTrackBuiltInInstrument = false; + if (trackFXSnapshot) + { + for (const auto& plugin : *trackFXSnapshot) + { + if (isBuiltInInstrumentProcessor(plugin.get())) + { + hasTrackBuiltInInstrument = true; + break; + } + } + } if (araController != nullptr && araController->isActive()) araController->updateTransportDebugState(araTransportPlayingDebugState.load(std::memory_order_acquire), @@ -670,8 +983,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 +1263,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 +1310,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 +1356,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 && !hasTrackBuiltInInstrument) + { + 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 +1585,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 +1626,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 +1675,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 +1690,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 +2316,697 @@ 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( + std::max(1, std::min(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( + std::min(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& channelPhases : fallbackInstrumentPhaseB) + channelPhases.fill(0.0f); + for (auto& channelPhases : fallbackInstrumentSubPhase) + channelPhases.fill(0.0f); + for (auto& channelFilters : fallbackInstrumentFilterState) + channelFilters.fill(0.0f); + for (auto& channelVelocities : fallbackInstrumentVelocity) + channelVelocities.fill(0.0f); + for (auto& channelEnvelopes : fallbackInstrumentEnvelope) + channelEnvelopes.fill(0.0f); + for (auto& channelAges : fallbackInstrumentVoiceAgeSamples) + channelAges.fill(0); + 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); +} + +float TrackProcessor::getFallbackInstrumentParam(const juce::String& paramId) const +{ + if (paramId == "attackMs") return fallbackSynthAttackMs.load(std::memory_order_relaxed); + if (paramId == "releaseMs") return fallbackSynthReleaseMs.load(std::memory_order_relaxed); + if (paramId == "brightness") return fallbackSynthBrightness.load(std::memory_order_relaxed); + if (paramId == "detuneCents") return fallbackSynthDetuneCents.load(std::memory_order_relaxed); + if (paramId == "subLevel") return fallbackSynthSubLevel.load(std::memory_order_relaxed); + if (paramId == "noiseLevel") return fallbackSynthNoiseLevel.load(std::memory_order_relaxed); + if (paramId == "outputGainDb") return fallbackSynthOutputGainDb.load(std::memory_order_relaxed); + if (paramId == "instrumentMode") return fallbackInstrumentMode.load(std::memory_order_relaxed); + if (paramId == "pianoTone") return fallbackPianoTone.load(std::memory_order_relaxed); + if (paramId == "pianoBody") return fallbackPianoBody.load(std::memory_order_relaxed); + if (paramId == "drumKit") return fallbackDrumKit.load(std::memory_order_relaxed); + if (paramId == "drumTuning") return fallbackDrumTuning.load(std::memory_order_relaxed); + if (paramId == "drumAmbience") return fallbackDrumAmbience.load(std::memory_order_relaxed); + return 0.0f; +} + +bool TrackProcessor::setFallbackInstrumentParam(const juce::String& paramId, float value) +{ + if (paramId == "attackMs") + { + fallbackSynthAttackMs.store(juce::jlimit(0.5f, 2000.0f, value), std::memory_order_relaxed); + return true; + } + if (paramId == "releaseMs") + { + fallbackSynthReleaseMs.store(juce::jlimit(5.0f, 5000.0f, value), std::memory_order_relaxed); + return true; + } + if (paramId == "brightness") + { + fallbackSynthBrightness.store(juce::jlimit(0.0f, 1.0f, value), std::memory_order_relaxed); + return true; + } + if (paramId == "detuneCents") + { + fallbackSynthDetuneCents.store(juce::jlimit(0.0f, 35.0f, value), std::memory_order_relaxed); + return true; + } + if (paramId == "subLevel") + { + fallbackSynthSubLevel.store(juce::jlimit(0.0f, 0.8f, value), std::memory_order_relaxed); + return true; + } + if (paramId == "noiseLevel") + { + fallbackSynthNoiseLevel.store(juce::jlimit(0.0f, 0.25f, value), std::memory_order_relaxed); + return true; + } + if (paramId == "outputGainDb") + { + fallbackSynthOutputGainDb.store(juce::jlimit(-36.0f, 0.0f, value), std::memory_order_relaxed); + return true; + } + if (paramId == "instrumentMode") + { + fallbackInstrumentMode.store(juce::jlimit(0.0f, 2.0f, std::round(value)), std::memory_order_relaxed); + fallbackInstrumentResetRequested.store(true, std::memory_order_release); + return true; + } + if (paramId == "pianoTone") + { + fallbackPianoTone.store(juce::jlimit(0.0f, 1.0f, value), std::memory_order_relaxed); + return true; + } + if (paramId == "pianoBody") + { + fallbackPianoBody.store(juce::jlimit(0.0f, 1.0f, value), std::memory_order_relaxed); + return true; + } + if (paramId == "drumKit") + { + fallbackDrumKit.store(juce::jlimit(0.0f, 2.0f, std::round(value)), std::memory_order_relaxed); + return true; + } + if (paramId == "drumTuning") + { + fallbackDrumTuning.store(juce::jlimit(-12.0f, 12.0f, value), std::memory_order_relaxed); + return true; + } + if (paramId == "drumAmbience") + { + fallbackDrumAmbience.store(juce::jlimit(0.0f, 1.0f, value), std::memory_order_relaxed); + return true; + } + + return false; +} + +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; + fallbackInstrumentPhaseB[static_cast(channelIndex)][static_cast(note)] = 0.13f; + fallbackInstrumentSubPhase[static_cast(channelIndex)][static_cast(note)] = 0.31f; + fallbackInstrumentFilterState[static_cast(channelIndex)][static_cast(note)] = 0.0f; + fallbackInstrumentEnvelope[static_cast(channelIndex)][static_cast(note)] + = fallbackInstrumentMode.load(std::memory_order_relaxed) >= 1.5f ? 1.0f : 0.0f; + fallbackInstrumentVoiceAgeSamples[static_cast(channelIndex)][static_cast(note)] = 0; + 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 int instrumentMode = juce::jlimit(0, 2, static_cast(std::round(fallbackInstrumentMode.load(std::memory_order_relaxed)))); + const bool useSampler = instrumentMode != 2 + && samplerSample != nullptr + && samplerSample->samples.getNumSamples() > 1 + && samplerSample->samples.getNumChannels() > 0; + const float attackMs = juce::jlimit(0.5f, 2000.0f, fallbackSynthAttackMs.load(std::memory_order_relaxed)); + const float releaseMs = juce::jlimit(5.0f, 5000.0f, fallbackSynthReleaseMs.load(std::memory_order_relaxed)); + const float attackStep = 1.0f / juce::jmax(1.0f, static_cast(sampleRate) * attackMs * 0.001f); + const float releaseStep = 1.0f / juce::jmax(1.0f, static_cast(sampleRate) * releaseMs * 0.001f); + const float brightness = juce::jlimit(0.0f, 1.0f, fallbackSynthBrightness.load(std::memory_order_relaxed)); + const float detuneCents = juce::jlimit(0.0f, 35.0f, fallbackSynthDetuneCents.load(std::memory_order_relaxed)); + const float detuneFactor = std::pow(2.0f, detuneCents / 1200.0f); + const float subLevel = juce::jlimit(0.0f, 0.8f, fallbackSynthSubLevel.load(std::memory_order_relaxed)); + const float noiseLevel = juce::jlimit(0.0f, 0.25f, fallbackSynthNoiseLevel.load(std::memory_order_relaxed)); + const float pianoTone = juce::jlimit(0.0f, 1.0f, fallbackPianoTone.load(std::memory_order_relaxed)); + const float pianoBody = juce::jlimit(0.0f, 1.0f, fallbackPianoBody.load(std::memory_order_relaxed)); + const int drumKit = juce::jlimit(0, 2, static_cast(std::round(fallbackDrumKit.load(std::memory_order_relaxed)))); + const float drumTuningFactor = std::pow(2.0f, juce::jlimit(-12.0f, 12.0f, fallbackDrumTuning.load(std::memory_order_relaxed)) / 12.0f); + const float drumAmbience = juce::jlimit(0.0f, 1.0f, fallbackDrumAmbience.load(std::memory_order_relaxed)); + const float synthGain = useSampler + ? 0.24f + : juce::Decibels::decibelsToGain(juce::jlimit(-36.0f, 0.0f, fallbackSynthOutputGainDb.load(std::memory_order_relaxed))); + const float twoPi = juce::MathConstants::twoPi; + + 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]; + int& voiceAge = fallbackInstrumentVoiceAgeSamples[channel][note]; + if (!isActive && envelope <= 0.0f) + continue; + + if (instrumentMode == 2) + { + const float ageSec = static_cast(voiceAge) / static_cast(sampleRate); + envelope = std::exp(-ageSec / getDrumDecaySeconds(static_cast(note))); + if (envelope < 0.0002f) + envelope = 0.0f; + } + else if (instrumentMode == 1) + { + if (isActive && !isReleasing) + envelope = juce::jmin(1.0f, envelope + juce::jmax(attackStep, 1.0f / juce::jmax(1.0f, static_cast(sampleRate) * 0.003f))); + else + envelope = juce::jmax(0.0f, envelope - releaseStep * 0.55f); + } + else 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 (instrumentMode == 2) + { + const int midiNote = static_cast(note); + const float ageSec = static_cast(voiceAge) / static_cast(sampleRate); + float& phase = fallbackInstrumentPhase[channel][note]; + const float baseFreq = getDrumBaseFrequency(midiNote) * drumTuningFactor; + const float sweep = (midiNote == 35 || midiNote == 36) ? std::exp(-ageSec / 0.035f) * 72.0f : 0.0f; + const float freq = juce::jlimit(20.0f, 8000.0f, baseFreq + sweep); + phase += freq / static_cast(sampleRate); + if (phase >= 1.0f) + phase -= std::floor(phase); + + const float noise = fastNoise(voiceAge, midiNote); + float drum = 0.0f; + if (midiNote == 35 || midiNote == 36) + { + const float body = std::sin(twoPi * phase) * std::exp(-ageSec / (drumKit == 1 ? 0.52f : 0.36f)); + const float click = noise * std::exp(-ageSec / 0.012f) * (drumKit == 2 ? 0.38f : 0.18f); + drum = body * 1.18f + click; + } + else if (midiNote == 37 || midiNote == 38 || midiNote == 40) + { + const float snap = noise * std::exp(-ageSec / (drumKit == 1 ? 0.22f : 0.16f)); + const float body = std::sin(twoPi * phase) * std::exp(-ageSec / 0.12f); + drum = snap * (drumKit == 2 ? 0.95f : 0.72f) + body * 0.34f; + } + else if (midiNote == 42 || midiNote == 44 || midiNote == 46) + { + const float metal = std::sin(twoPi * phase * 7.1f) * 0.24f + std::sin(twoPi * phase * 11.7f) * 0.18f; + drum = (noise * 0.78f + metal) * std::exp(-ageSec / getDrumDecaySeconds(midiNote)); + } + else if (midiNote == 49 || midiNote == 51 || midiNote == 52 || midiNote == 53 || midiNote == 55 || midiNote == 57 || midiNote == 59) + { + const float shimmer = std::sin(twoPi * phase * 5.3f) * 0.15f + std::sin(twoPi * phase * 9.7f) * 0.12f; + drum = (noise * 0.64f + shimmer) * std::exp(-ageSec / getDrumDecaySeconds(midiNote)); + } + else + { + const float body = std::sin(twoPi * phase) * std::exp(-ageSec / getDrumDecaySeconds(midiNote)); + drum = body * 0.9f + noise * 0.08f * std::exp(-ageSec / 0.04f); + } + + const float room = std::sin(twoPi * phase * 0.37f + static_cast(midiNote)) * drumAmbience * 0.08f * std::exp(-ageSec / 0.9f); + mixed += (drum + room) * fallbackInstrumentVelocity[channel][note] * 0.34f; + ++voiceAge; + } + else 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 int i0 = juce::jlimit(0, sourceLength - 1, index - 1); + const int i1 = juce::jlimit(0, sourceLength - 1, index); + const int i2 = juce::jlimit(0, sourceLength - 1, index + 1); + const int i3 = juce::jlimit(0, sourceLength - 1, index + 2); + sampleValue += cubicInterpolate(samplerSample->samples.getSample(sourceChannel, i0), + samplerSample->samples.getSample(sourceChannel, i1), + samplerSample->samples.getSample(sourceChannel, i2), + samplerSample->samples.getSample(sourceChannel, i3), + frac); + } + sampleValue /= static_cast(sourceChannels); + mixed += sampleValue * envelope * fallbackInstrumentVelocity[channel][note] * synthGain; + samplePosition += fallbackSamplerIncrement[channel][note] * static_cast(pitchBendFactor); + ++voiceAge; + } + else if (instrumentMode == 1) + { + const float frequency = static_cast(juce::MidiMessage::getMidiNoteInHertz(static_cast(note))) * pitchBendFactor; + const float phaseDelta = juce::jlimit(0.0f, 0.49f, frequency / static_cast(sampleRate)); + float& phase = fallbackInstrumentPhase[channel][note]; + const float ageSec = static_cast(voiceAge) / static_cast(sampleRate); + const float noteBright = juce::jlimit(0.35f, 1.35f, 0.72f + (static_cast(note) - 60.0f) * 0.008f); + const float decay = std::exp(-ageSec / (0.85f + pianoBody * 2.6f + (1.0f - noteBright) * 0.4f)); + const float hammer = fastNoise(voiceAge, static_cast(note)) * std::exp(-ageSec / 0.012f) * (0.03f + pianoTone * 0.05f); + const float fundamental = std::sin(twoPi * phase) * (0.82f + pianoBody * 0.28f); + const float partial2 = std::sin(twoPi * phase * 2.003f) * (0.24f + pianoTone * 0.20f) * std::exp(-ageSec / 1.1f); + const float partial3 = std::sin(twoPi * phase * 3.011f) * (0.13f + pianoTone * 0.15f) * std::exp(-ageSec / 0.74f); + const float partial5 = std::sin(twoPi * phase * 5.031f) * (0.04f + pianoTone * 0.08f) * std::exp(-ageSec / 0.42f); + const float piano = (fundamental + partial2 + partial3 + partial5 + hammer) * decay; + mixed += piano * envelope * fallbackInstrumentVelocity[channel][note] * synthGain * 0.92f; + + phase += phaseDelta; + if (phase >= 1.0f) + phase -= std::floor(phase); + ++voiceAge; + } + else + { + const float frequency = static_cast(juce::MidiMessage::getMidiNoteInHertz(static_cast(note))) * pitchBendFactor; + const float phaseDeltaA = juce::jlimit(0.0f, 0.49f, frequency / static_cast(sampleRate)); + const float phaseDeltaB = juce::jlimit(0.0f, 0.49f, (frequency * detuneFactor) / static_cast(sampleRate)); + const float subDelta = juce::jlimit(0.0f, 0.49f, (frequency * 0.5f) / static_cast(sampleRate)); + float& phaseA = fallbackInstrumentPhase[channel][note]; + float& phaseB = fallbackInstrumentPhaseB[channel][note]; + float& subPhase = fallbackInstrumentSubPhase[channel][note]; + float& filterState = fallbackInstrumentFilterState[channel][note]; + + const float sawA = polyBlepSaw(phaseA, phaseDeltaA); + const float sawB = polyBlepSaw(phaseB, phaseDeltaB); + const float pulseWidth = 0.48f + 0.12f * std::sin(modulationPhase + static_cast(note) * 0.07f); + const float square = polyBlepSquare(phaseA, phaseDeltaA, pulseWidth); + const float sub = polyBlepSquare(subPhase, subDelta, 0.5f); + const float air = std::sin((phaseA * 91.7f + phaseB * 53.1f + static_cast(note)) * twoPi) * noiseLevel; + + float tone = (sawA * 0.42f + sawB * 0.32f + square * (0.10f + 0.16f * brightness) + + sub * subLevel + air) / (0.92f + subLevel + noiseLevel); + const float cutoff = juce::jlimit(220.0f, 18000.0f, + 520.0f + brightness * 8600.0f + + frequency * (1.2f + brightness * 5.0f) + + envelope * 3200.0f); + const float filterAlpha = juce::jlimit(0.001f, 0.98f, + 1.0f - std::exp(-twoPi * cutoff / static_cast(sampleRate))); + filterState += filterAlpha * (tone - filterState); + tone = filterState; + + mixed += tone * envelope * fallbackInstrumentVelocity[channel][note] * synthGain; + + phaseA += phaseDeltaA; + phaseB += phaseDeltaB; + subPhase += subDelta; + if (phaseA >= 1.0f) phaseA -= std::floor(phaseA); + if (phaseB >= 1.0f) phaseB -= std::floor(phaseB); + if (subPhase >= 1.0f) subPhase -= std::floor(subPhase); + ++voiceAge; + } + } + } + + 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 +3029,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,20 +3038,68 @@ 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, @@ -2059,6 +3134,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 +3254,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 +3408,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); @@ -2134,21 +3436,46 @@ bool TrackProcessor::needsProcessing(double blockTimeSeconds, int numSamples, { // Instrument tracks must always be processed so they can respond to // live MIDI input and produce sustain / reverb tails after note-off. - if (trackType.load(std::memory_order_acquire) == TrackType::Instrument - && std::atomic_load_explicit(&realtimeInstrumentSnapshot, std::memory_order_acquire) != nullptr) - return true; + if (trackType.load(std::memory_order_acquire) == TrackType::Instrument) + { + if (std::atomic_load_explicit(&realtimeInstrumentSnapshot, std::memory_order_acquire) != nullptr) + return true; + + auto trackFXSnapshot = std::atomic_load_explicit(&realtimeTrackFXSnapshot, std::memory_order_acquire); + if (trackFXSnapshot) + { + for (const auto& plugin : *trackFXSnapshot) + { + if (isBuiltInInstrumentProcessor(plugin.get())) + return true; + } + } + + if (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 +3489,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 +3741,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..fa46c32 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,29 @@ 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; + float getFallbackInstrumentParam(const juce::String& paramId) const; + bool setFallbackInstrumentParam(const juce::String& paramId, float value); + 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 +265,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 +333,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 +344,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 +355,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 +371,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 +412,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 +537,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 +592,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 +610,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 +641,63 @@ 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> fallbackInstrumentPhaseB {}; + std::array, 16> fallbackInstrumentSubPhase {}; + std::array, 16> fallbackInstrumentFilterState {}; + 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::atomic fallbackSynthAttackMs { 8.0f }; + std::atomic fallbackSynthReleaseMs { 180.0f }; + std::atomic fallbackSynthBrightness { 0.62f }; + std::atomic fallbackSynthDetuneCents { 7.0f }; + std::atomic fallbackSynthSubLevel { 0.18f }; + std::atomic fallbackSynthNoiseLevel { 0.015f }; + std::atomic fallbackSynthOutputGainDb { -15.0f }; + std::atomic fallbackInstrumentMode { 0.0f }; // 0=synth, 1=piano, 2=drums + std::atomic fallbackPianoTone { 0.58f }; + std::atomic fallbackPianoBody { 0.72f }; + std::atomic fallbackDrumKit { 0.0f }; // 0=studio, 1=rock, 2=electronic + std::atomic fallbackDrumTuning { 0.0f }; + std::atomic fallbackDrumAmbience { 0.18f }; + std::array, 16> fallbackInstrumentVoiceAgeSamples {}; + 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..ebf989c 100644 --- a/build.py +++ b/build.py @@ -13,7 +13,7 @@ vite_process = None cpp_process = None -VITE_DEV_URL = "http://localhost:5173" +VITE_DEV_URL = "http://127.0.0.1:5173" def kill_process_tree(pid): """Kill a process and all its child processes (Windows: taskkill /T)""" @@ -116,7 +116,9 @@ def build_frontend(mode="dev"): if mode == "prod": run_command("npm run build", cwd=frontend_dir) elif mode == "dev": - print("Frontend dependencies ready.") + # Keep packaged fallback assets current even in dev. The native app normally + # loads Vite, but it can fall back to webui if WebView2 startup times out. + run_command("npm run build", cwd=frontend_dir) def get_cpp_exe_path(config="Debug"): """Return the platform-appropriate path to the built C++ executable.""" @@ -156,7 +158,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/built_in_plugin_upgrade_status.md b/built_in_plugin_upgrade_status.md new file mode 100644 index 0000000..fbe0d32 --- /dev/null +++ b/built_in_plugin_upgrade_status.md @@ -0,0 +1,154 @@ +# Built-In Plugin Upgrade Status + +This file tracks the remaining work for the Studio13 built-in plugin suite. Items marked done are implemented in the current working tree, but subjective sound quality still needs user audition before it can be called final. + +## Completed + +- [x] Add a built-in plugin schema bridge for React editors. +- [x] Add bridge methods for built-in plugin schema, state, parameter set, and state set. +- [x] Add React schema-driven built-in plugin panel inside `FXChainPanel`. +- [x] Keep external VST/CLAP/LV2 editors native while built-ins use React panels. +- [x] Fix dev frontend fallback/HMR mismatch by using `127.0.0.1:5173` consistently. +- [x] Make dev mode rebuild packaged frontend fallback assets. +- [x] Add bundled built-in instrument plugins addable from the FX/plugin chain: + - [x] `Studio13 Basic Synth` + - [x] `Studio13 Piano` + - [x] `Studio13 Drums` +- [x] Set tracks to `instrument` when a built-in instrument plugin is added. +- [x] Prevent the old fallback instrument from double-rendering when a built-in instrument FX exists. +- [x] Preserve built-in FX/instrument save/load by storing/restoring built-in plugin names. +- [x] Preserve built-in FX/instrument duplication by restoring built-ins through the built-in FX bridge. +- [x] Improve Basic Synth from a simple fallback tone to a polyphonic subtractive synth with anti-aliased oscillators, sub, noise, brightness, detune, attack, release, and output gain. +- [x] Add Basic Synth pitch-bend and mod-wheel routing. +- [x] Add a playable synthesized piano instrument with tone, body, hammer, release, and output gain controls. +- [x] Improve piano toward hybrid-modeled behavior with model flavors, sustain pedal, resonance, and stereo width. +- [x] Add a playable synthesized drum instrument with kit, tuning, room, hi-hat tightness, output gain, GM notes, and CC4 hi-hat pedal behavior for e-drums. +- [x] Improve drums toward hybrid-modeled behavior with velocity curve, punch, and stereo kit placement controls. +- [x] Improve sampler fallback interpolation from linear to cubic. +- [x] Remove selected audio-thread temporary allocations in built-in reverb and saturator paths. +- [x] Make compressor auto makeup apply adaptive makeup gain from current gain reduction. +- [x] Make compressor auto release adapt release time from current gain reduction. +- [x] Replace limiter hard-clip end stage with a preallocated lookahead gain stage and soft safety ceiling. +- [x] Add intersample-aware peak estimation to limiter detection. +- [x] Add EQ auto-gain based on the actual filter response instead of a decorative toggle. +- [x] Add compressor Peak/RMS/Auto detector modes. +- [x] Add compressor stereo-link detector control. +- [x] Add 4x oversampled true-peak detection into limiter gain reduction. +- [x] Add gate Peak/RMS/Auto detector modes. +- [x] Avoid constant gate sidechain filter coefficient rebuilds when filter values have not changed. +- [x] Smooth delay-time changes to avoid abrupt delay jumps. +- [x] Correct delay tempo-sync note mapping to match the UI labels. +- [x] Add delay ducking control. +- [x] Add tape-style delay modulation in the feedback path. +- [x] Add reverb early reflection taps independent from late-tail level. +- [x] Cache reverb wet tone filter coefficients instead of rebuilding them every block. +- [x] Replace stock reverb late-tail processing with a native 8-line feedback delay network. +- [x] Add denser reverb late-tail diffusion with algorithm-specific delay spacing. +- [x] Add reverb late-tail modulation, width shaping, freeze feedback, and shimmer-style feedback coloration. +- [x] Expose EQ magnitude response and pre/post analyzer snapshots through the built-in schema. +- [x] Add draggable EQ response graph editing in the React built-in panel. +- [x] Add EQ band audition parameter with DSP/schema/state support. +- [x] Add EQ dynamic band parameters with detector-driven gain modulation and UI visualization. +- [x] Add EQ stereo/mid/side processing mode where stereo routing supports it. +- [x] Cache EQ band parameter state to avoid rebuilding unchanged filter coefficients every audio block. +- [x] Add built-in plugin offline DSP smoke fixture to the native automated regression suite. +- [x] Add compressor sidechain HPF regression fixture comparing low-frequency gain reduction at 20 Hz vs 500 Hz HPF. +- [x] Expose dynamics gain-reduction, input/output level, and gate-open metrics through the built-in schema. +- [x] Add live dynamics gain-reduction history and meters in the React built-in panel. +- [x] Expose pitch-correct live pitch telemetry/history through the built-in schema and React panel. +- [x] Make chorus tempo sync affect LFO rate. +- [x] Add chorus/flanger/phaser character modes for Clean, Ensemble, and BBD-style modulation. +- [x] Apply chorus/flanger/phaser wet low-cut and high-cut filters. +- [x] Preallocate separate saturator 2x and 4x oversamplers and choose quality mode without audio-thread allocation. +- [x] Add saturator drive output compensation. +- [x] Add Console, Transformer, and Foldback saturator models. +- [x] Add saturator post-drive low-cut tone filter. +- [x] Remove remaining reverb audio-thread dry/early scratch resizing by preallocating larger process buffers in `prepareToPlay`. +- [x] Replace raw built-in parameter rows with plugin-aware macro controls, grouped sections, and responsive editor grids. +- [x] Add built-in latency/tail and delay smoothing regression fixtures. +- [x] Remove per-sample phaser all-pass coefficient allocation and cache dynamic EQ detector coefficients. +- [x] Add frontend Vitest coverage for built-in schema classification, primary controls, and React control rendering. +- [x] Make existing `ParametricGraph` controls theme-aware with unique clip paths and reusable graph color tokens. +- [x] Add smoothing for saturator drive, mix, and compensated output gain. +- [x] Wire the built-in EQ editor to the reusable schema-driven `ParametricGraph` adapter with response and analyzer curves. +- [x] Add responsive layout guards for every built-in panel kind across desktop, tablet, and narrow CSS contracts. + +## Remaining DSP Work + +- [x] EQ: add analyzer pre/post display. +- [x] EQ: add draggable response graph editing. +- [x] EQ: add dynamic bands. +- [x] EQ: add band audition. +- [x] EQ: add stereo/mid-side modes where routing supports it. +- [x] EQ: implement real auto-gain or remove misleading auto-gain behavior. +- [x] Compressor: add peak/RMS/auto detector modes. +- [x] Compressor: add stereo link control. +- [x] Compressor: complete working auto release and auto makeup behavior. +- [x] Compressor: add sidechain HPF behavior that is audibly and measurably effective. +- [x] Compressor: add gain-reduction history for UI. +- [x] Gate: add detector mode and sidechain filter polish. +- [x] Gate: add gain-reduction/open-close history for UI. +- [x] Limiter: add lookahead ring buffer if current path is insufficient. +- [x] Limiter: add oversampled true-peak detection/limiting. +- [x] Limiter: avoid hard-clip distortion at ceiling. +- [x] Delay: smooth delay-time changes. +- [x] Delay: add dotted/triplet tempo sync polish. +- [x] Delay: add ducking. +- [x] Delay: improve modulation, width, tone, and saturation in feedback. +- [x] Reverb: replace current wrapper-level behavior with stronger native room/hall/plate algorithms. +- [x] Reverb: add early reflections. +- [x] Reverb: add denser late tail. +- [x] Reverb: improve damping, modulation, width, freeze, and shimmer behavior. +- [x] Chorus/flanger/phaser: add interpolated delay lines. +- [x] Chorus/flanger/phaser: add ensemble/BBD character modes. +- [x] Chorus/flanger/phaser: improve tone filters, tempo sync, stereo spread, and modulation quality. +- [x] Saturator: complete oversampling quality modes without realtime allocations. +- [x] Saturator: add more modeled curves, bias/asymmetry polish, tone filters, and output compensation. +- [x] Pitch Correct: finish unified built-in UI/schema polish and deterministic routing tests. +- [x] Piano: improve from synthesized decent to sample-library-grade or hybrid modeled/sample playback. +- [x] Drums: improve from synthesized decent to sample-library-grade or hybrid modeled/sample playback. +- [x] Drums: add named e-drum mapping presets, including Roland TD-style mappings. + +## Remaining UI Work + +- [x] Replace generic schema layout with polished plugin-specific React editors for each built-in. +- [x] Add professional DAW-style graph controls for EQ, dynamics, delay, reverb, modulation, and saturation. +- [x] Refactor existing `ParametricGraph` controls into fully schema-driven graph adapters. +- [x] Add meters/history visualizations for dynamics, limiter, and gate. +- [x] Add analyzer visualization for EQ. +- [x] Add instrument-specific visual polish for synth, piano, and drums. +- [x] Check responsive layouts at desktop and narrow widths for every built-in panel. + +## Remaining Realtime Safety Work + +- [x] Audit all built-ins for audio-thread heap allocations. +- [x] Preallocate analyzer, delay, oversampling, scratch, and tail buffers in `prepareToPlay`. +- [x] Add parameter smoothing where zipper noise can occur. +- [x] Add denormal, NaN, and bounded-output guards across all built-ins. +- [x] Avoid expensive coefficient rebuilds inside audio callbacks where possible. + +## Remaining Tests + +- [x] Add offline C++ DSP harnesses for built-ins. +- [x] Test bypass parity. +- [x] Test finite output and no NaN/Inf. +- [x] Test bounded gain. +- [x] Test latency and tail behavior. +- [x] Test parameter smoothing and zipper-spike avoidance. +- [x] Test EQ curves. +- [x] Test compressor, gate, and limiter gain behavior. +- [x] Test limiter true-peak handling. +- [x] Test delay tempo timing. +- [x] Test reverb tail decay. +- [x] Test synth tuning, MIDI note handling, voice stealing, pitch bend, and mod behavior. +- [x] Test piano MIDI behavior and voice cleanup. +- [x] Test drum GM/Roland-style note mapping and CC4 hi-hat behavior. +- [x] Add frontend tests for built-in schema mapping and React controls. +- [x] Verify all new `useDAWStore` consumers use `useShallow`. + +## Acceptance Status + +- Objective build/type verification: pass as of the latest implementation pass. +- Objective routing/schema behavior: pass for the implemented built-in instrument/React bridge foundation. +- Subjective audio quality: not_asserted until user auditions exact artifacts in the app. +- Industry-standard parity for the full built-in suite: implementation pass complete, subjective audition pending. diff --git a/docs/cubase-grid-quantize-parity-plan.md b/docs/cubase-grid-quantize-parity-plan.md new file mode 100644 index 0000000..8191090 --- /dev/null +++ b/docs/cubase-grid-quantize-parity-plan.md @@ -0,0 +1,34 @@ +# Cubase-Style Global Grid, Snap, and MIDI Quantize Parity + +## Summary +Implement Cubase-style parity for the global grid, snap behavior, MIDI editor snapping, and MIDI quantize workflow. This pass excludes audio warp/hitpoint quantize, groove extraction from audio, and backend DSP changes. + +## Already Completed +- [x] Reviewed Steinberg Cubase 15 docs for Quantize Panel, Quantize Presets, Grid Type, Snap Grid, Snap Types, Key Editor Toolbar, and MIDI snap behavior. +- [x] Audited Studio13's current global snap, timeline grid, piano roll snap, and MIDI quantize implementation. + +## Tracking Checklist +- [x] Create `docs/cubase-grid-quantize-parity-plan.md` with this checklist. +- [x] Add shared grid/quantize preset model and interval resolver. +- [x] Persist new snap/grid/quantize state in store and project save/load. +- [x] Add header toolbar visual controls. +- [x] Update View menu and Preferences grid controls. +- [x] Refactor timeline snapping and grid rendering to use shared resolver. +- [x] Refactor piano roll snapping and grid rendering to use shared resolver. +- [x] Implement MIDI Ctrl snap-bypass and resolve duplicate-drag conflict. +- [x] Replace MIDI Quantize dialog with Cubase-style Quantize Panel. +- [x] Add Length Quantize and Quantize Link behavior for MIDI editor/step input. +- [x] Implement custom quantize preset save/rename/remove/restore factory. +- [x] Add tests and mark checklist items done as each passes. + +## Implemented Visual Changes +- [x] Main header toolbar now shows Snap, Snap Type, Grid Type, Quantize Preset, Apply Quantize, and Quantize Panel controls. +- [x] Piano roll toolbar now mirrors the Cubase-style Snap, Snap Type, Grid Type, Quantize Preset, Apply, Quantize Panel, and Length Quantize controls. +- [x] Piano roll status strip displays the active resolved grid label instead of the old fixed `0.25 beat` snap readout. +- [x] MIDI Quantize dialog is now a Quantize Panel-style grid with Preset, Mode, Grid Type, Soft Quantize, Tuplet, Swing, Groove, Catch Range, Safe Range, Rough Quantize, Length Quantize, Move Controllers, Auto Apply, Reset, and Apply controls. + +## Test Plan +- [x] Unit-test straight, triplet, dotted, Bar/Beat, Use Quantize, and Adapt to Zoom interval resolution. +- [x] Unit-test Snap Type behavior for grid, relative grid, cursor, event, and combined snap candidates. +- [ ] Add MIDI editor interaction coverage for Snap on, Ctrl-drag off-grid placement, draw/resize/split using selected grid, quantize using current preset, and Length Quantize. +- [x] Run `npx tsc --noEmit` and targeted frontend tests; note known pre-existing TypeScript errors separately if still present. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b49b8e8..7f5fc2e 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. @@ -826,6 +1139,10 @@ function App() { } if (mediaFiles.length === 0) return; + if (nativeBridge.usesNativeExternalMediaDrop()) { + console.warn("[App] Ignoring HTML5 file drop in native mode; native path-based timeline drop handles external media."); + return; + } console.log(`[App] ${mediaFiles.length} media file(s) dropped from OS`); for (const file of mediaFiles) { @@ -852,7 +1169,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 +1283,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 +1592,63 @@ function App() { )} + {showPianoRoll && dockedMidiEditorSession && dockedPianoRollTrackId && dockedPianoRollClipId && ( +
+
+ +
+
+

{dockedMidiEditorTitle}

+
+ + +
+
+
+ Loading...
}> + + + +
+ )} + {/* Transport Bar (above Mixer like Reaper) */} @@ -1286,48 +1663,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 +2004,7 @@ function App() { {/* Project Loading Overlay */} {isProjectLoading && ( -
+

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

+
@@ -1773,7 +2110,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__/automationCubaseReadWrite.test.ts b/frontend/src/__tests__/automationCubaseReadWrite.test.ts new file mode 100644 index 0000000..bb9ca26 --- /dev/null +++ b/frontend/src/__tests__/automationCubaseReadWrite.test.ts @@ -0,0 +1,531 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createDefaultTrack, type AutomationLane, type Track, useDAWStore } from "../store/useDAWStore"; +import { commandManager } from "../store/commands"; +import { + _autoRecordTimers, + _automationLatchedParams, + _automationTouchedParams, + _automationWriteValues, + automationTouchKey, +} from "../store/actions/storeHelpers"; + +const initialState = useDAWStore.getState(); + +function makeTrack(overrides: Partial = {}): Track { + const base = createDefaultTrack("track-auto", "Track Auto", "#14b8a6", "audio", []); + return { + ...base, + ...overrides, + }; +} + +function volumeLane(overrides: Partial = {}): AutomationLane { + return { + id: "vol", + param: "volume", + points: [], + visible: true, + mode: "read", + armed: false, + readEnabled: true, + ...overrides, + }; +} + +function loadTrack(track: Track, isPlaying = false, currentTime = 1) { + useDAWStore.setState({ + tracks: [track], + transport: { + ...useDAWStore.getState().transport, + isPlaying, + currentTime, + }, + automatedParamValues: {}, + }); +} + +afterEach(() => { + useDAWStore.getState().endAutomationWriteSession?.(); + _automationTouchedParams.clear(); + _automationLatchedParams.clear(); + _automationWriteValues.clear(); + _autoRecordTimers.clear(); + commandManager.clear(); + useDAWStore.setState(initialState); +}); + +describe("Cubase-style automation read/write state", () => { + it("new tracks start without readable automation", () => { + const track = makeTrack(); + + expect(track.automationLanes).toHaveLength(0); + expect(track.automationReadEnabled).toBe(false); + expect(track.automationWriteEnabled).toBe(false); + }); + + it("adding a track does not run mute automation write capture", () => { + useDAWStore.setState({ + tracks: [], + canUndo: false, + canRedo: false, + }); + + expect(() => { + useDAWStore.getState().addTrack({ + id: "track-added", + name: "Added", + type: "audio", + }); + }).not.toThrow(); + + expect(useDAWStore.getState().tracks.map((track) => track.id)).toContain("track-added"); + expect(useDAWStore.getState().canUndo).toBe(true); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks.map((track) => track.id)).not.toContain("track-added"); + }); + + it("enabling write also enables read", () => { + const track = makeTrack({ + automationReadEnabled: false, + automationWriteEnabled: false, + automationEnabled: false, + automationLanes: [volumeLane({ mode: "off" })], + }); + loadTrack(track); + + useDAWStore.getState().setTrackAutomationWrite(track.id, true); + + const updated = useDAWStore.getState().tracks[0]; + expect(updated.automationReadEnabled).toBe(true); + expect(updated.automationWriteEnabled).toBe(true); + expect(updated.automationLanes[0].mode).toBe("touch"); + }); + + it("toggles read only on empty tracks while write stays armed", () => { + const track = makeTrack({ + automationReadEnabled: false, + automationWriteEnabled: false, + automationEnabled: false, + automationLanes: [], + }); + loadTrack(track); + + useDAWStore.getState().setTrackAutomationWrite(track.id, true); + useDAWStore.getState().toggleTrackAutomationRead(track.id); + + const updated = useDAWStore.getState().tracks[0]; + expect(updated.automationReadEnabled).toBe(false); + expect(updated.automationWriteEnabled).toBe(true); + }); + + it("toggles master read only with no master automation lanes while write stays armed", () => { + useDAWStore.setState({ + masterAutomationLanes: [], + masterAutomationReadEnabled: false, + masterAutomationWriteEnabled: false, + masterAutomationEnabled: false, + }); + + useDAWStore.getState().setMasterAutomationWrite(true); + useDAWStore.getState().toggleMasterAutomationRead(); + + expect(useDAWStore.getState().masterAutomationReadEnabled).toBe(false); + expect(useDAWStore.getState().masterAutomationWriteEnabled).toBe(true); + }); + + it("disabling read keeps lanes and points visible but resolves backend mode off", () => { + const track = makeTrack({ + showAutomation: true, + automationReadEnabled: true, + automationWriteEnabled: true, + automationLanes: [volumeLane({ points: [{ time: 0.5, value: 0.8 }], mode: "touch" })], + }); + loadTrack(track); + + useDAWStore.getState().setTrackAutomationRead(track.id, false); + + const updated = useDAWStore.getState().tracks[0]; + expect(updated.showAutomation).toBe(true); + expect(updated.automationWriteEnabled).toBe(true); + expect(updated.automationLanes[0].visible).toBe(true); + expect(updated.automationLanes[0].points).toHaveLength(1); + expect(updated.automationLanes[0].mode).toBe("off"); + }); + + it("write can capture while track read is off without re-enabling read", () => { + const track = makeTrack({ + automationReadEnabled: false, + automationWriteEnabled: true, + automationEnabled: false, + automationLanes: [], + }); + loadTrack(track, true, 2); + + useDAWStore.getState().beginAutomationParamTouch(track.id, "volume"); + useDAWStore.getState().setAutomationWriteValue(track.id, "volume", 0.75); + useDAWStore.getState().recordAutomationWriteTick(Date.now() + 1000); + + const updated = useDAWStore.getState().tracks[0]; + expect(updated.automationReadEnabled).toBe(false); + expect(updated.automationWriteEnabled).toBe(true); + expect(updated.automationLanes).toHaveLength(1); + expect(updated.automationLanes[0].readEnabled).toBe(true); + expect(updated.automationLanes[0].mode).toBe("off"); + expect(updated.automationLanes[0].points).toEqual([{ time: 2, value: 0.75 }]); + }); + + it("write enabled with no touched parameter writes no points", () => { + const track = makeTrack({ + automationReadEnabled: true, + automationEnabled: true, + automationLanes: [volumeLane()], + }); + loadTrack(track, true, 2); + + useDAWStore.getState().setTrackAutomationWrite(track.id, true); + useDAWStore.getState().recordAutomationWriteTick(Date.now() + 1000); + + const updated = useDAWStore.getState().tracks[0]; + expect(updated.automationLanes.find((lane) => lane.param === "volume")?.points).toHaveLength(0); + }); + + it("touching a parameter while stopped does not start a write session or create lanes", () => { + const track = makeTrack({ + automationLanes: [], + showAutomation: false, + }); + loadTrack(track, false, 3); + + useDAWStore.getState().setTrackAutomationWrite(track.id, true); + useDAWStore.getState().beginAutomationParamTouch(track.id, "volume"); + useDAWStore.getState().setAutomationWriteValue(track.id, "volume", 0.75); + useDAWStore.getState().recordAutomationWriteTick(Date.now() + 1000); + + const key = automationTouchKey(track.id, "volume"); + const updated = useDAWStore.getState().tracks[0]; + expect(updated.automationWriteEnabled).toBe(true); + expect(updated.automationLanes).toHaveLength(0); + expect(_automationTouchedParams.has(key)).toBe(false); + expect(_automationLatchedParams.has(key)).toBe(false); + expect(_automationWriteValues.has(key)).toBe(false); + }); + + it("touching an existing lane while stopped leaves write armed but inactive", () => { + const track = makeTrack({ + automationReadEnabled: true, + automationWriteEnabled: false, + automationEnabled: true, + automationLanes: [volumeLane()], + }); + loadTrack(track, false, 3); + + useDAWStore.getState().setAutomationWriteBehavior("latch"); + useDAWStore.getState().setTrackAutomationWrite(track.id, true); + useDAWStore.getState().beginAutomationParamTouch(track.id, "volume"); + useDAWStore.getState().setAutomationWriteValue(track.id, "volume", 0.75); + useDAWStore.getState().recordAutomationWriteTick(Date.now() + 1000); + + const key = automationTouchKey(track.id, "volume"); + const updated = useDAWStore.getState().tracks[0]; + expect(updated.automationWriteEnabled).toBe(true); + expect(updated.automationLanes[0].mode).toBe("latch"); + expect(updated.automationLanes[0].points).toHaveLength(0); + expect(_automationTouchedParams.has(key)).toBe(false); + expect(_automationLatchedParams.has(key)).toBe(false); + }); + + it("touching a parameter with write enabled creates and writes a revealed lane", () => { + const track = makeTrack({ + automationLanes: [], + showAutomation: false, + }); + loadTrack(track, true, 3); + + useDAWStore.getState().setTrackAutomationWrite(track.id, true); + useDAWStore.getState().beginAutomationParamTouch(track.id, "volume"); + useDAWStore.getState().setAutomationWriteValue(track.id, "volume", 0.75); + useDAWStore.getState().recordAutomationWriteTick(Date.now() + 1000); + useDAWStore.getState().endAutomationParamTouch(track.id, "volume"); + + const updated = useDAWStore.getState().tracks[0]; + const lane = updated.automationLanes.find((candidate) => candidate.param === "volume"); + expect(updated.showAutomation).toBe(true); + expect(lane?.visible).toBe(true); + expect(lane?.readEnabled).toBe(true); + expect(lane?.points).toEqual([{ time: 3, value: 0.75 }]); + }); + + it("continuous touch writing simplifies simple ramps into sparse points", () => { + const track = makeTrack({ + automationReadEnabled: true, + automationEnabled: true, + automationLanes: [volumeLane()], + }); + loadTrack(track, true, 0); + + useDAWStore.getState().setTrackAutomationWrite(track.id, true); + useDAWStore.getState().beginAutomationParamTouch(track.id, "volume"); + + for (let i = 0; i <= 40; i += 1) { + useDAWStore.setState((state) => ({ + transport: { + ...state.transport, + isPlaying: true, + currentTime: i * 0.05, + }, + })); + useDAWStore.getState().setAutomationWriteValue(track.id, "volume", i / 40); + useDAWStore.getState().recordAutomationWriteTick(1000 + i * 60); + } + + const points = useDAWStore.getState().tracks[0].automationLanes[0].points; + expect(points.length).toBeLessThanOrEqual(6); + expect(points[0]).toEqual({ time: 0, value: 0 }); + expect(points[points.length - 1].time).toBeCloseTo(2); + expect(points[points.length - 1].value).toBeCloseTo(1); + }); + + it("global write behavior maps touch, latch, and overwrite to backend lane modes", () => { + const track = makeTrack({ + automationReadEnabled: true, + automationEnabled: true, + automationLanes: [volumeLane()], + }); + loadTrack(track); + + useDAWStore.getState().setTrackAutomationWrite(track.id, true); + expect(useDAWStore.getState().tracks[0].automationLanes[0].mode).toBe("touch"); + + useDAWStore.getState().setAutomationWriteBehavior("latch"); + expect(useDAWStore.getState().tracks[0].automationLanes[0].mode).toBe("latch"); + + useDAWStore.getState().setAutomationWriteBehavior("overwrite"); + expect(useDAWStore.getState().tracks[0].automationLanes[0].mode).toBe("read"); + + loadTrack(useDAWStore.getState().tracks[0], true, 1); + useDAWStore.getState().beginAutomationParamTouch(track.id, "volume"); + expect(useDAWStore.getState().tracks[0].automationLanes[0].mode).toBe("write"); + }); + + it("manual point add enables read and stays undoable", () => { + const track = makeTrack({ + automationReadEnabled: false, + automationWriteEnabled: false, + automationEnabled: false, + automationLanes: [volumeLane({ mode: "off", readEnabled: false })], + }); + loadTrack(track); + + useDAWStore.getState().addAutomationPoint(track.id, "vol", 1.25, 0.5); + + let updated = useDAWStore.getState().tracks[0]; + expect(updated.automationReadEnabled).toBe(true); + expect(updated.automationLanes[0].readEnabled).toBe(true); + expect(updated.automationLanes[0].points).toEqual([{ time: 1.25, value: 0.5 }]); + + useDAWStore.getState().undo(); + + updated = useDAWStore.getState().tracks[0]; + expect(updated.automationReadEnabled).toBe(false); + expect(updated.automationLanes[0].readEnabled).toBe(false); + expect(updated.automationLanes[0].points).toHaveLength(0); + }); + + it("manual point add punches out active write capture for that lane only", () => { + const track = makeTrack({ + automationReadEnabled: true, + automationWriteEnabled: true, + automationEnabled: true, + automationLanes: [volumeLane({ mode: "latch" })], + }); + loadTrack(track, true, 4); + + useDAWStore.getState().setAutomationWriteBehavior("latch"); + useDAWStore.getState().beginAutomationParamTouch(track.id, "volume"); + useDAWStore.getState().setAutomationWriteValue(track.id, "volume", 0.9); + + const key = automationTouchKey(track.id, "volume"); + expect(_automationTouchedParams.has(key)).toBe(true); + expect(_automationLatchedParams.has(key)).toBe(true); + + useDAWStore.getState().addAutomationPoint(track.id, "vol", 1.25, 0.5); + useDAWStore.getState().recordAutomationWriteTick(Date.now() + 1000); + + const updated = useDAWStore.getState().tracks[0]; + expect(updated.automationWriteEnabled).toBe(true); + expect(updated.automationLanes[0].points).toEqual([{ time: 1.25, value: 0.5 }]); + expect(_automationTouchedParams.has(key)).toBe(false); + expect(_automationLatchedParams.has(key)).toBe(false); + expect(_automationWriteValues.has(key)).toBe(false); + }); + + it("manual lane drawing works while stopped even when write is armed", () => { + const track = makeTrack({ + showAutomation: true, + automationReadEnabled: true, + automationWriteEnabled: true, + automationEnabled: true, + automationLanes: [volumeLane({ mode: "touch" })], + }); + loadTrack(track, false, 2); + + useDAWStore.getState().setAutomationLanePoints(track.id, "vol", [ + { time: 1, value: 0.25 }, + { time: 1.5, value: 0.75 }, + ], { + undoable: true, + oldPoints: [], + oldTrackRead: true, + oldTrackWrite: true, + oldLaneRead: true, + oldLaneMode: "touch", + }); + + let updated = useDAWStore.getState().tracks[0]; + expect(updated.automationWriteEnabled).toBe(true); + expect(updated.automationLanes[0].readEnabled).toBe(true); + expect(updated.automationLanes[0].points).toEqual([ + { time: 1, value: 0.25 }, + { time: 1.5, value: 0.75 }, + ]); + + useDAWStore.getState().undo(); + updated = useDAWStore.getState().tracks[0]; + expect(updated.automationWriteEnabled).toBe(true); + expect(updated.automationLanes[0].points).toHaveLength(0); + }); + + it("toggle mute writes mute automation while playing and track write is armed", async () => { + const track = makeTrack({ + automationReadEnabled: true, + automationWriteEnabled: true, + automationEnabled: true, + automationLanes: [], + }); + loadTrack(track, true, 5); + + await useDAWStore.getState().toggleTrackMute(track.id); + + const updated = useDAWStore.getState().tracks[0]; + const muteLane = updated.automationLanes.find((lane) => lane.param === "mute"); + expect(updated.muted).toBe(true); + expect(muteLane?.points).toEqual([{ time: 5, value: 1 }]); + }); + + it("toggle mute does not create write data while stopped", async () => { + const track = makeTrack({ + automationReadEnabled: false, + automationWriteEnabled: true, + automationEnabled: false, + automationLanes: [], + }); + loadTrack(track, false, 5); + + await useDAWStore.getState().toggleTrackMute(track.id); + + const updated = useDAWStore.getState().tracks[0]; + expect(updated.muted).toBe(true); + expect(updated.automationLanes).toHaveLength(0); + }); + + it("master volume write records a master automation point", async () => { + useDAWStore.setState({ + masterVolume: 1, + masterPan: 0, + masterAutomationLanes: [], + masterAutomationReadEnabled: false, + masterAutomationWriteEnabled: false, + masterAutomationEnabled: false, + transport: { + ...useDAWStore.getState().transport, + isPlaying: true, + currentTime: 6, + }, + }); + + useDAWStore.getState().setMasterAutomationWrite(true); + useDAWStore.getState().beginAutomationParamTouch("master", "volume"); + await useDAWStore.getState().setMasterVolume(0.5); + useDAWStore.getState().recordAutomationWriteTick(1000); + useDAWStore.getState().endAutomationParamTouch("master", "volume"); + + const lane = useDAWStore.getState().masterAutomationLanes.find((candidate) => candidate.param === "volume"); + expect(lane?.points[0].time).toBe(6); + expect(lane?.points[0].value).toBeCloseTo((20 * Math.log10(0.5) + 60) / 72, 6); + }); + + it("master pan write records normalized pan automation", async () => { + useDAWStore.setState({ + masterPan: 0, + masterAutomationLanes: [], + masterAutomationReadEnabled: false, + masterAutomationWriteEnabled: false, + masterAutomationEnabled: false, + transport: { + ...useDAWStore.getState().transport, + isPlaying: true, + currentTime: 7, + }, + }); + + useDAWStore.getState().setMasterAutomationWrite(true); + useDAWStore.getState().beginAutomationParamTouch("master", "pan"); + await useDAWStore.getState().setMasterPan(0.25); + useDAWStore.getState().recordAutomationWriteTick(1000); + useDAWStore.getState().endAutomationParamTouch("master", "pan"); + + const lane = useDAWStore.getState().masterAutomationLanes.find((candidate) => candidate.param === "pan"); + expect(lane?.points).toEqual([{ time: 7, value: 0.625 }]); + }); + + it("master write while stopped does not create automation lanes", async () => { + useDAWStore.setState({ + masterAutomationLanes: [], + masterAutomationReadEnabled: false, + masterAutomationWriteEnabled: false, + masterAutomationEnabled: false, + transport: { + ...useDAWStore.getState().transport, + isPlaying: false, + currentTime: 8, + }, + }); + + useDAWStore.getState().setMasterAutomationWrite(true); + useDAWStore.getState().beginAutomationParamTouch("master", "volume"); + await useDAWStore.getState().setMasterVolume(0.5); + useDAWStore.getState().recordAutomationWriteTick(1000); + + expect(useDAWStore.getState().masterAutomationLanes).toHaveLength(0); + }); + + it("master read-off write-on captures points while resolving lane mode off", async () => { + useDAWStore.setState({ + masterVolume: 1, + masterAutomationLanes: [], + masterAutomationReadEnabled: false, + masterAutomationWriteEnabled: false, + masterAutomationEnabled: false, + transport: { + ...useDAWStore.getState().transport, + isPlaying: true, + currentTime: 9, + }, + }); + + useDAWStore.getState().setMasterAutomationWrite(true); + useDAWStore.getState().toggleMasterAutomationRead(); + useDAWStore.getState().beginAutomationParamTouch("master", "volume"); + await useDAWStore.getState().setMasterVolume(1); + useDAWStore.getState().recordAutomationWriteTick(1000); + useDAWStore.getState().endAutomationParamTouch("master", "volume"); + + const state = useDAWStore.getState(); + const lane = state.masterAutomationLanes.find((candidate) => candidate.param === "volume"); + expect(state.masterAutomationReadEnabled).toBe(false); + expect(state.masterAutomationWriteEnabled).toBe(true); + expect(lane?.mode).toBe("off"); + expect(lane?.points).toEqual([{ time: 9, value: 60 / 72 }]); + }); +}); 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__/builtInPluginPanel.test.tsx b/frontend/src/__tests__/builtInPluginPanel.test.tsx new file mode 100644 index 0000000..de5dbc6 --- /dev/null +++ b/frontend/src/__tests__/builtInPluginPanel.test.tsx @@ -0,0 +1,243 @@ +import { renderToStaticMarkup } from "react-dom/server"; +// @ts-expect-error The app tsconfig does not include Node builtin typings, but Vitest runs this file in Node. +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + BuiltInPluginPanel, + BuiltInParamControl, + formatParamValue, + getPluginKind, + groupLabel, + groupSortWeight, + primaryParamIdsForKind, + stepForParam, +} from "../components/BuiltInPluginPanel"; +import type { BuiltInParamDescriptor, BuiltInPluginSchema } from "../services/NativeBridge"; + +function param(overrides: Partial): BuiltInParamDescriptor { + return { + id: "value", + label: "Value", + type: "continuous", + value: 0.5, + min: 0, + max: 1, + defaultValue: 0, + automatable: true, + ...overrides, + }; +} + +function schema(name: string, category: string): BuiltInPluginSchema { + return { + schemaVersion: 1, + name, + category, + chain: "track", + fxIndex: 0, + parameters: [], + }; +} + +function schemaWithParams(name: string, category: string, params: BuiltInParamDescriptor[]): BuiltInPluginSchema { + return { + ...schema(name, category), + parameters: params, + visualization: { + frequencies: [20, 100, 1000, 10000, 20000], + responseDb: [0, 1, -2, 0.5, 0], + spectrumPreDb: [-70, -45, -30, -55, -78], + spectrumPostDb: [-76, -48, -28, -58, -82], + spectrumReady: true, + dynamicGainDb: [0, -1, 0.5, 0, 0, 0, 0, 0], + gainReductionDb: -6, + inputLevelDb: -18, + outputLevelDb: -24, + gateOpen: true, + historyDetectedMidi: [58, 59, 60, 61], + historyCorrectedMidi: [60, 60, 60, 60], + historyConfidence: [0.7, 0.8, 0.9], + }, + }; +} + +function continuous(id: string, label: string, value: number, min = 0, max = 1, graphRole = "controls", unit = "") { + return param({ id, label, value, min, max, graphRole, unit }); +} + +function toggle(id: string, label: string, value: number, graphRole = "controls") { + return param({ id, label, type: "toggle", value, min: 0, max: 1, graphRole }); +} + +function choice(id: string, label: string, value: number, labels: string[], graphRole = "controls") { + return param({ + id, + label, + type: "enum", + value, + min: 0, + max: labels.length - 1, + graphRole, + enumOptions: labels.map((optionLabel, index) => ({ value: index, label: optionLabel })), + }); +} + +function eqSchema() { + const params: BuiltInParamDescriptor[] = []; + for (let band = 0; band < 8; band += 1) { + params.push(toggle(`band${band}.enabled`, `Band ${band + 1} On`, band === 0 ? 0 : 1, "eqBand")); + params.push(continuous(`band${band}.freq`, `Band ${band + 1} Freq`, 100 * (band + 1), 20, 20000, "eqBand", "Hz")); + params.push(continuous(`band${band}.gain`, `Band ${band + 1} Gain`, band === 2 ? 4 : 0, -30, 30, "eqBand", "dB")); + params.push(continuous(`band${band}.q`, `Band ${band + 1} Q`, 1, 0.1, 30, "eqBand")); + } + params.push(continuous("outputGain", "Output", 0, -12, 12, "output", "dB")); + params.push(toggle("autoGain", "Auto Gain", 1, "output")); + params.push(choice("stereoMode", "Processing", 0, ["Stereo", "Mid", "Side"], "routing")); + params.push(choice("auditionBand", "Audition", 0, ["Off", "Band 1"], "eqBand")); + return schemaWithParams("S13 EQ", "EQ", params); +} + +const panelSchemas = [ + eqSchema(), + schemaWithParams("S13 Compressor", "Dynamics", [ + continuous("threshold", "Threshold", -18, -60, 0, "dynamics", "dB"), + continuous("ratio", "Ratio", 4, 1, 20, "dynamics"), + continuous("attack", "Attack", 12, 0.1, 100, "dynamics", "ms"), + continuous("release", "Release", 160, 10, 2000, "dynamics", "ms"), + toggle("autoMakeup", "Auto Makeup", 1, "output"), + ]), + schemaWithParams("S13 Delay", "Delay", [ + continuous("delayTimeL", "Delay L", 250, 1, 2000, "time", "ms"), + continuous("delayTimeR", "Delay R", 375, 1, 2000, "time", "ms"), + continuous("feedback", "Feedback", 0.42, 0, 0.95, "feedback"), + continuous("mix", "Mix", 0.5, 0, 1, "mix"), + continuous("ducking", "Ducking", 0.2, 0, 1, "dynamics"), + ]), + schemaWithParams("S13 Reverb", "Reverb", [ + choice("algorithm", "Algorithm", 1, ["Room", "Hall", "Plate"], "space"), + continuous("roomSize", "Size", 0.6, 0, 1, "space"), + continuous("decayTime", "Decay", 2.2, 0.1, 20, "space", "s"), + continuous("wetLevel", "Wet", 0.33, 0, 1, "mix"), + continuous("dryLevel", "Dry", 0.7, 0, 1, "mix"), + ]), + schemaWithParams("S13 Chorus", "Modulation", [ + choice("mode", "Mode", 0, ["Chorus", "Flanger", "Phaser"], "modulation"), + continuous("rate", "Rate", 1, 0.01, 20, "modulation", "Hz"), + continuous("depth", "Depth", 0.5, 0, 1, "modulation"), + continuous("mix", "Mix", 0.5, 0, 1, "mix"), + choice("characterMode", "Character", 1, ["Clean", "Ensemble", "BBD"], "character"), + ]), + schemaWithParams("S13 Saturator", "Saturation", [ + choice("satType", "Type", 1, ["Tape", "Tube", "Console"], "character"), + continuous("drive", "Drive", 6, 0, 30, "drive", "dB"), + continuous("mix", "Mix", 1, 0, 1, "mix"), + continuous("outputGain", "Output", -3, -12, 0, "output", "dB"), + choice("oversampleMode", "Oversampling", 2, ["Off", "2x", "4x"], "quality"), + ]), + schemaWithParams("S13 Pitch Correct", "Pitch", [ + choice("key", "Key", 0, ["C", "C#"], "scale"), + choice("scale", "Scale", 1, ["Chromatic", "Major"], "scale"), + continuous("retuneSpeed", "Retune", 50, 0, 400, "correction", "ms"), + continuous("correctionStrength", "Strength", 0.8, 0, 1, "correction"), + continuous("mix", "Mix", 1, 0, 1, "mix"), + ]), + schemaWithParams("OpenStudio Basic Synth", "Instrument", [ + continuous("brightness", "Brightness", 0.62, 0, 1, "tone"), + continuous("detuneCents", "Detune", 7, 0, 35, "oscillator", "ct"), + continuous("subLevel", "Sub", 0.18, 0, 0.8, "oscillator"), + continuous("noiseLevel", "Air", 0.015, 0, 0.25, "oscillator"), + continuous("outputGain", "Output", -15, -36, 0, "output", "dB"), + ]), + schemaWithParams("OpenStudio Piano", "Instrument", [ + choice("model", "Model", 0, ["Studio Grand", "Felt"], "character"), + continuous("tone", "Tone", 0.58, 0, 1, "tone"), + continuous("body", "Body", 0.72, 0, 1, "body"), + continuous("resonance", "Resonance", 0.38, 0, 1, "body"), + continuous("outputGain", "Output", -15, -36, 0, "output", "dB"), + ]), + schemaWithParams("OpenStudio Drums", "Instrument", [ + choice("kit", "Kit", 0, ["Studio", "Rock"], "drums"), + choice("mapPreset", "MIDI Map", 1, ["GM", "Roland TD"], "drums"), + continuous("punch", "Punch", 0.55, 0, 1, "character"), + continuous("ambience", "Room", 0.18, 0, 1, "space"), + continuous("outputGain", "Output", -10, -36, 0, "output", "dB"), + ]), +]; + +describe("BuiltInPluginPanel schema model", () => { + it("classifies built-in plugin schemas and selects primary controls", () => { + const drums = schema("OpenStudio Drums", "Instrument"); + const reverb = schema("S13 Reverb", "Reverb"); + const limiter = schema("S13 Limiter", "Dynamics"); + + expect(getPluginKind(drums)).toBe("drums"); + expect(getPluginKind(reverb)).toBe("reverb"); + expect(getPluginKind(limiter)).toBe("dynamics"); + expect(primaryParamIdsForKind("drums", drums)).toEqual(["kit", "mapPreset", "punch", "ambience", "outputGain"]); + expect(primaryParamIdsForKind("reverb", reverb)).toContain("decayTime"); + expect(primaryParamIdsForKind("dynamics", limiter)).toEqual(["threshold", "ceiling", "lookaheadMs", "releaseMs"]); + }); + + it("keeps grouped controls in plugin-specific order", () => { + expect(groupLabel("eqBand")).toBe("Bands"); + expect(groupSortWeight("eq", "eqBand")).toBeLessThan(groupSortWeight("eq", "output")); + expect(groupSortWeight("saturation", "drive")).toBeLessThan(groupSortWeight("saturation", "quality")); + }); + + it("formats and renders continuous, enum, and toggle controls", () => { + const continuous = param({ label: "Drive", value: 6, min: 0, max: 30, unit: "dB" }); + const enumParam = param({ + id: "satType", + label: "Type", + type: "enum", + value: 1, + min: 0, + max: 2, + enumOptions: [ + { value: 0, label: "Tape" }, + { value: 1, label: "Tube" }, + ], + }); + const toggle = param({ id: "autoGain", label: "Auto Gain", type: "toggle", value: 1 }); + + expect(formatParamValue(continuous)).toBe("6.0 dB"); + expect(formatParamValue(enumParam)).toBe("Tube"); + expect(formatParamValue(toggle)).toBe("On"); + expect(stepForParam(continuous)).toBeGreaterThan(0); + + expect(renderToStaticMarkup( undefined} />)).toContain('type="range"'); + expect(renderToStaticMarkup( undefined} />)).toContain(" undefined} />)).toContain('aria-pressed="true"'); + }); + + it("renders every built-in panel kind with visual, macro, and grouped responsive containers", () => { + for (const panelSchema of panelSchemas) { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain(`data-kind="${getPluginKind(panelSchema)}"`); + expect(html).toContain("builtin-visual"); + expect(html).toContain("builtin-macro-strip"); + expect(html).toContain("builtin-param-groups"); + expect(html).toContain("builtin-control"); + expect(html).not.toContain("builtin-param-row"); + } + }); + + it("keeps responsive CSS contracts for desktop, tablet, and narrow plugin panels", () => { + const css = readFileSync(new URL("../components/FXChainPanel.css", import.meta.url), "utf8"); + + expect(css).toContain("grid-template-columns: 400px 1fr"); + expect(css).toContain("grid-template-columns: repeat(auto-fit, minmax(164px, 1fr))"); + expect(css).toContain("@media (max-width: 900px)"); + expect(css).toContain("grid-template-columns: 1fr"); + expect(css).toContain("grid-template-rows: minmax(260px, 44vh) minmax(0, 1fr)"); + expect(css).toContain("@media (max-width: 520px)"); + expect(css).toContain("height: 112px"); + }); +}); diff --git a/frontend/src/__tests__/channelStripMasterControls.test.tsx b/frontend/src/__tests__/channelStripMasterControls.test.tsx index 340c08b..36a9e8c 100644 --- a/frontend/src/__tests__/channelStripMasterControls.test.tsx +++ b/frontend/src/__tests__/channelStripMasterControls.test.tsx @@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { ChannelStrip } from "../components/ChannelStrip"; import channelStripSource from "../components/ChannelStrip.tsx?raw"; import masterTrackHeaderSource from "../components/MasterTrackHeader.tsx?raw"; -import { type Track, useDAWStore } from "../store/useDAWStore"; +import { type AutomationLane, type Track, useDAWStore } from "../store/useDAWStore"; const initialState = useDAWStore.getState(); @@ -32,7 +32,9 @@ const masterTrack: Track = { fxBypassed: false, automationLanes: [], showAutomation: false, - automationEnabled: true, + automationReadEnabled: false, + automationWriteEnabled: false, + automationEnabled: false, suspendedAutomationState: null, frozen: false, takes: [], @@ -54,6 +56,23 @@ afterEach(() => { useDAWStore.setState(initialState); }); +function getButtonTag(html: string, title: string) { + const escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = html.match(new RegExp(`]*title="${escapedTitle}"[^>]*>`)); + expect(match, `Expected button with title "${title}" to be present`).not.toBeNull(); + return match![0]; +} + +const masterVolumeLane: AutomationLane = { + id: "master-volume", + param: "volume", + points: [], + visible: true, + mode: "read", + armed: false, + readEnabled: true, +}; + describe("master channel strip controls", () => { it("renders mute and mono in the top row without a solo button", () => { useDAWStore.setState({ @@ -85,4 +104,54 @@ describe("master channel strip controls", () => { 'title={isMasterMuted ? "Unmute Master" : "Mute Master"}', ); }); + + it("uses Cubase-style R/W automation controls on the master strip", () => { + useDAWStore.setState({ + masterVolume: 1, + isMasterMuted: false, + masterMono: false, + masterFxCount: 1, + masterAutomationLanes: [], + showMasterAutomation: false, + masterAutomationReadEnabled: false, + masterAutomationWriteEnabled: false, + masterAutomationEnabled: false, + }); + + const htmlWithoutLanes = renderToStaticMarkup( + , + ); + + expect(htmlWithoutLanes).not.toContain('title="Master Automation"'); + expect(getButtonTag(htmlWithoutLanes, "Add a master automation lane or enable write first")).toContain("disabled"); + expect(getButtonTag(htmlWithoutLanes, "Enable master automation write")).not.toContain("disabled"); + expect(getButtonTag(htmlWithoutLanes, "Master automation panel")).toContain("w-3"); + + useDAWStore.setState({ + masterAutomationLanes: [masterVolumeLane], + showMasterAutomation: true, + masterAutomationReadEnabled: true, + masterAutomationWriteEnabled: true, + masterAutomationEnabled: true, + }); + + const htmlWithWrite = renderToStaticMarkup( + , + ); + + expect(getButtonTag(htmlWithWrite, "Disable master automation read")).toContain("text-teal-300"); + expect(getButtonTag(htmlWithWrite, "Disable master automation write")).toContain("text-red-300"); + expect(getButtonTag(htmlWithWrite, "Master automation panel")).toContain("text-teal-300"); + }); }); 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/__tests__/midiQuantize.test.ts b/frontend/src/__tests__/midiQuantize.test.ts new file mode 100644 index 0000000..3e1e4c0 --- /dev/null +++ b/frontend/src/__tests__/midiQuantize.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { type MIDIClip, type Track, useDAWStore } from "../store/useDAWStore"; + +const initialState = useDAWStore.getState(); + +function makeMidiTrack(midiClip: MIDIClip): Track { + return { + id: "track-midi", + name: "MIDI", + color: "#38bdf8", + type: "midi", + inputType: "midi", + volume: 1, + volumeDB: 0, + pan: 0, + muted: false, + soloed: false, + armed: false, + monitorEnabled: false, + recordSafe: false, + meterLevel: 0, + peakLevel: 0, + clipping: false, + inputChannel: null, + inputStartChannel: 0, + inputChannelCount: 2, + inputFxCount: 0, + trackFxCount: 0, + fxBypassed: false, + automationLanes: [], + showAutomation: false, + automationReadEnabled: false, + automationWriteEnabled: false, + automationEnabled: false, + suspendedAutomationState: null, + frozen: false, + takes: [], + activeTakeIndex: 0, + sends: [], + phaseInverted: false, + stereoWidth: 100, + masterSendEnabled: true, + outputStartChannel: 0, + outputChannelCount: 2, + playbackOffsetMs: 0, + trackChannelCount: 2, + midiOutputDevice: "", + clips: [], + midiClips: [midiClip], + }; +} + +afterEach(() => { + useDAWStore.setState(initialState); +}); + +describe("MIDI quantize", () => { + it("aligns note starts to the visible project grid, not the clip-local origin", () => { + const clip: MIDIClip = { + id: "clip-midi", + name: "Offset MIDI", + startTime: 1.5, + duration: 4, + sourceLength: 4, + events: [ + { type: "noteOn", timestamp: 0.6, note: 60, velocity: 100 }, + { type: "noteOff", timestamp: 0.8, note: 60, velocity: 0 }, + ], + ccEvents: [], + color: "#38bdf8", + }; + + useDAWStore.setState({ + tracks: [makeMidiTrack(clip)], + selectedNoteIds: [], + transport: { + ...useDAWStore.getState().transport, + tempo: 120, + }, + timeSignature: { numerator: 4, denominator: 4 }, + quantizePresetId: "factory-1/1", + }); + + useDAWStore.getState().quantizeSelectedMIDINotes("track-midi", "clip-midi", 2, 1, { + presetId: "factory-1/1", + gridSize: "1/1", + mode: "start", + }); + + const updatedClip = useDAWStore + .getState() + .tracks[0] + .midiClips[0]; + const noteOn = updatedClip.events.find((event) => event.type === "noteOn"); + const noteOff = updatedClip.events.find((event) => event.type === "noteOff"); + + expect(noteOn?.timestamp).toBeCloseTo(0.5, 6); + expect(noteOff?.timestamp).toBeCloseTo(0.7, 6); + }); +}); diff --git a/frontend/src/__tests__/rulerClickSnap.test.ts b/frontend/src/__tests__/rulerClickSnap.test.ts index bb04a08..7e85179 100644 --- a/frontend/src/__tests__/rulerClickSnap.test.ts +++ b/frontend/src/__tests__/rulerClickSnap.test.ts @@ -70,4 +70,34 @@ describe("getRulerClickSnapTime", () => { }) ).toBe(0.37); }); + + it("uses cursor snap as the grid fallback candidate when requested", () => { + expect( + getRulerClickSnapTime({ + time: 1.18, + pixelsPerSecond: 100, + tempo: 120, + timeSignature: TS_4_4, + gridSize: "minute", + snapType: "cursor", + cursorTime: 1.2, + snapEnabled: true, + }) + ).toBe(1.2); + }); + + it("uses event snap as the grid fallback candidate when requested", () => { + expect( + getRulerClickSnapTime({ + time: 1.18, + pixelsPerSecond: 100, + tempo: 120, + timeSignature: TS_4_4, + gridSize: "minute", + snapType: "events", + eventTimes: [1.21], + snapEnabled: true, + }) + ).toBe(1.21); + }); }); diff --git a/frontend/src/__tests__/snapToGrid.test.ts b/frontend/src/__tests__/snapToGrid.test.ts index ccdf520..56c216b 100644 --- a/frontend/src/__tests__/snapToGrid.test.ts +++ b/frontend/src/__tests__/snapToGrid.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from "vitest"; import { calculateGridInterval, + FACTORY_QUANTIZE_PRESETS, + getQuantizePresetById, + resolveVisualGrid, + snapTimeByType, snapToGrid, snapToGridFloor, snapToGridCeil, @@ -45,6 +49,94 @@ describe("calculateGridInterval", () => { expect(calculateGridInterval(60, TS_4_4, "bar")).toBe(4); expect(calculateGridInterval(60, TS_4_4, "beat")).toBe(1); }); + + it("resolves straight, triplet, and dotted Cubase-style note values", () => { + expect(calculateGridInterval(120, TS_4_4, "1/1")).toBe(2); + expect(calculateGridInterval(120, TS_3_4, "1/1")).toBe(2); + expect(calculateGridInterval(120, TS_4_4, "1/16")).toBe(0.125); + expect(calculateGridInterval(120, TS_4_4, "1/8T")).toBeCloseTo(1 / 6, 6); + expect(calculateGridInterval(120, TS_4_4, "1/8D")).toBe(0.375); + }); + + it("resolves Use Quantize through the active quantize preset", () => { + const preset = getQuantizePresetById(FACTORY_QUANTIZE_PRESETS, "factory-1/32"); + expect(calculateGridInterval(120, TS_4_4, "use_quantize", { quantizePreset: preset })).toBe(0.0625); + }); + + it("adapts to zoom from coarse bars to fine subdivisions", () => { + expect(calculateGridInterval(120, TS_4_4, "adapt_to_zoom", { pixelsPerSecond: 4 })).toBe(2); + expect(calculateGridInterval(120, TS_4_4, "adapt_to_zoom", { pixelsPerSecond: 1200 })).toBe(0.03125); + }); +}); + +describe("resolveVisualGrid", () => { + it("thins dense straight grids using equal bar subdivisions", () => { + const visual = resolveVisualGrid(120, TS_4_4, "1/16", { + pixelsPerSecond: 55, + minPixelsPerGrid: 18, + }); + + expect(visual.alignedToBar).toBe(true); + expect(visual.divisionsPerBar).toBe(4); + expect(visual.visualInterval).toBe(0.5); + }); + + it("shows the full grid when spacing is readable", () => { + const visual = resolveVisualGrid(120, TS_4_4, "1/16", { + pixelsPerSecond: 200, + minPixelsPerGrid: 18, + }); + + expect(visual.alignedToBar).toBe(true); + expect(visual.divisionsPerBar).toBe(16); + expect(visual.visualInterval).toBe(0.125); + }); +}); + +describe("snapTimeByType", () => { + it("snaps to grid candidates", () => { + expect(snapTimeByType({ + time: 0.19, + tempo: 120, + timeSignature: TS_4_4, + gridSize: "1/16", + snapType: "grid", + })).toBe(0.25); + }); + + it("preserves the original offset for Grid Relative", () => { + expect(snapTimeByType({ + time: 0.44, + originalTime: 0.06, + tempo: 120, + timeSignature: TS_4_4, + gridSize: "1/16", + snapType: "grid_relative", + })).toBeCloseTo(0.435, 6); + }); + + it("chooses event and cursor candidates for combined snap types", () => { + expect(snapTimeByType({ + time: 1.03, + tempo: 120, + timeSignature: TS_4_4, + gridSize: "1/4", + snapType: "events_grid_cursor", + cursorTime: 0.88, + eventTimes: [1.01, 1.7], + })).toBe(1.01); + }); + + it("includes adjacent event candidates for Shuffle snap", () => { + expect(snapTimeByType({ + time: 0.92, + tempo: 120, + timeSignature: TS_4_4, + gridSize: "1/4", + snapType: "shuffle", + eventTimes: [0.91, 1.5], + })).toBe(0.91); + }); }); describe("snapToGrid", () => { diff --git a/frontend/src/__tests__/tcpHeaderButtonAlignment.test.tsx b/frontend/src/__tests__/tcpHeaderButtonAlignment.test.tsx index 58ad3d2..43581d8 100644 --- a/frontend/src/__tests__/tcpHeaderButtonAlignment.test.tsx +++ b/frontend/src/__tests__/tcpHeaderButtonAlignment.test.tsx @@ -8,7 +8,7 @@ import { TCP_HEADER_PRIMARY_BUTTON_CLASS, TCP_HEADER_TOGGLE_BUTTON_CLASS, } from "../components/tcpHeaderButtonStyles"; -import { type Track, useDAWStore } from "../store/useDAWStore"; +import { createDefaultTrack, type Track, useDAWStore } from "../store/useDAWStore"; const initialState = useDAWStore.getState(); @@ -54,9 +54,12 @@ const baseTrack: Track = { visible: true, mode: "read", armed: false, + readEnabled: true, }, ], showAutomation: true, + automationReadEnabled: true, + automationWriteEnabled: false, automationEnabled: true, suspendedAutomationState: null, frozen: false, @@ -80,6 +83,35 @@ afterEach(() => { }); describe("TCP header button alignment", () => { + it("disables track automation read when no automation lanes exist", () => { + const freshTrack = createDefaultTrack("track-fresh", "Fresh", "#14b8a6", "audio", []); + + const html = renderToStaticMarkup( + , + ); + + expect(getButtonTag(html, "Add an automation lane or enable write first")).toContain("disabled"); + expect(getButtonTag(html, "Add an automation lane or enable write first")).toContain("text-neutral-600!"); + expect(getButtonTag(html, "Enable automation write")).not.toContain("disabled"); + }); + + it("keeps empty-track read clickable while write forces read on", () => { + const freshTrack = { + ...createDefaultTrack("track-fresh", "Fresh", "#14b8a6", "audio", []), + automationReadEnabled: true, + automationWriteEnabled: true, + automationEnabled: true, + }; + + const html = renderToStaticMarkup( + , + ); + + expect(getButtonTag(html, "Disable automation read")).not.toContain("disabled"); + expect(getButtonTag(html, "Disable automation read")).toContain("bg-teal-600/25!"); + expect(getButtonTag(html, "Disable automation write")).not.toContain("disabled"); + }); + it("renders the master mono button as MONO", () => { useDAWStore.setState({ masterVolume: 1, @@ -94,9 +126,12 @@ describe("TCP header button alignment", () => { visible: true, mode: "read", armed: false, + readEnabled: true, }, ], showMasterAutomation: true, + masterAutomationReadEnabled: true, + masterAutomationWriteEnabled: false, masterAutomationEnabled: true, }); @@ -106,7 +141,7 @@ describe("TCP header button alignment", () => { expect(html).toContain('title="Master Volume: 0.0 dB"'); }); - it("keeps the master automation pair gray when no lanes are active", () => { + it("renders independent master automation read and write buttons", () => { useDAWStore.setState({ masterVolume: 1, isMasterMuted: false, @@ -120,25 +155,24 @@ describe("TCP header button alignment", () => { visible: false, mode: "read", armed: false, + readEnabled: true, }, ], showMasterAutomation: false, + masterAutomationReadEnabled: true, + masterAutomationWriteEnabled: false, masterAutomationEnabled: true, }); const html = renderToStaticMarkup(); - expect( - getButtonTag(html, "Master Automation (right-click to toggle lanes)"), - ).toContain("hover:text-green-500 hover:border-green-500"); - expect( - getButtonTag(html, "Master Automation (right-click to toggle lanes)"), - ).not.toContain("text-green-400!"); - expect(getButtonTag(html, "No automation lanes")).toContain( - "hover:text-green-500 hover:border-green-500", + expect(getButtonTag(html, "Add a master automation lane or enable write first")).toContain("disabled"); + expect(getButtonTag(html, "Add a master automation lane or enable write first")).toContain("text-neutral-600!"); + expect(getButtonTag(html, "Enable master automation write")).toContain( + "hover:text-red-300 hover:border-red-500", ); - expect(getButtonTag(html, "No automation lanes")).not.toContain( - "text-green-400!", + expect(getButtonTag(html, "Master automation panel")).toContain( + "hover:text-teal-300 hover:border-teal-500", ); }); @@ -156,9 +190,12 @@ describe("TCP header button alignment", () => { visible: true, mode: "read", armed: false, + readEnabled: true, }, ], showMasterAutomation: true, + masterAutomationReadEnabled: true, + masterAutomationWriteEnabled: false, masterAutomationEnabled: true, }); @@ -170,8 +207,14 @@ describe("TCP header button alignment", () => { expect(trackHtml).toContain( `data-tcp-pair="fx" class="${TCP_HEADER_BUTTON_PAIR_CLASS}"`, ); - expect(trackHtml).toContain( - `data-tcp-pair="automation" class="${TCP_HEADER_ANCHORED_BUTTON_PAIR_CLASS}"`, + expect(trackHtml).toMatch( + /data-tcp-pair="automation" class="[^"]*relative inline-flex[^"]*h-6[^"]*shrink-0[^"]*items-center[^"]*gap-0[^"]*overflow-hidden[^"]*ring-1[^"]*ring-inset[^"]*"/, + ); + expect(trackHtml).toMatch( + /data-tcp-pair="automation" class="[^"]*ring-neutral-700[^"]*"/, + ); + expect(trackHtml).not.toMatch( + /data-tcp-pair="automation" class="[^"]*ring-teal-500[^"]*"/, ); expect(masterHtml).toMatch( new RegExp( @@ -183,7 +226,7 @@ describe("TCP header button alignment", () => { expect(masterHtml).toMatch( new RegExp( `data-tcp-pair="automation" class="[^"]*${escapeForRegex( - TCP_HEADER_BUTTON_PAIR_CLASS, + TCP_HEADER_ANCHORED_BUTTON_PAIR_CLASS, )}[^"]*"`, ), ); @@ -195,16 +238,31 @@ describe("TCP header button alignment", () => { TCP_HEADER_PRIMARY_BUTTON_CLASS, ); expect( - getButtonTag(trackHtml, "Envelope Manager (right-click for quick options)"), + getButtonTag(trackHtml, "Disable automation read"), ).toContain(`w-6 h-6 text-[10px]`); expect( - getButtonTag(trackHtml, "Envelope Manager (right-click for quick options)"), - ).toContain(TCP_HEADER_PRIMARY_BUTTON_CLASS); - expect(getButtonTag(trackHtml, "Suspend automation")).toContain( + getButtonTag(trackHtml, "Disable automation read"), + ).toContain("rounded"); + expect(getButtonTag(trackHtml, "Automation panel")).toContain( `w-4 h-6 text-[10px]`, ); - expect(getButtonTag(trackHtml, "Suspend automation")).toContain( - TCP_HEADER_TOGGLE_BUTTON_CLASS, + expect(getButtonTag(trackHtml, "Automation panel")).toContain( + "rounded-none", + ); + expect(getButtonTag(trackHtml, "Automation panel")).toContain( + "border-y-0!", + ); + expect(getButtonTag(trackHtml, "Automation panel")).toContain( + "border-l!", + ); + expect(getButtonTag(trackHtml, "Automation panel")).not.toContain( + "bg-teal-500/10!", + ); + expect(getButtonTag(trackHtml, "Enable automation write")).toContain( + "rounded-none border-y-0! border-r-0! border-l!", + ); + expect(getButtonTag(trackHtml, "Enable automation write")).toContain( + `w-6 h-6 text-[10px]`, ); expect(trackHtml).toMatch( new RegExp( @@ -223,16 +281,16 @@ describe("TCP header button alignment", () => { TCP_HEADER_PRIMARY_BUTTON_CLASS, ); expect( - getButtonTag(masterHtml, "Master Automation (right-click to toggle lanes)"), + getButtonTag(masterHtml, "Add a master automation lane or enable write first"), ).toContain(`w-6 h-6 text-[10px]`); expect( - getButtonTag(masterHtml, "Master Automation (right-click to toggle lanes)"), - ).toContain(TCP_HEADER_PRIMARY_BUTTON_CLASS); + getButtonTag(masterHtml, "Add a master automation lane or enable write first"), + ).toContain("rounded"); expect(masterHtml).toMatch( new RegExp( `data-tcp-pair="automation" class="[^"]*${escapeForRegex( - TCP_HEADER_BUTTON_PAIR_CLASS, - )}[^"]*"[\\s\\S]*?${escapeForRegex( + TCP_HEADER_ANCHORED_BUTTON_PAIR_CLASS, + )}[^"]*"[\\s\\S]*?rounded[\\s\\S]*?${escapeForRegex( TCP_HEADER_PRIMARY_BUTTON_CLASS, )}[\\s\\S]*?w-4 h-6 text-\\[10px\\][\\s\\S]*?${escapeForRegex( TCP_HEADER_TOGGLE_BUTTON_CLASS, 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/BuiltInPluginPanel.tsx b/frontend/src/components/BuiltInPluginPanel.tsx new file mode 100644 index 0000000..b0ffef9 --- /dev/null +++ b/frontend/src/components/BuiltInPluginPanel.tsx @@ -0,0 +1,763 @@ +import { type CSSProperties, useCallback, useEffect, useMemo, useState } from "react"; +import { Activity, SlidersHorizontal, X } from "lucide-react"; +import { + BuiltInParamDescriptor, + BuiltInPluginAddress, + BuiltInPluginSchema, + nativeBridge, +} from "../services/NativeBridge"; +import { ParametricGraph } from "./ParametricGraph"; +import type { GraphAxis, GraphNode, GraphNodeConfig } from "./ParametricGraph"; +import { Button } from "./ui"; + +interface BuiltInPluginPanelProps { + address: BuiltInPluginAddress; + fallbackName: string; + onClose?: () => void; + initialSchema?: BuiltInPluginSchema; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +export function formatParamValue(param: BuiltInParamDescriptor) { + if (param.type === "toggle") return param.value >= 0.5 ? "On" : "Off"; + if (param.type === "enum") { + return ( + param.enumOptions?.find((option) => Math.round(option.value) === Math.round(param.value)) + ?.label ?? String(Math.round(param.value)) + ); + } + const span = Math.abs(param.max - param.min); + const decimals = span <= 2 ? 2 : span <= 50 ? 1 : 0; + return `${param.value.toFixed(decimals)}${param.unit ? ` ${param.unit}` : ""}`; +} + +function normalize(param: BuiltInParamDescriptor) { + if (param.max <= param.min) return 0; + return clamp((param.value - param.min) / (param.max - param.min), 0, 1); +} + +function getParam(params: BuiltInParamDescriptor[], id: string) { + return params.find((param) => param.id === id); +} + +type BuiltInPluginKind = + | "eq" + | "dynamics" + | "delay" + | "reverb" + | "modulation" + | "saturation" + | "pitch" + | "synth" + | "piano" + | "drums" + | "generic"; + +export function getPluginKind(schema: BuiltInPluginSchema | null): BuiltInPluginKind { + const label = `${schema?.category ?? ""} ${schema?.name ?? ""}`.toLowerCase(); + if (label.includes("eq")) return "eq"; + if (label.includes("compressor") || label.includes("gate") || label.includes("limiter") || label.includes("dynamics")) return "dynamics"; + if (label.includes("delay")) return "delay"; + if (label.includes("reverb")) return "reverb"; + if (label.includes("chorus") || label.includes("flanger") || label.includes("phaser") || label.includes("modulation")) return "modulation"; + if (label.includes("saturat")) return "saturation"; + if (label.includes("pitch")) return "pitch"; + if (label.includes("piano")) return "piano"; + if (label.includes("drum")) return "drums"; + if (label.includes("synth") || label.includes("sampler")) return "synth"; + return "generic"; +} + +export function primaryParamIdsForKind(kind: BuiltInPluginKind, schema: BuiltInPluginSchema | null) { + const name = schema?.name.toLowerCase() ?? ""; + if (kind === "eq") return ["outputGain", "autoGain", "stereoMode", "auditionBand"]; + if (kind === "delay") return ["delayTimeL", "delayTimeR", "feedback", "mix", "ducking"]; + if (kind === "reverb") return ["algorithm", "roomSize", "decayTime", "wetLevel", "dryLevel"]; + if (kind === "modulation") return ["mode", "rate", "depth", "mix", "characterMode"]; + if (kind === "saturation") return ["satType", "drive", "mix", "outputGain", "oversampleMode"]; + if (kind === "pitch") return ["key", "scale", "retuneSpeed", "correctionStrength", "mix"]; + if (kind === "piano") return ["model", "tone", "body", "resonance", "outputGain"]; + if (kind === "drums") return ["kit", "mapPreset", "punch", "ambience", "outputGain"]; + if (kind === "synth") return ["brightness", "detuneCents", "subLevel", "noiseLevel", "outputGain"]; + if (kind === "dynamics" && name.includes("limiter")) return ["threshold", "ceiling", "lookaheadMs", "releaseMs"]; + if (kind === "dynamics" && name.includes("gate")) return ["threshold", "range", "attackMs", "releaseMs", "detectorMode"]; + if (kind === "dynamics") return ["threshold", "ratio", "attack", "release", "autoMakeup"]; + return []; +} + +export function groupLabel(group: string) { + const labels: Record = { + body: "Body", + character: "Character", + correction: "Correction", + detection: "Detection", + drive: "Drive", + drums: "Kit", + dynamic: "Dynamic Bands", + dynamics: "Dynamics", + envelope: "Envelope", + eqBand: "Bands", + feedback: "Feedback", + formant: "Formants", + instrument: "Instrument", + midi: "MIDI", + mix: "Mix", + modulation: "Modulation", + oscillator: "Oscillators", + output: "Output", + piano: "Piano", + quality: "Quality", + routing: "Routing", + scale: "Scale", + sidechain: "Sidechain", + space: "Space", + time: "Timing", + tone: "Tone", + width: "Stereo", + }; + return labels[group] ?? group; +} + +export function groupSortWeight(kind: BuiltInPluginKind, group: string) { + const orderByKind: Record = { + eq: ["eqBand", "dynamic", "routing", "output"], + dynamics: ["dynamics", "detection", "sidechain", "character", "mix", "output"], + delay: ["time", "feedback", "dynamics", "tone", "character", "width", "mix"], + reverb: ["space", "time", "tone", "width", "mix"], + modulation: ["modulation", "feedback", "character", "tone", "width", "mix"], + saturation: ["drive", "character", "tone", "quality", "mix", "output"], + pitch: ["scale", "correction", "detection", "formant", "midi", "mix"], + synth: ["oscillator", "tone", "envelope", "output"], + piano: ["character", "tone", "body", "width", "envelope", "output"], + drums: ["drums", "character", "space", "width", "output"], + generic: ["controls", "output"], + }; + const order = orderByKind[kind] ?? orderByKind.generic; + const index = order.indexOf(group); + return index === -1 ? 100 : index; +} + +export function stepForParam(param: BuiltInParamDescriptor) { + const span = Math.abs(param.max - param.min); + if (param.type === "toggle" || param.type === "enum") return 1; + if (param.unit === "Hz" && param.max > 1000) return 1; + if (param.unit === "ms" || param.unit === "s" || param.unit === "dB" || param.unit === "st" || param.unit === "ct") return Math.max(span / 500, 0.01); + return Math.max(span / 500, 0.001); +} + +export function BuiltInParamControl({ + param, + onChange, + compact = false, +}: { + param: BuiltInParamDescriptor; + onChange: (param: BuiltInParamDescriptor, value: number) => void; + compact?: boolean; +}) { + const pct = normalize(param); + const style = { "--knob-pct": `${pct * 100}%` } as CSSProperties; + + if (param.type === "enum") { + return ( + + ); + } + + if (param.type === "toggle") { + const active = param.value >= 0.5; + return ( + + ); + } + + return ( + + ); +} + +function BuiltInVisualization({ + schema, + onParamChange, +}: { + schema: BuiltInPluginSchema; + onParamChange: (param: BuiltInParamDescriptor, value: number) => void; +}) { + const params = schema.parameters; + const category = `${schema.category} ${schema.name}`.toLowerCase(); + const width = 360; + const height = 126; + const [dynamicsHistory, setDynamicsHistory] = useState(() => Array(56).fill(0)); + const gainReductionDb = schema.visualization?.gainReductionDb; + + useEffect(() => { + setDynamicsHistory(Array(56).fill(0)); + }, [schema.chain, schema.fxIndex, schema.name]); + + useEffect(() => { + if (typeof gainReductionDb !== "number" || !Number.isFinite(gainReductionDb)) return; + setDynamicsHistory((history) => [...history.slice(1), clamp(Math.abs(gainReductionDb), 0, 36)]); + }, [gainReductionDb]); + + if (category.includes("eq")) { + const nodes: GraphNode[] = []; + const dynamicGains = schema.visualization?.dynamicGainDb ?? []; + for (let band = 0; band < 8; band += 1) { + const enabled = (getParam(params, `band${band}.enabled`)?.value ?? 0) >= 0.5; + const freq = getParam(params, `band${band}.freq`)?.value ?? 1000; + const gain = getParam(params, `band${band}.gain`)?.value ?? 0; + const dynamicValue = dynamicGains[band] ?? 0; + nodes.push({ + id: `band-${band}`, + x: freq, + y: gain, + z: getParam(params, `band${band}.q`)?.value ?? 1, + enabled, + label: `Band ${band + 1}`, + color: Math.abs(dynamicValue) > 0.05 ? "#fbbf24" : undefined, + }); + } + const frequencies = schema.visualization?.frequencies ?? []; + const responseCurve = schema.visualization?.responseDb?.map((value, index) => ({ + x: frequencies[index] ?? 20, + y: clamp(value, -24, 24), + })); + const spectrumToGraphPoints = (values: number[] | undefined) => + values?.map((value, index) => ({ + x: frequencies[index] ?? 20, + y: clamp(((value + 90) / 78) * 48 - 24, -24, 24), + })) ?? []; + const backgroundCurves = schema.visualization?.spectrumReady + ? [ + { + id: "spectrum-pre", + points: spectrumToGraphPoints(schema.visualization.spectrumPreDb), + color: "rgba(148, 163, 184, 0.72)", + opacity: 0.42, + strokeWidth: 1, + }, + { + id: "spectrum-post", + points: spectrumToGraphPoints(schema.visualization.spectrumPostDb), + color: "rgba(34, 197, 94, 0.76)", + opacity: 0.58, + strokeWidth: 1.15, + }, + ] + : []; + const xAxis: GraphAxis = { + label: "Frequency", + min: 20, + max: 20000, + scale: "log", + unit: "Hz", + gridLines: [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000], + }; + const yAxis: GraphAxis = { + label: "Gain", + min: -24, + max: 24, + scale: "linear", + unit: "dB", + gridLines: [-24, -12, 0, 12, 24], + }; + const nodeConfig: GraphNodeConfig = { + maxNodes: 8, + zAxis: { + label: "Q", + min: 0.1, + max: 30, + default: 1, + sensitivity: 0.01, + }, + }; + return ( + { + const band = Number(id.replace("band-", "")); + if (!Number.isFinite(band)) return; + const enabledParam = getParam(params, `band${band}.enabled`); + const freqParam = getParam(params, `band${band}.freq`); + const gainParam = getParam(params, `band${band}.gain`); + const qParam = getParam(params, `band${band}.q`); + if (enabledParam && enabledParam.value < 0.5) onParamChange(enabledParam, 1); + if (freqParam && changes.x !== undefined) onParamChange(freqParam, changes.x); + if (gainParam && changes.y !== undefined) onParamChange(gainParam, changes.y); + if (qParam && changes.z !== undefined) onParamChange(qParam, changes.z); + }} + /> + ); + } + + if (category.includes("dynamics") || category.includes("compressor") || category.includes("gate") || category.includes("limiter")) { + const threshold = normalize(getParam(params, "threshold") ?? { value: -18, min: -60, max: 0 } as BuiltInParamDescriptor); + const ratio = normalize(getParam(params, "ratio") ?? { value: 4, min: 1, max: 20 } as BuiltInParamDescriptor); + const knee = normalize(getParam(params, "knee") ?? { value: 0, min: 0, max: 24 } as BuiltInParamDescriptor); + const x = threshold * width; + const y = height - threshold * height; + const endY = clamp(y - (1 - ratio) * height * 0.38 + knee * 8, 12, height - 10); + const currentGr = clamp(Math.abs(gainReductionDb ?? 0), 0, 36); + const inputLevel = clamp(schema.visualization?.inputLevelDb ?? -90, -90, 6); + const outputLevel = clamp(schema.visualization?.outputLevelDb ?? -90, -90, 6); + const levelY = (db: number) => height - 12 - clamp((db + 90) / 96, 0, 1) * (height - 22); + const historyPoints = dynamicsHistory + .map((value, index) => { + const hx = 8 + (index / Math.max(1, dynamicsHistory.length - 1)) * (width - 86); + const hy = height - 10 - (value / 36) * (height - 26); + return `${hx},${hy}`; + }) + .join(" "); + return ( + + + + + + + + {typeof schema.visualization?.gateOpen === "boolean" && ( + + )} + + + ); + } + + if (category.includes("saturation")) { + const drive = normalize(getParam(params, "drive") ?? { value: 6, min: 0, max: 30 } as BuiltInParamDescriptor); + const bias = getParam(params, "asymmetry")?.value ?? 0; + const curve = Array.from({ length: 44 }, (_, index) => { + const xNorm = (index / 43) * 2 - 1; + const yNorm = Math.tanh(xNorm * (1.2 + drive * 5) + bias * 0.4); + const x = (index / 43) * width; + const y = height * 0.5 - yNorm * height * 0.38; + return `${x},${y}`; + }).join(" "); + return ( + + + + + + ); + } + + if (category.includes("pitch")) { + const detected = schema.visualization?.historyDetectedMidi ?? []; + const corrected = schema.visualization?.historyCorrectedMidi ?? []; + const confidence = schema.visualization?.historyConfidence ?? []; + const pitchPoints = (values: number[]) => + values + .map((value, index) => { + const x = (index / Math.max(1, values.length - 1)) * width; + const y = height - clamp((value - 36) / 48, 0, 1) * height; + return `${x},${y}`; + }) + .join(" "); + const confidenceBars = confidence.filter((value) => value > 0.01).slice(-28); + return ( + + + + {confidenceBars.map((value, index) => { + const barWidth = 4; + const x = width - 124 + index * barWidth; + return ; + })} + + + 0 ? 0.72 : 0.22), 0, 1) * height} + r="5" + data-active={(schema.visualization?.confidence ?? 0) > 0.2} + /> + + ); + } + + if (category.includes("delay")) { + const delayL = normalize(getParam(params, "delayTimeL") ?? { value: 250, min: 1, max: 2000 } as BuiltInParamDescriptor); + const delayR = normalize(getParam(params, "delayTimeR") ?? { value: 250, min: 1, max: 2000 } as BuiltInParamDescriptor); + const feedbackValue = normalize(getParam(params, "feedback") ?? { value: 0.4, min: 0, max: 0.95 } as BuiltInParamDescriptor); + const mixValue = normalize(getParam(params, "mix") ?? { value: 0.5, min: 0, max: 1 } as BuiltInParamDescriptor); + const tapL = 38 + delayL * 230; + const tapR = 54 + delayR * 230; + const repeats = Array.from({ length: 5 }, (_, index) => ({ + x: 54 + index * 58, + y: height * 0.5 + Math.sin(index * 1.2) * 24 * mixValue, + r: 5 + feedbackValue * 9 * Math.pow(0.72, index), + opacity: 0.35 + feedbackValue * Math.pow(0.72, index) * 0.55, + })); + return ( + + + + + {repeats.map((repeat, index) => ( + + ))} + + + + ); + } + + if (category.includes("reverb")) { + const decayValue = normalize(getParam(params, "decayTime") ?? { value: 2, min: 0.1, max: 20 } as BuiltInParamDescriptor); + const sizeValue = normalize(getParam(params, "roomSize") ?? { value: 0.5, min: 0, max: 1 } as BuiltInParamDescriptor); + const dampingValue = normalize(getParam(params, "damping") ?? { value: 0.5, min: 0, max: 1 } as BuiltInParamDescriptor); + const widthValue = normalize(getParam(params, "width") ?? { value: 1, min: 0, max: 1 } as BuiltInParamDescriptor); + const tail = Array.from({ length: 72 }, (_, index) => { + const t = index / 71; + const envelope = Math.exp(-t * (2.2 - decayValue * 1.45)); + const ripple = Math.sin(t * Math.PI * (8 + sizeValue * 12)) * (1 - dampingValue * 0.65); + const x = t * width; + const y = height * 0.5 - envelope * ripple * height * 0.32; + return `${x},${y}`; + }).join(" "); + return ( + + + + + + + ); + } + + if (category.includes("modulation") || category.includes("chorus") || category.includes("flanger") || category.includes("phaser")) { + const depthValue = normalize(getParam(params, "depth") ?? { value: 0.5, min: 0, max: 1 } as BuiltInParamDescriptor); + const spreadValue = normalize(getParam(params, "spread") ?? { value: 0.5, min: 0, max: 1 } as BuiltInParamDescriptor); + const feedbackValue = normalize(getParam(params, "fbAmount") ?? { value: 0, min: -1, max: 1 } as BuiltInParamDescriptor); + const waveA = Array.from({ length: 80 }, (_, index) => { + const t = index / 79; + const x = t * width; + const y = height * 0.5 + Math.sin(t * Math.PI * 4) * depthValue * height * 0.32; + return `${x},${y}`; + }).join(" "); + const waveB = Array.from({ length: 80 }, (_, index) => { + const t = index / 79; + const x = t * width; + const y = height * 0.5 + Math.sin(t * Math.PI * 4 + spreadValue * Math.PI) * depthValue * height * 0.26; + return `${x},${y}`; + }).join(" "); + return ( + + + + + + 0.52} /> + + ); + } + + if (category.includes("synth")) { + const brightness = normalize(getParam(params, "brightness") ?? { value: 0.62, min: 0, max: 1 } as BuiltInParamDescriptor); + const sub = normalize(getParam(params, "subLevel") ?? { value: 0.18, min: 0, max: 0.8 } as BuiltInParamDescriptor); + const noise = normalize(getParam(params, "noiseLevel") ?? { value: 0.015, min: 0, max: 0.25 } as BuiltInParamDescriptor); + const wave = Array.from({ length: 64 }, (_, index) => { + const phase = index / 63; + const saw = phase * 2 - 1; + const square = phase < 0.5 ? 1 : -1; + const yNorm = saw * (0.5 + brightness * 0.2) + square * brightness * 0.18 + Math.sin(phase * Math.PI * 2) * sub * 0.26; + const x = phase * width; + const y = height * 0.5 - yNorm * height * 0.34; + return `${x},${y}`; + }).join(" "); + return ( + + + + + + + + ); + } + + if (category.includes("piano")) { + const toneValue = normalize(getParam(params, "tone") ?? { value: 0.58, min: 0, max: 1 } as BuiltInParamDescriptor); + const bodyValue = normalize(getParam(params, "body") ?? { value: 0.72, min: 0, max: 1 } as BuiltInParamDescriptor); + const resonanceValue = normalize(getParam(params, "resonance") ?? { value: 0.38, min: 0, max: 1 } as BuiltInParamDescriptor); + const harmonics = [1, 2.003, 3.011, 5.031, 1.497].map((ratio, index) => { + const value = [bodyValue, toneValue * 0.72, toneValue * 0.52, toneValue * 0.34, resonanceValue * 0.64][index]; + return { ratio, value }; + }); + return ( + + + {Array.from({ length: 18 }, (_, index) => ( + + ))} + {harmonics.map((harmonic, index) => ( + + ))} + + + ); + } + + if (category.includes("drum")) { + const punchValue = normalize(getParam(params, "punch") ?? { value: 0.55, min: 0, max: 1 } as BuiltInParamDescriptor); + const roomValue = normalize(getParam(params, "ambience") ?? { value: 0.18, min: 0, max: 1 } as BuiltInParamDescriptor); + const widthValue = normalize(getParam(params, "stereoWidth") ?? { value: 0.7, min: 0, max: 1 } as BuiltInParamDescriptor); + const shells = [ + { x: 176, y: 70, r: 26 + punchValue * 8 }, + { x: 116 - widthValue * 22, y: 56, r: 18 }, + { x: 238 + widthValue * 22, y: 56, r: 18 }, + { x: 72 - widthValue * 28, y: 34, r: 13 + roomValue * 5 }, + { x: 288 + widthValue * 28, y: 34, r: 13 + roomValue * 5 }, + ]; + return ( + + + + {shells.map((shell, index) => ( + + ))} + + + ); + } + + const bars = params.slice(0, 14); + return ( + + + {bars.map((param, index) => { + const barWidth = width / Math.max(1, bars.length); + const value = normalize(param); + const barHeight = 16 + value * (height - 34); + return ( + + ); + })} + + ); +} + +export function BuiltInPluginPanel({ + address, + fallbackName, + onClose, + initialSchema, +}: BuiltInPluginPanelProps) { + const [schema, setSchema] = useState(initialSchema ?? null); + const [loading, setLoading] = useState(false); + + const loadSchema = useCallback(async (showLoading = true) => { + if (showLoading) setLoading(true); + try { + const nextSchema = await nativeBridge.getBuiltInPluginSchema(address); + setSchema(nextSchema); + } catch (error) { + console.error("[BuiltInPluginPanel] Failed to load schema:", error); + setSchema({ + schemaVersion: 1, + name: fallbackName, + category: "Built-in", + chain: address.chain, + fxIndex: address.fxIndex ?? -1, + parameters: [], + }); + } finally { + if (showLoading) setLoading(false); + } + }, [address, fallbackName]); + + useEffect(() => { + if (initialSchema) { + setSchema(initialSchema); + return; + } + void loadSchema(); + }, [initialSchema, loadSchema]); + + useEffect(() => { + const pluginKind = `${schema?.category ?? ""} ${schema?.name ?? ""}`.toLowerCase(); + const needsLiveSchema = pluginKind.includes("eq") || pluginKind.includes("pitch") || pluginKind.includes("dynamics") || pluginKind.includes("compressor") || pluginKind.includes("gate") || pluginKind.includes("limiter"); + if (!needsLiveSchema) return; + const intervalId = window.setInterval(() => { + void loadSchema(false); + }, 500); + return () => window.clearInterval(intervalId); + }, [loadSchema, schema?.category, schema?.name]); + + const pluginKind = useMemo(() => getPluginKind(schema), [schema]); + + const primaryParamIds = useMemo( + () => primaryParamIdsForKind(pluginKind, schema), + [pluginKind, schema], + ); + + const primaryParams = useMemo(() => { + const params = schema?.parameters ?? []; + return primaryParamIds + .map((id) => params.find((param) => param.id === id)) + .filter((param): param is BuiltInParamDescriptor => Boolean(param)); + }, [primaryParamIds, schema]); + + const groupedParams = useMemo(() => { + const primaryIds = new Set(primaryParams.map((param) => param.id)); + const groups = new Map(); + for (const param of schema?.parameters ?? []) { + if (primaryIds.has(param.id)) continue; + const group = param.graphRole || "controls"; + groups.set(group, [...(groups.get(group) ?? []), param]); + } + return Array.from(groups.entries()) + .sort(([groupA], [groupB]) => groupSortWeight(pluginKind, groupA) - groupSortWeight(pluginKind, groupB)); + }, [pluginKind, primaryParams, schema]); + + const handleParamChange = async (param: BuiltInParamDescriptor, rawValue: number) => { + const value = param.type === "toggle" ? (rawValue >= 0.5 ? 1 : 0) : clamp(rawValue, param.min, param.max); + setSchema((current) => + current + ? { + ...current, + parameters: current.parameters.map((entry) => + entry.id === param.id ? { ...entry, value } : entry, + ), + } + : current, + ); + await nativeBridge.setBuiltInPluginParam(address, param.id, value); + }; + + const title = schema?.name || fallbackName; + + return ( +
event.stopPropagation()}> +
+
+ + {title} +
+ {onClose && ( + + )} +
+ + {schema && schema.parameters.length > 0 && ( + { + void handleParamChange(param, value); + }} + /> + )} + + {loading ? ( +
Loading
+ ) : !schema || schema.parameters.length === 0 ? ( +
No editable parameters
+ ) : ( +
+ {primaryParams.length > 0 && ( +
+ {primaryParams.map((param) => ( + { + void handleParamChange(nextParam, value); + }} + /> + ))} +
+ )} + {groupedParams.map(([group, params]) => ( +
+
+ + {groupLabel(group)} +
+
+ {params.map((param) => ( + { + void handleParamChange(nextParam, value); + }} + /> + ))} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ChannelStrip.tsx b/frontend/src/components/ChannelStrip.tsx index d320fd7..5eccc6e 100644 --- a/frontend/src/components/ChannelStrip.tsx +++ b/frontend/src/components/ChannelStrip.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useMemo } from "react"; import classNames from "classnames"; -import { Power } from "lucide-react"; +import { ChevronDown, Power } from "lucide-react"; import { PeakMeter } from "./PeakMeter"; import { MasterPeakMeterCluster } from "./MasterPeakMeterCluster"; import { @@ -77,6 +77,8 @@ export const ChannelStrip = React.memo(function ChannelStrip({ commitTrackVolumeEdit, beginTrackPanEdit, commitTrackPanEdit, + beginAutomationParamTouch, + endAutomationParamTouch, selectedTrackIds, trackGroups, addTrackGroup, @@ -85,9 +87,16 @@ export const ChannelStrip = React.memo(function ChannelStrip({ openTrackRouting, isMasterMuted, masterVolume, + masterAutomationLanes, + showMasterAutomation, + masterAutomationReadEnabled, + masterAutomationWriteEnabled, + masterAutomationEnabled, openChannelStripEQ, masterMono, toggleMasterMono, + toggleMasterAutomationRead, + toggleMasterAutomationWrite, openEnvelopeManager, } = useDAWStore( useShallow((s) => ({ @@ -104,6 +113,8 @@ export const ChannelStrip = React.memo(function ChannelStrip({ commitTrackVolumeEdit: s.commitTrackVolumeEdit, beginTrackPanEdit: s.beginTrackPanEdit, commitTrackPanEdit: s.commitTrackPanEdit, + beginAutomationParamTouch: s.beginAutomationParamTouch, + endAutomationParamTouch: s.endAutomationParamTouch, selectedTrackIds: s.selectedTrackIds, trackGroups: s.trackGroups, addTrackGroup: s.addTrackGroup, @@ -114,9 +125,16 @@ export const ChannelStrip = React.memo(function ChannelStrip({ // NOTE: currentTime intentionally NOT selected here — it updates at 60fps // and would cause every ChannelStrip to re-render 60x/sec during playback. masterVolume: s.masterVolume, + masterAutomationLanes: s.masterAutomationLanes, + showMasterAutomation: s.showMasterAutomation, + masterAutomationReadEnabled: s.masterAutomationReadEnabled, + masterAutomationWriteEnabled: s.masterAutomationWriteEnabled, + masterAutomationEnabled: s.masterAutomationEnabled, openChannelStripEQ: s.openChannelStripEQ, masterMono: s.masterMono, toggleMasterMono: s.toggleMasterMono, + toggleMasterAutomationRead: s.toggleMasterAutomationRead, + toggleMasterAutomationWrite: s.toggleMasterAutomationWrite, openEnvelopeManager: s.openEnvelopeManager, })), ); @@ -129,7 +147,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. @@ -144,6 +162,37 @@ export const ChannelStrip = React.memo(function ChannelStrip({ }, [isMaster, track.clips]); const masterVolumeDB = masterVolume > 0 ? 20 * Math.log10(masterVolume) : -60; + const stripMasterAutomationLanes = + isMaster && track.automationLanes.length > 0 + ? track.automationLanes + : masterAutomationLanes; + const stripMasterAutomationReadEnabled = + isMaster && typeof track.automationReadEnabled === "boolean" + ? track.automationReadEnabled + : masterAutomationReadEnabled; + const stripMasterAutomationWriteEnabled = + isMaster && typeof track.automationWriteEnabled === "boolean" + ? track.automationWriteEnabled + : masterAutomationWriteEnabled; + const stripMasterAutomationEnabled = + isMaster && typeof track.automationEnabled === "boolean" + ? track.automationEnabled + : masterAutomationEnabled; + const stripShowMasterAutomation = + isMaster ? track.showAutomation || showMasterAutomation : showMasterAutomation; + const hasMasterAutomationLane = stripMasterAutomationLanes.length > 0; + const hasMasterAutomation = + (stripShowMasterAutomation && stripMasterAutomationLanes.some((lane) => lane.visible)) || + stripMasterAutomationLanes.some((lane) => lane.points.length > 0); + const masterAutomationReadActive = + typeof stripMasterAutomationReadEnabled === "boolean" + ? stripMasterAutomationReadEnabled + : typeof stripMasterAutomationEnabled === "boolean" + ? stripMasterAutomationEnabled + : hasMasterAutomationLane; + const masterAutomationWriteActive = stripMasterAutomationWriteEnabled === true; + const canToggleMasterAutomationRead = + hasMasterAutomationLane || masterAutomationWriteActive; const ALL_LINKED_PARAMS = [ "volume", @@ -254,24 +303,40 @@ export const ChannelStrip = React.memo(function ChannelStrip({ // Undo/redo: capture starting value on pointer down, commit on pointer up const handleVolumePointerDown = useCallback(() => { - if (isMaster) return; + if (isMaster) { + beginAutomationParamTouch("master", "volume"); + const endTouchOnUp = () => { + document.removeEventListener("pointerup", endTouchOnUp); + endAutomationParamTouch("master", "volume"); + }; + document.addEventListener("pointerup", endTouchOnUp); + return; + } beginTrackVolumeEdit(track.id); const commitOnUp = () => { document.removeEventListener("pointerup", commitOnUp); commitTrackVolumeEdit(track.id); }; document.addEventListener("pointerup", commitOnUp); - }, [isMaster, track.id, beginTrackVolumeEdit, commitTrackVolumeEdit]); + }, [isMaster, track.id, beginTrackVolumeEdit, commitTrackVolumeEdit, beginAutomationParamTouch, endAutomationParamTouch]); const handlePanPointerDown = useCallback(() => { - if (isMaster) return; + if (isMaster) { + beginAutomationParamTouch("master", "pan"); + const endTouchOnUp = () => { + document.removeEventListener("pointerup", endTouchOnUp); + endAutomationParamTouch("master", "pan"); + }; + document.addEventListener("pointerup", endTouchOnUp); + return; + } beginTrackPanEdit(track.id); const commitOnUp = () => { document.removeEventListener("pointerup", commitOnUp); commitTrackPanEdit(track.id); }; document.addEventListener("pointerup", commitOnUp); - }, [isMaster, track.id, beginTrackPanEdit, commitTrackPanEdit]); + }, [isMaster, track.id, beginTrackPanEdit, commitTrackPanEdit, beginAutomationParamTouch, endAutomationParamTouch]); const formatVolume = (db: number) => { if (db <= -60) return "-∞"; @@ -483,14 +548,77 @@ export const ChannelStrip = React.memo(function ChannelStrip({ )} {isMaster && ( - + + + + + + + )}
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..82f39e4 100644 --- a/frontend/src/components/EnvelopeManagerModal.tsx +++ b/frontend/src/components/EnvelopeManagerModal.tsx @@ -1,10 +1,10 @@ import { useState, useEffect, useMemo } from "react"; import { useShallow } from "zustand/shallow"; import { ChevronDown, ChevronRight, Search } from "lucide-react"; -import { useDAWStore, AutomationModeType } from "../store/useDAWStore"; +import { useDAWStore, type AutomationWriteBehavior } 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; @@ -25,16 +25,14 @@ interface EnvelopeRow { category: string; isActive: boolean; isVisible: boolean; - isArmed: boolean; + isReadEnabled: boolean; laneId: string | null; } -const MODE_OPTIONS: { value: AutomationModeType; label: string }[] = [ - { value: "off", label: "Off" }, - { value: "read", label: "Read" }, +const WRITE_BEHAVIOR_OPTIONS: { value: AutomationWriteBehavior; label: string }[] = [ { value: "touch", label: "Touch" }, { value: "latch", label: "Latch" }, - { value: "write", label: "Write" }, + { value: "overwrite", label: "Overwrite" }, ]; export function EnvelopeManagerModal() { @@ -43,25 +41,21 @@ export function EnvelopeManagerModal() { envelopeManagerTrackId, closeEnvelopeManager, tracks, + automationWriteBehavior, + setAutomationWriteBehavior, addAutomationLane, toggleAutomationLaneVisibility, - armAutomationLane, - setTrackAutomationMode, + setAutomationLaneRead, showAllActiveEnvelopes, hideAllEnvelopes, - armAllVisibleAutomationLanes, - disarmAllAutomationLanes, toggleTrackAutomation, // Master-specific masterAutomationLanes, addMasterAutomationLane, toggleMasterAutomationLaneVisibility, - armMasterAutomationLane, - setMasterTrackAutomationMode, + setMasterAutomationLaneRead, showAllActiveMasterEnvelopes, hideAllMasterEnvelopes, - armAllVisibleMasterAutomationLanes, - disarmAllMasterAutomationLanes, toggleMasterAutomation, } = useDAWStore( useShallow((s) => ({ @@ -69,25 +63,21 @@ export function EnvelopeManagerModal() { envelopeManagerTrackId: s.envelopeManagerTrackId, closeEnvelopeManager: s.closeEnvelopeManager, tracks: s.tracks, + automationWriteBehavior: s.automationWriteBehavior, + setAutomationWriteBehavior: s.setAutomationWriteBehavior, addAutomationLane: s.addAutomationLane, toggleAutomationLaneVisibility: s.toggleAutomationLaneVisibility, - armAutomationLane: s.armAutomationLane, - setTrackAutomationMode: s.setTrackAutomationMode, + setAutomationLaneRead: s.setAutomationLaneRead, showAllActiveEnvelopes: s.showAllActiveEnvelopes, hideAllEnvelopes: s.hideAllEnvelopes, - armAllVisibleAutomationLanes: s.armAllVisibleAutomationLanes, - disarmAllAutomationLanes: s.disarmAllAutomationLanes, toggleTrackAutomation: s.toggleTrackAutomation, // Master masterAutomationLanes: s.masterAutomationLanes, addMasterAutomationLane: s.addMasterAutomationLane, toggleMasterAutomationLaneVisibility: s.toggleMasterAutomationLaneVisibility, - armMasterAutomationLane: s.armMasterAutomationLane, - setMasterTrackAutomationMode: s.setMasterTrackAutomationMode, + setMasterAutomationLaneRead: s.setMasterAutomationLaneRead, showAllActiveMasterEnvelopes: s.showAllActiveMasterEnvelopes, hideAllMasterEnvelopes: s.hideAllMasterEnvelopes, - armAllVisibleMasterAutomationLanes: s.armAllVisibleMasterAutomationLanes, - disarmAllMasterAutomationLanes: s.disarmAllMasterAutomationLanes, toggleMasterAutomation: s.toggleMasterAutomation, })), ); @@ -104,7 +94,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 +127,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 +136,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), []); } }), ); @@ -182,18 +172,18 @@ export function EnvelopeManagerModal() { category: categoryLabel, isActive: lane ? lane.points.length > 0 : false, isVisible: lane ? lane.visible : false, - isArmed: lane ? lane.armed : false, + isReadEnabled: lane ? (lane.readEnabled ?? lane.mode !== "off") : false, laneId: lane?.id ?? null, }); } // 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, @@ -201,7 +191,7 @@ export function EnvelopeManagerModal() { category: fxCategory, isActive: lane ? lane.points.length > 0 : false, isVisible: lane ? lane.visible : false, - isArmed: lane ? lane.armed : false, + isReadEnabled: lane ? (lane.readEnabled ?? lane.mode !== "off") : false, laneId: lane?.id ?? null, }); } @@ -229,23 +219,8 @@ export function EnvelopeManagerModal() { return groups; }, [filteredRows]); - // Global automation mode (most common mode across lanes) - const globalMode: AutomationModeType = useMemo(() => { - if (automationLanes.length === 0) return "off"; - const counts = new Map(); - for (const lane of automationLanes) { - counts.set(lane.mode, (counts.get(lane.mode) || 0) + 1); - } - let best: AutomationModeType = "off"; - let bestCount = 0; - for (const [mode, count] of counts) { - if (count > bestCount) { - best = mode; - bestCount = count; - } - } - return best; - }, [automationLanes]); + // Global write behavior is selected once for the project. + const currentWriteBehavior = automationWriteBehavior ?? "touch"; // Handlers — dispatch to master or track actions const trackId = envelopeManagerTrackId!; @@ -274,16 +249,11 @@ export function EnvelopeManagerModal() { } }; - const handleToggleArm = (row: EnvelopeRow) => { + const handleToggleRead = (row: EnvelopeRow) => { const laneId = ensureLane(row); if (!laneId) return; - if (isMaster) armMasterAutomationLane(laneId, !row.isArmed); - else armAutomationLane(trackId, laneId, !row.isArmed); - }; - - const handleSetMode = (mode: AutomationModeType) => { - if (isMaster) setMasterTrackAutomationMode(mode); - else setTrackAutomationMode(trackId, mode); + if (isMaster) setMasterAutomationLaneRead(laneId, !row.isReadEnabled); + else setAutomationLaneRead(trackId, laneId, !row.isReadEnabled); }; const handleShowActive = () => { @@ -296,16 +266,6 @@ export function EnvelopeManagerModal() { else hideAllEnvelopes(trackId); }; - const handleArmVisible = () => { - if (isMaster) armAllVisibleMasterAutomationLanes(); - else armAllVisibleAutomationLanes(trackId); - }; - - const handleDisarmAll = () => { - if (isMaster) disarmAllMasterAutomationLanes(); - else disarmAllAutomationLanes(trackId); - }; - const handleToggleSection = (category: string) => { setCollapsedSections((prev) => { const next = new Set(prev); @@ -325,13 +285,13 @@ export function EnvelopeManagerModal() { {/* Top controls */}
- + @@ -350,18 +310,6 @@ export function EnvelopeManagerModal() { > Hide All - -
{/* Filter */} @@ -382,7 +330,7 @@ export function EnvelopeManagerModal() { Name Active Visible - Arm + Read
{loading ? ( @@ -438,18 +386,18 @@ export function EnvelopeManagerModal() { - {/* Arm */} + {/* Read */}
@@ -869,7 +1167,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 +1228,16 @@ export function FXChainPanel({
)} + {showMidiFxControls && currentTrack && ( +
+
+ + MIDI FX +
+ +
+ )} +
{loading ? (
@@ -933,11 +1245,84 @@ 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 && ( +
+
+ setExpandedFallbackInstrument((expanded) => !expanded) + } + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setExpandedFallbackInstrument( + (expanded) => !expanded, + ); + } + }} + > +
+ +
+
+
+ +
+
+ {fallbackInstrumentDisplayName} +
+
+ + Built-in + + +
+ {expandedFallbackInstrument && ( + setExpandedFallbackInstrument(false)} + /> + )} +
+ )} {hasLoadedInstrument && (
{instrumentDisplayName}
@@ -982,379 +1370,648 @@ 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) { + setExpandedBuiltInFX( + expandedBuiltInFX === fx.index + ? null + : fx.index, + ); + if (fx.name.includes("Pitch Correct")) { + setExpandedPitchCorrector( + expandedPitchCorrector === fx.index + ? null + : fx.index, + ); + } else { + setExpandedPitchCorrector(null); + } + } 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 && !isBuiltIn && ( + - - )} - {/* 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) => ( - - ))} + {/* React built-in editor panel */} + {isBuiltIn && expandedBuiltInFX === fx.index && ( + setExpandedBuiltInFX(null)} + /> + )} + + {/* 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); + } + }} + /> +
+ )} +
+ ); })} )} @@ -1368,15 +2025,17 @@ export function FXChainPanel({
{/* Search and Filter */} -
+
setSearchTerm(e.target.value)} - className="flex-1" + className="flex-1 mr-4" + inputClassName="bg-neutral-900" + 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..368659d 100644 --- a/frontend/src/components/MainToolbar.tsx +++ b/frontend/src/components/MainToolbar.tsx @@ -1,3 +1,4 @@ +import { useEffect, useMemo, useRef, useState } from "react"; import { Repeat, Circle, @@ -6,6 +7,8 @@ import { Undo2, Redo2, Grid3x3, + Check, + ChevronDown, SlidersHorizontal, Settings, Blend, @@ -18,7 +21,17 @@ import { usePitchEditorStore } from "../store/pitchEditorStore"; import { getDisplayShortcut, getActionShortcutScopeLabel } from "../store/actionRegistry"; import { useDAWStore } from "../store/useDAWStore"; import { useShallow } from "zustand/shallow"; -import { Button } from "./ui"; +import { Button, NativeSelect } from "./ui"; +import { + calculateGridInterval, + GRID_TYPE_MODE_OPTIONS, + SNAP_TYPE_OPTIONS, + getGridSizeLabel, + getQuantizePresetById, + ticksToSeconds, + type GridSize, + type SnapType, +} from "../utils/snapToGrid"; interface MainToolbarProps { onOpenSettings: () => void; @@ -31,6 +44,10 @@ export function MainToolbar({ onToggleMixer, showMixer, }: MainToolbarProps) { + const [showQuantizePanel, setShowQuantizePanel] = useState(false); + const [quantizeApplyState, setQuantizeApplyState] = useState<"idle" | "applied">("idle"); + const gridPanelRef = useRef(null); + const applyCloseTimerRef = useRef(null); const mixerShortcut = getDisplayShortcut("view.toggleMixer") ?? "Ctrl+M"; const loopShortcut = getDisplayShortcut("transport.loop") ?? "L"; const recordShortcut = getDisplayShortcut("transport.record") ?? "Ctrl+R"; @@ -51,6 +68,17 @@ export function MainToolbar({ toggleLoop, tracks, snapEnabled, + snapType, + setSnapType, + gridSize, + setGridSize, + quantizePresetId, + quantizePresets, + setQuantizePresetId, + saveQuantizePreset, + renameQuantizePreset, + removeQuantizePreset, + restoreFactoryQuantizePresets, toggleSnap, undo, redo, @@ -64,8 +92,6 @@ export function MainToolbar({ toggleMuteTool, showPitchEditor, aiToolsStatus, - installAiTools, - reopenStemSeparation, openAiToolsSetup, } = useDAWStore( useShallow((s) => ({ @@ -78,6 +104,17 @@ export function MainToolbar({ toggleLoop: s.toggleLoop, tracks: s.tracks, snapEnabled: s.snapEnabled, + snapType: s.snapType, + setSnapType: s.setSnapType, + gridSize: s.gridSize, + setGridSize: s.setGridSize, + quantizePresetId: s.quantizePresetId, + quantizePresets: s.quantizePresets, + setQuantizePresetId: s.setQuantizePresetId, + saveQuantizePreset: s.saveQuantizePreset, + renameQuantizePreset: s.renameQuantizePreset, + removeQuantizePreset: s.removeQuantizePreset, + restoreFactoryQuantizePresets: s.restoreFactoryQuantizePresets, toggleSnap: s.toggleSnap, undo: s.undo, redo: s.redo, @@ -91,8 +128,6 @@ export function MainToolbar({ toggleMuteTool: s.toggleMuteTool, showPitchEditor: s.showPitchEditor, aiToolsStatus: s.aiToolsStatus, - installAiTools: s.installAiTools, - reopenStemSeparation: s.reopenStemSeparation, openAiToolsSetup: s.openAiToolsSetup, })), ); @@ -108,23 +143,121 @@ export function MainToolbar({ const effectiveCanUndo = showPitchEditor ? peUndoStack.length > 0 : canUndo; const effectiveCanRedo = showPitchEditor ? peRedoStack.length > 0 : canRedo; const hasArmedTracks = tracks.some((t) => t.armed); + const gridOptions = useMemo( + () => [...GRID_TYPE_MODE_OPTIONS], + [], + ); + const quantizeOptions = useMemo( + () => quantizePresets.map((preset) => ({ + value: preset.id, + label: preset.name, + })), + [quantizePresets], + ); + const selectedQuantizePreset = useMemo( + () => getQuantizePresetById(quantizePresets, quantizePresetId), + [quantizePresetId, quantizePresets], + ); + const toolbarGridLabel = getGridSizeLabel( + gridSize === "use_quantize" ? selectedQuantizePreset.gridSize : gridSize, + ); - const handleAiToolsClick = async () => { - if (aiToolsStatus.installInProgress) { - reopenStemSeparation(); - return; - } + useEffect(() => { + if (!showQuantizePanel) return undefined; + setQuantizeApplyState("idle"); + const handlePointerDown = (event: MouseEvent) => { + if (!gridPanelRef.current?.contains(event.target as Node)) { + setShowQuantizePanel(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setShowQuantizePanel(false); + }; + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [showQuantizePanel]); + + useEffect(() => { + return () => { + if (applyCloseTimerRef.current !== null) { + window.clearTimeout(applyCloseTimerRef.current); + } + }; + }, []); + + const handleApplyQuantize = () => { + const state = useDAWStore.getState(); + const preset = getQuantizePresetById(state.quantizePresets, state.quantizePresetId); + const gridSeconds = calculateGridInterval( + state.transport.tempo, + state.timeSignature, + preset.gridSize, + { + quantizePreset: preset, + quantizeGridSize: preset.gridSize, + pixelsPerSecond: state.pixelsPerSecond, + }, + ); - if (aiToolsStatus.available) { - return; + if (state.pianoRollTrackId && state.pianoRollClipId) { + state.quantizeSelectedMIDINotes( + state.pianoRollTrackId, + state.pianoRollClipId, + gridSeconds, + preset.strength, + { + presetId: preset.id, + gridSize: preset.gridSize, + mode: "start", + swing: preset.swing, + groovePreset: preset.groovePreset, + tupletDivisions: preset.tupletDivisions, + catchRangeMs: ticksToSeconds(preset.catchRangeTicks, state.transport.tempo) * 1000, + safeRangeMs: ticksToSeconds(preset.safeRangeTicks, state.transport.tempo) * 1000, + randomizeMs: ticksToSeconds(preset.roughTicks, state.transport.tempo) * 1000, + moveControllers: preset.moveControllers, + }, + ); + } else { + state.quantizeSelectedClips(); } - if (aiToolsStatus.state === "pythonMissing" || aiToolsStatus.state === "error") { - openAiToolsSetup(); - return; + setQuantizeApplyState("applied"); + if (applyCloseTimerRef.current !== null) { + window.clearTimeout(applyCloseTimerRef.current); } + applyCloseTimerRef.current = window.setTimeout(() => { + setShowQuantizePanel(false); + setQuantizeApplyState("idle"); + applyCloseTimerRef.current = null; + }, 450); + }; + + const handleSavePreset = () => { + const name = window.prompt("Quantize preset name:", selectedQuantizePreset.name); + if (!name) return; + saveQuantizePreset(name, selectedQuantizePreset); + }; + + const handleRenamePreset = () => { + if (selectedQuantizePreset.isFactory) return; + const name = window.prompt("Rename quantize preset:", selectedQuantizePreset.name); + if (!name) return; + renameQuantizePreset(selectedQuantizePreset.id, name); + }; - await installAiTools(); + const handleRemovePreset = () => { + if (selectedQuantizePreset.isFactory) return; + if (!window.confirm(`Remove quantize preset "${selectedQuantizePreset.name}"?`)) return; + removeQuantizePreset(selectedQuantizePreset.id); + }; + + const handleAiToolsClick = () => { + openAiToolsSetup(); }; const aiToolsTitle = aiToolsStatus.available @@ -153,7 +286,7 @@ export function MainToolbar({ return (
@@ -342,6 +475,91 @@ export function MainToolbar({ {/* Settings */}
+ + + {showQuantizePanel && ( +
+
+
+
Grid / Snap
+
{toolbarGridLabel}
+
+ +
+ +
+ Snap Type + setSnapType(value as SnapType)} + title="Snap Type" + /> + Grid Mode + setGridSize(value as GridSize)} + title="Grid Mode" + /> + Quantize + setQuantizePresetId(String(value))} + title="Quantize Presets" + /> +
+ +
+ Strength + {Math.round(selectedQuantizePreset.strength * 100)}% + Swing + {Math.round(selectedQuantizePreset.swing * 100)}% + Tuplet + {selectedQuantizePreset.tupletDivisions > 1 ? selectedQuantizePreset.tupletDivisions : "Off"} + Catch/Safe + {selectedQuantizePreset.catchRangeTicks} / {selectedQuantizePreset.safeRangeTicks} ticks + Rough + {selectedQuantizePreset.roughTicks} ticks +
+ +
+ + + + +
+
+ )} +
- + + + +
{/* Master Volume Knob */} @@ -210,6 +250,8 @@ export function MasterTrackHeader() { max={12} value={masterVolumeDB} onChange={handleVolumeChange} + onBeginEdit={handleMasterVolumeBeginEdit} + onCommitEdit={handleMasterVolumeCommitEdit} defaultValue={0} formatValue={formatVolume} label="Master Volume" @@ -243,23 +285,15 @@ export function MasterTrackHeader() { {paramDef.formatNormalized(paramDef.defaultNormalized)} - {/* Mode selector */} - - {/* Arm toggle */} + {/* Lane read toggle */} diff --git a/frontend/src/components/MenuBar.tsx b/frontend/src/components/MenuBar.tsx index 86abbd5..6f4dafe 100644 --- a/frontend/src/components/MenuBar.tsx +++ b/frontend/src/components/MenuBar.tsx @@ -8,6 +8,7 @@ import { useShallow } from "zustand/shallow"; import { nativeBridge } from "../services/NativeBridge"; import { usesNativeWindowChrome } from "../utils/windowEnvironment"; import { createTrackOfType } from "../utils/trackCreation"; +import { GRID_TYPE_MODE_OPTIONS, type GridSize } from "../utils/snapToGrid"; /** * Main Menu Bar Component @@ -582,54 +583,12 @@ export function MenuBar() { ], }, { - label: "Grid Size", - submenu: [ - { - label: "Bar", - onClick: () => setGridSize("bar"), - checked: gridSize === "bar", - }, - { - label: "1/2 Bar", - onClick: () => setGridSize("half_bar"), - checked: gridSize === "half_bar", - }, - { - label: "1/4 Bar", - onClick: () => setGridSize("quarter_bar"), - checked: gridSize === "quarter_bar", - }, - { - label: "1/8 Bar", - onClick: () => setGridSize("eighth_bar"), - checked: gridSize === "eighth_bar", - }, - { - label: "Beat", - onClick: () => setGridSize("beat"), - checked: gridSize === "beat", - }, - { - label: "Half Beat", - onClick: () => setGridSize("half_beat"), - checked: gridSize === "half_beat", - }, - { - label: "Quarter Beat", - onClick: () => setGridSize("quarter_beat"), - checked: gridSize === "quarter_beat", - }, - { - label: "Second", - onClick: () => setGridSize("second"), - checked: gridSize === "second", - }, - { - label: "Minute", - onClick: () => setGridSize("minute"), - checked: gridSize === "minute", - }, - ], + label: "Grid Type", + submenu: GRID_TYPE_MODE_OPTIONS.map((option) => ({ + label: option.label, + onClick: () => setGridSize(option.value as GridSize), + checked: gridSize === option.value, + })), }, ]; @@ -864,6 +823,13 @@ export function MenuBar() { }, ], }, + { + label: "MIDI Panic", + shortcut: shortcut("midi.panic", "Ctrl+Alt+P"), + onClick: () => { + void nativeBridge.panicMIDI(); + }, + }, { label: "Ripple Editing", submenu: [ @@ -998,7 +964,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/MixerPanel.tsx b/frontend/src/components/MixerPanel.tsx index 30daa72..20381d1 100644 --- a/frontend/src/components/MixerPanel.tsx +++ b/frontend/src/components/MixerPanel.tsx @@ -46,6 +46,10 @@ export function MixerPanel({ masterPan, masterLevel, masterFxCount, + masterAutomationLanes, + showMasterAutomation, + masterAutomationReadEnabled, + masterAutomationWriteEnabled, toggleMixer, reorderTrack, selectedTrackIds, @@ -61,6 +65,10 @@ export function MixerPanel({ masterPan: s.masterPan, masterLevel: s.masterLevel, masterFxCount: s.masterFxCount, + masterAutomationLanes: s.masterAutomationLanes, + showMasterAutomation: s.showMasterAutomation, + masterAutomationReadEnabled: s.masterAutomationReadEnabled, + masterAutomationWriteEnabled: s.masterAutomationWriteEnabled, toggleMixer: s.toggleMixer, reorderTrack: s.reorderTrack, selectedTrackIds: s.selectedTrackIds, @@ -224,9 +232,11 @@ export function MixerPanel({ meterLevel: masterLevel, peakLevel: masterLevel, clipping: false, - automationLanes: [], - showAutomation: false, - automationEnabled: true, + automationLanes: masterAutomationLanes, + showAutomation: showMasterAutomation, + automationReadEnabled: masterAutomationReadEnabled, + automationWriteEnabled: masterAutomationWriteEnabled, + automationEnabled: masterAutomationReadEnabled, suspendedAutomationState: null, frozen: false, takes: [], diff --git a/frontend/src/components/ParametricGraph/ParametricGraph.tsx b/frontend/src/components/ParametricGraph/ParametricGraph.tsx index a55e1ba..4ac696c 100644 --- a/frontend/src/components/ParametricGraph/ParametricGraph.tsx +++ b/frontend/src/components/ParametricGraph/ParametricGraph.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useId, useRef, useState } from "react"; import type { ParametricGraphProps, GraphNode } from "./ParametricGraph.types"; // --- Coordinate helpers --- @@ -17,6 +17,17 @@ const NODE_COLORS = [ "#ec4899", // pink ]; +const GRAPH_COLORS = { + surface: "var(--s13-graph-surface, #0a0a0a)", + plot: "var(--s13-graph-plot, #111111)", + grid: "var(--s13-graph-grid, #222222)", + gridZero: "var(--s13-graph-grid-zero, #444444)", + label: "var(--s13-graph-label, #737373)", + tooltip: "var(--s13-graph-tooltip, #d4d4d4)", + response: "var(--s13-graph-response, #38bdf8)", + responseFill: "var(--s13-graph-response-fill, rgba(56, 189, 248, 0.08))", +}; + function valueToPixelX( value: number, min: number, @@ -91,6 +102,7 @@ export function ParametricGraph({ nodes, nodeConfig, responseCurve, + backgroundCurves, perNodeCurves, onNodeAdd, onNodeChange, @@ -100,6 +112,7 @@ export function ParametricGraph({ className, }: ParametricGraphProps) { const svgRef = useRef(null); + const clipPathId = useId(); const [dragNodeId, setDragNodeId] = useState(null); const [hoveredNodeId, setHoveredNodeId] = useState(null); @@ -238,6 +251,18 @@ export function ParametricGraph({ } } + const backgroundCurvePaths = + backgroundCurves?.map((curve) => { + const pts = curve.points.map((p) => ({ + px: toPixelX(p.x), + py: toPixelY(p.y), + })); + return { + ...curve, + path: pts.map((p, i) => `${i === 0 ? "M" : "L"} ${p.px} ${p.py}`).join(" "), + }; + }) ?? []; + // Find the enabled nodes for rendering const enabledNodes = nodes.filter((n) => n.enabled); @@ -246,15 +271,15 @@ export function ParametricGraph({ ref={svgRef} width={width} height={height} - className={`select-none ${className ?? ""}`} - style={{ background: "#0a0a0a" }} + className={`parametric-graph select-none ${className ?? ""}`} + style={{ background: GRAPH_COLORS.surface }} > {/* Plot background */} ); @@ -288,7 +313,7 @@ export function ParametricGraph({ y1={py} x2={plotWidth} y2={py} - stroke={v === 0 ? "#444" : "#222"} + stroke={v === 0 ? GRAPH_COLORS.gridZero : GRAPH_COLORS.grid} strokeWidth={v === 0 ? 1 : 0.5} /> ); @@ -303,7 +328,7 @@ export function ParametricGraph({ key={`xl-${v}`} x={px} y={plotHeight + 14} - fill="#555" + fill={GRAPH_COLORS.label} fontSize={8} textAnchor="middle" > @@ -321,7 +346,7 @@ export function ParametricGraph({ key={`yl-${v}`} x={-6} y={py + 3} - fill="#555" + fill={GRAPH_COLORS.label} fontSize={8} textAnchor="end" > @@ -334,11 +359,24 @@ export function ParametricGraph({ {responseAreaPath && ( )} + {/* Background analyzer/modulation curves */} + {backgroundCurvePaths.map((curve) => ( + + ))} + {/* Per-node individual curves */} {nodeCurvePaths.map((nc) => { const node = enabledNodes.find((n) => n.id === nc.nodeId); @@ -352,7 +390,7 @@ export function ParametricGraph({ stroke={color} strokeWidth={1} strokeOpacity={hoveredNodeId === nc.nodeId ? 0.6 : 0.2} - clipPath="url(#plotClip)" + clipPath={`url(#${clipPathId})`} /> ); })} @@ -362,15 +400,15 @@ export function ParametricGraph({ )} {/* Clip path for curves */} - + @@ -408,7 +446,7 @@ export function ParametricGraph({ r={isDragging ? 7 : isHovered ? 6 : 5} fill={color} fillOpacity={0.85} - stroke={isHovered || isDragging ? "#fff" : color} + stroke={isHovered || isDragging ? "var(--s13-graph-node-stroke, #ffffff)" : color} strokeWidth={isHovered || isDragging ? 1.5 : 1} strokeOpacity={0.8} pointerEvents="none" @@ -432,7 +470,7 @@ export function ParametricGraph({ ; perNodeCurves?: { nodeId: string; points: { x: number; y: number }[] }[]; onNodeAdd?: (x: number, y: number) => void; onNodeChange?: (id: string, changes: Partial) => void; diff --git a/frontend/src/components/PianoRoll.css b/frontend/src/components/PianoRoll.css index b9608e8..731b17b 100644 --- a/frontend/src/components/PianoRoll.css +++ b/frontend/src/components/PianoRoll.css @@ -3,23 +3,855 @@ 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-snap-type-select { + width: 120px; +} + +.piano-roll-grid-select { + width: 98px; +} + +.piano-roll-quantize-preset-select { + width: 116px; +} + +.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 +876,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 +902,8 @@ appearance: none; width: 12px; height: 12px; - background: #4cc9f0; + background: #64c7e8; + border: 1px solid #d8f5ff; border-radius: 50%; cursor: pointer; } @@ -88,23 +923,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 +1015,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 +1053,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 +1065,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..a9cd337 100644 --- a/frontend/src/components/PianoRoll.tsx +++ b/frontend/src/components/PianoRoll.tsx @@ -2,8 +2,105 @@ 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, + projectXToMidiSourceTime, +} from "../utils/timelineGeometry"; +import { + guardModalContextMenu, + shouldSuppressWorkspaceContextMenu, +} from "../utils/modalEventGuards"; +import { + calculateGridInterval, + GRID_SIZE_GROUPS, + GRID_TICKS_PER_SIXTEENTH, + GRID_TYPE_MODE_OPTIONS, + getGridSizeLabel, + getQuantizePresetById, + resolveVisualGrid, + snapTimeByType, + ticksToSeconds, + type GridSize, + type QuantizeGroovePreset, +} from "../utils/snapToGrid"; +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 +109,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 +149,79 @@ 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 TransformDialogState = + | { type: "quantize"; value: number; gridSize: GridSize; strength: number; mode: QuantizeMode; swing: number; groovePreset: QuantizeGroovePreset; tupletDivisions: number; catchRangeTicks: number; safeRangeTicks: number; roughTicks: number; fixedLength: number; fixedLengthGridSize: GridSize | "quantize_link"; moveControllers: boolean; autoApply: 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 +250,254 @@ 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 GRID_SNAP = 0.25; +const PIANO_WIDTH = 0; +const PIANO_KEY_STRIP_MIN_WIDTH = 56; +const PIANO_KEY_STRIP_MAX_WIDTH = 86; 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, + snapEnabled, + snapType, + gridSize, + quantizePresetId, + quantizePresets, + 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, + snapEnabled: state.snapEnabled, + snapType: state.snapType, + gridSize: state.gridSize, + quantizePresetId: state.quantizePresetId, + quantizePresets: state.quantizePresets, + 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 +506,72 @@ 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, + toggleSnap, + setSnapType, + setGridSize, + setQuantizePresetId, 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 +580,206 @@ 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, + toggleSnap: state.toggleSnap, + setSnapType: state.setSnapType, + setGridSize: state.setGridSize, + setQuantizePresetId: state.setQuantizePresetId, 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 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 pixelsPerSecond = timelinePixelsPerSecond; + const scrollX = timelineScrollX - clipStartTime * pixelsPerSecond; + const activeQuantizePreset = useMemo( + () => getQuantizePresetById(quantizePresets, quantizePresetId), + [quantizePresetId, quantizePresets], + ); + const snapDuration = useMemo( + () => calculateGridInterval(tempo, timeSignature, gridSize, { + quantizePreset: activeQuantizePreset, + quantizeGridSize: activeQuantizePreset.gridSize, + pixelsPerSecond, + }), + [activeQuantizePreset, gridSize, pixelsPerSecond, tempo, timeSignature], + ); + const stepDurationSeconds = snapDuration || stepInputSize / beatsPerSecond; + 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 +798,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 contentWidth = Math.max(visibleGridWidth, contentDuration * pixelsPerSecond); + 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 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 +927,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 +939,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; @@ -489,19 +1003,148 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll return clamp(TOTAL_NOTES - 1 - Math.floor((y + scrollY) / NOTE_HEIGHT), 0, 127); }, [scrollY]); - const snapTime = useCallback((time: number): number => { - return Math.max(0, Math.round(time / snapDuration) * snapDuration); - }, [snapDuration]); + const midiSnapEventTimes = useMemo(() => { + const times: number[] = []; + for (const pair of notePairs) { + times.push(pair.startTime, pair.startTime + pair.duration); + } + for (const event of clipCCEvents || []) { + times.push(event.time); + } + for (const event of clipEvents || []) { + if (Number.isFinite(event.timestamp)) times.push(event.timestamp); + } + return times.filter((time) => Number.isFinite(time) && time >= 0); + }, [clipCCEvents, clipEvents, notePairs]); + + const snapTime = useCallback(( + time: number, + options: { bypassSnap?: boolean; originalTime?: number } = {}, + ): number => { + if (!snapEnabled || options.bypassSnap) return Math.max(0, time); + const projectTime = clipStartTime + time; + const originalProjectTime = options.originalTime === undefined + ? undefined + : clipStartTime + options.originalTime; + const snappedProjectTime = snapTimeByType({ + time: projectTime, + originalTime: originalProjectTime, + tempo, + timeSignature, + gridSize, + snapType, + quantizePreset: activeQuantizePreset, + quantizeGridSize: activeQuantizePreset.gridSize, + pixelsPerSecond, + cursorTime: useDAWStore.getState().transport.currentTime, + eventTimes: midiSnapEventTimes.map((eventTime) => clipStartTime + eventTime), + }); + return Math.max(0, snappedProjectTime - clipStartTime); + }, [ + activeQuantizePreset, + clipStartTime, + gridSize, + midiSnapEventTimes, + pixelsPerSecond, + snapEnabled, + snapType, + tempo, + timeSignature, + ]); 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 +1161,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 +1172,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 +1185,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 +1196,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); + } + 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); } - }, [scrollX]); + }, [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 +1338,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 +1377,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 +1388,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 +1541,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 +1571,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 +1590,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)); + setStepInputPosition(stepInputPosition + stepDurationSeconds); + setTimelineScroll(clamp(timelineScrollX + stepDurationSeconds * pixelsPerSecond, 0, maxScrollX), timelineScrollY); return; } @@ -724,69 +1606,1158 @@ 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); - advanceStepInput(); + auditionNote(noteNumber, pianoRollInsertVelocity); + setStepInputPosition(stepInputPosition + stepDurationSeconds); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [ addMIDINote, - advanceStepInput, 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]); + }, [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)]); + }, [ + ccLaneHeight, + ccLaneY, + getTimeFromX, + makeSelectedScalarMIDIEvent, + selectedScalarMIDIEventMatches, + selectedScalarMIDIEventType, + snapDuration, + snapTime, + ]); - const handleVelocityMouseDown = useCallback((event: KonvaEvent) => { - const pos = getPointer(event); - if (!pos || pos.y < velocityLaneY || pos.y >= velocityLaneY + VELOCITY_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 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 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 id = noteIdFor(clipId, pair.startTime, pair.noteNumber); - const velocity = getVelocityFromLaneY(pos.y); - setSelectedNoteIds([id]); - setVelocityEdit({ - noteId: id, - timestamp: pair.startTime, - noteNumber: pair.noteNumber, + 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 }); @@ -801,39 +2772,88 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll scrollX, trackId, updateMIDINoteVelocity, + velocityLaneHeight, velocityLaneY, ]); const handleCCMouseDown = useCallback((event: KonvaEvent) => { const pos = getPointer(event); - if (!pos || pos.y < ccLaneY || pos.y >= ccLaneY + CC_LANE_HEIGHT) return; + 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; const pos = getPointer(event); if (!pos) return; - const pointerTime = snapTime(getTimeFromX(pos.x)); + const nativeEvent = event.evt as MouseEvent; + const bypassSnap = Boolean(nativeEvent?.ctrlKey || nativeEvent?.metaKey); + const pointerTime = snapTime(getTimeFromX(pos.x), { + bypassSnap, + originalTime: dragState.startPointerTime, + }); 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 +2868,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 +2902,172 @@ 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; } - const modifier = nativeEvent.ctrlKey || nativeEvent.metaKey || nativeEvent.shiftKey; + 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), { + bypassSnap: Boolean(nativeEvent.ctrlKey || nativeEvent.metaKey), + originalTime: pair.startTime, + }); + 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.shiftKey; + if ( + tool === "select" + && nativeEvent.altKey + && 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), { + bypassSnap: Boolean(nativeEvent.ctrlKey || nativeEvent.metaKey), + originalTime: pair.startTime, + }), + 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 +3080,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" @@ -921,7 +3094,10 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll mode, noteIds: mode === "move" ? nextSelection : [id], originalEvents: getLatestClipEvents(), - startPointerTime: snapTime(getTimeFromX(pos.x)), + startPointerTime: snapTime(getTimeFromX(pos.x), { + bypassSnap: Boolean(nativeEvent.ctrlKey || nativeEvent.metaKey), + originalTime: pair.startTime, + }), startPointerNote: pair.noteNumber, activeNoteId: id, }); @@ -932,66 +3108,325 @@ 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 (pos.y >= ccLaneY && pos.y < ccLaneY + CC_LANE_HEIGHT) { + 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 (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 nativeEvent = event.evt as MouseEvent; + const ctrlSnapBypass = Boolean(nativeEvent.ctrlKey || nativeEvent.metaKey); + const rawTime = hitTarget.kind === "grid" ? hitTarget.time : getTimeFromX(pos.x); + const time = snapTime(rawTime, { bypassSnap: ctrlSnapBypass, originalTime: rawTime }); + const note = hitTarget.kind === "grid" ? hitTarget.noteNumber : getNoteFromY(pos.y); + setPianoRollEditCursorTime(time); + + const selectionModifier = nativeEvent.shiftKey; + + if (tool === "range") { + setSelectedNoteIds([]); + setRangeDragState({ + startX: pos.x, + startY: pos.y, + currentX: pos.x, + currentY: pos.y, + }); + return; + } - if (tool === "draw") { + 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" : "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) { + const nativeEvent = event.evt as MouseEvent; + setStageCursor("ew-resize"); + const nextX = clamp(pos.x, PIANO_WIDTH, stageWidth); + setLoopBoundaryDrag((current) => current ? { ...current, currentX: nextX } : null); + setPianoRollEditCursorTime(snapTime(getTimeFromX(nextX), { + bypassSnap: Boolean(nativeEvent.ctrlKey || nativeEvent.metaKey), + originalTime: 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); @@ -999,17 +3434,36 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll } if (drawingState) { + const nativeEvent = event.evt as MouseEvent; setDrawingState((current) => { if (!current) return null; + const rawTime = getTimeFromX(pos.x); return { ...current, - endTime: Math.min(clipDuration, Math.max(0, snapTime(getTimeFromX(pos.x)))), + endTime: Math.max(0, snapTime(rawTime, { + bypassSnap: Boolean(nativeEvent.ctrlKey || nativeEvent.metaKey), + originalTime: current.startTime, + })), 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 +3471,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 +3625,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 +3683,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,70 +3710,50 @@ 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, - ]); - - useEffect(() => { - window.addEventListener("mouseup", handleStageMouseUp); - return () => window.removeEventListener("mouseup", handleStageMouseUp); - }, [handleStageMouseUp]); - - if (!clip || !track) { - 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; - }; + velocityEdit, + ]); + + useEffect(() => { + window.addEventListener("mouseup", handleStageMouseUp); + return () => window.removeEventListener("mouseup", handleStageMouseUp); + }, [handleStageMouseUp]); + + if (!clip || !track) { + return
No MIDI clip selected
; + } 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 +3762,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 +3770,6 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll />, ); } - beatIndex += 1; } for (let row = firstRow; row <= lastRow; row += 1) { @@ -1217,7 +3812,7 @@ export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRoll elements.push( dimensions.width) continue; - const isBeat = Math.abs(time % beatInterval) < 0.001; + const visualGrid = resolveVisualGrid(tempo, timeSignature, gridSize, { + quantizePreset: activeQuantizePreset, + quantizeGridSize: activeQuantizePreset.gridSize, + pixelsPerSecond, + viewportPixels: visibleGridWidth, + maxVisibleLines: 180, + minPixelsPerGrid: 18, + }); + const pushDivisionLine = (projectTime: number, key: string) => { + if (projectTime > projectContentEnd) return; + const x = projectTime * pixelsPerSecond - timelineScrollX; + if (x < PIANO_WIDTH || x > stageWidth) return; + const beatIndex = projectTime / beatInterval; + const isBeat = Math.abs(beatIndex - Math.round(beatIndex)) < 0.0001; + const barIndex = projectTime / Math.max(0.000001, visualGrid.barInterval); + const isBar = Math.abs(barIndex - Math.round(barIndex)) < 0.0001; elements.push( , ); + }; + + if (visualGrid.alignedToBar) { + const startBar = Math.floor(visibleProjectStart / visualGrid.barInterval) - 1; + const endBar = Math.ceil(Math.min(visibleProjectEnd, projectContentEnd) / visualGrid.barInterval) + 1; + for (let bar = Math.max(0, startBar); bar <= endBar; bar += 1) { + const barTime = bar * visualGrid.barInterval; + for (let division = 0; division < visualGrid.divisionsPerBar; division += 1) { + pushDivisionLine(barTime + division * visualGrid.visualInterval, `v-bar-${bar}-${division}`); + } + } + } else { + const firstDivisionIndex = Math.max(0, Math.floor(visibleProjectStart / visualGrid.visualInterval)); + const lastDivisionIndex = Math.ceil(Math.min(visibleProjectEnd, projectContentEnd) / visualGrid.visualInterval); + for (let divisionIndex = firstDivisionIndex; divisionIndex <= lastDivisionIndex; divisionIndex += 1) { + pushDivisionLine(divisionIndex * visualGrid.visualInterval, `v-${divisionIndex}`); + } } return elements; }; + const renderLoopBoundaries = () => { + 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 +3904,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 +4052,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 +4109,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; + const rawTime = Math.max(0, (x + timelineScrollX) / pixelsPerSecond); + if (!snapEnabled || bypassSnap) return rawTime; + return snapTimeByType({ + time: rawTime, + tempo, + timeSignature, + gridSize, + snapType, + quantizePreset: activeQuantizePreset, + quantizeGridSize: activeQuantizePreset.gridSize, + pixelsPerSecond, + cursorTime: useDAWStore.getState().transport.currentTime, + eventTimes: midiSnapEventTimes.map((time) => clipStartTime + time), + }); + }, [ + activeQuantizePreset, + clipStartTime, + gridSize, + midiSnapEventTimes, + pixelsPerSecond, + snapEnabled, + snapType, + tempo, + timeSignature, + 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 openQuantizePanel = useCallback((overrides: Partial> = {}) => { + const preset = activeQuantizePreset; + const panelGridSize = overrides.gridSize ?? preset.gridSize; + const panelGridSeconds = calculateGridInterval(tempo, timeSignature, panelGridSize, { + quantizePreset: preset, + quantizeGridSize: preset.gridSize, + pixelsPerSecond, + }); + openTransformDialog({ + type: "quantize", + value: panelGridSeconds, + gridSize: panelGridSize, + strength: overrides.strength ?? preset.strength, + mode: overrides.mode ?? "start", + swing: overrides.swing ?? preset.swing, + groovePreset: overrides.groovePreset ?? preset.groovePreset, + tupletDivisions: overrides.tupletDivisions ?? preset.tupletDivisions, + catchRangeTicks: overrides.catchRangeTicks ?? preset.catchRangeTicks, + safeRangeTicks: overrides.safeRangeTicks ?? preset.safeRangeTicks, + roughTicks: overrides.roughTicks ?? preset.roughTicks, + fixedLength: overrides.fixedLength ?? panelGridSeconds, + fixedLengthGridSize: overrides.fixedLengthGridSize ?? "quantize_link", + moveControllers: overrides.moveControllers ?? preset.moveControllers, + autoApply: overrides.autoApply ?? false, + }); + }, [activeQuantizePreset, pixelsPerSecond, tempo, timeSignature]); + + const applyCurrentQuantizePreset = useCallback((mode: QuantizeMode = "start") => { + const preset = activeQuantizePreset; + const gridSeconds = calculateGridInterval(tempo, timeSignature, preset.gridSize, { + quantizePreset: preset, + quantizeGridSize: preset.gridSize, + pixelsPerSecond, + }); + const nextIds = quantizeSelectedMIDINotes(trackId, clipId, gridSeconds, preset.strength, { + presetId: preset.id, + gridSize: preset.gridSize, + mode, + swing: preset.swing, + groovePreset: preset.groovePreset, + tupletDivisions: preset.tupletDivisions, + catchRangeMs: ticksToSeconds(preset.catchRangeTicks, tempo) * 1000, + safeRangeMs: ticksToSeconds(preset.safeRangeTicks, tempo) * 1000, + randomizeMs: ticksToSeconds(preset.roughTicks, tempo) * 1000, + fixedLength: mode === "length" ? gridSeconds : undefined, + moveControllers: preset.moveControllers, + }); + if (nextIds.length > 0) setSelectedNoteIds(nextIds); + }, [ + activeQuantizePreset, + clipId, + pixelsPerSecond, + quantizeSelectedMIDINotes, + setSelectedNoteIds, + tempo, + timeSignature, + trackId, + ]); + + 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: () => openQuantizePanel() }, + { 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 applyQuantizeDialogSettings = useCallback((settings: Extract) => { + const quantizeGridSeconds = calculateGridInterval(tempo, timeSignature, settings.gridSize, { + quantizePreset: activeQuantizePreset, + quantizeGridSize: activeQuantizePreset.gridSize, + pixelsPerSecond, + }); + const fixedLengthSeconds = settings.fixedLengthGridSize === "quantize_link" + ? quantizeGridSeconds + : calculateGridInterval(tempo, timeSignature, settings.fixedLengthGridSize, { + quantizePreset: activeQuantizePreset, + quantizeGridSize: activeQuantizePreset.gridSize, + pixelsPerSecond, + }); + const nextIds = quantizeSelectedMIDINotes(trackId, clipId, quantizeGridSeconds, settings.strength, { + presetId: activeQuantizePreset.id, + gridSize: settings.gridSize, + mode: settings.mode, + swing: settings.swing, + groovePreset: settings.groovePreset, + tupletDivisions: settings.tupletDivisions, + catchRangeMs: ticksToSeconds(settings.catchRangeTicks, tempo) * 1000, + safeRangeMs: ticksToSeconds(settings.safeRangeTicks, tempo) * 1000, + randomizeMs: ticksToSeconds(settings.roughTicks, tempo) * 1000, + fixedLength: settings.mode === "length" ? fixedLengthSeconds : undefined, + moveControllers: settings.moveControllers, + }); + if (nextIds.length > 0) setSelectedNoteIds(nextIds); + }, [ + activeQuantizePreset, + clipId, + pixelsPerSecond, + quantizeSelectedMIDINotes, + setSelectedNoteIds, + tempo, + timeSignature, + trackId, + ]); + + useEffect(() => { + if (transformDialog?.type !== "quantize" || !transformDialog.autoApply) return undefined; + const timeoutId = window.setTimeout(() => applyQuantizeDialogSettings(transformDialog), 250); + return () => window.clearTimeout(timeoutId); + }, [applyQuantizeDialogSettings, transformDialog]); + + const applyTransformDialog = () => { + if (!transformDialog) return; + if (transformDialog.type === "quantize") { + applyQuantizeDialogSettings(transformDialog); + } 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); + }} + snapEnabled={snapEnabled} + onSnapToggle={toggleSnap} + snapType={snapType} + onSnapTypeChange={setSnapType} + gridSize={gridSize} + onGridSizeChange={setGridSize} + gridTypeOptions={GRID_TYPE_MODE_OPTIONS} + quantizePresetId={quantizePresetId} + quantizePresets={quantizePresets} + onQuantizePresetChange={setQuantizePresetId} + onApplyQuantize={() => applyCurrentQuantizePreset("start")} + onLengthQuantize={() => applyCurrentQuantizePreset("length")} + onQuantizeLast={() => { + const nextIds = quantizeSelectedMIDINotesUsingLast(trackId, clipId); + if (nextIds.length > 0) setSelectedNoteIds(nextIds); + }} + onOpenQuantizeDialog={() => openQuantizePanel()} + 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"} + /> + +