From ef51712f58b3ea605a7ec55313836ea33477424f Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Sun, 1 Mar 2026 09:58:31 +0530 Subject: [PATCH 1/6] chore: Implemented base version of inbuilt plugins and automation with lua, testing remaining --- CMakeLists.txt | 63 +- Source/AudioEngine.cpp | 258 ++++- Source/AudioEngine.h | 18 + Source/MainComponent.cpp | 92 ++ Source/PluginManager.cpp | 90 +- Source/PluginManager.h | 40 +- Source/S13FXProcessor.cpp | 389 ++++++++ Source/S13FXProcessor.h | 90 ++ Source/ScriptEngine.cpp | 898 ++++++++++++++++++ Source/ScriptEngine.h | 66 ++ THIRD_PARTY_LICENSES.md | 114 +++ effects/chorus.jsfx | 68 ++ effects/chorus_advanced.jsfx | 206 ++++ effects/compressor.jsfx | 48 + effects/compressor_advanced.jsfx | 158 +++ effects/delay.jsfx | 79 ++ effects/delay_advanced.jsfx | 156 +++ effects/eq3.jsfx | 78 ++ effects/eq_advanced.jsfx | 224 +++++ effects/gain.jsfx | 39 + effects/gate.jsfx | 64 ++ effects/gate_advanced.jsfx | 149 +++ effects/reverb.jsfx | 108 +++ effects/reverb_advanced.jsfx | 212 +++++ effects/saturation.jsfx | 46 + effects/saturation_advanced.jsfx | 140 +++ frontend/src/components/FXChainPanel.tsx | 281 +++++- .../ParametricGraph/ChorusGraph.tsx | 184 ++++ .../ParametricGraph/CompressorGraph.tsx | 182 ++++ .../components/ParametricGraph/DelayGraph.tsx | 162 ++++ .../components/ParametricGraph/EQGraph.tsx | 216 +++++ .../components/ParametricGraph/GateGraph.tsx | 166 ++++ .../ParametricGraph/ParametricGraph.tsx | 453 +++++++++ .../ParametricGraph/ParametricGraph.types.ts | 48 + .../ParametricGraph/ReverbGraph.tsx | 148 +++ .../ParametricGraph/SaturationGraph.tsx | 199 ++++ .../ParametricGraph/eqResponseCurve.ts | 201 ++++ .../src/components/ParametricGraph/index.ts | 9 + frontend/src/components/PluginBrowser.tsx | 59 +- frontend/src/components/ScriptEditor.tsx | 213 ++++- frontend/src/services/NativeBridge.ts | 212 +++++ frontend/src/store/useDAWStore.ts | 9 + new_features.md | 14 +- scripts/add_tracks.lua | 21 + scripts/hello_world.lua | 15 + scripts/project_info.lua | 41 + scripts/set_all_volumes.lua | 26 + scripts/tempo_ramp.lua | 16 + 48 files changed, 6656 insertions(+), 112 deletions(-) create mode 100644 Source/S13FXProcessor.cpp create mode 100644 Source/S13FXProcessor.h create mode 100644 Source/ScriptEngine.cpp create mode 100644 Source/ScriptEngine.h create mode 100644 THIRD_PARTY_LICENSES.md create mode 100644 effects/chorus.jsfx create mode 100644 effects/chorus_advanced.jsfx create mode 100644 effects/compressor.jsfx create mode 100644 effects/compressor_advanced.jsfx create mode 100644 effects/delay.jsfx create mode 100644 effects/delay_advanced.jsfx create mode 100644 effects/eq3.jsfx create mode 100644 effects/eq_advanced.jsfx create mode 100644 effects/gain.jsfx create mode 100644 effects/gate.jsfx create mode 100644 effects/gate_advanced.jsfx create mode 100644 effects/reverb.jsfx create mode 100644 effects/reverb_advanced.jsfx create mode 100644 effects/saturation.jsfx create mode 100644 effects/saturation_advanced.jsfx create mode 100644 frontend/src/components/ParametricGraph/ChorusGraph.tsx create mode 100644 frontend/src/components/ParametricGraph/CompressorGraph.tsx create mode 100644 frontend/src/components/ParametricGraph/DelayGraph.tsx create mode 100644 frontend/src/components/ParametricGraph/EQGraph.tsx create mode 100644 frontend/src/components/ParametricGraph/GateGraph.tsx create mode 100644 frontend/src/components/ParametricGraph/ParametricGraph.tsx create mode 100644 frontend/src/components/ParametricGraph/ParametricGraph.types.ts create mode 100644 frontend/src/components/ParametricGraph/ReverbGraph.tsx create mode 100644 frontend/src/components/ParametricGraph/SaturationGraph.tsx create mode 100644 frontend/src/components/ParametricGraph/eqResponseCurve.ts create mode 100644 frontend/src/components/ParametricGraph/index.ts create mode 100644 scripts/add_tracks.lua create mode 100644 scripts/hello_world.lua create mode 100644 scripts/project_info.lua create mode 100644 scripts/set_all_volumes.lua create mode 100644 scripts/tempo_ramp.lua diff --git a/CMakeLists.txt b/CMakeLists.txt index 0774f49..d4c704c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,6 +18,39 @@ FetchContent_Declare( ) FetchContent_MakeAvailable(juce) +# Fetch YSFX — open-source JSFX runtime (Apache 2.0) +FetchContent_Declare( + ysfx + GIT_REPOSITORY https://github.com/jpcima/ysfx.git + GIT_TAG master + GIT_SHALLOW TRUE +) +set(YSFX_PLUGIN OFF CACHE BOOL "" FORCE) +set(YSFX_TOOLS OFF CACHE BOOL "" FORCE) +set(YSFX_TESTS OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(ysfx) + +# Fetch Lua 5.4 — scripting engine (MIT license) +FetchContent_Declare( + lua + URL "https://www.lua.org/ftp/lua-5.4.7.tar.gz" +) +FetchContent_MakeAvailable(lua) + +# Build Lua as a static library (it ships as raw C sources) +file(GLOB LUA_CORE_SOURCES "${lua_SOURCE_DIR}/src/*.c") +# Remove lua.c and luac.c (standalone interpreter/compiler, not needed) +list(FILTER LUA_CORE_SOURCES EXCLUDE REGEX "lua\\.c$|luac\\.c$") +add_library(lua_static STATIC ${LUA_CORE_SOURCES}) +target_include_directories(lua_static PUBLIC "${lua_SOURCE_DIR}/src") +if(MSVC) + target_compile_definitions(lua_static PRIVATE _CRT_SECURE_NO_WARNINGS) + # Suppress Lua C code warnings (third-party code) + target_compile_options(lua_static PRIVATE /W0) +else() + target_compile_options(lua_static PRIVATE -w) +endif() + # Define the JUCE applicati# Add our executable juce_add_gui_app(Studio13_v2 PRODUCT_NAME "Studio13" @@ -42,15 +75,19 @@ target_sources(Studio13_v2 PRIVATE Source/TrackProcessor.cpp Source/Metronome.cpp Source/AudioAnalyzer.cpp + Source/S13FXProcessor.cpp + Source/ScriptEngine.cpp ) # Include directories -target_include_directories(Studio13_v2 PRIVATE +target_include_directories(Studio13_v2 PRIVATE Source # ASIO SDK include paths "C:/SDKs/ASIO-SDK_2.3.4_2025-10-15/ASIOSDK/common" "C:/SDKs/ASIO-SDK_2.3.4_2025-10-15/ASIOSDK/host" "C:/SDKs/ASIO-SDK_2.3.4_2025-10-15/ASIOSDK/host/pc" + # YSFX include paths + "${ysfx_SOURCE_DIR}/include" ) # Link against JUCE modules @@ -67,6 +104,8 @@ target_link_libraries(Studio13_v2 PRIVATE juce::juce_audio_processors juce::juce_audio_utils juce::juce_dsp + ysfx + lua_static ) # Enable WebView2 on Windows @@ -128,6 +167,28 @@ else() message(WARNING "tools/ffmpeg.exe not found. Run: powershell -ExecutionPolicy Bypass -File tools/setup-ffmpeg.ps1") endif() +# Copy bundled JSFX effects to build output +set(EFFECTS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/effects") +if(EXISTS "${EFFECTS_DIR}") + add_custom_command(TARGET Studio13_v2 POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${EFFECTS_DIR}" + "$/effects" + COMMENT "Copying stock JSFX effects to output directory" + ) +endif() + +# Copy bundled Lua scripts to build output +set(SCRIPTS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/scripts") +if(EXISTS "${SCRIPTS_DIR}") + add_custom_command(TARGET Studio13_v2 POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${SCRIPTS_DIR}" + "$/scripts" + COMMENT "Copying stock Lua scripts to output directory" + ) +endif() + # Prepare for BinaryData (Frontend assets) # We will uncomment this once the frontend build is set up # juce_add_binary_data(FrontendAssets diff --git a/Source/AudioEngine.cpp b/Source/AudioEngine.cpp index a415f5d..5a7c088 100644 --- a/Source/AudioEngine.cpp +++ b/Source/AudioEngine.cpp @@ -1,4 +1,5 @@ #include "AudioEngine.h" +#include "S13FXProcessor.h" // Debug logging — always active for FX diagnostics static void logToDisk(const juce::String& msg) @@ -31,7 +32,11 @@ AudioEngine::AudioEngine() loadDeviceSettings(); deviceManager.addAudioCallback (this); + // Initialize Lua scripting engine (runs on message thread only) + scriptEngine.registerAPI(*this); + juce::Logger::writeToLog("AudioEngine: MIDI Manager initialized"); + juce::Logger::writeToLog("AudioEngine: Lua ScriptEngine initialized"); } AudioEngine::~AudioEngine() @@ -1323,6 +1328,237 @@ bool AudioEngine::addTrackFX(const juce::String& trackId, const juce::String& pl return success; } +//============================================================================== +// S13FX (JSFX) Management + +bool AudioEngine::addTrackS13FX(const juce::String& trackId, const juce::String& scriptPath, bool isInputFX) +{ + // Create S13FXProcessor and load script BEFORE acquiring the lock + auto s13fx = std::make_unique(); + if (!s13fx->loadScript(scriptPath)) + { + juce::Logger::writeToLog("AudioEngine: Failed to load S13FX script: " + scriptPath); + return false; + } + + // Provide tempo/position info + s13fx->setPlayHead(this); + + double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; + int bs = juce::jmax(currentBlockSize > 0 ? currentBlockSize : 512, 512); + + bool success = false; + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + { + if (isInputFX) + success = it->second->addInputFX(std::move(s13fx), sr, bs); + else + success = it->second->addTrackFX(std::move(s13fx), sr, bs); + } + } + + if (success) + juce::Logger::writeToLog("AudioEngine: Added S13FX to " + juce::String(isInputFX ? "input" : "track") + " chain: " + scriptPath); + + return success; +} + +bool AudioEngine::addMasterS13FX(const juce::String& scriptPath) +{ + auto s13fx = std::make_unique(); + if (!s13fx->loadScript(scriptPath)) + { + juce::Logger::writeToLog("AudioEngine: Failed to load S13FX for master: " + scriptPath); + return false; + } + + s13fx->setPlayHead(this); + + double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; + int bs = juce::jmax(currentBlockSize > 0 ? currentBlockSize : 512, 512); + + s13fx->prepareToPlay(sr, bs); + + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto node = masterFXChain->addNode(std::move(s13fx)); + if (node) + { + masterFXNodes.push_back(node); + juce::Logger::writeToLog("AudioEngine: Added S13FX to master chain: " + scriptPath); + return true; + } + } + + return false; +} + +juce::var AudioEngine::getS13FXSliders(const juce::String& trackId, int fxIndex, bool isInputFX) +{ + juce::Array sliderList; + + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return sliderList; + + auto* track = it->second; + juce::AudioProcessor* proc = isInputFX + ? track->getInputFXProcessor(fxIndex) + : track->getTrackFXProcessor(fxIndex); + + if (!proc) + return sliderList; + + auto* s13fx = dynamic_cast(proc); + if (!s13fx) + return sliderList; + + auto sliders = s13fx->getSliders(); + for (const auto& s : sliders) + { + auto* obj = new juce::DynamicObject(); + obj->setProperty("index", static_cast(s.index)); + obj->setProperty("name", s.name); + obj->setProperty("min", s.min); + obj->setProperty("max", s.max); + obj->setProperty("def", s.def); + obj->setProperty("inc", s.inc); + obj->setProperty("value", s.value); + obj->setProperty("isEnum", s.isEnum); + + if (s.isEnum) + { + juce::Array names; + for (const auto& n : s.enumNames) + names.add(n); + obj->setProperty("enumNames", names); + } + + sliderList.add(juce::var(obj)); + } + + return sliderList; +} + +bool AudioEngine::setS13FXSlider(const juce::String& trackId, int fxIndex, bool isInputFX, int sliderIndex, double value) +{ + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return false; + + auto* track = it->second; + juce::AudioProcessor* proc = isInputFX + ? track->getInputFXProcessor(fxIndex) + : track->getTrackFXProcessor(fxIndex); + + if (!proc) + return false; + + auto* s13fx = dynamic_cast(proc); + if (!s13fx) + return false; + + return s13fx->setSliderValue(static_cast(sliderIndex), value); +} + +bool AudioEngine::reloadS13FX(const juce::String& trackId, int fxIndex, bool isInputFX) +{ + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return false; + + auto* track = it->second; + juce::AudioProcessor* proc = isInputFX + ? track->getInputFXProcessor(fxIndex) + : track->getTrackFXProcessor(fxIndex); + + if (!proc) + return false; + + auto* s13fx = dynamic_cast(proc); + if (!s13fx) + return false; + + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + return s13fx->reloadScript(); +} + +juce::var AudioEngine::getAvailableS13FX() +{ + pluginManager.scanForS13FX(); + + juce::Array list; + for (const auto& info : pluginManager.getAvailableS13FX()) + { + auto* obj = new juce::DynamicObject(); + obj->setProperty("name", info.name); + obj->setProperty("filePath", info.filePath); + obj->setProperty("author", info.author); + obj->setProperty("isStock", info.isStock); + obj->setProperty("type", "s13fx"); + + juce::Array tags; + for (const auto& tag : info.tags) + tags.add(tag); + obj->setProperty("tags", tags); + + list.add(juce::var(obj)); + } + + return list; +} + +//============================================================================== +// Lua Scripting (S13Script) + +juce::var AudioEngine::runScript(const juce::String& scriptPath) +{ + auto* result = new juce::DynamicObject(); + bool success = scriptEngine.loadAndRun(scriptPath); + result->setProperty("success", success); + result->setProperty("output", scriptEngine.getLastOutput()); + if (!success) + result->setProperty("error", scriptEngine.getLastError()); + return juce::var(result); +} + +juce::var AudioEngine::runScriptCode(const juce::String& luaCode) +{ + auto* result = new juce::DynamicObject(); + bool success = scriptEngine.executeString(luaCode); + result->setProperty("success", success); + result->setProperty("output", scriptEngine.getLastOutput()); + if (!success) + result->setProperty("error", scriptEngine.getLastError()); + return juce::var(result); +} + +juce::String AudioEngine::getScriptDirectory() +{ + return ScriptEngine::getUserScriptsDirectory().getFullPathName(); +} + +juce::var AudioEngine::listScripts() +{ + auto scripts = scriptEngine.listAvailableScripts(); + juce::Array list; + + for (const auto& info : scripts) + { + auto* obj = new juce::DynamicObject(); + obj->setProperty("name", info.name); + obj->setProperty("filePath", info.filePath); + obj->setProperty("description", info.description); + obj->setProperty("isStock", info.isStock); + list.add(juce::var(obj)); + } + + return list; +} + //============================================================================== // Plugin Editor Windows (Phase 3) @@ -1434,9 +1670,16 @@ juce::var AudioEngine::getTrackInputFX(const juce::String& trackId) juce::DynamicObject::Ptr fxInfo = new juce::DynamicObject(); fxInfo->setProperty("index", i); fxInfo->setProperty("name", processor->getName()); - // Include plugin file path for save/restore - if (auto* pluginInstance = dynamic_cast(processor)) + + // Check if it's an S13FX processor + if (auto* s13fx = dynamic_cast(processor)) + { + fxInfo->setProperty("type", "s13fx"); + fxInfo->setProperty("pluginPath", s13fx->getScriptPath()); + } + else if (auto* pluginInstance = dynamic_cast(processor)) { + fxInfo->setProperty("type", "vst3"); auto desc = pluginInstance->getPluginDescription(); fxInfo->setProperty("pluginPath", desc.fileOrIdentifier); } @@ -1467,9 +1710,16 @@ juce::var AudioEngine::getTrackFX(const juce::String& trackId) juce::DynamicObject::Ptr fxInfo = new juce::DynamicObject(); fxInfo->setProperty("index", i); fxInfo->setProperty("name", processor->getName()); - // Include plugin file path for save/restore - if (auto* pluginInstance = dynamic_cast(processor)) + + // Check if it's an S13FX processor + if (auto* s13fx = dynamic_cast(processor)) + { + fxInfo->setProperty("type", "s13fx"); + fxInfo->setProperty("pluginPath", s13fx->getScriptPath()); + } + else if (auto* pluginInstance = dynamic_cast(processor)) { + fxInfo->setProperty("type", "vst3"); auto desc = pluginInstance->getPluginDescription(); fxInfo->setProperty("pluginPath", desc.fileOrIdentifier); } diff --git a/Source/AudioEngine.h b/Source/AudioEngine.h index 788f828..cc16574 100644 --- a/Source/AudioEngine.h +++ b/Source/AudioEngine.h @@ -10,6 +10,7 @@ #include "Metronome.h" #include "PeakCache.h" #include "AudioAnalyzer.h" +#include "ScriptEngine.h" #include #include // ... (skip lines) ... @@ -104,6 +105,14 @@ class AudioEngine : public juce::AudioIODeviceCallback, juce::var getAvailablePlugins(); bool addTrackInputFX(const juce::String& trackId, const juce::String& pluginPath, bool openEditor = true); bool addTrackFX(const juce::String& trackId, const juce::String& pluginPath, bool openEditor = true); + + // S13FX (JSFX) Management + bool addTrackS13FX(const juce::String& trackId, const juce::String& scriptPath, bool isInputFX = false); + bool addMasterS13FX(const juce::String& scriptPath); + juce::var getS13FXSliders(const juce::String& trackId, int fxIndex, bool isInputFX); + bool setS13FXSlider(const juce::String& trackId, int fxIndex, bool isInputFX, int sliderIndex, double value); + bool reloadS13FX(const juce::String& trackId, int fxIndex, bool isInputFX); + juce::var getAvailableS13FX(); // Plugin Editor Windows (Phase 3) - ID-based void openPluginEditor(const juce::String& trackId, int fxIndex, bool isInputFX); @@ -178,6 +187,12 @@ class AudioEngine : public juce::AudioIODeviceCallback, void setTrackSendPreFader(const juce::String& sourceTrackId, int sendIndex, bool preFader); juce::var getTrackSends(const juce::String& trackId); + // Lua Scripting (S13Script) + juce::var runScript(const juce::String& scriptPath); + juce::var runScriptCode(const juce::String& luaCode); + juce::String getScriptDirectory(); + juce::var listScripts(); + // Audio Analysis (Phase 9) AudioAnalyzer& getAudioAnalyzer() { return audioAnalyzer; } @@ -258,6 +273,9 @@ class AudioEngine : public juce::AudioIODeviceCallback, std::atomic cachedMasterPanL { 0.707107f }; // cos(pi/4) std::atomic cachedMasterPanR { 0.707107f }; // sin(pi/4) + // Lua Scripting + ScriptEngine scriptEngine; + // Audio Analysis (Phase 9) AudioAnalyzer audioAnalyzer; diff --git a/Source/MainComponent.cpp b/Source/MainComponent.cpp index dfae781..3d21bbc 100644 --- a/Source/MainComponent.cpp +++ b/Source/MainComponent.cpp @@ -408,6 +408,98 @@ MainComponent::MainComponent() completion(false); } }) + // S13FX (JSFX) Management + .withNativeFunction ("addTrackS13FX", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() >= 2) { + juce::String trackId = args[0].toString(); + juce::String scriptPath = args[1].toString(); + bool isInputFX = args.size() >= 3 ? (bool)args[2] : false; + bool success = audioEngine.addTrackS13FX(trackId, scriptPath, isInputFX); + completion(success); + } else { + completion(false); + } + }) + .withNativeFunction ("addMasterS13FX", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() >= 1) { + juce::String scriptPath = args[0].toString(); + bool success = audioEngine.addMasterS13FX(scriptPath); + completion(success); + } else { + completion(false); + } + }) + .withNativeFunction ("getS13FXSliders", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() >= 3) { + juce::String trackId = args[0].toString(); + int fxIndex = args[1]; + bool isInputFX = args[2]; + completion(audioEngine.getS13FXSliders(trackId, fxIndex, isInputFX)); + } else { + completion(juce::Array()); + } + }) + .withNativeFunction ("setS13FXSlider", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() >= 5) { + juce::String trackId = args[0].toString(); + int fxIndex = args[1]; + bool isInputFX = args[2]; + int sliderIndex = args[3]; + double value = args[4]; + bool success = audioEngine.setS13FXSlider(trackId, fxIndex, isInputFX, sliderIndex, value); + completion(success); + } else { + completion(false); + } + }) + .withNativeFunction ("reloadS13FX", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() >= 3) { + juce::String trackId = args[0].toString(); + int fxIndex = args[1]; + bool isInputFX = args[2]; + bool success = audioEngine.reloadS13FX(trackId, fxIndex, isInputFX); + completion(success); + } else { + completion(false); + } + }) + .withNativeFunction ("getAvailableS13FX", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::ignoreUnused(args); + completion(audioEngine.getAvailableS13FX()); + }) + // Lua Scripting (S13Script) + .withNativeFunction ("runScript", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() >= 1) { + juce::String scriptPath = args[0].toString(); + completion(audioEngine.runScript(scriptPath)); + } else { + auto* err = new juce::DynamicObject(); + err->setProperty("success", false); + err->setProperty("error", "Missing scriptPath argument"); + err->setProperty("output", ""); + completion(juce::var(err)); + } + }) + .withNativeFunction ("runScriptCode", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() >= 1) { + juce::String code = args[0].toString(); + completion(audioEngine.runScriptCode(code)); + } else { + auto* err = new juce::DynamicObject(); + err->setProperty("success", false); + err->setProperty("error", "Missing code argument"); + err->setProperty("output", ""); + completion(juce::var(err)); + } + }) + .withNativeFunction ("getScriptDirectory", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::ignoreUnused(args); + completion(audioEngine.getScriptDirectory()); + }) + .withNativeFunction ("listScripts", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::ignoreUnused(args); + completion(audioEngine.listScripts()); + }) // Transport Position .withNativeFunction ("getTransportPosition", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { juce::ignoreUnused(args); diff --git a/Source/PluginManager.cpp b/Source/PluginManager.cpp index 5d8525e..f3ed2fc 100644 --- a/Source/PluginManager.cpp +++ b/Source/PluginManager.cpp @@ -1,26 +1,30 @@ #include "PluginManager.h" +#include "S13FXProcessor.h" PluginManager::PluginManager() { // Add default formats (VST3, AU, etc.) formatManager.addDefaultFormats(); - + // Debug: Log how many formats were added - juce::Logger::writeToLog("PluginManager: Constructor - formatManager has " + + juce::Logger::writeToLog("PluginManager: Constructor - formatManager has " + juce::String(formatManager.getNumFormats()) + " formats"); - + for (int i = 0; i < formatManager.getNumFormats(); ++i) { auto* format = formatManager.getFormat(i); juce::Logger::writeToLog("PluginManager: Format " + juce::String(i) + ": " + format->getName()); } - + // Get plugin list file location (Documents/Studio13/PluginList.xml) pluginListFile = juce::File::getSpecialLocation(juce::File::userDocumentsDirectory) .getChildFile("Studio13").getChildFile("PluginList.xml"); - + // Load existing plugin list if available loadPluginList(); + + // Ensure user effects directory exists + getUserEffectsDirectory().createDirectory(); } PluginManager::~PluginManager() @@ -120,11 +124,14 @@ void PluginManager::scanForPlugins() } writeLog("PluginManager: ========================================"); - writeLog("PluginManager: SCAN COMPLETE. Total plugins found: " + + writeLog("PluginManager: SCAN COMPLETE. Total plugins found: " + juce::String(knownPluginList.getNumTypes())); writeLog("PluginManager: ========================================"); writeLog("PluginManager: Debug log saved to: " + debugLog.getFullPathName()); savePluginList(); + + // Also scan for S13FX/JSFX scripts + scanForS13FX(); } juce::Array PluginManager::getAvailablePlugins() const @@ -246,7 +253,7 @@ void PluginManager::loadPluginList() if (auto xml = juce::parseXML(pluginListFile)) { knownPluginList.recreateFromXml(*xml); - juce::Logger::writeToLog("PluginManager: Loaded " + juce::String(knownPluginList.getNumTypes()) + + juce::Logger::writeToLog("PluginManager: Loaded " + juce::String(knownPluginList.getNumTypes()) + " plugins from " + pluginListFile.getFullPathName()); } } @@ -255,3 +262,72 @@ void PluginManager::loadPluginList() juce::Logger::writeToLog("PluginManager: No existing plugin list found"); } } + +// ---- S13FX / JSFX scanning ---- + +juce::File PluginManager::getUserEffectsDirectory() +{ + return juce::File::getSpecialLocation(juce::File::userDocumentsDirectory) + .getChildFile("Studio13").getChildFile("Effects"); +} + +juce::File PluginManager::getStockEffectsDirectory() +{ + return juce::File::getSpecialLocation(juce::File::currentExecutableFile) + .getParentDirectory().getChildFile("effects"); +} + +void PluginManager::scanForS13FX() +{ + s13fxList.clear(); + + // Scan stock effects (bundled with app) + auto stockDir = getStockEffectsDirectory(); + if (stockDir.isDirectory()) + scanDirectory(stockDir, true); + + // Scan user effects + auto userDir = getUserEffectsDirectory(); + if (userDir.isDirectory()) + scanDirectory(userDir, false); + + juce::Logger::writeToLog("PluginManager: Found " + juce::String(s13fxList.size()) + " S13FX/JSFX scripts"); +} + +void PluginManager::scanDirectory(const juce::File& dir, bool isStock) +{ + juce::Array files; + dir.findChildFiles(files, juce::File::findFiles, true, "*.jsfx;*.s13fx"); + + for (const auto& file : files) + { + S13FXInfo info; + info.filePath = file.getFullPathName(); + info.isStock = isStock; + + // Try to extract metadata by loading the script header (first 50 lines) + juce::StringArray lines; + file.readLines(lines); + + info.name = file.getFileNameWithoutExtension(); + + for (int i = 0; i < juce::jmin(lines.size(), 50); ++i) + { + auto line = lines[i].trim(); + + if (line.startsWith("desc:")) + info.name = line.fromFirstOccurrenceOf("desc:", false, false).trim(); + else if (line.startsWith("author:")) + info.author = line.fromFirstOccurrenceOf("author:", false, false).trim(); + else if (line.startsWith("tags:")) + { + auto tagStr = line.fromFirstOccurrenceOf("tags:", false, false).trim(); + info.tags.addTokens(tagStr, " ", ""); + } + } + + s13fxList.push_back(std::move(info)); + juce::Logger::writeToLog("PluginManager: Found S13FX: " + info.name + + (isStock ? " (stock)" : " (user)")); + } +} diff --git a/Source/PluginManager.h b/Source/PluginManager.h index 302ea5f..959a8a2 100644 --- a/Source/PluginManager.h +++ b/Source/PluginManager.h @@ -4,19 +4,35 @@ #include #include -// Manages VST3 plugin scanning and loading +// Info for a discovered S13FX/JSFX script +struct S13FXInfo +{ + juce::String name; + juce::String filePath; + juce::String author; + juce::StringArray tags; + bool isStock = false; // true = shipped with app (read-only) +}; + +// Manages VST3 plugin scanning and S13FX/JSFX script discovery class PluginManager { public: PluginManager(); ~PluginManager(); - - // Scan for available plugins + + // Scan for available plugins (VST3 + S13FX/JSFX) void scanForPlugins(); - - // Get list of available plugins + + // Get list of available VST3 plugins juce::Array getAvailablePlugins() const; - + + // Get list of available S13FX/JSFX scripts + const std::vector& getAvailableS13FX() const { return s13fxList; } + + // Scan for S13FX/JSFX scripts only + void scanForS13FX(); + // Load a plugin by its description (uses actual device sample rate & block size) std::unique_ptr loadPlugin(const juce::PluginDescription& description, double sampleRate = 44100.0, int blockSize = 512); @@ -25,13 +41,21 @@ class PluginManager std::unique_ptr loadPluginFromFile(const juce::String& filePath, double sampleRate = 44100.0, int blockSize = 512); + // Get the user effects directory (Documents/Studio13/Effects/) + static juce::File getUserEffectsDirectory(); + + // Get the stock effects directory (app bundle/effects/) + static juce::File getStockEffectsDirectory(); + private: juce::AudioPluginFormatManager formatManager; juce::KnownPluginList knownPluginList; juce::File pluginListFile; - + std::vector s13fxList; + void savePluginList(); void loadPluginList(); - + void scanDirectory(const juce::File& dir, bool isStock); + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PluginManager) }; diff --git a/Source/S13FXProcessor.cpp b/Source/S13FXProcessor.cpp new file mode 100644 index 0000000..edcfe2f --- /dev/null +++ b/Source/S13FXProcessor.cpp @@ -0,0 +1,389 @@ +#include "S13FXProcessor.h" + +S13FXProcessor::S13FXProcessor() + : AudioProcessor(BusesProperties() + .withInput("Input", juce::AudioChannelSet::stereo(), true) + .withOutput("Output", juce::AudioChannelSet::stereo(), true)) +{ + config = ysfx_config_new(); + ysfx_register_builtin_audio_formats(config); +} + +S13FXProcessor::~S13FXProcessor() +{ + if (effect) + ysfx_free(effect); + if (config) + ysfx_config_free(config); +} + +bool S13FXProcessor::loadScript(const juce::String& path) +{ + // Free any existing effect + if (effect) + { + ysfx_free(effect); + effect = nullptr; + } + + scriptPath = path; + + // Set import/data roots based on script location + ysfx_guess_file_roots(config, path.toRawUTF8()); + + // Create new effect instance + effect = ysfx_new(config); + if (!effect) + { + juce::Logger::writeToLog("S13FXProcessor: Failed to create YSFX instance"); + return false; + } + + // Load the script file + if (!ysfx_load_file(effect, path.toRawUTF8(), 0)) + { + juce::Logger::writeToLog("S13FXProcessor: Failed to load script: " + path); + ysfx_free(effect); + effect = nullptr; + return false; + } + + // Compile the script + if (!ysfx_compile(effect, 0)) + { + juce::Logger::writeToLog("S13FXProcessor: Failed to compile script: " + path); + ysfx_free(effect); + effect = nullptr; + return false; + } + + // Cache the effect name + const char* name = ysfx_get_name(effect); + effectName = name ? juce::String(name) : juce::File(path).getFileNameWithoutExtension(); + + // Set sample rate and block size if we've already been prepared + if (cachedSampleRate > 0) + { + ysfx_set_sample_rate(effect, cachedSampleRate); + ysfx_set_block_size(effect, static_cast(cachedBlockSize)); + ysfx_init(effect); + } + + juce::Logger::writeToLog("S13FXProcessor: Loaded script: " + effectName + + " (ins=" + juce::String(ysfx_get_num_inputs(effect)) + + " outs=" + juce::String(ysfx_get_num_outputs(effect)) + ")"); + return true; +} + +bool S13FXProcessor::reloadScript() +{ + if (scriptPath.isEmpty()) + return false; + + // Save current slider state + std::vector> savedSliders; + if (effect && ysfx_is_compiled(effect)) + { + for (uint32_t i = 0; i < ysfx_max_sliders; ++i) + { + if (ysfx_slider_exists(effect, i)) + savedSliders.push_back({ i, ysfx_slider_get_value(effect, i) }); + } + } + + // Reload the script + if (!loadScript(scriptPath)) + return false; + + // Restore slider values + for (auto& [idx, val] : savedSliders) + { + if (ysfx_slider_exists(effect, idx)) + ysfx_slider_set_value(effect, idx, val); + } + + return true; +} + +bool S13FXProcessor::isScriptLoaded() const +{ + return effect != nullptr && ysfx_is_compiled(effect); +} + +std::vector S13FXProcessor::getSliders() const +{ + std::vector sliders; + + if (!effect || !ysfx_is_compiled(effect)) + return sliders; + + for (uint32_t i = 0; i < ysfx_max_sliders; ++i) + { + if (!ysfx_slider_exists(effect, i)) + continue; + + SliderInfo info; + info.index = i; + + const char* name = ysfx_slider_get_name(effect, i); + info.name = name ? juce::String(name) : ("Slider " + juce::String(i)); + + ysfx_slider_range_t range; + if (ysfx_slider_get_range(effect, i, &range)) + { + info.min = range.min; + info.max = range.max; + info.def = range.def; + info.inc = range.inc; + } + else + { + info.min = 0.0; + info.max = 1.0; + info.def = 0.0; + info.inc = 0.001; + } + + info.value = ysfx_slider_get_value(effect, i); + info.isEnum = ysfx_slider_is_enum(effect, i); + + if (info.isEnum) + { + uint32_t enumSize = ysfx_slider_get_enum_size(effect, i); + for (uint32_t e = 0; e < enumSize; ++e) + { + const char* ename = ysfx_slider_get_enum_name(effect, i, e); + info.enumNames.add(ename ? juce::String(ename) : ""); + } + } + + sliders.push_back(std::move(info)); + } + + return sliders; +} + +bool S13FXProcessor::setSliderValue(uint32_t index, double value) +{ + if (!effect || !ysfx_is_compiled(effect)) + return false; + + if (!ysfx_slider_exists(effect, index)) + return false; + + ysfx_slider_set_value(effect, index, value); + return true; +} + +// ---- juce::AudioProcessor overrides ---- + +void S13FXProcessor::prepareToPlay(double sampleRate, int samplesPerBlock) +{ + cachedSampleRate = sampleRate; + cachedBlockSize = samplesPerBlock; + + if (effect && ysfx_is_compiled(effect)) + { + ysfx_set_sample_rate(effect, sampleRate); + ysfx_set_block_size(effect, static_cast(samplesPerBlock)); + ysfx_set_midi_capacity(effect, 1024, true); + ysfx_init(effect); + } + + // Pre-allocate temp buffer for channel adaptation + int maxChannels = juce::jmax(2, getTotalNumInputChannels(), getTotalNumOutputChannels()); + tempBuffer.setSize(maxChannels, samplesPerBlock); +} + +void S13FXProcessor::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midiMessages) +{ + if (!effect || !ysfx_is_compiled(effect)) + return; + + const int numSamples = buffer.getNumSamples(); + const int bufferChannels = buffer.getNumChannels(); + + // Feed MIDI events to YSFX + for (const auto metadata : midiMessages) + { + const auto msg = metadata.getMessage(); + ysfx_midi_event_t event {}; + event.bus = 0; + event.offset = static_cast(metadata.samplePosition); + event.size = static_cast(msg.getRawDataSize()); + event.data = msg.getRawData(); + ysfx_send_midi(effect, &event); + } + + // Update time info from the playhead + updateTimeInfo(); + + // Determine channel counts for YSFX + uint32_t ysfxIns = ysfx_get_num_inputs(effect); + uint32_t ysfxOuts = ysfx_get_num_outputs(effect); + + // Ensure we have at least 2 channels for processing + if (ysfxIns == 0) ysfxIns = 2; + if (ysfxOuts == 0) ysfxOuts = 2; + + uint32_t maxCh = juce::jmax(ysfxIns, ysfxOuts, static_cast(bufferChannels)); + + // Resize temp buffer if needed + if (tempBuffer.getNumChannels() < static_cast(maxCh) || tempBuffer.getNumSamples() < numSamples) + tempBuffer.setSize(static_cast(maxCh), numSamples, false, false, true); + + // Copy input data into temp buffer + for (int ch = 0; ch < static_cast(maxCh); ++ch) + { + if (ch < bufferChannels) + tempBuffer.copyFrom(ch, 0, buffer, ch, 0, numSamples); + else + tempBuffer.clear(ch, 0, numSamples); + } + + // Set up channel pointer arrays + inputPtrs.resize(ysfxIns); + outputPtrs.resize(ysfxOuts); + + for (uint32_t ch = 0; ch < ysfxIns; ++ch) + inputPtrs[ch] = tempBuffer.getReadPointer(static_cast(ch < maxCh ? ch : 0)); + + for (uint32_t ch = 0; ch < ysfxOuts; ++ch) + outputPtrs[ch] = tempBuffer.getWritePointer(static_cast(ch < maxCh ? ch : 0)); + + // Process audio through YSFX + ysfx_process_float(effect, inputPtrs.data(), outputPtrs.data(), + ysfxIns, ysfxOuts, static_cast(numSamples)); + + // Copy output back to the JUCE buffer + for (int ch = 0; ch < bufferChannels; ++ch) + { + if (ch < static_cast(ysfxOuts)) + buffer.copyFrom(ch, 0, tempBuffer, ch, 0, numSamples); + else + buffer.clear(ch, 0, numSamples); + } + + // Receive MIDI output from YSFX + midiMessages.clear(); + ysfx_midi_event_t outEvent {}; + while (ysfx_receive_midi(effect, &outEvent)) + { + if (outEvent.size > 0 && outEvent.size <= 3) + { + auto msg = juce::MidiMessage(outEvent.data, static_cast(outEvent.size)); + midiMessages.addEvent(msg, static_cast(outEvent.offset)); + } + } +} + +void S13FXProcessor::releaseResources() +{ + // Nothing to release — YSFX handles its own memory +} + +const juce::String S13FXProcessor::getName() const +{ + return effectName.isNotEmpty() ? effectName : "S13FX"; +} + +void S13FXProcessor::getStateInformation(juce::MemoryBlock& destData) +{ + if (!effect || !ysfx_is_compiled(effect)) + return; + + // Save slider values as a simple binary format + auto sliders = getSliders(); + juce::MemoryOutputStream stream(destData, false); + + // Write magic + version + stream.writeInt(0x53313346); // "S13F" + stream.writeInt(1); // version + + // Write script path + stream.writeString(scriptPath); + + // Write slider count and values + stream.writeInt(static_cast(sliders.size())); + for (const auto& s : sliders) + { + stream.writeInt(static_cast(s.index)); + stream.writeDouble(s.value); + } +} + +void S13FXProcessor::setStateInformation(const void* data, int sizeInBytes) +{ + juce::MemoryInputStream stream(data, static_cast(sizeInBytes), false); + + int magic = stream.readInt(); + if (magic != 0x53313346) // "S13F" + return; + + int version = stream.readInt(); + juce::ignoreUnused(version); + + juce::String path = stream.readString(); + + // Load the script if not already loaded + if (path.isNotEmpty() && (scriptPath.isEmpty() || !isScriptLoaded())) + loadScript(path); + + // Read and apply slider values + int sliderCount = stream.readInt(); + for (int i = 0; i < sliderCount; ++i) + { + uint32_t idx = static_cast(stream.readInt()); + double val = stream.readDouble(); + setSliderValue(idx, val); + } +} + +bool S13FXProcessor::isBusesLayoutSupported(const BusesLayout& layouts) const +{ + // Accept any layout — we handle channel adaptation in processBlock + juce::ignoreUnused(layouts); + return true; +} + +void S13FXProcessor::updateTimeInfo() +{ + if (!effect) + return; + + auto* playHead = getPlayHead(); + if (!playHead) + return; + + auto posInfo = playHead->getPosition(); + if (!posInfo.hasValue()) + return; + + ysfx_time_info_t timeInfo {}; + + if (auto bpm = posInfo->getBpm()) + timeInfo.tempo = *bpm; + else + timeInfo.tempo = 120.0; + + timeInfo.playback_state = posInfo->getIsPlaying() ? 1 : 0; + + if (auto timeInSeconds = posInfo->getTimeInSeconds()) + timeInfo.time_position = *timeInSeconds; + + if (auto ppq = posInfo->getPpqPosition()) + timeInfo.beat_position = *ppq; + + if (auto timeSig = posInfo->getTimeSignature()) + { + timeInfo.time_signature[0] = static_cast(timeSig->numerator); + timeInfo.time_signature[1] = static_cast(timeSig->denominator); + } + else + { + timeInfo.time_signature[0] = 4; + timeInfo.time_signature[1] = 4; + } + + ysfx_set_time_info(effect, &timeInfo); +} diff --git a/Source/S13FXProcessor.h b/Source/S13FXProcessor.h new file mode 100644 index 0000000..24a2e13 --- /dev/null +++ b/Source/S13FXProcessor.h @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include + +/** + * S13FXProcessor — JUCE AudioProcessor wrapper around a YSFX (JSFX) effect instance. + * + * Slots into the existing FX chain (TrackProcessor::inputFXPlugins / trackFXPlugins) + * exactly like a VST3 plugin. The caller adds this via TrackProcessor::addInputFX() + * or addTrackFX() — no special handling needed in the audio pipeline. + */ +class S13FXProcessor : public juce::AudioProcessor +{ +public: + S13FXProcessor(); + ~S13FXProcessor() override; + + // Script management + bool loadScript(const juce::String& path); + bool reloadScript(); + juce::String getScriptPath() const { return scriptPath; } + bool isScriptLoaded() const; + + // Slider (parameter) access for the frontend + struct SliderInfo + { + uint32_t index; + juce::String name; + double min; + double max; + double def; + double inc; + double value; + bool isEnum; + juce::StringArray enumNames; + }; + + std::vector getSliders() const; + bool setSliderValue(uint32_t index, double value); + + // Identify as S13FX (not VST3) + bool isS13FX() const { return true; } + + // ---- juce::AudioProcessor overrides ---- + void prepareToPlay(double sampleRate, int samplesPerBlock) override; + void processBlock(juce::AudioBuffer&, juce::MidiBuffer&) override; + void releaseResources() override; + + const juce::String getName() const override; + bool acceptsMidi() const override { return true; } + bool producesMidi() const override { return true; } + bool isMidiEffect() const override { return false; } + double getTailLengthSeconds() const override { return 0.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 {} + + bool hasEditor() const override { return false; } + juce::AudioProcessorEditor* createEditor() override { return nullptr; } + + void getStateInformation(juce::MemoryBlock& destData) override; + void setStateInformation(const void* data, int sizeInBytes) override; + + bool isBusesLayoutSupported(const BusesLayout& layouts) const override; + +private: + ysfx_t* effect = nullptr; + ysfx_config_t* config = nullptr; + juce::String scriptPath; + juce::String effectName; + + double cachedSampleRate = 44100.0; + int cachedBlockSize = 512; + + // Pre-allocated channel pointer arrays for ysfx_process_float + std::vector inputPtrs; + std::vector outputPtrs; + + // Temporary buffer when in-place processing with different channel counts + juce::AudioBuffer tempBuffer; + + void updateTimeInfo(); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(S13FXProcessor) +}; diff --git a/Source/ScriptEngine.cpp b/Source/ScriptEngine.cpp new file mode 100644 index 0000000..5093760 --- /dev/null +++ b/Source/ScriptEngine.cpp @@ -0,0 +1,898 @@ +#include "ScriptEngine.h" +#include "AudioEngine.h" + +// Lua is compiled as C, so we need extern "C" linkage +extern "C" { +#include +#include +#include +} + +// ============================================================================ +// Registry key for the AudioEngine pointer stored in Lua +// ============================================================================ +static const char* const kEngineKey = "s13_engine_ptr"; +static const char* const kScriptEngineKey = "s13_script_engine_ptr"; + +static AudioEngine* getEngine(lua_State* L) +{ + lua_getfield(L, LUA_REGISTRYINDEX, kEngineKey); + auto* engine = static_cast(lua_touserdata(L, -1)); + lua_pop(L, 1); + return engine; +} + +static ScriptEngine* getScriptEngine(lua_State* L) +{ + lua_getfield(L, LUA_REGISTRYINDEX, kScriptEngineKey); + auto* se = static_cast(lua_touserdata(L, -1)); + lua_pop(L, 1); + return se; +} + +// ============================================================================ +// Helper: push a juce::var as a Lua value +// ============================================================================ +static void pushVar(lua_State* L, const juce::var& v) +{ + if (v.isVoid() || v.isUndefined()) + lua_pushnil(L); + else if (v.isBool()) + lua_pushboolean(L, (bool)v ? 1 : 0); + else if (v.isInt() || v.isInt64()) + lua_pushinteger(L, (lua_Integer)(int64_t)v); + else if (v.isDouble()) + lua_pushnumber(L, (double)v); + else if (v.isString()) + lua_pushstring(L, v.toString().toRawUTF8()); + else if (v.isArray()) + { + auto* arr = v.getArray(); + lua_createtable(L, arr->size(), 0); + for (int i = 0; i < arr->size(); ++i) + { + pushVar(L, (*arr)[i]); + lua_rawseti(L, -2, i + 1); // Lua arrays are 1-based + } + } + else if (v.isObject()) + { + auto* obj = v.getDynamicObject(); + if (obj != nullptr) + { + auto& props = obj->getProperties(); + lua_createtable(L, 0, props.size()); + for (int i = 0; i < props.size(); ++i) + { + lua_pushstring(L, props.getName(i).toString().toRawUTF8()); + pushVar(L, props.getValueAt(i)); + lua_rawset(L, -3); + } + } + else + { + lua_pushnil(L); + } + } + else + { + lua_pushstring(L, v.toString().toRawUTF8()); + } +} + +// ============================================================================ +// s13.print(...) — capture output for the console +// ============================================================================ +static int l_print(lua_State* L) +{ + auto* se = getScriptEngine(L); + int nargs = lua_gettop(L); + juce::String line; + + for (int i = 1; i <= nargs; ++i) + { + if (i > 1) line += "\t"; + if (lua_isstring(L, i)) + line += lua_tostring(L, i); + else if (lua_isboolean(L, i)) + line += lua_toboolean(L, i) ? "true" : "false"; + else if (lua_isnil(L, i)) + line += "nil"; + else + line += luaL_tolstring(L, i, nullptr); + } + + if (se != nullptr && se->onPrint) + se->onPrint(line); + + // Also log to JUCE logger for debugging + juce::Logger::writeToLog("[Lua] " + line); + return 0; +} + +// ============================================================================ +// Track operations +// ============================================================================ +static int l_getTrackCount(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) { lua_pushinteger(L, 0); return 1; } + + // Use getMeteringData to count tracks (it returns an array with one entry per track) + auto data = engine->getMeteringData(); + if (auto* arr = data.getArray()) + lua_pushinteger(L, arr->size()); + else + lua_pushinteger(L, 0); + return 1; +} + +static int l_addTrack(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) { lua_pushnil(L); return 1; } + + juce::String trackId = engine->addTrack(); + lua_pushstring(L, trackId.toRawUTF8()); + return 1; +} + +static int l_removeTrack(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) { lua_pushboolean(L, 0); return 1; } + + const char* trackId = luaL_checkstring(L, 1); + lua_pushboolean(L, engine->removeTrack(juce::String(trackId)) ? 1 : 0); + return 1; +} + +static int l_setTrackVolume(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) return 0; + + const char* trackId = luaL_checkstring(L, 1); + double volumeDB = luaL_checknumber(L, 2); + engine->setTrackVolume(juce::String(trackId), (float)volumeDB); + return 0; +} + +static int l_setTrackPan(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) return 0; + + const char* trackId = luaL_checkstring(L, 1); + double pan = luaL_checknumber(L, 2); + engine->setTrackPan(juce::String(trackId), (float)pan); + return 0; +} + +static int l_setTrackMute(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) return 0; + + const char* trackId = luaL_checkstring(L, 1); + bool muted = lua_toboolean(L, 2) != 0; + engine->setTrackMute(juce::String(trackId), muted); + return 0; +} + +static int l_setTrackSolo(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) return 0; + + const char* trackId = luaL_checkstring(L, 1); + bool soloed = lua_toboolean(L, 2) != 0; + engine->setTrackSolo(juce::String(trackId), soloed); + return 0; +} + +static int l_setTrackArm(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) return 0; + + const char* trackId = luaL_checkstring(L, 1); + bool armed = lua_toboolean(L, 2) != 0; + engine->setTrackRecordArm(juce::String(trackId), armed); + return 0; +} + +static int l_reorderTrack(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) { lua_pushboolean(L, 0); return 1; } + + const char* trackId = luaL_checkstring(L, 1); + int newIndex = (int)luaL_checkinteger(L, 2); + lua_pushboolean(L, engine->reorderTrack(juce::String(trackId), newIndex) ? 1 : 0); + return 1; +} + +// ============================================================================ +// Transport +// ============================================================================ +static int l_play(lua_State* L) +{ + auto* engine = getEngine(L); + if (engine) engine->setTransportPlaying(true); + return 0; +} + +static int l_stop(lua_State* L) +{ + auto* engine = getEngine(L); + if (engine) + { + engine->setTransportPlaying(false); + engine->setTransportRecording(false); + } + return 0; +} + +static int l_record(lua_State* L) +{ + auto* engine = getEngine(L); + if (engine) + { + engine->setTransportRecording(true); + engine->setTransportPlaying(true); + } + return 0; +} + +static int l_isPlaying(lua_State* L) +{ + auto* engine = getEngine(L); + lua_pushboolean(L, engine && engine->isTransportPlaying() ? 1 : 0); + return 1; +} + +static int l_isRecording(lua_State* L) +{ + auto* engine = getEngine(L); + lua_pushboolean(L, engine && engine->isTransportRecording() ? 1 : 0); + return 1; +} + +static int l_getPlayhead(lua_State* L) +{ + auto* engine = getEngine(L); + lua_pushnumber(L, engine ? engine->getTransportPosition() : 0.0); + return 1; +} + +static int l_setPlayhead(lua_State* L) +{ + auto* engine = getEngine(L); + if (engine) + { + double seconds = luaL_checknumber(L, 1); + engine->setTransportPosition(seconds); + } + return 0; +} + +static int l_getTempo(lua_State* L) +{ + auto* engine = getEngine(L); + lua_pushnumber(L, engine ? engine->getTempo() : 120.0); + return 1; +} + +static int l_setTempo(lua_State* L) +{ + auto* engine = getEngine(L); + if (engine) + { + double bpm = luaL_checknumber(L, 1); + engine->setTempo(bpm); + } + return 0; +} + +static int l_getTimeSignature(lua_State* L) +{ + auto* engine = getEngine(L); + int num = 4, den = 4; + if (engine) + engine->getTimeSignature(num, den); + + lua_createtable(L, 0, 2); + lua_pushinteger(L, num); + lua_setfield(L, -2, "num"); + lua_pushinteger(L, den); + lua_setfield(L, -2, "den"); + return 1; +} + +static int l_setTimeSignature(lua_State* L) +{ + auto* engine = getEngine(L); + if (engine) + { + int num = (int)luaL_checkinteger(L, 1); + int den = (int)luaL_checkinteger(L, 2); + engine->setTimeSignature(num, den); + } + return 0; +} + +static int l_setLoop(lua_State* L) +{ + auto* engine = getEngine(L); + if (engine) + { + bool enabled = lua_toboolean(L, 1) != 0; + engine->setLoopMode(enabled); + } + return 0; +} + +// ============================================================================ +// FX operations +// ============================================================================ +static int l_getTrackFX(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) { lua_pushnil(L); return 1; } + + const char* trackId = luaL_checkstring(L, 1); + auto fxData = engine->getTrackFX(juce::String(trackId)); + pushVar(L, fxData); + return 1; +} + +static int l_getTrackInputFX(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) { lua_pushnil(L); return 1; } + + const char* trackId = luaL_checkstring(L, 1); + auto fxData = engine->getTrackInputFX(juce::String(trackId)); + pushVar(L, fxData); + return 1; +} + +static int l_addTrackFX(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) { lua_pushboolean(L, 0); return 1; } + + const char* trackId = luaL_checkstring(L, 1); + const char* pluginPath = luaL_checkstring(L, 2); + bool result = engine->addTrackFX(juce::String(trackId), juce::String(pluginPath), false); + lua_pushboolean(L, result ? 1 : 0); + return 1; +} + +static int l_removeTrackFX(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) return 0; + + const char* trackId = luaL_checkstring(L, 1); + int fxIndex = (int)luaL_checkinteger(L, 2); + engine->removeTrackFX(juce::String(trackId), fxIndex); + return 0; +} + +static int l_bypassTrackFX(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) return 0; + + const char* trackId = luaL_checkstring(L, 1); + int fxIndex = (int)luaL_checkinteger(L, 2); + bool bypassed = lua_toboolean(L, 3) != 0; + engine->bypassTrackFX(juce::String(trackId), fxIndex, bypassed); + return 0; +} + +// ============================================================================ +// Master +// ============================================================================ +static int l_setMasterVolume(lua_State* L) +{ + auto* engine = getEngine(L); + if (engine) + { + float vol = (float)luaL_checknumber(L, 1); + engine->setMasterVolume(vol); + } + return 0; +} + +static int l_getMasterVolume(lua_State* L) +{ + auto* engine = getEngine(L); + lua_pushnumber(L, engine ? engine->getMasterVolume() : 1.0); + return 1; +} + +static int l_setMasterPan(lua_State* L) +{ + auto* engine = getEngine(L); + if (engine) + { + float pan = (float)luaL_checknumber(L, 1); + engine->setMasterPan(pan); + } + return 0; +} + +static int l_getMasterPan(lua_State* L) +{ + auto* engine = getEngine(L); + lua_pushnumber(L, engine ? engine->getMasterPan() : 0.0); + return 1; +} + +// ============================================================================ +// MIDI devices +// ============================================================================ +static int l_getMIDIDevices(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) { lua_pushnil(L); return 1; } + + auto devices = engine->getMIDIInputDevices(); + pushVar(L, devices); + return 1; +} + +// ============================================================================ +// Metronome +// ============================================================================ +static int l_setMetronomeEnabled(lua_State* L) +{ + auto* engine = getEngine(L); + if (engine) + { + bool enabled = lua_toboolean(L, 1) != 0; + engine->setMetronomeEnabled(enabled); + } + return 0; +} + +static int l_isMetronomeEnabled(lua_State* L) +{ + auto* engine = getEngine(L); + lua_pushboolean(L, engine && engine->isMetronomeEnabled() ? 1 : 0); + return 1; +} + +// ============================================================================ +// Utility +// ============================================================================ +static int l_getAppVersion(lua_State* L) +{ + lua_pushstring(L, "0.0.1"); + return 1; +} + +static int l_showMessage(lua_State* L) +{ + const char* title = luaL_checkstring(L, 1); + const char* msg = luaL_checkstring(L, 2); + juce::AlertWindow::showMessageBoxAsync(juce::MessageBoxIconType::InfoIcon, + juce::String(title), juce::String(msg)); + return 0; +} + +// ============================================================================ +// Playback clips +// ============================================================================ +static int l_addPlaybackClip(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) return 0; + + const char* trackId = luaL_checkstring(L, 1); + const char* filePath = luaL_checkstring(L, 2); + double startTime = luaL_checknumber(L, 3); + double duration = luaL_checknumber(L, 4); + double offset = luaL_optnumber(L, 5, 0.0); + double volumeDB = luaL_optnumber(L, 6, 0.0); + double fadeIn = luaL_optnumber(L, 7, 0.0); + double fadeOut = luaL_optnumber(L, 8, 0.0); + + engine->addPlaybackClip(juce::String(trackId), juce::String(filePath), + startTime, duration, offset, volumeDB, fadeIn, fadeOut); + return 0; +} + +static int l_removePlaybackClip(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) return 0; + + const char* trackId = luaL_checkstring(L, 1); + const char* filePath = luaL_checkstring(L, 2); + engine->removePlaybackClip(juce::String(trackId), juce::String(filePath)); + return 0; +} + +static int l_clearPlaybackClips(lua_State* L) +{ + auto* engine = getEngine(L); + if (engine) engine->clearPlaybackClips(); + return 0; +} + +// ============================================================================ +// Send/Bus routing +// ============================================================================ +static int l_addTrackSend(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) { lua_pushinteger(L, -1); return 1; } + + const char* srcId = luaL_checkstring(L, 1); + const char* dstId = luaL_checkstring(L, 2); + int idx = engine->addTrackSend(juce::String(srcId), juce::String(dstId)); + lua_pushinteger(L, idx); + return 1; +} + +static int l_removeTrackSend(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) return 0; + + const char* srcId = luaL_checkstring(L, 1); + int sendIdx = (int)luaL_checkinteger(L, 2); + engine->removeTrackSend(juce::String(srcId), sendIdx); + return 0; +} + +static int l_setTrackSendLevel(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) return 0; + + const char* srcId = luaL_checkstring(L, 1); + int sendIdx = (int)luaL_checkinteger(L, 2); + float level = (float)luaL_checknumber(L, 3); + engine->setTrackSendLevel(juce::String(srcId), sendIdx, level); + return 0; +} + +static int l_getTrackSends(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) { lua_pushnil(L); return 1; } + + const char* trackId = luaL_checkstring(L, 1); + auto sends = engine->getTrackSends(juce::String(trackId)); + pushVar(L, sends); + return 1; +} + +// ============================================================================ +// S13FX (JSFX) from Lua +// ============================================================================ +static int l_addTrackS13FX(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) { lua_pushboolean(L, 0); return 1; } + + const char* trackId = luaL_checkstring(L, 1); + const char* scriptPath = luaL_checkstring(L, 2); + bool isInputFX = lua_toboolean(L, 3) != 0; + bool result = engine->addTrackS13FX(juce::String(trackId), juce::String(scriptPath), isInputFX); + lua_pushboolean(L, result ? 1 : 0); + return 1; +} + +static int l_getAvailableS13FX(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) { lua_pushnil(L); return 1; } + + auto fx = engine->getAvailableS13FX(); + pushVar(L, fx); + return 1; +} + +// ============================================================================ +// Plugin scanning +// ============================================================================ +static int l_scanForPlugins(lua_State* L) +{ + auto* engine = getEngine(L); + if (engine) engine->scanForPlugins(); + return 0; +} + +static int l_getAvailablePlugins(lua_State* L) +{ + auto* engine = getEngine(L); + if (!engine) { lua_pushnil(L); return 1; } + + auto plugins = engine->getAvailablePlugins(); + pushVar(L, plugins); + return 1; +} + +// ============================================================================ +// ScriptEngine implementation +// ============================================================================ + +ScriptEngine::ScriptEngine() +{ + L = luaL_newstate(); + if (L != nullptr) + { + // Open standard libraries (string, math, table, io, os, etc.) + luaL_openlibs(L); + + // Store this ScriptEngine pointer in the registry + lua_pushlightuserdata(L, this); + lua_setfield(L, LUA_REGISTRYINDEX, kScriptEngineKey); + + // Override global print() to route through our capture + lua_pushcfunction(L, l_print); + lua_setglobal(L, "print"); + } + + // Ensure user scripts directory exists + getUserScriptsDirectory().createDirectory(); +} + +ScriptEngine::~ScriptEngine() +{ + if (L != nullptr) + { + lua_close(L); + L = nullptr; + } +} + +void ScriptEngine::resetOutput() +{ + lastError = {}; + lastOutput = {}; +} + +void ScriptEngine::registerAPI(AudioEngine& engine) +{ + if (L == nullptr) return; + + // Store AudioEngine pointer in Lua registry + lua_pushlightuserdata(L, &engine); + lua_setfield(L, LUA_REGISTRYINDEX, kEngineKey); + + // Create the "s13" global table + lua_newtable(L); + + // ------ Track operations ------ + auto reg = [&](const char* name, lua_CFunction fn) { + lua_pushcfunction(L, fn); + lua_setfield(L, -2, name); + }; + + reg("getTrackCount", l_getTrackCount); + reg("addTrack", l_addTrack); + reg("removeTrack", l_removeTrack); + reg("setTrackVolume", l_setTrackVolume); + reg("setTrackPan", l_setTrackPan); + reg("setTrackMute", l_setTrackMute); + reg("setTrackSolo", l_setTrackSolo); + reg("setTrackArm", l_setTrackArm); + reg("reorderTrack", l_reorderTrack); + + // ------ Transport ------ + reg("play", l_play); + reg("stop", l_stop); + reg("record", l_record); + reg("isPlaying", l_isPlaying); + reg("isRecording", l_isRecording); + reg("getPlayhead", l_getPlayhead); + reg("setPlayhead", l_setPlayhead); + reg("getTempo", l_getTempo); + reg("setTempo", l_setTempo); + reg("getTimeSignature", l_getTimeSignature); + reg("setTimeSignature", l_setTimeSignature); + reg("setLoop", l_setLoop); + + // ------ FX ------ + reg("getTrackFX", l_getTrackFX); + reg("getTrackInputFX", l_getTrackInputFX); + reg("addTrackFX", l_addTrackFX); + reg("removeTrackFX", l_removeTrackFX); + reg("bypassTrackFX", l_bypassTrackFX); + + // ------ Master ------ + reg("setMasterVolume", l_setMasterVolume); + reg("getMasterVolume", l_getMasterVolume); + reg("setMasterPan", l_setMasterPan); + reg("getMasterPan", l_getMasterPan); + + // ------ MIDI ------ + reg("getMIDIDevices", l_getMIDIDevices); + + // ------ Metronome ------ + reg("setMetronomeEnabled", l_setMetronomeEnabled); + reg("isMetronomeEnabled", l_isMetronomeEnabled); + + // ------ Playback clips ------ + reg("addPlaybackClip", l_addPlaybackClip); + reg("removePlaybackClip", l_removePlaybackClip); + reg("clearPlaybackClips", l_clearPlaybackClips); + + // ------ Sends ------ + reg("addTrackSend", l_addTrackSend); + reg("removeTrackSend", l_removeTrackSend); + reg("setTrackSendLevel", l_setTrackSendLevel); + reg("getTrackSends", l_getTrackSends); + + // ------ S13FX ------ + reg("addTrackS13FX", l_addTrackS13FX); + reg("getAvailableS13FX", l_getAvailableS13FX); + + // ------ Plugins ------ + reg("scanForPlugins", l_scanForPlugins); + reg("getAvailablePlugins", l_getAvailablePlugins); + + // ------ Utility ------ + reg("print", l_print); + reg("getAppVersion", l_getAppVersion); + reg("showMessage", l_showMessage); + + // Set the table as global "s13" + lua_setglobal(L, "s13"); +} + +bool ScriptEngine::loadAndRun(const juce::String& scriptPath) +{ + if (L == nullptr) + { + lastError = "Lua VM not initialized"; + return false; + } + + resetOutput(); + + // Set up the print capture + auto prevOnPrint = onPrint; + onPrint = [this, &prevOnPrint](const juce::String& msg) { + if (lastOutput.isNotEmpty()) + lastOutput += "\n"; + lastOutput += msg; + if (prevOnPrint) + prevOnPrint(msg); + }; + + juce::File scriptFile(scriptPath); + if (!scriptFile.existsAsFile()) + { + lastError = "Script file not found: " + scriptPath; + onPrint = prevOnPrint; + return false; + } + + juce::String code = scriptFile.loadFileAsString(); + int result = luaL_loadbuffer(L, code.toRawUTF8(), (size_t)code.getNumBytesAsUTF8(), scriptPath.toRawUTF8()); + if (result != LUA_OK) + { + lastError = juce::String(lua_tostring(L, -1)); + lua_pop(L, 1); + onPrint = prevOnPrint; + return false; + } + + result = lua_pcall(L, 0, LUA_MULTRET, 0); + if (result != LUA_OK) + { + lastError = juce::String(lua_tostring(L, -1)); + lua_pop(L, 1); + onPrint = prevOnPrint; + return false; + } + + onPrint = prevOnPrint; + return true; +} + +bool ScriptEngine::executeString(const juce::String& luaCode) +{ + if (L == nullptr) + { + lastError = "Lua VM not initialized"; + return false; + } + + resetOutput(); + + auto prevOnPrint = onPrint; + onPrint = [this, &prevOnPrint](const juce::String& msg) { + if (lastOutput.isNotEmpty()) + lastOutput += "\n"; + lastOutput += msg; + if (prevOnPrint) + prevOnPrint(msg); + }; + + int result = luaL_loadbuffer(L, luaCode.toRawUTF8(), (size_t)luaCode.getNumBytesAsUTF8(), "=console"); + if (result != LUA_OK) + { + lastError = juce::String(lua_tostring(L, -1)); + lua_pop(L, 1); + onPrint = prevOnPrint; + return false; + } + + result = lua_pcall(L, 0, LUA_MULTRET, 0); + if (result != LUA_OK) + { + lastError = juce::String(lua_tostring(L, -1)); + lua_pop(L, 1); + onPrint = prevOnPrint; + return false; + } + + onPrint = prevOnPrint; + return true; +} + +juce::File ScriptEngine::getUserScriptsDirectory() +{ + return juce::File::getSpecialLocation(juce::File::userDocumentsDirectory) + .getChildFile("Studio13").getChildFile("Scripts"); +} + +juce::File ScriptEngine::getStockScriptsDirectory() +{ + return juce::File::getSpecialLocation(juce::File::currentExecutableFile) + .getParentDirectory().getChildFile("scripts"); +} + +std::vector ScriptEngine::listAvailableScripts() const +{ + std::vector scripts; + + auto scanDir = [&scripts](const juce::File& dir, bool isStock) { + if (!dir.isDirectory()) return; + + juce::Array files; + dir.findChildFiles(files, juce::File::findFiles, true, "*.lua"); + + for (const auto& file : files) + { + ScriptInfo info; + info.name = file.getFileNameWithoutExtension(); + info.filePath = file.getFullPathName(); + info.isStock = isStock; + + // Try to extract description from first comment line + juce::StringArray lines; + file.readLines(lines); + for (int i = 0; i < juce::jmin(lines.size(), 20); ++i) + { + auto line = lines[i].trim(); + if (line.startsWith("-- @desc")) + { + info.description = line.fromFirstOccurrenceOf("-- @desc", false, false).trim(); + break; + } + else if (line.startsWith("-- ") && info.description.isEmpty() && i == 0) + { + // Use first comment as description if no @desc tag + info.description = line.fromFirstOccurrenceOf("-- ", false, false).trim(); + } + } + + scripts.push_back(std::move(info)); + } + }; + + scanDir(getStockScriptsDirectory(), true); + scanDir(getUserScriptsDirectory(), false); + + return scripts; +} diff --git a/Source/ScriptEngine.h b/Source/ScriptEngine.h new file mode 100644 index 0000000..f46069c --- /dev/null +++ b/Source/ScriptEngine.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include +#include + +// Forward declarations — avoid pulling heavy headers into every TU +struct lua_State; +class AudioEngine; + +// Lua 5.4 scripting engine for DAW automation (runs on message thread ONLY) +class ScriptEngine +{ +public: + ScriptEngine(); + ~ScriptEngine(); + + // Register the full s13.* API surface against the given AudioEngine + void registerAPI(AudioEngine& engine); + + // Load and execute a .lua file. Returns true on success. + bool loadAndRun(const juce::String& scriptPath); + + // Execute an arbitrary Lua string (REPL / console). Returns true on success. + bool executeString(const juce::String& luaCode); + + // Last error message from Lua (empty on success) + juce::String getLastError() const { return lastError; } + + // Accumulated print() output from the last execution + juce::String getLastOutput() const { return lastOutput; } + + // Get the user scripts directory (Documents/Studio13/Scripts/) + static juce::File getUserScriptsDirectory(); + + // Get the stock scripts directory (/scripts/) + static juce::File getStockScriptsDirectory(); + + // Enumerate available scripts from both stock and user directories + struct ScriptInfo + { + juce::String name; + juce::String filePath; + juce::String description; // First comment line starting with "-- @desc" + bool isStock = false; + }; + std::vector listAvailableScripts() const; + + // Callback invoked by s13.print() — caller can route to frontend console + std::function onPrint; + +private: + lua_State* L = nullptr; + juce::String lastError; + juce::String lastOutput; + + // Reset output buffer before each execution + void resetOutput(); + + // Helper: push a C closure that captures AudioEngine* + void registerFunction(const char* tableName, const char* funcName, + int (*func)(lua_State*)); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ScriptEngine) +}; diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md new file mode 100644 index 0000000..37898ad --- /dev/null +++ b/THIRD_PARTY_LICENSES.md @@ -0,0 +1,114 @@ +# Third-Party Licenses + +Studio13 uses the following open-source libraries and dependencies. + +--- + +## JUCE 8.0.0 + +- **Website:** https://juce.com/ +- **License:** AGPLv3 / Commercial +- **Copyright:** (c) Raw Material Software Limited +- **Usage:** Audio engine, GUI framework, plugin hosting, WebView2 integration + +The JUCE framework is dual-licensed under the AGPLv3 and a commercial license. +Studio13 is released under AGPLv3-compatible terms. + +--- + +## YSFX + +- **Website:** https://github.com/jpcima/ysfx +- **License:** Apache License 2.0 +- **Copyright:** (c) Jean Pierre Cimalando and contributors +- **Usage:** JSFX/EEL2 scripting runtime for built-in S13FX audio effects + +Licensed under the Apache License, Version 2.0. You may obtain a copy at: +http://www.apache.org/licenses/LICENSE-2.0 + +YSFX bundles portions of WDL (Cockos) under the WTFPL license. + +--- + +## WDL (Cockos) + +- **Website:** https://www.cockos.com/wdl/ +- **License:** WTFPL (Do What The F*** You Want To Public License) +- **Copyright:** (c) Cockos Incorporated +- **Usage:** Bundled with YSFX for EEL2 compilation and DSP primitives + +--- + +## Lua 5.4 + +- **Website:** https://www.lua.org/ +- **License:** MIT License +- **Copyright:** (c) 1994-2024 Lua.org, PUC-Rio +- **Usage:** Embedded scripting engine for DAW automation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + +--- + +## Microsoft WebView2 + +- **Website:** https://developer.microsoft.com/en-us/microsoft-edge/webview2/ +- **License:** Microsoft Software License Terms +- **Copyright:** (c) Microsoft Corporation +- **Usage:** Embedded Chromium-based browser for the React frontend on Windows + +--- + +## FFmpeg + +- **Website:** https://ffmpeg.org/ +- **License:** LGPLv2.1+ / GPLv2+ +- **Copyright:** (c) The FFmpeg developers +- **Usage:** Audio format conversion (MP3, OGG, etc.) via external process + +FFmpeg is distributed as a standalone executable and is not linked into +the Studio13 binary. It is invoked as a child process for lossy format +encoding and sample rate conversion. + +--- + +## ASIO SDK + +- **Website:** https://www.steinberg.net/developers/ +- **License:** Steinberg ASIO SDK License Agreement +- **Copyright:** (c) Steinberg Media Technologies GmbH +- **Usage:** Low-latency audio driver support on Windows + +The ASIO SDK headers are used at compile time only. The SDK is not +redistributed with Studio13 binaries. + +--- + +## Frontend Dependencies (npm) + +The React frontend uses packages installed via npm. Key dependencies include: + +| Package | License | Usage | +|---------|---------|-------| +| React | MIT | UI framework | +| Zustand | MIT | State management | +| react-konva / Konva | MIT | Canvas-based timeline rendering | +| Tailwind CSS | MIT | Utility-first CSS framework | +| Vite | MIT | Build tool and dev server | +| Lucide React | ISC | Icon library | +| @dnd-kit | MIT | Drag-and-drop toolkit | + +For a complete list of frontend dependencies and their licenses, see +`frontend/package.json` and run `npx license-checker` in the frontend directory. diff --git a/effects/chorus.jsfx b/effects/chorus.jsfx new file mode 100644 index 0000000..c7bbd97 --- /dev/null +++ b/effects/chorus.jsfx @@ -0,0 +1,68 @@ +desc:S13 Chorus +author:Studio13 +tags:modulation chorus + +slider1:5<0.1,20,0.1>Delay (ms) +slider2:2<0.1,10,0.1>Depth (ms) +slider3:0.5<0.05,5,0.01>Rate (Hz) +slider4:50<0,100,1>Mix (%) +slider5:0<0,2,1{Mono,Stereo,Wide}>Mode + +@init +bufsize = srate; // 1 second buffer +buf_l = 0; +buf_r = bufsize; +memset(buf_l, 0, bufsize * 2); +write_pos = 0; +phase_l = 0; +phase_r = 0; + +@slider +base_delay = slider1 * 0.001 * srate; +depth = slider2 * 0.001 * srate; +rate = slider3; +mix = slider4 / 100; +mode = slider5; + +// Phase offset for stereo +mode == 0 ? phase_offset = 0; +mode == 1 ? phase_offset = $pi * 0.5; +mode == 2 ? phase_offset = $pi; + +@sample +// Write to buffer +buf_l[write_pos] = spl0; +buf_r[write_pos] = spl1; + +// LFO +phase_l += rate * 2 * $pi / srate; +phase_l >= 2 * $pi ? phase_l -= 2 * $pi; +phase_r = phase_l + phase_offset; + +// Calculate modulated delay +mod_l = base_delay + depth * sin(phase_l); +mod_r = base_delay + depth * sin(phase_r); + +// Read with linear interpolation +read_l = write_pos - mod_l; +read_l < 0 ? read_l += bufsize; +rd_l = floor(read_l); +frac_l = read_l - rd_l; +rd_l2 = rd_l + 1; +rd_l2 >= bufsize ? rd_l2 -= bufsize; +del_l = buf_l[rd_l] * (1-frac_l) + buf_l[rd_l2] * frac_l; + +read_r = write_pos - mod_r; +read_r < 0 ? read_r += bufsize; +rd_r = floor(read_r); +frac_r = read_r - rd_r; +rd_r2 = rd_r + 1; +rd_r2 >= bufsize ? rd_r2 -= bufsize; +del_r = buf_r[rd_r] * (1-frac_r) + buf_r[rd_r2] * frac_r; + +write_pos += 1; +write_pos >= bufsize ? write_pos = 0; + +// Output mix +spl0 = spl0 * (1-mix) + del_l * mix; +spl1 = spl1 * (1-mix) + del_r * mix; diff --git a/effects/chorus_advanced.jsfx b/effects/chorus_advanced.jsfx new file mode 100644 index 0000000..8a81b07 --- /dev/null +++ b/effects/chorus_advanced.jsfx @@ -0,0 +1,206 @@ +desc:S13 Chorus (4-Voice) +author:Studio13 +tags:modulation chorus advanced + +// Voice 1 +slider1:1<0,1,1{Off,On}>Voice 1 Enabled +slider2:5<0.1,30,0.1>Voice 1 Delay (ms) +slider3:2<0.1,10,0.1>Voice 1 Depth (ms) +slider4:0.5<0.05,5,0.01>Voice 1 Rate (Hz) + +// Voice 2 +slider5:0<0,1,1{Off,On}>Voice 2 Enabled +slider6:7<0.1,30,0.1>Voice 2 Delay (ms) +slider7:3<0.1,10,0.1>Voice 2 Depth (ms) +slider8:0.7<0.05,5,0.01>Voice 2 Rate (Hz) + +// Voice 3 +slider9:0<0,1,1{Off,On}>Voice 3 Enabled +slider10:10<0.1,30,0.1>Voice 3 Delay (ms) +slider11:1.5<0.1,10,0.1>Voice 3 Depth (ms) +slider12:0.3<0.05,5,0.01>Voice 3 Rate (Hz) + +// Voice 4 +slider13:0<0,1,1{Off,On}>Voice 4 Enabled +slider14:15<0.1,30,0.1>Voice 4 Delay (ms) +slider15:4<0.1,10,0.1>Voice 4 Depth (ms) +slider16:0.9<0.05,5,0.01>Voice 4 Rate (Hz) + +// Global +slider17:50<0,100,1>Mix (%) +slider18:0<0,2,1{Mono,Stereo,Wide}>Stereo Mode + +@init + +// 1 second circular buffer per channel +buf_size = ceil(srate); +buf_l = 0; +buf_r = buf_size; +buf_pos = 0; +memset(buf_l, 0, buf_size * 2); + +// LFO phases for 4 voices +lfo_phase0 = 0; +lfo_phase1 = 0; +lfo_phase2 = 0; +lfo_phase3 = 0; + +@slider + +// Voice parameters: enabled, delay_samps, depth_samps, lfo_inc +// Store at offset 100000 +i = 0; +loop(4, + base = i * 4; + 100000[i] = slider(base + 1); // enabled + 100004[i] = slider(base + 2) * srate / 1000; // delay in samples + 100008[i] = slider(base + 3) * srate / 1000; // depth in samples + 100012[i] = slider(base + 4) * 2 * $pi / srate; // LFO increment + i += 1; +); + +mix_wet = slider17 / 100; +mix_dry = 1 - mix_wet; +stereo_mode = slider18; +// Phase offsets for stereo: 0=mono, 1=stereo(pi/2), 2=wide(pi) +stereo_mode == 0 ? phase_offset = 0; +stereo_mode == 1 ? phase_offset = $pi / 2; +stereo_mode == 2 ? phase_offset = $pi; + +@sample + +// Write input to buffer +buf_l[buf_pos] = spl0; +buf_r[buf_pos] = spl1; + +wet_l = 0; +wet_r = 0; +voice_count = 0; + +// Process each voice +// Voice 0 +100000[0] ? ( + mod_l = sin(lfo_phase0) * 100008[0]; + mod_r = sin(lfo_phase0 + phase_offset) * 100008[0]; + delay_l = 100004[0] + mod_l; + delay_r = 100004[0] + mod_r; + + // Read with linear interpolation + rd = buf_pos - delay_l; + rd < 0 ? rd += buf_size; + idx = floor(rd); + frac = rd - idx; + idx2 = idx + 1; + idx2 >= buf_size ? idx2 -= buf_size; + wet_l += buf_l[idx] * (1 - frac) + buf_l[idx2] * frac; + + rd = buf_pos - delay_r; + rd < 0 ? rd += buf_size; + idx = floor(rd); + frac = rd - idx; + idx2 = idx + 1; + idx2 >= buf_size ? idx2 -= buf_size; + wet_r += buf_r[idx] * (1 - frac) + buf_r[idx2] * frac; + + lfo_phase0 += 100012[0]; + lfo_phase0 >= 2 * $pi ? lfo_phase0 -= 2 * $pi; + voice_count += 1; +); + +// Voice 1 +100000[1] ? ( + mod_l = sin(lfo_phase1) * 100008[1]; + mod_r = sin(lfo_phase1 + phase_offset) * 100008[1]; + delay_l = 100004[1] + mod_l; + delay_r = 100004[1] + mod_r; + + rd = buf_pos - delay_l; + rd < 0 ? rd += buf_size; + idx = floor(rd); + frac = rd - idx; + idx2 = idx + 1; + idx2 >= buf_size ? idx2 -= buf_size; + wet_l += buf_l[idx] * (1 - frac) + buf_l[idx2] * frac; + + rd = buf_pos - delay_r; + rd < 0 ? rd += buf_size; + idx = floor(rd); + frac = rd - idx; + idx2 = idx + 1; + idx2 >= buf_size ? idx2 -= buf_size; + wet_r += buf_r[idx] * (1 - frac) + buf_r[idx2] * frac; + + lfo_phase1 += 100012[1]; + lfo_phase1 >= 2 * $pi ? lfo_phase1 -= 2 * $pi; + voice_count += 1; +); + +// Voice 2 +100000[2] ? ( + mod_l = sin(lfo_phase2) * 100008[2]; + mod_r = sin(lfo_phase2 + phase_offset) * 100008[2]; + delay_l = 100004[2] + mod_l; + delay_r = 100004[2] + mod_r; + + rd = buf_pos - delay_l; + rd < 0 ? rd += buf_size; + idx = floor(rd); + frac = rd - idx; + idx2 = idx + 1; + idx2 >= buf_size ? idx2 -= buf_size; + wet_l += buf_l[idx] * (1 - frac) + buf_l[idx2] * frac; + + rd = buf_pos - delay_r; + rd < 0 ? rd += buf_size; + idx = floor(rd); + frac = rd - idx; + idx2 = idx + 1; + idx2 >= buf_size ? idx2 -= buf_size; + wet_r += buf_r[idx] * (1 - frac) + buf_r[idx2] * frac; + + lfo_phase2 += 100012[2]; + lfo_phase2 >= 2 * $pi ? lfo_phase2 -= 2 * $pi; + voice_count += 1; +); + +// Voice 3 +100000[3] ? ( + mod_l = sin(lfo_phase3) * 100008[3]; + mod_r = sin(lfo_phase3 + phase_offset) * 100008[3]; + delay_l = 100004[3] + mod_l; + delay_r = 100004[3] + mod_r; + + rd = buf_pos - delay_l; + rd < 0 ? rd += buf_size; + idx = floor(rd); + frac = rd - idx; + idx2 = idx + 1; + idx2 >= buf_size ? idx2 -= buf_size; + wet_l += buf_l[idx] * (1 - frac) + buf_l[idx2] * frac; + + rd = buf_pos - delay_r; + rd < 0 ? rd += buf_size; + idx = floor(rd); + frac = rd - idx; + idx2 = idx + 1; + idx2 >= buf_size ? idx2 -= buf_size; + wet_r += buf_r[idx] * (1 - frac) + buf_r[idx2] * frac; + + lfo_phase3 += 100012[3]; + lfo_phase3 >= 2 * $pi ? lfo_phase3 -= 2 * $pi; + voice_count += 1; +); + +// Average voices +voice_count > 0 ? ( + wet_l /= voice_count; + wet_r /= voice_count; +); + +// Advance buffer +buf_pos += 1; +buf_pos >= buf_size ? buf_pos = 0; + +// Output mix +spl0 = spl0 * mix_dry + wet_l * mix_wet; +spl1 = spl1 * mix_dry + wet_r * mix_wet; diff --git a/effects/compressor.jsfx b/effects/compressor.jsfx new file mode 100644 index 0000000..79bac3a --- /dev/null +++ b/effects/compressor.jsfx @@ -0,0 +1,48 @@ +desc:S13 Compressor +author:Studio13 +tags:dynamics compressor + +slider1:-20<-60,0,0.5>Threshold (dB) +slider2:4<1,20,0.1>Ratio +slider3:5<0.1,100,0.1>Attack (ms) +slider4:100<10,1000,1>Release (ms) +slider5:0<0,24,0.1>Makeup Gain (dB) +slider6:0<0,1,1{Peak,RMS}>Detection + +@init +env = 0; +gr = 1; + +@slider +threshold = 10^(slider1/20); +ratio = slider2; +attack_coeff = exp(-1 / (slider3 * 0.001 * srate)); +release_coeff = exp(-1 / (slider4 * 0.001 * srate)); +makeup = 10^(slider5/20); +use_rms = slider6 > 0.5; + +@sample +// Detect input level +use_rms ? ( + level = sqrt((spl0*spl0 + spl1*spl1) * 0.5); +) : ( + level = max(abs(spl0), abs(spl1)); +); + +// Envelope follower +level > env ? ( + env = attack_coeff * env + (1 - attack_coeff) * level; +) : ( + env = release_coeff * env + (1 - release_coeff) * level; +); + +// Gain reduction +env > threshold ? ( + gr = threshold * (env / threshold) ^ (1/ratio - 1); +) : ( + gr = 1; +); + +// Apply gain reduction and makeup +spl0 *= gr * makeup; +spl1 *= gr * makeup; diff --git a/effects/compressor_advanced.jsfx b/effects/compressor_advanced.jsfx new file mode 100644 index 0000000..85c6609 --- /dev/null +++ b/effects/compressor_advanced.jsfx @@ -0,0 +1,158 @@ +desc:S13 Compressor (Advanced) +author:Studio13 +tags:dynamics compressor advanced + +slider1:-20<-60,0,0.1>Threshold (dB) +slider2:4<1,20,0.1>Ratio +slider3:6<0,24,0.1>Knee (dB) +slider4:5<0.01,200,0.01>Attack (ms) +slider5:100<5,2000,1>Release (ms) +slider6:0<0,500,1>Hold (ms) +slider7:0<-12,36,0.1>Makeup Gain (dB) +slider8:100<0,100,1>Mix (%) +slider9:0<0,1,1{Peak,RMS}>Detection +slider10:20<20,2000,1>SC HPF (Hz) +slider11:20000<200,20000,1>SC LPF (Hz) +slider12:0<0,1,1{Off,On}>Auto Gain +slider13:100<0,100,1>Stereo Link (%) +slider14:0<0,10,0.1>Lookahead (ms) + +@init + +// Envelope follower state +env_l = 0; +env_r = 0; +hold_counter = 0; + +// Sidechain filter state +sc_hp_l_z1 = 0; +sc_hp_r_z1 = 0; +sc_lp_l_z1 = 0; +sc_lp_r_z1 = 0; + +// Lookahead buffer +la_buf_size = ceil(srate * 0.01) + 1; // max 10ms +la_buf_l = 0; +la_buf_r = la_buf_size; +la_pos = 0; +memset(la_buf_l, 0, la_buf_size); +memset(la_buf_r, 0, la_buf_size); + +// Current gain reduction for metering +gr_current = 0; + +@slider + +threshold = slider1; +ratio = slider2; +knee = slider3; +att_coeff = exp(-1 / (slider4 * 0.001 * srate)); +rel_coeff = exp(-1 / (slider5 * 0.001 * srate)); +hold_samples = floor(slider6 * 0.001 * srate); +makeup = 10^(slider7 / 20); +mix = slider8 / 100; +detect_rms = slider9; +stereo_link = slider13 / 100; + +// Sidechain filters — 1-pole coefficients +sc_hp_coeff = exp(-2 * $pi * slider10 / srate); +sc_lp_coeff = 1 - exp(-2 * $pi * slider11 / srate); + +// Auto-gain: compensate based on threshold and ratio +auto_gain = slider12; +auto_gain ? ( + auto_comp = -threshold * (1 - 1/ratio); + makeup = 10^((slider7 + auto_comp) / 20); +); + +// Lookahead delay in samples +la_delay = floor(slider14 * 0.001 * srate); + +@sample + +// Read from lookahead buffer (delayed signal) +la_out_l = la_buf_l[la_pos]; +la_out_r = la_buf_r[la_pos]; + +// Write current input to lookahead buffer +la_buf_l[la_pos] = spl0; +la_buf_r[la_pos] = spl1; +la_pos += 1; +la_pos >= la_buf_size ? la_pos = 0; + +// Use current input for detection (not delayed) +det_l = spl0; +det_r = spl1; + +// Sidechain highpass filter (1-pole) +sc_hp_l_z1 = sc_hp_coeff * sc_hp_l_z1 + (1 - sc_hp_coeff) * det_l; +det_l -= sc_hp_l_z1; +sc_hp_r_z1 = sc_hp_coeff * sc_hp_r_z1 + (1 - sc_hp_coeff) * det_r; +det_r -= sc_hp_r_z1; + +// Sidechain lowpass filter (1-pole) +sc_lp_l_z1 += sc_lp_coeff * (det_l - sc_lp_l_z1); +det_l = sc_lp_l_z1; +sc_lp_r_z1 += sc_lp_coeff * (det_r - sc_lp_r_z1); +det_r = sc_lp_r_z1; + +// Level detection +detect_rms ? ( + level_l = sqrt(det_l * det_l); + level_r = sqrt(det_r * det_r); +) : ( + level_l = abs(det_l); + level_r = abs(det_r); +); + +// Stereo linking +level = level_l * stereo_link + level_l * (1 - stereo_link); +level_r_linked = level_r * stereo_link + level_r * (1 - stereo_link); +level = max(level, level_r_linked * stereo_link); + +// Convert to dB +level_db = level > 0.0000001 ? 20 * log10(level) : -140; + +// Compute gain reduction (soft knee) +knee_lo = threshold - knee / 2; +knee_hi = threshold + knee / 2; + +level_db < knee_lo ? ( + gr_db = 0; +) : level_db > knee_hi ? ( + gr_db = (level_db - threshold) * (1 - 1/ratio); +) : ( + // Soft knee: quadratic interpolation + x = level_db - knee_lo; + gr_db = (1 - 1/ratio) * x * x / (2 * knee); +); + +gr_target = 10^(-gr_db / 20); + +// Envelope follower with attack/release/hold +gr_target < env_l ? ( + env_l = att_coeff * env_l + (1 - att_coeff) * gr_target; + hold_counter = hold_samples; +) : ( + hold_counter > 0 ? ( + hold_counter -= 1; + ) : ( + env_l = rel_coeff * env_l + (1 - rel_coeff) * gr_target; + ); +); + +// Store current GR for metering +gr_current = env_l; + +// Apply gain to lookahead-delayed signal +la_delay > 0 ? ( + out_l = la_out_l * env_l * makeup; + out_r = la_out_r * env_l * makeup; +) : ( + out_l = spl0 * env_l * makeup; + out_r = spl1 * env_l * makeup; +); + +// Wet/dry mix +spl0 = spl0 * (1 - mix) + out_l * mix; +spl1 = spl1 * (1 - mix) + out_r * mix; diff --git a/effects/delay.jsfx b/effects/delay.jsfx new file mode 100644 index 0000000..4070283 --- /dev/null +++ b/effects/delay.jsfx @@ -0,0 +1,79 @@ +desc:S13 Delay +author:Studio13 +tags:delay echo + +slider1:250<1,2000,1>Delay Time (ms) +slider2:50<0,100,1>Feedback (%) +slider3:50<0,100,1>Mix (%) +slider4:0<0,1,1{Off,On}>Tempo Sync +slider5:0<0,5,1{1/4,1/8,1/8 Dot,1/16,1/4 Trip,1/2}>Note Division +slider6:50<0,100,1>Low Cut (%) +slider7:80<0,100,1>High Cut (%) + +@init +bufsize = srate * 4; // 4 seconds max delay +buf_l = 0; +buf_r = bufsize; +write_pos = 0; +memset(buf_l, 0, bufsize); +memset(buf_r, 0, bufsize); + +// Simple 1-pole filters for feedback +lp_l = 0; lp_r = 0; +hp_l = 0; hp_r = 0; + +@slider +// Calculate delay in samples +slider4 > 0.5 && tempo > 0 ? ( + // Tempo synced + beat_len = 60 / tempo * srate; + slider5 == 0 ? delay_samps = beat_len; // 1/4 + slider5 == 1 ? delay_samps = beat_len/2; // 1/8 + slider5 == 2 ? delay_samps = beat_len*3/4; // 1/8 dot + slider5 == 3 ? delay_samps = beat_len/4; // 1/16 + slider5 == 4 ? delay_samps = beat_len*2/3; // 1/4 triplet + slider5 == 5 ? delay_samps = beat_len*2; // 1/2 +) : ( + delay_samps = slider1 * 0.001 * srate; +); + +delay_samps = min(delay_samps, bufsize - 1); +feedback = slider2 / 100; +mix = slider3 / 100; + +// Filter coefficients +lp_coeff = 1 - slider7/100; +hp_coeff = slider6/100 * 0.99; + +@sample +// Read from delay buffer +read_pos = write_pos - delay_samps; +read_pos < 0 ? read_pos += bufsize; + +rd = floor(read_pos); +frac = read_pos - rd; +rd2 = rd + 1; +rd2 >= bufsize ? rd2 -= bufsize; + +// Linear interpolation +del_l = buf_l[rd] * (1-frac) + buf_l[rd2] * frac; +del_r = buf_r[rd] * (1-frac) + buf_r[rd2] * frac; + +// Apply feedback filters +lp_l += lp_coeff * (del_l - lp_l); +lp_r += lp_coeff * (del_r - lp_r); +hp_l += hp_coeff * (lp_l - hp_l); +hp_r += hp_coeff * (lp_r - hp_r); +del_l = lp_l - hp_l; +del_r = lp_r - hp_r; + +// Write to delay buffer +buf_l[write_pos] = spl0 + del_l * feedback; +buf_r[write_pos] = spl1 + del_r * feedback; + +write_pos += 1; +write_pos >= bufsize ? write_pos = 0; + +// Mix dry/wet +spl0 = spl0 * (1 - mix) + del_l * mix; +spl1 = spl1 * (1 - mix) + del_r * mix; diff --git a/effects/delay_advanced.jsfx b/effects/delay_advanced.jsfx new file mode 100644 index 0000000..ce64218 --- /dev/null +++ b/effects/delay_advanced.jsfx @@ -0,0 +1,156 @@ +desc:S13 Delay (8-Tap) +author:Studio13 +tags:delay multitap advanced + +// Tap 1 +slider1:1<0,1,1{Off,On}>Tap 1 Enabled +slider2:250<1,2000,1>Tap 1 Time (ms) +slider3:100<0,100,1>Tap 1 Level (%) +slider4:0<-100,100,1>Tap 1 Pan (%) +slider5:0<0,95,1>Tap 1 Feedback (%) + +// Tap 2 +slider6:0<0,1,1{Off,On}>Tap 2 Enabled +slider7:500<1,2000,1>Tap 2 Time (ms) +slider8:70<0,100,1>Tap 2 Level (%) +slider9:0<-100,100,1>Tap 2 Pan (%) +slider10:0<0,95,1>Tap 2 Feedback (%) + +// Tap 3 +slider11:0<0,1,1{Off,On}>Tap 3 Enabled +slider12:750<1,2000,1>Tap 3 Time (ms) +slider13:50<0,100,1>Tap 3 Level (%) +slider14:0<-100,100,1>Tap 3 Pan (%) +slider15:0<0,95,1>Tap 3 Feedback (%) + +// Tap 4 +slider16:0<0,1,1{Off,On}>Tap 4 Enabled +slider17:1000<1,2000,1>Tap 4 Time (ms) +slider18:35<0,100,1>Tap 4 Level (%) +slider19:0<-100,100,1>Tap 4 Pan (%) +slider20:0<0,95,1>Tap 4 Feedback (%) + +// Tap 5 +slider21:0<0,1,1{Off,On}>Tap 5 Enabled +slider22:375<1,2000,1>Tap 5 Time (ms) +slider23:60<0,100,1>Tap 5 Level (%) +slider24:0<-100,100,1>Tap 5 Pan (%) +slider25:0<0,95,1>Tap 5 Feedback (%) + +// Tap 6 +slider26:0<0,1,1{Off,On}>Tap 6 Enabled +slider27:625<1,2000,1>Tap 6 Time (ms) +slider28:40<0,100,1>Tap 6 Level (%) +slider29:0<-100,100,1>Tap 6 Pan (%) +slider30:0<0,95,1>Tap 6 Feedback (%) + +// Tap 7 +slider31:0<0,1,1{Off,On}>Tap 7 Enabled +slider32:875<1,2000,1>Tap 7 Time (ms) +slider33:30<0,100,1>Tap 7 Level (%) +slider34:0<-100,100,1>Tap 7 Pan (%) +slider35:0<0,95,1>Tap 7 Feedback (%) + +// Tap 8 +slider36:0<0,1,1{Off,On}>Tap 8 Enabled +slider37:1250<1,2000,1>Tap 8 Time (ms) +slider38:20<0,100,1>Tap 8 Level (%) +slider39:0<-100,100,1>Tap 8 Pan (%) +slider40:0<0,95,1>Tap 8 Feedback (%) + +// Global +slider41:0<0,1,1{Off,On}>Tempo Sync +slider42:50<0,100,1>Mix (%) + +@init + +// 4-second circular buffer per channel +buf_size = ceil(srate * 4); +buf_l = 0; +buf_r = buf_size; +buf_pos = 0; +memset(buf_l, 0, buf_size); +memset(buf_r, 0, buf_size); + +// Per-tap feedback accumulators +fb0 = fb1 = fb2 = fb3 = fb4 = fb5 = fb6 = fb7 = 0; + +@slider + +// Read tap parameters into arrays using memory +// tap_en: offset 200000, tap_delay: 200008, tap_level: 200016 +// tap_pan_l: 200024, tap_pan_r: 200032, tap_fb: 200040 +i = 0; +loop(8, + base = i * 5; + en = slider(base + 1); + 200000[i] = en; // enabled + time_ms = slider(base + 2); + 200008[i] = floor(time_ms * srate / 1000); // delay in samples + 200016[i] = slider(base + 3) / 100; // level + pan = slider(base + 4) / 100; // -1 to 1 + 200024[i] = cos((pan + 1) * $pi / 4); // pan L + 200032[i] = sin((pan + 1) * $pi / 4); // pan R + 200040[i] = slider(base + 5) / 100; // feedback + i += 1; +); + +mix_wet = slider42 / 100; +mix_dry = 1 - mix_wet; + +@sample + +// Write input + feedback to buffer +fb_sum_l = 0; +fb_sum_r = 0; + +// Sum per-tap feedback +i = 0; +loop(8, + 200000[i] ? ( + delay_samps = 200008[i]; + fb_amt = 200040[i]; + fb_amt > 0 ? ( + read_pos = buf_pos - delay_samps; + read_pos < 0 ? read_pos += buf_size; + fb_sum_l += buf_l[read_pos] * fb_amt * 200016[i]; + fb_sum_r += buf_r[read_pos] * fb_amt * 200016[i]; + ); + ); + i += 1; +); + +buf_l[buf_pos] = spl0 + fb_sum_l; +buf_r[buf_pos] = spl1 + fb_sum_r; + +// Sum all enabled taps +wet_l = 0; +wet_r = 0; + +i = 0; +loop(8, + 200000[i] ? ( + delay_samps = 200008[i]; + level = 200016[i]; + pan_l = 200024[i]; + pan_r = 200032[i]; + + read_pos = buf_pos - delay_samps; + read_pos < 0 ? read_pos += buf_size; + + tap_l = buf_l[read_pos] * level; + tap_r = buf_r[read_pos] * level; + + wet_l += tap_l * pan_l; + wet_r += tap_r * pan_r; + ); + i += 1; +); + +// Advance buffer position +buf_pos += 1; +buf_pos >= buf_size ? buf_pos = 0; + +// Output mix +spl0 = spl0 * mix_dry + wet_l * mix_wet; +spl1 = spl1 * mix_dry + wet_r * mix_wet; diff --git a/effects/eq3.jsfx b/effects/eq3.jsfx new file mode 100644 index 0000000..c7a874a --- /dev/null +++ b/effects/eq3.jsfx @@ -0,0 +1,78 @@ +desc:S13 3-Band EQ +author:Studio13 +tags:eq equalizer + +slider1:0<-24,24,0.1>Low Gain (dB) +slider2:200<20,1000,1>Low Freq (Hz) +slider3:0<-24,24,0.1>Mid Gain (dB) +slider4:1000<200,8000,1>Mid Freq (Hz) +slider5:1.0<0.1,10,0.1>Mid Q +slider6:0<-24,24,0.1>High Gain (dB) +slider7:4000<1000,20000,1>High Freq (Hz) + +@init +function biquad_coeff(type, fc, gain_db, q) + local(w0, alpha, a, a0, a1, a2, b0, b1, b2, A) +( + w0 = 2 * $pi * fc / srate; + A = 10^(gain_db / 40); + + type == 0 ? ( // Low shelf + alpha = sin(w0) / 2 * sqrt((A + 1/A) * (1/0.707 - 1) + 2); + b0 = A * ((A+1) - (A-1)*cos(w0) + 2*sqrt(A)*alpha); + b1 = 2*A * ((A-1) - (A+1)*cos(w0)); + b2 = A * ((A+1) - (A-1)*cos(w0) - 2*sqrt(A)*alpha); + a0 = (A+1) + (A-1)*cos(w0) + 2*sqrt(A)*alpha; + a1 = -2 * ((A-1) + (A+1)*cos(w0)); + a2 = (A+1) + (A-1)*cos(w0) - 2*sqrt(A)*alpha; + ) : type == 1 ? ( // Peaking + alpha = sin(w0) / (2*q); + b0 = 1 + alpha*A; + b1 = -2*cos(w0); + b2 = 1 - alpha*A; + a0 = 1 + alpha/A; + a1 = -2*cos(w0); + a2 = 1 - alpha/A; + ) : ( // High shelf + alpha = sin(w0) / 2 * sqrt((A + 1/A) * (1/0.707 - 1) + 2); + b0 = A * ((A+1) + (A-1)*cos(w0) + 2*sqrt(A)*alpha); + b1 = -2*A * ((A-1) + (A+1)*cos(w0)); + b2 = A * ((A+1) + (A-1)*cos(w0) - 2*sqrt(A)*alpha); + a0 = (A+1) - (A-1)*cos(w0) + 2*sqrt(A)*alpha; + a1 = 2 * ((A-1) - (A+1)*cos(w0)); + a2 = (A+1) - (A-1)*cos(w0) - 2*sqrt(A)*alpha; + ); + + // Normalize + this.b0 = b0/a0; this.b1 = b1/a0; this.b2 = b2/a0; + this.a1 = a1/a0; this.a2 = a2/a0; +); + +function biquad_process(x) + local(y) +( + y = this.b0*x + this.b1*this.x1 + this.b2*this.x2 - this.a1*this.y1 - this.a2*this.y2; + this.x2 = this.x1; this.x1 = x; + this.y2 = this.y1; this.y1 = y; + y; +); + +// 6 biquad filters: 3 bands x 2 channels +low_l.x1 = low_l.x2 = low_l.y1 = low_l.y2 = 0; +low_r.x1 = low_r.x2 = low_r.y1 = low_r.y2 = 0; +mid_l.x1 = mid_l.x2 = mid_l.y1 = mid_l.y2 = 0; +mid_r.x1 = mid_r.x2 = mid_r.y1 = mid_r.y2 = 0; +high_l.x1 = high_l.x2 = high_l.y1 = high_l.y2 = 0; +high_r.x1 = high_r.x2 = high_r.y1 = high_r.y2 = 0; + +@slider +low_l.biquad_coeff(0, slider2, slider1, 0.707); +low_r.biquad_coeff(0, slider2, slider1, 0.707); +mid_l.biquad_coeff(1, slider4, slider3, slider5); +mid_r.biquad_coeff(1, slider4, slider3, slider5); +high_l.biquad_coeff(2, slider7, slider6, 0.707); +high_r.biquad_coeff(2, slider7, slider6, 0.707); + +@sample +spl0 = high_l.biquad_process(mid_l.biquad_process(low_l.biquad_process(spl0))); +spl1 = high_r.biquad_process(mid_r.biquad_process(low_r.biquad_process(spl1))); diff --git a/effects/eq_advanced.jsfx b/effects/eq_advanced.jsfx new file mode 100644 index 0000000..e3079f9 --- /dev/null +++ b/effects/eq_advanced.jsfx @@ -0,0 +1,224 @@ +desc:S13 Parametric EQ (8-Band) +author:Studio13 +tags:eq equalizer parametric advanced + +// Band 1 +slider1:0<0,1,1{Off,On}>Band 1 Enabled +slider2:1<0,5,1{Low Shelf,Peak,High Shelf,Low Pass,High Pass,Notch}>Band 1 Type +slider3:80<20,20000,1>Band 1 Freq (Hz) +slider4:0<-24,24,0.1>Band 1 Gain (dB) +slider5:1.0<0.1,10,0.01>Band 1 Q + +// Band 2 +slider6:0<0,1,1{Off,On}>Band 2 Enabled +slider7:1<0,5,1{Low Shelf,Peak,High Shelf,Low Pass,High Pass,Notch}>Band 2 Type +slider8:250<20,20000,1>Band 2 Freq (Hz) +slider9:0<-24,24,0.1>Band 2 Gain (dB) +slider10:1.0<0.1,10,0.01>Band 2 Q + +// Band 3 +slider11:0<0,1,1{Off,On}>Band 3 Enabled +slider12:1<0,5,1{Low Shelf,Peak,High Shelf,Low Pass,High Pass,Notch}>Band 3 Type +slider13:800<20,20000,1>Band 3 Freq (Hz) +slider14:0<-24,24,0.1>Band 3 Gain (dB) +slider15:1.0<0.1,10,0.01>Band 3 Q + +// Band 4 +slider16:0<0,1,1{Off,On}>Band 4 Enabled +slider17:1<0,5,1{Low Shelf,Peak,High Shelf,Low Pass,High Pass,Notch}>Band 4 Type +slider18:2000<20,20000,1>Band 4 Freq (Hz) +slider19:0<-24,24,0.1>Band 4 Gain (dB) +slider20:1.0<0.1,10,0.01>Band 4 Q + +// Band 5 +slider21:0<0,1,1{Off,On}>Band 5 Enabled +slider22:1<0,5,1{Low Shelf,Peak,High Shelf,Low Pass,High Pass,Notch}>Band 5 Type +slider23:4000<20,20000,1>Band 5 Freq (Hz) +slider24:0<-24,24,0.1>Band 5 Gain (dB) +slider25:1.0<0.1,10,0.01>Band 5 Q + +// Band 6 +slider26:0<0,1,1{Off,On}>Band 6 Enabled +slider27:1<0,5,1{Low Shelf,Peak,High Shelf,Low Pass,High Pass,Notch}>Band 6 Type +slider28:8000<20,20000,1>Band 6 Freq (Hz) +slider29:0<-24,24,0.1>Band 6 Gain (dB) +slider30:1.0<0.1,10,0.01>Band 6 Q + +// Band 7 +slider31:0<0,1,1{Off,On}>Band 7 Enabled +slider32:1<0,5,1{Low Shelf,Peak,High Shelf,Low Pass,High Pass,Notch}>Band 7 Type +slider33:12000<20,20000,1>Band 7 Freq (Hz) +slider34:0<-24,24,0.1>Band 7 Gain (dB) +slider35:1.0<0.1,10,0.01>Band 7 Q + +// Band 8 +slider36:0<0,1,1{Off,On}>Band 8 Enabled +slider37:1<0,5,1{Low Shelf,Peak,High Shelf,Low Pass,High Pass,Notch}>Band 8 Type +slider38:16000<20,20000,1>Band 8 Freq (Hz) +slider39:0<-24,24,0.1>Band 8 Gain (dB) +slider40:1.0<0.1,10,0.01>Band 8 Q + +// Output +slider41:0<-12,12,0.1>Output Gain (dB) + +@init + +// Biquad coefficient calculation — supports 6 filter types +// type: 0=Low Shelf, 1=Peak, 2=High Shelf, 3=Low Pass, 4=High Pass, 5=Notch +function biquad_coeff(type, fc, gain_db, q) + local(w0, alpha, A, cosw, sinw, a0, a1, a2, b0, b1, b2, sqrtA2alpha) +( + w0 = 2 * $pi * fc / srate; + cosw = cos(w0); + sinw = sin(w0); + A = 10^(gain_db / 40); + + type == 0 ? ( // Low shelf + alpha = sinw / 2 * sqrt((A + 1/A) * (1/0.707 - 1) + 2); + sqrtA2alpha = 2 * sqrt(A) * alpha; + b0 = A * ((A+1) - (A-1)*cosw + sqrtA2alpha); + b1 = 2*A * ((A-1) - (A+1)*cosw); + b2 = A * ((A+1) - (A-1)*cosw - sqrtA2alpha); + a0 = (A+1) + (A-1)*cosw + sqrtA2alpha; + a1 = -2 * ((A-1) + (A+1)*cosw); + a2 = (A+1) + (A-1)*cosw - sqrtA2alpha; + ) : type == 1 ? ( // Peak / Bell + alpha = sinw / (2*q); + b0 = 1 + alpha*A; + b1 = -2*cosw; + b2 = 1 - alpha*A; + a0 = 1 + alpha/A; + a1 = -2*cosw; + a2 = 1 - alpha/A; + ) : type == 2 ? ( // High shelf + alpha = sinw / 2 * sqrt((A + 1/A) * (1/0.707 - 1) + 2); + sqrtA2alpha = 2 * sqrt(A) * alpha; + b0 = A * ((A+1) + (A-1)*cosw + sqrtA2alpha); + b1 = -2*A * ((A-1) + (A+1)*cosw); + b2 = A * ((A+1) + (A-1)*cosw - sqrtA2alpha); + a0 = (A+1) - (A-1)*cosw + sqrtA2alpha; + a1 = 2 * ((A-1) - (A+1)*cosw); + a2 = (A+1) - (A-1)*cosw - sqrtA2alpha; + ) : type == 3 ? ( // Low pass + alpha = sinw / (2*q); + b0 = (1 - cosw) / 2; + b1 = 1 - cosw; + b2 = (1 - cosw) / 2; + a0 = 1 + alpha; + a1 = -2*cosw; + a2 = 1 - alpha; + ) : type == 4 ? ( // High pass + alpha = sinw / (2*q); + b0 = (1 + cosw) / 2; + b1 = -(1 + cosw); + b2 = (1 + cosw) / 2; + a0 = 1 + alpha; + a1 = -2*cosw; + a2 = 1 - alpha; + ) : ( // Notch (type == 5) + alpha = sinw / (2*q); + b0 = 1; + b1 = -2*cosw; + b2 = 1; + a0 = 1 + alpha; + a1 = -2*cosw; + a2 = 1 - alpha; + ); + + // Normalize coefficients + this.b0 = b0/a0; this.b1 = b1/a0; this.b2 = b2/a0; + this.a1 = a1/a0; this.a2 = a2/a0; +); + +function biquad_process(x) + local(y) +( + y = this.b0*x + this.b1*this.x1 + this.b2*this.x2 - this.a1*this.y1 - this.a2*this.y2; + this.x2 = this.x1; this.x1 = x; + this.y2 = this.y1; this.y1 = y; + y; +); + +// Initialize 8 bands x 2 channels = 16 biquad filter states +// Memory layout: band_N_L at offset N*2, band_N_R at offset N*2+1 (using namespace trick) +b1_l.x1 = b1_l.x2 = b1_l.y1 = b1_l.y2 = 0; +b1_r.x1 = b1_r.x2 = b1_r.y1 = b1_r.y2 = 0; +b2_l.x1 = b2_l.x2 = b2_l.y1 = b2_l.y2 = 0; +b2_r.x1 = b2_r.x2 = b2_r.y1 = b2_r.y2 = 0; +b3_l.x1 = b3_l.x2 = b3_l.y1 = b3_l.y2 = 0; +b3_r.x1 = b3_r.x2 = b3_r.y1 = b3_r.y2 = 0; +b4_l.x1 = b4_l.x2 = b4_l.y1 = b4_l.y2 = 0; +b4_r.x1 = b4_r.x2 = b4_r.y1 = b4_r.y2 = 0; +b5_l.x1 = b5_l.x2 = b5_l.y1 = b5_l.y2 = 0; +b5_r.x1 = b5_r.x2 = b5_r.y1 = b5_r.y2 = 0; +b6_l.x1 = b6_l.x2 = b6_l.y1 = b6_l.y2 = 0; +b6_r.x1 = b6_r.x2 = b6_r.y1 = b6_r.y2 = 0; +b7_l.x1 = b7_l.x2 = b7_l.y1 = b7_l.y2 = 0; +b7_r.x1 = b7_r.x2 = b7_r.y1 = b7_r.y2 = 0; +b8_l.x1 = b8_l.x2 = b8_l.y1 = b8_l.y2 = 0; +b8_r.x1 = b8_r.x2 = b8_r.y1 = b8_r.y2 = 0; + +// Enabled flags (avoid per-sample branching on slider reads) +en1 = en2 = en3 = en4 = en5 = en6 = en7 = en8 = 0; +out_gain = 1; + +@slider +// Recalculate coefficients for all enabled bands +en1 = slider1; +en1 ? b1_l.biquad_coeff(slider2, slider3, slider4, slider5); +en1 ? b1_r.biquad_coeff(slider2, slider3, slider4, slider5); + +en2 = slider6; +en2 ? b2_l.biquad_coeff(slider7, slider8, slider9, slider10); +en2 ? b2_r.biquad_coeff(slider7, slider8, slider9, slider10); + +en3 = slider11; +en3 ? b3_l.biquad_coeff(slider12, slider13, slider14, slider15); +en3 ? b3_r.biquad_coeff(slider12, slider13, slider14, slider15); + +en4 = slider16; +en4 ? b4_l.biquad_coeff(slider17, slider18, slider19, slider20); +en4 ? b4_r.biquad_coeff(slider17, slider18, slider19, slider20); + +en5 = slider21; +en5 ? b5_l.biquad_coeff(slider22, slider23, slider24, slider25); +en5 ? b5_r.biquad_coeff(slider22, slider23, slider24, slider25); + +en6 = slider26; +en6 ? b6_l.biquad_coeff(slider27, slider28, slider29, slider30); +en6 ? b6_r.biquad_coeff(slider27, slider28, slider29, slider30); + +en7 = slider31; +en7 ? b7_l.biquad_coeff(slider32, slider33, slider34, slider35); +en7 ? b7_r.biquad_coeff(slider32, slider33, slider34, slider35); + +en8 = slider36; +en8 ? b8_l.biquad_coeff(slider37, slider38, slider39, slider40); +en8 ? b8_r.biquad_coeff(slider37, slider38, slider39, slider40); + +out_gain = 10^(slider41/20); + +@sample +// Process left channel through all enabled bands +en1 ? spl0 = b1_l.biquad_process(spl0); +en2 ? spl0 = b2_l.biquad_process(spl0); +en3 ? spl0 = b3_l.biquad_process(spl0); +en4 ? spl0 = b4_l.biquad_process(spl0); +en5 ? spl0 = b5_l.biquad_process(spl0); +en6 ? spl0 = b6_l.biquad_process(spl0); +en7 ? spl0 = b7_l.biquad_process(spl0); +en8 ? spl0 = b8_l.biquad_process(spl0); + +// Process right channel through all enabled bands +en1 ? spl1 = b1_r.biquad_process(spl1); +en2 ? spl1 = b2_r.biquad_process(spl1); +en3 ? spl1 = b3_r.biquad_process(spl1); +en4 ? spl1 = b4_r.biquad_process(spl1); +en5 ? spl1 = b5_r.biquad_process(spl1); +en6 ? spl1 = b6_r.biquad_process(spl1); +en7 ? spl1 = b7_r.biquad_process(spl1); +en8 ? spl1 = b8_r.biquad_process(spl1); + +// Apply output gain +spl0 *= out_gain; +spl1 *= out_gain; diff --git a/effects/gain.jsfx b/effects/gain.jsfx new file mode 100644 index 0000000..e880190 --- /dev/null +++ b/effects/gain.jsfx @@ -0,0 +1,39 @@ +desc:S13 Gain / Utility +author:Studio13 +tags:utility gain pan + +slider1:0<-60,12,0.1>Volume (dB) +slider2:0<-100,100,1>Pan (%) +slider3:0<0,1,1{Normal,Invert}>Phase Invert +slider4:0<0,2,1{Stereo,Mono,Swap}>Channel Mode + +@init +last_gain = 0; + +@slider +gain = 10^(slider1/20); +pan = slider2 / 100; +invert = slider3 > 0.5 ? -1 : 1; +mode = slider4; + +// Equal-power panning +pan_l = cos((pan + 1) * $pi / 4); +pan_r = sin((pan + 1) * $pi / 4); + +@sample +// Apply channel mode +mode == 1 ? ( + // Mono: average both channels + mono = (spl0 + spl1) * 0.5; + spl0 = mono; + spl1 = mono; +) : mode == 2 ? ( + // Swap channels + tmp = spl0; + spl0 = spl1; + spl1 = tmp; +); + +// Apply gain, pan, and phase +spl0 *= gain * pan_l * invert; +spl1 *= gain * pan_r * invert; diff --git a/effects/gate.jsfx b/effects/gate.jsfx new file mode 100644 index 0000000..f4b81dc --- /dev/null +++ b/effects/gate.jsfx @@ -0,0 +1,64 @@ +desc:S13 Gate +author:Studio13 +tags:dynamics gate + +slider1:-40<-80,0,0.5>Threshold (dB) +slider2:0.5<0.01,50,0.01>Attack (ms) +slider3:50<1,500,1>Hold (ms) +slider4:100<10,2000,1>Release (ms) +slider5:-80<-80,0,1>Range (dB) + +@init +env = 0; +gate_state = 0; // 0=closed, 1=opening, 2=open, 3=holding, 4=closing +hold_counter = 0; +gain = 0; + +@slider +threshold = 10^(slider1/20); +attack_coeff = exp(-1 / (slider2 * 0.001 * srate)); +hold_samps = slider3 * 0.001 * srate; +release_coeff = exp(-1 / (slider4 * 0.001 * srate)); +range_gain = 10^(slider5/20); + +@sample +// Detect level +level = max(abs(spl0), abs(spl1)); + +// Gate state machine +level > threshold ? ( + // Signal above threshold + gate_state < 2 ? gate_state = 1; // Opening + gate_state == 3 ? gate_state = 2; // Was holding, now open + gate_state == 4 ? gate_state = 1; // Was closing, now opening + hold_counter = hold_samps; +) : ( + // Signal below threshold + gate_state == 2 ? ( + gate_state = 3; // Start holding + ); + gate_state == 3 ? ( + hold_counter -= 1; + hold_counter <= 0 ? gate_state = 4; // Start closing + ); +); + +// Gain envelope +gate_state == 1 ? ( + // Attack (opening) + gain = 1 - attack_coeff * (1 - gain); + gain >= 0.999 ? (gain = 1; gate_state = 2); +) : gate_state == 2 || gate_state == 3 ? ( + // Open or holding + gain = 1; +) : gate_state == 4 ? ( + // Release (closing) + gain = release_coeff * gain; + gain <= range_gain + 0.001 ? (gain = range_gain; gate_state = 0); +) : ( + // Closed + gain = range_gain; +); + +spl0 *= gain; +spl1 *= gain; diff --git a/effects/gate_advanced.jsfx b/effects/gate_advanced.jsfx new file mode 100644 index 0000000..c93fd89 --- /dev/null +++ b/effects/gate_advanced.jsfx @@ -0,0 +1,149 @@ +desc:S13 Gate (Advanced) +author:Studio13 +tags:dynamics gate advanced + +slider1:-40<-80,0,0.1>Threshold (dB) +slider2:-80<-80,0,0.1>Range (dB) +slider3:6<0,12,0.1>Hysteresis (dB) +slider4:0.5<0.01,50,0.01>Attack (ms) +slider5:50<1,500,1>Hold (ms) +slider6:100<10,2000,1>Release (ms) +slider7:0<0,10,0.1>Lookahead (ms) +slider8:0<0,1,1{Peak,RMS}>Detection +slider9:20<20,2000,1>SC HPF (Hz) +slider10:20000<200,20000,1>SC LPF (Hz) +slider11:0<0,1,1{Gate,Duck}>Mode +slider12:100<0,100,1>Mix (%) + +@init + +// Gate states: 0=closed, 1=opening, 2=open, 3=holding, 4=closing +gate_state = 0; +gate_gain = 0; +hold_counter = 0; + +// Sidechain filter state +sc_hp_l_z1 = 0; +sc_hp_r_z1 = 0; +sc_lp_l_z1 = 0; +sc_lp_r_z1 = 0; + +// Lookahead buffer +la_buf_size = ceil(srate * 0.01) + 1; +la_buf_l = 0; +la_buf_r = la_buf_size; +la_pos = 0; +memset(la_buf_l, 0, la_buf_size); +memset(la_buf_r, 0, la_buf_size); + +@slider + +threshold = slider1; +range_lin = 10^(slider2 / 20); +hysteresis = slider3; +close_threshold = threshold - hysteresis; +att_coeff = 1 - exp(-1 / (slider4 * 0.001 * srate)); +hold_samples = floor(slider5 * 0.001 * srate); +rel_coeff = 1 - exp(-1 / (slider6 * 0.001 * srate)); +la_delay = floor(slider7 * 0.001 * srate); +detect_rms = slider8; +duck_mode = slider11; +mix = slider12 / 100; + +// Sidechain filters +sc_hp_coeff = exp(-2 * $pi * slider9 / srate); +sc_lp_coeff = 1 - exp(-2 * $pi * slider10 / srate); + +@sample + +// Read from lookahead buffer +la_out_l = la_buf_l[la_pos]; +la_out_r = la_buf_r[la_pos]; + +// Write current input +la_buf_l[la_pos] = spl0; +la_buf_r[la_pos] = spl1; +la_pos += 1; +la_pos >= la_buf_size ? la_pos = 0; + +// Detection signal +det_l = spl0; +det_r = spl1; + +// Sidechain HPF +sc_hp_l_z1 = sc_hp_coeff * sc_hp_l_z1 + (1 - sc_hp_coeff) * det_l; +det_l -= sc_hp_l_z1; +sc_hp_r_z1 = sc_hp_coeff * sc_hp_r_z1 + (1 - sc_hp_coeff) * det_r; +det_r -= sc_hp_r_z1; + +// Sidechain LPF +sc_lp_l_z1 += sc_lp_coeff * (det_l - sc_lp_l_z1); +det_l = sc_lp_l_z1; +sc_lp_r_z1 += sc_lp_coeff * (det_r - sc_lp_r_z1); +det_r = sc_lp_r_z1; + +// Level detection +detect_rms ? ( + level = sqrt((det_l * det_l + det_r * det_r) / 2); +) : ( + level = max(abs(det_l), abs(det_r)); +); + +level_db = level > 0.0000001 ? 20 * log10(level) : -140; + +// State machine +gate_state == 0 ? ( // Closed + level_db >= threshold ? ( + gate_state = 1; // Start opening + ); +) : gate_state == 1 ? ( // Opening + gate_gain += att_coeff * (1 - gate_gain); + gate_gain >= 0.999 ? ( + gate_gain = 1; + gate_state = 2; // Fully open + ); +) : gate_state == 2 ? ( // Open + level_db < close_threshold ? ( + hold_counter = hold_samples; + gate_state = 3; // Start holding + ); +) : gate_state == 3 ? ( // Holding + level_db >= threshold ? ( + gate_state = 2; // Re-open + ) : ( + hold_counter -= 1; + hold_counter <= 0 ? ( + gate_state = 4; // Start closing + ); + ); +) : gate_state == 4 ? ( // Closing + level_db >= threshold ? ( + gate_state = 1; // Re-open + ) : ( + gate_gain -= rel_coeff * gate_gain; + gate_gain <= range_lin + 0.001 ? ( + gate_gain = range_lin; + gate_state = 0; // Fully closed + ); + ); +); + +// Apply gate (or duck mode — inverted) +duck_mode ? ( + g = 1 - gate_gain + range_lin; +) : ( + g = gate_gain < range_lin ? range_lin : gate_gain; +); + +// Apply to delayed or direct signal +la_delay > 0 ? ( + out_l = la_out_l * g; + out_r = la_out_r * g; +) : ( + out_l = spl0 * g; + out_r = spl1 * g; +); + +// Mix +spl0 = spl0 * (1 - mix) + out_l * mix; +spl1 = spl1 * (1 - mix) + out_r * mix; diff --git a/effects/reverb.jsfx b/effects/reverb.jsfx new file mode 100644 index 0000000..6beaab7 --- /dev/null +++ b/effects/reverb.jsfx @@ -0,0 +1,108 @@ +desc:S13 Reverb +author:Studio13 +tags:reverb + +slider1:50<0,100,1>Mix (%) +slider2:2.0<0.1,10,0.1>Decay (s) +slider3:50<0,100,1>Damping (%) +slider4:20<0,100,1>Pre-delay (ms) +slider5:50<0,100,1>Size (%) + +@init +// Simple Schroeder reverb with 4 comb filters and 2 allpass filters +// Buffer allocation +base = 0; +comb1_size = 1557; comb1_buf = base; base += comb1_size; +comb2_size = 1617; comb2_buf = base; base += comb2_size; +comb3_size = 1491; comb3_buf = base; base += comb3_size; +comb4_size = 1422; comb4_buf = base; base += comb4_size; +comb5_size = 1611; comb5_buf = base; base += comb5_size; +comb6_size = 1533; comb6_buf = base; base += comb6_size; +comb7_size = 1356; comb7_buf = base; base += comb7_size; +comb8_size = 1476; comb8_buf = base; base += comb8_size; + +ap1_size = 225; ap1_buf = base; base += ap1_size; +ap2_size = 556; ap2_buf = base; base += ap2_size; +ap3_size = 441; ap3_buf = base; base += ap3_size; +ap4_size = 341; ap4_buf = base; base += ap4_size; + +// Pre-delay buffer +pd_size = srate * 0.1; // max 100ms +pd_buf_l = base; base += pd_size; +pd_buf_r = base; base += pd_size; + +// Clear all buffers +memset(0, 0, base); + +c1_pos = 0; c2_pos = 0; c3_pos = 0; c4_pos = 0; +c5_pos = 0; c6_pos = 0; c7_pos = 0; c8_pos = 0; +a1_pos = 0; a2_pos = 0; a3_pos = 0; a4_pos = 0; +pd_pos = 0; + +// Lowpass state for damping +lp1 = 0; lp2 = 0; lp3 = 0; lp4 = 0; +lp5 = 0; lp6 = 0; lp7 = 0; lp8 = 0; + +@slider +mix = slider2 / 100; +wet = slider1 / 100; + +// Scale feedback based on decay time and size +size = 0.5 + slider5/200; +fb = 0.5 + (1 - exp(-slider2 * 0.7)) * 0.49; +fb = min(fb, 0.98); + +damp = slider3 / 100; +pd_samps = min(slider4 * 0.001 * srate, pd_size - 1); + +@sample +// Pre-delay +pd_read = pd_pos - pd_samps; +pd_read < 0 ? pd_read += pd_size; +pd_rd = floor(pd_read); +in_l = pd_buf_l[pd_rd]; +in_r = pd_buf_r[pd_rd]; +pd_buf_l[pd_pos] = spl0; +pd_buf_r[pd_pos] = spl1; +pd_pos += 1; +pd_pos >= pd_size ? pd_pos = 0; + +// Comb filters (left channel: 1-4, right channel: 5-8) +function comb(buf, pos, sz, fb_val, lp_state, damp_val) + local(out, filtered) +( + out = buf[pos]; + filtered = out * (1-damp_val) + lp_state * damp_val; + lp_state = filtered; + buf[pos] = in_l + filtered * fb_val; + pos += 1; + pos >= sz ? pos = 0; + out; +); + +out_l = 0; out_r = 0; + +// Left combs +tmp = comb1_buf[c1_pos]; lp1 = tmp*(1-damp)+lp1*damp; comb1_buf[c1_pos] = in_l + lp1*fb; c1_pos += 1; c1_pos >= comb1_size ? c1_pos = 0; out_l += tmp; +tmp = comb2_buf[c2_pos]; lp2 = tmp*(1-damp)+lp2*damp; comb2_buf[c2_pos] = in_l + lp2*fb; c2_pos += 1; c2_pos >= comb2_size ? c2_pos = 0; out_l += tmp; +tmp = comb3_buf[c3_pos]; lp3 = tmp*(1-damp)+lp3*damp; comb3_buf[c3_pos] = in_l + lp3*fb; c3_pos += 1; c3_pos >= comb3_size ? c3_pos = 0; out_l += tmp; +tmp = comb4_buf[c4_pos]; lp4 = tmp*(1-damp)+lp4*damp; comb4_buf[c4_pos] = in_l + lp4*fb; c4_pos += 1; c4_pos >= comb4_size ? c4_pos = 0; out_l += tmp; + +// Right combs +tmp = comb5_buf[c5_pos]; lp5 = tmp*(1-damp)+lp5*damp; comb5_buf[c5_pos] = in_r + lp5*fb; c5_pos += 1; c5_pos >= comb5_size ? c5_pos = 0; out_r += tmp; +tmp = comb6_buf[c6_pos]; lp6 = tmp*(1-damp)+lp6*damp; comb6_buf[c6_pos] = in_r + lp6*fb; c6_pos += 1; c6_pos >= comb6_size ? c6_pos = 0; out_r += tmp; +tmp = comb7_buf[c7_pos]; lp7 = tmp*(1-damp)+lp7*damp; comb7_buf[c7_pos] = in_r + lp7*fb; c7_pos += 1; c7_pos >= comb7_size ? c7_pos = 0; out_r += tmp; +tmp = comb8_buf[c8_pos]; lp8 = tmp*(1-damp)+lp8*damp; comb8_buf[c8_pos] = in_r + lp8*fb; c8_pos += 1; c8_pos >= comb8_size ? c8_pos = 0; out_r += tmp; + +out_l *= 0.25; +out_r *= 0.25; + +// Allpass filters +tmp = ap1_buf[a1_pos]; ap1_buf[a1_pos] = out_l + tmp*0.5; out_l = tmp - out_l*0.5; a1_pos += 1; a1_pos >= ap1_size ? a1_pos = 0; +tmp = ap2_buf[a2_pos]; ap2_buf[a2_pos] = out_l + tmp*0.5; out_l = tmp - out_l*0.5; a2_pos += 1; a2_pos >= ap2_size ? a2_pos = 0; +tmp = ap3_buf[a3_pos]; ap3_buf[a3_pos] = out_r + tmp*0.5; out_r = tmp - out_r*0.5; a3_pos += 1; a3_pos >= ap3_size ? a3_pos = 0; +tmp = ap4_buf[a4_pos]; ap4_buf[a4_pos] = out_r + tmp*0.5; out_r = tmp - out_r*0.5; a4_pos += 1; a4_pos >= ap4_size ? a4_pos = 0; + +// Mix +spl0 = spl0 * (1-wet) + out_l * wet; +spl1 = spl1 * (1-wet) + out_r * wet; diff --git a/effects/reverb_advanced.jsfx b/effects/reverb_advanced.jsfx new file mode 100644 index 0000000..6469806 --- /dev/null +++ b/effects/reverb_advanced.jsfx @@ -0,0 +1,212 @@ +desc:S13 Reverb (Advanced) +author:Studio13 +tags:reverb advanced + +// Envelope points (time in seconds, level in %) +slider1:0<0,0.5,0.001>Point 1 Time (s) +slider2:100<0,100,1>Point 1 Level (%) +slider3:0.5<0,5,0.01>Point 2 Time (s) +slider4:80<0,100,1>Point 2 Level (%) +slider5:1.0<0,5,0.01>Point 3 Time (s) +slider6:50<0,100,1>Point 3 Level (%) +slider7:2.0<0,5,0.01>Point 4 Time (s) +slider8:25<0,100,1>Point 4 Level (%) +slider9:3.0<0,5,0.01>Point 5 Time (s) +slider10:10<0,100,1>Point 5 Level (%) +slider11:5.0<0,10,0.01>Point 6 Time (s) +slider12:0<0,100,1>Point 6 Level (%) + +// Global controls +slider13:20<0,200,1>Pre-delay (ms) +slider14:50<0,100,1>Size (%) +slider15:50<0,100,1>Damping (%) +slider16:50<0,100,1>Mix (%) + +@init + +// Schroeder reverb with 8 comb filters + 4 allpass filters +// Comb filter delays (prime-ish numbers scaled by size) +comb_base_0 = 1116; +comb_base_1 = 1188; +comb_base_2 = 1277; +comb_base_3 = 1356; +comb_base_4 = 1422; +comb_base_5 = 1491; +comb_base_6 = 1557; +comb_base_7 = 1617; + +// Allpass delays +ap_base_0 = 225; +ap_base_1 = 556; +ap_base_2 = 441; +ap_base_3 = 341; + +// Max buffer sizes (scaled for 96kHz) +max_comb = 4096; +max_ap = 2048; +max_predelay = ceil(srate * 0.2) + 1; + +// Allocate buffers +// 8 comb L + 8 comb R + 4 allpass L + 4 allpass R + predelay L + predelay R +comb_buf = 0; +// Each comb/allpass gets max_comb or max_ap +// comb_buf[ch * 8 * max_comb + filt * max_comb] +// Simpler: allocate sequentially +ptr = 0; + +// Comb filter buffers (8 per channel x 2 channels) +cb0_l = ptr; ptr += max_comb; +cb1_l = ptr; ptr += max_comb; +cb2_l = ptr; ptr += max_comb; +cb3_l = ptr; ptr += max_comb; +cb4_l = ptr; ptr += max_comb; +cb5_l = ptr; ptr += max_comb; +cb6_l = ptr; ptr += max_comb; +cb7_l = ptr; ptr += max_comb; +cb0_r = ptr; ptr += max_comb; +cb1_r = ptr; ptr += max_comb; +cb2_r = ptr; ptr += max_comb; +cb3_r = ptr; ptr += max_comb; +cb4_r = ptr; ptr += max_comb; +cb5_r = ptr; ptr += max_comb; +cb6_r = ptr; ptr += max_comb; +cb7_r = ptr; ptr += max_comb; + +// Allpass buffers +ap0_l = ptr; ptr += max_ap; +ap1_l = ptr; ptr += max_ap; +ap0_r = ptr; ptr += max_ap; +ap1_r = ptr; ptr += max_ap; + +// Pre-delay buffers +pd_l = ptr; ptr += max_predelay; +pd_r = ptr; ptr += max_predelay; + +// Clear all +memset(0, 0, ptr); + +// Comb filter positions +cb_pos0 = cb_pos1 = cb_pos2 = cb_pos3 = 0; +cb_pos4 = cb_pos5 = cb_pos6 = cb_pos7 = 0; +ap_pos0 = ap_pos1 = 0; +pd_pos = 0; + +// Damping filter state +damp_l0 = damp_l1 = damp_l2 = damp_l3 = 0; +damp_l4 = damp_l5 = damp_l6 = damp_l7 = 0; +damp_r0 = damp_r1 = damp_r2 = damp_r3 = 0; +damp_r4 = damp_r5 = damp_r6 = damp_r7 = 0; + +@slider + +// Scale comb delays by size and sample rate +size_scale = 0.5 + slider14 / 100; +sr_scale = srate / 44100; + +cd0 = min(floor(comb_base_0 * size_scale * sr_scale), max_comb - 1); +cd1 = min(floor(comb_base_1 * size_scale * sr_scale), max_comb - 1); +cd2 = min(floor(comb_base_2 * size_scale * sr_scale), max_comb - 1); +cd3 = min(floor(comb_base_3 * size_scale * sr_scale), max_comb - 1); +cd4 = min(floor(comb_base_4 * size_scale * sr_scale), max_comb - 1); +cd5 = min(floor(comb_base_5 * size_scale * sr_scale), max_comb - 1); +cd6 = min(floor(comb_base_6 * size_scale * sr_scale), max_comb - 1); +cd7 = min(floor(comb_base_7 * size_scale * sr_scale), max_comb - 1); + +apd0 = min(floor(ap_base_0 * sr_scale), max_ap - 1); +apd1 = min(floor(ap_base_1 * sr_scale), max_ap - 1); + +// Compute feedback from envelope (approximate decay time from last point) +max_time = max(slider11, 0.1); +comb_fb = 0.001 ^ (1 / (max_time * srate / cd0)); +comb_fb = min(comb_fb, 0.998); + +damp = slider15 / 100; +predelay_samps = floor(slider13 * srate / 1000); +predelay_samps = min(predelay_samps, max_predelay - 1); + +mix_wet = slider16 / 100; +mix_dry = 1 - mix_wet; + +@sample + +// Pre-delay +pd_l[pd_pos] = spl0; +pd_r[pd_pos] = spl1; +pd_read = pd_pos - predelay_samps; +pd_read < 0 ? pd_read += max_predelay; +in_l = pd_l[pd_read]; +in_r = pd_r[pd_read]; +pd_pos += 1; +pd_pos >= max_predelay ? pd_pos = 0; + +// Process 8 comb filters per channel (4 each side for stereo spread) +// Left channel combs +function comb_process(buf, pos, delay, fb, damp_state, input) + local(rd, output, filtered) +( + rd = pos - delay; + rd < 0 ? rd += max_comb; + output = buf[rd]; + // Lowpass damping in feedback + filtered = output * (1 - damp) + damp_state * damp; + buf[pos] = input + filtered * fb; + output; +); + +out_l = 0; +out_l += comb_process(cb0_l, cb_pos0, cd0, comb_fb, damp_l0, in_l); +damp_l0 = cb0_l[cb_pos0 > 0 ? cb_pos0 - 1 : max_comb - 1]; +out_l += comb_process(cb1_l, cb_pos1, cd1, comb_fb, damp_l1, in_l); +damp_l1 = cb1_l[cb_pos1 > 0 ? cb_pos1 - 1 : max_comb - 1]; +out_l += comb_process(cb2_l, cb_pos2, cd2, comb_fb, damp_l2, in_l); +damp_l2 = cb2_l[cb_pos2 > 0 ? cb_pos2 - 1 : max_comb - 1]; +out_l += comb_process(cb3_l, cb_pos3, cd3, comb_fb, damp_l3, in_l); +damp_l3 = cb3_l[cb_pos3 > 0 ? cb_pos3 - 1 : max_comb - 1]; + +out_r = 0; +out_r += comb_process(cb4_r, cb_pos4, cd4, comb_fb, damp_r4, in_r); +damp_r4 = cb4_r[cb_pos4 > 0 ? cb_pos4 - 1 : max_comb - 1]; +out_r += comb_process(cb5_r, cb_pos5, cd5, comb_fb, damp_r5, in_r); +damp_r5 = cb5_r[cb_pos5 > 0 ? cb_pos5 - 1 : max_comb - 1]; +out_r += comb_process(cb6_r, cb_pos6, cd6, comb_fb, damp_r6, in_r); +damp_r6 = cb6_r[cb_pos6 > 0 ? cb_pos6 - 1 : max_comb - 1]; +out_r += comb_process(cb7_r, cb_pos7, cd7, comb_fb, damp_r7, in_r); +damp_r7 = cb7_r[cb_pos7 > 0 ? cb_pos7 - 1 : max_comb - 1]; + +// Advance comb positions +cb_pos0 += 1; cb_pos0 >= max_comb ? cb_pos0 = 0; +cb_pos1 += 1; cb_pos1 >= max_comb ? cb_pos1 = 0; +cb_pos2 += 1; cb_pos2 >= max_comb ? cb_pos2 = 0; +cb_pos3 += 1; cb_pos3 >= max_comb ? cb_pos3 = 0; +cb_pos4 += 1; cb_pos4 >= max_comb ? cb_pos4 = 0; +cb_pos5 += 1; cb_pos5 >= max_comb ? cb_pos5 = 0; +cb_pos6 += 1; cb_pos6 >= max_comb ? cb_pos6 = 0; +cb_pos7 += 1; cb_pos7 >= max_comb ? cb_pos7 = 0; + +// Allpass filters +function allpass_process(buf, pos, delay, input) + local(rd, buffered, output) +( + rd = pos - delay; + rd < 0 ? rd += max_ap; + buffered = buf[rd]; + output = -input + buffered; + buf[pos] = input + buffered * 0.5; + output; +); + +out_l = allpass_process(ap0_l, ap_pos0, apd0, out_l); +out_l = allpass_process(ap1_l, ap_pos1, apd1, out_l); +out_r = allpass_process(ap0_r, ap_pos0, apd0, out_r); +out_r = allpass_process(ap1_r, ap_pos1, apd1, out_r); + +ap_pos0 += 1; ap_pos0 >= max_ap ? ap_pos0 = 0; +ap_pos1 += 1; ap_pos1 >= max_ap ? ap_pos1 = 0; + +// Scale comb output +out_l *= 0.25; +out_r *= 0.25; + +// Mix +spl0 = spl0 * mix_dry + out_l * mix_wet; +spl1 = spl1 * mix_dry + out_r * mix_wet; diff --git a/effects/saturation.jsfx b/effects/saturation.jsfx new file mode 100644 index 0000000..b602b6b --- /dev/null +++ b/effects/saturation.jsfx @@ -0,0 +1,46 @@ +desc:S13 Saturation +author:Studio13 +tags:distortion saturation + +slider1:0<0,100,1>Drive (%) +slider2:0<0,3,1{Soft Clip,Hard Clip,Tape,Tube}>Type +slider3:50<0,100,1>Mix (%) +slider4:0<-12,12,0.1>Output (dB) + +@init + +@slider +drive = 1 + slider1 / 100 * 9; // 1x to 10x +type = slider2; +mix = slider3 / 100; +output = 10^(slider4/20); + +@sample +dry_l = spl0; +dry_r = spl1; + +// Apply drive +spl0 *= drive; +spl1 *= drive; + +type == 0 ? ( + // Soft clip (tanh approximation) + spl0 = spl0 / (1 + abs(spl0)); + spl1 = spl1 / (1 + abs(spl1)); +) : type == 1 ? ( + // Hard clip + spl0 = max(-1, min(1, spl0)); + spl1 = max(-1, min(1, spl1)); +) : type == 2 ? ( + // Tape-style (asymmetric soft clip) + spl0 > 0 ? spl0 = 1 - exp(-spl0) : spl0 = -1 + exp(spl0); + spl1 > 0 ? spl1 = 1 - exp(-spl1) : spl1 = -1 + exp(spl1); +) : ( + // Tube-style (even harmonics via asymmetric waveshaping) + spl0 = (3*spl0/2) / (1 + spl0*spl0*0.5) * 0.7; + spl1 = (3*spl1/2) / (1 + spl1*spl1*0.5) * 0.7; +); + +// Mix and output +spl0 = (dry_l * (1-mix) + spl0 * mix) * output; +spl1 = (dry_r * (1-mix) + spl1 * mix) * output; diff --git a/effects/saturation_advanced.jsfx b/effects/saturation_advanced.jsfx new file mode 100644 index 0000000..833d01f --- /dev/null +++ b/effects/saturation_advanced.jsfx @@ -0,0 +1,140 @@ +desc:S13 Saturation (Advanced) +author:Studio13 +tags:distortion saturation waveshaper advanced + +// Control points define the waveshaper transfer curve +// Each point: X input level (-1 to 1), Y output level (-1 to 1) +// Points should be ordered by X. The curve is interpolated between them. + +// Point 1 (negative extreme) +slider1:1<0,1,1{Off,On}>Point 1 Enabled +slider2:-1.0<-1,1,0.01>Point 1 Input +slider3:-0.8<-1,1,0.01>Point 1 Output + +// Point 2 +slider4:1<0,1,1{Off,On}>Point 2 Enabled +slider5:-0.5<-1,1,0.01>Point 2 Input +slider6:-0.45<-1,1,0.01>Point 2 Output + +// Point 3 +slider7:1<0,1,1{Off,On}>Point 3 Enabled +slider8:0<-1,1,0.01>Point 3 Input +slider9:0<-1,1,0.01>Point 3 Output + +// Point 4 +slider10:1<0,1,1{Off,On}>Point 4 Enabled +slider11:0.5<-1,1,0.01>Point 4 Input +slider12:0.45<-1,1,0.01>Point 4 Output + +// Point 5 (positive extreme) +slider13:1<0,1,1{Off,On}>Point 5 Enabled +slider14:1.0<-1,1,0.01>Point 5 Input +slider15:0.8<-1,1,0.01>Point 5 Output + +// Point 6 +slider16:0<0,1,1{Off,On}>Point 6 Enabled +slider17:0.25<-1,1,0.01>Point 6 Input +slider18:0.24<-1,1,0.01>Point 6 Output + +// Point 7 +slider19:0<0,1,1{Off,On}>Point 7 Enabled +slider20:-0.25<-1,1,0.01>Point 7 Input +slider21:-0.24<-1,1,0.01>Point 7 Output + +// Point 8 +slider22:0<0,1,1{Off,On}>Point 8 Enabled +slider23:0.75<-1,1,0.01>Point 8 Input +slider24:0.72<-1,1,0.01>Point 8 Output + +// Global controls +slider25:50<0,100,1>Drive (%) +slider26:100<0,100,1>Mix (%) +slider27:0<-12,12,0.1>Output Gain (dB) + +@init + +// Sorted point storage: memory offset 100000 +// Each point: [x, y] — max 8 points +num_sorted = 0; + +@slider + +// Collect enabled points, sort by x +num_sorted = 0; +i = 0; +loop(8, + base = i * 3; + slider(base + 1) ? ( // enabled + 100000[num_sorted * 2] = slider(base + 2); // x + 100000[num_sorted * 2 + 1] = slider(base + 3); // y + num_sorted += 1; + ); + i += 1; +); + +// Bubble sort by x +i = 0; +loop(num_sorted - 1, + j = 0; + loop(num_sorted - 1 - i, + 100000[j * 2] > 100000[(j + 1) * 2] ? ( + // Swap + tmp_x = 100000[j * 2]; + tmp_y = 100000[j * 2 + 1]; + 100000[j * 2] = 100000[(j + 1) * 2]; + 100000[j * 2 + 1] = 100000[(j + 1) * 2 + 1]; + 100000[(j + 1) * 2] = tmp_x; + 100000[(j + 1) * 2 + 1] = tmp_y; + ); + j += 1; + ); + i += 1; +); + +drive = 1 + slider25 / 100 * 9; // 1x to 10x +mix_wet = slider26 / 100; +mix_dry = 1 - mix_wet; +out_gain = 10^(slider27 / 20); + +@sample + +// Apply drive +driven_l = spl0 * drive; +driven_r = spl1 * drive; + +// Waveshaper lookup via linear interpolation between sorted points +function waveshape(x) + local(i, x0, y0, x1, y1, t) +( + num_sorted < 2 ? ( + x; // Pass through if less than 2 points + ) : ( + // Clamp to first/last point + x <= 100000[0] ? ( + 100000[1]; // Return first y + ) : x >= 100000[(num_sorted - 1) * 2] ? ( + 100000[(num_sorted - 1) * 2 + 1]; // Return last y + ) : ( + // Find segment + i = 0; + while (i < num_sorted - 1 && 100000[(i + 1) * 2] < x) ( + i += 1; + ); + x0 = 100000[i * 2]; + y0 = 100000[i * 2 + 1]; + x1 = 100000[(i + 1) * 2]; + y1 = 100000[(i + 1) * 2 + 1]; + t = (x - x0) / max(x1 - x0, 0.0001); + // Hermite-ish smoothstep for smoother curve + t = t * t * (3 - 2 * t); + y0 + t * (y1 - y0); + ); + ); +); + +shaped_l = waveshape(driven_l); +shaped_r = waveshape(driven_r); + +// Mix and output gain +spl0 = (spl0 * mix_dry + shaped_l * mix_wet) * out_gain; +spl1 = (spl1 * mix_dry + shaped_r * mix_wet) * out_gain; diff --git a/frontend/src/components/FXChainPanel.tsx b/frontend/src/components/FXChainPanel.tsx index 7ec2f9e..5c8a7e4 100644 --- a/frontend/src/components/FXChainPanel.tsx +++ b/frontend/src/components/FXChainPanel.tsx @@ -12,11 +12,24 @@ import { AudioWaveform, Music, Box, + Code, + RefreshCw, + ChevronDown, + ChevronRight, } from "lucide-react"; import { nativeBridge } from "../services/NativeBridge"; import { useDAWStore } from "../store/useDAWStore"; import { useShallow } from "zustand/react/shallow"; import { Button, Input, Select } from "./ui"; +import { + EQGraph, + CompressorGraph, + GateGraph, + DelayGraph, + ReverbGraph, + SaturationGraph, + ChorusGraph, +} from "./ParametricGraph"; import "./FXChainPanel.css"; interface FXChainPanelProps { @@ -29,6 +42,20 @@ interface FXChainPanelProps { interface FXSlot { index: number; name: string; + type?: "vst3" | "s13fx"; + pluginPath?: string; +} + +interface S13FXSlider { + index: number; + name: string; + min: number; + max: number; + def: number; + inc: number; + value: number; + isEnum: boolean; + enumNames?: string[]; } interface Plugin { @@ -38,6 +65,7 @@ interface Plugin { fileOrIdentifier: string; isInstrument: boolean; snapshot?: string; + pluginType?: "vst3" | "s13fx"; } // Map VST3 category substrings to Lucide icons and colors @@ -89,6 +117,9 @@ export function FXChainPanel({ const [draggedIndex, setDraggedIndex] = useState(null); const [addingPlugin, setAddingPlugin] = useState(null); const [bypassedFx, setBypassedFx] = useState>(new Set()); + const [expandedS13FX, setExpandedS13FX] = useState(null); + const [s13fxSliders, setS13fxSliders] = useState([]); + const [showRawSliders, setShowRawSliders] = useState(false); // Plugin browser state const [plugins, setPlugins] = useState([]); @@ -144,7 +175,27 @@ export function FXChainPanel({ setPluginsLoading(true); try { const pluginList = await nativeBridge.getAvailablePlugins(); - setPlugins(pluginList); + const vst3Plugins: Plugin[] = pluginList.map((p: any) => ({ + ...p, + pluginType: "vst3" as const, + })); + + let s13fxPlugins: Plugin[] = []; + try { + const scripts = await nativeBridge.getAvailableS13FX(); + s13fxPlugins = scripts.map((s: any) => ({ + name: s.name, + manufacturer: s.author || "S13FX", + category: s.tags?.[0] || "Script", + fileOrIdentifier: s.filePath, + isInstrument: false, + pluginType: "s13fx" as const, + })); + } catch { + // S13FX not available + } + + setPlugins([...vst3Plugins, ...s13fxPlugins]); } catch (e) { console.error("[FXChain] Failed to load available plugins:", e); } finally { @@ -168,7 +219,15 @@ export function FXChainPanel({ setAddingPlugin(plugin.fileOrIdentifier); try { let success = false; - if (chainType === "master") { + + if (plugin.pluginType === "s13fx") { + if (chainType === "master") { + success = await nativeBridge.addMasterS13FX(plugin.fileOrIdentifier); + } else { + const isInputFX = chainType === "input"; + success = await nativeBridge.addTrackS13FX(trackId, plugin.fileOrIdentifier, isInputFX); + } + } else if (chainType === "master") { success = await nativeBridge.addMasterFX(plugin.fileOrIdentifier); } else if (chainType === "input" || chainType === "track") { success = await addTrackFXWithUndo(trackId, plugin.fileOrIdentifier, chainType); @@ -176,7 +235,7 @@ export function FXChainPanel({ if (success) { console.log(`[FXChain] Added ${plugin.name} to ${chainType} chain`); - await loadPlugins(); // Reload FX list + await loadPlugins(); } } catch (e) { console.error("[FXChain] Failed to add plugin:", e); @@ -284,6 +343,48 @@ export function FXChainPanel({ } }; + const handleToggleS13FXSliders = async (fxIndex: number) => { + if (expandedS13FX === fxIndex) { + setExpandedS13FX(null); + setS13fxSliders([]); + return; + } + try { + const isInputFX = chainType === "input"; + const sliders = await nativeBridge.getS13FXSliders(trackId, fxIndex, isInputFX); + setS13fxSliders(sliders); + setExpandedS13FX(fxIndex); + } catch (e) { + console.error("[FXChain] Failed to load S13FX sliders:", e); + } + }; + + const handleS13FXSliderChange = async (sliderIndex: number, value: number) => { + if (expandedS13FX === null) return; + const isInputFX = chainType === "input"; + await nativeBridge.setS13FXSlider(trackId, expandedS13FX, isInputFX, sliderIndex, value); + setS13fxSliders((prev) => + prev.map((s) => (s.index === sliderIndex ? { ...s, value } : s)), + ); + }; + + const handleReloadS13FX = async (fxIndex: number) => { + try { + const isInputFX = chainType === "input"; + const success = await nativeBridge.reloadS13FX(trackId, fxIndex, isInputFX); + if (success) { + console.log("[FXChain] Reloaded S13FX at index", fxIndex); + await loadPlugins(); + if (expandedS13FX === fxIndex) { + const sliders = await nativeBridge.getS13FXSliders(trackId, fxIndex, isInputFX); + setS13fxSliders(sliders); + } + } + } catch (e) { + console.error("[FXChain] Failed to reload S13FX:", e); + } + }; + const categories = [ "All", ...Array.from(new Set(plugins.map((p) => p.category))), @@ -336,49 +437,128 @@ export function FXChainPanel({

Add plugins from the browser →

) : ( - fxSlots.map((fx, index) => ( -
handleDragStart(index)} - onDragOver={handleDragOver} - onDrop={(e) => handleDrop(e, index)} - onClick={() => handleOpenEditor(fx.index)} - > -
- -
- { e.stopPropagation(); handleToggleBypass(fx.index); }} - onClick={(e) => e.stopPropagation()} - title={bypassedFx.has(fx.index) ? "Enable plugin" : "Bypass plugin"} - className="fx-bypass-checkbox" - style={{ accentColor: "#22c55e", width: 14, height: 14, cursor: "pointer", flexShrink: 0 }} - /> -
-
{index + 1}
+ fxSlots.map((fx, index) => { + const isS13FX = fx.type === "s13fx"; + return ( +
handleOpenEditor(fx.index)} - title="Click to open editor" + className={`fx-slot-item ${draggedIndex === index ? "dragging" : ""} ${isS13FX ? "border-l-2 border-l-lime-500" : ""}`} + draggable + onDragStart={() => handleDragStart(index)} + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(e, index)} + onClick={() => isS13FX ? handleToggleS13FXSliders(fx.index) : handleOpenEditor(fx.index)} > - {fx.name} +
+ +
+ { e.stopPropagation(); handleToggleBypass(fx.index); }} + onClick={(e) => e.stopPropagation()} + title={bypassedFx.has(fx.index) ? "Enable plugin" : "Bypass plugin"} + className="fx-bypass-checkbox" + style={{ accentColor: "#22c55e", width: 14, height: 14, cursor: "pointer", flexShrink: 0 }} + /> +
+
+ {isS13FX ? : index + 1} +
+
+ {fx.name} +
+
+ {isS13FX && ( + + )} +
+ + {/* 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 && ( + + )} + + {/* 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)} + +
+ ))} +
+ ); + })()}
- -
- )) + ); + }) )}
@@ -430,13 +610,17 @@ export function FXChainPanel({ ) : ( filteredPlugins.map((plugin, idx) => { - const { Icon, color } = getCategoryIcon(plugin.category); + const isScript = plugin.pluginType === "s13fx"; + const { Icon, color } = isScript + ? { match: "Script", Icon: Code, color: "#84cc16" } + : getCategoryIcon(plugin.category); return (
- {/* Snapshot or category icon */} {plugin.snapshot ? (
{plugin.name} + {isScript && ( + + S13FX + + )}
{plugin.manufacturer}
- {plugin.category} + {isScript ? "JSFX Script" : plugin.category}
+
+ {/* Sidebar tabs */} +
+ +
-
- {userScripts.length === 0 ? ( -
No saved scripts
- ) : ( - userScripts.map((script) => ( -
handleLoadScript(script.id)} - > - {script.name} - + + {activeTab === "editor" ? ( + <> +
+ Saved + +
+
+ {userScripts.length === 0 ? ( +
No saved scripts
+ ) : ( + userScripts.map((script) => ( +
handleLoadScript(script.id)} + > + {script.name} + +
+ )) + )} +
+ + ) : ( + <> +
+ Available +
+ +
- )) - )} -
+
+
+ {nativeScripts.length === 0 ? ( +
+ No .lua scripts found. +
+ Place scripts in Documents/Studio13/Scripts/ +
+ ) : ( + nativeScripts.map((script) => ( +
handleRunFile(script.filePath)} + title={script.description || script.filePath} + > +
+ + {script.isStock && ( + STOCK + )} + {script.name} + + {script.description && ( + {script.description} + )} +
+ +
+ )) + )} +
+ + )}
{/* Editor + Console */} @@ -120,14 +237,32 @@ export function ScriptEditor() { value={code} onChange={(e) => setCode(e.target.value)} spellCheck={false} - placeholder="Write your script here..." + placeholder="Write your Lua script here..." + onKeyDown={(e) => { + // Ctrl+Enter to run + if (e.ctrlKey && e.key === "Enter") { + e.preventDefault(); + handleRun(); + } + // Tab inserts 2 spaces + if (e.key === "Tab") { + e.preventDefault(); + const target = e.target as HTMLTextAreaElement; + const start = target.selectionStart; + const end = target.selectionEnd; + setCode(code.substring(0, start) + " " + code.substring(end)); + setTimeout(() => { + target.selectionStart = target.selectionEnd = start + 2; + }, 0); + } + }} />
{/* Console output */}
- Console + Console Output
{/* Close Media Explorer + Workspace wrapper */} @@ -925,7 +1111,11 @@ function App() { {/* Virtual MIDI Keyboard */} - {showVirtualKeyboard && } + {showVirtualKeyboard && ( + + + + )} {/* Piano Roll Editor Modal */} {showPianoRoll && pianoRollTrackId && pianoRollClipId && ( @@ -945,7 +1135,24 @@ function App() { {/* 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), + ); + })() + : [] + } + /> + @@ -954,143 +1161,236 @@ function App() { {/* Undo History Panel */} {showUndoHistory && (
- + + +
)} {/* Big Clock */} {showBigClock && (
- + + +
)} {/* Clip Properties Panel */} {showClipProperties && (
- + + +
)} {/* Region/Marker Manager */} {showRegionMarkerManager && (
- + + +
)} {/* Render Queue Panel */} {showRenderQueue && (
- + + +
)} {/* Preferences Modal */} - useDAWStore.getState().togglePreferences()} - /> + {showPreferences && ( + + useDAWStore.getState().togglePreferences()} + /> + + )} {/* Keyboard Shortcuts Modal */} - useDAWStore.getState().toggleKeyboardShortcuts()} - /> + {showKeyboardShortcuts && ( + + useDAWStore.getState().toggleKeyboardShortcuts()} + /> + + )} {/* Command Palette */} - useDAWStore.getState().toggleCommandPalette()} - /> + {showCommandPalette && ( + + useDAWStore.getState().toggleCommandPalette()} + /> + + )} {/* Dynamic Split Modal (Phase 9B) */} - useDAWStore.getState().closeDynamicSplit()} - /> + {showDynamicSplit && ( + + useDAWStore.getState().closeDynamicSplit()} + /> + + )} {/* Mixer Panel */} - - + detachPanel("mixer")} + onAttach={() => attachPanel("mixer")} + onClose={toggleMixer} + /> {/* Settings Modal */} - + {showSettings && ( + + + + )} {/* Project Settings Modal */} - + {showProjectSettings && ( + + + + )} + + {/* Project Compare Modal */} + {showProjectCompare && ( + + useDAWStore.getState().toggleProjectCompare()} + /> + + )} {/* Render Modal */} - + {showRenderModal && ( + + + + )} {/* Routing Matrix (Phase 11B) */} - useDAWStore.getState().toggleRoutingMatrix()} - /> + {showRoutingMatrix && ( + + useDAWStore.getState().toggleRoutingMatrix()} + /> + + )} {/* Region Render Matrix (Phase 10A) */} - useDAWStore.getState().toggleRegionRenderMatrix()} - /> + {showRegionRenderMatrix && ( + + useDAWStore.getState().toggleRegionRenderMatrix()} + /> + + )} {/* Clean Project Directory (Phase 12B) */} - useDAWStore.getState().toggleCleanProject()} - /> + {showCleanProject && ( + + useDAWStore.getState().toggleCleanProject()} + /> + + )} {/* Batch File Converter (Phase 12E) */} - useDAWStore.getState().toggleBatchConverter()} - /> + {showBatchConverter && ( + + useDAWStore.getState().toggleBatchConverter()} + /> + + )} {/* Crossfade Editor (Phase 13A) */} - useDAWStore.getState().closeCrossfadeEditor()} - /> + {showCrossfadeEditor && ( + + useDAWStore.getState().closeCrossfadeEditor()} + /> + + )} {/* Theme Editor (Phase 14A+B) */} - useDAWStore.getState().toggleThemeEditor()} - /> + {showThemeEditor && ( + + useDAWStore.getState().toggleThemeEditor()} + /> + + )} {/* Video Window (Phase 15A) */} - + + + {/* Script Editor (Phase 15B) */} - {showScriptEditor && } + {showScriptEditor && ( + + + + )} {/* Toolbar Editor (Phase 15D) */} - useDAWStore.getState().toggleToolbarEditor()} - /> + {showToolbarEditor && ( + + useDAWStore.getState().toggleToolbarEditor()} + /> + + )} {/* DDP Export (Phase 16C) */} - useDAWStore.getState().toggleDDPExport()} - /> + {showDDPExport && ( + + useDAWStore.getState().toggleDDPExport()} + /> + + )} {/* Plugin Browser (from action registry — instrument track creation) */} {showPluginBrowser && pluginBrowserTrackId && ( - t.id === pluginBrowserTrackId)?.type === "instrument" - ? "instrument" - : "track" - } - onClose={() => useDAWStore.getState().closePluginBrowser()} - /> + + t.id === pluginBrowserTrackId)?.type === "instrument" + ? "instrument" + : "track" + } + onClose={() => useDAWStore.getState().closePluginBrowser()} + /> + )} {/* Project Loading Overlay */} @@ -1105,6 +1405,31 @@ function App() { )} + {/* File Drop Overlay — shown when dragging files from OS file explorer */} + {isDraggingFiles && ( +
+
+ + + + + + Drop files here + Audio, MIDI, or video files +
+
+ )} + {/* Toast Notification */} {toastVisible && (
s.transport.currentTime); - const tempo = useDAWStore((s) => s.transport.tempo); - const isPlaying = useDAWStore((s) => s.transport.isPlaying); - const isRecording = useDAWStore((s) => s.transport.isRecording); - const format = useDAWStore((s) => s.bigClockFormat); - const toggleBigClock = useDAWStore((s) => s.toggleBigClock); - const toggleBigClockFormat = useDAWStore((s) => s.toggleBigClockFormat); - const timeSignature = useDAWStore((s) => s.timeSignature); - const projectName = useDAWStore((s) => s.projectName); + const { + currentTime, tempo, isPlaying, isRecording, + format, toggleBigClock, toggleBigClockFormat, timeSignature, projectName, + } = useDAWStore(useShallow((s) => ({ + currentTime: s.transport.currentTime, + tempo: s.transport.tempo, + isPlaying: s.transport.isPlaying, + isRecording: s.transport.isRecording, + format: s.bigClockFormat, + toggleBigClock: s.toggleBigClock, + toggleBigClockFormat: s.toggleBigClockFormat, + timeSignature: s.timeSignature, + projectName: s.projectName, + }))); const formatTimeDisplay = (): string => { if (format === "beats") { diff --git a/frontend/src/components/ChannelStrip.tsx b/frontend/src/components/ChannelStrip.tsx index f49a87d..9938d33 100644 --- a/frontend/src/components/ChannelStrip.tsx +++ b/frontend/src/components/ChannelStrip.tsx @@ -1,6 +1,6 @@ -import { useState, useCallback } from "react"; +import React, { useState, useCallback, useMemo } from "react"; import classNames from "classnames"; -import { Power } from "lucide-react"; +import { Power, ChevronDown, ChevronRight } from "lucide-react"; import { PeakMeter } from "./PeakMeter"; import { useDAWStore, Track, getTrackGroupInfo, TRACK_GROUP_COLORS } from "../store/useDAWStore"; import { useShallow } from "zustand/react/shallow"; @@ -33,7 +33,20 @@ function getDbPosition(db: number): number { return ((12 - db) / 72) * 100; } -export function ChannelStrip({ +/** Color-code a dB value: green (-18 to -6), yellow (-6 to 0), red (>0), dim otherwise */ +function gainDbColor(db: number): string { + if (db > 0) return "text-red-400"; + if (db > -6) return "text-yellow-400"; + if (db >= -18) return "text-green-400"; + return "text-neutral-500"; +} + +function formatGainDb(db: number): string { + if (db <= -60) return "-inf"; + return (db >= 0 ? "+" : "") + db.toFixed(1); +} + +export const ChannelStrip = React.memo(function ChannelStrip({ track, trackIndex, isMaster = false, @@ -63,6 +76,14 @@ export function ChannelStrip({ addTrackGroup, removeTrackGroup, updateTrackGroup, + tracks, + addTrackSend, + removeTrackSend, + setTrackSendLevel, + setTrackSendEnabled, + setTrackSendPreFader, + currentTime, + masterVolume, } = useDAWStore( useShallow((s) => ({ toggleTrackMute: s.toggleTrackMute, @@ -82,6 +103,14 @@ export function ChannelStrip({ addTrackGroup: s.addTrackGroup, removeTrackGroup: s.removeTrackGroup, updateTrackGroup: s.updateTrackGroup, + tracks: s.tracks, + addTrackSend: s.addTrackSend, + removeTrackSend: s.removeTrackSend, + setTrackSendLevel: s.setTrackSendLevel, + setTrackSendEnabled: s.setTrackSendEnabled, + setTrackSendPreFader: s.setTrackSendPreFader, + currentTime: s.transport.currentTime, + masterVolume: s.masterVolume, })), ); @@ -89,8 +118,20 @@ export function ChannelStrip({ const [phaseInverted, setPhaseInverted] = useState(false); const [showFXChain, setShowFXChain] = useState(false); + const [showGainStaging, setShowGainStaging] = useState(false); const hasFx = track.inputFxCount + track.trackFxCount > 0; + // Find the clip under the playhead for gain staging display + const activeClipGainDB = useMemo(() => { + if (isMaster) return null; + const clip = track.clips.find( + (c) => currentTime >= c.startTime && currentTime < c.startTime + c.duration && !c.muted, + ); + return clip ? (clip.volumeDB ?? 0) : null; + }, [isMaster, track.clips, currentTime]); + + const masterVolumeDB = masterVolume > 0 ? 20 * Math.log10(masterVolume) : -60; + const ALL_LINKED_PARAMS = ["volume", "pan", "mute", "solo", "armed", "fxBypass"]; const trackGroup = trackGroups.find((g) => g.memberTrackIds.includes(track.id)); const groupInfo = getTrackGroupInfo(track.id, trackGroups); @@ -239,54 +280,88 @@ export function ChannelStrip({ {isMaster ? "● MASTER ●" : track.name}
- {/* Send Slots (Phase 11) */} - {!isMaster && track.sends?.length > 0 && ( + {/* Sends Section */} + {!isMaster && (
- {track.sends.map((send, i) => { - const destTrack = useDAWStore - .getState() - .tracks.find((t) => t.id === send.destTrackId); + {track.sends?.length > 0 && track.sends.map((send, i) => { + const destTrack = tracks.find((t) => t.id === send.destTrackId); return (
- useDAWStore - .getState() - .setTrackSendEnabled(track.id, i, !send.enabled) - } - onContextMenu={(e) => { - e.preventDefault(); - useDAWStore.getState().removeTrackSend(track.id, i); - }} - title={`Send → ${destTrack?.name || "?"} (${Math.round(send.level * 100)}%) — Right-click to remove`} > - - → {destTrack?.name || "?"} - - {Math.round(send.level * 100)}% + {/* Send header: destination + pre/post + remove */} +
+ setTrackSendEnabled(track.id, i, !send.enabled)} + title={`${send.enabled ? "Disable" : "Enable"} send to ${destTrack?.name || "?"}`} + > + {destTrack?.name || "?"} + + + + + +
+ {/* Send level slider */} +
+ setTrackSendLevel(track.id, i, Number(e.target.value) / 100)} + className="flex-1 h-1.5 accent-cyan-500 cursor-pointer" + style={{ minWidth: 0 }} + title={`${Math.round(send.level * 100)}%`} + /> + + {Math.round(send.level * 100)} + +
); })} + {/* Add send button */}
{ - const otherTracks = useDAWStore - .getState() - .tracks.filter((t) => t.id !== track.id); + const otherTracks = tracks.filter((t) => t.id !== track.id); if (otherTracks.length > 0) { - // Add send to first available track (user can change via routing matrix) - useDAWStore - .getState() - .addTrackSend(track.id, otherTracks[0].id); + addTrackSend(track.id, otherTracks[0].id); } }} + title="Add send to another track" > + Send
@@ -481,6 +556,58 @@ export function ChannelStrip({ {formatVolume(track.volumeDB)} dB
+ {/* Gain Staging Display */} + {!isMaster && ( +
+ + {showGainStaging && ( +
+ {/* Clip Gain */} +
+ Clip + + {activeClipGainDB !== null ? formatGainDb(activeClipGainDB) : "--"} + +
+ {/* Track Fader */} +
+ Fader + + {formatGainDb(track.volumeDB)} + +
+ {/* Master */} +
+ Master + + {formatGainDb(masterVolumeDB)} + +
+ {/* Sum indicator */} +
+ Sum + {(() => { + const clipDb = activeClipGainDB ?? 0; + const sum = clipDb + track.volumeDB + masterVolumeDB; + return ( + + {formatGainDb(sum)} + + ); + })()} +
+
+ )} +
+ )} + {/* M/S/R Buttons for tracks */} {!isMaster && (
@@ -539,4 +666,4 @@ export function ChannelStrip({ {ContextMenuComponent} ); -} +}); diff --git a/frontend/src/components/ClipPropertiesPanel.tsx b/frontend/src/components/ClipPropertiesPanel.tsx index c4ce53b..b518f9d 100644 --- a/frontend/src/components/ClipPropertiesPanel.tsx +++ b/frontend/src/components/ClipPropertiesPanel.tsx @@ -1,5 +1,6 @@ import { X, Lock, Unlock } from "lucide-react"; import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/shallow"; import { Button, Input, Slider } from "./ui"; function formatTime(seconds: number): string { @@ -13,13 +14,18 @@ function formatTime(seconds: number): string { * Opens with F2 or from View menu */ export function ClipPropertiesPanel() { - const selectedClipId = useDAWStore((s) => s.selectedClipId); - const tracks = useDAWStore((s) => s.tracks); - const setClipVolume = useDAWStore((s) => s.setClipVolume); - const setClipFades = useDAWStore((s) => s.setClipFades); - const toggleClipMute = useDAWStore((s) => s.toggleClipMute); - const toggleClipLock = useDAWStore((s) => s.toggleClipLock); - const toggleClipProperties = useDAWStore((s) => s.toggleClipProperties); + const { + selectedClipId, tracks, setClipVolume, setClipFades, + toggleClipMute, toggleClipLock, toggleClipProperties, + } = useDAWStore(useShallow((s) => ({ + selectedClipId: s.selectedClipId, + tracks: s.tracks, + setClipVolume: s.setClipVolume, + setClipFades: s.setClipFades, + toggleClipMute: s.toggleClipMute, + toggleClipLock: s.toggleClipLock, + toggleClipProperties: s.toggleClipProperties, + }))); // Find the selected clip let clip = null; diff --git a/frontend/src/components/ColorPicker.tsx b/frontend/src/components/ColorPicker.tsx index c993098..9b1447f 100644 --- a/frontend/src/components/ColorPicker.tsx +++ b/frontend/src/components/ColorPicker.tsx @@ -1,4 +1,6 @@ import { useEffect, useRef, useState } from "react"; +import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/shallow"; import { Button } from "./ui"; interface ColorPickerProps { @@ -8,7 +10,7 @@ interface ColorPickerProps { anchorRef?: React.RefObject; } -// Predefined track colors - users can assign same color to multiple tracks for grouping +// 16 predefined DAW colors — users can assign same color to multiple tracks for grouping export const TRACK_COLORS = [ { name: "Cyan", value: "#00bcd4" }, { name: "Blue", value: "#2196f3" }, @@ -16,12 +18,16 @@ export const TRACK_COLORS = [ { name: "Purple", value: "#9c27b0" }, { name: "Pink", value: "#e91e63" }, { name: "Red", value: "#f44336" }, + { name: "Deep Orange", value: "#ff5722" }, { name: "Orange", value: "#ff9800" }, + { name: "Amber", value: "#ffc107" }, { name: "Yellow", value: "#ffeb3b" }, { name: "Lime", value: "#cddc39" }, { name: "Green", value: "#4caf50" }, { name: "Teal", value: "#009688" }, { name: "Brown", value: "#795548" }, + { name: "Blue Grey", value: "#607d8b" }, + { name: "Grey", value: "#9e9e9e" }, ]; export function ColorPicker({ @@ -32,6 +38,15 @@ export function ColorPicker({ }: ColorPickerProps) { const popupRef = useRef(null); const [position, setPosition] = useState<{ top: number; left: number } | null>(null); + const { recentColors, addRecentColor } = useDAWStore(useShallow((s) => ({ + recentColors: s.recentColors, + addRecentColor: s.addRecentColor, + }))); + + const handleColorSelect = (color: string) => { + addRecentColor(color); + onColorChange(color); + }; // Position relative to anchor element useEffect(() => { @@ -41,7 +56,7 @@ export function ColorPicker({ let left = rect.right + 4; // Ensure popup doesn't go below viewport - const popupHeight = 180; // approximate height + const popupHeight = 260; // approximate height with recent colors if (top + popupHeight > window.innerHeight) { top = window.innerHeight - popupHeight - 8; } @@ -99,7 +114,7 @@ export function ColorPicker({
+ + {/* Recent Colors */} + {recentColors.length > 0 && ( + <> +
+ Recent +
+
+ {recentColors.map((color, i) => ( +
+ + )} ); } diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index e7d2d93..2cb1113 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo } from "react"; import { createPortal } from "react-dom"; import { getRegisteredActions, ActionDef } from "../store/actionRegistry"; import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/react/shallow"; interface CommandPaletteProps { isOpen: boolean; @@ -15,7 +16,9 @@ export function CommandPalette({ isOpen, onClose }: CommandPaletteProps) { const listRef = useRef(null); const actions = useMemo(() => getRegisteredActions(), []); - const recentActionIds = useDAWStore((state) => state.recentActions); + const { recentActionIds } = useDAWStore(useShallow((state) => ({ + recentActionIds: state.recentActions, + }))); const filtered = useMemo(() => { if (!query.trim()) { diff --git a/frontend/src/components/CrossfadeEditor.tsx b/frontend/src/components/CrossfadeEditor.tsx index 99aae46..7537021 100644 --- a/frontend/src/components/CrossfadeEditor.tsx +++ b/frontend/src/components/CrossfadeEditor.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/react/shallow"; import { Modal, Button, Select } from "./ui"; import { Slider } from "./ui"; @@ -35,7 +36,9 @@ interface CrossfadeEditorProps { } export function CrossfadeEditor({ isOpen, onClose }: CrossfadeEditorProps) { - const crossfadeEditorClipIds = useDAWStore((s) => s.crossfadeEditorClipIds); + const { crossfadeEditorClipIds } = useDAWStore(useShallow((s) => ({ + crossfadeEditorClipIds: s.crossfadeEditorClipIds, + }))); // Find the two clips const clip1 = crossfadeEditorClipIds diff --git a/frontend/src/components/DDPExportModal.tsx b/frontend/src/components/DDPExportModal.tsx index 798502c..d9ab381 100644 --- a/frontend/src/components/DDPExportModal.tsx +++ b/frontend/src/components/DDPExportModal.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/react/shallow"; import { nativeBridge } from "../services/NativeBridge"; import { Modal, Button } from "./ui"; @@ -9,18 +10,31 @@ interface DDPExportModalProps { } export function DDPExportModal({ isOpen, onClose }: DDPExportModalProps) { - const regions = useDAWStore((s) => s.regions); + const { regions } = useDAWStore(useShallow((s) => ({ + regions: s.regions, + }))); const [isExporting, setIsExporting] = useState(false); const [result, setResult] = useState(null); + const [sourceWav, setSourceWav] = useState(""); + const [catalogNumber, setCatalogNumber] = useState(""); + + const handleSelectSource = async () => { + const path = await nativeBridge.showOpenDialog("Select Red Book WAV (44.1kHz/16-bit)"); + if (path) setSourceWav(path); + }; const handleExport = async () => { - const dir = await nativeBridge.showSaveDialog("Select DDP Output Directory", "ddp"); + if (!sourceWav) { + setResult("Please select a source WAV file first."); + return; + } + const dir = await nativeBridge.showSaveDialog(undefined, "Select DDP Output Directory"); if (!dir) return; setIsExporting(true); setResult(null); try { - const success = await useDAWStore.getState().exportDDP(dir); + const success = await useDAWStore.getState().exportDDP(sourceWav, dir, catalogNumber || undefined); setResult(success ? "DDP export completed successfully!" : "DDP export failed."); } catch { setResult("DDP export failed with an error."); @@ -43,6 +57,32 @@ export function DDPExportModal({ isOpen, onClose }: DDPExportModalProps) { Regions in your project are used as CD track markers. + {/* Source WAV selection */} +
+
Source WAV (Red Book: 44.1kHz / 16-bit / Stereo)
+
+
+ {sourceWav || "No file selected"} +
+ +
+
+ + {/* Catalog number */} +
+
UPC/EAN Catalog Number (optional)
+ setCatalogNumber(e.target.value)} + /> +
+ {/* Region summary */}
CD Tracks (from regions)
diff --git a/frontend/src/components/DetachablePanel.tsx b/frontend/src/components/DetachablePanel.tsx new file mode 100644 index 0000000..19dc6dd --- /dev/null +++ b/frontend/src/components/DetachablePanel.tsx @@ -0,0 +1,93 @@ +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +interface DetachablePanelProps { + title: string; + width?: number; + height?: number; + isDetached: boolean; + onDetach: () => void; + onAttach: () => void; + children: React.ReactNode; +} + +export function DetachablePanel({ + title, + width = 800, + height = 400, + isDetached, + onDetach, + onAttach, + children, +}: DetachablePanelProps) { + const externalWindow = useRef(null); + const [containerEl, setContainerEl] = useState(null); + + useEffect(() => { + if (!isDetached) { + if (externalWindow.current && !externalWindow.current.closed) { + externalWindow.current.close(); + } + externalWindow.current = null; + setContainerEl(null); + return; + } + + const left = window.screenX + (window.outerWidth - width) / 2; + const top = window.screenY + (window.outerHeight - height) / 2; + + const win = window.open( + "", + `studio13_${title.toLowerCase().replace(/\s+/g, "_")}`, + `width=${width},height=${height},left=${left},top=${top},resizable=yes`, + ); + + if (!win) { + onAttach(); + return; + } + + externalWindow.current = win; + + win.document.title = `${title} - Studio13`; + + const container = win.document.createElement("div"); + container.id = "detached-root"; + win.document.body.appendChild(container); + + win.document.body.style.margin = "0"; + win.document.body.style.padding = "0"; + win.document.body.style.overflow = "hidden"; + win.document.body.style.backgroundColor = "#121212"; + + const parentStyleSheets = document.querySelectorAll( + 'style, link[rel="stylesheet"]', + ); + parentStyleSheets.forEach((node) => { + const clone = node.cloneNode(true) as HTMLElement; + win.document.head.appendChild(clone); + }); + + setContainerEl(container); + + const handleUnload = () => { + onAttach(); + }; + win.addEventListener("beforeunload", handleUnload); + + return () => { + win.removeEventListener("beforeunload", handleUnload); + if (!win.closed) { + win.close(); + } + externalWindow.current = null; + setContainerEl(null); + }; + }, [isDetached, title, width, height, onAttach, onDetach]); + + if (isDetached && containerEl) { + return createPortal(children, containerEl); + } + + return <>{children}; +} diff --git a/frontend/src/components/DrumEditor.tsx b/frontend/src/components/DrumEditor.tsx new file mode 100644 index 0000000..662c7a7 --- /dev/null +++ b/frontend/src/components/DrumEditor.tsx @@ -0,0 +1,297 @@ +import { useState, useCallback, useMemo, useRef } from "react"; +import { Stage, Layer, Rect, Line, Text, Group } from "react-konva"; +import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/react/shallow"; +import { Select } from "./ui"; + +// General MIDI drum names (notes 35-81) +const GM_DRUM_MAP: Record = { + 35: "Acoustic Bass Drum", + 36: "Bass Drum 1", + 37: "Side Stick", + 38: "Acoustic Snare", + 39: "Hand Clap", + 40: "Electric Snare", + 41: "Low Floor Tom", + 42: "Closed Hi-Hat", + 43: "High Floor Tom", + 44: "Pedal Hi-Hat", + 45: "Low Tom", + 46: "Open Hi-Hat", + 47: "Low-Mid Tom", + 48: "Hi-Mid Tom", + 49: "Crash Cymbal 1", + 50: "High Tom", + 51: "Ride Cymbal 1", + 52: "Chinese Cymbal", + 53: "Ride Bell", + 54: "Tambourine", + 55: "Splash Cymbal", + 56: "Cowbell", + 57: "Crash Cymbal 2", + 59: "Ride Cymbal 2", + 60: "Hi Bongo", + 61: "Low Bongo", + 62: "Mute Hi Conga", + 63: "Open Hi Conga", + 64: "Low Conga", + 69: "Cabasa", + 70: "Maracas", + 75: "Claves", + 76: "Hi Wood Block", + 77: "Low Wood Block", +}; + +// Common drum kit rows (subset of GM) +const DEFAULT_ROWS = [36, 38, 42, 46, 45, 48, 50, 49, 51, 56]; + +const CELL_WIDTH = 28; +const CELL_HEIGHT = 24; +const LABEL_WIDTH = 120; +const HEADER_HEIGHT = 24; + +interface DrumEditorProps { + clipId: string; +} + +/** + * DrumEditor (Sprint 19.8) + * Grid-based percussion editor. Rows represent drum instruments, + * columns represent time steps. Click to toggle notes. + */ +export function DrumEditor({ clipId }: DrumEditorProps) { + const { clip, tempo } = useDAWStore( + useShallow((s) => { + const c = s.tracks + .flatMap((t) => t.clips) + .find((c) => c.id === clipId); + return { clip: c, tempo: s.transport.tempo }; + }), + ); + + const [steps, setSteps] = useState(32); + const [gridDivision, setGridDivision] = useState("1/16"); + const [velocity, setVelocity] = useState(100); + const containerRef = useRef(null); + + const drumRows = DEFAULT_ROWS; + + // Parse grid division to beat fraction + const stepDuration = useMemo(() => { + const beatDuration = 60 / tempo; + const parts = gridDivision.split("/"); + if (parts.length === 2) { + return ((4 * parseInt(parts[0])) / parseInt(parts[1])) * beatDuration; + } + return beatDuration / 4; // default 1/16 + }, [gridDivision, tempo]); + + const midiClip = clip && (clip as any).type === "midi" ? (clip as any) : null; + const notes: Array<{ id: string; note: number; time: number; duration: number; velocity: number }> = + midiClip?.notes || []; + + // Build a set of active cells + const activeNotes = useMemo(() => { + const set = new Set(); + if (!clip) return set; + for (const note of notes) { + const relTime = note.time - clip.startTime; + const stepIndex = Math.round(relTime / stepDuration); + if (stepIndex >= 0 && stepIndex < steps) { + set.add(`${note.note}-${stepIndex}`); + } + } + return set; + }, [notes, clip, stepDuration, steps]); + + const handleCellClick = useCallback( + (drumNote: number, stepIndex: number) => { + if (!clip) return; + const store = useDAWStore.getState(); + const key = `${drumNote}-${stepIndex}`; + const time = clip.startTime + stepIndex * stepDuration; + + if (activeNotes.has(key)) { + // Remove note + const noteToRemove = notes.find( + (n) => + n.note === drumNote && + Math.abs(n.time - time) < stepDuration * 0.5, + ); + if (noteToRemove && (store as any).removeMIDINote) { + (store as any).removeMIDINote(clipId, noteToRemove.id); + } + } else { + // Add note + if ((store as any).addMIDINote) { + (store as any).addMIDINote(clipId, { + note: drumNote, + time, + duration: stepDuration * 0.9, + velocity: velocity / 127, + }); + } + } + }, + [clip, clipId, notes, activeNotes, stepDuration, velocity], + ); + + const totalWidth = LABEL_WIDTH + steps * CELL_WIDTH; + const totalHeight = HEADER_HEIGHT + drumRows.length * CELL_HEIGHT; + + return ( +
+ {/* Toolbar */} +
+ Steps: + setGridDivision(v as string)} + options={[ + { value: "1/8", label: "1/8" }, + { value: "1/16", label: "1/16" }, + { value: "1/32", label: "1/32" }, + ]} + size="sm" + /> + Vel: + setVelocity(parseInt(e.target.value))} + className="w-16" + /> + {velocity} +
+ + {/* Grid */} +
+ + + {/* Header - step numbers */} + {Array.from({ length: steps }, (_, i) => ( + + ))} + + {/* Drum rows */} + {drumRows.map((drumNote, rowIndex) => { + const y = HEADER_HEIGHT + rowIndex * CELL_HEIGHT; + const drumName = + GM_DRUM_MAP[drumNote] || `Note ${drumNote}`; + + return ( + + {/* Row background */} + + + {/* Label */} + + + {/* Grid cells */} + {Array.from({ length: steps }, (_, stepIdx) => { + const cellX = LABEL_WIDTH + stepIdx * CELL_WIDTH; + const isActive = activeNotes.has( + `${drumNote}-${stepIdx}`, + ); + const isBeatStart = stepIdx % 4 === 0; + + return ( + + {/* Cell background */} + + handleCellClick(drumNote, stepIdx) + } + /> + {/* Active note indicator */} + {isActive && ( + + )} + + ); + })} + + ); + })} + + {/* Beat lines */} + {Array.from({ length: Math.ceil(steps / 4) + 1 }, (_, i) => ( + + ))} + + +
+
+ ); +} diff --git a/frontend/src/components/DynamicSplitModal.tsx b/frontend/src/components/DynamicSplitModal.tsx index 2e652e8..f3752ed 100644 --- a/frontend/src/components/DynamicSplitModal.tsx +++ b/frontend/src/components/DynamicSplitModal.tsx @@ -1,5 +1,6 @@ import { useState, useCallback } from "react"; import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/shallow"; import { nativeBridge } from "../services/NativeBridge"; import { Button, Slider } from "./ui"; import { Modal } from "./ui/Modal/Modal"; @@ -10,9 +11,11 @@ interface DynamicSplitModalProps { } export function DynamicSplitModal({ isOpen, onClose }: DynamicSplitModalProps) { - const clipId = useDAWStore((s) => s.dynamicSplitClipId); - const tracks = useDAWStore((s) => s.tracks); - const executeDynamicSplit = useDAWStore((s) => s.executeDynamicSplit); + const { clipId, tracks, executeDynamicSplit } = useDAWStore(useShallow((s) => ({ + clipId: s.dynamicSplitClipId, + tracks: s.tracks, + executeDynamicSplit: s.executeDynamicSplit, + }))); const [sensitivity, setSensitivity] = useState(0.5); const [minGapMs, setMinGapMs] = useState(100); diff --git a/frontend/src/components/FXChainPanel.css b/frontend/src/components/FXChainPanel.css index a5c008d..de5d570 100644 --- a/frontend/src/components/FXChainPanel.css +++ b/frontend/src/components/FXChainPanel.css @@ -6,6 +6,12 @@ align-items: center; justify-content: center; z-index: 2000; + animation: fx-overlay-fade-in 0.15s ease-out; +} + +@keyframes fx-overlay-fade-in { + from { opacity: 0; } + to { opacity: 1; } } /* Two-column layout */ @@ -19,6 +25,18 @@ display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + animation: fx-panel-slide-in 0.2s ease-out; +} + +@keyframes fx-panel-slide-in { + from { + opacity: 0; + transform: scale(0.96) translateY(8px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } } .fx-chain-header { diff --git a/frontend/src/components/FXChainPanel.tsx b/frontend/src/components/FXChainPanel.tsx index 5c8a7e4..71401f7 100644 --- a/frontend/src/components/FXChainPanel.tsx +++ b/frontend/src/components/FXChainPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { createPortal } from "react-dom"; import { X, @@ -16,6 +16,13 @@ import { RefreshCw, ChevronDown, ChevronRight, + ArrowLeftRight, + Save, + FolderOpen, + Trash2, + List, + Disc3, + Radio, } from "lucide-react"; import { nativeBridge } from "../services/NativeBridge"; import { useDAWStore } from "../store/useDAWStore"; @@ -42,7 +49,7 @@ interface FXChainPanelProps { interface FXSlot { index: number; name: string; - type?: "vst3" | "s13fx"; + type?: "vst3" | "lv2" | "clap" | "s13fx"; pluginPath?: string; } @@ -58,6 +65,13 @@ interface S13FXSlider { enumNames?: string[]; } +interface PluginParam { + index: number; + name: string; + value: number; + text: string; +} + interface Plugin { name: string; manufacturer: string; @@ -65,7 +79,7 @@ interface Plugin { fileOrIdentifier: string; isInstrument: boolean; snapshot?: string; - pluginType?: "vst3" | "s13fx"; + pluginType?: "vst3" | "lv2" | "clap" | "s13fx"; } // Map VST3 category substrings to Lucide icons and colors @@ -105,11 +119,23 @@ export function FXChainPanel({ chainType, onClose, }: FXChainPanelProps) { - const { updateTrack, addTrackFXWithUndo, removeTrackFXWithUndo } = useDAWStore( + const { + updateTrack, addTrackFXWithUndo, removeTrackFXWithUndo, + pluginABStates, togglePluginAB, + fxChainPresets, saveFXChainPreset, loadFXChainPreset, deleteFXChainPreset, + addAutomationLane, + } = useDAWStore( useShallow((s) => ({ updateTrack: s.updateTrack, addTrackFXWithUndo: s.addTrackFXWithUndo, removeTrackFXWithUndo: s.removeTrackFXWithUndo, + pluginABStates: s.pluginABStates, + togglePluginAB: s.togglePluginAB, + fxChainPresets: s.fxChainPresets, + saveFXChainPreset: s.saveFXChainPreset, + loadFXChainPreset: s.loadFXChainPreset, + deleteFXChainPreset: s.deleteFXChainPreset, + addAutomationLane: s.addAutomationLane, })) ); const [fxSlots, setFxSlots] = useState([]); @@ -120,6 +146,56 @@ export function FXChainPanel({ const [expandedS13FX, setExpandedS13FX] = useState(null); const [s13fxSliders, setS13fxSliders] = useState([]); const [showRawSliders, setShowRawSliders] = useState(false); + const [showPresetMenu, setShowPresetMenu] = useState(false); + const [presetName, setPresetName] = useState(""); + + // Plugin parameter list state (per-slot) + const [expandedParamsFx, setExpandedParamsFx] = useState(null); + const [pluginParams, setPluginParams] = useState([]); + const [paramsLoading, setParamsLoading] = useState(false); + + // Plugin presets state (per-slot) + const [expandedPresetsFx, setExpandedPresetsFx] = useState(null); + const [pluginPresetsList, setPluginPresetsList] = useState([]); + const [presetsLoading, setPresetsLoading] = useState(false); + const [savePresetName, setSavePresetName] = useState(""); + + // MIDI Learn state + const [midiLearnActive, setMidiLearnActive] = useState<{ fxIndex: number; paramIndex: number } | null>(null); + const midiLearnTimerRef = useRef | null>(null); + + const handleStartMIDILearn = useCallback(async (fxIndex: number, paramIndex: number) => { + // Cancel any existing learn session + if (midiLearnActive) { + await nativeBridge.cancelPluginMIDILearn(); + if (midiLearnTimerRef.current) clearTimeout(midiLearnTimerRef.current); + } + setMidiLearnActive({ fxIndex, paramIndex }); + await nativeBridge.startPluginMIDILearn(trackId, fxIndex, paramIndex); + // Auto-cancel after 10 seconds + midiLearnTimerRef.current = setTimeout(async () => { + await nativeBridge.cancelPluginMIDILearn(); + setMidiLearnActive(null); + midiLearnTimerRef.current = null; + }, 10000); + }, [midiLearnActive, trackId]); + + const handleCancelMIDILearn = useCallback(async () => { + if (midiLearnTimerRef.current) clearTimeout(midiLearnTimerRef.current); + midiLearnTimerRef.current = null; + await nativeBridge.cancelPluginMIDILearn(); + setMidiLearnActive(null); + }, []); + + // Clean up MIDI learn timer on unmount + useEffect(() => { + return () => { + if (midiLearnTimerRef.current) { + clearTimeout(midiLearnTimerRef.current); + nativeBridge.cancelPluginMIDILearn(); + } + }; + }, []); // Plugin browser state const [plugins, setPlugins] = useState([]); @@ -175,10 +251,13 @@ export function FXChainPanel({ setPluginsLoading(true); try { const pluginList = await nativeBridge.getAvailablePlugins(); - const vst3Plugins: Plugin[] = pluginList.map((p: any) => ({ - ...p, - pluginType: "vst3" as const, - })); + const vst3Plugins: Plugin[] = pluginList.map((p: any) => { + const fmt = (p.pluginFormatName || "").toLowerCase(); + let pluginType: Plugin["pluginType"] = "vst3"; + if (fmt.includes("lv2")) pluginType = "lv2"; + else if (fmt.includes("clap")) pluginType = "clap"; + return { ...p, pluginType }; + }); let s13fxPlugins: Plugin[] = []; try { @@ -385,6 +464,82 @@ export function FXChainPanel({ } }; + // ---- Plugin Parameter List handlers ---- + const handleToggleParams = async (fxIndex: number) => { + if (expandedParamsFx === fxIndex) { + setExpandedParamsFx(null); + setPluginParams([]); + return; + } + setParamsLoading(true); + try { + const isInputFX = chainType === "input"; + const params = await nativeBridge.getPluginParameters(trackId, fxIndex, isInputFX); + setPluginParams(params); + setExpandedParamsFx(fxIndex); + // Close presets panel if open on another slot + setExpandedPresetsFx(null); + } catch (e) { + console.error("[FXChain] Failed to load plugin parameters:", e); + } finally { + setParamsLoading(false); + } + }; + + const handleAutomateParam = (fxIndex: number, param: PluginParam) => { + if (chainType === "master") return; // Master automation not supported + const automationParam = `plugin_${fxIndex}_${param.index}`; + addAutomationLane(trackId, automationParam, `${fxSlots.find((f) => f.index === fxIndex)?.name || "Plugin"}: ${param.name}`); + }; + + // ---- Plugin Presets handlers ---- + const handleTogglePluginPresets = async (fxIndex: number) => { + if (expandedPresetsFx === fxIndex) { + setExpandedPresetsFx(null); + setPluginPresetsList([]); + return; + } + setPresetsLoading(true); + try { + const isInputFX = chainType === "input"; + const presets = await nativeBridge.getPluginPresets(trackId, fxIndex, isInputFX); + setPluginPresetsList(presets); + setExpandedPresetsFx(fxIndex); + // Close params panel if open on another slot + setExpandedParamsFx(null); + } catch (e) { + console.error("[FXChain] Failed to load plugin presets:", e); + } finally { + setPresetsLoading(false); + } + }; + + const handleLoadPluginPreset = async (fxIndex: number, presetNameStr: string) => { + try { + const isInputFX = chainType === "input"; + await nativeBridge.loadPluginPreset(trackId, fxIndex, isInputFX, presetNameStr); + console.log(`[FXChain] Loaded preset "${presetNameStr}" on FX ${fxIndex}`); + } catch (e) { + console.error("[FXChain] Failed to load plugin preset:", e); + } + }; + + const handleSavePluginPreset = async (fxIndex: number) => { + const name = savePresetName.trim(); + if (!name) return; + try { + const isInputFX = chainType === "input"; + await nativeBridge.savePluginPreset(trackId, fxIndex, isInputFX, name); + console.log(`[FXChain] Saved preset "${name}" on FX ${fxIndex}`); + setSavePresetName(""); + // Refresh the presets list + const presets = await nativeBridge.getPluginPresets(trackId, fxIndex, isInputFX); + setPluginPresetsList(presets); + } catch (e) { + console.error("[FXChain] Failed to save plugin preset:", e); + } + }; + const categories = [ "All", ...Array.from(new Set(plugins.map((p) => p.category))), @@ -421,11 +576,96 @@ export function FXChainPanel({

Loaded FX

- - {fxSlots.length} plugin{fxSlots.length !== 1 ? "s" : ""} - +
+ + {fxSlots.length} plugin{fxSlots.length !== 1 ? "s" : ""} + + +
+ {/* FX Chain Presets Panel */} + {showPresetMenu && ( +
+ {/* Save Current Chain */} +
+ setPresetName(e.target.value)} + className="flex-1 text-xs" + /> + +
+ + {/* Preset List */} + {fxChainPresets.length === 0 ? ( +
+ No presets saved yet +
+ ) : ( +
+ {fxChainPresets.map((preset, idx) => ( +
+ + {preset.name} + + + {preset.plugins.length} fx + + + +
+ ))} +
+ )} +
+ )} +
{loading ? (
@@ -483,6 +723,57 @@ export function FXChainPanel({ )} + {/* A/B Comparison Toggle */} + {!isS13FX && (() => { + const abKey = `${trackId}-${fx.index}`; + const abState = pluginABStates[abKey]; + const activeSlot = abState?.active || "a"; + return ( + + ); + })()} + {/* Parameters button */} + {!isS13FX && ( + + )} + {/* Presets button */} + {!isS13FX && ( + + )}
+ {/* 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" && ( + + )} +
+ ); + })} +
+ )} +
+ )} + + {/* 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) => ( + + ))} +
+ )} +
+ )} + {/* S13FX inline sliders + advanced graph */} {isS13FX && expandedS13FX === fx.index && s13fxSliders.length > 0 && (() => { const advancedType = fx.pluginPath?.match(/(\w+)_advanced\.jsfx/)?.[1]; diff --git a/frontend/src/components/GenericPluginEditor.tsx b/frontend/src/components/GenericPluginEditor.tsx new file mode 100644 index 0000000..c0203d5 --- /dev/null +++ b/frontend/src/components/GenericPluginEditor.tsx @@ -0,0 +1,164 @@ +import { useState, useEffect, useCallback } from "react"; +import { nativeBridge } from "../services/NativeBridge"; +import { + Button, + Modal, + ModalHeader, + ModalContent, + ModalFooter, + Slider, +} from "./ui"; + +interface PluginParam { + index: number; + name: string; + value: number; + label: string; // e.g., "50%" or "12.5 Hz" +} + +interface GenericPluginEditorProps { + isOpen: boolean; + onClose: () => void; + trackId: string; + fxIndex: number; + pluginName: string; + chainType: "input" | "track" | "master"; +} + +/** + * GenericPluginEditor (Sprint 19.15) + * Fallback slider UI for plugins without a native editor. + * Fetches plugin parameters via bridge and provides sliders. + */ +export function GenericPluginEditor({ + isOpen, + onClose, + trackId, + fxIndex, + pluginName, + chainType, +}: GenericPluginEditorProps) { + const [params, setParams] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + + const fetchParams = useCallback(async () => { + setLoading(true); + try { + const result = await nativeBridge.getPluginParameters( + trackId, + fxIndex, + chainType === "input", + ); + if (Array.isArray(result)) { + setParams( + result.map((p: any, i: number) => ({ + index: p.index ?? i, + name: p.name ?? `Param ${i}`, + value: p.value ?? 0, + label: p.label ?? `${Math.round((p.value ?? 0) * 100)}%`, + })), + ); + } + } catch (e) { + console.error("[GenericPluginEditor] Failed to fetch params:", e); + } finally { + setLoading(false); + } + }, [trackId, fxIndex, chainType]); + + useEffect(() => { + if (isOpen) fetchParams(); + }, [isOpen, fetchParams]); + + const handleParamChange = async (paramIndex: number, newValue: number) => { + // Update local state immediately + setParams((prev) => + prev.map((p) => + p.index === paramIndex + ? { ...p, value: newValue, label: `${Math.round(newValue * 100)}%` } + : p, + ), + ); + + // Send to backend + try { + await nativeBridge.setPluginParameter( + trackId, + fxIndex, + chainType === "input", + paramIndex, + newValue, + ); + } catch { + // Silently fail + } + }; + + const filteredParams = params.filter( + (p) => + !searchTerm || + p.name.toLowerCase().includes(searchTerm.toLowerCase()), + ); + + return ( + + + +
+ {/* Search */} + setSearchTerm(e.target.value)} + placeholder="Search parameters..." + className="w-full px-2 py-1 text-xs bg-daw-dark border border-daw-border rounded text-daw-text" + /> + + {/* Parameters */} +
+ {loading ? ( +

+ Loading parameters... +

+ ) : filteredParams.length === 0 ? ( +

+ No parameters found. +

+ ) : ( + filteredParams.map((param) => ( +
+ + {param.name} + + + handleParamChange(param.index, v / 100) + } + /> + + {param.label} + +
+ )) + )} +
+
+
+ + + + +
+ ); +} diff --git a/frontend/src/components/KeyboardShortcutsModal.tsx b/frontend/src/components/KeyboardShortcutsModal.tsx index 67f37b4..9127f81 100644 --- a/frontend/src/components/KeyboardShortcutsModal.tsx +++ b/frontend/src/components/KeyboardShortcutsModal.tsx @@ -1,6 +1,8 @@ -import { useState, useMemo } from "react"; -import { Search } from "lucide-react"; +import { useState, useMemo, useEffect, useCallback, useRef } from "react"; +import { Search, Printer } from "lucide-react"; +import { useShallow } from "zustand/shallow"; import { getRegisteredActions } from "../store/actionRegistry"; +import { useDAWStore } from "../store/useDAWStore"; import { Button, Input } from "./ui"; import { Modal } from "./ui/Modal/Modal"; @@ -9,24 +11,90 @@ interface KeyboardShortcutsModalProps { onClose: () => void; } +/** + * Convert a KeyboardEvent into a human-readable shortcut string + * matching the format used in actionRegistry (e.g. "Ctrl+Shift+Z", "Alt+B", "F1"). + */ +function keyEventToShortcutString(e: KeyboardEvent): string { + const parts: string[] = []; + + if (e.ctrlKey || e.metaKey) parts.push("Ctrl"); + if (e.shiftKey) parts.push("Shift"); + if (e.altKey) parts.push("Alt"); + + // Map the key to a display name + let key = e.key; + + // Skip standalone modifier keys + if (["Control", "Shift", "Alt", "Meta"].includes(key)) return ""; + + // Normalize keys + if (key === " ") key = "Space"; + else if (key === "ArrowLeft") key = "Left"; + else if (key === "ArrowRight") key = "Right"; + else if (key === "ArrowUp") key = "Up"; + else if (key === "ArrowDown") key = "Down"; + else if (key === "Escape") key = "Esc"; + else if (key.startsWith("F") && key.length <= 3 && /^F\d+$/.test(key)) { + // Function keys: keep as-is (F1, F2, etc.) + } else if (key.length === 1) { + // Single character: uppercase it + key = key.toUpperCase(); + } + + parts.push(key); + return parts.join("+"); +} + /** * KeyboardShortcutsModal - Searchable, categorized keyboard shortcuts reference + * with rebinding support. */ -export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) { +export function KeyboardShortcutsModal({ + isOpen, + onClose, +}: KeyboardShortcutsModalProps) { const [search, setSearch] = useState(""); + const [listeningActionId, setListeningActionId] = useState( + null + ); + const [capturedShortcut, setCapturedShortcut] = useState(""); + const listeningRef = useRef(null); + + const { customShortcuts, setCustomShortcut, resetCustomShortcuts } = + useDAWStore( + useShallow((s) => ({ + customShortcuts: s.customShortcuts, + setCustomShortcut: s.setCustomShortcut, + resetCustomShortcuts: s.resetCustomShortcuts, + })) + ); const actions = useMemo(() => getRegisteredActions(), []); + // Compute effective shortcut for each action (custom overrides default) + const getEffectiveShortcut = useCallback( + (actionId: string, defaultShortcut?: string): string | undefined => { + if (customShortcuts[actionId] !== undefined) { + return customShortcuts[actionId]; + } + return defaultShortcut; + }, + [customShortcuts] + ); + const filtered = useMemo(() => { if (!search) return actions; const q = search.toLowerCase(); - return actions.filter( - (a) => + return actions.filter((a) => { + const effectiveShortcut = getEffectiveShortcut(a.id, a.shortcut); + return ( a.name.toLowerCase().includes(q) || a.category.toLowerCase().includes(q) || - (a.shortcut && a.shortcut.toLowerCase().includes(q)), - ); - }, [actions, search]); + (effectiveShortcut && effectiveShortcut.toLowerCase().includes(q)) + ); + }); + }, [actions, search, getEffectiveShortcut]); // Group by category const grouped = useMemo(() => { @@ -38,68 +106,367 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod return groups; }, [filtered]); + // Keep ref in sync with state for the event listener + useEffect(() => { + listeningRef.current = listeningActionId; + }, [listeningActionId]); + + // Listen for key events when in rebinding mode + useEffect(() => { + if (!listeningActionId) return; + + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Escape cancels rebinding + if (e.key === "Escape") { + setListeningActionId(null); + setCapturedShortcut(""); + return; + } + + const shortcut = keyEventToShortcutString(e); + if (!shortcut) return; // Ignore standalone modifier keys + + setCapturedShortcut(shortcut); + + // Save the shortcut + if (listeningRef.current) { + setCustomShortcut(listeningRef.current, shortcut); + } + setListeningActionId(null); + setCapturedShortcut(""); + }; + + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [listeningActionId, setCustomShortcut]); + + // Cancel listening when modal closes + useEffect(() => { + if (!isOpen) { + setListeningActionId(null); + setCapturedShortcut(""); + } + }, [isOpen]); + + const handleStartRebind = (actionId: string) => { + setListeningActionId(actionId); + setCapturedShortcut(""); + }; + + const handleResetSingle = (actionId: string) => { + // Remove only this action's custom shortcut + const updated = { ...customShortcuts }; + delete updated[actionId]; + // We need to set it via the store; since there's no "remove single" action, + // we'll just set it to the empty object minus this key + // Actually we can overwrite by setting the full map + useDAWStore.setState({ customShortcuts: updated }); + localStorage.setItem("s13_customShortcuts", JSON.stringify(updated)); + }; + + const hasAnyCustomShortcuts = Object.keys(customShortcuts).length > 0; + + const handlePrintCheatSheet = useCallback(() => { + // Build grouped data for the printable view using all actions (not filtered by search) + const allActions = getRegisteredActions(); + const printGroups: Record = {}; + for (const action of allActions) { + const effectiveShortcut = getEffectiveShortcut(action.id, action.shortcut); + if (!effectiveShortcut) continue; // Only include actions that have shortcuts + if (!printGroups[action.category]) printGroups[action.category] = []; + printGroups[action.category].push({ + name: action.name, + shortcut: effectiveShortcut, + }); + } + + const categoriesHtml = Object.entries(printGroups) + .map( + ([category, items]) => ` +
+

${category}

+ + ${items + .map( + (item) => ` + + + + ` + ) + .join("")} +
${item.name}${item.shortcut}
+
` + ) + .join(""); + + const html = ` + + + Studio13 - Keyboard Shortcuts + + + +

Studio13 Keyboard Shortcuts

+

Generated on ${new Date().toLocaleDateString()}

+
+ +
+
+ ${categoriesHtml} +
+ +`; + + const printWindow = window.open("", "_blank"); + if (printWindow) { + printWindow.document.write(html); + printWindow.document.close(); + } + }, [getEffectiveShortcut]); + if (!isOpen) return null; return ( - +
{/* Search */}
- + setSearch(e.target.value)} placeholder="Search shortcuts..." className="pl-7" - autoFocus + autoFocus={!listeningActionId} />
+ {/* Listening overlay */} + {listeningActionId && ( +
+ + Press a key combination... + + {capturedShortcut && ( + + {capturedShortcut} + + )} +
+ Press Esc to cancel +
+
+ )} + {/* Shortcuts List */}
{Object.entries(grouped).map(([category, categoryActions]) => (
-

+

{category} ({categoryActions.length})

- {categoryActions.map((action) => ( -
{ - action.execute(); - onClose(); - }} - > - {action.name} - {action.shortcut ? ( - - {action.shortcut} + {categoryActions.map((action) => { + const isCustom = + customShortcuts[action.id] !== undefined; + const effectiveShortcut = getEffectiveShortcut( + action.id, + action.shortcut + ); + const isListening = listeningActionId === action.id; + + return ( +
+ { + if (!listeningActionId) { + action.execute(); + onClose(); + } + }} + title={action.name} + > + {action.name} - ) : ( - — - )} -
- ))} + +
+ {/* Shortcut badge */} + {effectiveShortcut ? ( + + {effectiveShortcut} + + ) : ( + + — + + )} + + {/* Rebind button */} + + + {/* Reset button (only visible for custom shortcuts) */} + {isCustom && ( + + )} +
+
+ ); + })}
))} {Object.keys(grouped).length === 0 && (
- No shortcuts found for "{search}" + No shortcuts found for "{search}"
)}
{/* Footer */}
- - {filtered.length} action{filtered.length !== 1 ? "s" : ""} - - +
+ + {filtered.length} action{filtered.length !== 1 ? "s" : ""} + + {hasAnyCustomShortcuts && ( + + )} +
+
+ + +
diff --git a/frontend/src/components/LoudnessMeter.tsx b/frontend/src/components/LoudnessMeter.tsx new file mode 100644 index 0000000..a157ace --- /dev/null +++ b/frontend/src/components/LoudnessMeter.tsx @@ -0,0 +1,159 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { nativeBridge } from "../services/NativeBridge"; + +interface LoudnessMeterProps { + width?: number; + height?: number; +} + +interface LoudnessData { + momentary: number; // LUFS (short-term, ~400ms) + shortTerm: number; // LUFS (~3s integration) + integrated: number; // LUFS (entire program) + range: number; // LU (loudness range) + truePeak: number; // dBTP +} + +/** + * LoudnessMeter (Sprint 20.9) + * Displays LUFS metering data from the backend AudioAnalyzer. + * Shows momentary, short-term, integrated loudness, range, and true peak. + */ +export function LoudnessMeter({ width = 200, height = 120 }: LoudnessMeterProps) { + const [data, setData] = useState({ + momentary: -Infinity, + shortTerm: -Infinity, + integrated: -Infinity, + range: 0, + truePeak: -Infinity, + }); + const intervalRef = useRef>(undefined); + + const fetchLoudness = useCallback(async () => { + try { + const result = await nativeBridge.getLoudnessData(); + if (result) { + setData({ + momentary: result.momentary ?? -Infinity, + shortTerm: result.shortTerm ?? -Infinity, + integrated: result.integrated ?? -Infinity, + range: result.range ?? 0, + truePeak: result.truePeak ?? -Infinity, + }); + } + } catch { + // Backend not available + } + }, []); + + useEffect(() => { + intervalRef.current = setInterval(fetchLoudness, 200); + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [fetchLoudness]); + + const formatLUFS = (val: number) => + isFinite(val) ? val.toFixed(1) : "-inf"; + const formatdB = (val: number) => + isFinite(val) ? val.toFixed(1) : "-inf"; + + // Color based on loudness level + const getLevelColor = (lufs: number): string => { + if (!isFinite(lufs)) return "#333"; + if (lufs > -8) return "#ef4444"; // Red - too loud + if (lufs > -14) return "#eab308"; // Yellow - caution + return "#22c55e"; // Green - OK + }; + + // Meter bar width (map -60..0 LUFS to 0..100%) + const getMeterWidth = (lufs: number): number => { + if (!isFinite(lufs)) return 0; + return Math.max(0, Math.min(100, ((lufs + 60) / 60) * 100)); + }; + + return ( +
+
+ Loudness (LUFS) +
+ + {/* Momentary */} + + + {/* Short-term */} + + + {/* Integrated */} + + + {/* Range */} +
+ LRA + + {data.range.toFixed(1)} LU + +
+ + {/* True Peak */} +
+ TP + -1 ? "text-red-400" : "text-daw-text" + }`} + > + {formatdB(data.truePeak)} dBTP + +
+
+ ); +} + +function MeterRow({ + label, + value, + barWidth, + barColor, +}: { + label: string; + value: string; + barWidth: number; + barColor: string; +}) { + return ( +
+ {label} +
+
+
+ + {value} + +
+ ); +} diff --git a/frontend/src/components/MainToolbar.tsx b/frontend/src/components/MainToolbar.tsx index 9c036e6..60d2271 100644 --- a/frontend/src/components/MainToolbar.tsx +++ b/frontend/src/components/MainToolbar.tsx @@ -1,5 +1,6 @@ -import { Repeat, Circle, Play, Square, Undo2, Redo2, Grid3x3, SlidersHorizontal, SlidersVertical, Settings, Blend, MousePointer, Scissors, VolumeX } from "lucide-react"; +import { Repeat, Circle, Play, Square, Undo2, Redo2, Grid3x3, SlidersHorizontal, SlidersVertical, Settings, Blend, MousePointer, Scissors, VolumeX, Wand2 } from "lucide-react"; import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/shallow"; import { Button } from "./ui"; interface MainToolbarProps { @@ -13,21 +14,46 @@ export function MainToolbar({ onToggleMixer, showMixer, }: MainToolbarProps) { - const { transport, play, record, stop, toggleLoop, tracks, snapEnabled, toggleSnap, undo, redo, canUndo, canRedo, autoCrossfade, toggleAutoCrossfade, toolMode, setToolMode, toggleSplitTool, toggleMuteTool } = - useDAWStore(); - const { isPlaying, isPaused, loopEnabled } = transport; + const { + isPlaying, isPaused, loopEnabled, + play, record, stop, toggleLoop, tracks, snapEnabled, toggleSnap, + undo, redo, canUndo, canRedo, autoCrossfade, toggleAutoCrossfade, + toolMode, setToolMode, toggleSplitTool, toggleMuteTool, + } = useDAWStore(useShallow((s) => ({ + isPlaying: s.transport.isPlaying, + isPaused: s.transport.isPaused, + loopEnabled: s.transport.loopEnabled, + play: s.play, + record: s.record, + stop: s.stop, + toggleLoop: s.toggleLoop, + tracks: s.tracks, + snapEnabled: s.snapEnabled, + toggleSnap: s.toggleSnap, + undo: s.undo, + redo: s.redo, + canUndo: s.canUndo, + canRedo: s.canRedo, + autoCrossfade: s.autoCrossfade, + toggleAutoCrossfade: s.toggleAutoCrossfade, + toolMode: s.toolMode, + setToolMode: s.setToolMode, + toggleSplitTool: s.toggleSplitTool, + toggleMuteTool: s.toggleMuteTool, + }))); const hasArmedTracks = tracks.some((t) => t.armed); return ( -
+
{/* Transport Section */} -
+
@@ -37,7 +63,8 @@ export function MainToolbar({ active={hasArmedTracks && isPlaying} disabled={!hasArmedTracks} onClick={() => record()} - title={hasArmedTracks ? "Record" : "Arm a track to record"} + title={hasArmedTracks ? "Record (Ctrl+R)" : "Arm a track to record"} + aria-label={hasArmedTracks ? "Record" : "Arm a track to record"} > @@ -46,7 +73,8 @@ export function MainToolbar({ size="icon-lg" active={isPlaying} onClick={() => play()} - title="Play" + title="Play (Space)" + aria-label="Play" > @@ -55,7 +83,8 @@ export function MainToolbar({ size="icon-lg" disabled={!isPlaying && !isPaused} onClick={() => stop()} - title="Stop" + title="Stop (Space)" + aria-label="Stop" > @@ -64,13 +93,14 @@ export function MainToolbar({
{/* Edit Tools */} -
+
@@ -80,6 +110,7 @@ export function MainToolbar({ disabled={!canRedo} onClick={redo} title="Redo (Ctrl+Shift+Z)" + aria-label="Redo" > @@ -93,6 +124,7 @@ export function MainToolbar({ ? "Snap Enabled (Click to disable)" : "Snap Disabled (Click to enable)" } + aria-label={snapEnabled ? "Disable Snap to Grid" : "Enable Snap to Grid"} > @@ -106,6 +138,7 @@ export function MainToolbar({ ? "Auto-Crossfade On (Click to disable)" : "Auto-Crossfade Off (Click to enable)" } + aria-label={autoCrossfade ? "Disable Auto-Crossfade" : "Enable Auto-Crossfade"} > @@ -114,13 +147,14 @@ export function MainToolbar({
{/* Tool Mode */} -
+
@@ -130,6 +164,7 @@ export function MainToolbar({ active={toolMode === "split"} onClick={toggleSplitTool} title="Split Tool (B)" + aria-label="Split Tool" > @@ -139,21 +174,33 @@ export function MainToolbar({ active={toolMode === "mute"} onClick={toggleMuteTool} title="Mute Tool (X)" + aria-label="Mute Tool" > +
{/* View Toggles - Mixer Now Works! */} -
+
@@ -162,6 +209,7 @@ export function MainToolbar({ size="icon-lg" disabled title="FX Browser (TODO)" + aria-label="FX Browser" > @@ -176,6 +224,7 @@ export function MainToolbar({ size="icon-lg" onClick={onOpenSettings} title="Audio Settings" + aria-label="Audio Settings" > diff --git a/frontend/src/components/MasterTrackHeader.tsx b/frontend/src/components/MasterTrackHeader.tsx index d711344..acce3be 100644 --- a/frontend/src/components/MasterTrackHeader.tsx +++ b/frontend/src/components/MasterTrackHeader.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/shallow"; import { Button, Slider } from "./ui"; import { Volume2, VolumeX, Power } from "lucide-react"; import { FXChainPanel } from "./FXChainPanel"; @@ -9,11 +10,13 @@ import { FXChainPanel } from "./FXChainPanel"; * Shows master volume fader, mute, FX button with bypass toggle */ export function MasterTrackHeader() { - const masterVolume = useDAWStore((s) => s.masterVolume); - const isMasterMuted = useDAWStore((s) => s.isMasterMuted); - const masterFxCount = useDAWStore((s) => s.masterFxCount); - const setMasterVolume = useDAWStore((s) => s.setMasterVolume); - const toggleMasterMute = useDAWStore((s) => s.toggleMasterMute); + const { masterVolume, isMasterMuted, masterFxCount, setMasterVolume, toggleMasterMute } = useDAWStore(useShallow((s) => ({ + masterVolume: s.masterVolume, + isMasterMuted: s.isMasterMuted, + masterFxCount: s.masterFxCount, + setMasterVolume: s.setMasterVolume, + toggleMasterMute: s.toggleMasterMute, + }))); const [showFXChain, setShowFXChain] = useState(false); const [fxBypassed, setFxBypassed] = useState(false); diff --git a/frontend/src/components/MediaExplorer.tsx b/frontend/src/components/MediaExplorer.tsx index a159890..02c4891 100644 --- a/frontend/src/components/MediaExplorer.tsx +++ b/frontend/src/components/MediaExplorer.tsx @@ -13,6 +13,7 @@ import { } from "lucide-react"; import { nativeBridge } from "../services/NativeBridge"; import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/shallow"; import { Button, Input } from "./ui"; interface FileEntry { @@ -54,7 +55,12 @@ export function MediaExplorer({ isVisible, onClose }: MediaExplorerProps) { setMediaExplorerPath, mediaExplorerRecentPaths, addMediaExplorerRecentPath, - } = useDAWStore(); + } = useDAWStore(useShallow((s) => ({ + mediaExplorerPath: s.mediaExplorerPath, + setMediaExplorerPath: s.setMediaExplorerPath, + mediaExplorerRecentPaths: s.mediaExplorerRecentPaths, + addMediaExplorerRecentPath: s.addMediaExplorerRecentPath, + }))); const [files, setFiles] = useState([]); const [loading, setLoading] = useState(false); diff --git a/frontend/src/components/MediaPool.tsx b/frontend/src/components/MediaPool.tsx new file mode 100644 index 0000000..847efb7 --- /dev/null +++ b/frontend/src/components/MediaPool.tsx @@ -0,0 +1,196 @@ +import { useState, useMemo } from "react"; +import { + Music, + FileAudio, + HardDrive, + Search, +} from "lucide-react"; +import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/react/shallow"; +import { + Button, + Input, + Modal, + ModalHeader, + ModalContent, + ModalFooter, +} from "./ui"; + +interface MediaItem { + filePath: string; + fileName: string; + usageCount: number; + clipIds: string[]; + trackNames: string[]; +} + +interface MediaPoolProps { + isOpen: boolean; + onClose: () => void; +} + +/** + * MediaPool (Sprint 20.7) + * Lists all audio files referenced in the project with usage counts. + * Helps identify unused files and navigate to clips using them. + */ +export function MediaPool({ isOpen, onClose }: MediaPoolProps) { + const { tracks } = useDAWStore(useShallow((s) => ({ tracks: s.tracks }))); + const [searchTerm, setSearchTerm] = useState(""); + const [sortBy, setSortBy] = useState<"name" | "usage">("name"); + + const mediaItems = useMemo(() => { + const fileMap = new Map(); + + for (const track of tracks) { + for (const clip of track.clips) { + if ((clip as any).type !== "audio" && !(clip as any).filePath) continue; + const audioClip = clip as any; + const fp = audioClip.filePath || audioClip.file; + if (!fp) continue; + + const existing = fileMap.get(fp); + if (existing) { + existing.usageCount++; + existing.clipIds.push(clip.id); + if (!existing.trackNames.includes(track.name)) { + existing.trackNames.push(track.name); + } + } else { + fileMap.set(fp, { + filePath: fp, + fileName: fp.replace(/\\/g, "/").split("/").pop() || fp, + usageCount: 1, + clipIds: [clip.id], + trackNames: [track.name], + }); + } + } + } + + return Array.from(fileMap.values()); + }, [tracks]); + + const filtered = useMemo(() => { + let items = mediaItems; + if (searchTerm) { + const q = searchTerm.toLowerCase(); + items = items.filter( + (m) => + m.fileName.toLowerCase().includes(q) || + m.filePath.toLowerCase().includes(q), + ); + } + items.sort((a, b) => + sortBy === "name" + ? a.fileName.localeCompare(b.fileName) + : b.usageCount - a.usageCount, + ); + return items; + }, [mediaItems, searchTerm, sortBy]); + + const totalFiles = mediaItems.length; + const unusedFiles = mediaItems.filter((m) => m.usageCount === 0).length; + + return ( + + + +
+ {/* Summary */} +
+ + + {totalFiles} file{totalFiles !== 1 ? "s" : ""} + + {unusedFiles > 0 && ( + + + {unusedFiles} unused + + )} +
+ + {/* Search + Sort */} +
+
+ + setSearchTerm(e.target.value)} + placeholder="Search files..." + className="pl-7" + size="sm" + /> +
+ + +
+ + {/* File list */} +
+ {filtered.length === 0 ? ( +

+ {searchTerm ? "No files match search." : "No media files in project."} +

+ ) : ( + filtered.map((item) => ( +
+ +
+

+ {item.fileName} +

+

+ {item.filePath} +

+ {item.trackNames.length > 0 && ( +

+ Tracks: {item.trackNames.join(", ")} +

+ )} +
+ + {item.usageCount}x + +
+ )) + )} +
+
+
+ + + {filtered.length} of {totalFiles} files shown + + + +
+ ); +} diff --git a/frontend/src/components/MenuBar.tsx b/frontend/src/components/MenuBar.tsx index 057ecbb..607800b 100644 --- a/frontend/src/components/MenuBar.tsx +++ b/frontend/src/components/MenuBar.tsx @@ -3,6 +3,7 @@ import { Minus, Square, X, Copy } from "lucide-react"; import { EditMenu } from "./menus/EditMenu"; import { MenuDropdown, MenuItemProps } from "./menus/MenuDropdown"; import { useDAWStore, THEME_PRESETS } from "../store/useDAWStore"; +import { useShallow } from "zustand/shallow"; import { nativeBridge } from "../services/NativeBridge"; /** @@ -31,7 +32,28 @@ export function MenuBar() { toggleVirtualKeyboard, showUndoHistory, toggleUndoHistory, - } = useDAWStore(); + } = useDAWStore(useShallow((s) => ({ + toggleMixer: s.toggleMixer, + showMixer: s.showMixer, + showMasterTrackInTCP: s.showMasterTrackInTCP, + toggleMasterTrackInTCP: s.toggleMasterTrackInTCP, + openSettings: s.openSettings, + openProjectSettings: s.openProjectSettings, + openRenderModal: s.openRenderModal, + newProject: s.newProject, + saveProject: s.saveProject, + loadProject: s.loadProject, + snapEnabled: s.snapEnabled, + toggleSnap: s.toggleSnap, + gridSize: s.gridSize, + setGridSize: s.setGridSize, + recentProjects: s.recentProjects, + clearRecentProjects: s.clearRecentProjects, + showVirtualKeyboard: s.showVirtualKeyboard, + toggleVirtualKeyboard: s.toggleVirtualKeyboard, + showUndoHistory: s.showUndoHistory, + toggleUndoHistory: s.toggleUndoHistory, + }))); const [isMaximized, setIsMaximized] = useState(false); @@ -144,6 +166,50 @@ export function MenuBar() { if (success) console.log("New version saved"); }); }, + }, + { + label: "Save as Template...", + onClick: () => { + const name = prompt("Template name:"); + if (name) useDAWStore.getState().saveAsTemplate(name); + }, + }, + { + label: "New from Template...", + onClick: () => { + const state = useDAWStore.getState(); + const templates = state.projectTemplates; + if (templates.length === 0) { + alert("No templates saved yet. Use 'Save as Template' first."); + return; + } + state.toggleProjectTemplates(); + }, + submenu: (() => { + const templates = useDAWStore.getState().projectTemplates; + if (templates.length === 0) return [{ label: "(no templates)", disabled: true }]; + return [ + ...templates.map((t, i) => ({ + label: t.name, + onClick: () => { + if (confirm(`Load template "${t.name}"? This will replace the current project.`)) { + useDAWStore.getState().loadTemplate(i); + } + }, + })), + { + label: "Delete Template...", + submenu: templates.map((t, i) => ({ + label: t.name, + onClick: () => { + if (confirm(`Delete template "${t.name}"?`)) { + useDAWStore.getState().deleteTemplate(i); + } + }, + })), + }, + ]; + })(), dividerAfter: true, }, { @@ -214,6 +280,14 @@ export function MenuBar() { if (success) console.log("Project loaded in Safe Mode (FX bypassed)"); }); }, + }, + { + label: "Archive Session...", + onClick: () => { useDAWStore.getState().archiveSession(); }, + }, + { + label: "Media Pool", + onClick: () => useDAWStore.getState().toggleMediaPool(), dividerAfter: true, }, { @@ -300,6 +374,23 @@ export function MenuBar() { label: "Toolbar Editor...", onClick: () => useDAWStore.getState().toggleToolbarEditor(), }, + { + label: "Metering", + submenu: [ + { + label: "Loudness Meter (LUFS)", + onClick: () => useDAWStore.getState().toggleLoudnessMeter(), + }, + { + label: "Spectrum Analyzer", + onClick: () => useDAWStore.getState().toggleSpectrumAnalyzer(), + }, + { + label: "Phase Correlation", + onClick: () => useDAWStore.getState().togglePhaseCorrelation(), + }, + ], + }, { label: "Toolbars", submenu: (() => { @@ -537,6 +628,21 @@ export function MenuBar() { }, dividerAfter: true, }, + { + label: "New Bus/Group Track", + onClick: () => { + const { addTrack, tracks } = useDAWStore.getState(); + addTrack({ + id: crypto.randomUUID(), + name: `Bus ${tracks.filter((t: any) => t.type === "bus").length + 1}`, + type: "bus", + }); + }, + }, + { + label: "Create Bus from Selected Tracks", + onClick: () => useDAWStore.getState().createBusFromSelectedTracks(), + }, { label: "Insert Multiple Tracks...", onClick: () => { diff --git a/frontend/src/components/MetronomeSettings.tsx b/frontend/src/components/MetronomeSettings.tsx index 7ac34a1..08a3f15 100644 --- a/frontend/src/components/MetronomeSettings.tsx +++ b/frontend/src/components/MetronomeSettings.tsx @@ -1,6 +1,7 @@ import { createPortal } from "react-dom"; import { X } from "lucide-react"; import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/shallow"; import classNames from "classnames"; import { Button, TimeSignatureInput, Slider } from "./ui"; @@ -24,7 +25,21 @@ export function MetronomeSettings({ isOpen, onClose }: MetronomeSettingsProps) { removeMetronomeTrack, metronomeClickPath, metronomeAccentPath, - } = useDAWStore(); + } = useDAWStore(useShallow((s) => ({ + timeSignature: s.timeSignature, + setTimeSignature: s.setTimeSignature, + metronomeAccentBeats: s.metronomeAccentBeats, + setMetronomeAccentBeats: s.setMetronomeAccentBeats, + metronomeEnabled: s.metronomeEnabled, + toggleMetronome: s.toggleMetronome, + metronomeVolume: s.metronomeVolume, + setMetronomeVolume: s.setMetronomeVolume, + metronomeTrackId: s.metronomeTrackId, + generateMetronomeTrack: s.generateMetronomeTrack, + removeMetronomeTrack: s.removeMetronomeTrack, + metronomeClickPath: s.metronomeClickPath, + metronomeAccentPath: s.metronomeAccentPath, + }))); if (!isOpen) return null; diff --git a/frontend/src/components/MissingMediaResolver.tsx b/frontend/src/components/MissingMediaResolver.tsx new file mode 100644 index 0000000..7dce27f --- /dev/null +++ b/frontend/src/components/MissingMediaResolver.tsx @@ -0,0 +1,200 @@ +import { useState } from "react"; +import { AlertTriangle, Search, FolderOpen, X, Check } from "lucide-react"; +import { nativeBridge } from "../services/NativeBridge"; +import { + Button, + Modal, + ModalHeader, + ModalContent, + ModalFooter, +} from "./ui"; + +interface MissingFile { + originalPath: string; + clipIds: string[]; + resolved: boolean; + newPath?: string; +} + +interface MissingMediaResolverProps { + isOpen: boolean; + onClose: () => void; + missingFiles: Array<{ path: string; clipIds: string[] }>; + onResolve: (originalPath: string, newPath: string) => void; + onResolveAll: () => void; +} + +/** + * MissingMediaResolver (Sprint 20.6) + * Detects missing audio files on project load and provides + * a dialog to locate or skip them. + */ +export function MissingMediaResolver({ + isOpen, + onClose, + missingFiles, + onResolve, + onResolveAll, +}: MissingMediaResolverProps) { + const [files, setFiles] = useState( + missingFiles.map((f) => ({ + originalPath: f.path, + clipIds: f.clipIds, + resolved: false, + })), + ); + const [searchDir, setSearchDir] = useState(""); + + const resolvedCount = files.filter((f) => f.resolved).length; + + const handleLocateFile = async (index: number) => { + try { + const result = await nativeBridge.browseForFile( + "Locate: " + getFileName(files[index].originalPath), + "Audio Files|*.wav;*.mp3;*.flac;*.aif;*.aiff;*.ogg", + ); + if (result) { + const updated = [...files]; + updated[index] = { ...updated[index], resolved: true, newPath: result }; + setFiles(updated); + onResolve(files[index].originalPath, result); + } + } catch { + // User cancelled + } + }; + + const handleSearchDirectory = async () => { + try { + const dir = await nativeBridge.browseForFolder("Search in folder"); + if (!dir) return; + setSearchDir(dir); + + // Try to find each missing file by name in the selected directory + const updated = [...files]; + for (let i = 0; i < updated.length; i++) { + if (updated[i].resolved) continue; + const fileName = getFileName(updated[i].originalPath); + // Check if file exists in the selected directory + try { + const found = await nativeBridge.fileExists(dir + "/" + fileName); + if (found) { + const newPath = dir + "/" + fileName; + updated[i] = { ...updated[i], resolved: true, newPath }; + onResolve(updated[i].originalPath, newPath); + } + } catch { + // File not found in dir + } + } + setFiles(updated); + } catch { + // User cancelled + } + }; + + const handleSkipAll = () => { + onClose(); + }; + + const handleDone = () => { + onResolveAll(); + onClose(); + }; + + return ( + + + +
+ {/* Warning banner */} +
+ + + {files.length} media file{files.length !== 1 ? "s" : ""} could + not be found. Locate them to restore playback. + +
+ + {/* Search in directory */} +
+ + {searchDir && ( + + Searched: {searchDir} + + )} +
+ + {/* File list */} +
+ {files.map((file, index) => ( +
+ {file.resolved ? ( + + ) : ( + + )} +
+

+ {getFileName(file.originalPath)} +

+

+ {file.originalPath} +

+ {file.newPath && ( +

+ → {file.newPath} +

+ )} +
+ + {file.clipIds.length} clip{file.clipIds.length !== 1 ? "s" : ""} + + {!file.resolved && ( + + )} +
+ ))} +
+ + {/* Status */} +

+ {resolvedCount} of {files.length} resolved +

+
+
+ + + + +
+ ); +} + +function getFileName(path: string): string { + return path.replace(/\\/g, "/").split("/").pop() || path; +} diff --git a/frontend/src/components/MixerPanel.tsx b/frontend/src/components/MixerPanel.tsx index 52eda79..e8db440 100644 --- a/frontend/src/components/MixerPanel.tsx +++ b/frontend/src/components/MixerPanel.tsx @@ -13,19 +13,24 @@ import { horizontalListSortingStrategy, } from "@dnd-kit/sortable"; -import { X } from "lucide-react"; +import { X, Save, Trash2, ExternalLink, ArrowDownToLine } from "lucide-react"; import { ChannelStrip } from "./ChannelStrip"; import { SortableTrack } from "./SortableTrack"; -import { useDAWStore, Track } from "../store/useDAWStore"; +import { DetachablePanel } from "./DetachablePanel"; +import { useDAWStore, Track, MixerSnapshot } from "../store/useDAWStore"; import { useShallow } from "zustand/react/shallow"; import { Button } from "./ui"; +import { useCallback } from "react"; interface MixerPanelProps { isVisible: boolean; + isDetached?: boolean; + onDetach?: () => void; + onAttach?: () => void; onClose?: () => void; } -export function MixerPanel({ isVisible, onClose }: MixerPanelProps) { +export function MixerPanel({ isVisible, isDetached = false, onDetach, onAttach, onClose }: MixerPanelProps) { const { tracks, masterVolume, @@ -37,6 +42,10 @@ export function MixerPanel({ isVisible, onClose }: MixerPanelProps) { selectedTrackIds, selectTrack, deselectAllTracks, + mixerSnapshots, + saveMixerSnapshot, + recallMixerSnapshot, + deleteMixerSnapshot, } = useDAWStore(useShallow((s) => ({ tracks: s.tracks, masterVolume: s.masterVolume, @@ -48,8 +57,15 @@ export function MixerPanel({ isVisible, onClose }: MixerPanelProps) { selectedTrackIds: s.selectedTrackIds, selectTrack: s.selectTrack, deselectAllTracks: s.deselectAllTracks, + mixerSnapshots: s.mixerSnapshots, + saveMixerSnapshot: s.saveMixerSnapshot, + recallMixerSnapshot: s.recallMixerSnapshot, + deleteMixerSnapshot: s.deleteMixerSnapshot, }))); + const handleDetach = useCallback(() => onDetach?.(), [onDetach]); + const handleAttach = useCallback(() => onAttach?.(), [onAttach]); + const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { @@ -65,8 +81,6 @@ export function MixerPanel({ isVisible, onClose }: MixerPanelProps) { } }; - if (!isVisible) return null; - const masterVolumeDB = masterVolume > 0 ? 20 * Math.log10(masterVolume) : -60; // Create a virtual master track for the ChannelStrip component @@ -82,6 +96,7 @@ export function MixerPanel({ isVisible, onClose }: MixerPanelProps) { muted: false, soloed: false, armed: false, + recordSafe: false, monitorEnabled: false, inputChannel: null, inputStartChannel: 0, @@ -102,34 +117,101 @@ export function MixerPanel({ isVisible, onClose }: MixerPanelProps) { sends: [], }; - return ( -
+ const mixerContent = ( +
{/* Header */} -
+
Mixer - + ) : ( + + )} + {!isDetached && ( + + )} +
+
+ + {/* Mixer Snapshots Toolbar */} +
+ + Snapshots + + {mixerSnapshots.map((snap: MixerSnapshot, idx: number) => ( +
+ + +
+ ))} + + + Save +
- {/* Channel Strips Container — click empty space to deselect */} + {/* Channel Strips Container */}
{ if (e.target === e.currentTarget) deselectAllTracks(); }} > - {/* Master Channel - Fixed position */} - - {/* Track Channels - Sortable */} +
); + + return ( + + {mixerContent} + + ); } diff --git a/frontend/src/components/PeakMeter.tsx b/frontend/src/components/PeakMeter.tsx index ba578a5..422b917 100644 --- a/frontend/src/components/PeakMeter.tsx +++ b/frontend/src/components/PeakMeter.tsx @@ -19,6 +19,15 @@ export function PeakMeter({ const [containerHeight, setContainerHeight] = useState(height || 140); const resizeObserverRef = useRef(null); + // Peak hold with 3 dB/sec decay + const peakHoldLevelRef = useRef(0); + const peakHoldTimerRef = useRef(0); // timestamp when peak was captured + const animFrameRef = useRef(null); + const lastDrawTimeRef = useRef(0); + + // RMS simulation via exponential smoothing of squared peak values + const rmsSmoothedRef = useRef(0); + // Observe container size changes const containerCallbackRef = useCallback((node: HTMLDivElement | null) => { resizeObserverRef.current?.disconnect(); @@ -39,89 +48,181 @@ export function PeakMeter({ const canvasHeight = height || containerHeight; + // Refs for latest values (avoid stale closures in animation loop) + const levelRef = useRef(level); + levelRef.current = level; + const peakHoldPropRef = useRef(peakHold); + peakHoldPropRef.current = peakHold; + const stereoRef = useRef(stereo); + stereoRef.current = stereo; + const clippingRef = useRef(clipping); + clippingRef.current = clipping; + const canvasHeightRef = useRef(canvasHeight); + canvasHeightRef.current = canvasHeight; + + // Create gradient once and cache it + const gradientCacheRef = useRef<{ gradient: CanvasGradient; height: number; ctxId: CanvasRenderingContext2D } | null>(null); + + const getGradient = useCallback((ctx: CanvasRenderingContext2D, h: number): CanvasGradient => { + const cached = gradientCacheRef.current; + if (cached && cached.height === h && cached.ctxId === ctx) { + return cached.gradient; + } + const grad = ctx.createLinearGradient(0, h, 0, 0); + grad.addColorStop(0, "#16a34a"); // Green 600 at bottom + grad.addColorStop(0.5, "#22c55e"); // Green 500 + grad.addColorStop(0.7, "#4ade80"); // Green 400 + grad.addColorStop(0.85, "#facc15"); // Yellow 400 + grad.addColorStop(0.92, "#f97316"); // Orange 500 + grad.addColorStop(1.0, "#ef4444"); // Red 500 at top + gradientCacheRef.current = { gradient: grad, height: h, ctxId: ctx }; + return grad; + }, []); + + // Animation loop for smooth peak hold decay useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const width = canvas.width; - const channelWidth = stereo ? (width - 1) / 2 : width; - - // Clear canvas - ctx.fillStyle = "#0a0a0a"; - ctx.fillRect(0, 0, width, canvasHeight); - - // Apply noise floor - levels below this are treated as silence - const NOISE_FLOOR = 0.001; // ~-60dB - const clampedLevel = level < NOISE_FLOOR ? 0 : level; - - // Convert RMS to dB with proper zero handling - const dbLevel = - clampedLevel > 0 ? 20 * Math.log10(clampedLevel) : -Infinity; - const normalizedLevel = - dbLevel === -Infinity ? 0 : Math.max(0, Math.min(1, (dbLevel + 60) / 72)); // -60dB to +12dB - - // Draw meter for left channel - const drawChannel = (x: number, w: number, lv: number) => { - const h = canvasHeight * lv; - - // Background segments (LED style) - const segHeight = 2; - const gap = 1; - for (let y = canvasHeight; y > 0; y -= segHeight + gap) { - const segY = y - segHeight; - if (segY >= canvasHeight - h) { - // Lit segment - const ratio = 1 - segY / canvasHeight; - if (ratio < 0.7) { - ctx.fillStyle = "#16a34a"; // Green 600 - } else if (ratio < 0.85) { - ctx.fillStyle = "#4ade80"; // Green 400 - } else if (ratio < 0.92) { - ctx.fillStyle = "#facc15"; // Yellow 400 + const draw = (timestamp: number) => { + const canvas = canvasRef.current; + if (!canvas) { animFrameRef.current = requestAnimationFrame(draw); return; } + const ctx = canvas.getContext("2d"); + if (!ctx) { animFrameRef.current = requestAnimationFrame(draw); return; } + + const currentLevel = levelRef.current; + const currentPeakHoldProp = peakHoldPropRef.current; + const isStereo = stereoRef.current; + const isClipping = clippingRef.current; + const ch = canvasHeightRef.current; + const width = canvas.width; + const channelWidth = isStereo ? (width - 1) / 2 : width; + + // Throttle to ~30fps to reduce CPU + if (timestamp - lastDrawTimeRef.current < 33) { + animFrameRef.current = requestAnimationFrame(draw); + return; + } + lastDrawTimeRef.current = timestamp; + + // Update peak hold with decay + const NOISE_FLOOR = 0.001; + const clampedLevel = currentLevel < NOISE_FLOOR ? 0 : currentLevel; + const dbLevel = clampedLevel > 0 ? 20 * Math.log10(clampedLevel) : -Infinity; + const normalizedLevel = dbLevel === -Infinity ? 0 : Math.max(0, Math.min(1, (dbLevel + 60) / 72)); + + // RMS simulation: exponential smoothing of squared normalized level + const smoothFactor = 0.3; + const squared = normalizedLevel * normalizedLevel; + rmsSmoothedRef.current = rmsSmoothedRef.current * (1 - smoothFactor) + squared * smoothFactor; + const rmsLevel = Math.sqrt(rmsSmoothedRef.current); + + // Use prop peakHold if provided, otherwise compute our own + let peakDisplay: number; + if (currentPeakHoldProp > 0) { + // External peak hold + const peakDb = 20 * Math.log10(currentPeakHoldProp); + peakDisplay = Math.max(0, Math.min(1, (peakDb + 60) / 72)); + } else { + peakDisplay = 0; + } + + // Internal peak hold with 3 dB/sec decay + if (normalizedLevel > peakHoldLevelRef.current) { + peakHoldLevelRef.current = normalizedLevel; + peakHoldTimerRef.current = timestamp; + } else { + // Decay: 3dB/sec in normalized space ~ 3/72 per second + const elapsed = (timestamp - peakHoldTimerRef.current) / 1000; + if (elapsed > 1.5) { // Hold for 1.5 seconds before decay + const decay = (elapsed - 1.5) * (3 / 72); + peakHoldLevelRef.current = Math.max(0, peakHoldLevelRef.current - decay); + } + } + + // Use whichever peak is higher + const effectivePeak = Math.max(peakDisplay, peakHoldLevelRef.current); + + // Clear canvas + ctx.fillStyle = "#0a0a0a"; + ctx.fillRect(0, 0, width, ch); + + const meterGradient = getGradient(ctx, ch); + + // Draw meter channel with gradient fill (RMS = dimmer bar, peak = bright bar on top) + const drawChannel = (cx: number, w: number, peakLv: number, rmsLv: number) => { + const peakH = ch * peakLv; + const rmsH = ch * rmsLv; + + // LED-style segments with gradient + const segHeight = 2; + const gap = 1; + for (let y = ch; y > 0; y -= segHeight + gap) { + const segY = y - segHeight; + if (segY >= ch - peakH) { + // Peak: fully lit segment + ctx.fillStyle = meterGradient; + ctx.globalAlpha = 1; + } else if (segY >= ch - rmsH) { + // RMS only zone: dimmer gradient + ctx.fillStyle = meterGradient; + ctx.globalAlpha = 0.35; } else { - ctx.fillStyle = "#ef4444"; // Red 500 + // Unlit segment + ctx.fillStyle = "#171717"; + ctx.globalAlpha = 1; } - } else { - // Unlit segment - ctx.fillStyle = "#171717"; // Neutral 900 + ctx.fillRect(cx, segY, w, segHeight); } - ctx.fillRect(x, segY, w, segHeight); + ctx.globalAlpha = 1; + }; + + if (isStereo) { + const leftLevel = normalizedLevel * 0.97; + const rightLevel = normalizedLevel * 1.03; + const leftRms = rmsLevel * 0.97; + const rightRms = rmsLevel * 1.03; + drawChannel(0, channelWidth, leftLevel, leftRms); + drawChannel(channelWidth + 1, channelWidth, rightLevel, rightRms); + + // Center divider + ctx.fillStyle = "#0a0a0a"; + ctx.fillRect(channelWidth, 0, 1, ch); + } else { + drawChannel(0, width, normalizedLevel, rmsLevel); } - }; - if (stereo) { - // Slight deterministic L/R offset for visual interest (no Math.random() — - // random values differ every render, forcing unnecessary canvas redraws) - const leftLevel = normalizedLevel * 0.97; - const rightLevel = normalizedLevel * 1.03; - drawChannel(0, channelWidth, leftLevel); - drawChannel(channelWidth + 1, channelWidth, rightLevel); + // Peak hold indicator line (white/bright line) + if (effectivePeak > 0.01) { + const peakY = ch - ch * effectivePeak; + // Determine peak color based on level + let peakColor: string; + if (effectivePeak > 0.92) peakColor = "#ef4444"; + else if (effectivePeak > 0.85) peakColor = "#f97316"; + else peakColor = "#ffffff"; + + ctx.fillStyle = peakColor; + if (isStereo) { + ctx.fillRect(0, peakY, channelWidth, 2); + ctx.fillRect(channelWidth + 1, peakY, channelWidth, 2); + } else { + ctx.fillRect(0, peakY, width, 2); + } + } - // Center divider - ctx.fillStyle = "#0a0a0a"; - ctx.fillRect(channelWidth, 0, 1, canvasHeight); - } else { - drawChannel(0, width, normalizedLevel); - } + // Clip indicator - red block at top if clipping + if (isClipping) { + ctx.fillStyle = "#dc2626"; + ctx.fillRect(0, 0, width, 3); + } - // Peak hold indicator - if (peakHold > 0) { - const peakDb = 20 * Math.log10(peakHold); - const normalizedPeak = Math.max(0, Math.min(1, (peakDb + 60) / 72)); - const peakY = canvasHeight - canvasHeight * normalizedPeak; - ctx.fillStyle = "#f97316"; // Orange 500 - ctx.fillRect(0, peakY, width, 2); - } + animFrameRef.current = requestAnimationFrame(draw); + }; - // Clip indicator - red line at top if clipping - if (clipping) { - ctx.fillStyle = "#dc2626"; // Red 600 - ctx.fillRect(0, 0, width, 3); - } - }, [level, peakHold, canvasHeight, stereo, clipping]); + animFrameRef.current = requestAnimationFrame(draw); + return () => { + if (animFrameRef.current !== null) { + cancelAnimationFrame(animFrameRef.current); + } + }; + }, [getGradient]); return (
diff --git a/frontend/src/components/PhaseCorrelationMeter.tsx b/frontend/src/components/PhaseCorrelationMeter.tsx new file mode 100644 index 0000000..6b153c1 --- /dev/null +++ b/frontend/src/components/PhaseCorrelationMeter.tsx @@ -0,0 +1,133 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { nativeBridge } from "../services/NativeBridge"; + +interface PhaseCorrelationMeterProps { + width?: number; + height?: number; +} + +/** + * PhaseCorrelationMeter (Sprint 20.10) + * Displays stereo phase correlation as a horizontal bar. + * Range: -1 (out of phase) to +1 (in phase / mono). + * 0 = no correlation (stereo). Values below 0 indicate phase issues. + */ +export function PhaseCorrelationMeter({ + width = 200, + height = 24, +}: PhaseCorrelationMeterProps) { + const [correlation, setCorrelation] = useState(0); + const intervalRef = useRef>(undefined); + const canvasRef = useRef(null); + + const fetchCorrelation = useCallback(async () => { + try { + const val = await nativeBridge.getPhaseCorrelation(); + if (typeof val === "number" && isFinite(val)) { + setCorrelation(val); + } + } catch { + // Backend not available + } + }, []); + + useEffect(() => { + intervalRef.current = setInterval(fetchCorrelation, 100); + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [fetchCorrelation]); + + // Draw the meter + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const w = canvas.width; + const h = canvas.height; + const dpr = window.devicePixelRatio || 1; + + ctx.clearRect(0, 0, w, h); + + // Background + ctx.fillStyle = "#1a1a1a"; + ctx.fillRect(0, 0, w, h); + + // Center line + const centerX = w / 2; + ctx.strokeStyle = "#444"; + ctx.lineWidth = dpr; + ctx.beginPath(); + ctx.moveTo(centerX, 0); + ctx.lineTo(centerX, h); + ctx.stroke(); + + // Tick marks + ctx.fillStyle = "#555"; + ctx.font = `${8 * dpr}px monospace`; + ctx.textAlign = "center"; + // -1 mark + ctx.fillText("-1", 10 * dpr, h - 2 * dpr); + // 0 mark + ctx.fillText("0", centerX, h - 2 * dpr); + // +1 mark + ctx.fillText("+1", w - 10 * dpr, h - 2 * dpr); + + // Indicator bar + const barY = 2 * dpr; + const barH = h - 14 * dpr; + // Map correlation (-1 to +1) to pixel position + const normalized = (correlation + 1) / 2; // 0 to 1 + const indicatorX = normalized * (w - 4 * dpr) + 2 * dpr; + + // Draw gradient bar from center to indicator + const gradStart = centerX; + const gradEnd = indicatorX; + + if (correlation >= 0) { + // Green for positive correlation + const grad = ctx.createLinearGradient(gradStart, 0, gradEnd, 0); + grad.addColorStop(0, "#22c55e80"); + grad.addColorStop(1, "#22c55e"); + ctx.fillStyle = grad; + ctx.fillRect(gradStart, barY, gradEnd - gradStart, barH); + } else { + // Red for negative correlation + const grad = ctx.createLinearGradient(gradEnd, 0, gradStart, 0); + grad.addColorStop(0, "#ef4444"); + grad.addColorStop(1, "#ef444480"); + ctx.fillStyle = grad; + ctx.fillRect(gradEnd, barY, gradStart - gradEnd, barH); + } + + // Indicator line + ctx.strokeStyle = "#fff"; + ctx.lineWidth = 2 * dpr; + ctx.beginPath(); + ctx.moveTo(indicatorX, barY); + ctx.lineTo(indicatorX, barY + barH); + ctx.stroke(); + }, [correlation]); + + const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1; + + return ( +
+
+ Phase +
+ +
+ {correlation.toFixed(2)} +
+
+ ); +} diff --git a/frontend/src/components/PianoRoll.css b/frontend/src/components/PianoRoll.css index f362ad1..30f6952 100644 --- a/frontend/src/components/PianoRoll.css +++ b/frontend/src/components/PianoRoll.css @@ -87,3 +87,49 @@ color: #666; 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; + font-size: 11px; + cursor: pointer; + outline: none; + min-width: 60px; +} + +.piano-roll-select:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.25); +} + +.piano-roll-select:focus { + border-color: #4cc9f0; +} + +.piano-roll-select option { + background: #222; + color: #ccc; +} + +/* Transform dropdown menu items */ +.piano-roll-transform-item { + display: block; + width: 100%; + padding: 6px 12px; + background: none; + border: none; + color: #ccc; + font-size: 12px; + text-align: left; + cursor: pointer; + white-space: nowrap; +} + +.piano-roll-transform-item:hover { + background: rgba(76, 201, 240, 0.15); + color: #fff; +} diff --git a/frontend/src/components/PianoRoll.tsx b/frontend/src/components/PianoRoll.tsx index da3b655..4b333ad 100644 --- a/frontend/src/components/PianoRoll.tsx +++ b/frontend/src/components/PianoRoll.tsx @@ -1,20 +1,54 @@ -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useCallback, useMemo } from "react"; import { Stage, Layer, Rect, Line, Text, Group } from "react-konva"; -import { useDAWStore, MIDIEvent } from "../store/useDAWStore"; +import { useDAWStore, MIDIEvent, MIDICCEvent } from "../store/useDAWStore"; +import { useShallow } from "zustand/react/shallow"; import { Button } from "./ui"; import "./PianoRoll.css"; interface PianoRollProps { - clipId: string; - trackId: string; + readonly clipId: string; + readonly trackId: string; + /** Additional MIDI clip IDs to show simultaneously (multi-clip editing) */ + readonly additionalClipIds?: string[]; } +// Per-clip color tints for multi-clip editing (hue shifts) +const MULTI_CLIP_TINTS = [ + null, // Primary clip: use default velocity colors + "#ff6b9d", // Pink + "#51cf66", // Green + "#ffd43b", // Yellow + "#748ffc", // Indigo + "#f06595", // Hot pink + "#20c997", // Teal + "#ff922b", // Orange +]; + +// Step size options for step input mode +const STEP_SIZE_OPTIONS = [ + { label: "1/4", beats: 1 }, + { label: "1/8", beats: 0.5 }, + { label: "1/16", beats: 0.25 }, + { label: "1/32", beats: 0.125 }, +]; + +// Key-to-note mapping for step input (C major by default, octave set separately) +const KEY_TO_NOTE: Record = { + c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11, +}; + const NOTES_PER_OCTAVE = 12; const TOTAL_NOTES = 128; // MIDI range 0-127 const NOTE_HEIGHT = 12; const PIANO_WIDTH = 60; const GRID_SNAP = 0.25; // 1/16 note +// Lane heights +const VELOCITY_LANE_HEIGHT = 60; +const CC_LANE_HEIGHT = 80; +const LANE_DIVIDER_HEIGHT = 1; +const TOOLBAR_HEIGHT = 40; + const NOTE_NAMES = [ "C", "C#", @@ -30,19 +64,217 @@ const NOTE_NAMES = [ "B", ]; -export function PianoRoll({ clipId, trackId }: PianoRollProps) { +// ============================================ +// Scale Definitions +// ============================================ +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", +}; + +// CC lane presets +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" }, +]; + +/** Convert MIDI pitch number to note name string, e.g. 60 -> "C4" */ +function getNoteNameFromPitch(pitch: number): string { + const noteName = NOTE_NAMES[pitch % NOTES_PER_OCTAVE]; + const octave = Math.floor(pitch / 12) - 2; + return `${noteName}${octave}`; +} + +/** Velocity-based color: blue (quiet) -> cyan -> yellow -> red (loud) */ +function velocityColor(velocity: number): string { + const v = Math.max(0, Math.min(127, velocity)); + const t = v / 127; // 0..1 + let r: number, g: number, 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})`; +} + +/** Velocity-based stroke color (slightly darker version) */ +function velocityStrokeColor(velocity: number): string { + const v = Math.max(0, Math.min(127, velocity)); + const t = v / 127; + if (t < 0.5) return "#3b82f6"; + if (t < 0.75) return "#22c55e"; + return "#ef4444"; +} + +/** Check if a MIDI note number is in the given scale */ +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); +} + +// ============================================ +// Parsed note pair: noteOn + noteOff matched +// ============================================ +interface NotePair { + noteOn: MIDIEvent; + noteOff: MIDIEvent; + noteNumber: number; + velocity: number; + startTime: number; + duration: number; + pitchBend?: number; + pressure?: number; + slide?: number; +} + +/** Extended note pair with clip ownership info for multi-clip editing */ +interface MultiClipNotePair extends NotePair { + clipId: string; + clipIndex: number; // 1-based index into additionalClips (0 = primary) + timeOffset: number; // time offset relative to primary clip +} + +export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRollProps) { const containerRef = useRef(null); const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); const [tool, setTool] = useState<"draw" | "select" | "erase">("draw"); const [zoom, setZoom] = useState(100); // pixels per beat const [scrollX, setScrollX] = useState(0); - const [scrollY, setScrollY] = useState(TOTAL_NOTES * NOTE_HEIGHT / 2 - 300); // Center around middle C + const [scrollY, setScrollY] = useState(TOTAL_NOTES * NOTE_HEIGHT / 2 - 300); + + // Step input: octave for keyboard input + const [stepInputOctave, setStepInputOctave] = useState(4); // C4 = middle C + + // Velocity editing state + const [velocityEditingNote, setVelocityEditingNote] = useState<{ timestamp: number; note: number } | null>(null); - const track = useDAWStore((state) => - state.tracks.find((t) => t.id === trackId), + // CC lane state + const [selectedCC, setSelectedCC] = useState(1); // CC#1 Modulation by default + const [ccDrawing, setCCDrawing] = useState(false); + + // Store selectors — consolidated into useShallow to prevent unnecessary re-renders + const { + track, tempo, scaleRoot, scaleType, + stepInputEnabled, stepInputSize, stepInputPosition, + } = useDAWStore( + useShallow((state) => ({ + track: state.tracks.find((t) => t.id === trackId), + tempo: state.transport.tempo, + scaleRoot: state.pianoRollScaleRoot, + scaleType: state.pianoRollScaleType, + stepInputEnabled: state.stepInputEnabled, + stepInputSize: state.stepInputSize, + stepInputPosition: state.stepInputPosition, + })), ); + + // Step input actions + const toggleStepInput = useDAWStore((s) => s.toggleStepInput); + const setStepInputSize = useDAWStore((s) => s.setStepInputSize); + const advanceStepInput = useDAWStore((s) => s.advanceStepInput); + const setStepInputPosition = useDAWStore((s) => s.setStepInputPosition); + const clip = track?.midiClips.find((c) => c.id === clipId); - const tempo = useDAWStore((state) => state.transport.tempo); + const clipEvents = clip?.events; + const clipCCEvents = clip?.ccEvents; + const clipDuration = clip?.duration ?? 0; + const clipStartTime = clip?.startTime ?? 0; + + // Multi-clip editing: gather additional clips from the same track + const additionalClips = useMemo(() => { + if (!track || additionalClipIds.length === 0) return []; + return additionalClipIds + .map((id) => track.midiClips.find((c) => c.id === id)) + .filter((c): c is NonNullable => c != null); + }, [track, additionalClipIds]); + + // Transform dropdown state + const [showTransformMenu, setShowTransformMenu] = useState(false); + const transformMenuRef = useRef(null); + + // Close transform menu on outside click + useEffect(() => { + if (!showTransformMenu) return; + const handleClickOutside = (e: MouseEvent) => { + if (transformMenuRef.current && !transformMenuRef.current.contains(e.target as Node)) { + setShowTransformMenu(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showTransformMenu]); + + // Store actions — consolidated into useShallow to reduce subscription overhead + const { + updateMIDINoteVelocity, + updateMIDICCEvents, + setPianoRollScaleRoot, + setPianoRollScaleType, + transposeMIDINotes, + scaleMIDINoteVelocity, + reverseMIDINotes, + invertMIDINotes, + } = useDAWStore( + useShallow((s) => ({ + updateMIDINoteVelocity: s.updateMIDINoteVelocity, + updateMIDICCEvents: s.updateMIDICCEvents, + setPianoRollScaleRoot: s.setPianoRollScaleRoot, + setPianoRollScaleType: s.setPianoRollScaleType, + transposeMIDINotes: s.transposeMIDINotes, + scaleMIDINoteVelocity: s.scaleMIDINoteVelocity, + reverseMIDINotes: s.reverseMIDINotes, + invertMIDINotes: s.invertMIDINotes, + })), + ); + + // Derived values (computed before hooks that depend on them) + const beatsPerSecond = tempo / 60; + const pixelsPerSecond = zoom * beatsPerSecond; + const bottomLanesHeight = VELOCITY_LANE_HEIGHT + LANE_DIVIDER_HEIGHT + CC_LANE_HEIGHT + LANE_DIVIDER_HEIGHT; + const stageHeight = dimensions.height - TOOLBAR_HEIGHT; + const noteGridHeight = stageHeight - bottomLanesHeight; + const velocityLaneY = noteGridHeight; + const ccLaneY = velocityLaneY + VELOCITY_LANE_HEIGHT + LANE_DIVIDER_HEIGHT; useEffect(() => { const updateDimensions = () => { @@ -67,11 +299,9 @@ export function PianoRoll({ clipId, trackId }: PianoRollProps) { const handleWheel = (e: WheelEvent) => { e.preventDefault(); if (e.shiftKey) { - // Horizontal scroll setScrollX((prev) => Math.max(0, prev + e.deltaY)); } else { - // Vertical scroll - const maxScrollY = TOTAL_NOTES * NOTE_HEIGHT - (dimensions.height - 40); + const maxScrollY = TOTAL_NOTES * NOTE_HEIGHT - (dimensions.height - TOOLBAR_HEIGHT); setScrollY((prev) => Math.max(0, Math.min(maxScrollY, prev + e.deltaY))); } }; @@ -80,13 +310,290 @@ export function PianoRoll({ clipId, trackId }: PianoRollProps) { return () => container.removeEventListener("wheel", handleWheel); }, [dimensions.height]); + // Step input keyboard handler + useEffect(() => { + if (!stepInputEnabled) return; + + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore when typing in inputs/selects + const tag = (e.target as HTMLElement).tagName; + if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return; + + const key = e.key.toLowerCase(); + + // Octave shift with up/down arrows + if (key === "arrowup") { + e.preventDefault(); + setStepInputOctave((prev) => Math.min(8, prev + 1)); + return; + } + if (key === "arrowdown") { + e.preventDefault(); + setStepInputOctave((prev) => Math.max(-2, prev - 1)); + return; + } + + // Left/Right arrows to manually move step cursor + if (key === "arrowleft") { + e.preventDefault(); + const bps = tempo / 60; + const stepSeconds = stepInputSize / bps; + setStepInputPosition(Math.max(0, stepInputPosition - stepSeconds)); + return; + } + if (key === "arrowright") { + e.preventDefault(); + advanceStepInput(); + return; + } + + // Note input: C, D, E, F, G, A, B keys + const semitone = KEY_TO_NOTE[key]; + if (semitone !== undefined) { + e.preventDefault(); + // Shift key adds sharp (+1 semitone) + const sharpOffset = e.shiftKey ? 1 : 0; + const noteNumber = (stepInputOctave + 2) * 12 + semitone + sharpOffset; + if (noteNumber < 0 || noteNumber > 127) return; + + const bps = tempo / 60; + const durationSeconds = stepInputSize / bps; + + // Add the note at stepInputPosition (direct setState to avoid closure issues) + const noteOnEvent: MIDIEvent = { timestamp: stepInputPosition, type: "noteOn", note: noteNumber, velocity: 80 }; + const noteOffEvent: MIDIEvent = { timestamp: stepInputPosition + durationSeconds, type: "noteOff", note: noteNumber, velocity: 0 }; + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((t) => + t.id === trackId + ? { + ...t, + midiClips: t.midiClips.map((c) => + c.id === clipId + ? { ...c, events: [...c.events, noteOnEvent, noteOffEvent].sort((a, b) => a.timestamp - b.timestamp) } + : c, + ), + } + : t, + ), + })); + + // Advance cursor + advanceStepInput(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [stepInputEnabled, stepInputOctave, stepInputSize, stepInputPosition, tempo, clipId, trackId]); + + // Parse noteOn/noteOff pairs (must be before early return) + const notePairs: NotePair[] = useMemo(() => { + if (!clipEvents) return []; + const pairs: NotePair[] = []; + const noteOns = clipEvents.filter((e) => e.type === "noteOn"); + for (const noteOn of noteOns) { + const noteOff = clipEvents.find( + (e) => + e.type === "noteOff" && + e.note === noteOn.note && + e.timestamp > noteOn.timestamp, + ); + if (!noteOff || noteOn.note === undefined) continue; + pairs.push({ + noteOn, + noteOff, + noteNumber: noteOn.note, + velocity: noteOn.velocity || 80, + startTime: noteOn.timestamp, + duration: noteOff.timestamp - noteOn.timestamp, + pitchBend: noteOn.pitchBend, + pressure: noteOn.pressure, + slide: noteOn.slide, + }); + } + return pairs; + }, [clipEvents]); + + // Parse note pairs for additional clips (multi-clip editing) + const additionalClipNotePairs: MultiClipNotePair[] = useMemo(() => { + if (additionalClips.length === 0) return []; + const allPairs: MultiClipNotePair[] = []; + for (let ci = 0; ci < additionalClips.length; ci++) { + const ac = additionalClips[ci]; + const timeOffset = ac.startTime - clipStartTime; + const noteOns = ac.events.filter((e) => e.type === "noteOn"); + for (const noteOn of noteOns) { + const noteOff = ac.events.find( + (e) => e.type === "noteOff" && e.note === noteOn.note && e.timestamp > noteOn.timestamp, + ); + if (!noteOff || noteOn.note === undefined) continue; + allPairs.push({ + noteOn, + noteOff, + noteNumber: noteOn.note, + velocity: noteOn.velocity || 80, + startTime: noteOn.timestamp + timeOffset, + duration: noteOff.timestamp - noteOn.timestamp, + clipId: ac.id, + clipIndex: ci + 1, + timeOffset, + }); + } + } + return allPairs; + }, [additionalClips, clipStartTime]); + + // Get CC events for the selected CC number (must be before early return) + const ccEventsForLane: MIDICCEvent[] = useMemo(() => { + if (!clipCCEvents) return []; + return clipCCEvents.filter((e) => e.cc === selectedCC); + }, [clipCCEvents, selectedCC]); + + // ============================================ + // Velocity Lane Mouse Handlers (must be before early return) + // ============================================ + const handleVelocityMouseDown = useCallback((e: any) => { + const stage = e.target.getStage(); + const pos = stage.getPointerPosition(); + if (!pos) return; + const y = pos.y; + + if (y < velocityLaneY || y >= velocityLaneY + VELOCITY_LANE_HEIGHT) return; + + const x = pos.x; + for (const pair of notePairs) { + const barX = PIANO_WIDTH + pair.startTime * pixelsPerSecond - scrollX; + const barWidth = Math.max(4, pair.duration * pixelsPerSecond); + if (x >= barX && x <= barX + barWidth) { + const relY = y - velocityLaneY; + const newVelocity = Math.round(127 * (1 - relY / VELOCITY_LANE_HEIGHT)); + const clamped = Math.max(1, Math.min(127, newVelocity)); + setVelocityEditingNote({ timestamp: pair.startTime, note: pair.noteNumber }); + updateMIDINoteVelocity(trackId, clipId, pair.startTime, pair.noteNumber, clamped); + return; + } + } + }, [notePairs, velocityLaneY, pixelsPerSecond, scrollX, trackId, clipId, updateMIDINoteVelocity]); + + const handleVelocityMouseMove = useCallback((e: any) => { + if (!velocityEditingNote) return; + const stage = e.target.getStage(); + const pos = stage.getPointerPosition(); + if (!pos) return; + const relY = pos.y - velocityLaneY; + const newVelocity = Math.round(127 * (1 - Math.max(0, Math.min(VELOCITY_LANE_HEIGHT, relY)) / VELOCITY_LANE_HEIGHT)); + const clamped = Math.max(1, Math.min(127, newVelocity)); + updateMIDINoteVelocity(trackId, clipId, velocityEditingNote.timestamp, velocityEditingNote.note, clamped); + }, [velocityEditingNote, velocityLaneY, trackId, clipId, updateMIDINoteVelocity]); + + const handleVelocityMouseUp = useCallback(() => { + setVelocityEditingNote(null); + }, []); + + // ============================================ + // CC Lane Mouse Handlers (must be before early return) + // ============================================ + const handleCCMouseDown = useCallback((e: any) => { + const stage = e.target.getStage(); + const pos = stage.getPointerPosition(); + if (!pos) return; + const y = pos.y; + + if (y < ccLaneY || y >= ccLaneY + CC_LANE_HEIGHT) return; + + setCCDrawing(true); + + const x = pos.x - PIANO_WIDTH + scrollX; + const time = Math.max(0, x / pixelsPerSecond); + const relY = y - ccLaneY; + const value = Math.round(127 * (1 - relY / CC_LANE_HEIGHT)); + const clamped = Math.max(0, Math.min(127, value)); + + const existingEvents = clipCCEvents || []; + const filtered = existingEvents.filter( + (ev) => !(ev.cc === selectedCC && Math.abs(ev.time - time) < 0.01), + ); + const newEvents = [...filtered, { cc: selectedCC, time, value: clamped }] + .sort((a, b) => a.time - b.time); + updateMIDICCEvents(trackId, clipId, newEvents); + }, [ccLaneY, scrollX, pixelsPerSecond, selectedCC, clipCCEvents, trackId, clipId, updateMIDICCEvents]); + + const handleCCMouseMove = useCallback((e: any) => { + if (!ccDrawing) return; + const stage = e.target.getStage(); + const pos = stage.getPointerPosition(); + if (!pos) return; + + const x = pos.x - PIANO_WIDTH + scrollX; + const time = Math.max(0, x / pixelsPerSecond); + const relY = pos.y - ccLaneY; + const value = Math.round(127 * (1 - Math.max(0, Math.min(CC_LANE_HEIGHT, relY)) / CC_LANE_HEIGHT)); + const clamped = Math.max(0, Math.min(127, value)); + + // Read current state directly for continuous drawing + const currentTrack = useDAWStore.getState().tracks.find((t) => t.id === trackId); + const currentClip = currentTrack?.midiClips.find((c) => c.id === clipId); + const existingEvents = currentClip?.ccEvents || []; + const filtered = existingEvents.filter( + (ev) => !(ev.cc === selectedCC && Math.abs(ev.time - time) < 0.02), + ); + const newEvents = [...filtered, { cc: selectedCC, time, value: clamped }] + .sort((a, b) => a.time - b.time); + // Direct setState to avoid pushing undo for every mousemove + useDAWStore.setState((s) => ({ + tracks: s.tracks.map((t) => + t.id === trackId + ? { ...t, midiClips: t.midiClips.map((c) => c.id === clipId ? { ...c, ccEvents: newEvents } : c) } + : t, + ), + })); + }, [ccDrawing, ccLaneY, scrollX, pixelsPerSecond, selectedCC, trackId, clipId]); + + const handleCCMouseUp = useCallback(() => { + if (ccDrawing) { + setCCDrawing(false); + // Push final undo on mouse up + const currentClip = useDAWStore.getState().tracks + .find((t) => t.id === trackId)?.midiClips.find((c) => c.id === clipId); + if (currentClip?.ccEvents) { + updateMIDICCEvents(trackId, clipId, currentClip.ccEvents); + } + } + }, [ccDrawing, trackId, clipId, updateMIDICCEvents]); + + // Combined stage mouse handlers (must be before early return) + const handleStageMouseDown = useCallback((e: any) => { + const stage = e.target.getStage(); + const pos = stage.getPointerPosition(); + if (!pos) return; + + if (pos.y >= ccLaneY && pos.y < ccLaneY + CC_LANE_HEIGHT) { + handleCCMouseDown(e); + } else if (pos.y >= velocityLaneY && pos.y < velocityLaneY + VELOCITY_LANE_HEIGHT) { + handleVelocityMouseDown(e); + } + }, [ccLaneY, velocityLaneY, handleCCMouseDown, handleVelocityMouseDown]); + + const handleStageMouseMove = useCallback((e: any) => { + if (velocityEditingNote) { + handleVelocityMouseMove(e); + } else if (ccDrawing) { + handleCCMouseMove(e); + } + }, [velocityEditingNote, ccDrawing, handleVelocityMouseMove, handleCCMouseMove]); + + const handleStageMouseUp = useCallback(() => { + handleVelocityMouseUp(); + handleCCMouseUp(); + }, [handleVelocityMouseUp, handleCCMouseUp]); + + // ============================================ + // Early return AFTER all hooks + // ============================================ if (!clip || !track) { return
No MIDI clip selected
; } - const beatsPerSecond = tempo / 60; - const pixelsPerSecond = zoom * beatsPerSecond; - const getNoteY = (noteNumber: number) => { return (TOTAL_NOTES - 1 - noteNumber) * NOTE_HEIGHT - scrollY; }; @@ -100,27 +607,10 @@ export function PianoRoll({ clipId, trackId }: PianoRollProps) { return Math.round(time / beatTime) * beatTime; }; - const handleStageClick = (e: any) => { - const stage = e.target.getStage(); - const pos = stage.getPointerPosition(); - const x = pos.x - PIANO_WIDTH + scrollX; - const y = pos.y; - - if (pos.x < PIANO_WIDTH) return; // Clicked on piano keyboard - - const time = snapTime(x / pixelsPerSecond); - const note = getNoteFromY(y); - - if (note < 0 || note >= TOTAL_NOTES) return; - - if (tool === "draw") { - addNote(time, note, 0.25, 80); // Default: 1/4 note, velocity 80 - } else if (tool === "erase") { - removeNoteAtPosition(time, note); - } - }; - - const addNote = ( + /** Add a note to a specific clip (used by both draw tool and step input) */ + const addNoteToClip = ( + targetClipId: string, + targetTrackId: string, time: number, note: number, duration: number, @@ -132,21 +622,19 @@ export function PianoRoll({ clipId, trackId }: PianoRollProps) { note, velocity, }; - const noteOffEvent: MIDIEvent = { timestamp: time + duration, type: "noteOff", note, velocity: 0, }; - useDAWStore.setState((state) => ({ tracks: state.tracks.map((t) => - t.id === trackId + t.id === targetTrackId ? { ...t, midiClips: t.midiClips.map((c) => - c.id === clipId + c.id === targetClipId ? { ...c, events: [...c.events, noteOnEvent, noteOffEvent].sort( @@ -161,14 +649,20 @@ export function PianoRoll({ clipId, trackId }: PianoRollProps) { })); }; - const removeNoteAtPosition = (time: number, note: number) => { + /** Remove a note from a specific clip */ + const removeNoteFromClip = ( + targetClipId: string, + targetTrackId: string, + time: number, + note: number, + ) => { useDAWStore.setState((state) => ({ tracks: state.tracks.map((t) => - t.id === trackId + t.id === targetTrackId ? { ...t, midiClips: t.midiClips.map((c) => - c.id === clipId + c.id === targetClipId ? { ...c, events: c.events.filter( @@ -187,6 +681,37 @@ export function PianoRoll({ clipId, trackId }: PianoRollProps) { })); }; + const handleStageClick = (e: any) => { + const stage = e.target.getStage(); + const pos = stage.getPointerPosition(); + const x = pos.x - PIANO_WIDTH + scrollX; + const y = pos.y; + + // Ignore clicks in velocity lane and CC lane (handled separately) + if (y >= velocityLaneY) return; + + if (pos.x < PIANO_WIDTH) return; + + const time = snapTime(x / pixelsPerSecond); + const note = getNoteFromY(y); + + if (note < 0 || note >= TOTAL_NOTES) return; + + if (tool === "draw") { + addNoteToClip(clipId, trackId, time, note, 0.25, 80); + } else if (tool === "erase") { + // Try to remove from primary clip first, then additional clips + removeNoteFromClip(clipId, trackId, time, note); + for (const ac of additionalClips) { + removeNoteFromClip(ac.id, trackId, time, note); + } + } + }; + + // ============================================ + // Render Functions + // ============================================ + const renderPianoKeyboard = () => { const keys = []; for (let i = 0; i < TOTAL_NOTES; i++) { @@ -196,7 +721,7 @@ export function PianoRoll({ clipId, trackId }: PianoRollProps) { const isC = noteName === "C"; keys.push( - + { - const lines = []; - const gridWidth = clip.duration * pixelsPerSecond; + const elements: React.ReactNode[] = []; + const gridWidth = clipDuration * pixelsPerSecond; + const beatInterval = 1 / beatsPerSecond; + + // Beat grid shading: alternate beats + let beatIndex = 0; + for (let t = 0; t < clipDuration; t += beatInterval) { + const bx = PIANO_WIDTH + t * pixelsPerSecond - scrollX; + const bw = beatInterval * pixelsPerSecond; + + if (bx + bw < PIANO_WIDTH || bx > dimensions.width) { + beatIndex++; + continue; + } + + if (beatIndex % 2 === 1) { + elements.push( + , + ); + } + beatIndex++; + } - // Horizontal lines (note rows) + // Horizontal lines (note rows) with scale highlighting for (let i = 0; i <= TOTAL_NOTES; i++) { const y = i * NOTE_HEIGHT - scrollY; - const noteName = NOTE_NAMES[(TOTAL_NOTES - 1 - i) % NOTES_PER_OCTAVE]; + const noteNumber = TOTAL_NOTES - 1 - i; + const noteName = NOTE_NAMES[noteNumber >= 0 ? noteNumber % NOTES_PER_OCTAVE : 0]; const isC = noteName === "C"; + const isBlackKey = noteName.includes("#"); + const inScale = noteNumber >= 0 && isNoteInScale(noteNumber, scaleRoot, scaleType); - lines.push( + // Scale highlighting: in-scale rows get a lighter tint + if (i < TOTAL_NOTES && scaleType !== "chromatic" && inScale) { + elements.push( + , + ); + } + + // Black key row background tint + if (isBlackKey && i < TOTAL_NOTES) { + elements.push( + , + ); + } + + elements.push( dimensions.width) continue; - lines.push( + elements.push( { + const ghostElements: React.ReactNode[] = []; + const editingClipIds = new Set([clipId, ...additionalClipIds]); + const otherClips = track.midiClips.filter((c) => !editingClipIds.has(c.id)); + + for (const otherClip of otherClips) { + const timeOffset = otherClip.startTime - clipStartTime; + const noteOns = otherClip.events.filter((e) => e.type === "noteOn"); + + for (const noteOn of noteOns) { + const noteOff = otherClip.events.find( + (e) => + e.type === "noteOff" && + e.note === noteOn.note && + e.timestamp > noteOn.timestamp, + ); + + if (!noteOff || noteOn.note === undefined) continue; + + const adjustedTime = noteOn.timestamp + timeOffset; + const x = PIANO_WIDTH + adjustedTime * pixelsPerSecond - scrollX; + const y = getNoteY(noteOn.note); + const width = (noteOff.timestamp - noteOn.timestamp) * pixelsPerSecond; + + if (x + width < PIANO_WIDTH || x > dimensions.width) continue; + + ghostElements.push( + , + ); + } + } + + return ghostElements; }; const renderNotes = () => { - const noteRects = []; - const noteOns = clip.events.filter((e) => e.type === "noteOn"); + const noteElements: React.ReactNode[] = []; - for (const noteOn of noteOns) { - const noteOff = clip.events.find( - (e) => - e.type === "noteOff" && - e.note === noteOn.note && - e.timestamp > noteOn.timestamp, + // Render primary clip notes + for (const pair of notePairs) { + const x = PIANO_WIDTH + pair.startTime * pixelsPerSecond - scrollX; + const y = getNoteY(pair.noteNumber); + const width = pair.duration * pixelsPerSecond; + + if (x + width < PIANO_WIDTH || x > dimensions.width) continue; + + const fillColor = velocityColor(pair.velocity); + const strokeColor = velocityStrokeColor(pair.velocity); + const hasPressure = pair.pressure !== undefined && pair.pressure > 0; + const hasPitchBend = pair.pitchBend !== undefined && pair.pitchBend !== 0; + const hasSlide = pair.slide !== undefined && pair.slide > 0; + + const noteLabel = getNoteNameFromPitch(pair.noteNumber); + const showName = width > 40; + + noteElements.push( + + + {hasPressure && ( + + )} + {hasPitchBend && width > 8 && ( + 0 + ? [x + width - 6, y + NOTE_HEIGHT - 4, x + width - 3, y + 2, x + width, y + NOTE_HEIGHT - 4] + : [x + width - 6, y + 2, x + width - 3, y + NOTE_HEIGHT - 4, x + width, y + 2] + } + fill="#ffffff" + closed + opacity={0.7} + listening={false} + /> + )} + {hasSlide && width > 8 && ( + + )} + {showName && ( + + )} + , ); + } - if (!noteOff || noteOn.note === undefined) continue; + // Render additional clip notes (multi-clip editing) with tinted colors + for (const pair of additionalClipNotePairs) { + const x = PIANO_WIDTH + pair.startTime * pixelsPerSecond - scrollX; + const y = getNoteY(pair.noteNumber); + const width = pair.duration * pixelsPerSecond; + + if (x + width < PIANO_WIDTH || x > dimensions.width) continue; + + const tintColor = MULTI_CLIP_TINTS[pair.clipIndex % MULTI_CLIP_TINTS.length] || "#ff6b9d"; + const noteLabel = getNoteNameFromPitch(pair.noteNumber); + const showName = width > 40; + + noteElements.push( + + + {showName && ( + + )} + , + ); + } - const x = PIANO_WIDTH + noteOn.timestamp * pixelsPerSecond - scrollX; - const y = getNoteY(noteOn.note); + return noteElements; + }; + + // Step input cursor line + const renderStepInputCursor = () => { + if (!stepInputEnabled) return null; + const cursorX = PIANO_WIDTH + stepInputPosition * pixelsPerSecond - scrollX; + if (cursorX < PIANO_WIDTH || cursorX > dimensions.width) return null; + + return ( + + {/* Vertical cursor line */} + + {/* Small triangle indicator at top */} + + + ); + }; + + // ============================================ + // Velocity Lane Rendering + // ============================================ + const renderVelocityLane = () => { + const elements: React.ReactNode[] = []; - // Skip if outside visible area - const noteWidth = (noteOff.timestamp - noteOn.timestamp) * pixelsPerSecond; - if (x + noteWidth < PIANO_WIDTH || x > dimensions.width) continue; - const width = (noteOff.timestamp - noteOn.timestamp) * pixelsPerSecond; - const velocity = noteOn.velocity || 80; - const opacity = velocity / 127; + // Background + divider + label + elements.push( + , + , + , + ); - noteRects.push( + // Horizontal guide lines at 25%, 50%, 75% + for (const frac of [0.25, 0.5, 0.75]) { + const gy = velocityLaneY + VELOCITY_LANE_HEIGHT * (1 - frac); + elements.push( + , + ); + } + + // Draw velocity bars for each note + for (const pair of notePairs) { + const barX = PIANO_WIDTH + pair.startTime * pixelsPerSecond - scrollX; + const barWidth = Math.max(4, pair.duration * pixelsPerSecond - 1); + + if (barX + barWidth < PIANO_WIDTH || barX > dimensions.width) continue; + + const velFrac = pair.velocity / 127; + const barHeight = velFrac * (VELOCITY_LANE_HEIGHT - 4); + const barY = velocityLaneY + VELOCITY_LANE_HEIGHT - barHeight - 2; + const color = velocityColor(pair.velocity); + + elements.push( , ); } - return noteRects; + // Clickable overlay for the whole velocity lane + elements.push( + , + ); + + return elements; + }; + + // ============================================ + // CC Lane Rendering + // ============================================ + const renderCCLane = () => { + const elements: React.ReactNode[] = []; + const ccPreset = CC_PRESETS.find((p) => p.cc === selectedCC); + + // Background + divider + label + elements.push( + , + , + , + ); + + // Horizontal guide lines + for (const frac of [0.25, 0.5, 0.75]) { + const gy = ccLaneY + CC_LANE_HEIGHT * (1 - frac); + elements.push( + , + ); + } + + // Draw CC events as vertical bars and connecting line + if (ccEventsForLane.length > 0) { + // Draw connecting line + const linePoints: number[] = []; + for (const ev of ccEventsForLane) { + const x = PIANO_WIDTH + ev.time * pixelsPerSecond - scrollX; + const y = ccLaneY + CC_LANE_HEIGHT * (1 - ev.value / 127); + linePoints.push(x, y); + } + if (linePoints.length >= 4) { + elements.push( + , + ); + } + + // Draw individual CC points + for (let i = 0; i < ccEventsForLane.length; i++) { + const ev = ccEventsForLane[i]; + const x = PIANO_WIDTH + ev.time * pixelsPerSecond - scrollX; + if (x < PIANO_WIDTH || x > dimensions.width) continue; + + const valFrac = ev.value / 127; + const barHeight = valFrac * (CC_LANE_HEIGHT - 4); + const barY = ccLaneY + CC_LANE_HEIGHT - barHeight - 2; + + // Vertical stem + dot + elements.push( + , + , + ); + } + } + + // Clickable overlay + elements.push( + , + ); + + return elements; }; return ( @@ -315,7 +1181,7 @@ export function PianoRoll({ clipId, trackId }: PianoRollProps) { onClick={() => setTool("draw")} title="Draw Tool (D)" > - ✏️ Draw + Draw
- + setZoom(parseInt(e.target.value))} + onChange={(e) => setZoom(Number.parseInt(e.target.value))} className="zoom-slider" /> +
+ + {/* Scale Selector */} + + + + +
+ + {/* CC Lane Selector */} + + +
+ + {/* Transform Dropdown */} +
+ + {showTransformMenu && ( +
+ + + + +
+ + +
+ + +
+ )} +
+
+ + {/* Step Input Mode */} + + {stepInputEnabled && ( + <> + + + + Oct: {stepInputOctave} + + + )} + + {/* Multi-clip indicator */} + {additionalClips.length > 0 && ( + <> +
+ + Editing {additionalClips.length + 1} clips + + + )}
{/* Background */} @@ -358,18 +1372,30 @@ export function PianoRoll({ clipId, trackId }: PianoRollProps) { x={0} y={0} width={dimensions.width} - height={dimensions.height} + height={stageHeight} fill="#1a1a1a" /> - {/* Grid */} + {/* Grid with beat shading and scale highlighting */} {renderGrid()} {/* Piano keyboard */} {renderPianoKeyboard()} - {/* MIDI notes */} + {/* Ghost notes from other MIDI clips on this track */} + {renderGhostNotes()} + + {/* MIDI notes with velocity coloring (includes multi-clip notes) */} {renderNotes()} + + {/* Step input cursor line */} + {renderStepInputCursor()} + + {/* Velocity lane */} + {renderVelocityLane()} + + {/* CC lane */} + {renderCCLane()}
diff --git a/frontend/src/components/PluginBrowser.tsx b/frontend/src/components/PluginBrowser.tsx index 357e2ab..b56132f 100644 --- a/frontend/src/components/PluginBrowser.tsx +++ b/frontend/src/components/PluginBrowser.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { createPortal } from "react-dom"; import { X, @@ -12,11 +12,24 @@ import { Music, Box, Code, + Star, } from "lucide-react"; import { nativeBridge } from "../services/NativeBridge"; import { useDAWStore } from "../store/useDAWStore"; import { Button, Input, Select } from "./ui"; +// Persist favorites in localStorage +const FAVORITES_KEY = "studio13_plugin_favorites"; +function loadFavorites(): Set { + try { + const raw = localStorage.getItem(FAVORITES_KEY); + return raw ? new Set(JSON.parse(raw)) : new Set(); + } catch { return new Set(); } +} +function saveFavorites(favs: Set) { + localStorage.setItem(FAVORITES_KEY, JSON.stringify([...favs])); +} + interface Plugin { name: string; manufacturer: string; @@ -24,7 +37,7 @@ interface Plugin { fileOrIdentifier: string; isInstrument: boolean; snapshot?: string; // base64 data URL from C++ snapshot lookup - pluginType?: "vst3" | "s13fx"; // Distinguishes VST3 from S13FX scripts + pluginType?: "vst3" | "lv2" | "clap" | "s13fx"; // Plugin format type } // Map VST3 category substrings to Lucide icons and colors @@ -58,6 +71,36 @@ function getCategoryIcon(category: string) { return { match: "Other", Icon: Box, color: "#6b7280" }; } +// ---- Plugin Category Groups ---- +// Maps VST3 category strings (e.g. "Fx|Dynamics", "Instrument|Synth") to +// predefined groups for easy filtering. +const CATEGORY_GROUPS = [ + { id: "all", label: "All", keywords: [] }, + { id: "instruments", label: "Instruments", keywords: ["instrument", "synth", "sampler", "piano", "organ", "drum"] }, + { id: "effects", label: "Effects", keywords: ["fx", "effect"] }, + { id: "dynamics", label: "Dynamics", keywords: ["dynamics", "compressor", "limiter", "gate", "expander"] }, + { id: "eq", label: "EQ", keywords: ["eq", "equalizer", "filter"] }, + { id: "reverb", label: "Reverb", keywords: ["reverb", "room", "hall", "plate"] }, + { id: "delay", label: "Delay", keywords: ["delay", "echo"] }, + { id: "modulation", label: "Modulation", keywords: ["modulation", "chorus", "flanger", "phaser", "tremolo", "vibrato"] }, + { id: "distortion", label: "Distortion", keywords: ["distortion", "saturation", "overdrive", "bitcrusher", "waveshaper"] }, + { id: "other", label: "Other", keywords: [] }, +] as const; + +type CategoryGroupId = typeof CATEGORY_GROUPS[number]["id"]; + +function getPluginGroupId(plugin: { category: string; isInstrument: boolean }): CategoryGroupId { + if (plugin.isInstrument) return "instruments"; + const lowerCat = plugin.category.toLowerCase(); + for (const group of CATEGORY_GROUPS) { + if (group.id === "all" || group.id === "other") continue; + if (group.keywords.some((kw) => lowerCat.includes(kw))) return group.id; + } + // If it has "Fx" in category but no specific match, classify as "effects" + if (lowerCat.includes("fx") || lowerCat.includes("effect")) return "effects"; + return "other"; +} + interface PluginBrowserProps { trackId: string; targetChain: "input" | "track" | "master" | "instrument"; @@ -75,7 +118,20 @@ export function PluginBrowser({ const [loading, setLoading] = useState(false); const [searchTerm, setSearchTerm] = useState(""); const [categoryFilter, setCategoryFilter] = useState("All"); + const [categoryGroupFilter, setCategoryGroupFilter] = useState("all"); const [addingPlugin, setAddingPlugin] = useState(null); + const [favorites, setFavorites] = useState>(loadFavorites); + const [showFavoritesOnly, setShowFavoritesOnly] = useState(false); + + const toggleFavorite = (pluginId: string) => { + setFavorites((prev) => { + const next = new Set(prev); + if (next.has(pluginId)) next.delete(pluginId); + else next.add(pluginId); + saveFavorites(next); + return next; + }); + }; useEffect(() => { loadPlugins(); @@ -85,10 +141,13 @@ export function PluginBrowser({ setLoading(true); try { const pluginList = await nativeBridge.getAvailablePlugins(); - const vst3Plugins: Plugin[] = pluginList.map((p: any) => ({ - ...p, - pluginType: "vst3" as const, - })); + const vst3Plugins: Plugin[] = pluginList.map((p: any) => { + const fmt = (p.pluginFormatName || "").toLowerCase(); + let pluginType: Plugin["pluginType"] = "vst3"; + if (fmt.includes("lv2")) pluginType = "lv2"; + else if (fmt.includes("clap")) pluginType = "clap"; + return { ...p, pluginType }; + }); // Also load S13FX scripts (not for instrument target) let s13fxPlugins: Plugin[] = []; @@ -193,6 +252,21 @@ export function PluginBrowser({ ...Array.from(new Set(basePlugins.map((p) => p.category))), ]; + // Compute available category groups (only show tabs that have plugins) + const availableGroups = useMemo(() => { + const groupCounts = new Map(); + for (const p of basePlugins) { + const gid = getPluginGroupId(p); + groupCounts.set(gid, (groupCounts.get(gid) || 0) + 1); + } + return CATEGORY_GROUPS.filter( + (g) => g.id === "all" || (groupCounts.get(g.id) || 0) > 0 + ).map((g) => ({ + ...g, + count: g.id === "all" ? basePlugins.length : groupCounts.get(g.id) || 0, + })); + }, [basePlugins]); + // Enhanced search: match against name, manufacturer, AND category const filteredPlugins = basePlugins.filter((p) => { const term = searchTerm.toLowerCase(); @@ -202,9 +276,22 @@ export function PluginBrowser({ p.category.toLowerCase().includes(term); const matchesCategory = categoryFilter === "All" || p.category === categoryFilter; - return matchesSearch && matchesCategory; + const matchesGroup = + categoryGroupFilter === "all" || getPluginGroupId(p) === categoryGroupFilter; + const matchesFavorite = !showFavoritesOnly || favorites.has(p.fileOrIdentifier); + return matchesSearch && matchesCategory && matchesGroup && matchesFavorite; }); + // Sort: favorites first, then alphabetical + const sortedPlugins = useMemo(() => { + return [...filteredPlugins].sort((a, b) => { + const aFav = favorites.has(a.fileOrIdentifier) ? 0 : 1; + const bFav = favorites.has(b.fileOrIdentifier) ? 0 : 1; + if (aFav !== bFav) return aFav - bFav; + return a.name.localeCompare(b.name); + }); + }, [filteredPlugins, favorites]); + const content = ( <>
({ value: cat, label: cat }))} className="min-w-[150px]" /> +
+ {/* Category Group Filter Tabs */} +
+ {availableGroups.map((group) => ( + + ))} +
+
{loading ? ( -
- Loading plugins... +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ {/* Icon skeleton */} +
+ {/* Text skeleton */} +
+
+
+
+
+ {/* Button skeleton */} +
+
+ ))} +
+ Scanning for plugins... +
- ) : filteredPlugins.length === 0 ? ( + ) : sortedPlugins.length === 0 ? (
- No plugins found. Click "Scan" to search your system. + {showFavoritesOnly ? "No favorite plugins. Click the star on a plugin to favorite it." : "No plugins found. Click \"Scan\" to search your system."}
) : ( - filteredPlugins.map((plugin, idx) => { + sortedPlugins.map((plugin, idx) => { const isScript = plugin.pluginType === "s13fx"; + const isFav = favorites.has(plugin.fileOrIdentifier); const { Icon, color } = isScript ? { match: "Script", Icon: Code, color: "#84cc16" } : getCategoryIcon(plugin.category); @@ -269,6 +410,14 @@ export function PluginBrowser({ isScript ? "border-lime-700/40" : "border-neutral-700" }`} > + {/* Favorite star */} + {/* Snapshot or category icon */} {plugin.snapshot ? ( s.snapEnabled); - const gridSize = useDAWStore((s) => s.gridSize); + const { snapEnabled, gridSize } = useDAWStore(useShallow((s) => ({ + snapEnabled: s.snapEnabled, + gridSize: s.gridSize, + }))); return (
@@ -124,10 +127,12 @@ function GeneralTab() { // ========== Editing Tab ========== function EditingTab() { - const autoCrossfade = useDAWStore((s) => s.autoCrossfade); - const defaultCrossfadeLength = useDAWStore((s) => s.defaultCrossfadeLength); - const rippleMode = useDAWStore((s) => s.rippleMode); - const recordMode = useDAWStore((s) => s.recordMode); + const { autoCrossfade, defaultCrossfadeLength, rippleMode, recordMode } = useDAWStore(useShallow((s) => ({ + autoCrossfade: s.autoCrossfade, + defaultCrossfadeLength: s.defaultCrossfadeLength, + rippleMode: s.rippleMode, + recordMode: s.recordMode, + }))); return (
@@ -185,8 +190,11 @@ function EditingTab() { // ========== Display Tab ========== function DisplayTab() { - const timecodeMode = useDAWStore((s) => s.timecodeMode); - const smpteFrameRate = useDAWStore((s) => s.smpteFrameRate); + const { timecodeMode, smpteFrameRate, uiFontScale } = useDAWStore(useShallow((s) => ({ + timecodeMode: s.timecodeMode, + smpteFrameRate: s.smpteFrameRate, + uiFontScale: s.uiFontScale, + }))); return (
@@ -213,6 +221,28 @@ function DisplayTab() { )} + Accessibility + +
+ useDAWStore.getState().setUIFontScale(Number.parseFloat(e.target.value))} + className="w-24 cursor-pointer accent-blue-600" + aria-label="UI Font Scale" + aria-valuemin={0.75} + aria-valuemax={1.5} + aria-valuenow={uiFontScale} + /> + + {Math.round(uiFontScale * 100)}% + +
+
+ Panels Use Ctrl+M to toggle @@ -245,7 +275,9 @@ const ACTION_OPTIONS: Record = { }; function MouseModifierTab() { - const mouseModifiers = useDAWStore((s) => s.mouseModifiers); + const { mouseModifiers } = useDAWStore(useShallow((s) => ({ + mouseModifiers: s.mouseModifiers, + }))); return (
@@ -315,8 +347,10 @@ function MouseModifierTab() { // ========== Backup Tab ========== function BackupTab() { - const autoBackupEnabled = useDAWStore((s) => s.autoBackupEnabled); - const autoBackupInterval = useDAWStore((s) => s.autoBackupInterval); + const { autoBackupEnabled, autoBackupInterval } = useDAWStore(useShallow((s) => ({ + autoBackupEnabled: s.autoBackupEnabled, + autoBackupInterval: s.autoBackupInterval, + }))); const intervalMinutes = Math.round(autoBackupInterval / 60000); diff --git a/frontend/src/components/ProjectCompareModal.tsx b/frontend/src/components/ProjectCompareModal.tsx new file mode 100644 index 0000000..4d45838 --- /dev/null +++ b/frontend/src/components/ProjectCompareModal.tsx @@ -0,0 +1,245 @@ +import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/shallow"; +import { + Button, + Modal, + ModalHeader, + ModalContent, + ModalFooter, +} from "./ui"; + +interface ProjectCompareModalProps { + isOpen: boolean; + onClose: () => void; +} + +/** + * Project Compare Modal + * Shows a diff between the current project state and the last saved version. + * Categories: Settings, Tracks (added/removed/modified), Clips (added/removed/moved). + */ +export function ProjectCompareModal({ + isOpen, + onClose, +}: ProjectCompareModalProps) { + const { projectCompareData, projectPath } = useDAWStore( + useShallow((s) => ({ + projectCompareData: s.projectCompareData, + projectPath: s.projectPath, + })), + ); + + const data = projectCompareData; + const hasChanges = + data && + (data.tracksDiff.length > 0 || + data.clipsDiff.length > 0 || + data.settingsDiff.length > 0); + + return ( + + + + +
+ {/* Current file path */} +
+ Comparing with: {projectPath || "(unsaved project)"} +
+ + {!data && ( +
+ Loading comparison... +
+ )} + + {data && !hasChanges && ( +
+
+ No changes detected +
+
+ The current project matches the saved version. +
+
+ )} + + {data && hasChanges && ( + <> + {/* Settings Changes */} + {data.settingsDiff.length > 0 && ( +
+

+ Settings Changes + + ({data.settingsDiff.length}) + +

+
+ {data.settingsDiff.map((s, i) => ( +
+ + ~ + + + {s.field}: + + + {s.oldValue} + + -> + {s.newValue} +
+ ))} +
+
+ )} + + {/* Track Changes */} + {data.tracksDiff.length > 0 && ( +
+

+ Track Changes + + ({data.tracksDiff.length}) + +

+
+ {data.tracksDiff.map((t, i) => ( +
+ + {t.type === "added" + ? "+" + : t.type === "removed" + ? "-" + : "~"} + +
+ + {t.name} + + {t.details && ( +
+ {t.details} +
+ )} +
+
+ ))} +
+
+ )} + + {/* Clip Changes */} + {data.clipsDiff.length > 0 && ( +
+

+ Clip Changes + + ({data.clipsDiff.length}) + +

+
+ {data.clipsDiff.map((c, i) => ( +
+ + {c.type === "added" + ? "+" + : c.type === "removed" + ? "-" + : "~"} + +
+ + {c.name} + + + on "{c.trackName}" + + {c.details && ( +
+ {c.details} +
+ )} +
+
+ ))} +
+
+ )} + + {/* Summary */} +
+ + + +{data.tracksDiff.filter((t) => t.type === "added").length + + data.clipsDiff.filter((c) => c.type === "added").length} + {" "} + added + + + + -{data.tracksDiff.filter((t) => t.type === "removed").length + + data.clipsDiff.filter((c) => c.type === "removed").length} + {" "} + removed + + + + ~{data.tracksDiff.filter((t) => t.type === "modified").length + + data.clipsDiff.filter((c) => c.type === "modified").length + + data.settingsDiff.length} + {" "} + modified + +
+ + )} +
+
+ + + + +
+ ); +} diff --git a/frontend/src/components/ProjectSettingsModal.tsx b/frontend/src/components/ProjectSettingsModal.tsx index 591be2c..72f29eb 100644 --- a/frontend/src/components/ProjectSettingsModal.tsx +++ b/frontend/src/components/ProjectSettingsModal.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/shallow"; import { Button, Input, @@ -31,13 +32,36 @@ export function ProjectSettingsModal({ projectBitDepth, transport, timeSignature, + projectAuthor, + projectRevisionNotes, setProjectName, setProjectNotes, setProjectSampleRate, setProjectBitDepth, setTempo, setTimeSignature: setTimeSignatureAction, - } = useDAWStore(); + setProjectAuthor, + addRevisionNote, + deleteRevisionNote, + } = useDAWStore(useShallow((s) => ({ + projectName: s.projectName, + projectNotes: s.projectNotes, + projectSampleRate: s.projectSampleRate, + projectBitDepth: s.projectBitDepth, + transport: s.transport, + timeSignature: s.timeSignature, + projectAuthor: s.projectAuthor, + projectRevisionNotes: s.projectRevisionNotes, + setProjectName: s.setProjectName, + setProjectNotes: s.setProjectNotes, + setProjectSampleRate: s.setProjectSampleRate, + setProjectBitDepth: s.setProjectBitDepth, + setTempo: s.setTempo, + setTimeSignature: s.setTimeSignature, + setProjectAuthor: s.setProjectAuthor, + addRevisionNote: s.addRevisionNote, + deleteRevisionNote: s.deleteRevisionNote, + }))); // Local state for form const [localName, setLocalName] = useState(projectName); @@ -51,6 +75,8 @@ export function ProjectSettingsModal({ const [localTimeSignatureDenom, setLocalTimeSignatureDenom] = useState( timeSignature.denominator ); + const [localAuthor, setLocalAuthor] = useState(projectAuthor); + const [newNote, setNewNote] = useState(""); // Sync local state when modal opens or store changes useEffect(() => { @@ -62,6 +88,8 @@ export function ProjectSettingsModal({ setLocalTempo(transport.tempo); setLocalTimeSignatureNum(timeSignature.numerator); setLocalTimeSignatureDenom(timeSignature.denominator); + setLocalAuthor(projectAuthor); + setNewNote(""); } }, [ isOpen, @@ -71,6 +99,7 @@ export function ProjectSettingsModal({ projectBitDepth, transport.tempo, timeSignature, + projectAuthor, ]); const handleApply = () => { @@ -80,9 +109,17 @@ export function ProjectSettingsModal({ setProjectBitDepth(localBitDepth); setTempo(localTempo); setTimeSignatureAction(localTimeSignatureNum, localTimeSignatureDenom); + if (localAuthor !== projectAuthor) setProjectAuthor(localAuthor); onClose(); }; + const handleAddNote = () => { + const trimmed = newNote.trim(); + if (!trimmed) return; + addRevisionNote(trimmed); + setNewNote(""); + }; + const handleCancel = () => { // Reset to original values setLocalName(projectName); @@ -92,6 +129,8 @@ export function ProjectSettingsModal({ setLocalTempo(transport.tempo); setLocalTimeSignatureNum(timeSignature.numerator); setLocalTimeSignatureDenom(timeSignature.denominator); + setLocalAuthor(projectAuthor); + setNewNote(""); onClose(); }; @@ -235,6 +274,93 @@ export function ProjectSettingsModal({
+ + {/* Author */} +
+

+ Collaboration +

+ setLocalAuthor(e.target.value)} + placeholder="Your name" + /> +
+ + {/* Revision History */} +
+

+ Revision History + {projectRevisionNotes.length > 0 && ( + + ({projectRevisionNotes.length}) + + )} +

+ + {/* Add new note */} +
+ setNewNote(e.target.value)} + placeholder="Add a revision note..." + onKeyDown={(e) => { + if (e.key === "Enter") handleAddNote(); + }} + /> + +
+ + {/* Notes list */} + {projectRevisionNotes.length === 0 ? ( +
+ No revision notes yet. Add a note to track project changes. +
+ ) : ( +
+ {[...projectRevisionNotes].reverse().map((entry, i) => { + const realIndex = projectRevisionNotes.length - 1 - i; + return ( +
+
+
{entry.note}
+
+ {entry.author} —{" "} + {new Date(entry.timestamp).toLocaleString()} +
+
+ +
+ ); + })} +
+ )} +
diff --git a/frontend/src/components/ProjectTabBar.tsx b/frontend/src/components/ProjectTabBar.tsx index 0b80801..bcdb9bb 100644 --- a/frontend/src/components/ProjectTabBar.tsx +++ b/frontend/src/components/ProjectTabBar.tsx @@ -1,10 +1,13 @@ import { X, Plus } from "lucide-react"; import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/shallow"; import { useContextMenu } from "./ContextMenu"; export function ProjectTabBar() { - const projectTabs = useDAWStore((s) => s.projectTabs); - const activeTabId = useDAWStore((s) => s.activeTabId); + const { projectTabs, activeTabId } = useDAWStore(useShallow((s) => ({ + projectTabs: s.projectTabs, + activeTabId: s.activeTabId, + }))); const { showContextMenu, ContextMenuComponent } = useContextMenu(); if (projectTabs.length <= 1) return null; // Hide when single tab diff --git a/frontend/src/components/QuantizeDialog.tsx b/frontend/src/components/QuantizeDialog.tsx new file mode 100644 index 0000000..3d9594d --- /dev/null +++ b/frontend/src/components/QuantizeDialog.tsx @@ -0,0 +1,202 @@ +import { useState } from "react"; +import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/react/shallow"; +import { commandManager } from "../store/commands"; +import { + Button, + Modal, + ModalHeader, + ModalContent, + ModalFooter, + Select, + Slider, +} from "./ui"; + +interface QuantizeDialogProps { + isOpen: boolean; + onClose: () => void; +} + +const GRID_OPTIONS = [ + { value: "1/1", label: "Whole Note" }, + { value: "1/2", label: "Half Note" }, + { value: "1/4", label: "Quarter Note" }, + { value: "1/8", label: "8th Note" }, + { value: "1/16", label: "16th Note" }, + { value: "1/32", label: "32nd Note" }, + { value: "1/4T", label: "Quarter Triplet" }, + { value: "1/8T", label: "8th Triplet" }, + { value: "1/16T", label: "16th Triplet" }, +]; + +/** + * QuantizeDialog (Sprint 19.3) + * Provides grid-based quantization for selected MIDI notes + * with configurable strength, swing, and humanize parameters. + */ +export function QuantizeDialog({ isOpen, onClose }: QuantizeDialogProps) { + const { selectedNoteIds, pianoRollClipId } = useDAWStore( + useShallow((s) => ({ + selectedNoteIds: s.selectedNoteIds, + pianoRollClipId: s.pianoRollClipId, + })), + ); + + const [gridSize, setGridSize] = useState("1/8"); + const [strength, setStrength] = useState(100); + const [swing, setSwing] = useState(0); + const [humanize, setHumanize] = useState(0); + + const handleApply = () => { + const store = useDAWStore.getState(); + if (!pianoRollClipId || selectedNoteIds.length === 0) return; + + // Find the clip + const clip = store.tracks + .flatMap((t) => t.clips) + .find((c) => c.id === pianoRollClipId); + if (!clip || (clip as any).type !== "midi") return; + + const tempo = store.transport.tempo; + const beatDuration = 60 / tempo; + + // Parse grid size to beat fraction + let gridBeats = 0.5; // default 1/8 + const triplet = gridSize.endsWith("T"); + const base = triplet ? gridSize.slice(0, -1) : gridSize; + const parts = base.split("/"); + if (parts.length === 2) { + gridBeats = (4 * parseInt(parts[0])) / parseInt(parts[1]); + } + if (triplet) gridBeats = (gridBeats * 2) / 3; + + const gridSeconds = gridBeats * beatDuration; + const strengthFactor = strength / 100; + + // Quantize each selected note + const midiClip = clip as any; + if (!midiClip.notes) return; + + const oldNotes = midiClip.notes.map((n: any) => ({ ...n })); + + const updatedNotes = midiClip.notes.map((note: any) => { + if (!selectedNoteIds.includes(note.id)) return note; + + const relativeTime = note.time - clip.startTime; + const nearestGrid = Math.round(relativeTime / gridSeconds) * gridSeconds; + + // Apply swing (offset even grid positions) + let target = nearestGrid; + if (swing !== 0) { + const gridIndex = Math.round(relativeTime / gridSeconds); + if (gridIndex % 2 === 1) { + target += (swing / 100) * gridSeconds * 0.5; + } + } + + // Apply strength (interpolate between original and quantized) + let newTime = clip.startTime + relativeTime + (target - relativeTime) * strengthFactor; + + // Apply humanize (random offset) + if (humanize > 0) { + const maxOffset = (humanize / 100) * gridSeconds * 0.25; + newTime += (Math.random() * 2 - 1) * maxOffset; + } + + return { ...note, time: Math.max(clip.startTime, newTime) }; + }); + + // Apply changes through store + store.updateMIDINotes(pianoRollClipId, updatedNotes); + + // Push undo command + commandManager.push({ + type: "quantize_notes", + description: `Quantize ${selectedNoteIds.length} notes to ${gridSize}`, + timestamp: Date.now(), + execute: () => store.updateMIDINotes(pianoRollClipId, updatedNotes), + undo: () => store.updateMIDINotes(pianoRollClipId, oldNotes), + }); + + onClose(); + }; + + const noteCount = selectedNoteIds.length; + + return ( + + + +
+

+ {noteCount} note{noteCount !== 1 ? "s" : ""} selected +

+ + {/* Grid Size */} +
+ + M @@ -287,7 +346,8 @@ export function TrackHeader({ track, isSelected }: TrackHeaderProps) { shape="square" active={track.soloed} onClick={handleSolo} - title="Solo" + title="Solo (S)" + aria-label={track.soloed ? "Unsolo track" : "Solo track"} > S @@ -342,6 +402,65 @@ export function TrackHeader({ track, isSelected }: TrackHeaderProps) { )} + {/* Track Notes Button */} + + {showNotes && notesBtnRef.current && + createPortal( +
+
e.stopPropagation()} + > +
Track Notes
+