diff --git a/.github/workflows/reaninjam-ci.yml b/.github/workflows/reaninjam-ci.yml new file mode 100644 index 00000000..d18585ae --- /dev/null +++ b/.github/workflows/reaninjam-ci.yml @@ -0,0 +1,65 @@ +name: ReaNINJAM CI + +on: + push: + branches: ["**"] + pull_request: + +jobs: + windows-build: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup MSBuild + uses: microsoft/setup-msbuild@v2 + + - name: Build VST3 (x64 Release) + shell: pwsh + run: | + msbuild "jmde/fx/reaninjam/reaninjam.vcxproj" ` + /t:Build ` + /p:Configuration=Release ` + /p:Platform=x64 + + - name: Upload Windows artifact + uses: actions/upload-artifact@v4 + with: + name: reaninjam-windows-vst3 + path: jmde/fx/reaninjam/x64/Release/reaninjam.vst3 + if-no-files-found: error + + macos-build: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + brew update + brew install libogg libvorbis + + - name: Build plugin target + run: | + mkdir -p build/macos + make -C jmde/fx/reaninjam \ + OUTDIR="$GITHUB_WORKSPACE/build/macos" \ + TARGET_DARWIN=1 \ + DLL_EXT=.dylib \ + OGGDIR=/opt/homebrew/opt/libogg \ + VORBISDIR=/opt/homebrew/opt/libvorbis \ + WDL_PATH="$GITHUB_WORKSPACE/WDL" + + - name: Package macOS VST3 bundle + run: | + mkdir -p build/macos/reaninjam.vst3/Contents/MacOS + cp build/macos/reaninjam.vst.dylib build/macos/reaninjam.vst3/Contents/MacOS/reaninjam + + - name: Upload macOS artifact + uses: actions/upload-artifact@v4 + with: + name: reaninjam-macos-vst3 + path: build/macos/reaninjam.vst3 + if-no-files-found: error diff --git a/.github/workflows/reaninjam-release.yml b/.github/workflows/reaninjam-release.yml new file mode 100644 index 00000000..e9f4902a --- /dev/null +++ b/.github/workflows/reaninjam-release.yml @@ -0,0 +1,98 @@ +name: ReaNINJAM Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + build-windows: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup MSBuild + uses: microsoft/setup-msbuild@v2 + + - name: Build VST3 (x64 Release) + shell: pwsh + run: | + msbuild "jmde/fx/reaninjam/reaninjam.vcxproj" ` + /t:Build ` + /p:Configuration=Release ` + /p:Platform=x64 + + - name: Package Windows artifact + shell: pwsh + run: | + Compress-Archive -Path "jmde/fx/reaninjam/x64/Release/reaninjam.vst3" -DestinationPath "reaninjam-windows-vst3.zip" + + - name: Upload Windows artifact + uses: actions/upload-artifact@v4 + with: + name: reaninjam-windows-vst3 + path: reaninjam-windows-vst3.zip + if-no-files-found: error + + build-macos: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + brew update + brew install libogg libvorbis + + - name: Build plugin target + run: | + mkdir -p build/macos + make -C jmde/fx/reaninjam \ + OUTDIR="$GITHUB_WORKSPACE/build/macos" \ + TARGET_DARWIN=1 \ + DLL_EXT=.dylib \ + OGGDIR=/opt/homebrew/opt/libogg \ + VORBISDIR=/opt/homebrew/opt/libvorbis \ + WDL_PATH="$GITHUB_WORKSPACE/WDL" + + - name: Package macOS VST3 bundle + run: | + mkdir -p build/macos/reaninjam.vst3/Contents/MacOS + cp build/macos/reaninjam.vst.dylib build/macos/reaninjam.vst3/Contents/MacOS/reaninjam + cd build/macos + zip -r ../../reaninjam-macos-vst3.zip reaninjam.vst3 + + - name: Upload macOS artifact + uses: actions/upload-artifact@v4 + with: + name: reaninjam-macos-vst3 + path: reaninjam-macos-vst3.zip + if-no-files-found: error + + publish-release: + runs-on: ubuntu-latest + needs: [build-windows, build-macos] + permissions: + contents: write + steps: + - name: Download Windows artifact + uses: actions/download-artifact@v4 + with: + name: reaninjam-windows-vst3 + path: artifacts + + - name: Download macOS artifact + uses: actions/download-artifact@v4 + with: + name: reaninjam-macos-vst3 + path: artifacts + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + files: | + artifacts/reaninjam-windows-vst3.zip + artifacts/reaninjam-macos-vst3.zip diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..2233fd7c --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +.DS_Store +Thumbs.db +.vscode/ +.vs/ +*.user +*.suo +*.opensdf +*.sdf + +build/ +build_*/ +build_server/ +build_test/ +CMakeFiles/ +cmake_install.cmake +CMakeCache.txt +*.dir/ +*.tlog +*.obj +*.pdb +*.ilk +*.idb +*.lib +*.exp +*.exe +*.dll + +*.vst3/ +*.component/ +*.vst/ +*.au/ +*.aaxplugin/ + +node_modules/ +npm-debug.log* + +build_*.txt +cmake_*.txt +err_*.txt +final_build_log*.txt +ninjam_debug.txt +slider_log.txt +temp_video.txt +tmp_videoClicked.txt +*.bak + +NINJAM-VDO-Package/ +NINJAM_VST3_mac_source/ +NINJAM_VST3_mac_source.zip +_tmp_JamTaba/ +ninjam - Copy/ +server.js diff --git a/README b/README index 4f63f677..e5607348 100644 --- a/README +++ b/README @@ -1,8 +1,14 @@ -This is NINJAM, www.ninjam.com +This is based on the original code of NINJAM made by Justin Frankel -To build the server, go to ninjam/server and run make +Mainly this is the same version updated via Juce which includes VST3, AU multi I/O plugins +and Standalone's which also support Multiple I/O. +Including Midi Learn and OSC Learn for knobs, buttons and faders. +Skin and Video backgrounds systenm to add to and make your own skins. +VDO Ninjam VideoSync'd to NINJAM Intervals with options to perfect the sync. -The various clients in ninjam/ are all old and probably need some attention to function - -The REAPER ReaNINJAM plug-in is in jmde/fx/reaninjam/. It is more fully featured. You will need the VST2 SDK to compile it, though. +Other extra's, Auto volume to better level all, Master Out Limiter, Reverb and Delay Sends for +all local channels. +Multiple Channels sent via Opus Multichannel Codec. +Backwards compatible with NINJAMs Vobis Audio. +All the extra's are in the branches. diff --git a/extras/ninjam-vst3/CMakeLists.txt b/extras/ninjam-vst3/CMakeLists.txt new file mode 100644 index 00000000..8b03f747 --- /dev/null +++ b/extras/ninjam-vst3/CMakeLists.txt @@ -0,0 +1,179 @@ +cmake_minimum_required(VERSION 3.22) + +# Fix for old CMake policies in dependencies (ogg/vorbis) +set(CMAKE_POLICY_VERSION_MINIMUM 3.5) + +project(NINJAM_VST3 VERSION 0.0.1) + +# Enable C++17 +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Fetch JUCE +include(FetchContent) +FetchContent_Declare( + JUCE + GIT_REPOSITORY https://github.com/juce-framework/JUCE.git + GIT_TAG 8.0.0 +) +FetchContent_MakeAvailable(JUCE) + +if (WIN32) + FetchContent_Declare( + asiosdk + GIT_REPOSITORY https://github.com/audiosdk/asio.git + GIT_TAG main + ) + FetchContent_MakeAvailable(asiosdk) +endif() + +# Fetch Ogg +FetchContent_Declare( + ogg + GIT_REPOSITORY https://github.com/xiph/ogg.git + GIT_TAG v1.3.5 +) +FetchContent_MakeAvailable(ogg) + +# Help Vorbis find Ogg +set(OGG_INCLUDE_DIR ${ogg_SOURCE_DIR}/include) +set(OGG_LIBRARY ogg) +set(OGG_FOUND TRUE) + +# Fetch Vorbis +FetchContent_Declare( + vorbis + GIT_REPOSITORY https://github.com/xiph/vorbis.git + GIT_TAG v1.3.7 +) +FetchContent_MakeAvailable(vorbis) + +# Fetch Opus +FetchContent_Declare( + opus + GIT_REPOSITORY https://github.com/xiph/opus.git + GIT_TAG v1.4 +) +FetchContent_MakeAvailable(opus) + +# Define the plugin +juce_add_plugin(NINJAM_VST3 + COMPANY_NAME "AndyMcProducer" + IS_SYNTH FALSE + NEEDS_MIDI_INPUT TRUE + NEEDS_MIDI_OUTPUT TRUE + IS_MIDI_EFFECT FALSE + EDITOR_WANTS_KEYBOARD_FOCUS TRUE + COPY_PLUGIN_AFTER_BUILD TRUE + PLUGIN_MANUFACTURER_CODE Juce + PLUGIN_CODE Njv3 + FORMATS VST3 AU Standalone + PRODUCT_NAME "NINJAM VST3" +) + +# Generate JuceHeader.h +juce_generate_juce_header(NINJAM_VST3) + +# WDL and NINJAM sources +get_filename_component(NINJAMPLUS_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +if (EXISTS "${NINJAMPLUS_ROOT}/WDL" AND EXISTS "${NINJAMPLUS_ROOT}/ninjam/njclient.cpp") + set(NINJAM_ROOT "${NINJAMPLUS_ROOT}") + set(WDL_ROOT "${NINJAMPLUS_ROOT}/WDL") + set(NINJAM_SRC_ROOT "${NINJAMPLUS_ROOT}/ninjam") +else() + set(NINJAM_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/ninjam") + set(WDL_ROOT "${NINJAM_ROOT}/WDL") + set(NINJAM_SRC_ROOT "${NINJAM_ROOT}/ninjam") +endif() + +# Define sources +target_sources(NINJAM_VST3 PRIVATE + Source/PluginProcessor.cpp + Source/PluginProcessor.h + Source/PluginEditor.cpp + Source/PluginEditor.h + Source/StandaloneApp.cpp + + # NINJAM Core + ${NINJAM_SRC_ROOT}/njclient.cpp + ${NINJAM_SRC_ROOT}/netmsg.cpp + ${NINJAM_SRC_ROOT}/mpb.cpp + ${NINJAM_SRC_ROOT}/njmisc.cpp + + # WDL sources needed by NINJAM + ${WDL_ROOT}/jnetlib/asyncdns.cpp + ${WDL_ROOT}/jnetlib/connection.cpp + ${WDL_ROOT}/jnetlib/httpget.cpp + ${WDL_ROOT}/jnetlib/util.cpp + ${WDL_ROOT}/jnetlib/listen.cpp + ${WDL_ROOT}/jnetlib/webserver.cpp + + ${WDL_ROOT}/sha.cpp + ${WDL_ROOT}/rng.cpp + ${WDL_ROOT}/resample.cpp # Might be needed + ${WDL_ROOT}/fft.c +) + +# Include directories +target_include_directories(NINJAM_VST3 PRIVATE + Source + ${NINJAM_ROOT} + ${NINJAM_SRC_ROOT} + ${WDL_ROOT} + ${ogg_SOURCE_DIR}/include + ${vorbis_SOURCE_DIR}/include + ${vorbis_SOURCE_DIR}/lib # for vorbis internal headers if needed + ${opus_SOURCE_DIR}/include + $<$:${asiosdk_SOURCE_DIR}/common> + $<$:${asiosdk_SOURCE_DIR}/host> +) + +# Link libraries +target_link_libraries(NINJAM_VST3 PRIVATE + juce::juce_audio_utils + juce::juce_audio_processors + juce::juce_gui_basics + juce::juce_gui_extra + juce::juce_dsp + juce::juce_osc + ogg + vorbis + vorbisenc + vorbisfile + opus +) + +# Compiler definitions +target_compile_definitions(NINJAM_VST3 PRIVATE + JUCE_WEB_BROWSER=0 + JUCE_USE_CURL=0 + JUCE_USE_CUSTOM_PLUGIN_STANDALONE_APP=1 + JUCE_VST3_EMULATE_MIDI_CC_WITH_PARAMETERS=0 + WDL_NO_DEFINE_MINMAX + _CRT_SECURE_NO_WARNINGS + _WINSOCK_DEPRECATED_NO_WARNINGS + JUCE_VST3_CAN_REPLACE_VST2=0 + $<$:JUCE_ASIO=1> +) + +if (WIN32) + target_sources(NINJAM_VST3 PRIVATE ${WDL_ROOT}/win32_utf8.c) + target_link_libraries(NINJAM_VST3 PRIVATE ws2_32 winmm) +endif() + +if (TARGET NINJAM_VST3_Standalone) + add_custom_target(NINJAM_VST3_SyncWebAssets + COMMAND ${CMAKE_COMMAND} -E make_directory "$/advanced-vdo-client" + COMMAND ${CMAKE_COMMAND} -E copy + "${CMAKE_CURRENT_SOURCE_DIR}/advanced-vdo-client/server.js" + "$/advanced-vdo-client/server.js" + COMMAND ${CMAKE_COMMAND} -E copy + "${CMAKE_CURRENT_SOURCE_DIR}/advanced-vdo-client/index.html" + "$/advanced-vdo-client/index.html" + COMMAND ${CMAKE_COMMAND} -E copy + "${CMAKE_CURRENT_SOURCE_DIR}/advanced-vdo-client/icon.png" + "$/advanced-vdo-client/icon.png" + VERBATIM + ) + add_dependencies(NINJAM_VST3_Standalone NINJAM_VST3_SyncWebAssets) +endif() diff --git a/extras/ninjam-vst3/Source - Copy/PluginEditor.cpp b/extras/ninjam-vst3/Source - Copy/PluginEditor.cpp new file mode 100644 index 00000000..71cbe260 --- /dev/null +++ b/extras/ninjam-vst3/Source - Copy/PluginEditor.cpp @@ -0,0 +1,4352 @@ +#include "PluginProcessor.h" +#include "PluginEditor.h" + +static juce::String normaliseColourPresetName(const juce::String& name); +static juce::Colour colourFromPresetName(const juce::String& preset, const juce::Colour& fallback); + +#if JUCE_WINDOWS +#include +#include +#include +#pragma comment(lib, "mfplat.lib") +#pragma comment(lib, "mfreadwrite.lib") +#pragma comment(lib, "mfuuid.lib") + +/** Decodes video frames on a background thread using Windows Media Foundation. + The main thread calls getLatestFrame() — it returns instantly (no blocking). */ +struct WinVideoReader : public juce::Thread +{ + WinVideoReader() : juce::Thread ("BgVideoDecoder") {} + + ~WinVideoReader() override + { + signalThreadShouldExit(); + stopThread (3000); + if (reader != nullptr) { reader->Release(); reader = nullptr; } + if (mfStarted) { MFShutdown(); mfStarted = false; } + } + + bool open (const juce::File& file) + { + if (FAILED (MFStartup (MF_VERSION))) return false; + mfStarted = true; + + IMFAttributes* attrs = nullptr; + MFCreateAttributes (&attrs, 1); + attrs->SetUINT32 (MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE); + HRESULT hr = MFCreateSourceReaderFromURL ( + file.getFullPathName().toWideCharPointer(), attrs, &reader); + attrs->Release(); + if (FAILED (hr) || reader == nullptr) return false; + + IMFMediaType* type = nullptr; + MFCreateMediaType (&type); + type->SetGUID (MF_MT_MAJOR_TYPE, MFMediaType_Video); + type->SetGUID (MF_MT_SUBTYPE, MFVideoFormat_RGB32); + reader->SetCurrentMediaType ((DWORD) MF_SOURCE_READER_FIRST_VIDEO_STREAM, nullptr, type); + type->Release(); + + IMFMediaType* outType = nullptr; + if (SUCCEEDED (reader->GetCurrentMediaType ( + (DWORD) MF_SOURCE_READER_FIRST_VIDEO_STREAM, &outType))) + { + UINT32 w = 0, h = 0; + MFGetAttributeSize (outType, MF_MT_FRAME_SIZE, &w, &h); + frameWidth = (int) w; + frameHeight = (int) h; + + UINT32 num = 0, den = 0; + MFGetAttributeRatio (outType, MF_MT_FRAME_RATE, &num, &den); + if (num > 0 && den > 0) + framePeriodMs = juce::jlimit (10, 200, (int) (1000.0 * den / num)); + + outType->Release(); + } + + if (frameWidth <= 0 || frameHeight <= 0) return false; + + startThread (juce::Thread::Priority::low); + return true; + } + + /** Called from the message thread. Returns a new frame image if one is ready, + or an invalid Image if nothing new has been decoded since last call. */ + juce::Image getLatestFrame() + { + juce::ScopedLock sl (frameLock); + auto f = pendingFrame; // ref-counted copy — cheap + pendingFrame = {}; + return f; + } + + //============================================================================== + void run() override + { + CoInitializeEx (nullptr, COINIT_MULTITHREADED); + + while (!threadShouldExit()) + { + if (eof) + { + seekToStart(); + continue; // go straight back to decode after looping + } + + auto img = decodeNextFrame(); + if (img.isValid()) + { + juce::ScopedLock sl (frameLock); + pendingFrame = std::move (img); + } + + // Sleep one frame period between decodes; wakes early on exit signal + wait (framePeriodMs); + } + + CoUninitialize(); + } + +private: + IMFSourceReader* reader = nullptr; + bool mfStarted = false; + int frameWidth = 0; + int frameHeight = 0; + int framePeriodMs = 33; // ~30 fps default + bool eof = false; + + juce::CriticalSection frameLock; + juce::Image pendingFrame; + + void seekToStart() + { + if (reader == nullptr) return; + PROPVARIANT pv; + PropVariantInit (&pv); + pv.vt = VT_I8; + pv.hVal.QuadPart = 0; + reader->SetCurrentPosition (GUID_NULL, pv); + PropVariantClear (&pv); + eof = false; + } + + juce::Image decodeNextFrame() + { + if (reader == nullptr) return {}; + + DWORD streamIdx = 0, flags = 0; + LONGLONG ts = 0; + IMFSample* sample = nullptr; + + HRESULT hr = reader->ReadSample ( + (DWORD) MF_SOURCE_READER_FIRST_VIDEO_STREAM, + 0, &streamIdx, &flags, &ts, &sample); + + if (FAILED (hr) || (flags & MF_SOURCE_READERF_ENDOFSTREAM)) + { + if (sample != nullptr) sample->Release(); + eof = true; + return {}; + } + if (sample == nullptr) return {}; + + IMFMediaBuffer* buf = nullptr; + sample->ConvertToContiguousBuffer (&buf); + sample->Release(); + if (buf == nullptr) return {}; + + BYTE* data = nullptr; + DWORD maxLen = 0, curLen = 0; + buf->Lock (&data, &maxLen, &curLen); + + juce::Image img (juce::Image::ARGB, frameWidth, frameHeight, false); + { + juce::Image::BitmapData bd (img, juce::Image::BitmapData::writeOnly); + const size_t srcRowBytes = (size_t) frameWidth * 4; + for (int y = 0; y < frameHeight; ++y) + { + auto* src = reinterpret_cast (data + y * srcRowBytes); + auto* dst = reinterpret_cast (bd.getLinePointer (y)); + for (int x = 0; x < frameWidth; ++x) + dst[x] = src[x] | 0xFF000000u; + } + } + + buf->Unlock(); + buf->Release(); + return img; + } +}; +#endif // JUCE_WINDOWS + +namespace +{ +class NoArrowCallOutLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + void drawCallOutBoxBackground(juce::CallOutBox& box, juce::Graphics& g, const juce::Path&, juce::Image&) override + { + auto bounds = box.getLocalBounds().toFloat().reduced(1.0f); + auto background = box.findColour(juce::ResizableWindow::backgroundColourId).withAlpha(0.97f); + g.setColour(background); + g.fillRoundedRectangle(bounds, 10.0f); + g.setColour(juce::Colours::lightblue.withAlpha(0.5f)); + g.drawRoundedRectangle(bounds, 10.0f, 1.0f); + } +}; + +static NoArrowCallOutLookAndFeel noArrowCallOutLookAndFeel; + +class ReverbSettingsPopupComponent : public juce::Component +{ +public: + explicit ReverbSettingsPopupComponent(NinjamVst3AudioProcessor& p) + : processor(p) + { + addAndMakeVisible(roomSizeLabel); + roomSizeLabel.setText("Room Size", juce::dontSendNotification); + addAndMakeVisible(roomSizeSlider); + roomSizeSlider.setSliderStyle(juce::Slider::LinearHorizontal); + roomSizeSlider.setTextBoxStyle(juce::Slider::TextBoxRight, false, 52, 18); + roomSizeSlider.setRange(0.0, 1.0, 0.01); + roomSizeSlider.setValue(processor.getFxReverbRoomSize(), juce::dontSendNotification); + roomSizeSlider.onValueChange = [this] { processor.setFxReverbRoomSize((float)roomSizeSlider.getValue()); }; + + addAndMakeVisible(dampingLabel); + dampingLabel.setText("Dampening", juce::dontSendNotification); + addAndMakeVisible(dampingSlider); + dampingSlider.setSliderStyle(juce::Slider::LinearHorizontal); + dampingSlider.setTextBoxStyle(juce::Slider::TextBoxRight, false, 52, 18); + dampingSlider.setRange(0.0, 1.0, 0.01); + dampingSlider.setValue(processor.getFxReverbDamping(), juce::dontSendNotification); + dampingSlider.onValueChange = [this] { processor.setFxReverbDamping((float)dampingSlider.getValue()); }; + + addAndMakeVisible(wetDryLabel); + wetDryLabel.setText("Wet/Dry", juce::dontSendNotification); + addAndMakeVisible(wetDrySlider); + wetDrySlider.setSliderStyle(juce::Slider::LinearHorizontal); + wetDrySlider.setTextBoxStyle(juce::Slider::TextBoxRight, false, 52, 18); + wetDrySlider.setRange(0.0, 1.0, 0.01); + wetDrySlider.setValue(processor.getFxReverbWetDryMix(), juce::dontSendNotification); + wetDrySlider.onValueChange = [this] { processor.setFxReverbWetDryMix((float)wetDrySlider.getValue()); }; + + addAndMakeVisible(earlyLabel); + earlyLabel.setText("Early Reflections", juce::dontSendNotification); + addAndMakeVisible(earlySlider); + earlySlider.setSliderStyle(juce::Slider::LinearHorizontal); + earlySlider.setTextBoxStyle(juce::Slider::TextBoxRight, false, 52, 18); + earlySlider.setRange(0.0, 1.0, 0.01); + earlySlider.setValue(processor.getFxReverbEarlyReflections(), juce::dontSendNotification); + earlySlider.onValueChange = [this] { processor.setFxReverbEarlyReflections((float)earlySlider.getValue()); }; + + addAndMakeVisible(tailLabel); + tailLabel.setText("Tail", juce::dontSendNotification); + addAndMakeVisible(tailSlider); + tailSlider.setSliderStyle(juce::Slider::LinearHorizontal); + tailSlider.setTextBoxStyle(juce::Slider::TextBoxRight, false, 52, 18); + tailSlider.setRange(0.0, 1.0, 0.01); + tailSlider.setValue(processor.getFxReverbTail(), juce::dontSendNotification); + tailSlider.onValueChange = [this] { processor.setFxReverbTail((float)tailSlider.getValue()); }; + + setSize(340, 180); + } + + void resized() override + { + auto area = getLocalBounds().reduced(8); + layoutRow(area, roomSizeLabel, roomSizeSlider); + layoutRow(area, earlyLabel, earlySlider); + layoutRow(area, tailLabel, tailSlider); + layoutRow(area, dampingLabel, dampingSlider); + layoutRow(area, wetDryLabel, wetDrySlider); + } + +private: + void layoutRow(juce::Rectangle& area, juce::Label& label, juce::Slider& slider) + { + auto row = area.removeFromTop(32); + label.setBounds(row.removeFromLeft(130)); + slider.setBounds(row); + } + + NinjamVst3AudioProcessor& processor; + juce::Label roomSizeLabel; + juce::Label dampingLabel; + juce::Label wetDryLabel; + juce::Label earlyLabel; + juce::Label tailLabel; + juce::Slider roomSizeSlider; + juce::Slider dampingSlider; + juce::Slider wetDrySlider; + juce::Slider earlySlider; + juce::Slider tailSlider; +}; + +class DelaySettingsPopupComponent : public juce::Component +{ +public: + explicit DelaySettingsPopupComponent(NinjamVst3AudioProcessor& p) + : processor(p) + { + addAndMakeVisible(modeLabel); + modeLabel.setText("Time Mode", juce::dontSendNotification); + addAndMakeVisible(modeSelector); + modeSelector.addItem("Milliseconds", 1); + modeSelector.addItem("Sync Host", 2); + modeSelector.setSelectedId(processor.isFxDelaySyncToHost() ? 2 : 1, juce::dontSendNotification); + modeSelector.onChange = [this] + { + processor.setFxDelaySyncToHost(modeSelector.getSelectedId() == 2); + updateEnabledState(); + }; + + addAndMakeVisible(timeMsLabel); + timeMsLabel.setText("Delay Time (ms)", juce::dontSendNotification); + addAndMakeVisible(timeMsSlider); + timeMsSlider.setSliderStyle(juce::Slider::LinearHorizontal); + timeMsSlider.setTextBoxStyle(juce::Slider::TextBoxRight, false, 52, 18); + timeMsSlider.setRange(20.0, 2000.0, 1.0); + timeMsSlider.setValue(processor.getFxDelayTimeMs(), juce::dontSendNotification); + timeMsSlider.onValueChange = [this] { processor.setFxDelayTimeMs((float)timeMsSlider.getValue()); }; + + addAndMakeVisible(syncLabel); + syncLabel.setText("Sync Division", juce::dontSendNotification); + addAndMakeVisible(syncSelector); + syncSelector.addItem("1/16", 16); + syncSelector.addItem("1/8", 8); + syncSelector.addItem("1/1", 1); + syncSelector.setSelectedId(processor.getFxDelayDivision(), juce::dontSendNotification); + syncSelector.onChange = [this] + { + int division = syncSelector.getSelectedId(); + if (division <= 0) + division = 8; + processor.setFxDelayDivision(division); + }; + + addAndMakeVisible(pingPongLabel); + pingPongLabel.setText("Ping Pong", juce::dontSendNotification); + addAndMakeVisible(pingPongToggle); + pingPongToggle.setClickingTogglesState(true); + pingPongToggle.setToggleState(processor.isFxDelayPingPong(), juce::dontSendNotification); + pingPongToggle.onClick = [this] { processor.setFxDelayPingPong(pingPongToggle.getToggleState()); }; + + addAndMakeVisible(wetDryLabel); + wetDryLabel.setText("Wet/Dry", juce::dontSendNotification); + addAndMakeVisible(wetDrySlider); + wetDrySlider.setSliderStyle(juce::Slider::LinearHorizontal); + wetDrySlider.setTextBoxStyle(juce::Slider::TextBoxRight, false, 52, 18); + wetDrySlider.setRange(0.0, 1.0, 0.01); + wetDrySlider.setValue(processor.getFxDelayWetDryMix(), juce::dontSendNotification); + wetDrySlider.onValueChange = [this] { processor.setFxDelayWetDryMix((float)wetDrySlider.getValue()); }; + + addAndMakeVisible(feedbackLabel); + feedbackLabel.setText("Feedback", juce::dontSendNotification); + addAndMakeVisible(feedbackSlider); + feedbackSlider.setSliderStyle(juce::Slider::LinearHorizontal); + feedbackSlider.setTextBoxStyle(juce::Slider::TextBoxRight, false, 52, 18); + feedbackSlider.setRange(0.0, 0.95, 0.01); + feedbackSlider.setValue(processor.getFxDelayFeedback(), juce::dontSendNotification); + feedbackSlider.onValueChange = [this] { processor.setFxDelayFeedback((float)feedbackSlider.getValue()); }; + + updateEnabledState(); + setSize(360, 220); + } + + void resized() override + { + auto area = getLocalBounds().reduced(8); + layoutRow(area, modeLabel, modeSelector); + layoutRow(area, timeMsLabel, timeMsSlider); + layoutRow(area, syncLabel, syncSelector); + layoutRow(area, pingPongLabel, pingPongToggle); + layoutRow(area, wetDryLabel, wetDrySlider); + layoutRow(area, feedbackLabel, feedbackSlider); + } + +private: + template + void layoutRow(juce::Rectangle& area, juce::Label& label, ControlType& control) + { + auto row = area.removeFromTop(34); + label.setBounds(row.removeFromLeft(130)); + control.setBounds(row); + } + + void updateEnabledState() + { + const bool syncEnabled = modeSelector.getSelectedId() == 2; + timeMsSlider.setEnabled(!syncEnabled); + syncSelector.setEnabled(syncEnabled); + } + + NinjamVst3AudioProcessor& processor; + juce::Label modeLabel; + juce::ComboBox modeSelector; + juce::Label timeMsLabel; + juce::Slider timeMsSlider; + juce::Label syncLabel; + juce::ComboBox syncSelector; + juce::Label pingPongLabel; + juce::ToggleButton pingPongToggle; + juce::Label wetDryLabel; + juce::Slider wetDrySlider; + juce::Label feedbackLabel; + juce::Slider feedbackSlider; +}; + +class MidiOptionsPopupComponent : public juce::Component +{ +public: + MidiOptionsPopupComponent(NinjamVst3AudioProcessor& p, std::function onChangedCallback) + : processor(p), onChanged(std::move(onChangedCallback)) + { + addAndMakeVisible(learnDeviceLabel); + learnDeviceLabel.setText("Midi Learn Device", juce::dontSendNotification); + addAndMakeVisible(learnDeviceSelector); + populateSelector(learnDeviceSelector, learnDeviceByMenuId, processor.getMidiLearnInputDeviceId()); + learnDeviceSelector.onChange = [this] + { + const int selected = learnDeviceSelector.getSelectedId(); + auto it = learnDeviceByMenuId.find(selected); + processor.setMidiLearnInputDeviceId(it != learnDeviceByMenuId.end() ? it->second : juce::String()); + if (onChanged) + onChanged(); + }; + + addAndMakeVisible(relayDeviceLabel); + relayDeviceLabel.setText("Midi Relay Device", juce::dontSendNotification); + addAndMakeVisible(relayDeviceSelector); + populateSelector(relayDeviceSelector, relayDeviceByMenuId, processor.getMidiRelayInputDeviceId()); + relayDeviceSelector.onChange = [this] + { + const int selected = relayDeviceSelector.getSelectedId(); + auto it = relayDeviceByMenuId.find(selected); + processor.setMidiRelayInputDeviceId(it != relayDeviceByMenuId.end() ? it->second : juce::String()); + if (onChanged) + onChanged(); + }; + + setSize(360, 104); + } + + void resized() override + { + auto area = getLocalBounds().reduced(8); + layoutRow(area, learnDeviceLabel, learnDeviceSelector); + layoutRow(area, relayDeviceLabel, relayDeviceSelector); + } + +private: + template + void layoutRow(juce::Rectangle& area, juce::Label& label, ControlType& control) + { + auto row = area.removeFromTop(42); + label.setBounds(row.removeFromLeft(140)); + control.setBounds(row.removeFromTop(28)); + } + + static void populateSelector(juce::ComboBox& selector, + std::map& idByMenuId, + const juce::String& selectedDeviceId) + { + selector.clear(juce::dontSendNotification); + idByMenuId.clear(); + int menuId = 1; + selector.addItem("Host MIDI / Any", menuId); + idByMenuId[menuId] = {}; + int selectedMenuId = selectedDeviceId.isEmpty() ? menuId : 0; + ++menuId; + + const auto devices = juce::MidiInput::getAvailableDevices(); + for (const auto& device : devices) + { + selector.addItem(device.name, menuId); + idByMenuId[menuId] = device.identifier; + if (device.identifier == selectedDeviceId) + selectedMenuId = menuId; + ++menuId; + } + + if (selectedMenuId == 0) + selectedMenuId = 1; + selector.setSelectedId(selectedMenuId, juce::dontSendNotification); + } + + NinjamVst3AudioProcessor& processor; + std::function onChanged; + juce::Label learnDeviceLabel; + juce::ComboBox learnDeviceSelector; + juce::Label relayDeviceLabel; + juce::ComboBox relayDeviceSelector; + std::map learnDeviceByMenuId; + std::map relayDeviceByMenuId; +}; +} + +void FaderLookAndFeel::drawLinearSliderBackground(juce::Graphics& g, int x, int y, int width, int height, + float sliderPos, float minSliderPos, float maxSliderPos, + const juce::Slider::SliderStyle style, juce::Slider& slider) +{ + juce::Rectangle bounds(x, y, width, height); + if (style == juce::Slider::LinearVertical) + { + int trackWidth = 4; + juce::Rectangle track(bounds.getCentreX() - trackWidth / 2, + bounds.getY() + 4, + trackWidth, + bounds.getHeight() - 8); + + juce::ColourGradient grad(juce::Colours::black.withAlpha(0.9f), (float)track.getCentreX(), (float)track.getY(), + juce::Colours::darkgrey.darker(), (float)track.getCentreX(), (float)track.getBottom(), false); + g.setGradientFill(grad); + g.fillRect(track); + + g.setColour(juce::Colours::black); + g.drawRect(track); + + int tickX = track.getRight() + 6; + + const int numTicksBelowZero = 3; + float zeroProp = slider.valueToProportionOfLength(1.0); + zeroProp = juce::jlimit(0.0f, 1.0f, zeroProp); + + for (int i = 0; i <= numTicksBelowZero + 1; ++i) + { + float prop = zeroProp * (float)i / (float)numTicksBelowZero; + if (i > numTicksBelowZero) prop = 1.0f; + double value = slider.proportionOfLengthToValue(prop); + float gain = (float)value; + float clampedGain = juce::jlimit(1.0e-6f, 2.0f, gain); + float db = 20.0f * std::log10(clampedGain); + + int yPos = track.getY() + (int)((1.0f - prop) * (float)track.getHeight()); + + float alpha = 0.7f; + if (i == numTicksBelowZero) alpha = 0.95f; + + g.setColour(juce::Colours::lightgrey.withAlpha(alpha)); + g.drawLine((float)(track.getX() - 6), (float)yPos, + (float)(tickX + 4), (float)yPos); + + juce::String label; + if (i == numTicksBelowZero) + label = "0 dB"; + else if (i > numTicksBelowZero) + label = "+6 dB"; + else + label = juce::String((int)std::round(db)) + " dB"; + + g.setFont(9.0f); + g.drawText(label, tickX + 4, yPos - 7, 40, 14, + juce::Justification::centredLeft, false); + } + } + else if (style == juce::Slider::LinearHorizontal) + { + int trackHeight = 4; + juce::Rectangle track(bounds.getX() + 4, + bounds.getCentreY() - trackHeight / 2, + bounds.getWidth() - 8, + trackHeight); + + juce::ColourGradient grad(juce::Colours::black.withAlpha(0.9f), (float)track.getX(), (float)track.getCentreY(), + juce::Colours::darkgrey.darker(), (float)track.getRight(), (float)track.getCentreY(), false); + g.setGradientFill(grad); + g.fillRect(track); + + g.setColour(juce::Colours::black); + g.drawRect(track); + + const int numTicksBelowZero = 3; + float zeroProp = slider.valueToProportionOfLength(1.0); + zeroProp = juce::jlimit(0.0f, 1.0f, zeroProp); + + for (int i = 0; i <= numTicksBelowZero + 1; ++i) + { + float prop = zeroProp * (float)i / (float)numTicksBelowZero; + if (i > numTicksBelowZero) prop = 1.0f; + double value = slider.proportionOfLengthToValue(prop); + float gain = (float)value; + float clampedGain = juce::jlimit(1.0e-6f, 2.0f, gain); + float db = 20.0f * std::log10(clampedGain); + + int xPos = track.getX() + (int)(prop * (float)track.getWidth()); + + float alpha = 0.7f; + if (i == numTicksBelowZero) alpha = 0.95f; + + g.setColour(juce::Colours::lightgrey.withAlpha(alpha)); + g.drawLine((float)xPos, (float)(track.getY() - 6), + (float)xPos, (float)(track.getBottom() + 6)); + + juce::String label; + if (i == numTicksBelowZero) + label = "0 dB"; + else if (i > numTicksBelowZero) + label = "+6 dB"; + else + label = juce::String((int)std::round(db)) + " dB"; + + g.setFont(10.0f); + g.drawText(label, + xPos - 20, + track.getBottom() + 8, + 40, + 14, + juce::Justification::centred, + false); + } + } + else + { + juce::LookAndFeel_V4::drawLinearSliderBackground(g, x, y, width, height, sliderPos, minSliderPos, maxSliderPos, style, slider); + } +} + +void FaderLookAndFeel::drawLinearSlider(juce::Graphics& g, int x, int y, int width, int height, + float sliderPos, float minSliderPos, float maxSliderPos, + const juce::Slider::SliderStyle style, juce::Slider& slider) +{ + // Walk parent hierarchy to find the editor (sliders may be inside sub-components) + NinjamVst3AudioProcessorEditor* editor = nullptr; + for (auto* p = slider.getParentComponent(); p != nullptr && editor == nullptr; p = p->getParentComponent()) + editor = dynamic_cast(p); + + if (editor != nullptr && editor->faderKnobImage.isValid()) + { + drawLinearSliderBackground(g, x, y, width, height, sliderPos, minSliderPos, maxSliderPos, style, slider); + bool isVert = (style == juce::Slider::LinearVertical); + float thumbW = isVert ? (float)width * 0.95f : 40.0f; + float thumbH = isVert ? 42.0f : (float)height * 0.95f; + float thumbX = isVert ? (float)x + (float)width * 0.025f : sliderPos - thumbW * 0.5f; + float thumbY = isVert ? sliderPos - thumbH * 0.5f : (float)y + (float)height * 0.025f; + // Clamp so thumb never clips outside the slider bounds + thumbX = juce::jlimit((float)x, (float)(x + width) - thumbW, thumbX); + thumbY = juce::jlimit((float)y, (float)(y + height) - thumbH, thumbY); + g.drawImageWithin(editor->faderKnobImage, (int)thumbX, (int)thumbY, (int)thumbW, (int)thumbH, + juce::RectanglePlacement::centred); + return; + } + + if (style == juce::Slider::LinearVertical) + { + drawLinearSliderBackground(g, x, y, width, height, sliderPos, minSliderPos, maxSliderPos, style, slider); + + juce::Rectangle bounds(x, y, width, height); + int trackWidth = 4; + juce::Rectangle track(bounds.getCentreX() - trackWidth / 2, + bounds.getY() + 4, + trackWidth, + bounds.getHeight() - 8); + + int thumbHeight = juce::jmin(52, track.getHeight() / 2); + int thumbWidth = 30; + int thumbY = juce::jlimit(bounds.getY(), bounds.getBottom() - thumbHeight, + (int)sliderPos - thumbHeight / 2); + juce::Rectangle thumb(track.getCentreX() - thumbWidth / 2, thumbY, thumbWidth, thumbHeight); + + const juce::Colour base = editor != nullptr ? editor->faderThemeColour : juce::Colour(0xff666666); + juce::ColourGradient grad(base.brighter(0.5f), (float)thumb.getX(), (float)thumb.getY(), + base.darker(0.4f), (float)thumb.getRight(), (float)thumb.getBottom(), false); + if (editor != nullptr && normaliseColourPresetName(editor->faderColourPreset).startsWith("multi")) + grad.addColour(0.4, juce::Colour::fromHSV((float)slider.getValue() * 0.8f, 0.8f, 1.0f, 1.0f)); + else + grad.addColour(0.4, base.brighter(0.2f)); + g.setGradientFill(grad); + g.fillRect(thumb); + + g.setColour(juce::Colours::black.withAlpha(0.7f)); + g.drawRect(thumb); + + g.setColour(juce::Colours::white.withAlpha(0.4f)); + int innerY = thumb.getY() + 4; + for (int i = 0; i < 4; ++i) + { + g.drawLine((float)(thumb.getX() + 2), (float)innerY, (float)(thumb.getRight() - 2), (float)innerY); + innerY += 4; + } + + double v = slider.getValue(); + double db = (v <= 1.0e-6) ? -60.0 : 20.0 * std::log10(v); + int dbInt = (int)std::round(db); + + juce::Rectangle box(thumb.getX() + 3, thumb.getY() + 6, thumb.getWidth() - 6, 14); + g.setColour(juce::Colours::black); + g.fillRect(box); + g.setColour(juce::Colours::white.withAlpha(0.9f)); + + juce::String text; + if (v <= 1.0e-6) + text = "-inf"; + else if (dbInt > 0) + text = "+" + juce::String(dbInt) + " dB"; + else + text = juce::String(dbInt) + " dB"; + + g.setFont(10.0f); + g.drawText(text, box, juce::Justification::centred, false); + } + else if (style == juce::Slider::LinearHorizontal) + { + drawLinearSliderBackground(g, x, y, width, height, sliderPos, minSliderPos, maxSliderPos, style, slider); + + juce::Rectangle bounds(x, y, width, height); + int trackHeight = 4; + juce::Rectangle track(bounds.getX() + 4, + bounds.getCentreY() - trackHeight / 2, + bounds.getWidth() - 8, + trackHeight); + + int thumbWidth = juce::jmin(52, track.getWidth() / 2); + int thumbHeight = 24; + int thumbX = juce::jlimit(bounds.getX(), bounds.getRight() - thumbWidth, + (int)sliderPos - thumbWidth / 2); + juce::Rectangle thumb(thumbX, track.getCentreY() - thumbHeight / 2, thumbWidth, thumbHeight); + + const juce::Colour base = editor != nullptr ? editor->faderThemeColour : juce::Colour(0xff666666); + juce::ColourGradient grad(base.brighter(0.5f), (float)thumb.getX(), (float)thumb.getY(), + base.darker(0.4f), (float)thumb.getRight(), (float)thumb.getBottom(), false); + if (editor != nullptr && normaliseColourPresetName(editor->faderColourPreset).startsWith("multi")) + grad.addColour(0.4, juce::Colour::fromHSV((float)slider.getValue() * 0.8f, 0.8f, 1.0f, 1.0f)); + else + grad.addColour(0.4, base.brighter(0.2f)); + g.setGradientFill(grad); + g.fillRect(thumb); + + g.setColour(juce::Colours::black.withAlpha(0.7f)); + g.drawRect(thumb); + + g.setColour(juce::Colours::white.withAlpha(0.4f)); + int innerX = thumb.getX() + 4; + for (int i = 0; i < 4; ++i) + { + g.drawLine((float)innerX, (float)(thumb.getY() + 2), + (float)innerX, (float)(thumb.getBottom() - 2)); + innerX += 4; + } + + double v = slider.getValue(); + double db = (v <= 1.0e-6) ? -60.0 : 20.0 * std::log10(v); + int dbInt = (int)std::round(db); + + juce::Rectangle box(thumb.getX(), thumb.getY() - 16, thumb.getWidth(), 14); + g.setColour(juce::Colours::black); + g.fillRect(box); + g.setColour(juce::Colours::white.withAlpha(0.9f)); + + juce::String text; + if (v <= 1.0e-6) + text = "-inf"; + else if (dbInt > 0) + text = "+" + juce::String(dbInt) + " dB"; + else + text = juce::String(dbInt) + " dB"; + + g.setFont(10.0f); + g.drawText(text, box, juce::Justification::centred, false); + } + else + { + juce::LookAndFeel_V4::drawLinearSlider(g, x, y, width, height, sliderPos, minSliderPos, maxSliderPos, style, slider); + } +} + +class ServerListComponent : public juce::Component, + public juce::ListBoxModel +{ +public: + ServerListComponent(NinjamVst3AudioProcessor& p, + std::function onChooseServer, + std::function onConnectServer) + : processor(p), + onServerChosen(std::move(onChooseServer)), + onServerConnect(std::move(onConnectServer)) + { + addAndMakeVisible(listBox); + listBox.setModel(this); + listBox.setRowHeight(24); + + addAndMakeVisible(refreshButton); + refreshButton.setButtonText("Refresh"); + refreshButton.onClick = [this] { refreshServers(); }; + + addAndMakeVisible(connectButton); + connectButton.setButtonText("Set Server"); + connectButton.onClick = [this] + { + int row = listBox.getSelectedRow(); + chooseServer(row); + }; + + refreshServers(); + } + + void paint(juce::Graphics& g) override + { + g.fillAll(juce::Colours::darkgrey); + } + + void resized() override + { + auto area = getLocalBounds().reduced(8); + auto controls = area.removeFromBottom(30); + refreshButton.setBounds(controls.removeFromLeft(100)); + controls.removeFromLeft(8); + connectButton.setBounds(controls.removeFromLeft(120)); + listBox.setBounds(area); + } + + int getNumRows() override { return (int)servers.size(); } + + void paintListBoxItem(int rowNumber, juce::Graphics& g, int width, int height, bool rowIsSelected) override + { + if (rowNumber < 0 || rowNumber >= (int)servers.size()) + return; + + auto& s = servers[(size_t)rowNumber]; + + if (rowIsSelected) + g.fillAll(juce::Colours::darkblue.withAlpha(0.6f)); + else + g.fillAll(juce::Colours::darkgrey); + + g.setColour(juce::Colours::white); + + juce::String text; + text << s.name << " " + << s.userCount << "/" << s.userMax + << " " << juce::String(s.bpm, 1) << " BPM" + << " / " << s.bpi << " BPI"; + + g.drawText(text, 4, 0, width - 8, height, juce::Justification::centredLeft, true); + } + + void listBoxItemDoubleClicked(int row, const juce::MouseEvent&) override { connectServer(row); } + +private: + NinjamVst3AudioProcessor& processor; + juce::ListBox listBox; + juce::TextButton refreshButton; + juce::TextButton connectButton; + std::vector servers; + std::function onServerChosen; + std::function onServerConnect; + + void refreshServers() + { + processor.refreshPublicServers(); + servers = processor.getPublicServers(); + listBox.updateContent(); + repaint(); + } + + void chooseServer(int row) + { + if (row < 0 || row >= (int)servers.size()) + return; + auto& s = servers[(size_t)row]; + juce::String hostPort = s.host + ":" + juce::String(s.port); + if (onServerChosen) + onServerChosen(hostPort); + } + + void connectServer(int row) + { + if (row < 0 || row >= (int)servers.size()) + return; + auto& s = servers[(size_t)row]; + juce::String hostPort = s.host + ":" + juce::String(s.port); + if (onServerConnect) + onServerConnect(hostPort); + } +}; + +class ServerListWindow : public juce::DocumentWindow +{ +public: + ServerListWindow(NinjamVst3AudioProcessor& p, + std::function onChooseServer, + std::function onConnectServer) + : DocumentWindow("NINJAM Servers", juce::Colours::black, DocumentWindow::closeButton) + { + setUsingNativeTitleBar(true); + setResizable(true, true); + setContentOwned(new ServerListComponent(p, + std::move(onChooseServer), + std::move(onConnectServer)), true); + centreWithSize(600, 400); + setVisible(true); + } + + void closeButtonPressed() override { setVisible(false); } +}; + +// Forward declarations (defined after ChatWindow) +static juce::Colour senderColour(const juce::String& sender); +static void applyColoredChat(juce::TextEditor&, const juce::StringArray&, const juce::StringArray&); + +class ChatPopupComponent : public juce::Component +{ +public: + ChatPopupComponent(NinjamVst3AudioProcessor& p) : processor(p) + { + addAndMakeVisible(chatDisplay); + chatDisplay.setMultiLine(true); + chatDisplay.setReadOnly(true); + chatDisplay.setFont(juce::Font(14.0f)); + + addAndMakeVisible(chatInput); + chatInput.onReturnKey = [this] { sendClicked(); }; + + addAndMakeVisible(sendButton); + sendButton.setButtonText("Send"); + sendButton.onClick = [this] { sendClicked(); }; + + addAndMakeVisible(atButton); + atButton.setClickingTogglesState(true); + atButton.setWantsKeyboardFocus(false); + atButton.setToggleState(false, juce::dontSendNotification); + atButton.setLookAndFeel(&atPopupBtnLAF); + atButton.onClick = [this] { atToggled(); }; + } + + ~ChatPopupComponent() override + { + atButton.setLookAndFeel(nullptr); + } + + void resized() override + { + auto area = getLocalBounds().reduced(8); + auto inputArea = area.removeFromBottom(30); + auto atArea = inputArea.removeFromRight(40); + auto sendArea = inputArea.removeFromRight(60); + chatInput.setBounds(inputArea); + sendButton.setBounds(sendArea); + atButton.setBounds(atArea); + chatDisplay.setBounds(area); + } + + void setChatText(const juce::StringArray& lines, const juce::StringArray& senders) + { + applyColoredChat(chatDisplay, lines, senders); + } + +private: + NinjamVst3AudioProcessor& processor; + juce::TextEditor chatDisplay; + juce::TextEditor chatInput; + juce::TextButton sendButton; + juce::TextButton atButton; + ATButtonLookAndFeel atPopupBtnLAF; + + void sendClicked() + { + auto msg = chatInput.getText(); + if (msg.isNotEmpty()) + { + processor.sendChatMessage(msg); + chatInput.clear(); + } + } + + void atToggled() { processor.setAutoTranslateEnabled(atButton.getToggleState()); } +}; + +class ChatWindow : public juce::DocumentWindow +{ +public: + ChatWindow(NinjamVst3AudioProcessor& p, std::function onClosedCallback) + : DocumentWindow("NINJAM Chat", juce::Colours::black, DocumentWindow::closeButton), + onClosed(std::move(onClosedCallback)) + { + setUsingNativeTitleBar(true); + setResizable(true, true); + setContentOwned(new ChatPopupComponent(p), true); + centreWithSize(500, 400); + setVisible(true); + } + + void closeButtonPressed() override + { + setVisible(false); + if (onClosed) + onClosed(); + } + +private: + std::function onClosed; +}; + +// --- Chat colour helpers --- +static juce::Colour senderColour(const juce::String& sender) +{ + if (sender == "me") + return juce::Colours::white; + if (sender.isEmpty()) + return juce::Colour::fromRGB(160, 160, 120); // dim amber – system + + // Deterministic hash → one of 8 distinct palette colours + uint32_t h = 5381u; + for (auto c : sender) + h = h * 33u ^ (uint32_t)juce::CharacterFunctions::toUpperCase(c); + + static const juce::Colour palette[] = { + juce::Colour::fromRGB(100, 180, 255), // blue + juce::Colour::fromRGB( 80, 210, 140), // green + juce::Colour::fromRGB(255, 165, 80), // orange + juce::Colour::fromRGB(190, 120, 255), // purple + juce::Colour::fromRGB( 80, 220, 215), // teal + juce::Colour::fromRGB(255, 130, 160), // pink + juce::Colour::fromRGB(230, 200, 80), // gold + juce::Colour::fromRGB(160, 200, 100), // lime + }; + return palette[h % 8u]; +} + +static juce::String normaliseColourPresetName(const juce::String& name) +{ + auto s = name.trim().toLowerCase(); + s = s.removeCharacters(" _-"); + return s; +} + +static juce::Colour colourFromPresetName(const juce::String& preset, const juce::Colour& fallback) +{ + const auto key = normaliseColourPresetName(preset); + if (key == "gold") return juce::Colour(0xffb8860b); + if (key == "grey" || key == "gray") return juce::Colour(0xff909090); + if (key == "sand" || key == "sandcolour" || key == "sandcolor") return juce::Colour(0xffc2b280); + if (key == "yellow") return juce::Colour(0xffffd700); + if (key == "orange") return juce::Colour(0xffff8c00); + if (key == "red") return juce::Colour(0xffdc143c); + if (key == "blue") return juce::Colour(0xff1e90ff); + if (key == "pink") return juce::Colour(0xffff69b4); + if (key == "purpleblue") return juce::Colour(0xff6a5acd); + if (key == "black") return juce::Colour(0xff202020); + if (key == "cream") return juce::Colour(0xfffff5dc); + if (key == "white") return juce::Colour(0xfff2f2f2); + return fallback; +} + +static void applyColoredChat(juce::TextEditor& display, + const juce::StringArray& lines, + const juce::StringArray& senders) +{ + display.setReadOnly(false); + display.clear(); + const int n = lines.size(); + for (int i = 0; i < n; ++i) + { + const juce::String& sndr = (i < senders.size()) ? senders[i] : juce::String(); + display.setColour(juce::TextEditor::textColourId, senderColour(sndr)); + display.insertTextAtCaret(lines[i] + "\n"); + } + display.setReadOnly(true); + display.moveCaretToEnd(); +} +// --- end chat helpers --- + +void CustomKnobLookAndFeel::drawRotarySlider(juce::Graphics& g, int x, int y, int width, int height, + float sliderPos, const float rotaryStartAngle, + const float rotaryEndAngle, juce::Slider& slider) +{ + auto centreX = (float)x + (float)width * 0.5f; + auto centreY = (float)y + (float)height * 0.5f; + + auto* editor = dynamic_cast(slider.getParentComponent()); + if (editor == nullptr) + { + auto* p = slider.getParentComponent(); + while (p != nullptr && editor == nullptr) + { + editor = dynamic_cast(p); + p = p->getParentComponent(); + } + } + + if (editor != nullptr && editor->radioKnobImage.isValid()) + { + const float angle = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle); + const float radius = (float)juce::jmin(width / 2, height / 2) - 1.0f; + g.saveState(); + g.addTransform(juce::AffineTransform::rotation(angle, centreX, centreY)); + g.drawImageWithin(editor->radioKnobImage, + (int)(centreX - radius), (int)(centreY - radius), + (int)(radius * 2.0f), (int)(radius * 2.0f), + juce::RectanglePlacement::fillDestination); + g.restoreState(); + return; + } + + const float angle = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle); + const float outerRadius = (float)juce::jmin(width / 2, height / 2) - 4.0f; + + const auto knobPreset = editor != nullptr ? normaliseColourPresetName(editor->knobColourPreset) : juce::String(); + const bool multiColourKnob = knobPreset.startsWith("multi"); + const juce::Colour knobBase = editor != nullptr ? editor->knobThemeColour : juce::Colours::grey; + const juce::Colour ringFill = knobBase.darker(1.1f); + const juce::Colour ringStroke = knobBase.darker(1.4f); + const juce::Colour tickColour = multiColourKnob + ? juce::Colour::fromHSV(sliderPos * 0.8f, 0.8f, 1.0f, 1.0f) + : knobBase.brighter(0.1f); + + // --- Tick marks --- + const int numTicks = 11; + for (int i = 0; i < numTicks; ++i) + { + float tickAngle = rotaryStartAngle + (float)i / (float)(numTicks - 1) * (rotaryEndAngle - rotaryStartAngle); + float s = std::sin(tickAngle), c = -std::cos(tickAngle); + float inner = outerRadius + 3.0f; + float outer = outerRadius + 7.0f; + g.setColour(tickColour); + g.drawLine(centreX + s * inner, centreY + c * inner, + centreX + s * outer, centreY + c * outer, 1.2f); + } + + // --- Knurled outer ring --- + const float ringRadius = outerRadius; + const int teeth = 24; + juce::Path ring; + for (int i = 0; i <= teeth * 2; ++i) + { + float a = (float)i / (float)(teeth * 2) * juce::MathConstants::twoPi; + float r = (i % 2 == 0) ? ringRadius : ringRadius - 3.0f; + float px = centreX + std::sin(a) * r; + float py = centreY - std::cos(a) * r; + if (i == 0) ring.startNewSubPath(px, py); + else ring.lineTo(px, py); + } + ring.closeSubPath(); + g.setColour(ringFill); + g.fillPath(ring); + g.setColour(ringStroke); + g.strokePath(ring, juce::PathStrokeType(0.8f)); + + // --- Inner cap with radial gradient --- + const float capRadius = ringRadius - 5.0f; + const juce::Colour capHighlight = multiColourKnob + ? juce::Colour::fromHSV(sliderPos * 0.8f, 0.7f, 1.0f, 1.0f) + : knobBase.brighter(0.75f); + const juce::Colour capShadow = multiColourKnob + ? juce::Colour::fromHSV(sliderPos * 0.8f, 0.9f, 0.45f, 1.0f) + : knobBase.darker(0.8f); + juce::ColourGradient capGrad(capHighlight, centreX - capRadius * 0.35f, centreY - capRadius * 0.35f, + capShadow, centreX + capRadius * 0.5f, centreY + capRadius * 0.6f, true); + capGrad.addColour(0.45, knobBase.brighter(0.35f)); + g.setGradientFill(capGrad); + g.fillEllipse(centreX - capRadius, centreY - capRadius, capRadius * 2.0f, capRadius * 2.0f); + + // Subtle rim shadow on cap + g.setColour(juce::Colours::black.withAlpha(0.35f)); + g.drawEllipse(centreX - capRadius, centreY - capRadius, capRadius * 2.0f, capRadius * 2.0f, 1.5f); + + // Specular highlight (top-left arc) + juce::Path highlight; + highlight.addArc(centreX - capRadius * 0.65f, centreY - capRadius * 0.65f, + capRadius * 1.3f, capRadius * 1.3f, + -juce::MathConstants::pi * 0.9f, + -juce::MathConstants::pi * 0.2f, true); + g.setColour(juce::Colours::white.withAlpha(multiColourKnob ? 0.35f : 0.28f)); + g.strokePath(highlight, juce::PathStrokeType(capRadius * 0.18f)); + + // --- Indicator line --- + const float lineStart = capRadius * 0.22f; + const float lineEnd = capRadius * 0.82f; + g.setColour(juce::Colours::white.withAlpha(0.95f)); + g.drawLine(centreX + std::sin(angle) * lineStart, centreY - std::cos(angle) * lineStart, + centreX + std::sin(angle) * lineEnd, centreY - std::cos(angle) * lineEnd, + 2.2f); + // Bright dot at tip + g.setColour(juce::Colours::white); + float dotX = centreX + std::sin(angle) * lineEnd; + float dotY = centreY - std::cos(angle) * lineEnd; + g.fillEllipse(dotX - 2.0f, dotY - 2.0f, 4.0f, 4.0f); +} + +NinjamVst3AudioProcessorEditor::NinjamVst3AudioProcessorEditor (NinjamVst3AudioProcessor& p) + : AudioProcessorEditor (&p), audioProcessor (p), intervalDisplay(p), userList(p) +{ + setSize (isAbletonLiveHost() ? 1240 : 1350, 600); + setResizable(true, true); + setResizeLimits(900, 500, 2200, 1500); + + juce::LookAndFeel::setDefaultLookAndFeel(&outlinedLabelLAF); + + serverLabel.setJustificationType(juce::Justification::centredRight); + addAndMakeVisible(serverLabel); + serverField.setText(""); + serverField.setIndents(4, 8); + serverField.onReturnKey = [this] { connectClicked(); }; + addAndMakeVisible(serverField); + + addAndMakeVisible(serverListButton); + serverListButton.setButtonText("Servers"); + serverListButton.setTooltip("Click to View Servers"); + serverListButton.onClick = [this] { serverListClicked(); }; + + userLabel.setJustificationType(juce::Justification::centredRight); + addAndMakeVisible(userLabel); + userField.setText("user" + juce::String(juce::Random::getSystemRandom().nextInt(100))); + userField.setIndents(4, 8); + addAndMakeVisible(userField); + + addAndMakeVisible(anonymousButton); + anonymousButton.setToggleState(true, juce::dontSendNotification); + anonymousButton.onClick = [this] { anonymousToggled(); }; + + passLabel.setJustificationType(juce::Justification::centredRight); + addAndMakeVisible(passLabel); + addAndMakeVisible(passField); + passField.setIndents(4, 8); + passField.setEnabled(false); + + addAndMakeVisible(connectButton); + connectButton.setButtonText("Connect"); + connectButton.onClick = [this] { connectClicked(); }; + + addAndMakeVisible(statusLabel); + + addAndMakeVisible(transmitButton); + transmitButton.setClickingTogglesState(true); + transmitButton.onClick = [this] { transmitToggled(); }; + updateTransmitButtonColor(); + + addAndMakeVisible(localMonitorButton); + localMonitorButton.setClickingTogglesState(true); + localMonitorButton.onClick = [this] + { + audioProcessor.setLocalMonitorEnabled(localMonitorButton.getToggleState()); + updateMonitorButtonColor(); + }; + updateMonitorButtonColor(); + + addAndMakeVisible(voiceChatButton); + voiceChatButton.setClickingTogglesState(true); + voiceChatButton.setToggleState(false, juce::dontSendNotification); + voiceChatButton.onClick = [this] + { + audioProcessor.setVoiceChatMode(voiceChatButton.getToggleState()); + voiceChatGlowPhase = 0.0f; + updateVoiceChatButtonColor(); + }; + updateVoiceChatButtonColor(); + + bitrateSelector.addItem("64 kbps", 1); + bitrateSelector.addItem("96 kbps", 2); + bitrateSelector.addItem("128 kbps", 3); + bitrateSelector.addItem("160 kbps", 4); + bitrateSelector.addItem("192 kbps", 5); + bitrateSelector.addItem("256 kbps", 6); + bitrateSelector.addItem("320 kbps", 7); + bitrateSelector.setSelectedId(3, juce::dontSendNotification); // 128 kbps default + bitrateSelector.onChange = [this] + { + const int bitrateValues[] = { 64, 96, 128, 160, 192, 256, 320 }; + int idx = bitrateSelector.getSelectedId() - 1; + if (idx >= 0 && idx < 7) + audioProcessor.setLocalBitrate(bitrateValues[idx]); + }; + addAndMakeVisible(bitrateSelector); + bitrateSelector.setTooltip("Quality"); + + addAndMakeVisible(midiRelayTargetSelector); + midiRelayTargetSelector.setTooltip("Send Midi"); + midiRelayTargetSelector.onChange = [this] + { + int id = midiRelayTargetSelector.getSelectedId(); + auto it = midiRelayTargetByMenuId.find(id); + if (it != midiRelayTargetByMenuId.end()) + audioProcessor.setMidiRelayTarget(it->second); + }; + refreshMidiRelayTargetSelector(); + addListener(this); + if (!connect(9001)) + for (int port = 9002; port <= 9010; ++port) + if (connect(port)) + break; + + addAndMakeVisible(videoButton); + videoButton.setTooltip("VDO Synced Video"); + videoButton.onClick = [this] { videoClicked(); }; + + addAndMakeVisible(layoutButton); + layoutButton.setClickingTogglesState(true); + layoutButton.setTooltip("Vertical Mixer"); + layoutButton.setLookAndFeel(&faderIconLookAndFeel); + layoutButton.onClick = [this] { layoutToggled(); updateLayoutButtonColor(); }; + updateLayoutButtonColor(); + + addAndMakeVisible(autoLevelButton); + autoLevelButton.setClickingTogglesState(true); + autoLevelButton.setTooltip("Auto Adjust Volume"); + autoLevelButton.onClick = [this] + { + bool newState = autoLevelButton.getToggleState(); + if (newState == autoLevelEnabled) + return; + + autoLevelEnabled = newState; + if (!autoLevelEnabled) + { + auto users = audioProcessor.getConnectedUsers(); + for (auto& u : users) + audioProcessor.setUserVolume(u.index, u.volume); + + autoLevelCurrentGains.clear(); + autoLevelPeakLevels.clear(); + autoLevelChannelActiveTicks.clear(); + } + updateAutoLevelButtonColor(); + }; + updateAutoLevelButtonColor(); + + addAndMakeVisible(metronomeLabel); + addAndMakeVisible(metronomeSlider); + metronomeSlider.setRange(0.0, 1.0); + metronomeSlider.setValue(0.5); + metronomeSlider.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + metronomeSlider.setTooltip("Metronome Volume"); + metronomeSlider.onValueChange = [this] { metronomeChanged(); }; + + addAndMakeVisible(metronomeMuteButton); + metronomeMuteButton.setClickingTogglesState(true); + metronomeMuteButton.setToggleState(true, juce::dontSendNotification); // starts unmuted + metronomeMuteButton.setTooltip("Mute Metronome"); + metronomeMuteButton.setLookAndFeel(&metronomeBtnLAF); + metronomeMuteButton.onClick = [this] + { + if (metronomeMuteButton.getToggleState()) + { + // unmuting: restore stored volume + metronomeSlider.setValue(storedMetronomeVolume, juce::dontSendNotification); + audioProcessor.setMetronomeVolume(storedMetronomeVolume); + } + else + { + // muting: store current volume and silence + storedMetronomeVolume = (float)metronomeSlider.getValue(); + audioProcessor.setMetronomeVolume(0.0f); + } + updateMetronomeButtonColor(); + }; + updateMetronomeButtonColor(); + + addAndMakeVisible(syncButton); + syncButton.setClickingTogglesState(true); + syncButton.setToggleState(false, juce::dontSendNotification); +#if JucePlugin_Build_Standalone + syncButton.setTooltip("Click to Sync to Midi Clock"); +#else + syncButton.setTooltip("Click to Sync to Host (vst)"); +#endif + syncButton.setLookAndFeel(&syncIconLAF); + syncButton.onClick = [this] { syncToggled(); updateSyncButtonColor(); }; + updateSyncButtonColor(); + + addAndMakeVisible(fxButton); + fxButton.onClick = [this] { showFxMenu(); }; + updateFxButtonLabel(); + addAndMakeVisible(optionsButton); + optionsButton.onClick = [this] { showOptionsMenu(); }; + + addAndMakeVisible(tempoLabel); + tempoLabel.setJustificationType(juce::Justification::centredLeft); + + addAndMakeVisible(chatButton); + chatButton.setClickingTogglesState(true); + chatButton.setWantsKeyboardFocus(false); + chatButton.setToggleState(false, juce::dontSendNotification); + chatButton.setTooltip("Open Chat"); + chatButton.setLookAndFeel(&chatBtnLAF); + chatButton.onClick = [this] { chatToggled(); }; + updateChatButtonColor(); + + addAndMakeVisible(usersLabel); + addAndMakeVisible(spreadOutputsButton); + spreadOutputsButton.setClickingTogglesState(true); + spreadOutputsButton.setToggleState(false, juce::dontSendNotification); + spreadOutputsButton.onClick = [this] + { + audioProcessor.setSpreadOutputsEnabled(spreadOutputsButton.getToggleState()); + }; + addAndMakeVisible(userList); + + addAndMakeVisible(addLocalChannelButton); + addLocalChannelButton.setTooltip("Add Channel"); + addAndMakeVisible(removeLocalChannelButton); + removeLocalChannelButton.setTooltip("Remove Channel"); + addAndMakeVisible(localFaderLabel); + localFaderLabel.setJustificationType(juce::Justification::centred); + + addLocalChannelButton.onClick = [this] + { + int current = audioProcessor.getNumLocalChannels(); + if (current < NinjamVst3AudioProcessor::maxLocalChannels) + { + audioProcessor.setNumLocalChannels(current + 1); + for (int i = 0; i < NinjamVst3AudioProcessor::maxLocalChannels; ++i) + localChannelNameLabels[(size_t)i].setText(audioProcessor.getLocalChannelName(i), juce::dontSendNotification); + resized(); + } + }; + + removeLocalChannelButton.onClick = [this] + { + int current = audioProcessor.getNumLocalChannels(); + if (current > 1) + { + audioProcessor.setNumLocalChannels(current - 1); + for (int i = 0; i < NinjamVst3AudioProcessor::maxLocalChannels; ++i) + localChannelNameLabels[(size_t)i].setText(audioProcessor.getLocalChannelName(i), juce::dontSendNotification); + resized(); + } + }; + + int totalInputs = audioProcessor.getTotalNumInputChannels(); + if (totalInputs <= 0) + totalInputs = 2; + int numPairs = totalInputs / 2; + + for (int i = 0; i < NinjamVst3AudioProcessor::maxLocalChannels; ++i) + { + addAndMakeVisible(localFaders[(size_t)i]); + addAndMakeVisible(localPeakMeters[(size_t)i]); + addAndMakeVisible(localInputSelectors[(size_t)i]); + addAndMakeVisible(localInputModeSelectors[(size_t)i]); + addAndMakeVisible(localDbLabels[(size_t)i]); + addAndMakeVisible(localReverbSendKnobs[(size_t)i]); + addAndMakeVisible(localDelaySendKnobs[(size_t)i]); + addAndMakeVisible(localReverbSendLabels[(size_t)i]); + addAndMakeVisible(localDelaySendLabels[(size_t)i]); + + // Editable channel name label + auto& nameLabel = localChannelNameLabels[(size_t)i]; + nameLabel.setText(audioProcessor.getLocalChannelName(i), juce::dontSendNotification); + nameLabel.setEditable(true, false); // single-click to edit + nameLabel.setJustificationType(juce::Justification::centred); + nameLabel.setColour(juce::Label::textColourId, juce::Colours::lightgrey); + nameLabel.setColour(juce::Label::backgroundColourId, juce::Colour(0xff1a1a1a)); + nameLabel.setColour(juce::Label::outlineColourId, juce::Colour(0xff333333)); + nameLabel.setTooltip("Click to name this channel"); + int ch = i; + nameLabel.onTextChange = [this, ch] + { + audioProcessor.setLocalChannelName(ch, localChannelNameLabels[(size_t)ch].getText()); + }; + addAndMakeVisible(nameLabel); + + auto& fader = localFaders[(size_t)i]; + fader.setSliderStyle(juce::Slider::LinearVertical); + fader.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + fader.setRange(0.0, 2.0); + fader.setSkewFactorFromMidPoint(0.25); + fader.setValue(audioProcessor.getLocalChannelGain(i), juce::dontSendNotification); + fader.setDoubleClickReturnValue(true, 1.0); + fader.setLookAndFeel(&mixerFaderLookAndFeel); + + fader.onValueChange = [this, i] + { + float value = (float)localFaders[(size_t)i].getValue(); + audioProcessor.setLocalChannelGain(i, value); + if (i == 0) + audioProcessor.setLocalInputGain(value); + }; + registerMidiLearnTarget(fader, "local.fader." + juce::String(i + 1), false); + + auto& revSend = localReverbSendKnobs[(size_t)i]; + revSend.setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag); + revSend.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + revSend.setRange(0.0, 1.0); + revSend.setValue(audioProcessor.getLocalChannelReverbSend(i), juce::dontSendNotification); + revSend.setLookAndFeel(&customKnobLookAndFeel); + revSend.setTooltip("Reverb Send"); + revSend.onValueChange = [this, i] + { + audioProcessor.setLocalChannelReverbSend(i, (float)localReverbSendKnobs[(size_t)i].getValue()); + }; + registerMidiLearnTarget(revSend, "local.send.reverb." + juce::String(i + 1), false); + + auto& dlySend = localDelaySendKnobs[(size_t)i]; + dlySend.setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag); + dlySend.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + dlySend.setRange(0.0, 1.0); + dlySend.setValue(audioProcessor.getLocalChannelDelaySend(i), juce::dontSendNotification); + dlySend.setLookAndFeel(&customKnobLookAndFeel); + dlySend.setTooltip("Delay Send"); + dlySend.onValueChange = [this, i] + { + audioProcessor.setLocalChannelDelaySend(i, (float)localDelaySendKnobs[(size_t)i].getValue()); + }; + registerMidiLearnTarget(dlySend, "local.send.delay." + juce::String(i + 1), false); + + auto& revLbl = localReverbSendLabels[(size_t)i]; + revLbl.setText("Rev", juce::dontSendNotification); + revLbl.setJustificationType(juce::Justification::centred); + revLbl.setFont(juce::Font(9.0f)); + + auto& dlyLbl = localDelaySendLabels[(size_t)i]; + dlyLbl.setText("Dly", juce::dontSendNotification); + dlyLbl.setJustificationType(juce::Justification::centred); + dlyLbl.setFont(juce::Font(9.0f)); + + auto& selector = localInputSelectors[(size_t)i]; + selector.clear(juce::dontSendNotification); + + for (int ch = 0; ch < totalInputs; ++ch) + selector.addItem("In " + juce::String(ch + 1), ch + 1); + + int stereoBaseId = 100; + for (int pair = 0; pair < numPairs; ++pair) + { + int left = pair * 2 + 1; + int right = left + 1; + selector.addItem(juce::String(left) + "/" + juce::String(right), stereoBaseId + pair); + } + + int currentInput = audioProcessor.getLocalChannelInput(i); + if (currentInput >= 0 && currentInput < totalInputs) + { + selector.setSelectedId(currentInput + 1, juce::dontSendNotification); + } + else if (currentInput < 0) + { + int pairIndex = -1 - currentInput; + if (numPairs > pairIndex) + { + selector.setSelectedId(stereoBaseId + pairIndex, juce::dontSendNotification); + } + else if (numPairs > 0) + { + // Preferred pair unavailable, use first available stereo pair + selector.setSelectedId(stereoBaseId, juce::dontSendNotification); + audioProcessor.setLocalChannelInput(i, -1); + } + else if (totalInputs > 0) + { + // No stereo pairs at all, fall back to mono channel 1 + selector.setSelectedId(1, juce::dontSendNotification); + audioProcessor.setLocalChannelInput(i, 0); + } + } + + selector.onChange = [this, i] + { + int id = localInputSelectors[(size_t)i].getSelectedId(); + if (id <= 0) + return; + + int total = audioProcessor.getTotalNumInputChannels(); + if (total <= 0) + total = 2; + int numPairsLocal = total / 2; + int stereoBase = 100; + + if (id >= 1 && id <= total) + { + audioProcessor.setLocalChannelInput(i, id - 1); + applyRemoteMidiRelaySelection(i, id - 1); + localInputModeSelectors[(size_t)i].setSelectedId(1, juce::dontSendNotification); + } + else if (id >= stereoBase && id < stereoBase + numPairsLocal) + { + int pairIndex = id - stereoBase; + audioProcessor.setLocalChannelInput(i, -1 - pairIndex); + applyRemoteMidiRelaySelection(i, -1 - pairIndex); + localInputModeSelectors[(size_t)i].setSelectedId(2, juce::dontSendNotification); + } + }; + + auto& modeSelector = localInputModeSelectors[(size_t)i]; + modeSelector.addItem("Mono", 1); + modeSelector.addItem("Stereo", 2); + modeSelector.setSelectedId(currentInput < 0 ? 2 : 1, juce::dontSendNotification); + } + + addAndMakeVisible(masterFaderLabel); + masterFaderLabel.setJustificationType(juce::Justification::centred); + addAndMakeVisible(masterFader); + masterFader.setSliderStyle(juce::Slider::LinearVertical); + masterFader.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + masterFader.setRange(0.0, 2.0); + masterFader.setSkewFactorFromMidPoint(0.25); + masterFader.setValue(1.0, juce::dontSendNotification); + masterFader.setDoubleClickReturnValue(true, 1.0); + masterFader.setLookAndFeel(&mixerFaderLookAndFeel); + masterFader.onValueChange = [this] + { + audioProcessor.setMasterOutputGain((float)masterFader.getValue()); + }; + registerMidiLearnTarget(masterFader, "master.fader", false); + addAndMakeVisible(masterPeakMeter); + addAndMakeVisible(masterDbLabel); + masterDbLabel.setFont(juce::Font(9.0f)); + masterDbLabel.setJustificationType(juce::Justification::centred); + addAndMakeVisible(limiterButton); + addAndMakeVisible(limiterReleaseLabel); + limiterReleaseLabel.setJustificationType(juce::Justification::centred); + limiterReleaseLabel.setTooltip("Limiter Release Amount"); + limiterButton.setClickingTogglesState(true); + limiterButton.setTooltip("Master Limiter Gain"); + limiterButton.setToggleState(false, juce::dontSendNotification); + limiterButton.onClick = [this] + { + audioProcessor.setMasterLimiterEnabled(limiterButton.getToggleState()); + updateLimiterButtonColor(); + }; + updateLimiterButtonColor(); + addAndMakeVisible(limiterThresholdSlider); + limiterThresholdSlider.setSliderStyle(juce::Slider::LinearVertical); + limiterThresholdSlider.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + limiterThresholdSlider.setRange(-6.0, 0.0); + limiterThresholdSlider.setValue(0.0, juce::dontSendNotification); + limiterThresholdSlider.setLookAndFeel(&mixerFaderLookAndFeel); + limiterThresholdSlider.onValueChange = [this] + { + audioProcessor.setLimiterThreshold((float)limiterThresholdSlider.getValue()); + }; + registerMidiLearnTarget(limiterThresholdSlider, "limiter.threshold", false); + + addAndMakeVisible(limiterReleaseSlider); + limiterReleaseSlider.setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag); + limiterReleaseSlider.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + limiterReleaseSlider.setRange(10.0, 1000.0); + limiterReleaseSlider.setValue(100.0, juce::dontSendNotification); + limiterReleaseSlider.setTooltip("Limiter Release Amount"); + limiterReleaseSlider.setLookAndFeel(&customKnobLookAndFeel); + limiterReleaseSlider.onValueChange = [this] + { + audioProcessor.setLimiterRelease((float)limiterReleaseSlider.getValue()); + }; + registerMidiLearnTarget(limiterReleaseSlider, "limiter.release", false); + + addAndMakeVisible(delayTimeLabel); + delayTimeLabel.setJustificationType(juce::Justification::centred); + addAndMakeVisible(delayTimeSlider); + delayTimeSlider.setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag); + delayTimeSlider.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + delayTimeSlider.setRange(20.0, 2000.0); + delayTimeSlider.setValue(audioProcessor.getFxDelayTimeMs(), juce::dontSendNotification); + delayTimeSlider.setLookAndFeel(&customKnobLookAndFeel); + delayTimeSlider.onValueChange = [this] + { + audioProcessor.setFxDelayTimeMs((float)delayTimeSlider.getValue()); + }; + registerMidiLearnTarget(delayTimeSlider, "fx.delay.time", false); + + addAndMakeVisible(delayDivisionSelector); + delayDivisionSelector.addItem("1/16", 16); + delayDivisionSelector.addItem("1/8", 8); + delayDivisionSelector.addItem("1/1", 1); + delayDivisionSelector.setSelectedId(audioProcessor.getFxDelayDivision(), juce::dontSendNotification); + delayDivisionSelector.onChange = [this] + { + int division = delayDivisionSelector.getSelectedId(); + if (division <= 0) + division = 8; + audioProcessor.setFxDelayDivision(division); + }; + + addAndMakeVisible(delayPingPongButton); + delayPingPongButton.setClickingTogglesState(true); + delayPingPongButton.setToggleState(audioProcessor.isFxDelayPingPong(), juce::dontSendNotification); + delayPingPongButton.onClick = [this] + { + audioProcessor.setFxDelayPingPong(delayPingPongButton.getToggleState()); + }; + registerMidiLearnTarget(delayPingPongButton, "fx.delay.pingpong", true); + + addAndMakeVisible(chatDisplay); + chatDisplay.setMultiLine(true); + chatDisplay.setReadOnly(true); + chatDisplay.setFont(juce::Font(14.0f)); + + addAndMakeVisible(chatInput); + chatInput.onReturnKey = [this] { sendClicked(); }; + + addAndMakeVisible(sendButton); + sendButton.onClick = [this] { sendClicked(); }; + + addAndMakeVisible(atButton); + atButton.setClickingTogglesState(true); + atButton.setWantsKeyboardFocus(false); + atButton.setToggleState(false, juce::dontSendNotification); + atButton.setLookAndFeel(&atBtnLAF); + atButton.onClick = [this] { atToggled(); }; + + addAndMakeVisible(chatPopoutButton); + chatPopoutButton.setButtonText("Popout"); + chatPopoutButton.onClick = [this] { chatPopoutClicked(); }; + + addAndMakeVisible(videoBgToggle); + videoBgToggle.setToggleState(true, juce::dontSendNotification); + videoBgToggle.onClick = [this] + { + int idx = backgroundSelector.getSelectedItemIndex(); + if (idx >= 0 && idx < textureFiles.size()) + loadControlImages(textureFiles[idx]); + }; + + addAndMakeVisible(backgroundSelector); + backgroundSelector.setTooltip("Skin"); + { + auto texturesDir = juce::File::getSpecialLocation(juce::File::currentExecutableFile) + .getParentDirectory().getChildFile("textures"); + if (texturesDir.isDirectory()) + { + // Each subdirectory is a theme; its name shows in the dropdown + auto dirs = texturesDir.findChildFiles(juce::File::findDirectories, false); + dirs.sort(); + for (int i = 0; i < dirs.size(); ++i) + { + if (dirs[i].getFileName().equalsIgnoreCase("Skin Template")) + continue; + // Only include dirs that contain a bg.* file + auto bgFiles = dirs[i].findChildFiles(juce::File::findFiles, false, "bg.*"); + if (bgFiles.isEmpty()) continue; + textureFiles.add(dirs[i]); + backgroundSelector.addItem(dirs[i].getFileName(), textureFiles.size()); + } + } + if (backgroundSelector.getNumItems() == 0) + backgroundSelector.addItem("Default", 1); + + // Determine which texture to select: saved preference > "Brushed Metal 1" > first item + juce::PropertiesFile::Options popts; + popts.applicationName = "NINJAM VST3"; + popts.filenameSuffix = "settings"; + popts.folderName = "NINJAM VST3"; + popts.osxLibrarySubFolder = "Application Support"; + juce::PropertiesFile props(popts); + juce::String savedTexture = props.getValue("texture", ""); + abletonWindowSizePreset = juce::jlimit(0, 2, props.getIntValue("abletonWindowSizePreset", 1)); + + int selectIdx = -1; + if (savedTexture.isNotEmpty()) + for (int i = 0; i < textureFiles.size(); ++i) + if (textureFiles[i].getFileName() == savedTexture) { selectIdx = i; break; } + if (selectIdx < 0) + for (int i = 0; i < textureFiles.size(); ++i) + if (textureFiles[i].getFileName() == "Brushed Metal 1") { selectIdx = i; break; } + if (selectIdx < 0 && backgroundSelector.getNumItems() > 0) + selectIdx = 0; + + if (selectIdx >= 0) + backgroundSelector.setSelectedId(backgroundSelector.getItemId(selectIdx), juce::dontSendNotification); + + // Load the selected texture immediately + if (selectIdx >= 0 && selectIdx < textureFiles.size()) + loadControlImages(textureFiles[selectIdx]); + } + backgroundSelector.onChange = [this] + { + int idx = backgroundSelector.getSelectedItemIndex(); + if (idx >= 0 && idx < textureFiles.size()) + { + // Persist the user's choice + juce::PropertiesFile::Options popts; + popts.applicationName = "NINJAM VST3"; + popts.filenameSuffix = "settings"; + popts.folderName = "NINJAM VST3"; + popts.osxLibrarySubFolder = "Application Support"; + juce::PropertiesFile props(popts); + props.setValue("texture", textureFiles[idx].getFileName()); + props.saveIfNeeded(); + + loadControlImages(textureFiles[idx]); + } + else + { + backgroundImage = juce::Image(); + radioKnobImage = juce::Image(); + faderKnobImage = juce::Image(); + metronomeThemeColour = juce::Colour::fromRGB(80, 185, 255); + windowThemeColour = juce::Colour(0x00000000); + applyThemeColours(); + } + repaint(); + }; + + if (isAbletonLiveHost() && !audioProcessor.isStandaloneWrapper()) + setAbletonWindowSizePreset(abletonWindowSizePreset); + + addAndMakeVisible(intervalDisplay); + registerMidiLearnTarget(metronomeSlider, "metronome.level", false); + registerMidiLearnTarget(transmitButton, "button.transmit", true); + registerMidiLearnTarget(localMonitorButton, "button.monitor", true); + registerMidiLearnTarget(voiceChatButton, "button.voicechat", true); + registerMidiLearnTarget(metronomeMuteButton, "button.metronomemute", true); + registerMidiLearnTarget(syncButton, "button.sync", true); + registerMidiLearnTarget(chatButton, "button.chat", true); + registerMidiLearnTarget(spreadOutputsButton, "button.spreadoutputs", true); + registerMidiLearnTarget(autoLevelButton, "button.autolevel", true); + registerMidiLearnTarget(limiterButton, "button.limiter", true); + syncUserStripMidiTargets(); + updateFxControlsVisibility(); + loadLearnMappingsFromProcessor(); + refreshExternalMidiInputDevices(); + + startTimer(30); +} + +NinjamVst3AudioProcessorEditor::~NinjamVst3AudioProcessorEditor() +{ +#if JUCE_WINDOWS + videoFrameReader.reset(); +#endif + for (auto& pair : midiTargetsByComponent) + if (pair.first != nullptr) + pair.first->removeMouseListener(this); + midiTargetsByComponent.clear(); + midiTargetsById.clear(); + midiSourceByTargetId.clear(); + oscSourceByTargetId.clear(); + midiLearnArmedTargetId.clear(); + oscLearnArmedTargetId.clear(); + midiLearnInputDevice.reset(); + midiRelayInputDevice.reset(); + openedMidiLearnInputDeviceId.clear(); + openedMidiRelayInputDeviceId.clear(); + stopTimer(); + disconnect(); + atButton.setLookAndFeel(nullptr); + chatButton.setLookAndFeel(nullptr); + metronomeMuteButton.setLookAndFeel(nullptr); + juce::LookAndFeel::setDefaultLookAndFeel(nullptr); +} + +void NinjamVst3AudioProcessorEditor::paint (juce::Graphics& g) +{ + if (backgroundImage.isValid()) + { + g.drawImageWithin(backgroundImage, 0, 0, getWidth(), getHeight(), juce::RectanglePlacement::fillDestination); + } + else + { + // Window Colour sets the app background; falls back to dark grey if not set + juce::Colour base = (windowThemeColour.getAlpha() > 0) ? windowThemeColour : juce::Colour(0xff222222); + g.fillAll(base); + } +} + +void NinjamVst3AudioProcessorEditor::paintOverChildren(juce::Graphics& g) +{ + // Helper: draw a small tight radial glow around any toggle button + auto drawGlow = [&](juce::Button& btn, juce::Colour onColour, juce::Colour offColour) + { + if (!btn.isVisible()) return; + bool isOn = btn.getToggleState(); + auto bc = btn.getBounds().toFloat(); + auto centre = bc.getCentre(); + // Glow starts a few px outside the button edge + float gap = 5.0f; + float r = bc.getWidth() * 0.55f + gap; // compact radius + juce::Colour col = isOn ? onColour : offColour; + juce::ColourGradient grad(col, centre.x, centre.y, + juce::Colours::transparentBlack, centre.x + r, centre.y, true); + g.setGradientFill(grad); + g.fillEllipse(centre.x - r, centre.y - r, r * 2.0f, r * 2.0f); + }; + + drawGlow(transmitButton, juce::Colour(0x5532cc60), juce::Colour(0x22154420)); // green + drawGlow(localMonitorButton, juce::Colour(0x55ff3232), juce::Colour(0x22441515)); // red + drawGlow(autoLevelButton, juce::Colour(0x55ffdd20), juce::Colour(0x22443a10)); // yellow + drawGlow(limiterButton, juce::Colour(0x55ff3232), juce::Colour(0x22441515)); // red + drawGlow(layoutButton, juce::Colour(0x5520c8e8), juce::Colour(0x220a3240)); // teal + drawGlow(metronomeMuteButton, + metronomeThemeColour.withAlpha(0.33f), + metronomeThemeColour.withMultipliedBrightness(0.10f).withAlpha(0.13f)); // themed + drawGlow(syncButton, juce::Colour(0x55ff9820), juce::Colour(0x22301808)); // orange + if (atButton.isVisible()) + drawGlow(atButton, juce::Colour(0x5550c8ff), juce::Colour(0x220a2840)); // sky blue + drawGlow(chatButton, juce::Colour(0x5550c8ff), juce::Colour(0x220a2840)); // sky blue +} + +void NinjamVst3AudioProcessorEditor::resized() +{ + if (!audioProcessor.isStandaloneWrapper() && !applyingDeferredResizeLayout) + { + pendingDeferredResizeLayout = true; + lastResizeEventMs = juce::Time::getMillisecondCounterHiRes(); + return; + } + + auto area = getLocalBounds().reduced(10); + + // Bottom: Interval Display + auto bottomRow = area.removeFromBottom(40); + intervalDisplay.setBounds(bottomRow); + area.removeFromBottom(10); + + auto topRow = area.removeFromTop(30); + // Right side of top row: texture / video-bg controls only + backgroundSelector.setBounds(topRow.removeFromRight(150)); + topRow.removeFromRight(4); + videoBgToggle.setBounds(topRow.removeFromRight(90)); + topRow.removeFromRight(10); + // Left side: server fields + serverLabel.setBounds(topRow.removeFromLeft(75)); + serverField.setBounds(topRow.removeFromLeft(160)); + topRow.removeFromLeft(6); + serverListButton.setBounds(topRow.removeFromLeft(72)); + topRow.removeFromLeft(6); + userLabel.setBounds(topRow.removeFromLeft(55)); + userField.setBounds(topRow.removeFromLeft(90)); + topRow.removeFromLeft(6); + anonymousButton.setBounds(topRow.removeFromLeft(110)); + topRow.removeFromLeft(6); + passLabel.setBounds(topRow.removeFromLeft(52)); + passField.setBounds(topRow.removeFromLeft(80)); + topRow.removeFromLeft(6); + connectButton.setBounds(topRow.removeFromLeft(80)); + topRow.removeFromLeft(10); + statusLabel.setBounds(topRow); + + area.removeFromTop(4); + + // Controls Row: layout, auto-level, metronome, tempo — chat+video buttons on the right + auto controlsRow = area.removeFromTop(30); + videoButton.setBounds(controlsRow.removeFromRight(100)); + controlsRow.removeFromRight(5); + chatButton.setBounds(controlsRow.removeFromRight(80)); + controlsRow.removeFromRight(10); + layoutButton.setBounds(controlsRow.removeFromLeft(40)); // icon-only button + controlsRow.removeFromLeft(10); + autoLevelButton.setBounds(controlsRow.removeFromLeft(110)); + controlsRow.removeFromLeft(10); + metronomeLabel.setBounds(controlsRow.removeFromLeft(90)); + metronomeSlider.setBounds(controlsRow.removeFromLeft(80)); + auto metBtn = controlsRow.removeFromLeft(24); + metronomeMuteButton.setBounds(metBtn.reduced(0, 3)); + controlsRow.removeFromLeft(6); + auto syncBtn = controlsRow.removeFromLeft(24); + syncButton.setBounds(syncBtn.reduced(0, 3)); + controlsRow.removeFromLeft(10); + fxButton.setBounds(controlsRow.removeFromLeft(70)); + controlsRow.removeFromLeft(8); + optionsButton.setBounds(controlsRow.removeFromLeft(78)); + controlsRow.removeFromLeft(8); + tempoLabel.setBounds(controlsRow); + + area.removeFromTop(10); + + bool showDockedChat = chatButton.getToggleState() && !chatPoppedOut; + juce::Rectangle chatArea; + if (showDockedChat) + { + auto chatWidth = (int)(area.getWidth() * 0.20f); + chatArea = area.removeFromRight(chatWidth); + area.removeFromRight(10); + } + + int numLocal = audioProcessor.getNumLocalChannels(); + numLocal = juce::jlimit(1, NinjamVst3AudioProcessor::maxLocalChannels, numLocal); + + int baseLocalWidth = 110; + int extraPerTrack = 40; + int localWidth = baseLocalWidth + (numLocal - 1) * extraPerTrack; + int maxLocalWidth = area.getWidth() / 2; + if (localWidth > maxLocalWidth) + localWidth = maxLocalWidth; + + int masterWidth = 190; + + auto localArea = area.removeFromLeft(localWidth); + auto masterArea = area.removeFromRight(masterWidth); + auto userArea = area; + + auto usersHeader = userArea.removeFromTop(20); + spreadOutputsButton.setBounds(usersHeader.removeFromLeft(110)); + usersLabel.setBounds(usersHeader); + userList.setBounds(userArea); + + // Transmit above local channels, monitor below it + transmitButton.setBounds(localArea.removeFromTop(26)); + localArea.removeFromTop(3); + localMonitorButton.setBounds(localArea.removeFromTop(26)); + localArea.removeFromTop(3); + { + auto row = localArea.removeFromTop(26); + auto third = row.getWidth() / 3; + voiceChatButton.setBounds(row.removeFromLeft(third)); + bitrateSelector.setBounds(row.removeFromLeft(third)); + midiRelayTargetSelector.setBounds(row); + } + localArea.removeFromTop(3); + + auto localHeader = localArea.removeFromTop(20); + addLocalChannelButton.setBounds(localHeader.removeFromLeft(20)); + removeLocalChannelButton.setBounds(localHeader.removeFromLeft(20)); + localFaderLabel.setBounds(localHeader); + auto localInner = localArea.reduced(4); + + int meterWidth = 10; + int totalWidth = localInner.getWidth(); + int columnWidth = totalWidth / numLocal; + + for (int i = 0; i < NinjamVst3AudioProcessor::maxLocalChannels; ++i) + { + bool visible = i < numLocal; + localFaders[(size_t)i].setVisible(visible); + localPeakMeters[(size_t)i].setVisible(visible); + localInputSelectors[(size_t)i].setVisible(visible); + localInputModeSelectors[(size_t)i].setVisible(visible); + localDbLabels[(size_t)i].setVisible(visible); + localChannelNameLabels[(size_t)i].setVisible(visible); + localReverbSendKnobs[(size_t)i].setVisible(visible); + localDelaySendKnobs[(size_t)i].setVisible(visible); + localReverbSendLabels[(size_t)i].setVisible(visible); + localDelaySendLabels[(size_t)i].setVisible(visible); + } + + for (int i = 0; i < numLocal; ++i) + { + juce::Rectangle col = localInner.removeFromLeft(columnWidth); + auto meterArea = col.removeFromLeft(meterWidth); + auto nameArea = col.removeFromTop(18); + auto dbArea = col.removeFromBottom(16); + auto inputArea = col.removeFromBottom(20); + auto inputModeArea = col.removeFromBottom(20); + auto sendArea = col.removeFromBottom(36); + auto revArea = sendArea.removeFromLeft(sendArea.getWidth() / 2); + auto dlyArea = sendArea; + auto revLabelArea = revArea.removeFromTop(10); + auto dlyLabelArea = dlyArea.removeFromTop(10); + localFaders[(size_t)i].setBounds(col); + localPeakMeters[(size_t)i].setBounds(meterArea); + localInputSelectors[(size_t)i].setBounds(inputArea); + localInputModeSelectors[(size_t)i].setBounds(inputModeArea); + localDbLabels[(size_t)i].setBounds(dbArea); + localChannelNameLabels[(size_t)i].setBounds(nameArea); + localReverbSendLabels[(size_t)i].setBounds(revLabelArea); + localDelaySendLabels[(size_t)i].setBounds(dlyLabelArea); + localReverbSendKnobs[(size_t)i].setBounds(revArea.reduced(2)); + localDelaySendKnobs[(size_t)i].setBounds(dlyArea.reduced(2)); + } + + masterFaderLabel.setBounds(masterArea.removeFromTop(20)); + auto masterInner = masterArea.reduced(4); + auto masterMeterWidth = 10; + auto masterMeterArea = masterInner.removeFromRight(masterMeterWidth); + auto controlColumn = masterInner.removeFromLeft(70); + auto fxColumn = masterInner; + + limiterButton.setBounds(controlColumn.removeFromTop(20)); + + int bottomHeight = 70; + if (bottomHeight > controlColumn.getHeight()) + bottomHeight = controlColumn.getHeight(); + + auto threshArea = controlColumn.removeFromTop(controlColumn.getHeight() - bottomHeight); + limiterThresholdSlider.setBounds(threshArea); + + auto releaseBlock = controlColumn; + limiterReleaseLabel.setBounds(releaseBlock.removeFromTop(18)); + + auto knobArea = releaseBlock.reduced(6, 0); + int knobSize = juce::jmin(knobArea.getWidth(), knobArea.getHeight()); + juce::Rectangle knobRect(0, 0, knobSize, knobSize); + knobRect = knobRect.withCentre(knobArea.getCentre()); + limiterReleaseSlider.setBounds(knobRect); + + auto delayBlock = fxColumn.removeFromTop(70); + delayTimeLabel.setBounds(delayBlock.removeFromTop(16)); + auto delayKnobBounds = delayBlock.reduced(4); + int delayKnobSize = juce::jmin(delayKnobBounds.getWidth(), delayKnobBounds.getHeight()); + delayTimeSlider.setBounds(juce::Rectangle(delayKnobSize, delayKnobSize).withCentre(delayKnobBounds.getCentre())); + + fxColumn.removeFromTop(2); + delayDivisionSelector.setBounds(fxColumn.removeFromTop(22)); + fxColumn.removeFromTop(2); + delayPingPongButton.setBounds(fxColumn.removeFromTop(22)); + + masterFader.setBounds(masterInner.removeFromTop(masterInner.getHeight() - 16)); + masterDbLabel.setBounds(masterInner); + masterPeakMeter.setBounds(masterMeterArea); + + if (showDockedChat) + { + chatDisplay.setVisible(true); + chatInput.setVisible(true); + sendButton.setVisible(true); + atButton.setVisible(true); + chatPopoutButton.setVisible(true); + + chatPopoutButton.setBounds(chatArea.removeFromTop(20).removeFromRight(70)); + + auto chatInputArea = chatArea.removeFromBottom(30); + chatArea.removeFromBottom(5); + chatDisplay.setBounds(chatArea); + + sendButton.setBounds(chatInputArea.removeFromRight(60)); + chatInputArea.removeFromRight(5); + atButton.setBounds(chatInputArea.removeFromRight(40)); + chatInputArea.removeFromRight(5); + chatInput.setBounds(chatInputArea); + } + else + { + chatDisplay.setVisible(false); + chatInput.setVisible(false); + sendButton.setVisible(false); + atButton.setVisible(false); + chatPopoutButton.setVisible(false); + } +} + +void NinjamVst3AudioProcessorEditor::timerCallback() +{ + const double nowMs = juce::Time::getMillisecondCounterHiRes(); + + if (pendingDeferredResizeLayout && !audioProcessor.isStandaloneWrapper()) + { + if (nowMs - lastResizeEventMs >= 85.0) + { + pendingDeferredResizeLayout = false; + applyingDeferredResizeLayout = true; + resized(); + applyingDeferredResizeLayout = false; + suppressHeavyUiUntilMs = nowMs + 350.0; + repaint(); + } + else + { + return; + } + } + + int status = audioProcessor.getClient().GetStatus(); + updateHostResizeModeForConnectionStatus(status); + juce::String statusStr; + switch (status) + { + case NJClient::NJC_STATUS_DISCONNECTED: statusStr = "Disconnected"; break; + case NJClient::NJC_STATUS_INVALIDAUTH: statusStr = "Invalid Auth"; break; + case NJClient::NJC_STATUS_CANTCONNECT: statusStr = "Can't Connect"; break; + case NJClient::NJC_STATUS_OK: statusStr = "Connected"; break; + case NJClient::NJC_STATUS_PRECONNECT: statusStr = "Connecting..."; break; + default: statusStr = "Unknown (" + juce::String(status) + ")"; break; + } + statusLabel.setText(statusStr, juce::dontSendNotification); + + if (status == NJClient::NJC_STATUS_OK || status == NJClient::NJC_STATUS_PRECONNECT) + connectButton.setButtonText("Disconnect"); + else + connectButton.setButtonText("Connect"); + + if (shouldDeferHeavyUiWork()) + return; + + // Chat + { + const juce::ScopedTryLock lock(audioProcessor.chatLock); + if (lock.isLocked()) + { + const auto& history = audioProcessor.chatHistory; + const auto& senders = audioProcessor.chatSenders; + + if (history.size() != lastChatSize) + { + applyColoredChat(chatDisplay, history, senders); + lastChatSize = history.size(); + + if (chatWindow) + { + if (auto* popup = dynamic_cast(chatWindow->getContentComponent())) + popup->setChatText(history, senders); + } + } + } + } + + const bool heavyUiAllowed = nowMs >= suppressHeavyUiUntilMs; + const bool runHeavyUiTick = ((++heavyUiTickCounter % 6) == 0); + if (heavyUiAllowed && runHeavyUiTick) + { + userList.updateContent(); + syncUserStripMidiTargets(); + refreshMidiRelayTargetSelector(); + } + applyMidiMappings(); + applyOscMappings(); + + int numLocal = audioProcessor.getNumLocalChannels(); + numLocal = juce::jlimit(1, NinjamVst3AudioProcessor::maxLocalChannels, numLocal); + for (int i = 0; i < numLocal; ++i) + { + float peak = audioProcessor.getLocalChannelPeak(i); + localPeakMeters[(size_t)i].setPeak(peak); + float db = -60.0f; + if (peak > 1.0e-6f) + db = juce::jlimit(-60.0f, 6.0f, 20.0f * std::log10(peak)); + localDbLabels[(size_t)i].setText(juce::String(db, 1) + " dB", juce::dontSendNotification); + } + + float masterPk = audioProcessor.getMasterPeak(); + masterPeakMeter.setPeak(masterPk); + { + float db = -60.0f; + if (masterPk > 1.0e-6f) + db = juce::jlimit(-60.0f, 6.0f, 20.0f * std::log10(masterPk)); + masterDbLabel.setText(juce::String((int)std::round(db)) + " dB", juce::dontSendNotification); + } + + if (autoLevelEnabled && runHeavyUiTick) + { + std::vector users = audioProcessor.getConnectedUsers(); + if (!users.empty()) + { + const float timerIntervalMs = 50.0f; + const float noiseFloor = 0.04f; + const float baseTargetLevel = 0.4f; + const float attackCoeff = 1.0f - std::exp(-timerIntervalMs / 200.0f); + const float releaseCoeff = 1.0f - std::exp(-timerIntervalMs / 1350.0f); + const float longTermDecayCoeff = 1.0f - std::exp(-timerIntervalMs / 2000.0f); + + float masterPeak = audioProcessor.getMasterPeak(); + const float targetMasterLevel = 0.4f; + float maxGain = 3.0f; + if (masterPeak < 0.25f) maxGain = 4.0f; + else if (masterPeak < 0.5f) maxGain = 3.5f; + + float globalGain = 1.0f; + if (masterPeak > 0.0001f) + globalGain = juce::jlimit(0.5f, 2.0f, targetMasterLevel / masterPeak); + + std::set activeIds; + + for (auto& u : users) + { + int id = u.index; + activeIds.insert(id); + + float peakL = audioProcessor.getUserPeak(id, 0); + float peakR = audioProcessor.getUserPeak(id, 1); + float currentLevel = juce::jmax(peakL, peakR); + + bool clipEnabled = audioProcessor.isUserClipEnabled(id); + if (clipEnabled) + { + auto softClipLevel = [](float x) + { + const float k = 2.0f; + const float d = std::tanh(k); + const float c = d / k; + const float target = 0.630957f; + float y = std::tanh(k * c * x); + if (d != 0.0f) y = (y / d) * target; + return y; + }; + currentLevel = softClipLevel(currentLevel); + } + + if (!autoLevelCurrentGains.count(id)) autoLevelCurrentGains[id] = u.volume; + if (!autoLevelPeakLevels.count(id)) autoLevelPeakLevels[id] = 0.0f; + if (!autoLevelChannelActiveTicks.count(id)) autoLevelChannelActiveTicks[id] = 0; + else autoLevelChannelActiveTicks[id]++; + + bool isNew = autoLevelChannelActiveTicks[id] < 40; + float& longTermPeak = autoLevelPeakLevels[id]; + + if (currentLevel >= noiseFloor) + longTermPeak += (currentLevel - longTermPeak) * longTermDecayCoeff; + else if (longTermPeak > 0.0f) + longTermPeak -= longTermPeak * (longTermDecayCoeff * 0.5f); + + longTermPeak = juce::jlimit(0.0f, 1.0f, longTermPeak); + + if (longTermPeak < noiseFloor) + { + autoLevelCurrentGains[id] += (1.0f - autoLevelCurrentGains[id]) * releaseCoeff; + audioProcessor.rememberUserVolume(id, autoLevelCurrentGains[id], u.name); + audioProcessor.setUserVolume(id, autoLevelCurrentGains[id]); + continue; + } + + float targetGain = juce::jlimit(0.1f, maxGain, (baseTargetLevel / longTermPeak) * globalGain); + + float estimatedOutput = currentLevel * targetGain; + if (!clipEnabled && estimatedOutput > 0.99f && currentLevel > noiseFloor) + targetGain = juce::jlimit(0.1f, maxGain, 0.95f / currentLevel); + + bool reducing = targetGain < autoLevelCurrentGains[id]; + float smoothingCoeff = reducing ? releaseCoeff : attackCoeff; + if (isNew) smoothingCoeff *= 0.5f; + + autoLevelCurrentGains[id] += (targetGain - autoLevelCurrentGains[id]) * smoothingCoeff; + autoLevelCurrentGains[id] = juce::jlimit(0.0f, maxGain, autoLevelCurrentGains[id]); + + audioProcessor.rememberUserVolume(id, autoLevelCurrentGains[id], u.name); + audioProcessor.setUserVolume(id, autoLevelCurrentGains[id]); + } + + for (auto it = autoLevelCurrentGains.begin(); it != autoLevelCurrentGains.end();) + { + if (!activeIds.count(it->first)) + { + int id = it->first; + autoLevelPeakLevels.erase(id); + autoLevelChannelActiveTicks.erase(id); + it = autoLevelCurrentGains.erase(it); + } + else { ++it; } + } + } + } + + intervalDisplay.repaint(); + + // Advance video background frame if active (Windows only) +#if JUCE_WINDOWS + if (videoFrameReader != nullptr) + { + auto frame = videoFrameReader->getLatestFrame(); + if (frame.isValid()) + { + backgroundImage = std::move(frame); + repaint(); + } + } +#endif + + updateVoiceChatButtonColor(); + + double hostBpm = 0.0; + bool hostPlaying = false; + { + juce::AudioPlayHead::CurrentPositionInfo info; + if (audioProcessor.getHostPosition(info)) + { + hostBpm = info.bpm; + hostPlaying = info.isPlaying; + } + } + + float njBpm = audioProcessor.getBPM(); + int bpi = audioProcessor.getBPI(); + + juce::String text; + text << "NJ " << juce::String(njBpm, 1) << " / " << bpi << " BPI"; + if (hostBpm > 0.0) + text << " | Host " << juce::String(hostBpm, 1) << (hostPlaying ? " (Play)" : " (Stop)"); + + int codecMode = audioProcessor.getCodecMode(); + juce::String codec; + if (codecMode == 2) codec = "Opus"; + else if (codecMode == 1) codec = "Vorbis+Opus"; + else codec = "Vorbis"; + text << " | Codec " << codec; + + tempoLabel.setText(text, juce::dontSendNotification); + + if (!delayTimeSlider.isMouseButtonDown()) + delayTimeSlider.setValue(audioProcessor.getFxDelayTimeMs(), juce::dontSendNotification); + delayDivisionSelector.setSelectedId(audioProcessor.getFxDelayDivision(), juce::dontSendNotification); + delayPingPongButton.setToggleState(audioProcessor.isFxDelayPingPong(), juce::dontSendNotification); + updateFxButtonLabel(); + updateFxControlsVisibility(); +} + +void NinjamVst3AudioProcessorEditor::mouseDown(const juce::MouseEvent& event) +{ + if (!(event.mods.isPopupMenu() || event.mods.isRightButtonDown())) + return; + + juce::Component* start = event.originalComponent != nullptr ? event.originalComponent : event.eventComponent; + for (auto* c = start; c != nullptr; c = c->getParentComponent()) + { + auto it = midiTargetsByComponent.find(c); + if (it != midiTargetsByComponent.end()) + { + showMidiLearnMenuForComponent(*c, event.getScreenPosition()); + return; + } + } +} + +void NinjamVst3AudioProcessorEditor::registerMidiLearnTarget(juce::Component& component, const juce::String& targetId, bool isToggle) +{ + auto existing = midiTargetsByComponent.find(&component); + if (existing != midiTargetsByComponent.end() && existing->second.id != targetId) + midiTargetsById.erase(existing->second.id); + + MidiLearnTarget target; + target.id = targetId; + target.component = &component; + target.isToggle = isToggle; + + midiTargetsByComponent[&component] = target; + midiTargetsById[targetId] = target; + component.addMouseListener(this, false); +} + +void NinjamVst3AudioProcessorEditor::syncUserStripMidiTargets() +{ + std::vector componentsToRemove; + for (auto it = midiTargetsById.begin(); it != midiTargetsById.end();) + { + if (it->first.startsWith("user.")) + { + if (it->second.component != nullptr) + componentsToRemove.push_back(it->second.component); + it = midiTargetsById.erase(it); + } + else + { + ++it; + } + } + + for (auto* component : componentsToRemove) + { + if (component != nullptr) + component->removeMouseListener(this); + midiTargetsByComponent.erase(component); + } + + auto strips = userList.getStripPointers(); + for (auto* strip : strips) + { + const int userIdx = strip->getUserIndex(); + const juce::String prefix = "user." + juce::String(userIdx) + "."; + registerMidiLearnTarget(strip->getVolumeSlider(), prefix + "volume", false); + registerMidiLearnTarget(strip->getPanSlider(), prefix + "pan", false); + registerMidiLearnTarget(strip->getMuteButton(), prefix + "mute", true); + registerMidiLearnTarget(strip->getSoloButton(), prefix + "solo", true); + for (int i = 0; i < 8; ++i) + registerMidiLearnTarget(strip->getChannelSlider(i), prefix + "channel." + juce::String(i + 1), false); + } +} + +void NinjamVst3AudioProcessorEditor::showMidiLearnMenuForComponent(juce::Component& component, juce::Point screenPos) +{ + auto it = midiTargetsByComponent.find(&component); + if (it == midiTargetsByComponent.end()) + return; + + const juce::String targetId = it->second.id; + juce::PopupMenu menu; + menu.addItem(3, "OSC Learn"); + menu.addItem(4, "OSC Forget", oscSourceByTargetId.find(targetId) != oscSourceByTargetId.end()); + menu.addSeparator(); + menu.addItem(1, "MIDI Learn"); + menu.addItem(2, "MIDI Forget", midiSourceByTargetId.find(targetId) != midiSourceByTargetId.end()); + menu.addSeparator(); + menu.addItem(10, "Save Learn Mappings"); + menu.addItem(11, "Load Learn Mappings"); + menu.addItem(12, "Forget All Learn Mappings"); + juce::Rectangle popupAnchor(screenPos.x, screenPos.y, 1, 1); + menu.showMenuAsync(juce::PopupMenu::Options().withTargetComponent(&component).withTargetScreenArea(popupAnchor), + [this, targetId](int result) + { + if (result == 3) + { + oscLearnArmedTargetId = targetId; + } + else if (result == 4) + { + oscSourceByTargetId.erase(targetId); + if (oscLearnArmedTargetId == targetId) + oscLearnArmedTargetId.clear(); + syncLearnMappingsToProcessor(); + } + else if (result == 1) + { + midiLearnArmedTargetId = targetId; + } + else if (result == 2) + { + midiSourceByTargetId.erase(targetId); + if (midiLearnArmedTargetId == targetId) + midiLearnArmedTargetId.clear(); + syncLearnMappingsToProcessor(); + } + else if (result == 10) + { + saveLearnMappingsToDisk(); + } + else if (result == 11) + { + loadLearnMappingsFromDisk(); + } + else if (result == 12) + { + clearLearnMappings(); + } + }); +} + +void NinjamVst3AudioProcessorEditor::applyMidiMappings() +{ + auto events = audioProcessor.popPendingMidiControllerEvents(); + if (events.empty()) + return; + + for (const auto& event : events) + { + if (midiLearnArmedTargetId.isNotEmpty() && midiTargetsById.find(midiLearnArmedTargetId) != midiTargetsById.end()) + { + MidiSourceMapping mapping; + mapping.isController = event.isController; + mapping.midiChannel = event.midiChannel; + mapping.number = event.number; + mapping.lastBinaryState = event.isNoteOn ? 1 : 0; + midiSourceByTargetId[midiLearnArmedTargetId] = mapping; + midiLearnArmedTargetId.clear(); + syncLearnMappingsToProcessor(); + } + + for (auto& pair : midiSourceByTargetId) + { + auto targetIt = midiTargetsById.find(pair.first); + if (targetIt == midiTargetsById.end()) + continue; + + auto& mapping = pair.second; + if (mapping.isController != event.isController) + continue; + if (mapping.midiChannel != event.midiChannel || mapping.number != event.number) + continue; + + auto* component = targetIt->second.component; + if (component == nullptr) + continue; + + if (targetIt->second.isToggle) + { + int binaryState = event.isNoteOn ? 1 : (event.value >= 64 ? 1 : 0); + if (binaryState == 1 && mapping.lastBinaryState != 1) + if (auto* button = dynamic_cast(component)) + button->triggerClick(); + mapping.lastBinaryState = binaryState; + } + else + { + if (auto* slider = dynamic_cast(component)) + { + if (slider->isMouseButtonDown()) + continue; + auto range = slider->getRange(); + const double norm = juce::jlimit(0.0, 1.0, (double)event.normalized); + const double value = juce::jlimit(range.getStart(), range.getEnd(), range.getStart() + norm * range.getLength()); + slider->setValue(value, juce::sendNotificationSync); + } + } + } + } +} + +void NinjamVst3AudioProcessorEditor::applyOscMappings() +{ + std::vector events; + { + const juce::SpinLock::ScopedLockType lock(oscEventQueueLock); + events.swap(pendingOscEvents); + } + if (events.empty()) + return; + + for (const auto& event : events) + { + if (oscLearnArmedTargetId.isNotEmpty() && midiTargetsById.find(oscLearnArmedTargetId) != midiTargetsById.end()) + { + OscSourceMapping mapping; + mapping.address = event.address; + mapping.lastBinaryState = event.binaryOn ? 1 : 0; + oscSourceByTargetId[oscLearnArmedTargetId] = mapping; + oscLearnArmedTargetId.clear(); + syncLearnMappingsToProcessor(); + } + + for (auto& pair : oscSourceByTargetId) + { + auto targetIt = midiTargetsById.find(pair.first); + if (targetIt == midiTargetsById.end()) + continue; + auto* component = targetIt->second.component; + if (component == nullptr) + continue; + auto& mapping = pair.second; + if (mapping.address != event.address) + continue; + + if (targetIt->second.isToggle) + { + const int binaryState = event.binaryOn ? 1 : 0; + if (binaryState == 1 && mapping.lastBinaryState != 1) + if (auto* button = dynamic_cast(component)) + button->triggerClick(); + mapping.lastBinaryState = binaryState; + } + else if (auto* slider = dynamic_cast(component)) + { + if (slider->isMouseButtonDown()) + continue; + auto range = slider->getRange(); + const double norm = juce::jlimit(0.0, 1.0, (double)event.normalized); + const double value = juce::jlimit(range.getStart(), range.getEnd(), range.getStart() + norm * range.getLength()); + slider->setValue(value, juce::sendNotificationSync); + } + } + } +} + +void NinjamVst3AudioProcessorEditor::applyRemoteMidiRelaySelection(int channel, int inputIndex) +{ + juce::DynamicObject::Ptr obj = new juce::DynamicObject(); + obj->setProperty("channel", channel); + obj->setProperty("inputIndex", inputIndex); + const juce::String payload = juce::JSON::toString(juce::var(obj.get())); + audioProcessor.sendSideSignal(audioProcessor.getMidiRelayTarget(), "localInputSelect", payload); +} + +void NinjamVst3AudioProcessorEditor::syncLearnMappingsToProcessor() +{ + juce::Array midiArray; + for (const auto& pair : midiSourceByTargetId) + { + juce::DynamicObject::Ptr obj = new juce::DynamicObject(); + obj->setProperty("target", pair.first); + obj->setProperty("isController", pair.second.isController); + obj->setProperty("midiChannel", pair.second.midiChannel); + obj->setProperty("number", pair.second.number); + obj->setProperty("lastBinaryState", pair.second.lastBinaryState); + midiArray.add(juce::var(obj.get())); + } + + juce::Array oscArray; + for (const auto& pair : oscSourceByTargetId) + { + juce::DynamicObject::Ptr obj = new juce::DynamicObject(); + obj->setProperty("target", pair.first); + obj->setProperty("address", pair.second.address); + obj->setProperty("lastBinaryState", pair.second.lastBinaryState); + oscArray.add(juce::var(obj.get())); + } + + audioProcessor.setMidiLearnStateJson(juce::JSON::toString(juce::var(midiArray))); + audioProcessor.setOscLearnStateJson(juce::JSON::toString(juce::var(oscArray))); +} + +void NinjamVst3AudioProcessorEditor::loadLearnMappingsFromProcessor() +{ + midiSourceByTargetId.clear(); + oscSourceByTargetId.clear(); + + const juce::var midiParsed = juce::JSON::parse(audioProcessor.getMidiLearnStateJson()); + if (auto* midiArray = midiParsed.getArray()) + { + for (const auto& entry : *midiArray) + { + auto* obj = entry.getDynamicObject(); + if (obj == nullptr || !obj->hasProperty("target")) + continue; + MidiSourceMapping mapping; + mapping.isController = obj->hasProperty("isController") ? (bool)obj->getProperty("isController") : true; + mapping.midiChannel = obj->hasProperty("midiChannel") ? (int)obj->getProperty("midiChannel") : 1; + mapping.number = obj->hasProperty("number") ? (int)obj->getProperty("number") : 0; + mapping.lastBinaryState = obj->hasProperty("lastBinaryState") ? (int)obj->getProperty("lastBinaryState") : -1; + midiSourceByTargetId[obj->getProperty("target").toString()] = mapping; + } + } + + const juce::var oscParsed = juce::JSON::parse(audioProcessor.getOscLearnStateJson()); + if (auto* oscArray = oscParsed.getArray()) + { + for (const auto& entry : *oscArray) + { + auto* obj = entry.getDynamicObject(); + if (obj == nullptr || !obj->hasProperty("target") || !obj->hasProperty("address")) + continue; + OscSourceMapping mapping; + mapping.address = obj->getProperty("address").toString(); + mapping.lastBinaryState = obj->hasProperty("lastBinaryState") ? (int)obj->getProperty("lastBinaryState") : -1; + oscSourceByTargetId[obj->getProperty("target").toString()] = mapping; + } + } +} + +void NinjamVst3AudioProcessorEditor::saveLearnMappingsToDisk() +{ + syncLearnMappingsToProcessor(); + juce::PropertiesFile::Options popts; + popts.applicationName = "NINJAM VST3"; + popts.filenameSuffix = "settings"; + popts.folderName = "NINJAM VST3"; + popts.osxLibrarySubFolder = "Application Support"; + juce::PropertiesFile props(popts); + props.setValue("midiLearnStateJson", audioProcessor.getMidiLearnStateJson()); + props.setValue("oscLearnStateJson", audioProcessor.getOscLearnStateJson()); + props.saveIfNeeded(); +} + +void NinjamVst3AudioProcessorEditor::loadLearnMappingsFromDisk() +{ + juce::PropertiesFile::Options popts; + popts.applicationName = "NINJAM VST3"; + popts.filenameSuffix = "settings"; + popts.folderName = "NINJAM VST3"; + popts.osxLibrarySubFolder = "Application Support"; + juce::PropertiesFile props(popts); + audioProcessor.setMidiLearnStateJson(props.getValue("midiLearnStateJson", {})); + audioProcessor.setOscLearnStateJson(props.getValue("oscLearnStateJson", {})); + loadLearnMappingsFromProcessor(); +} + +void NinjamVst3AudioProcessorEditor::clearLearnMappings() +{ + midiSourceByTargetId.clear(); + oscSourceByTargetId.clear(); + midiLearnArmedTargetId.clear(); + oscLearnArmedTargetId.clear(); + syncLearnMappingsToProcessor(); +} + +void NinjamVst3AudioProcessorEditor::connectClicked() +{ + const int status = audioProcessor.getClient().GetStatus(); + const bool isConnectedOrConnecting = (status == NJClient::NJC_STATUS_OK || status == NJClient::NJC_STATUS_PRECONNECT); + if (!isConnectedOrConnecting) + { + juce::String user = userField.getText(); + juce::String pass = passField.getText(); + + if (anonymousButton.getToggleState()) + { + if (!user.startsWith("anonymous:")) + user = "anonymous:" + user; + pass = ""; + } + + audioProcessor.connectToServer(serverField.getText(), user, pass); + } + else + { + audioProcessor.disconnectFromServer(); + clearLearnMappings(); + } +} + +void NinjamVst3AudioProcessorEditor::sendClicked() +{ + juce::String msg = chatInput.getText(); + if (msg.isNotEmpty()) + { + audioProcessor.sendChatMessage(msg); + chatInput.clear(); + } +} + +void NinjamVst3AudioProcessorEditor::transmitToggled() +{ + audioProcessor.setTransmitLocal(transmitButton.getToggleState()); + updateTransmitButtonColor(); +} + +void NinjamVst3AudioProcessorEditor::layoutToggled() +{ + userList.setLayoutMode(layoutButton.getToggleState()); +} + +void NinjamVst3AudioProcessorEditor::metronomeChanged() +{ + // only update volume when not muted + if (metronomeMuteButton.getToggleState()) + audioProcessor.setMetronomeVolume((float)metronomeSlider.getValue()); + else + storedMetronomeVolume = (float)metronomeSlider.getValue(); // update stored value silently +} + +void NinjamVst3AudioProcessorEditor::chatToggled() +{ + if (!chatButton.getToggleState()) + { + if (chatWindow) + { + chatWindow->setVisible(false); + chatWindow.reset(); + } + chatPoppedOut = false; + } + updateChatButtonColor(); + resized(); +} + +void NinjamVst3AudioProcessorEditor::chatPopoutClicked() +{ + if (!chatButton.getToggleState()) + { + chatButton.setToggleState(true, juce::dontSendNotification); + updateChatButtonColor(); + } + + if (!chatPoppedOut) + { + chatPoppedOut = true; + if (!chatWindow) + { + chatWindow.reset(new ChatWindow(audioProcessor, [this]() + { + chatWindow.reset(); + chatPoppedOut = false; + chatButton.setToggleState(false, juce::dontSendNotification); + updateChatButtonColor(); + resized(); + })); + } + else + { + chatWindow->setVisible(true); + } + } + else + { + chatPoppedOut = false; + if (chatWindow) + { + chatWindow->setVisible(false); + chatWindow.reset(); + } + } + + resized(); +} + +void NinjamVst3AudioProcessorEditor::anonymousToggled() +{ + passField.setEnabled(!anonymousButton.getToggleState()); +} + +void NinjamVst3AudioProcessorEditor::atToggled() +{ + audioProcessor.setAutoTranslateEnabled(atButton.getToggleState()); +} + +void NinjamVst3AudioProcessorEditor::syncToggled() +{ + bool enabled = syncButton.getToggleState(); + audioProcessor.setSyncToHost(enabled); + + if (!enabled) + return; + + double hostBpm = 0.0; + bool hostPlaying = false; + { + juce::AudioPlayHead::CurrentPositionInfo info; + if (audioProcessor.getHostPosition(info)) + { + hostBpm = info.bpm; + hostPlaying = info.isPlaying; + } + } + + float njBpm = audioProcessor.getBPM(); + juce::String message; + bool anyWarning = false; + + if (hostBpm > 0.0 && njBpm > 0.0f) + { + double diff = std::abs(hostBpm - (double)njBpm); + if (diff > 0.5) + { + anyWarning = true; + message << "Host BPM (" << juce::String(hostBpm, 1) + << ") is different from NINJAM BPM (" + << juce::String(njBpm, 1) << ").\n"; + } + } + + if (hostPlaying) + { + anyWarning = true; + message << "The DAW is currently playing.\n" + << "Stop the DAW, move the playhead to your desired start bar,\n" + << "then press Play to hear NINJAM in sync with the host."; + } + + if (!anyWarning) + return; + + juce::AlertWindow::showMessageBoxAsync(juce::AlertWindow::WarningIcon, "Sync to Host", message); +} + +void NinjamVst3AudioProcessorEditor::videoClicked() +{ + audioProcessor.launchVideoSession(); +} + +void NinjamVst3AudioProcessorEditor::serverListClicked() +{ + if (serverListWindow == nullptr) + { + serverListWindow.reset(new ServerListWindow( + audioProcessor, + [this](const juce::String& hostPort) + { + serverField.setText(hostPort, juce::dontSendNotification); + }, + [this](const juce::String& hostPort) + { + serverField.setText(hostPort, juce::dontSendNotification); + if (audioProcessor.getClient().GetStatus() != NJClient::NJC_STATUS_DISCONNECTED) + audioProcessor.disconnectFromServer(); + + juce::String user = userField.getText(); + juce::String pass = passField.getText(); + if (anonymousButton.getToggleState()) + { + if (!user.startsWith("anonymous:")) + user = "anonymous:" + user; + pass = ""; + } + + audioProcessor.connectToServer(serverField.getText(), user, pass); + if (serverListWindow != nullptr) + serverListWindow->setVisible(false); + })); + } + else + { + serverListWindow->setVisible(true); + serverListWindow->toFront(true); + } +} + +void NinjamVst3AudioProcessorEditor::refreshLocalInputSelectors() +{ + for (int i = 0; i < NinjamVst3AudioProcessor::maxLocalChannels; ++i) + refreshLocalInputSelector(i); +} + +void NinjamVst3AudioProcessorEditor::refreshMidiRelayTargetSelector() +{ + const juce::String selectedTarget = audioProcessor.getMidiRelayTarget(); + std::set seen; + midiRelayTargetByMenuId.clear(); + midiRelayTargetSelector.clear(juce::dontSendNotification); + + int id = 1; + midiRelayTargetSelector.addItem("MIDI->All", id); + midiRelayTargetByMenuId[id] = "*"; + ++id; + + for (const auto& user : audioProcessor.getConnectedUsers()) + { + if (user.name.isEmpty() || seen.find(user.name) != seen.end()) + continue; + seen.insert(user.name); + midiRelayTargetSelector.addItem("MIDI->" + user.name, id); + midiRelayTargetByMenuId[id] = user.name; + ++id; + } + + int selectedId = 1; + for (const auto& pair : midiRelayTargetByMenuId) + if (pair.second.equalsIgnoreCase(selectedTarget)) + selectedId = pair.first; + + midiRelayTargetSelector.setSelectedId(selectedId, juce::dontSendNotification); +} + +void NinjamVst3AudioProcessorEditor::oscMessageReceived(const juce::OSCMessage& message) +{ + PendingOscEvent event; + event.address = message.getAddressPattern().toString(); + if (message.size() > 0) + { + const auto arg = message[0]; + float raw = 1.0f; + if (arg.isFloat32()) raw = arg.getFloat32(); + else if (arg.isInt32()) raw = (float)arg.getInt32(); + event.normalized = raw > 1.0f ? juce::jlimit(0.0f, 1.0f, raw / 127.0f) : juce::jlimit(0.0f, 1.0f, raw); + event.binaryOn = raw >= 0.5f; + } + else + { + event.normalized = 1.0f; + event.binaryOn = true; + } + + const juce::SpinLock::ScopedLockType lock(oscEventQueueLock); + pendingOscEvents.push_back(event); + if (pendingOscEvents.size() > 512) + pendingOscEvents.erase(pendingOscEvents.begin(), pendingOscEvents.begin() + (long long)(pendingOscEvents.size() - 512)); +} + +void NinjamVst3AudioProcessorEditor::refreshLocalInputSelector(int channel) +{ + if (channel < 0 || channel >= NinjamVst3AudioProcessor::maxLocalChannels) + return; + + auto& selector = localInputSelectors[(size_t)channel]; + selector.clear(juce::dontSendNotification); + + int total = audioProcessor.getTotalNumInputChannels(); + if (total <= 0) total = 2; + int numPairs = total / 2; + + for (int ch = 0; ch < total; ++ch) + selector.addItem("In " + juce::String(ch + 1), ch + 1); + + int stereoBaseId = 100; + for (int pair = 0; pair < numPairs; ++pair) + { + int left = pair * 2 + 1; + int right = left + 1; + selector.addItem(juce::String(left) + "/" + juce::String(right), stereoBaseId + pair); + } + + int currentInput = audioProcessor.getLocalChannelInput(channel); + if (currentInput >= 0 && currentInput < total) + { + selector.setSelectedId(currentInput + 1, juce::dontSendNotification); + } + else if (currentInput < 0) + { + int pairIndex = -1 - currentInput; + if (numPairs > pairIndex) + { + selector.setSelectedId(stereoBaseId + pairIndex, juce::dontSendNotification); + } + else if (numPairs > 0) + { + selector.setSelectedId(stereoBaseId, juce::dontSendNotification); + audioProcessor.setLocalChannelInput(channel, -1); + } + else if (total > 0) + { + selector.setSelectedId(1, juce::dontSendNotification); + audioProcessor.setLocalChannelInput(channel, 0); + } + } + + if (channel >= 0 && channel < NinjamVst3AudioProcessor::maxLocalChannels) + localInputModeSelectors[(size_t)channel].setSelectedId(currentInput < 0 ? 2 : 1, juce::dontSendNotification); +} + +bool NinjamVst3AudioProcessorEditor::isSidechainInputActive() const +{ + return audioProcessor.getTotalNumInputChannels() > 2; +} + +void NinjamVst3AudioProcessorEditor::loadControlImages(const juce::File& themeDir) +{ + backgroundImage = juce::Image(); + + // Try bg.mp4 when the Video BG toggle is on (Windows only) + bool videoLoaded = false; + +#if JUCE_WINDOWS + videoFrameReader.reset(); + if (videoBgToggle.getToggleState()) + { + auto videoFile = themeDir.getChildFile("bg.mp4"); + if (videoFile.existsAsFile()) + { + videoFrameReader = std::make_unique(); + if (videoFrameReader->open(videoFile)) + videoLoaded = true; + else + videoFrameReader.reset(); + } + } +#endif + + if (!videoLoaded) + { + // Fall back to bg.* image files (bg.jpg, bg.png, bg.gif, etc.) + auto bgFiles = themeDir.findChildFiles(juce::File::findFiles, false, "bg.*"); + if (!bgFiles.isEmpty()) + backgroundImage = juce::ImageFileFormat::loadFrom(bgFiles[0]); + } + + // fknob.png — fader knob image + faderKnobImage = juce::ImageFileFormat::loadFrom(themeDir.getChildFile("fknob.png")); + + // rknob.png — radio/release knob image + radioKnobImage = juce::ImageFileFormat::loadFrom(themeDir.getChildFile("rknob.png")); + + // Reset theme colours to defaults before reading cfg + metronomeThemeColour = juce::Colour::fromRGB(80, 185, 255); + windowThemeColour = juce::Colour(0x00000000); + buttonThemeColour = juce::Colour(0x00000000); + menuBarThemeColour = juce::Colour(0x00000000); + knobColourPreset = "grey"; + faderColourPreset = "grey"; + knobThemeColour = juce::Colours::grey; + faderThemeColour = juce::Colour(0xff666666); + + // Parse companion skin.cfg if present + auto cfgFile = themeDir.getChildFile("skin.cfg"); + if (cfgFile.existsAsFile()) + { + auto lines = juce::StringArray::fromLines(cfgFile.loadFileAsString()); + auto parseHex = [](const juce::String& val, juce::Colour& out) -> bool + { + auto s = val.trim().trimCharactersAtStart("#"); + if (s.length() == 6 && s.containsOnly("0123456789abcdefABCDEF")) + { + out = juce::Colour::fromString("ff" + s); + return true; + } + return false; + }; + for (const auto& line : lines) + { + auto trimmed = line.trim(); + if (trimmed.startsWith("#") || trimmed.isEmpty()) continue; + auto val = trimmed.fromFirstOccurrenceOf(":", false, false).trim(); + if (trimmed.startsWithIgnoreCase("Metronome Colour:")) + parseHex(val, metronomeThemeColour); + else if (trimmed.startsWithIgnoreCase("Window Colour:")) + parseHex(val, windowThemeColour); + else if (trimmed.startsWithIgnoreCase("Button Colour:")) + parseHex(val, buttonThemeColour); + else if (trimmed.startsWithIgnoreCase("MenuBar Colour:")) + parseHex(val, menuBarThemeColour); + else if (trimmed.startsWithIgnoreCase("Knobs:")) + { + knobColourPreset = val; + knobThemeColour = colourFromPresetName(knobColourPreset, juce::Colours::grey); + } + else if (trimmed.startsWithIgnoreCase("Faders:")) + { + faderColourPreset = val; + faderThemeColour = colourFromPresetName(faderColourPreset, juce::Colour(0xff666666)); + } + } + } + + applyThemeColours(); + repaint(); +} + +void NinjamVst3AudioProcessorEditor::applyThemeColours() +{ + metronomeBtnLAF.themeColour = metronomeThemeColour; + metronomeMuteButton.repaint(); + + // Apply Window Colour to the global LAF palette so all component backgrounds pick it up. + // If no Window Colour is set, restore JUCE defaults. + if (windowThemeColour.getAlpha() > 0) + { + auto bg = windowThemeColour; + auto bgDk = bg.darker(0.25f); + auto bgLt = bg.brighter(0.15f); + + // drawDocumentWindowTitleBar reads widgetBackground from the colour scheme directly + // (bypasses colour IDs entirely), so we must patch the scheme to change the title bar. + auto titleBarBg = menuBarThemeColour.getAlpha() > 0 ? menuBarThemeColour : bg; + auto scheme = outlinedLabelLAF.getCurrentColourScheme(); + scheme.setUIColour(juce::LookAndFeel_V4::ColourScheme::UIColour::windowBackground, titleBarBg); + scheme.setUIColour(juce::LookAndFeel_V4::ColourScheme::UIColour::widgetBackground, titleBarBg); + outlinedLabelLAF.setColourScheme(scheme); // also calls initialiseColours(), resetting colour IDs + + // Now apply per-component colour ID overrides (override what setColourScheme just initialised) + outlinedLabelLAF.setColour(juce::ResizableWindow::backgroundColourId, bg); + outlinedLabelLAF.setColour(juce::DocumentWindow::backgroundColourId, bg); + outlinedLabelLAF.setColour(juce::ComboBox::backgroundColourId, bgDk); + outlinedLabelLAF.setColour(juce::ListBox::backgroundColourId, bgDk); + outlinedLabelLAF.setColour(juce::TextEditor::backgroundColourId, bgDk); + outlinedLabelLAF.setColour(juce::TextButton::buttonColourId, + buttonThemeColour.getAlpha() > 0 ? buttonThemeColour : bgLt); + outlinedLabelLAF.setColour(juce::Slider::backgroundColourId, bgDk); + outlinedLabelLAF.setColour(juce::GroupComponent::outlineColourId, bg.brighter(0.3f)); + outlinedLabelLAF.setColour(juce::PopupMenu::backgroundColourId, bgDk); + outlinedLabelLAF.setColour(juce::PopupMenu::highlightedBackgroundColourId, bgLt); + } + else + { + // Restore the dark colour scheme (JUCE default); this also resets all colour IDs. + // If a MenuBar Colour is set without a Window Colour, still patch widgetBackground. + if (menuBarThemeColour.getAlpha() > 0) + { + auto scheme = juce::LookAndFeel_V4::getDarkColourScheme(); + scheme.setUIColour(juce::LookAndFeel_V4::ColourScheme::UIColour::windowBackground, menuBarThemeColour); + scheme.setUIColour(juce::LookAndFeel_V4::ColourScheme::UIColour::widgetBackground, menuBarThemeColour); + outlinedLabelLAF.setColourScheme(scheme); + } + else + { + outlinedLabelLAF.setColourScheme(juce::LookAndFeel_V4::getDarkColourScheme()); + } + if (buttonThemeColour.getAlpha() > 0) + outlinedLabelLAF.setColour(juce::TextButton::buttonColourId, buttonThemeColour); + } + + repaint(); + sendLookAndFeelChange(); + + // Force the DocumentWindow title bar to repaint using the updated scheme. + if (auto* dw = dynamic_cast(getTopLevelComponent())) + { + auto effectiveTitleCol = menuBarThemeColour.getAlpha() > 0 ? menuBarThemeColour + : windowThemeColour.getAlpha() > 0 ? windowThemeColour + : juce::LookAndFeel_V4::getDarkColourScheme() + .getUIColour(juce::LookAndFeel_V4::ColourScheme::UIColour::windowBackground); + dw->setBackgroundColour(effectiveTitleCol); + dw->repaint(); + } +} + +void NinjamVst3AudioProcessorEditor::parentHierarchyChanged() +{ + // Re-apply title bar colour now that we may be properly parented under the DocumentWindow + if (auto* dw = dynamic_cast(getTopLevelComponent())) + { + auto effectiveTitleCol = menuBarThemeColour.getAlpha() > 0 ? menuBarThemeColour + : windowThemeColour.getAlpha() > 0 ? windowThemeColour + : juce::LookAndFeel_V4::getDarkColourScheme() + .getUIColour(juce::LookAndFeel_V4::ColourScheme::UIColour::windowBackground); + dw->setBackgroundColour(effectiveTitleCol); + dw->repaint(); + } +} + +bool NinjamVst3AudioProcessorEditor::shouldDeferHeavyUiWork() const +{ + if (audioProcessor.isStandaloneWrapper()) + return false; + if (pendingDeferredResizeLayout || applyingDeferredResizeLayout) + return true; + return juce::Time::getMillisecondCounterHiRes() < suppressHeavyUiUntilMs; +} + +bool NinjamVst3AudioProcessorEditor::isAbletonLiveHost() const +{ + return juce::PluginHostType().isAbletonLive(); +} + +void NinjamVst3AudioProcessorEditor::setAbletonWindowSizePreset(int presetIndex) +{ + if (audioProcessor.isStandaloneWrapper() || !isAbletonLiveHost()) + return; + + abletonWindowSizePreset = juce::jlimit(0, 2, presetIndex); + + int targetWidth = 1240; + int targetHeight = 600; + if (abletonWindowSizePreset == 0) targetWidth = 1100; + if (abletonWindowSizePreset == 0) targetHeight = 540; + if (abletonWindowSizePreset == 2) targetWidth = 1380; + if (abletonWindowSizePreset == 2) targetHeight = 700; + + if (hostResizeLockedForConnection) + { + pendingDeferredResizeLayout = false; + applyingDeferredResizeLayout = false; + setResizable(false, false); + setResizeLimits(targetWidth, targetHeight, targetWidth, targetHeight); + setSize(targetWidth, targetHeight); + suppressHeavyUiUntilMs = juce::Time::getMillisecondCounterHiRes() + 400.0; + } + else + { + setResizable(true, true); + setResizeLimits(900, 500, 2200, 1500); + setSize(targetWidth, juce::jlimit(500, 1500, targetHeight)); + } + + juce::PropertiesFile::Options popts; + popts.applicationName = "NINJAM VST3"; + popts.filenameSuffix = "settings"; + popts.folderName = "NINJAM VST3"; + popts.osxLibrarySubFolder = "Application Support"; + juce::PropertiesFile props(popts); + props.setValue("abletonWindowSizePreset", abletonWindowSizePreset); + props.saveIfNeeded(); +} + +void NinjamVst3AudioProcessorEditor::updateHostResizeModeForConnectionStatus(int status) +{ + if (audioProcessor.isStandaloneWrapper()) + return; + + const bool shouldLock = isAbletonLiveHost() + && (status == NJClient::NJC_STATUS_OK || status == NJClient::NJC_STATUS_PRECONNECT); + if (shouldLock == hostResizeLockedForConnection) + return; + + if (shouldLock) + { + const int currentWidth = getWidth(); + const int currentHeight = getHeight(); + pendingDeferredResizeLayout = false; + applyingDeferredResizeLayout = false; + setResizable(false, false); + setResizeLimits(currentWidth, currentHeight, currentWidth, currentHeight); + suppressHeavyUiUntilMs = juce::Time::getMillisecondCounterHiRes() + 500.0; + } + else + { + setResizable(true, true); + setResizeLimits(900, 500, 2200, 1500); + } + + hostResizeLockedForConnection = shouldLock; +} + +void NinjamVst3AudioProcessorEditor::updateAutoLevelButtonColor() +{ + if (autoLevelButton.getToggleState()) + { + juce::Colour on = juce::Colour::fromRGB(240, 220, 30); // bright yellow + autoLevelButton.setColour(juce::TextButton::buttonColourId, on); + autoLevelButton.setColour(juce::TextButton::buttonOnColourId, on); + autoLevelButton.setColour(juce::TextButton::textColourOnId, juce::Colours::black); + autoLevelButton.setColour(juce::TextButton::textColourOffId, juce::Colours::black); + } + else + { + juce::Colour off = juce::Colour::fromRGB(55, 50, 10); // dim yellow + autoLevelButton.setColour(juce::TextButton::buttonColourId, off); + autoLevelButton.setColour(juce::TextButton::buttonOnColourId, off); + autoLevelButton.setColour(juce::TextButton::textColourOnId, juce::Colours::grey); + autoLevelButton.setColour(juce::TextButton::textColourOffId, juce::Colours::grey); + } + repaint(); +} + +void NinjamVst3AudioProcessorEditor::updateChatButtonColor() +{ + chatButton.repaint(); +} + +void NinjamVst3AudioProcessorEditor::updateTransmitButtonColor() +{ + if (transmitButton.getToggleState()) + { + juce::Colour on = juce::Colour::fromRGB(50, 200, 80); // bright green when transmitting + transmitButton.setColour(juce::TextButton::buttonColourId, on); + transmitButton.setColour(juce::TextButton::buttonOnColourId, on); + transmitButton.setColour(juce::TextButton::textColourOnId, juce::Colours::black); + transmitButton.setColour(juce::TextButton::textColourOffId, juce::Colours::black); + } + else + { + juce::Colour off = juce::Colour::fromRGB(12, 50, 18); // dim green + transmitButton.setColour(juce::TextButton::buttonColourId, off); + transmitButton.setColour(juce::TextButton::buttonOnColourId, off); + transmitButton.setColour(juce::TextButton::textColourOnId, juce::Colours::grey); + transmitButton.setColour(juce::TextButton::textColourOffId, juce::Colours::grey); + } + repaint(); +} + +void NinjamVst3AudioProcessorEditor::updateMonitorButtonColor() +{ + if (localMonitorButton.getToggleState()) + { + juce::Colour on = juce::Colour::fromRGB(220, 55, 55); // bright red when monitoring + localMonitorButton.setColour(juce::TextButton::buttonColourId, on); + localMonitorButton.setColour(juce::TextButton::buttonOnColourId, on); + localMonitorButton.setColour(juce::TextButton::textColourOnId, juce::Colours::white); + localMonitorButton.setColour(juce::TextButton::textColourOffId, juce::Colours::white); + } + else + { + juce::Colour off = juce::Colour::fromRGB(60, 15, 15); // dim red + localMonitorButton.setColour(juce::TextButton::buttonColourId, off); + localMonitorButton.setColour(juce::TextButton::buttonOnColourId, off); + localMonitorButton.setColour(juce::TextButton::textColourOnId, juce::Colours::grey); + localMonitorButton.setColour(juce::TextButton::textColourOffId, juce::Colours::grey); + } + repaint(); +} + +void NinjamVst3AudioProcessorEditor::updateLimiterButtonColor() +{ + if (limiterButton.getToggleState()) + { + juce::Colour on = juce::Colour::fromRGB(220, 55, 55); // bright red when active + limiterButton.setColour(juce::TextButton::buttonColourId, on); + limiterButton.setColour(juce::TextButton::buttonOnColourId, on); + limiterButton.setColour(juce::TextButton::textColourOnId, juce::Colours::white); + limiterButton.setColour(juce::TextButton::textColourOffId, juce::Colours::white); + } + else + { + juce::Colour off = juce::Colour::fromRGB(60, 15, 15); // dim red + limiterButton.setColour(juce::TextButton::buttonColourId, off); + limiterButton.setColour(juce::TextButton::buttonOnColourId, off); + limiterButton.setColour(juce::TextButton::textColourOnId, juce::Colours::grey); + limiterButton.setColour(juce::TextButton::textColourOffId, juce::Colours::grey); + } + repaint(); +} + +void NinjamVst3AudioProcessorEditor::updateVoiceChatButtonColor() +{ + if (voiceChatButton.getToggleState()) + { + // Pulse between dim amber and bright amber (~1 s cycle) + voiceChatGlowPhase += juce::MathConstants::twoPi * 30.0f / 1000.0f; + float t = (std::sin(voiceChatGlowPhase) + 1.0f) * 0.5f; // 0..1 + uint8 r = (uint8)(100 + (uint8)(155 * t)); + uint8 g = (uint8)(50 + (uint8)(100 * t)); + juce::Colour pulse = juce::Colour::fromRGB(r, g, 0); + voiceChatButton.setColour(juce::TextButton::buttonColourId, pulse); + voiceChatButton.setColour(juce::TextButton::buttonOnColourId, pulse); + voiceChatButton.setColour(juce::TextButton::textColourOnId, juce::Colours::black); + voiceChatButton.setColour(juce::TextButton::textColourOffId, juce::Colours::black); + } + else + { + voiceChatGlowPhase = 0.0f; + juce::Colour off = juce::Colour::fromRGB(50, 30, 0); + voiceChatButton.setColour(juce::TextButton::buttonColourId, off); + voiceChatButton.setColour(juce::TextButton::buttonOnColourId, off); + voiceChatButton.setColour(juce::TextButton::textColourOnId, juce::Colours::orange); + voiceChatButton.setColour(juce::TextButton::textColourOffId, juce::Colours::orange); + } +} + +void NinjamVst3AudioProcessorEditor::updateLayoutButtonColor() +{ + repaint(); +} + +void NinjamVst3AudioProcessorEditor::updateMetronomeButtonColor() +{ + repaint(); +} + +void NinjamVst3AudioProcessorEditor::updateSyncButtonColor() +{ + repaint(); +} + +void NinjamVst3AudioProcessorEditor::updateFxButtonLabel() +{ + fxButton.setButtonText("FX"); +} + +void NinjamVst3AudioProcessorEditor::showFxMenu() +{ + audioProcessor.setFxReverbEnabled(true); + audioProcessor.setFxDelayEnabled(true); + + juce::PopupMenu menu; + menu.addItem(2, "Reverb"); + menu.addItem(3, "Delay"); + menu.showMenuAsync(juce::PopupMenu::Options().withTargetComponent(&fxButton), + [this](int result) + { + if (result == 0) + return; + + audioProcessor.setFxReverbEnabled(true); + audioProcessor.setFxDelayEnabled(true); + if (result == 2) + showReverbSettingsPopup(); + if (result == 3) + showDelaySettingsPopup(); + updateFxButtonLabel(); + updateFxControlsVisibility(); + repaint(); + }); +} + +void NinjamVst3AudioProcessorEditor::showOptionsMenu() +{ + juce::PopupMenu menu; + menu.addItem(41, "Midi Settings"); + if (isAbletonLiveHost() && !audioProcessor.isStandaloneWrapper()) + { + juce::PopupMenu sizeMenu; + sizeMenu.addItem(51, "Small", true, abletonWindowSizePreset == 0); + sizeMenu.addItem(52, "Medium", true, abletonWindowSizePreset == 1); + sizeMenu.addItem(53, "Large", true, abletonWindowSizePreset == 2); + menu.addSubMenu("Window Size", sizeMenu); + } + + menu.showMenuAsync(juce::PopupMenu::Options().withTargetComponent(&optionsButton), + [this](int result) + { + if (result == 0) + return; + if (result == 41) + showMidiOptionsPopup(); + if (result == 51) setAbletonWindowSizePreset(0); + if (result == 52) setAbletonWindowSizePreset(1); + if (result == 53) setAbletonWindowSizePreset(2); + }); +} + +void NinjamVst3AudioProcessorEditor::showReverbSettingsPopup() +{ + showSettingsCallout(std::make_unique(audioProcessor), fxButton); +} + +void NinjamVst3AudioProcessorEditor::showDelaySettingsPopup() +{ + showSettingsCallout(std::make_unique(audioProcessor), fxButton); +} + +void NinjamVst3AudioProcessorEditor::showMidiOptionsPopup() +{ + showSettingsCallout(std::make_unique(audioProcessor, [this] { refreshExternalMidiInputDevices(); }), + optionsButton.isShowing() ? static_cast(optionsButton) + : static_cast(fxButton)); +} + +void NinjamVst3AudioProcessorEditor::showSettingsCallout(std::unique_ptr content, juce::Component& anchorComponent) +{ + auto anchorOnScreen = anchorComponent.getScreenBounds(); + juce::Rectangle target(anchorOnScreen.getX() + 8, anchorOnScreen.getBottom() + 2, 2, 2); + auto& box = juce::CallOutBox::launchAsynchronously(std::move(content), target, nullptr); + box.setLookAndFeel(&noArrowCallOutLookAndFeel); + box.setArrowSize(0.0f); + box.setTopLeftPosition(anchorOnScreen.getX(), anchorOnScreen.getBottom() + 4); +} + +void NinjamVst3AudioProcessorEditor::refreshExternalMidiInputDevices() +{ + const juce::String desiredLearnId = audioProcessor.getMidiLearnInputDeviceId(); + const juce::String desiredRelayId = audioProcessor.getMidiRelayInputDeviceId(); + + if (desiredLearnId != openedMidiLearnInputDeviceId) + { + midiLearnInputDevice.reset(); + openedMidiLearnInputDeviceId.clear(); + if (desiredLearnId.isNotEmpty()) + { + midiLearnInputDevice = juce::MidiInput::openDevice(desiredLearnId, this); + if (midiLearnInputDevice != nullptr) + { + midiLearnInputDevice->start(); + openedMidiLearnInputDeviceId = desiredLearnId; + } + } + } + + if (desiredRelayId == openedMidiLearnInputDeviceId && desiredRelayId.isNotEmpty()) + { + midiRelayInputDevice.reset(); + openedMidiRelayInputDeviceId = desiredRelayId; + return; + } + + if (desiredRelayId != openedMidiRelayInputDeviceId) + { + midiRelayInputDevice.reset(); + openedMidiRelayInputDeviceId.clear(); + if (desiredRelayId.isNotEmpty()) + { + midiRelayInputDevice = juce::MidiInput::openDevice(desiredRelayId, this); + if (midiRelayInputDevice != nullptr) + { + midiRelayInputDevice->start(); + openedMidiRelayInputDeviceId = desiredRelayId; + } + } + } +} + +void NinjamVst3AudioProcessorEditor::handleIncomingMidiMessage(juce::MidiInput* source, const juce::MidiMessage& message) +{ + if (source == nullptr) + return; + + const juce::String sourceId = source->getIdentifier(); + const juce::String learnDeviceId = audioProcessor.getMidiLearnInputDeviceId(); + const juce::String relayDeviceId = audioProcessor.getMidiRelayInputDeviceId(); + const bool forLearn = learnDeviceId.isNotEmpty() && sourceId == learnDeviceId; + const bool forRelay = relayDeviceId.isNotEmpty() && sourceId == relayDeviceId; + if (!forLearn && !forRelay) + return; + + NinjamVst3AudioProcessor::MidiControllerEvent event; + if (message.isController()) + { + event.isController = true; + event.midiChannel = message.getChannel(); + event.number = message.getControllerNumber(); + event.value = message.getControllerValue(); + event.normalized = (float)event.value / 127.0f; + event.isNoteOn = event.value >= 64; + } + else if (message.isNoteOnOrOff()) + { + event.isController = false; + event.midiChannel = message.getChannel(); + event.number = message.getNoteNumber(); + event.value = message.getVelocity(); + event.normalized = message.isNoteOn() ? ((float)event.value / 127.0f) : 0.0f; + event.isNoteOn = message.isNoteOn(); + } + else + { + return; + } + + audioProcessor.enqueueExternalMidiControllerEvent(event, forLearn, forRelay); +} + +void NinjamVst3AudioProcessorEditor::updateFxControlsVisibility() +{ + reverbRoomLabel.setVisible(false); + reverbRoomSlider.setVisible(false); + delayTimeLabel.setVisible(false); + delayTimeSlider.setVisible(false); + delayDivisionSelector.setVisible(false); + delayPingPongButton.setVisible(false); +} + +// ============================================================================== +// UserChannelStrip Implementation +// ============================================================================== + +UserChannelStrip::UserChannelStrip(NinjamVst3AudioProcessor& p, int userIdx) + : processor(p), userIndex(userIdx) +{ + // Initialise per-channel state + for (int i = 0; i < kMaxRemoteCh; ++i) + { + perChannelGain[i] = 1.0f; + channelPeaks[i] = 0.0f; + } + + setOpaque(false); + addAndMakeVisible(nameLabel); + nameLabel.setJustificationType(juce::Justification::centred); + nameLabel.setColour(juce::Label::textColourId, juce::Colours::white); + + addAndMakeVisible(volumeSlider); + volumeSlider.setSliderStyle(juce::Slider::LinearVertical); + volumeSlider.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + volumeSlider.setRange(0.0, 2.0); + volumeSlider.setSkewFactorFromMidPoint(0.25); + volumeSlider.setValue(1.0, juce::dontSendNotification); + volumeSlider.setDoubleClickReturnValue(true, 1.0); + volumeSlider.setLookAndFeel(&faderLookAndFeel); + volumeSlider.onValueChange = [this] { volumeChanged(); }; + + addAndMakeVisible(panSlider); + panSlider.setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag); + panSlider.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + panSlider.setRange(-1.0, 1.0); + panSlider.setValue(0.0, juce::dontSendNotification); + panSlider.setDoubleClickReturnValue(true, 0.0); + panSlider.onValueChange = [this] { panChanged(); }; + + addAndMakeVisible(muteButton); + muteButton.setClickingTogglesState(true); + muteBtnLAF.isMute = true; + muteButton.setLookAndFeel(&muteBtnLAF); + muteButton.onClick = [this] { muteChanged(); }; + + addAndMakeVisible(soloButton); + soloButton.setClickingTogglesState(true); + soloBtnLAF.isMute = false; + soloButton.setLookAndFeel(&soloBtnLAF); + soloButton.onClick = [this] { soloChanged(); }; + + addAndMakeVisible(outputSelector); + + addAndMakeVisible(dbLabel); + dbLabel.setJustificationType(juce::Justification::centred); + dbLabel.setColour(juce::Label::backgroundColourId, juce::Colours::black); + dbLabel.setColour(juce::Label::textColourId, juce::Colours::white); + dbLabel.setFont(juce::Font(11.0f)); + + int totalOutputs = processor.getTotalNumOutputChannels(); + if (totalOutputs <= 0) totalOutputs = 2; + int numPairs = totalOutputs / 2; + + for (int ch = 0; ch < totalOutputs; ++ch) + outputSelector.addItem("Out " + juce::String(ch + 1), ch + 1); + + int stereoBaseId = 100; + for (int pair = 0; pair < numPairs; ++pair) + { + int left = pair * 2 + 1; + int right = left + 1; + outputSelector.addItem("Out " + juce::String(left) + "/" + juce::String(right), + stereoBaseId + pair); + } + + outputSelector.onChange = [this] { outputChanged(); }; + + // Expand button — shows ">" in list layout for multichan peers + expandButton.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff333333)); + expandButton.setColour(juce::TextButton::textColourOffId, juce::Colours::white); + addChildComponent(expandButton); // hidden until isMultiChanPeer + expandButton.onClick = [this] { toggleExpanded(); }; + + // Per-channel volume sliders and name labels (hidden until expanded) + for (int i = 0; i < kMaxRemoteCh; ++i) + { + channelSliders[i].setSliderStyle(juce::Slider::LinearHorizontal); + channelSliders[i].setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + channelSliders[i].setRange(0.0, 2.0); + channelSliders[i].setSkewFactorFromMidPoint(0.25); + channelSliders[i].setValue(1.0, juce::dontSendNotification); + channelSliders[i].setDoubleClickReturnValue(true, 1.0); + channelSliders[i].setLookAndFeel(&faderLookAndFeel); + int ch = i; + channelSliders[i].onValueChange = [this, ch] + { + perChannelGain[ch] = (float)channelSliders[ch].getValue(); + float master = (float)volumeSlider.getValue(); + // NINJAM ch0 = Vorbis mixdown; individual channels start at ch1 + processor.setUserNjChannelVolume(userIndex, ch + 1, master * perChannelGain[ch]); + }; + addChildComponent(channelSliders[i]); + + channelNameLabels[i].setFont(juce::Font(9.0f)); + channelNameLabels[i].setJustificationType(juce::Justification::centredLeft); + channelNameLabels[i].setColour(juce::Label::textColourId, juce::Colours::lightgrey); + addChildComponent(channelNameLabels[i]); + } + + startTimer(50); +} + +UserChannelStrip::~UserChannelStrip() +{ + volumeSlider.setLookAndFeel(nullptr); + panSlider.setLookAndFeel(nullptr); + muteButton.setLookAndFeel(nullptr); + soloButton.setLookAndFeel(nullptr); + for (int i = 0; i < kMaxRemoteCh; ++i) + channelSliders[i].setLookAndFeel(nullptr); + stopTimer(); +} + +int UserChannelStrip::getUserIndex() const +{ + return userIndex; +} + +juce::Slider& UserChannelStrip::getVolumeSlider() +{ + return volumeSlider; +} + +juce::Slider& UserChannelStrip::getPanSlider() +{ + return panSlider; +} + +juce::Button& UserChannelStrip::getMuteButton() +{ + return muteButton; +} + +juce::Button& UserChannelStrip::getSoloButton() +{ + return soloButton; +} + +juce::Slider& UserChannelStrip::getChannelSlider(int channel) +{ + return channelSliders[(size_t)juce::jlimit(0, kMaxRemoteCh - 1, channel)]; +} + +void UserChannelStrip::paintOverChildren(juce::Graphics& g) +{ + auto drawGlow = [&](juce::Button& btn, juce::Colour onColour, juce::Colour offColour) + { + bool isOn = btn.getToggleState(); + auto bc = btn.getBounds().toFloat(); + auto centre = bc.getCentre(); + float gap = 5.0f; + float r = bc.getWidth() * 0.55f + gap; + juce::Colour col = isOn ? onColour : offColour; + juce::ColourGradient grad(col, centre.x, centre.y, + juce::Colours::transparentBlack, centre.x + r, centre.y, true); + g.setGradientFill(grad); + g.fillEllipse(centre.x - r, centre.y - r, r * 2.0f, r * 2.0f); + }; + + drawGlow(muteButton, juce::Colour(0x55ff3030), juce::Colour(0x22200808)); + drawGlow(soloButton, juce::Colour(0x55ffd030), juce::Colour(0x22281e04)); +} + +void UserChannelStrip::paint(juce::Graphics& g) +{ + auto dbFromPeak = [](float peak) + { + float p = juce::jlimit(1.0e-6f, 1.0f, peak); + return 20.0f * std::log10(p); + }; + auto colourForPeak = [&](float peak) + { + float db = dbFromPeak(peak); + if (peak >= 0.999f) return juce::Colours::red; + if (db > -3.0f) return juce::Colours::orange; + return juce::Colours::green; + }; + + g.fillAll(juce::Colours::black.withAlpha(0.45f)); + g.setColour(juce::Colours::black.withAlpha(0.60f)); + g.drawRect(getLocalBounds(), 1); + + const bool multiChan = isMultiChanPeer && numRemoteChannels > 1; + // Only show wide per-channel meter bars when collapsed; when expanded the sub-faders show peaks + const bool showMultiMeter = multiChan && !isExpanded; + + if (isHorizontalLayout) + { + auto sliderBounds = volumeSlider.getBounds(); + int meterWidth = 10; // Fixed width to prevent growing/shrinking + juce::Rectangle meterBounds(sliderBounds.getRight(), sliderBounds.getY(), + meterWidth, sliderBounds.getHeight()); + + g.setColour(juce::Colours::black); + g.fillRect(meterBounds); + + if (showMultiMeter) + { + int n = numRemoteChannels; + int bw = juce::jmax(1, meterBounds.getWidth() / n); + int totalH = meterBounds.getHeight(); + for (int ch = 0; ch < n; ++ch) + { + float peak = channelPeaks[ch]; + int h = (int)(totalH * juce::jmin(peak, 1.0f)); + if (h > 0) + { + juce::Rectangle bar(meterBounds.getX() + ch * bw, + meterBounds.getBottom() - h, + bw - 1, h); + g.setColour(colourForPeak(peak)); + g.fillRect(bar); + } + } + } + else + { + auto meterL = meterBounds.removeFromLeft(meterBounds.getWidth() / 2); + auto meterR = meterBounds; + + int hL = (int)(meterL.getHeight() * juce::jmin(currentPeakL, 1.0f)); + int hR = (int)(meterR.getHeight() * juce::jmin(currentPeakR, 1.0f)); + + if (hL > 0) { auto bar = meterL.removeFromBottom(hL); g.setColour(colourForPeak(currentPeakL)); g.fillRect(bar); } + if (hR > 0) { auto bar = meterR.removeFromBottom(hR); g.setColour(colourForPeak(currentPeakR)); g.fillRect(bar); } + } + } + else + { + auto sliderBounds = volumeSlider.getBounds(); + int meterHeight = 6; // Fixed height to prevent growing/shrinking + juce::Rectangle meterBounds(sliderBounds.getX(), sliderBounds.getBottom(), + sliderBounds.getWidth(), meterHeight); + + g.setColour(juce::Colours::black); + g.fillRect(meterBounds); + + if (showMultiMeter) + { + int n = numRemoteChannels; + int bh = juce::jmax(1, meterBounds.getHeight() / n); + int totalW = meterBounds.getWidth(); + for (int ch = 0; ch < n; ++ch) + { + float peak = channelPeaks[ch]; + int w = (int)(totalW * juce::jmin(peak, 1.0f)); + if (w > 0) + { + juce::Rectangle bar(meterBounds.getX(), + meterBounds.getY() + ch * bh, + w, bh - 1); + g.setColour(colourForPeak(peak)); + g.fillRect(bar); + } + } + } + else + { + int w = meterBounds.getWidth(); + float maxP = juce::jmax(currentPeakL, currentPeakR); + int wP = (int)(w * juce::jmin(maxP, 1.0f)); + + if (wP > 0) + { + auto bar = meterBounds.removeFromLeft(wP); + g.setColour(colourForPeak(maxP)); + g.fillRect(bar); + } + } + } +} + +void UserChannelStrip::resized() +{ + auto area = getLocalBounds().reduced(2); + + if (isHorizontalLayout) + { + // When multichan is expanded, restrict main strip to the left column + if (isExpanded && isMultiChanPeer && numRemoteChannels > 1) + area.setWidth(56); // 60px column minus 2px margin each side + + nameLabel.setBounds(area.removeFromTop(20)); + outputSelector.setBounds(area.removeFromBottom(20)); + auto dbArea = area.removeFromBottom(16); + auto ctrlArea = area.removeFromBottom(20); + muteButton.setBounds(ctrlArea.removeFromLeft(area.getWidth() / 2)); + soloButton.setBounds(ctrlArea); + panSlider.setBounds(area.removeFromTop(20).reduced(4, 2)); + + int sliderWidth = juce::jmin(20, area.getWidth()); + int sliderHeight = (int)(area.getHeight() * 0.85f); + int sliderY = area.getY() + (area.getHeight() - sliderHeight) / 2; + volumeSlider.setBounds(area.getCentreX() - sliderWidth / 2, sliderY, sliderWidth, sliderHeight); + volumeSlider.setSliderStyle(juce::Slider::LinearVertical); + dbLabel.setBounds(dbArea); + dbLabel.setVisible(true); + + // Expand button at top-right of strip when multichannel peer + if (isMultiChanPeer) + { + auto nameBounds = nameLabel.getBounds(); + expandButton.setBounds(nameBounds.getRight() - 14, nameBounds.getY(), 14, nameBounds.getHeight()); + expandButton.setVisible(true); + } + else + { + expandButton.setVisible(false); + } + + // Per-channel faders as side columns to the right of main strip when expanded + if (isExpanded && isMultiChanPeer && numRemoteChannels > 1) + { + auto subArea = getLocalBounds().reduced(2); + subArea.removeFromLeft(60); // skip the narrower main column when expanded + int colW = 36; + for (int i = 0; i < numRemoteChannels; ++i) + { + auto col = subArea.removeFromLeft(colW); + // Name label at top (18px), slider fills rest + channelNameLabels[i].setBounds(col.removeFromTop(18).reduced(1, 0)); + channelNameLabels[i].setVisible(true); + col.reduce(2, 4); + channelSliders[i].setBounds(col); + channelSliders[i].setSliderStyle(juce::Slider::LinearVertical); + channelSliders[i].setVisible(true); + } + for (int i = numRemoteChannels; i < kMaxRemoteCh; ++i) + { + channelSliders[i].setVisible(false); + channelNameLabels[i].setVisible(false); + } + } + else + { + for (int i = 0; i < kMaxRemoteCh; ++i) + { + channelSliders[i].setVisible(false); + channelNameLabels[i].setVisible(false); + } + } + } + else + { + // List layout (strip is horizontal) + // When multichan is expanded, restrict main strip to the top row + if (isExpanded && isMultiChanPeer && numRemoteChannels > 1) + area.setHeight(36); // 40px base minus 2px padding top/bottom + // Reserve 18px at right for expand button if this is a multichan peer + if (isMultiChanPeer) + { + expandButton.setBounds(area.removeFromRight(18)); + expandButton.setVisible(true); + } + else + { + expandButton.setVisible(false); + } + + nameLabel.setBounds(area.removeFromLeft(80)); + outputSelector.setBounds(area.removeFromRight(60)); + auto ctrlArea = area.removeFromRight(40); + muteButton.setBounds(ctrlArea.removeFromTop(ctrlArea.getHeight() / 2)); + soloButton.setBounds(ctrlArea); + panSlider.setBounds(area.removeFromRight(40)); + + // Leave a fixed room for the meter rows at the bottom of the main strip + int meterH = 6; + area.removeFromBottom(meterH); + int sliderHeight = juce::jmin(18, area.getHeight()); + volumeSlider.setBounds(area.getX(), area.getCentreY() - sliderHeight / 2, area.getWidth(), sliderHeight); + volumeSlider.setSliderStyle(juce::Slider::LinearHorizontal); + dbLabel.setVisible(false); + + // Per-channel rows below the main strip when expanded + if (isExpanded && isMultiChanPeer && numRemoteChannels > 1) + { + auto expandArea = getLocalBounds().reduced(2); + expandArea.removeFromTop(40); // skip the main strip row + int rowH = 36; + for (int i = 0; i < numRemoteChannels; ++i) + { + auto row = expandArea.removeFromTop(rowH); + row.removeFromLeft(6); + // Left 80px: channel name; rest: slider + channelNameLabels[i].setBounds(row.removeFromLeft(80)); + channelNameLabels[i].setVisible(true); + channelSliders[i].setBounds(row.reduced(0, 2)); + channelSliders[i].setSliderStyle(juce::Slider::LinearHorizontal); + channelSliders[i].setVisible(true); + } + for (int i = numRemoteChannels; i < kMaxRemoteCh; ++i) + { + channelSliders[i].setVisible(false); + channelNameLabels[i].setVisible(false); + } + } + else + { + for (int i = 0; i < kMaxRemoteCh; ++i) + { + channelSliders[i].setVisible(false); + channelNameLabels[i].setVisible(false); + } + } + } +} + +int UserChannelStrip::getPreferredHeight() const +{ + int base = 40; + if (!isHorizontalLayout && isExpanded && isMultiChanPeer && numRemoteChannels > 1) + return base + numRemoteChannels * 36; + return base; +} + +int UserChannelStrip::getPreferredWidth() const +{ + if (isHorizontalLayout && isExpanded && isMultiChanPeer && numRemoteChannels > 1) + return 60 + numRemoteChannels * 36; // narrower main + wider sub-channels + return 80; +} + +void UserChannelStrip::setOrientation(bool isHorizontal) +{ + isHorizontalLayout = isHorizontal; + + if (isHorizontalLayout) + { + panSlider.setSliderStyle(juce::Slider::LinearHorizontal); + panSlider.setLookAndFeel(&panLookAndFeel); + } + else + { + panSlider.setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag); + panSlider.setLookAndFeel(nullptr); + } + + resized(); + repaint(); +} + +void UserChannelStrip::updateInfo(const NinjamVst3AudioProcessor::UserInfo& info) +{ + userIndex = info.index; + userInfo = info; + nameLabel.setText(info.name, juce::dontSendNotification); + + if (!volumeSlider.isMouseOverOrDragging()) + volumeSlider.setValue(juce::jmin(info.volume, 2.0f), juce::dontSendNotification); + + if (!panSlider.isMouseOverOrDragging()) + panSlider.setValue(info.pan, juce::dontSendNotification); + + muteButton.setToggleState(info.isMuted, juce::dontSendNotification); + + // Sync multichan state — trigger layout refresh if anything changed + const int newNCh = juce::jlimit(1, kMaxRemoteCh, info.numChannels); + const bool multiStateChanged = (info.isMultiChanPeer != isMultiChanPeer) || (newNCh != numRemoteChannels); + isMultiChanPeer = info.isMultiChanPeer; + numRemoteChannels = newNCh; + + // Update channel name labels + for (int i = 0; i < kMaxRemoteCh; ++i) + { + juce::String name = i < info.channelNames.size() ? info.channelNames[i] : ""; + channelNameLabels[i].setText(name, juce::dontSendNotification); + } + if (multiStateChanged) + { + // Set button text to match current state + if (isMultiChanPeer) + expandButton.setButtonText(isHorizontalLayout ? ">" : "v"); + // If we lost multichan, collapse + if (!isMultiChanPeer && isExpanded) + { + isExpanded = false; + expandButton.setButtonText(isHorizontalLayout ? ">" : "v"); + } + resized(); + repaint(); + // Walk up to UserListComponent so it recalculates strip heights + for (auto* p = getParentComponent(); p != nullptr; p = p->getParentComponent()) + { + if (auto* list = dynamic_cast(p)) + { + list->resized(); + break; + } + } + } + + int totalOutputs = processor.getTotalNumOutputChannels(); + if (totalOutputs <= 0) totalOutputs = 2; + int numPairs = totalOutputs / 2; + int stereoBaseId = 100; + + int ch = info.outputChannel; + int id = 0; + bool isMono = (ch & 1024) != 0; + int chanIdx = ch & 1023; + if (isMono) + { + if (chanIdx >= 0 && chanIdx < totalOutputs) + id = chanIdx + 1; + } + else + { + int pair = chanIdx / 2; + if (pair >= 0 && pair < numPairs) + id = stereoBaseId + pair; + } + + if (id > 0 && outputSelector.getSelectedId() != id) + outputSelector.setSelectedId(id, juce::dontSendNotification); +} + +void UserChannelStrip::setClipEnabled(bool enabled) +{ + clipButton.setToggleState(enabled, juce::dontSendNotification); + processor.setUserClipEnabled(userIndex, enabled); +} + +void UserChannelStrip::timerCallback() +{ + for (auto* c = getParentComponent(); c != nullptr; c = c->getParentComponent()) + if (auto* editor = dynamic_cast(c)) + if (editor->shouldDeferHeavyUiWork()) + return; + + auto peakL = processor.getUserPeak(userIndex, 0); + auto peakR = processor.getUserPeak(userIndex, 1); + + bool needRepaint = false; + + if (std::abs(peakL - currentPeakL) > 0.001f || std::abs(peakR - currentPeakR) > 0.001f) + { + currentPeakL = peakL; + currentPeakR = peakR; + float peak = juce::jmax(currentPeakL, currentPeakR); + float db = -60.0f; + if (peak > 1.0e-6f) + db = juce::jlimit(-60.0f, 6.0f, 20.0f * std::log10(peak)); + dbLabel.setText(juce::String(db, 1) + " dB", juce::dontSendNotification); + needRepaint = true; + } + + // Update per-NINJAM-channel peaks for multichan peers (used by collapsed multi-meter + expanded rows) + if (isMultiChanPeer && numRemoteChannels > 1) + { + for (int ch = 0; ch < numRemoteChannels; ++ch) + { + // NINJAM ch0 = Vorbis mixdown; individual channels start at ch1 + float chPeak = processor.getUserChannelPeak(userIndex, ch + 1, -1); // -1 = both/max + if (std::abs(chPeak - channelPeaks[ch]) > 0.001f) + { + channelPeaks[ch] = chPeak; + needRepaint = true; + } + } + } + + if (needRepaint) + repaint(); +} + +void UserChannelStrip::volumeChanged() +{ + applyVolumesToProcessor(); +} + +void UserChannelStrip::panChanged() +{ + applyVolumesToProcessor(); +} + +void UserChannelStrip::outputChanged() +{ + int selectedId = outputSelector.getSelectedId(); + if (selectedId <= 0) + return; + + int totalOutputs = processor.getTotalNumOutputChannels(); + if (totalOutputs <= 0) totalOutputs = 2; + int numPairs = totalOutputs / 2; + int stereoBaseId = 100; + + if (selectedId >= 1 && selectedId <= totalOutputs) + // Single (mono) channel: set the 1024 mono bit so njclient outputs to one channel only + processor.setUserOutput(userIndex, (selectedId - 1) | 1024); + else if (selectedId >= stereoBaseId && selectedId < stereoBaseId + numPairs) + // Stereo pair: no mono bit, base channel is pair * 2 + processor.setUserOutput(userIndex, (selectedId - stereoBaseId) * 2); +} + +void UserChannelStrip::muteChanged() +{ + applyVolumesToProcessor(); +} + +void UserChannelStrip::soloChanged() +{ + applyVolumesToProcessor(); +} + +void UserChannelStrip::clipChanged() +{ + processor.setUserClipEnabled(userIndex, clipButton.getToggleState()); +} + +void UserChannelStrip::toggleExpanded() +{ + isExpanded = !isExpanded; + if (isHorizontalLayout) + expandButton.setButtonText(isExpanded ? "<" : ">"); + else + expandButton.setButtonText(isExpanded ? "^" : "v"); + resized(); + // Walk up to UserListComponent to trigger full height recalculation + for (auto* p = getParentComponent(); p != nullptr; p = p->getParentComponent()) + { + if (auto* list = dynamic_cast(p)) + { + list->resized(); + break; + } + } +} + +void UserChannelStrip::applyVolumesToProcessor() +{ + float mv = (float)volumeSlider.getValue(); + float pan = (float)panSlider.getValue(); + bool mute = muteButton.getToggleState(); + bool solo = soloButton.getToggleState(); + processor.setUserLevel(userIndex, mv, pan, mute, solo); + // Re-apply per-channel gain overrides for multichan peers + if (isMultiChanPeer && numRemoteChannels > 1) + { + for (int ch = 0; ch < numRemoteChannels; ++ch) + // NINJAM ch0 = Vorbis mixdown; individual channels start at ch1 + processor.setUserNjChannelVolume(userIndex, ch + 1, mv * perChannelGain[ch]); + } +} + +// ============================================================================== +// UserListComponent Implementation +// ============================================================================== + +UserListComponent::UserListComponent(NinjamVst3AudioProcessor& p) + : processor(p) +{ + setOpaque(false); + addAndMakeVisible(viewport); + viewport.setViewedComponent(&contentComponent, false); + viewport.setScrollBarsShown(true, true); + contentComponent.setOpaque(false); +} + +UserListComponent::~UserListComponent() +{ + strips.clear(); +} + +void UserListComponent::paint(juce::Graphics& g) +{ + g.fillAll(juce::Colours::black.withAlpha(0.30f)); +} + +void UserListComponent::resized() +{ + viewport.setBounds(getLocalBounds()); + + int stripWidth = isHorizontal ? 80 : viewport.getWidth() - 15; + int defHeight = isHorizontal ? viewport.getHeight() - 20 : 40; + if (stripWidth < 10) stripWidth = 10; + if (defHeight < 10) defHeight = 10; + + int x = 0, y = 0; + for (auto& strip : strips) + { + int sw = isHorizontal ? strip->getPreferredWidth() : stripWidth; + int sh = isHorizontal ? defHeight : strip->getPreferredHeight(); + strip->setBounds(x, y, sw, sh); + if (isHorizontal) x += sw; + else y += sh; + } + + if (isHorizontal) + contentComponent.setBounds(0, 0, x, viewport.getHeight() - 20); + else + contentComponent.setBounds(0, 0, viewport.getWidth() - 15, juce::jmax(y, viewport.getHeight() - 20)); +} + +void UserListComponent::updateContent() +{ + auto users = processor.getConnectedUsers(); + + if (users.size() != strips.size()) + { + strips.clear(); + contentComponent.removeAllChildren(); + + for (const auto& u : users) + { + auto strip = std::make_unique(processor, u.index); + strip->setOrientation(isHorizontal); + strip->updateInfo(u); + strip->setClipEnabled(processor.isSoftLimiterEnabled()); + contentComponent.addAndMakeVisible(strip.get()); + strips.push_back(std::move(strip)); + } + resized(); + } + else + { + for (size_t i = 0; i < users.size(); ++i) + strips[i]->updateInfo(users[i]); + } +} + +void UserListComponent::setLayoutMode(bool horizontal) +{ + isHorizontal = horizontal; + for (auto& strip : strips) + strip->setOrientation(horizontal); + resized(); +} + +void UserListComponent::setAllClipEnabled(bool enabled) +{ + for (auto& strip : strips) + strip->setClipEnabled(enabled); +} + +std::vector UserListComponent::getStripPointers() const +{ + std::vector pointers; + pointers.reserve(strips.size()); + for (const auto& strip : strips) + pointers.push_back(strip.get()); + return pointers; +} +#if 0 +void FaderLookAndFeel::drawLinearSlider(juce::Graphics& g, int x, int y, int width, int height, float sliderPos, float minSliderPos, float maxSliderPos, const juce::Slider::SliderStyle style, juce::Slider& slider) { if (auto* editor = dynamic_cast(slider.getParentComponent())) { if (editor->faderKnobImage.isValid()) { auto isVertical = style == juce::Slider::LinearVertical; auto thumbWidth = isVertical ? width * 0.8f : 30.0f; auto thumbHeight = isVertical ? 30.0f : height * 0.8f; auto thumbX = isVertical ? (float)x + (float)width * 0.1f : sliderPos - thumbWidth * 0.5f; auto thumbY = isVertical ? sliderPos - thumbHeight * 0.5f : (float)y + (float)height * 0.1f; g.drawImageWithin(editor->faderKnobImage, (int)thumbX, (int)thumbY, (int)thumbWidth, (int)thumbHeight, juce::RectanglePlacement::centred); return; } } auto thumbWidth = (style == juce::Slider::LinearVertical) ? width * 0.8f : 12.0f; auto thumbHeight = (style == juce::Slider::LinearVertical) ? 12.0f : height * 0.8f; auto thumbX = (style == juce::Slider::LinearVertical) ? (float)x + (float)width * 0.1f : sliderPos - thumbWidth * 0.5f; auto thumbY = (style == juce::Slider::LinearVertical) ? sliderPos - thumbHeight * 0.5f : (float)y + (float)height * 0.1f; g.setColour(juce::Colour(0xff666666)); g.fillRoundedRectangle(thumbX, thumbY, thumbWidth, thumbHeight, 2.0f); } +void CustomKnobLookAndFeel::drawRotarySlider(juce::Graphics& g, int x, int y, int width, int height, float sliderPos, const float rotaryStartAngle, const float rotaryEndAngle, juce::Slider& slider) { auto centreX = (float)x + (float)width * 0.5f; auto centreY = (float)y + (float)height * 0.5f; auto* editor = dynamic_cast(slider.getParentComponent()); if (editor == nullptr) { auto* p = slider.getParentComponent(); while (p != nullptr && editor == nullptr) { editor = dynamic_cast(p); p = p->getParentComponent(); } } if (editor != nullptr && editor->radioKnobImage.isValid()) { const float radius = (float)juce::jmin(width / 2, height / 2) - 4.0f; auto angle = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle); g.drawImageWithin(editor->radioKnobImage, (int)(centreX - radius), (int)(centreY - radius), (int)(radius * 2.0f), (int)(radius * 2.0f), juce::RectanglePlacement::fillDestination); return; } auto radius = (float)juce::jmin(width / 2, height / 2) - 10.0f; g.setColour(juce::Colour(0xffdddddd)); g.fillEllipse(centreX - radius, centreY - radius, radius * 2.0f, radius * 2.0f); } +NinjamVst3AudioProcessorEditor::NinjamVst3AudioProcessorEditor (NinjamVst3AudioProcessor& p) : AudioProcessorEditor (&p), audioProcessor (p), intervalDisplay(p), userList(p) { setSize (1120, 620); setResizable(true, true); addAndMakeVisible(serverField); addAndMakeVisible(serverListButton); addAndMakeVisible(backgroundSelector); backgroundSelector.onChange = [this] { auto files = juce::File(\ C:\\\Users\\\mcand\\\Pictures\\\textures\).findChildFiles(juce::File::findFiles, false, \*.jpg\); int idx = backgroundSelector.getSelectedItemIndex(); if (idx >= 0 ; idx < files.size()) { backgroundImage = juce::ImageFileFormat::loadFrom(files[idx]); loadControlImages(files[idx]); repaint(); } }; loadControlImages(juce::File(\C:\\\Users\\\mcand\\\Pictures\\\textures\\\Brushed Metal 1.jpg\)); } NinjamVst3AudioProcessorEditor::~NinjamVst3AudioProcessorEditor() {} void NinjamVst3AudioProcessorEditor::paint (juce::Graphics& g) { if (backgroundImage.isValid()) g.drawImageWithin(backgroundImage, 0, 0, getWidth(), getHeight(), juce::RectanglePlacement::fillDestination); else g.fillAll(juce::Colour(0xff222222)); } void NinjamVst3AudioProcessorEditor::resized() { auto header = getLocalBounds().removeFromTop(40); backgroundSelector.setBounds(header.removeFromRight(150).reduced(2)); } void NinjamVst3AudioProcessorEditor::loadControlImages(const juce::File& f) { auto dir = f.getParentDirectory(); auto base = f.getFileNameWithoutExtension(); radioKnobImage = juce::ImageFileFormat::loadFrom(dir.getChildFile(base + \_radioknob.png\)); faderKnobImage = juce::ImageFileFormat::loadFrom(dir.getChildFile(base + \_faderknob.png\)); repaint(); } +#endif diff --git a/extras/ninjam-vst3/Source - Copy/PluginEditor.h b/extras/ninjam-vst3/Source - Copy/PluginEditor.h new file mode 100644 index 00000000..4c763295 --- /dev/null +++ b/extras/ninjam-vst3/Source - Copy/PluginEditor.h @@ -0,0 +1,1131 @@ +#pragma once + +#include +#include "PluginProcessor.h" + +class NinjamVst3AudioProcessorEditor; // forward declaration for LAF classes + +#if JUCE_WINDOWS +struct WinVideoReader; // Windows Media Foundation frame reader (defined in PluginEditor.cpp) +#endif + +class IntervalDisplayComponent : public juce::Component +{ +public: + IntervalDisplayComponent(NinjamVst3AudioProcessor& p) : processor(p) {} + + void paint(juce::Graphics& g) override + { + int bpi = processor.getBPI(); + if (bpi <= 0) + bpi = 4; + + const float progress = juce::jlimit(0.0f, 1.0f, processor.getIntervalProgress()); + const float totalBeats = progress * (float)bpi; + const int currentBeat = (int)totalBeats; + + auto bounds = getLocalBounds().toFloat(); + const float blockWidth = bounds.getWidth() / (float)bpi; + const float blockHeight = bounds.getHeight(); + + const juce::Colour onColor = juce::Colour(0xFFFFFDD0); + const juce::Colour offColor = juce::Colours::black.withAlpha(0.3f); + + for (int i = 0; i < bpi; ++i) + { + auto blockArea = juce::Rectangle(i * blockWidth, 0.0f, blockWidth, blockHeight).reduced(2.0f); + if (i < currentBeat) + { + g.setColour(onColor); + g.fillRect(blockArea); + } + else if (i == currentBeat && currentBeat < bpi) + { + const float subBeat = totalBeats - (float)currentBeat; + const float alpha = 0.6f + 0.4f * std::sin(subBeat * juce::MathConstants::pi); + g.setColour(onColor.withAlpha(alpha)); + g.fillRect(blockArea); + } + else + { + g.setColour(offColor); + g.drawRect(blockArea, 1.0f); + } + } + } + +private: + NinjamVst3AudioProcessor& processor; +}; + +class OutlinedLabelLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + void drawLabel(juce::Graphics& g, juce::Label& label) override + { + if (!label.isBeingEdited()) + { + auto alpha = label.isEnabled() ? 1.0f : 0.5f; + auto font = getLabelFont(label); + g.setFont(font); + + auto textArea = getLabelBorderSize(label).subtractedFrom(label.getLocalBounds()); + juce::String text = label.getText(); + if (text.isEmpty()) return; + + auto just = label.getJustificationType(); + + // black outline: draw at radius-1 and radius-2 for heavier weight + g.setColour(juce::Colours::black.withAlpha(alpha * 0.80f)); + for (int r = 1; r <= 2; ++r) + for (int dx = -r; dx <= r; ++dx) + for (int dy = -r; dy <= r; ++dy) + if (dx != 0 || dy != 0) + g.drawFittedText(text, + textArea.translated(dx, dy), + just, 1, 1.0f); + + // main text on top + g.setColour(label.findColour(juce::Label::textColourId).withMultipliedAlpha(alpha)); + g.drawFittedText(text, textArea, just, 1, 1.0f); + } + else + { + LookAndFeel_V4::drawLabel(g, label); + } + } + + void drawToggleButton(juce::Graphics& g, juce::ToggleButton& button, + bool shouldDrawButtonAsHighlighted, bool shouldDrawButtonAsDown) override + { + // Draw the tick box using the default implementation first (painted separately) + auto tickWidth = juce::jmin(20.0f, (float)button.getHeight() * 0.8f); + drawTickBox(g, button, 4.0f, ((float)button.getHeight() - tickWidth) * 0.5f, + tickWidth, tickWidth, button.getToggleState(), + button.isEnabled(), shouldDrawButtonAsHighlighted, shouldDrawButtonAsDown); + + // Draw label text with black outline + auto alpha = button.isEnabled() ? 1.0f : 0.5f; + auto textX = (int)(tickWidth + 8.0f); + auto textArea = button.getLocalBounds().withTrimmedLeft(textX); + juce::String text = button.getButtonText(); + g.setFont(13.0f); + + g.setColour(juce::Colours::black.withAlpha(alpha * 0.80f)); + for (int r = 1; r <= 2; ++r) + for (int dx = -r; dx <= r; ++dx) + for (int dy = -r; dy <= r; ++dy) + if (dx != 0 || dy != 0) + g.drawFittedText(text, textArea.translated(dx, dy), + juce::Justification::centredLeft, 1, 1.0f); + + g.setColour(button.findColour(juce::ToggleButton::textColourId).withMultipliedAlpha(alpha)); + g.drawFittedText(text, textArea, juce::Justification::centredLeft, 1, 1.0f); + } +}; + +class FaderLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + void drawLinearSlider(juce::Graphics&, int x, int y, int width, int height, + float sliderPos, float minSliderPos, float maxSliderPos, + const juce::Slider::SliderStyle, juce::Slider&) override; + void drawLinearSliderBackground(juce::Graphics&, int x, int y, int width, int height, + float sliderPos, float minSliderPos, float maxSliderPos, + const juce::Slider::SliderStyle, juce::Slider&) override; +}; + +class NonlinearFaderSlider : public juce::Slider +{ +public: + NonlinearFaderSlider() = default; + + double valueToProportionOfLength(double value) override + { + auto range = getRange(); + double minV = range.getStart(); + double maxV = range.getEnd(); + if (maxV <= minV) + return 0.0; + + double norm = (value - minV) / (maxV - minV); + norm = juce::jlimit(0.0, 1.0, norm); + + double midNorm = (1.0 - minV) / (maxV - minV); + double p0 = 0.8; + + if (norm <= midNorm) + { + if (midNorm <= 0.0) + return 0.0; + double p = (norm / midNorm) * p0; + return p; + } + else + { + double xProp = (norm - midNorm) / (1.0 - midNorm); + double p = p0 + xProp * (1.0 - p0); + return p; + } + } + + double proportionOfLengthToValue(double proportion) override + { + auto range = getRange(); + double minV = range.getStart(); + double maxV = range.getEnd(); + if (maxV <= minV) + return minV; + + double p = juce::jlimit(0.0, 1.0, (double)proportion); + double midNorm = (1.0 - minV) / (maxV - minV); + double p0 = 0.8; + + double norm; + if (p <= p0) + { + if (p0 <= 0.0) + norm = 0.0; + else + norm = (p / p0) * midNorm; + } + else + { + double xProp = (p - p0) / (1.0 - p0); + norm = midNorm + xProp * (1.0 - midNorm); + } + + return minV + norm * (maxV - minV); + } + + void mouseDown(const juce::MouseEvent& e) override + { + leftInteractionActive = e.mods.isLeftButtonDown(); + if (leftInteractionActive) + juce::Slider::mouseDown(e); + } + + void mouseDrag(const juce::MouseEvent& e) override + { + if (leftInteractionActive) + juce::Slider::mouseDrag(e); + } + + void mouseUp(const juce::MouseEvent& e) override + { + if (leftInteractionActive) + juce::Slider::mouseUp(e); + leftInteractionActive = false; + } + + void mouseDoubleClick(const juce::MouseEvent& e) override + { + if (e.mods.isLeftButtonDown()) + juce::Slider::mouseDoubleClick(e); + } + +private: + bool leftInteractionActive = false; +}; + +class LeftClickOnlySlider : public juce::Slider +{ +public: + using juce::Slider::Slider; + + void mouseDown(const juce::MouseEvent& e) override + { + leftInteractionActive = e.mods.isLeftButtonDown(); + if (leftInteractionActive) + juce::Slider::mouseDown(e); + } + + void mouseDrag(const juce::MouseEvent& e) override + { + if (leftInteractionActive) + juce::Slider::mouseDrag(e); + } + + void mouseUp(const juce::MouseEvent& e) override + { + if (leftInteractionActive) + juce::Slider::mouseUp(e); + leftInteractionActive = false; + } + + void mouseDoubleClick(const juce::MouseEvent& e) override + { + if (e.mods.isLeftButtonDown()) + juce::Slider::mouseDoubleClick(e); + } + +private: + bool leftInteractionActive = false; +}; + +class LeftClickOnlyTextButton : public juce::TextButton +{ +public: + using juce::TextButton::TextButton; + + void mouseDown(const juce::MouseEvent& e) override + { + leftInteractionActive = e.mods.isLeftButtonDown(); + if (leftInteractionActive) + juce::TextButton::mouseDown(e); + } + + void mouseDrag(const juce::MouseEvent& e) override + { + if (leftInteractionActive) + juce::TextButton::mouseDrag(e); + } + + void mouseUp(const juce::MouseEvent& e) override + { + if (leftInteractionActive) + juce::TextButton::mouseUp(e); + leftInteractionActive = false; + } + +private: + bool leftInteractionActive = false; +}; + +class LeftClickOnlyToggleButton : public juce::ToggleButton +{ +public: + using juce::ToggleButton::ToggleButton; + + void mouseDown(const juce::MouseEvent& e) override + { + leftInteractionActive = e.mods.isLeftButtonDown(); + if (leftInteractionActive) + juce::ToggleButton::mouseDown(e); + } + + void mouseDrag(const juce::MouseEvent& e) override + { + if (leftInteractionActive) + juce::ToggleButton::mouseDrag(e); + } + + void mouseUp(const juce::MouseEvent& e) override + { + if (leftInteractionActive) + juce::ToggleButton::mouseUp(e); + leftInteractionActive = false; + } + +private: + bool leftInteractionActive = false; +}; + +class MuteSoloBtnLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + bool isMute = true; // true = red (mute), false = yellow-orange (solo) + + void drawButtonBackground(juce::Graphics& g, juce::Button& button, const juce::Colour&, + bool shouldDrawButtonAsHighlighted, bool) override + { + auto bounds = button.getLocalBounds().toFloat().reduced(1.0f); + bool isOn = button.getToggleState(); + const float r = 4.0f; + + juce::Colour bg, rim; + if (isMute) + { + bg = isOn ? juce::Colour::fromRGB(130, 20, 20) : juce::Colour::fromRGB(35, 8, 8); + rim = isOn ? juce::Colour::fromRGB(255, 80, 80) + : juce::Colour::fromRGB(255, 80, 80).withAlpha(0.25f); + } + else + { + bg = isOn ? juce::Colour::fromRGB(155, 100, 5) : juce::Colour::fromRGB(42, 25, 3); + rim = isOn ? juce::Colour::fromRGB(255, 210, 60) + : juce::Colour::fromRGB(255, 210, 60).withAlpha(0.25f); + } + + g.setColour(bg); + g.fillRoundedRectangle(bounds, r); + g.setColour(rim); + g.drawRoundedRectangle(bounds, r, 1.5f); + + if (shouldDrawButtonAsHighlighted) + { + g.setColour(juce::Colours::white.withAlpha(0.06f)); + g.fillRoundedRectangle(bounds, r); + } + } + + void drawButtonText(juce::Graphics& g, juce::TextButton& button, bool, bool) override + { + bool isOn = button.getToggleState(); + auto bounds = button.getLocalBounds(); + float fontSize = juce::jmin(14.0f, (float)bounds.getHeight() * 0.65f); + g.setFont(juce::Font(fontSize, juce::Font::bold)); + + juce::Colour tc = isOn ? juce::Colours::white : juce::Colours::white.withAlpha(0.30f); + + g.setColour(juce::Colours::black.withAlpha(0.75f)); + for (int dx = -1; dx <= 1; ++dx) + for (int dy = -1; dy <= 1; ++dy) + if (dx != 0 || dy != 0) + g.drawFittedText(button.getButtonText(), bounds.translated(dx, dy), + juce::Justification::centred, 1); + g.setColour(tc); + g.drawFittedText(button.getButtonText(), bounds, juce::Justification::centred, 1); + } +}; + +class UserChannelStrip : public juce::Component, public juce::Timer +{ +public: + UserChannelStrip(NinjamVst3AudioProcessor& p, int userIdx); + ~UserChannelStrip() override; + + void paint(juce::Graphics& g) override; + void paintOverChildren(juce::Graphics& g) override; + void resized() override; + void timerCallback() override; + + void updateInfo(const NinjamVst3AudioProcessor::UserInfo& info); + void setOrientation(bool isHorizontal); // True = Mixer layout (Strip is vertical), False = List layout (Strip is horizontal) + void setClipEnabled(bool enabled); + int getPreferredHeight() const; // For dynamic height in list layout when expanded + int getPreferredWidth() const; // For dynamic width in mixer layout when expanded + int getUserIndex() const; + juce::Slider& getVolumeSlider(); + juce::Slider& getPanSlider(); + juce::Button& getMuteButton(); + juce::Button& getSoloButton(); + juce::Slider& getChannelSlider(int channel); + +private: + class PanSliderLookAndFeel : public juce::LookAndFeel_V4 + { + public: + void drawLinearSlider(juce::Graphics& g, int x, int y, int width, int height, + float sliderPos, float minSliderPos, float maxSliderPos, + const juce::Slider::SliderStyle style, juce::Slider& slider) override + { + if (style != juce::Slider::LinearHorizontal) + { + juce::LookAndFeel_V4::drawLinearSlider(g, x, y, width, height, sliderPos, minSliderPos, maxSliderPos, style, slider); + return; + } + + juce::Rectangle bounds(x, y, width, height); + int trackHeight = 6; + juce::Rectangle track(bounds.getX() + 4, + bounds.getCentreY() - trackHeight / 2, + bounds.getWidth() - 8, + trackHeight); + + juce::Colour base = slider.findColour(juce::Slider::backgroundColourId, true); + if (base == juce::Colour()) + base = juce::Colours::darkgrey.darker(); + + g.setColour(base); + g.fillRect(track); + + g.setColour(juce::Colours::black.withAlpha(0.7f)); + g.drawRect(track); + + double v = slider.getValue(); + double norm = juce::jlimit(-1.0, 1.0, v); + + int centreX = track.getCentreX(); + + if (std::abs(norm) > 0.001) + { + bool toRight = norm > 0.0; + float amount = (float)std::abs(norm); + const int leftEdge = track.getX(); + const int rightEdge = track.getRight(); + const int halfWidth = juce::jmax(1, track.getWidth() / 2); + const int activeWidth = juce::jmax(1, (int)std::round(halfWidth * amount)); + + juce::Rectangle active(track); + if (toRight) + { + active.setLeft(centreX); + active.setRight(juce::jmin(rightEdge, centreX + activeWidth)); + } + else + { + active.setRight(centreX); + active.setLeft(juce::jmax(leftEdge, centreX - activeWidth)); + } + + juce::Colour endColour = toRight ? juce::Colours::red : juce::Colours::white; + juce::ColourGradient grad(juce::Colours::black, (float)centreX, (float)track.getCentreY(), + endColour, toRight ? (float)active.getRight() : (float)active.getX(), (float)track.getCentreY(), false); + + g.setGradientFill(grad); + g.setOpacity(1.0f); + g.fillRect(active); + } + + int thumbWidth = 6; + int thumbHeight = trackHeight + 6; + int thumbX = (int)sliderPos - thumbWidth / 2; + juce::Rectangle thumb(thumbX, track.getCentreY() - thumbHeight / 2, thumbWidth, thumbHeight); + + g.setColour(juce::Colours::white); + g.fillRect(thumb); + g.setColour(juce::Colours::black); + g.drawRect(thumb); + } + }; + + NinjamVst3AudioProcessor& processor; + int userIndex; + NinjamVst3AudioProcessor::UserInfo userInfo; + + juce::Label nameLabel; + NonlinearFaderSlider volumeSlider; + LeftClickOnlySlider panSlider; + PanSliderLookAndFeel panLookAndFeel; + LeftClickOnlyToggleButton clipButton{"No-Clip"}; + LeftClickOnlyTextButton muteButton{"M"}; + LeftClickOnlyTextButton soloButton{"S"}; + MuteSoloBtnLookAndFeel muteBtnLAF; + MuteSoloBtnLookAndFeel soloBtnLAF; + juce::ComboBox outputSelector; + FaderLookAndFeel faderLookAndFeel; + juce::Label dbLabel; + bool showOutputSelector = true; + + float currentPeakL = 0.0f; + float currentPeakR = 0.0f; + bool isHorizontalLayout = false; // Default List view (strip is horizontal) + + // Multi-channel remote support + static constexpr int kMaxRemoteCh = 8; + LeftClickOnlyTextButton expandButton{ ">" }; + bool isExpanded = false; + int numRemoteChannels = 1; + bool isMultiChanPeer = false; + float perChannelGain[kMaxRemoteCh]; + float channelPeaks[kMaxRemoteCh]; + LeftClickOnlySlider channelSliders[kMaxRemoteCh]; + juce::Label channelNameLabels[kMaxRemoteCh]; // shows remote channel names + + void applyVolumesToProcessor(); + void toggleExpanded(); + void volumeChanged(); + void panChanged(); + void outputChanged(); + void muteChanged(); + void soloChanged(); + void clipChanged(); +}; + +class MasterPeakMeter : public juce::Component +{ +public: + void setPeak(float newPeak) + { + peakL = juce::jlimit(0.0f, 1.0f, newPeak); + peakR = peakL; + repaint(); + } + + void setPeak(float newPeakL, float newPeakR) + { + peakL = juce::jlimit(0.0f, 1.0f, newPeakL); + peakR = juce::jlimit(0.0f, 1.0f, newPeakR); + repaint(); + } + + void paint(juce::Graphics& g) override + { + auto bounds = getLocalBounds(); + g.fillAll(juce::Colours::black); + + auto gap = 1; + auto barWidth = juce::jmax(1, (bounds.getWidth() - gap) / 2); + auto leftBar = bounds.removeFromLeft(barWidth); + bounds.removeFromLeft(gap); + auto rightBar = bounds; + + auto drawBar = [&g] (juce::Rectangle barBounds, float peak) + { + float safePeak = juce::jlimit(1.0e-6f, 1.0f, peak); + float db = 20.0f * std::log10(safePeak); + + juce::Colour colour; + if (db >= 0.0f) colour = juce::Colours::red; + else if (db > -6.0f) colour = juce::Colours::yellow; + else colour = juce::Colours::green; + + int filled = (int)(barBounds.getHeight() * safePeak); + if (filled <= 0) + return; + + juce::Rectangle fill(barBounds.getX(), barBounds.getBottom() - filled, barBounds.getWidth(), filled); + g.setColour(colour); + g.fillRect(fill); + }; + + drawBar(leftBar, peakL); + drawBar(rightBar, peakR); + } + +private: + float peakL = 0.0f; + float peakR = 0.0f; +}; + +class UserListComponent : public juce::Component +{ +public: + UserListComponent(NinjamVst3AudioProcessor& p); + ~UserListComponent() override; + + void paint(juce::Graphics& g) override; + void resized() override; + + void updateContent(); + void setLayoutMode(bool horizontal); // True = Horizontal Mixer, False = Vertical List + void setAllClipEnabled(bool enabled); + std::vector getStripPointers() const; + +private: + NinjamVst3AudioProcessor& processor; + juce::Viewport viewport; + juce::Component contentComponent; + std::vector> strips; + bool isHorizontal = false; +}; + +class CustomKnobLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + void drawRotarySlider(juce::Graphics& g, int x, int y, int width, int height, float sliderPos, + const float rotaryStartAngle, const float rotaryEndAngle, juce::Slider& slider) override; +}; + +class SyncIconLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + void drawButtonBackground(juce::Graphics& g, juce::Button& button, const juce::Colour&, + bool shouldDrawButtonAsHighlighted, bool /*shouldDrawButtonAsDown*/) override + { + auto bounds = button.getLocalBounds().toFloat().reduced(1.0f); + bool isOn = button.getToggleState(); + const float r = 4.0f; + + juce::Colour bg = isOn ? juce::Colour::fromRGB(110, 60, 10) + : juce::Colour::fromRGB(35, 18, 4); + juce::Colour rim = isOn ? juce::Colour::fromRGB(255, 160, 60) + : juce::Colour::fromRGB(255, 160, 60).withAlpha(0.25f); + juce::Colour ic = isOn ? juce::Colour::fromRGB(255, 185, 90) + : juce::Colour::fromRGB(255, 185, 90).withAlpha(0.22f); + + g.setColour(bg); + g.fillRoundedRectangle(bounds, r); + g.setColour(rim); + g.drawRoundedRectangle(bounds, r, 1.5f); + + // --- sync / refresh icon: two circular arrows forming a circle --- + float cx = bounds.getCentreX(); + float cy = bounds.getCentreY(); + float ir = bounds.getWidth() * 0.34f; // arc radius + float sw = juce::jmax(1.4f, bounds.getWidth() * 0.115f); + float ahw = sw * 1.5f; // arrowhead half-width + float ahl = sw * 2.0f; // arrowhead length + + g.setColour(ic); + + using M = juce::MathConstants; + const float deg = M::pi / 180.0f; + + // Draw arc from startA to endA (clockwise), with filled arrowhead at end + auto drawArcArrow = [&](float startA, float endA) + { + juce::Path arc; + arc.addCentredArc(cx, cy, ir, ir, 0.0f, startA, endA, true); + g.strokePath(arc, juce::PathStrokeType(sw, juce::PathStrokeType::curved, + juce::PathStrokeType::butt)); + + // Arrowhead tip at end of arc; base recessed along clockwise tangent + float tipX = cx + std::cos(endA) * ir; + float tipY = cy + std::sin(endA) * ir; + float tanA = endA + M::halfPi; // clockwise tangent direction at endA + float bx1 = tipX - std::cos(tanA) * ahl - std::cos(endA) * ahw; + float by1 = tipY - std::sin(tanA) * ahl - std::sin(endA) * ahw; + float bx2 = tipX - std::cos(tanA) * ahl + std::cos(endA) * ahw; + float by2 = tipY - std::sin(tanA) * ahl + std::sin(endA) * ahw; + juce::Path arrowHead; + arrowHead.startNewSubPath(tipX, tipY); + arrowHead.lineTo(bx1, by1); + arrowHead.lineTo(bx2, by2); + arrowHead.closeSubPath(); + g.fillPath(arrowHead); + }; + + // Arc 1: 210° → 370°(=10°), sweeps clockwise over the TOP of the circle + drawArcArrow(210.0f * deg, 370.0f * deg); + // Arc 2: 30° → 190°, sweeps clockwise over the BOTTOM of the circle + drawArcArrow(30.0f * deg, 190.0f * deg); + + if (shouldDrawButtonAsHighlighted) + { + g.setColour(juce::Colours::white.withAlpha(0.06f)); + g.fillRoundedRectangle(bounds, r); + } + } +}; + +class MetronomeButtonLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + juce::Colour themeColour { juce::Colour::fromRGB(80, 185, 255) }; + + void drawButtonBackground(juce::Graphics& g, juce::Button& button, const juce::Colour&, + bool shouldDrawButtonAsHighlighted, bool /*shouldDrawButtonAsDown*/) override + { + auto bounds = button.getLocalBounds().toFloat().reduced(1.0f); + bool isOn = button.getToggleState(); + const float r = 4.0f; + + juce::Colour bg = isOn ? themeColour.withMultipliedBrightness(0.55f) + : themeColour.withMultipliedBrightness(0.09f); + juce::Colour rim = isOn ? themeColour + : themeColour.withAlpha(0.25f); + juce::Colour ic = isOn ? juce::Colours::white + : juce::Colours::white.withAlpha(0.30f); + + g.setColour(bg); + g.fillRoundedRectangle(bounds, r); + g.setColour(rim); + g.drawRoundedRectangle(bounds, r, 1.5f); + + // --- metronome icon (scaled to 72% of button, centred) --- + float cx = bounds.getCentreX(); + float cy = bounds.getCentreY(); + float bw = bounds.getWidth() * 0.72f; + float bh = bounds.getHeight() * 0.72f; + float bx = cx - bw * 0.5f; + float by = cy - bh * 0.5f; + + float sw = juce::jmax(1.2f, bw * 0.085f); // stroke width scales with size + + float baseH = bh * 0.16f; + float baseY = by + bh - baseH; + float bodyBot = baseY; // trapezoid bottom (top of base) + float bodyTop = by; + float topRad = bw * 0.28f; // half-width at top + float botRad = bw * 0.46f; // half-width at bottom + + // --- outer body: trapezoid with rounded arch top --- + juce::Path body; + // arc at top (rounded cap) + body.startNewSubPath(cx - topRad, bodyTop + topRad * 0.6f); + body.quadraticTo(cx - topRad, bodyTop, cx, bodyTop); + body.quadraticTo(cx + topRad, bodyTop, cx + topRad, bodyTop + topRad * 0.6f); + // right slant down to base + body.lineTo(cx + botRad, bodyBot); + // straight bottom + body.lineTo(cx - botRad, bodyBot); + body.closeSubPath(); + + g.setColour(ic); + g.strokePath(body, juce::PathStrokeType(sw, juce::PathStrokeType::curved, juce::PathStrokeType::rounded)); + + // --- solid thick base bar --- + float baseCorner = juce::jmax(1.0f, baseH * 0.35f); + g.fillRoundedRectangle(cx - botRad, baseY, botRad * 2.0f, baseH, baseCorner); + + // --- 3 small pill tick marks on left interior --- + float innerTop = bodyTop + bh * 0.18f; + float innerBot = bodyBot - bh * 0.06f; + float innerH = innerBot - innerTop; + float pillW = bw * 0.26f; + float pillH = juce::jmax(1.5f, bh * 0.075f); + float pillR = pillH * 0.5f; + float pillX = cx - botRad + (botRad - topRad) * 0.3f + bw * 0.03f; // left interior + for (int i = 0; i < 3; ++i) + { + float py = innerTop + innerH * (float)i / 2.5f + innerH * 0.05f; + g.fillRoundedRectangle(pillX, py - pillH * 0.5f, pillW, pillH, pillR); + } + + // --- pendulum arm: pivots at bottom-centre, swings up to upper-right --- + float armX0 = cx; + float armY0 = bodyBot; + float armX1 = cx + botRad * 0.85f; + float armY1 = bodyTop + bh * 0.08f; + g.drawLine(armX0, armY0, armX1, armY1, sw * 1.1f); + + if (shouldDrawButtonAsHighlighted) + { + g.setColour(juce::Colours::white.withAlpha(0.06f)); + g.fillRoundedRectangle(bounds, r); + } + } +}; + +class ATButtonLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + void drawButtonBackground(juce::Graphics& g, juce::Button& button, const juce::Colour&, + bool shouldDrawButtonAsHighlighted, bool) override + { + auto bounds = button.getLocalBounds().toFloat().reduced(1.0f); + bool isOn = button.getToggleState(); + const float r = 4.0f; + juce::Colour bg = isOn ? juce::Colour::fromRGB(10, 90, 160) : juce::Colour::fromRGB(5, 22, 42); + juce::Colour rim = isOn ? juce::Colour::fromRGB(80, 185, 255) + : juce::Colour::fromRGB(80, 185, 255).withAlpha(0.25f); + g.setColour(bg); + g.fillRoundedRectangle(bounds, r); + g.setColour(rim); + g.drawRoundedRectangle(bounds, r, 1.5f); + if (shouldDrawButtonAsHighlighted) + { + g.setColour(juce::Colours::white.withAlpha(0.06f)); + g.fillRoundedRectangle(bounds, r); + } + } + void drawButtonText(juce::Graphics& g, juce::TextButton& button, bool, bool) override + { + bool isOn = button.getToggleState(); + auto bounds = button.getLocalBounds(); + float fontSize = juce::jmin(13.0f, (float)bounds.getHeight() * 0.65f); + g.setFont(juce::Font(fontSize, juce::Font::bold)); + juce::Colour tc = isOn ? juce::Colours::white : juce::Colours::white.withAlpha(0.30f); + g.setColour(juce::Colours::black.withAlpha(0.75f)); + for (int dx = -1; dx <= 1; ++dx) + for (int dy = -1; dy <= 1; ++dy) + if (dx != 0 || dy != 0) + g.drawFittedText(button.getButtonText(), bounds.translated(dx, dy), + juce::Justification::centred, 1); + g.setColour(tc); + g.drawFittedText(button.getButtonText(), bounds, juce::Justification::centred, 1); + } +}; + +class FaderIconLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + void drawButtonBackground(juce::Graphics& g, juce::Button& button, const juce::Colour&, + bool shouldDrawButtonAsHighlighted, bool /*shouldDrawButtonAsDown*/) override + { + auto bounds = button.getLocalBounds().toFloat().reduced(1.0f); + bool isOn = button.getToggleState(); + const float r = 4.0f; + + g.setColour(isOn ? juce::Colour::fromRGB(15, 55, 60) + : juce::Colour::fromRGB(10, 22, 26)); + g.fillRoundedRectangle(bounds, r); + + g.setColour(isOn ? juce::Colour::fromRGB(30, 180, 200) + : juce::Colour::fromRGB(30, 180, 200).withAlpha(0.22f)); + g.drawRoundedRectangle(bounds, r, 1.0f); + + // 5 fader tracks + handles + juce::Colour iconCol = isOn ? juce::Colour::fromRGB(40, 210, 230) + : juce::Colour::fromRGB(40, 210, 230).withAlpha(0.22f); + g.setColour(iconCol); + + float ix = bounds.getX() + bounds.getWidth() * 0.09f; + float iw = bounds.getWidth() * 0.82f; + float iy = bounds.getY() + bounds.getHeight() * 0.12f; + float ih = bounds.getHeight() * 0.76f; + + const float pos[5] = { 0.35f, 0.65f, 0.2f, 0.55f, 0.45f }; + float fw = iw / 5.0f; + for (int i = 0; i < 5; ++i) + { + float cx = ix + fw * (i + 0.5f); + g.drawLine(cx, iy, cx, iy + ih, 1.2f); + float hy = iy + ih * pos[i]; + float hw = fw * 0.62f; + float hh = juce::jmax(3.0f, ih * 0.18f); + g.fillRect(cx - hw * 0.5f, hy - hh * 0.5f, hw, hh); + } + + if (shouldDrawButtonAsHighlighted) + { + g.setColour(juce::Colours::white.withAlpha(0.06f)); + g.fillRoundedRectangle(bounds, r); + } + } +}; + +class NinjamVst3AudioProcessorEditor : public juce::AudioProcessorEditor, + public juce::Timer, + private juce::OSCReceiver, + private juce::OSCReceiver::Listener, + private juce::MidiInputCallback +{ +public: + NinjamVst3AudioProcessorEditor (NinjamVst3AudioProcessor&); + ~NinjamVst3AudioProcessorEditor() override; + + void paint (juce::Graphics&) override; + void paintOverChildren (juce::Graphics&) override; + void resized() override; + void timerCallback() override; + void parentHierarchyChanged() override; + void mouseDown(const juce::MouseEvent& event) override; + bool shouldDeferHeavyUiWork() const; + + juce::Image backgroundImage; + juce::Image radioKnobImage; + juce::Image faderKnobImage; + juce::Array textureFiles; +#if JUCE_WINDOWS + std::unique_ptr videoFrameReader; +#endif + juce::String knobColourPreset { "grey" }; + juce::String faderColourPreset { "grey" }; + juce::Colour knobThemeColour { juce::Colours::grey }; + juce::Colour faderThemeColour { juce::Colour(0xff666666) }; + juce::Colour metronomeThemeColour { juce::Colour::fromRGB(80, 185, 255) }; + juce::Colour windowThemeColour { juce::Colour(0x00000000) }; // transparent = no override + juce::Colour buttonThemeColour { juce::Colour(0x00000000) }; // transparent = no override + juce::Colour menuBarThemeColour { juce::Colour(0x00000000) }; // transparent = no override + CustomKnobLookAndFeel customKnobLookAndFeel; + FaderIconLookAndFeel faderIconLookAndFeel; + MetronomeButtonLookAndFeel metronomeBtnLAF; + SyncIconLookAndFeel syncIconLAF; + ATButtonLookAndFeel atBtnLAF; + ATButtonLookAndFeel chatBtnLAF; + OutlinedLabelLookAndFeel outlinedLabelLAF; + +private: + NinjamVst3AudioProcessor& audioProcessor; + IntervalDisplayComponent intervalDisplay; + juce::TooltipWindow tooltipWindow{ this, 600 }; + + // UI components + juce::Label statusLabel; + + // Login + juce::Label serverLabel{ "Server", "Server:" }; + juce::TextEditor serverField; + LeftClickOnlyTextButton serverListButton; + juce::Label userLabel{ "User", "User:" }; + juce::TextEditor userField; + LeftClickOnlyToggleButton anonymousButton{ "Anonymous" }; + juce::Label passLabel{ "Pass", "Pass:" }; + juce::TextEditor passField; + LeftClickOnlyTextButton connectButton; + + // Controls + LeftClickOnlyTextButton transmitButton{ "Transmit" }; + LeftClickOnlyTextButton localMonitorButton{ "Monitor Local" }; + LeftClickOnlyTextButton voiceChatButton{ "Voice Chat" }; + juce::ComboBox bitrateSelector; + juce::ComboBox midiRelayTargetSelector; + LeftClickOnlyTextButton layoutButton{ "" }; + LeftClickOnlyTextButton opusSyncToggle{ "HD" }; + juce::Label metronomeLabel{ "Metro", "Metronome:" }; + LeftClickOnlySlider metronomeSlider; + LeftClickOnlyTextButton metronomeMuteButton{ "" }; + LeftClickOnlyTextButton autoLevelButton{ "Auto Level" }; + LeftClickOnlyTextButton syncButton{ "" }; + LeftClickOnlyTextButton fxButton{ "FX" }; + LeftClickOnlyTextButton optionsButton{ "Options" }; + juce::Label tempoLabel; + juce::ComboBox backgroundSelector{ "Background" }; + LeftClickOnlyToggleButton videoBgToggle{ "Video BG" }; + LeftClickOnlyTextButton videoButton{ "Video Room" }; + LeftClickOnlyTextButton chatButton{ "Chat" }; + + // Chat + juce::TextEditor chatDisplay; + juce::TextEditor chatInput; + LeftClickOnlyTextButton sendButton{ "Send" }; + LeftClickOnlyTextButton atButton{ "AT" }; + LeftClickOnlyTextButton chatPopoutButton{ "Popout" }; + + // Users + juce::Label usersLabel{ "Users", "Connected Users:" }; + LeftClickOnlyToggleButton spreadOutputsButton{ "Spread Outputs" }; + UserListComponent userList; + + FaderLookAndFeel mixerFaderLookAndFeel; + juce::Label localFaderLabel{ "Local", "Local" }; + LeftClickOnlyTextButton addLocalChannelButton{ "+" }; + LeftClickOnlyTextButton removeLocalChannelButton{ "-" }; + std::array localFaders; + std::array localPeakMeters; + std::array localInputModeSelectors; + std::array localInputSelectors; + std::array localReverbSendKnobs; + std::array localDelaySendKnobs; + std::array localReverbSendLabels; + std::array localDelaySendLabels; + juce::Label masterFaderLabel{ "Master", "Master" }; + NonlinearFaderSlider masterFader; + MasterPeakMeter masterPeakMeter; + std::array localDbLabels; + std::array localChannelNameLabels; // editable channel name + juce::Label masterDbLabel; + LeftClickOnlyTextButton limiterButton{ "Limiter" }; + juce::Label limiterReleaseLabel{ "Release", "Release" }; + class LimiterThresholdLookAndFeel : public juce::LookAndFeel_V4 + { + public: + void drawLinearSlider(juce::Graphics& g, int x, int y, int width, int height, + float sliderPos, float minSliderPos, float maxSliderPos, + const juce::Slider::SliderStyle style, juce::Slider& slider) override + { + juce::Rectangle bounds(x, y, width, height); + auto track = bounds.reduced(width / 3, 6); + g.setColour(juce::Colours::black); + g.fillRect(track); + g.setColour(juce::Colours::darkgrey.brighter(0.2f)); + g.drawRect(track); + + int handleHeight = 10; + int handleWidth = track.getWidth() + 4; + int clampedY = juce::jlimit(track.getY() + handleHeight / 2, + track.getBottom() - handleHeight / 2, + (int)sliderPos); + juce::Rectangle handle(track.getCentreX() - handleWidth / 2, + clampedY - handleHeight / 2, + handleWidth, + handleHeight); + + g.setColour(juce::Colours::lightblue); + g.fillRect(handle); + g.setColour(juce::Colours::black); + g.drawRect(handle); + } + } limiterThresholdLookAndFeel; + + LeftClickOnlySlider limiterThresholdSlider; + LeftClickOnlySlider limiterReleaseSlider; + juce::Label reverbRoomLabel{ "Reverb", "Reverb" }; + LeftClickOnlySlider reverbRoomSlider; + juce::Label delayTimeLabel{ "Delay", "Delay" }; + LeftClickOnlySlider delayTimeSlider; + juce::ComboBox delayDivisionSelector; + LeftClickOnlyToggleButton delayPingPongButton{ "PingPong" }; + + void connectClicked(); + void sendClicked(); + void transmitToggled(); + void layoutToggled(); + void metronomeChanged(); + void anonymousToggled(); + void atToggled(); + void syncToggled(); + void chatToggled(); + void chatPopoutClicked(); + void videoClicked(); + + void serverListClicked(); + void updateAutoLevelButtonColor(); + void updateChatButtonColor(); + void updateTransmitButtonColor(); + void updateMonitorButtonColor(); + void updateLimiterButtonColor(); + void updateVoiceChatButtonColor(); + void updateLayoutButtonColor(); + void updateMetronomeButtonColor(); + void updateSyncButtonColor(); + void updateFxButtonLabel(); + void showFxMenu(); + void showOptionsMenu(); + void showSettingsCallout(std::unique_ptr content, juce::Component& anchorComponent); + void showReverbSettingsPopup(); + void showDelaySettingsPopup(); + void updateFxControlsVisibility(); + void refreshLocalInputSelectors(); + void refreshMidiRelayTargetSelector(); + void oscMessageReceived(const juce::OSCMessage& message) override; + void applyOscMappings(); + void applyRemoteMidiRelaySelection(int channel, int inputIndex); + void refreshLocalInputSelector(int channel); + void showMidiOptionsPopup(); + void refreshExternalMidiInputDevices(); + void handleIncomingMidiMessage(juce::MidiInput* source, const juce::MidiMessage& message) override; + void syncLearnMappingsToProcessor(); + void loadLearnMappingsFromProcessor(); + void saveLearnMappingsToDisk(); + void loadLearnMappingsFromDisk(); + void clearLearnMappings(); + bool isSidechainInputActive() const; + bool isAbletonLiveHost() const; + void setAbletonWindowSizePreset(int presetIndex); + void updateHostResizeModeForConnectionStatus(int status); + void loadControlImages(const juce::File& themeDir); + void applyThemeColours(); + void registerMidiLearnTarget(juce::Component& component, const juce::String& targetId, bool isToggle); + void syncUserStripMidiTargets(); + void showMidiLearnMenuForComponent(juce::Component& component, juce::Point screenPos); + void applyMidiMappings(); + + struct MidiLearnTarget + { + juce::String id; + juce::Component* component = nullptr; + bool isToggle = false; + }; + + struct MidiSourceMapping + { + bool isController = true; + int midiChannel = 1; + int number = 0; + int lastBinaryState = -1; + }; + + struct OscSourceMapping + { + juce::String address; + int lastBinaryState = -1; + }; + + struct PendingOscEvent + { + juce::String address; + float normalized = 0.0f; + bool binaryOn = false; + }; + + int lastChatSize = 0; + + std::unique_ptr serverListWindow; + std::unique_ptr chatWindow; + + bool autoLevelEnabled = false; + bool chatPoppedOut = false; + bool pendingDeferredResizeLayout = false; + bool applyingDeferredResizeLayout = false; + bool hostResizeLockedForConnection = false; + int abletonWindowSizePreset = 1; + double lastResizeEventMs = 0.0; + double suppressHeavyUiUntilMs = 0.0; + int heavyUiTickCounter = 0; + float voiceChatGlowPhase = 0.0f; + float storedMetronomeVolume = 0.5f; + std::map autoLevelCurrentGains; + std::map autoLevelPeakLevels; + std::map autoLevelChannelActiveTicks; + std::map autoLevelMeasureTicks; + std::map autoLevelOverTargetTicks; + std::map midiTargetsByComponent; + std::map midiTargetsById; + std::map midiSourceByTargetId; + std::map oscSourceByTargetId; + juce::String midiLearnArmedTargetId; + juce::String oscLearnArmedTargetId; + juce::SpinLock oscEventQueueLock; + std::vector pendingOscEvents; + std::map midiRelayTargetByMenuId; + std::unique_ptr midiLearnInputDevice; + std::unique_ptr midiRelayInputDevice; + juce::String openedMidiLearnInputDeviceId; + juce::String openedMidiRelayInputDeviceId; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (NinjamVst3AudioProcessorEditor) +}; diff --git a/extras/ninjam-vst3/Source - Copy/PluginProcessor.cpp b/extras/ninjam-vst3/Source - Copy/PluginProcessor.cpp new file mode 100644 index 00000000..4aafac5f --- /dev/null +++ b/extras/ninjam-vst3/Source - Copy/PluginProcessor.cpp @@ -0,0 +1,4248 @@ +#include "PluginProcessor.h" +#include "PluginEditor.h" +#include + +// ---- Video pipeline debug log ---- +static void vlog(const char* msg) +{ + juce::File f = juce::File::getSpecialLocation(juce::File::tempDirectory) + .getChildFile("ninjam_video_debug.txt"); + const juce::String line = juce::Time::getCurrentTime().toString(true, true, true, true) + + " " + juce::String::fromUTF8(msg) + "\n"; + f.appendText(line, false, false); +} +static void vlogStr(const juce::String& msg) { vlog(msg.toRawUTF8()); } +// ---------------------------------- + +#ifdef _WIN32 +#include +#include +#pragma comment(lib, "winhttp.lib") +#endif + + +namespace +{ + constexpr unsigned int makeNjFourcc(const char a, const char b, const char c, const char d) + { + return ((unsigned int)(unsigned char)a) | + ((unsigned int)(unsigned char)b << 8) | + ((unsigned int)(unsigned char)c << 16) | + ((unsigned int)(unsigned char)d << 24); + } + constexpr const char* opusSyncAppFamily = "ninjam-vst3"; + constexpr int opusSyncHandshakeVersion = 1; + constexpr const char* opusSyncChatPrefix = "__NINJAM_VST3_OPUSSYNC__ "; + // Custom FOURCC for opusSyncSupport broadcast via NINJAM interval channel + // Any server routes it transparently; other clients ignore unknown FOURCCs + constexpr unsigned int kOpusSyncFourcc = makeNjFourcc('N','J','S','3'); + // Custom FOURCC for interval sync signals (intervalSyncTag, transportProbe, latencyReport) + constexpr unsigned int kSyncSignalFourcc = makeNjFourcc('N','J','S','4'); + constexpr const char* sideSignalChatPrefix = "__NINJAM_VST3_SIDESIGNAL__ "; + constexpr int remoteLatencyUpdateCadenceIntervals = 1; + + juce::String normaliseOpusPeerId(juce::String userId) + { + userId = userId.trim(); + const int atPos = userId.indexOfChar('@'); + if (atPos > 0) + userId = userId.substring(0, atPos); + return userId.toLowerCase(); + } + + juce::String normaliseChatTargetNick(juce::String userId) + { + userId = userId.trim(); + const int atPos = userId.indexOfChar('@'); + if (atPos > 0) + userId = userId.substring(0, atPos); + const int colonPos = userId.lastIndexOfChar(':'); + if (colonPos >= 0 && colonPos < userId.length() - 1) + userId = userId.substring(colonPos + 1); + return userId.trim(); + } + + juce::String canonicalDelayUserKey(juce::String userId) + { + userId = normaliseOpusPeerId(userId); + if (userId.startsWith("anonymous:")) + userId = userId.substring(10); + userId = userId.trim().toLowerCase(); + return userId; + } + + juce::String getWrapperTypeName(juce::AudioProcessor::WrapperType wrapperType) + { + using WrapperType = juce::AudioProcessor::WrapperType; + switch (wrapperType) + { + case WrapperType::wrapperType_Standalone: return "standalone"; + case WrapperType::wrapperType_VST: return "vst"; + case WrapperType::wrapperType_VST3: return "vst3"; + case WrapperType::wrapperType_AudioUnit: return "au"; + case WrapperType::wrapperType_AudioUnitv3: return "auv3"; + case WrapperType::wrapperType_AAX: return "aax"; + case WrapperType::wrapperType_LV2: return "lv2"; + default: break; + } + return "unknown"; + } + + inline float softClipSample(float x) + { + const float k = 2.0f; + const float d = std::tanh(k); + const float c = d / k; + const float target = 0.891251f; + + float y = std::tanh(k * c * x); + if (d != 0.0f) + y = (y / d) * target; + return y; + } + + inline juce::String buildDefaultLocalChannelName(int channelIndex) + { + return "Ch" + juce::String(channelIndex + 1); + } + + inline bool isDefaultLocalChannelName(const juce::String& name) + { + auto trimmed = name.trim(); + if (!trimmed.startsWithIgnoreCase("ch")) + return false; + + auto numberPart = trimmed.substring(2).trim(); + if (numberPart.isEmpty() || !numberPart.containsOnly("0123456789")) + return false; + + return numberPart.getIntValue() > 0; + } + +} + +NinjamVst3AudioProcessor::NinjamVst3AudioProcessor() + : AudioProcessor (BusesProperties() + .withInput ("Input", juce::AudioChannelSet::stereo(), true) + .withInput ("Input 2", juce::AudioChannelSet::stereo(), false) + .withInput ("Input 3", juce::AudioChannelSet::stereo(), false) + .withInput ("Input 4", juce::AudioChannelSet::stereo(), false) + .withInput ("Input 5", juce::AudioChannelSet::stereo(), false) + .withInput ("Input 6", juce::AudioChannelSet::stereo(), false) + .withInput ("Input 7", juce::AudioChannelSet::stereo(), false) + .withInput ("Input 8", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output Main", juce::AudioChannelSet::stereo(), true) + .withOutput ("Output 2", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 3", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 4", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 5", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 6", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 7", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 8", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 9", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 10", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 11", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 12", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 13", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 14", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 15", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 16", juce::AudioChannelSet::stereo(), false) + ) +{ + for (int i = 0; i < maxLocalChannels; ++i) + { + localChannelGains[(size_t)i].store(1.0f); + localChannelPeaks[(size_t)i].store(0.0f); + localChannelPeaksL[(size_t)i].store(0.0f); + localChannelPeaksR[(size_t)i].store(0.0f); + localChannelInputs[(size_t)i].store(-1); + localChannelReverbSends[(size_t)i].store(0.0f); + localChannelDelaySends[(size_t)i].store(0.0f); + localChannelNames[(size_t)i] = buildDefaultLocalChannelName(i); + } + + startTimer(20); // Run NINJAM client loop every 20ms + + // Set callbacks + ninjamClient.LicenseAgreementCallback = LicenseAgreementCallback; + ninjamClient.LicenseAgreement_User = this; + + ninjamClient.ChatMessage_Callback = ChatMessage_Callback; + ninjamClient.ChatMessage_User = this; + ninjamClient.IntervalMediaItem_Callback = IntervalMediaItem_Callback; + ninjamClient.IntervalMediaItem_User = this; + ninjamClient.IntervalChunkCallback = IntervalChunkCallback_cb; + ninjamClient.IntervalChunkCallbackUser = this; + ninjamClient.NewIntervalCallback = NewIntervalCallback_cb; + ninjamClient.NewIntervalCallbackUser = this; + opusSyncInstanceId = juce::Uuid().toString(); + + // Default Metronome + ninjamClient.config_metronome = 1.0f; // -12dB or similar? 1.0 is 0dB + + // Ensure disconnected state + ninjamClient.Disconnect(); + + // Initialize JNetLib (WSAStartup on Windows) + JNL::open_socketlib(); + + videoHelperRootDir = resolveVideoHelperRootDir(); + if (videoHelperRootDir.isDirectory()) + intervalJsonFile = videoHelperRootDir.getChildFile("intervals.json"); +} + +void NinjamVst3AudioProcessor::connectToServer(juce::String host, juce::String user, juce::String pass) +{ + host = host.trim(); + user = user.trim(); + pass = pass.trim(); + + if (host.isEmpty()) + host = "127.0.0.1"; + + if (user.isEmpty()) + { + user = "anonymous:jammer"; + pass = "anon"; + } + + { + const juce::ScopedLock lock(opusSyncPeerLock); + opusSyncPeers.clear(); + } + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + lastAnnouncedRemoteIntervalByUser.clear(); + localIntervalStartMsByInterval.clear(); + pendingRemoteIntervalStartsByUser.clear(); + remoteTransportRttMsByUser.clear(); + pendingTransportProbeSentMsById.clear(); + remoteLatencyLastAppliedIntervalByUser.clear(); + remoteLatencyAverageByUser.clear(); + remoteLatencyFirmDelayMsByUser.clear(); + } + opusSyncAvailable.store(false); + opusSyncHasLegacyClients.store(false); + lastOpusSupportBroadcastMs = 0.0; + lastTransportProbeBroadcastMs = 0.0; + + applyCodecPreference(); + + ninjamClient.Connect(host.toRawUTF8(), user.toRawUTF8(), pass.toRawUTF8()); + currentServer = host; + currentUser = user; + + // Do NOT reset isTransmitting here — the user may have toggled it before + // connecting. The NJC_STATUS_OK handler calls syncLocalIntervalChannelConfig() + // which re-applies the current isTransmitting state to NJClient. +} + +void NinjamVst3AudioProcessor::disconnectFromServer() +{ + ninjamClient.Disconnect(); + currentServer = {}; + currentUser = {}; + { + const juce::ScopedLock lock(opusSyncPeerLock); + opusSyncPeers.clear(); + } + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + lastAnnouncedRemoteIntervalByUser.clear(); + localIntervalStartMsByInterval.clear(); + pendingRemoteIntervalStartsByUser.clear(); + remoteTransportRttMsByUser.clear(); + pendingTransportProbeSentMsById.clear(); + remoteLatencyLastAppliedIntervalByUser.clear(); + remoteLatencyAverageByUser.clear(); + remoteLatencyFirmDelayMsByUser.clear(); + } + opusSyncAvailable.store(false); + opusSyncHasLegacyClients.store(false); + applyCodecPreference(); +} + +void NinjamVst3AudioProcessor::sendChatMessage(juce::String msg) +{ + msg = msg.trim(); + if (msg.isEmpty()) + return; + + { + juce::ScopedLock lock(chatLock); + juce::String localLine = "Me: " + msg; + chatHistory.add(localLine); + chatSenders.add("me"); + if (chatHistory.size() > 100) + { + chatHistory.removeRange(0, chatHistory.size() - 100); + chatSenders.removeRange(0, juce::jmax(0, chatSenders.size() - 100)); + } + } + + if (ninjamClient.GetStatus() == NJClient::NJC_STATUS_OK) + ninjamClient.ChatMessage_Send("MSG", msg.toRawUTF8()); + juce::Logger::writeToLog("NINJAM Chat (local): " + msg); +} + +void NinjamVst3AudioProcessor::setMetronomeVolume(float vol) +{ + ninjamClient.config_metronome = vol; +} + +float NinjamVst3AudioProcessor::getMetronomeVolume() const +{ + return ninjamClient.config_metronome; +} + +bool NinjamVst3AudioProcessor::isOpusSyncAvailable() const +{ + return opusSyncAvailable.load(); +} + +juce::String NinjamVst3AudioProcessor::getIntervalSyncStatusText() const +{ + const juce::ScopedLock lock(intervalSyncStatusLock); + return intervalSyncStatusText; +} + +void NinjamVst3AudioProcessor::setIntervalSyncStatusText(const juce::String& text) +{ + const juce::ScopedLock lock(intervalSyncStatusLock); + intervalSyncStatusText = text; +} + +void NinjamVst3AudioProcessor::broadcastIntervalSyncTag(const juce::String& target) +{ + if (ninjamClient.GetStatus() != NJClient::NJC_STATUS_OK) + return; + + const int displayInterval = getDisplayIntervalIndex(); + const int bpi = juce::jmax(1, getBPI()); + const float intervalProgress = juce::jlimit(0.0f, 1.0f, getIntervalProgress()); + const int beatIndex = juce::jlimit(0, bpi - 1, (int)std::floor(intervalProgress * (float)bpi)); + const juce::String userId = normaliseOpusPeerId(currentUser); + const juce::String tag = buildIntervalSyncTag(displayInterval, bpi); + + juce::DynamicObject::Ptr obj = new juce::DynamicObject(); + obj->setProperty("type", "intervalSyncTag"); + obj->setProperty("userId", userId.isNotEmpty() ? userId : currentUser); + obj->setProperty("tag", tag); + obj->setProperty("intervalIndex", displayInterval); + obj->setProperty("intervalAbsolute", intervalIndex.load()); + obj->setProperty("bpi", bpi); + obj->setProperty("beatIndex", beatIndex); + obj->setProperty("intervalProgress", intervalProgress); + obj->setProperty("eventId", "intervalTag:" + (userId.isNotEmpty() ? userId : currentUser) + ":" + juce::String(++sideSignalEventCounter)); + const juce::String payload = juce::JSON::toString(juce::var(obj.get())); + const juce::String safeTarget = target.isNotEmpty() ? target : "*"; + sendIntervalSignal("intervalSyncTag", payload); + return; +} + +void NinjamVst3AudioProcessor::broadcastTransportProbe(const juce::String& target) +{ + if (ninjamClient.GetStatus() != NJClient::NJC_STATUS_OK) + return; + + const juce::String userId = normaliseOpusPeerId(currentUser); + const juce::String probeId = "probe:" + (userId.isNotEmpty() ? userId : currentUser) + ":" + juce::String(++transportProbeCounter); + const double nowMs = juce::Time::getMillisecondCounterHiRes(); + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + pendingTransportProbeSentMsById[probeId] = nowMs; + while ((int)pendingTransportProbeSentMsById.size() > 256) + pendingTransportProbeSentMsById.erase(pendingTransportProbeSentMsById.begin()); + } + + juce::DynamicObject::Ptr obj = new juce::DynamicObject(); + obj->setProperty("type", "intervalTransportProbe"); + obj->setProperty("userId", userId.isNotEmpty() ? userId : currentUser); + obj->setProperty("probeId", probeId); + obj->setProperty("eventId", "transportProbe:" + probeId); + const juce::String payload = juce::JSON::toString(juce::var(obj.get())); + const juce::String safeTarget = target.isNotEmpty() ? target : "*"; + sendIntervalSignal("intervalTransportProbe", payload); +} + +void NinjamVst3AudioProcessor::broadcastOpusSyncSupport(const juce::String& target) +{ + if (ninjamClient.GetStatus() != NJClient::NJC_STATUS_OK) + return; + + const juce::String userId = normaliseOpusPeerId(currentUser); + juce::DynamicObject::Ptr obj = new juce::DynamicObject(); + obj->setProperty("type", "opusSyncSupport"); + obj->setProperty("userId", userId.isNotEmpty() ? userId : currentUser); + obj->setProperty("clientId", opusSyncInstanceId); + obj->setProperty("appFamily", opusSyncAppFamily); + obj->setProperty("handshakeVersion", opusSyncHandshakeVersion); + obj->setProperty("runtimeFormat", getWrapperTypeName(wrapperType)); + obj->setProperty("pluginName", juce::String(JucePlugin_Name)); + obj->setProperty("pluginVersion", juce::String(JucePlugin_VersionString)); + obj->setProperty("supportsOpus", true); + obj->setProperty("enabled", numLocalChannels.load() > 1); + obj->setProperty("numChannels", numLocalChannels.load()); + obj->setProperty("eventId", "opusSupport:" + (userId.isNotEmpty() ? userId : currentUser) + ":" + juce::String(++sideSignalEventCounter)); + const juce::String payload = juce::JSON::toString(juce::var(obj.get())); + vlogStr("broadcastOpusSyncSupport -> target=" + (target.isNotEmpty() ? target : "*") + " enabled=" + juce::String(numLocalChannels.load() > 1 ? "true" : "false")); + // Use NINJAM interval channel with custom FOURCC — works on any standard server, + // routed like audio data, other clients silently ignore unknown FOURCCs. + // Target is ignored here (interval data goes to all subscribers of our channel 0). + juce::ignoreUnused(target); + ninjamClient.SendRawIntervalItem(0, kOpusSyncFourcc, payload.toRawUTF8(), (int)payload.getNumBytesAsUTF8()); +} + +void NinjamVst3AudioProcessor::refreshOpusSyncAvailabilityFromUsers() +{ + const double nowMs = juce::Time::getMillisecondCounterHiRes(); + bool available = false; + int freshPeerCount = 0; + { + const juce::ScopedLock lock(opusSyncPeerLock); + for (auto it = opusSyncPeers.begin(); it != opusSyncPeers.end();) + { + const auto& peer = it->second; + const bool isFresh = (nowMs - peer.lastSeenMs) <= 6500.0; + if (peer.supportsOpus && isFresh) + ++it; + else + it = opusSyncPeers.erase(it); + } + available = !opusSyncPeers.empty(); + freshPeerCount = (int)opusSyncPeers.size(); + } + + // Rebuild the quick username→multiChan snapshot (separate lock, no njclient calls) + { + const juce::ScopedLock lock2(opusSyncPeerLock); + const juce::ScopedLock mcLock(peerMultiChanLock); + peerMultiChanByName.clear(); + for (auto& [key, peer] : opusSyncPeers) + { + if (peer.supportsOpus && !peer.userId.isEmpty()) + { + const juce::String snapKey = canonicalDelayUserKey(peer.userId); + peerMultiChanByName[snapKey] = { peer.multiChanEnabled, peer.numChannels }; + vlogStr("[MCSnap] stored snapKey='" + snapKey + "' (userId='" + peer.userId + "') multiChan=" + juce::String(peer.multiChanEnabled ? 1 : 0) + " nCh=" + juce::String(peer.numChannels)); + } + } + vlogStr("[MCSnap] rebuild done mapSize=" + juce::String((int)peerMultiChanByName.size())); + } + + const int remoteUserCount = juce::jmax(0, ninjamClient.GetNumUsers()); + const bool hasLegacyClients = remoteUserCount > freshPeerCount; + + const bool previous = opusSyncAvailable.exchange(available); + const bool previousLegacy = opusSyncHasLegacyClients.exchange(hasLegacyClients); + if (!previous && available) + { + juce::ScopedLock lock(chatLock); + chatHistory.add("Multi-Channel Audio Detected."); + chatSenders.add(""); + if (chatHistory.size() > 100) + { + chatHistory.removeRange(0, chatHistory.size() - 100); + chatSenders.removeRange(0, juce::jmax(0, chatSenders.size() - 100)); + } + } + if (previous != available || previousLegacy != hasLegacyClients) + { + applyCodecPreference(); + syncLocalIntervalChannelConfig(); + } +} + +void NinjamVst3AudioProcessor::setTransmitLocal(bool shouldTransmit) +{ + isTransmitting = shouldTransmit; + syncLocalIntervalChannelConfig(); +} + +void NinjamVst3AudioProcessor::syncLocalIntervalChannelConfig() +{ + const bool shouldTransmit = isTransmitting; + const int bitrate = shouldTransmit ? localBitrate : 24; + const int flags = voiceChatMode ? 2 : 0; + const int numCh = juce::jlimit(1, maxLocalChannels, numLocalChannels.load()); + const bool multiChanAuto = numCh > 1 && opusSyncAvailable.load() && shouldTransmit; + + if (multiChanAuto) + { + // NINJAM ch 0: Vorbis mixdown (for all clients including legacy) + // NINJAM ch 1..N: Opus per-channel (for our VST3 clients only) + juce::String ch0Name = getLocalChannelName(0); + if (ch0Name.isEmpty()) ch0Name = "Mix"; + ninjamClient.SetLocalChannelInfo(0, ch0Name.toRawUTF8(), + true, numCh, // srcch = mix buffer at inputs[numCh] + true, bitrate, true, true, false, 0, true, flags); + for (int i = 0; i < numCh; ++i) + { + juce::String chName = getLocalChannelName(i); + if (chName.isEmpty()) chName = "Ch " + juce::String(i + 1); + ninjamClient.SetLocalChannelInfo(i + 1, chName.toRawUTF8(), + true, i, // srcch = original buffer slot i + true, bitrate, true, true, false, 0, true, flags); + } + for (int i = numCh + 1; i <= maxLocalChannels; ++i) + ninjamClient.DeleteLocalChannel(i); + } + else + { + // Vorbis only: single channel + juce::String ch0Name = getLocalChannelName(0); + if (ch0Name.isEmpty()) ch0Name = "Input"; + const int sourceChannel = shouldTransmit ? 0 : 1023; + ninjamClient.SetLocalChannelInfo(0, ch0Name.toRawUTF8(), + true, sourceChannel, true, bitrate, true, true, false, 0, true, flags); + for (int i = 1; i <= maxLocalChannels; ++i) + ninjamClient.DeleteLocalChannel(i); + } + + if (ninjamClient.GetStatus() == NJClient::NJC_STATUS_OK) + ninjamClient.NotifyServerOfChannelChange(); +} + +void NinjamVst3AudioProcessor::setLocalBitrate(int bitrate) +{ + localBitrate = bitrate; + syncLocalIntervalChannelConfig(); +} + +int NinjamVst3AudioProcessor::getLocalBitrate() const +{ + return localBitrate; +} + +void NinjamVst3AudioProcessor::setVoiceChatMode(bool enabled) +{ + voiceChatMode = enabled; + syncLocalIntervalChannelConfig(); +} + +bool NinjamVst3AudioProcessor::isVoiceChatMode() const +{ + return voiceChatMode; +} + +void NinjamVst3AudioProcessor::applyCodecPreference() +{ + const int numCh = juce::jlimit(1, maxLocalChannels, numLocalChannels.load()); + const bool multiChanAuto = numCh > 1 && opusSyncAvailable.load(); + const int decodeCaps = NJClient::NJCLIENT_CAP_DECODE_VORBIS | NJClient::NJCLIENT_CAP_DECODE_OPUS; + + if (multiChanAuto) + { + // ch 0: Vorbis only (mixdown for all clients) + // ch 1..N: Opus only (per-channel for our VST3 clients) + unsigned int vorbisMask = 0x1u; + unsigned int opusMask = 0u; + for (int i = 0; i < numCh; ++i) + opusMask |= (1u << (i + 1)); + ninjamClient.SetCodecCapabilities( + NJClient::NJCLIENT_CAP_ENCODE_VORBIS | NJClient::NJCLIENT_CAP_ENCODE_OPUS, decodeCaps); + ninjamClient.SetCodecConfig(vorbisMask, opusMask); + } + else + { + // Single channel or no VST3 peers: Vorbis only + ninjamClient.SetCodecCapabilities(NJClient::NJCLIENT_CAP_ENCODE_VORBIS, decodeCaps); + ninjamClient.SetCodecConfig(0x1u, 0u); + } +} + +juce::String NinjamVst3AudioProcessor::buildIntervalSyncTag(int interval, int length) const +{ + const juce::String userPart = currentUser.isNotEmpty() ? currentUser : "unknown"; + return userPart + ":" + juce::String(interval) + ":" + juce::String(length); +} + +juce::File NinjamVst3AudioProcessor::resolveVideoHelperRootDir() const +{ + juce::Array candidates; + const juce::File exe = juce::File::getSpecialLocation(juce::File::currentExecutableFile); + juce::File probe = exe.getParentDirectory(); + for (int i = 0; i < 8; ++i) + { + candidates.add(probe.getChildFile("advanced-vdo-client")); + probe = probe.getParentDirectory(); + } + candidates.add(juce::File("E:\\Web stuff\\NINJAM VST3\\advanced-vdo-client")); + + for (const auto& dir : candidates) + { + if (dir.isDirectory() && dir.getChildFile("index.html").existsAsFile() && dir.getChildFile("server.js").existsAsFile()) + return dir; + } + + return {}; +} + +bool NinjamVst3AudioProcessor::isAdvancedVideoClientAvailable() const +{ +#ifdef _WIN32 + HINTERNET hSession = WinHttpOpen(L"NINJAM_VST3/1.0", + WINHTTP_ACCESS_TYPE_NO_PROXY, + WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, + 0); + if (!hSession) + return false; + + HINTERNET hConnect = WinHttpConnect(hSession, L"127.0.0.1", 8100, 0); + if (!hConnect) + { + WinHttpCloseHandle(hSession); + return false; + } + + HINTERNET hRequest = WinHttpOpenRequest(hConnect, + L"HEAD", + L"/", + nullptr, + WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, + 0); + if (!hRequest) + { + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return false; + } + + const bool ok = WinHttpSendRequest(hRequest, + WINHTTP_NO_ADDITIONAL_HEADERS, + 0, + WINHTTP_NO_REQUEST_DATA, + 0, + 0, + 0) && + WinHttpReceiveResponse(hRequest, nullptr); + + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return ok; +#else + // Use JUCE's cross-platform TCP socket to probe 127.0.0.1:8100 + juce::StreamingSocket sock; + if (!sock.connect("127.0.0.1", 8100, 500)) + return false; + const juce::String req = "HEAD / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n"; + sock.write(req.toRawUTF8(), (int)req.getNumBytesAsUTF8()); + char buf[16] = {}; + sock.read(buf, sizeof(buf) - 1, false); + return juce::String(buf).startsWith("HTTP/"); +#endif +} + +bool NinjamVst3AudioProcessor::ensureAdvancedVideoClientStarted() +{ + if (isAdvancedVideoClientAvailable()) + { + videoHelperRunning.store(true); + return true; + } + + if (advancedVideoProcess && advancedVideoProcess->isRunning()) + { + for (int i = 0; i < 30; ++i) + { + juce::Thread::sleep(100); + if (isAdvancedVideoClientAvailable()) + { + videoHelperRunning.store(true); + return true; + } + } + return false; + } + + const juce::File rootDir = resolveVideoHelperRootDir(); + const juce::File script = rootDir.getChildFile("server.js"); + if (!script.existsAsFile()) + return false; + + juce::StringArray nodeCandidates; + const juce::File exeDir = juce::File::getSpecialLocation(juce::File::currentExecutableFile).getParentDirectory(); +#ifdef _WIN32 + const juce::String nodeFilename = "node.exe"; +#else + const juce::String nodeFilename = "node"; +#endif + const juce::File rootNode = rootDir.getChildFile(nodeFilename); + const juce::File rootParentNode = rootDir.getParentDirectory().getChildFile(nodeFilename); + const juce::File exeNode = exeDir.getChildFile(nodeFilename); + if (rootNode.existsAsFile()) + nodeCandidates.add("\"" + rootNode.getFullPathName() + "\""); + if (rootParentNode.existsAsFile()) + nodeCandidates.add("\"" + rootParentNode.getFullPathName() + "\""); + if (exeNode.existsAsFile()) + nodeCandidates.add("\"" + exeNode.getFullPathName() + "\""); + nodeCandidates.add("node"); + + advancedVideoProcess = std::make_unique(); + bool started = false; + for (const auto& nodeCmd : nodeCandidates) + { + const juce::String cmd = nodeCmd + " \"" + script.getFullPathName() + "\""; + if (advancedVideoProcess->start(cmd)) + { + started = true; + break; + } + } + if (!started) + { + { + juce::ScopedLock lock(chatLock); + chatHistory.add("Video helper failed to start (node not found). Place node beside NINJAM executable or inside advanced-vdo-client."); + chatSenders.add(""); + if (chatHistory.size() > 100) + { + chatHistory.removeRange(0, chatHistory.size() - 100); + chatSenders.removeRange(0, juce::jmax(0, chatSenders.size() - 100)); + } + } + advancedVideoProcess.reset(); + return false; + } + + for (int i = 0; i < 40; ++i) + { + juce::Thread::sleep(100); + if (isAdvancedVideoClientAvailable()) + { + videoHelperRunning.store(true); + return true; + } + } + return false; +} + +void NinjamVst3AudioProcessor::stopAdvancedVideoClient() +{ + videoHelperRunning.store(false); + if (advancedVideoProcess && advancedVideoProcess->isRunning()) + advancedVideoProcess->kill(); + advancedVideoProcess.reset(); +} + + + +void NinjamVst3AudioProcessor::launchVideoSession() +{ + if (ninjamClient.GetStatus() != NJClient::NJC_STATUS_OK) + return; + + juce::String roomSource = currentServer.trim(); + const int schemePos = roomSource.indexOf("://"); + if (schemePos >= 0) + roomSource = roomSource.substring(schemePos + 3); + const int slashPos = roomSource.indexOfChar('/'); + if (slashPos >= 0) + roomSource = roomSource.substring(0, slashPos); + const int atPos = roomSource.lastIndexOfChar('@'); + if (atPos >= 0 && atPos + 1 < roomSource.length()) + roomSource = roomSource.substring(atPos + 1); + + juce::String hostPart = roomSource.trim(); + juce::String portPart; + const int lastColonPos = hostPart.lastIndexOfChar(':'); + if (lastColonPos > 0 && lastColonPos + 1 < hostPart.length()) + { + const juce::String candidatePort = hostPart.substring(lastColonPos + 1).trim(); + bool allDigits = candidatePort.isNotEmpty(); + for (int i = 0; i < candidatePort.length() && allDigits; ++i) + allDigits = juce::CharacterFunctions::isDigit(candidatePort[i]); + if (allDigits) + { + hostPart = hostPart.substring(0, lastColonPos); + portPart = candidatePort; + } + } + + const int firstDotPos = hostPart.indexOfChar('.'); + if (firstDotPos > 0) + hostPart = hostPart.substring(0, firstDotPos); + + juce::String roomRaw = hostPart; + if (portPart.isNotEmpty()) + roomRaw << "_" << portPart; + + juce::String room; + bool lastWasUnderscore = false; + for (int i = 0; i < roomRaw.length(); ++i) + { + const juce_wchar ch = roomRaw[i]; + if (juce::CharacterFunctions::isLetterOrDigit(ch)) + { + room << juce::String::charToString((juce_wchar) juce::CharacterFunctions::toLowerCase(ch)); + lastWasUnderscore = false; + } + else if (!lastWasUnderscore) + { + room << "_"; + lastWasUnderscore = true; + } + } + room = room.trimCharactersAtStart("_").trimCharactersAtEnd("_"); + if (room.isEmpty()) + room = "ninjam_room"; + const juce::String label = currentUser.isNotEmpty() ? currentUser : "NINJAM"; + int viewDelayMs = 0; + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + for (const auto& entry : remoteLatencyFirmDelayMsByUser) + viewDelayMs = juce::jmax(viewDelayMs, juce::jmax(0, entry.second)); + } + const int chunkMs = juce::jlimit(60, 800, viewDelayMs > 0 ? (int)std::llround((double)viewDelayMs * 0.25) : 120); + + if (ensureAdvancedVideoClientStarted()) + { + juce::URL helperUrl("http://127.0.0.1:8100/sync-buffer-room"); + helperUrl = helperUrl.withParameter("room", room) + .withParameter("label", label) + .withParameter("intervalSource", "ws://127.0.0.1:8100/ws") + .withParameter("chunked", juce::String(chunkMs)); + if (viewDelayMs > 0) + helperUrl = helperUrl.withParameter("buffer", juce::String(viewDelayMs)); + { + juce::ScopedLock lock(chatLock); + chatHistory.add("Tip: If your cam isn't showing, refresh the video page and select your camera before entering the room."); + chatSenders.add(""); + if (chatHistory.size() > 100) + { + chatHistory.removeRange(0, chatHistory.size() - 100); + chatSenders.removeRange(0, juce::jmax(0, chatSenders.size() - 100)); + } + } + helperUrl.launchInDefaultBrowser(); + return; + } + + juce::URL url("https://vdo.ninja/"); + url = url.withParameter("room", room) + .withParameter("label", label) + .withParameter("chunked", juce::String(chunkMs)) + .withParameter("chunkbufferadaptive", "0") + .withParameter("chunkbufferceil", "180000") + .withParameter("noaudio", "1") + .withParameter("buffer2", "0"); + if (viewDelayMs > 0) + url = url.withParameter("buffer", juce::String(viewDelayMs)); + { + juce::ScopedLock lock(chatLock); + chatHistory.add("Advanced sync helper unavailable on this machine; opening direct VDO view without live auto-buffer updates."); + chatSenders.add(""); + chatHistory.add("Tip: If your cam isn't showing, refresh the video page and select your camera before entering the room."); + chatSenders.add(""); + if (chatHistory.size() > 100) + { + chatHistory.removeRange(0, chatHistory.size() - 100); + chatSenders.removeRange(0, juce::jmax(0, chatSenders.size() - 100)); + } + } + url.launchInDefaultBrowser(); +} + +void NinjamVst3AudioProcessor::writeIntervalHelperJson(int pos, int length) +{ + if (!videoHelperRunning.load()) + return; + if (intervalJsonFile.getFullPathName().isEmpty()) + return; + + if (!intervalJsonFile.getParentDirectory().isDirectory()) + intervalJsonFile.getParentDirectory().createDirectory(); + + const int safeLength = juce::jmax(1, length); + const int displayInterval = getDisplayIntervalIndex(); + const int bpi = juce::jmax(1, getBPI()); + const double bpm = juce::jmax(1.0, (double)getBPM()); + const double nowMs = juce::Time::getMillisecondCounterHiRes(); + const double globalUnit = (double)displayInterval * (double)safeLength + (double)juce::jlimit(0, safeLength, pos); + const double beatLength = (double)safeLength / (double)bpi; + const double globalBeat = beatLength > 0.0 ? std::floor(globalUnit / beatLength) : 0.0; + const juce::String syncTag = buildIntervalSyncTag(displayInterval, safeLength); + + juce::Array entries; + { + juce::DynamicObject::Ptr infoObj = new juce::DynamicObject(); + infoObj->setProperty("type", "intervalInfo"); + infoObj->setProperty("interval", displayInterval); + infoObj->setProperty("pos", pos); + infoObj->setProperty("length", safeLength); + infoObj->setProperty("bpm", bpm); + infoObj->setProperty("bpi", bpi); + infoObj->setProperty("globalUnit", globalUnit); + infoObj->setProperty("globalBeat", globalBeat); + infoObj->setProperty("videoClockMs", nowMs); + infoObj->setProperty("syncTag", syncTag); + infoObj->setProperty("bufferMode", "remote"); + entries.add(juce::var(infoObj.get())); + } + + const int numUsers = ninjamClient.GetNumUsers(); + for (int userIdx = 0; userIdx < numUsers; ++userIdx) + { + const char* userNameChars = ninjamClient.GetUserState(userIdx, nullptr, nullptr, nullptr); + if (!userNameChars || !userNameChars[0]) + continue; + + const juce::String userName = juce::String::fromUTF8(userNameChars); + const juce::String senderKey = normaliseOpusPeerId(userName); + const juce::String canonicalUserKey = canonicalDelayUserKey(userName); + time_t lastUpdate = 0; + double maxLen = 0.0; + const double userPos = ninjamClient.GetUserSessionPos(userIdx, &lastUpdate, &maxLen); + + int bufferMs = -1; + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + auto firmIt = remoteLatencyFirmDelayMsByUser.find(senderKey); + if (firmIt != remoteLatencyFirmDelayMsByUser.end()) + bufferMs = juce::jmax(0, firmIt->second); + if (bufferMs < 0 && canonicalUserKey.isNotEmpty()) + { + auto canonicalFirmIt = remoteLatencyFirmDelayMsByUser.find(canonicalUserKey); + if (canonicalFirmIt != remoteLatencyFirmDelayMsByUser.end()) + bufferMs = juce::jmax(0, canonicalFirmIt->second); + } + if (bufferMs < 0) + { + auto avgIt = remoteLatencyAverageByUser.find(senderKey); + if (avgIt != remoteLatencyAverageByUser.end()) + { + const auto& state = avgIt->second; + double fallback = state.firmAverageMs; + if (!(fallback > 0.0)) + fallback = state.averageMs; + if (!(fallback > 0.0)) + fallback = state.lastMeasurementMs; + if (fallback > 0.0) + bufferMs = juce::jmax(0, (int)std::llround(fallback)); + } + } + if (bufferMs < 0 && canonicalUserKey.isNotEmpty()) + { + auto canonicalAvgIt = remoteLatencyAverageByUser.find(canonicalUserKey); + if (canonicalAvgIt != remoteLatencyAverageByUser.end()) + { + const auto& state = canonicalAvgIt->second; + double fallback = state.firmAverageMs; + if (!(fallback > 0.0)) + fallback = state.averageMs; + if (!(fallback > 0.0)) + fallback = state.lastMeasurementMs; + if (fallback > 0.0) + bufferMs = juce::jmax(0, (int)std::llround(fallback)); + } + } + // Diagnostic log per-user buffer decision + } + + juce::DynamicObject::Ptr userObj = new juce::DynamicObject(); + userObj->setProperty("type", "videoTimecode"); + userObj->setProperty("userId", userName); + userObj->setProperty("userKey", canonicalUserKey); + userObj->setProperty("interval", displayInterval); + userObj->setProperty("timecode", userPos); + userObj->setProperty("globalUnit", (double)displayInterval * (double)safeLength + userPos); + userObj->setProperty("globalBeat", globalBeat); + userObj->setProperty("videoClockMs", nowMs); + userObj->setProperty("syncTag", syncTag); + userObj->setProperty("bufferMode", "remote"); + if (bufferMs >= 0) + { + userObj->setProperty("bufferTotalMs", (double)bufferMs); + userObj->setProperty("senderBufferMs", 0.0); + userObj->setProperty("receiverBufferMs", (double)bufferMs); + userObj->setProperty("measuredAudioDelayMs", (double)bufferMs); + } + entries.add(juce::var(userObj.get())); + } + + const juce::String payload = juce::JSON::toString(juce::var(entries), false); + intervalJsonFile.replaceWithText(payload); + // Also broadcast the interval payload over the sync interval channel so + // other instances of our client can receive and write the same JSON + // locally (avoids chat leakage on non-aware clients). + sendIntervalSignal("intervals", payload); +} + +bool NinjamVst3AudioProcessor::isTransmittingLocal() const +{ + return isTransmitting; +} + +juce::StringArray NinjamVst3AudioProcessor::getChatMessages() +{ + juce::ScopedLock lock(chatLock); + return chatHistory; +} + +void NinjamVst3AudioProcessor::setAutoTranslateEnabled(bool shouldEnable) +{ + { + juce::ScopedLock lock(chatLock); + autoTranslate = shouldEnable; + } +} + +bool NinjamVst3AudioProcessor::isAutoTranslateEnabled() const +{ + return autoTranslate; +} + +void NinjamVst3AudioProcessor::setTranslateTargetLang(const juce::String& langCode) +{ + juce::ScopedLock lock(chatLock); + translateTargetLang = langCode; +} + +juce::String NinjamVst3AudioProcessor::getTranslateTargetLang() const +{ + return translateTargetLang; +} + +std::vector NinjamVst3AudioProcessor::getConnectedUsers() +{ + std::vector users; + int numUsers = ninjamClient.GetNumUsers(); + bool spread = spreadOutputsEnabled.load(); + + const int maxOutputPairs = 16; + std::set reservedPairs; + if (spread) + { + for (auto& kv : userOutputAssignment) + { + int pair = kv.second; + if (pair >= 0 && pair < maxOutputPairs) + reservedPairs.insert(pair); + } + } + + std::set usedPairsThisCall; + + for (int i=0; i 0) + u.name = fullName.substring(0, atPos); + else + u.name = fullName; + + bool sub = false; + float chVol = 1.0f, chPan = 0.0f; + bool chMute = false, chSolo = false; + int outCh = 0, flags = 0; + const char* chName = ninjamClient.GetUserChannelState(i, 0, &sub, &chVol, &chPan, &chMute, &chSolo, &outCh, &flags); + if (chName) + { + float baseVol = chVol; + bool hasStored = false; + auto byNameIt = userVolumeByName.find(u.name); + if (byNameIt != userVolumeByName.end()) + { + baseVol = byNameIt->second; + hasStored = true; + } + + auto volIt = userBaseVolume.find(i); + if (volIt != userBaseVolume.end()) + { + baseVol = volIt->second; + hasStored = true; + } + + if (!hasStored) + baseVol = 1.0f; + + u.volume = baseVol; + + auto panIt = userPanOverrides.find(i); + if (panIt != userPanOverrides.end()) + u.pan = panIt->second; + else + u.pan = chPan; + + u.isMuted = chMute; + u.outputChannel = outCh; + + if (!hasStored || std::abs(baseVol - chVol) > 1.0e-4f) + setUserVolume(i, baseVol); + } + else + { + float baseVol = 1.0f; + bool hasStored = false; + auto byNameIt = userVolumeByName.find(u.name); + if (byNameIt != userVolumeByName.end()) + { + baseVol = byNameIt->second; + hasStored = true; + } + + auto volIt = userBaseVolume.find(i); + if (volIt != userBaseVolume.end()) + { + baseVol = volIt->second; + hasStored = true; + } + + u.volume = baseVol; + + u.pan = 0.0f; + u.isMuted = false; + u.outputChannel = ninjamClient.GetUserChannelOutput(i, 0); + + if (!hasStored) + setUserVolume(i, baseVol); + } + + if (spread) + { + juce::String shortName = u.name; + auto itAssign = userOutputAssignment.find(shortName); + int desiredPair = -1; + + if (itAssign != userOutputAssignment.end()) + { + desiredPair = itAssign->second; + } + else + { + if ((int)reservedPairs.size() < maxOutputPairs) + { + for (int cand = 0; cand < maxOutputPairs; ++cand) + { + if (!reservedPairs.count(cand)) + { + desiredPair = cand; + reservedPairs.insert(cand); + break; + } + } + } + else + { + std::set connectedNow = usedPairsThisCall; + int fallback = -1; + for (int cand = 0; cand < maxOutputPairs; ++cand) + { + if (!connectedNow.count(cand)) + { + fallback = cand; + break; + } + } + if (fallback < 0) + fallback = 0; + desiredPair = fallback; + } + + userOutputAssignment[shortName] = desiredPair; + } + + if (desiredPair >= 0) + { + int desiredChannel = desiredPair * 2; + if (u.outputChannel != desiredChannel) + setUserOutput(i, desiredChannel); + u.outputChannel = desiredChannel; + usedPairsThisCall.insert(desiredPair); + } + } + + userBaseVolume[i] = u.volume; + userVolumeByName[u.name] = u.volume; + + // Look up multichannel state from the snapshot updated by refreshOpusSyncAvailabilityFromUsers(). + // This map is keyed by normalised username and never holds njclient locks. + { + const juce::String normName = canonicalDelayUserKey(u.name); + const juce::ScopedLock mcLock(peerMultiChanLock); + vlogStr("[MCLookup] normName='" + normName + "' mapSize=" + juce::String((int)peerMultiChanByName.size())); + for (auto& [mk, mv] : peerMultiChanByName) + vlogStr(" key='" + mk + "' isMultiChan=" + juce::String(mv.isMultiChan ? 1 : 0)); + auto it = peerMultiChanByName.find(normName); + if (it != peerMultiChanByName.end()) + { + u.isMultiChanPeer = it->second.isMultiChan; + if (u.isMultiChanPeer) + u.numChannels = juce::jmax(2, it->second.numChannels); + } + } + + // Populate channel names from NINJAM state (safe: no locks held here) + if (u.isMultiChanPeer) + { + u.channelNames.clear(); + for (int ch = 0; ch < u.numChannels; ++ch) + { + const char* chName = ninjamClient.GetUserChannelState(i, ch + 1); // ch0=mix, ch1..N=individual + if (chName != nullptr && *chName != '\0') + u.channelNames.add(juce::String::fromUTF8(chName)); + else + u.channelNames.add("Ch " + juce::String(ch + 1)); + } + } + else + { + // Count basic NINJAM channel names for non-VST3 peers (display only, no expand button) + u.channelNames.clear(); + for (int ch = 0; ch < 32; ++ch) + { + const char* chName = ninjamClient.GetUserChannelState(i, ch); + if (chName != nullptr) + { + ++u.numChannels; + u.channelNames.add(juce::String::fromUTF8(chName)); + } + } + if (u.numChannels < 1) { u.numChannels = 1; u.channelNames.add(""); } + } + + users.push_back(u); + } + } + // Log final result for each user + for (const auto& u : users) + vlogStr("[GCU] user='" + u.name + "' isMultiChanPeer=" + juce::String(u.isMultiChanPeer ? 1 : 0) + " nCh=" + juce::String(u.numChannels)); + return users; +} + +void NinjamVst3AudioProcessor::rememberUserVolume(int userIndex, float volume, const juce::String& name) +{ + userBaseVolume[userIndex] = volume; + juce::String shortName = name; + int atPos = shortName.indexOfChar('@'); + if (atPos > 0) + shortName = shortName.substring(0, atPos); + userVolumeByName[shortName] = volume; +} + +void NinjamVst3AudioProcessor::setUserOutput(int userIndex, int outputChannelIndex) +{ + // Update all channels for this user to the new output + // Iterate through all potential channels (MAX_USER_CHANNELS is 32) + for (int i = 0; i < 32; ++i) + { + // SetUserChannelState arguments: useridx, channelidx, setsub, sub, setvol, vol, setpan, pan, setmute, mute, setsolo, solo, setoutch, outchannel + ninjamClient.SetUserChannelState(userIndex, i, false, false, false, 0, false, 0, false, false, false, false, true, outputChannelIndex); + } + + const char* name = ninjamClient.GetUserState(userIndex, nullptr, nullptr, nullptr); + if (name) + { + juce::String fullName = juce::String::fromUTF8(name); + int atPos = fullName.indexOfChar('@'); + juce::String shortName; + if (atPos > 0) + shortName = fullName.substring(0, atPos); + else + shortName = fullName; + int pairIndex = (outputChannelIndex & 1023) / 2; + userOutputAssignment[shortName] = pairIndex; + } +} + +void NinjamVst3AudioProcessor::setUserLevel(int userIndex, float volume, float pan, bool isMuted, bool isSolo) +{ + userBaseVolume[userIndex] = volume; + int numUsers = ninjamClient.GetNumUsers(); + if (userIndex >= 0 && userIndex < numUsers) + { + const char* name = ninjamClient.GetUserState(userIndex, nullptr, nullptr, nullptr); + if (name) + { + juce::String fullName = juce::String::fromUTF8(name); + int atPos = fullName.indexOfChar('@'); + juce::String shortName; + if (atPos > 0) + shortName = fullName.substring(0, atPos); + else + shortName = fullName; + userVolumeByName[shortName] = volume; + } + } + userPanOverrides[userIndex] = pan; + for (int i = 0; i < 32; ++i) + { + ninjamClient.SetUserChannelState(userIndex, i, false, false, true, volume, true, pan, true, isMuted, true, isSolo); + } +} + +void NinjamVst3AudioProcessor::setUserVolume(int userIndex, float volume) +{ + for (int i = 0; i < 32; ++i) + { + ninjamClient.SetUserChannelState(userIndex, i, false, false, true, volume, false, 0, false, false, false, false, false, 0); + } +} + +float NinjamVst3AudioProcessor::getUserPeak(int userIndex, int channelIndex) +{ + if (isSyncToHostEnabled() && (!hostWasPlaying.load() || syncWaitForInterval.load())) + return 0.0f; + + float maxPeak = 0.0f; + for (int i = 0; i < 32; ++i) + { + float p = ninjamClient.GetUserChannelPeak(userIndex, i, channelIndex); + if (p > maxPeak) maxPeak = p; + } + return maxPeak; +} + +float NinjamVst3AudioProcessor::getUserChannelPeak(int userIndex, int njChanIdx, int lrSide) +{ + return ninjamClient.GetUserChannelPeak(userIndex, njChanIdx, lrSide); +} + +void NinjamVst3AudioProcessor::setUserNjChannelVolume(int userIndex, int njChanIdx, float volume) +{ + ninjamClient.SetUserChannelState(userIndex, njChanIdx, false, false, true, volume, false, 0, false, false, false, false); +} + +void NinjamVst3AudioProcessor::setMasterOutputGain(float gain) +{ + masterOutputGain.store(gain); +} + +float NinjamVst3AudioProcessor::getMasterOutputGain() const +{ + return masterOutputGain.load(); +} + +float NinjamVst3AudioProcessor::getMasterPeak() const +{ + return masterPeak.load(); +} + +float NinjamVst3AudioProcessor::getMasterPeakLeft() const +{ + return masterPeakL.load(); +} + +float NinjamVst3AudioProcessor::getMasterPeakRight() const +{ + return masterPeakR.load(); +} + +void NinjamVst3AudioProcessor::setSoftLimiterEnabled(bool shouldEnable) +{ + softLimiterEnabled.store(shouldEnable); +} + +bool NinjamVst3AudioProcessor::isSoftLimiterEnabled() const +{ + return softLimiterEnabled.load(); +} + +void NinjamVst3AudioProcessor::setUserClipEnabled(int userIndex, bool enabled) +{ + userClipEnabled[userIndex] = enabled; +} + +bool NinjamVst3AudioProcessor::isUserClipEnabled(int userIndex) const +{ + return true; +} + +void NinjamVst3AudioProcessor::setMasterLimiterEnabled(bool shouldEnable) +{ + dspLimiterEnabled.store(shouldEnable); +} + +bool NinjamVst3AudioProcessor::isMasterLimiterEnabled() const +{ + return dspLimiterEnabled.load(); +} + +void NinjamVst3AudioProcessor::setLimiterThreshold(float db) +{ + limiterThresholdDb.store(db); + masterLimiter.setThreshold(db); +} + +void NinjamVst3AudioProcessor::setLimiterRelease(float ms) +{ + limiterReleaseMs.store(ms); + masterLimiter.setRelease(ms); +} + +void NinjamVst3AudioProcessor::setLocalInputGain(float gain) +{ + localInputGain.store(gain); + setLocalChannelGain(0, gain); +} + +float NinjamVst3AudioProcessor::getLocalInputGain() const +{ + return localChannelGains[0].load(); +} + +void NinjamVst3AudioProcessor::setNumLocalChannels(int num) +{ + const int previous = numLocalChannels.load(); + int clamped = juce::jlimit(1, maxLocalChannels, num); + + { + juce::ScopedLock lock(localChannelNamesLock); + for (int i = 0; i < maxLocalChannels; ++i) + { + auto& name = localChannelNames[(size_t)i]; + if (name.isEmpty() || isDefaultLocalChannelName(name)) + name = buildDefaultLocalChannelName(i); + } + } + + numLocalChannels.store(clamped); + syncLocalIntervalChannelConfig(); + applyCodecPreference(); + + // Post a local status message when transitioning into or out of multichannel + if (previous != clamped) + { + juce::String msg; + if (clamped > 1 && previous <= 1) + msg = "MultiChannel mode enabled (" + juce::String(clamped) + " channels). Waiting for peer detection."; + else if (clamped > 1) + msg = "Local channels: " + juce::String(clamped) + "."; + else + msg = "MultiChannel mode disabled (single channel)."; + juce::ScopedLock lock(chatLock); + chatHistory.add(msg); + chatSenders.add(""); + if (chatHistory.size() > 100) + { + chatHistory.removeRange(0, chatHistory.size() - 100); + chatSenders.removeRange(0, juce::jmax(0, chatSenders.size() - 100)); + } + } + + // Immediately tell peers about the change so they update their expand buttons + if (ninjamClient.GetStatus() == NJClient::NJC_STATUS_OK) + broadcastOpusSyncSupport(); +} + +int NinjamVst3AudioProcessor::getNumLocalChannels() const +{ + return numLocalChannels.load(); +} + +void NinjamVst3AudioProcessor::setLocalChannelName(int channel, const juce::String& name) +{ + if (channel < 0 || channel >= maxLocalChannels) return; + { juce::ScopedLock lock(localChannelNamesLock); localChannelNames[(size_t)channel] = name; } + syncLocalIntervalChannelConfig(); +} + +juce::String NinjamVst3AudioProcessor::getLocalChannelName(int channel) const +{ + if (channel < 0 || channel >= maxLocalChannels) return {}; + juce::ScopedLock lock(localChannelNamesLock); + return localChannelNames[(size_t)channel]; +} + +void NinjamVst3AudioProcessor::setLocalChannelGain(int channel, float gain) +{ + if (channel < 0 || channel >= maxLocalChannels) + return; + localChannelGains[(size_t)channel].store(gain); +} + +float NinjamVst3AudioProcessor::getLocalChannelGain(int channel) const +{ + if (channel < 0 || channel >= maxLocalChannels) + return 1.0f; + return localChannelGains[(size_t)channel].load(); +} + +void NinjamVst3AudioProcessor::setLocalChannelInput(int channel, int inputIndex) +{ + if (channel < 0 || channel >= maxLocalChannels) + return; + localChannelInputs[(size_t)channel].store(inputIndex); +} + +int NinjamVst3AudioProcessor::getLocalChannelInput(int channel) const +{ + if (channel < 0 || channel >= maxLocalChannels) + return 0; + return localChannelInputs[(size_t)channel].load(); +} + +float NinjamVst3AudioProcessor::getLocalChannelPeak(int channel) const +{ + if (channel < 0 || channel >= maxLocalChannels) + return 0.0f; + return localChannelPeaks[(size_t)channel].load(); +} + +float NinjamVst3AudioProcessor::getLocalChannelPeakLeft(int channel) const +{ + if (channel < 0 || channel >= maxLocalChannels) + return 0.0f; + return localChannelPeaksL[(size_t)channel].load(); +} + +float NinjamVst3AudioProcessor::getLocalChannelPeakRight(int channel) const +{ + if (channel < 0 || channel >= maxLocalChannels) + return 0.0f; + return localChannelPeaksR[(size_t)channel].load(); +} + +void NinjamVst3AudioProcessor::setLocalMonitorEnabled(bool enabled) +{ + localMonitorEnabled.store(enabled); +} + +bool NinjamVst3AudioProcessor::isLocalMonitorEnabled() const +{ + return localMonitorEnabled.load(); +} + +void NinjamVst3AudioProcessor::setFxReverbEnabled(bool enabled) +{ + fxReverbEnabled.store(enabled); +} + +bool NinjamVst3AudioProcessor::isFxReverbEnabled() const +{ + return fxReverbEnabled.load(); +} + +void NinjamVst3AudioProcessor::setFxDelayEnabled(bool enabled) +{ + fxDelayEnabled.store(enabled); +} + +bool NinjamVst3AudioProcessor::isFxDelayEnabled() const +{ + return fxDelayEnabled.load(); +} + +void NinjamVst3AudioProcessor::setFxReverbRoomSize(float roomSize) +{ + fxReverbRoomSize.store(juce::jlimit(0.0f, 1.0f, roomSize)); +} + +float NinjamVst3AudioProcessor::getFxReverbRoomSize() const +{ + return fxReverbRoomSize.load(); +} + +void NinjamVst3AudioProcessor::setFxReverbDamping(float damping) +{ + fxReverbDamping.store(juce::jlimit(0.0f, 1.0f, damping)); +} + +float NinjamVst3AudioProcessor::getFxReverbDamping() const +{ + return fxReverbDamping.load(); +} + +void NinjamVst3AudioProcessor::setFxReverbWetDryMix(float wetDryMix) +{ + fxReverbWetDryMix.store(juce::jlimit(0.0f, 1.0f, wetDryMix)); +} + +float NinjamVst3AudioProcessor::getFxReverbWetDryMix() const +{ + return fxReverbWetDryMix.load(); +} + +void NinjamVst3AudioProcessor::setFxReverbEarlyReflections(float earlyReflections) +{ + fxReverbEarlyReflections.store(juce::jlimit(0.0f, 1.0f, earlyReflections)); +} + +float NinjamVst3AudioProcessor::getFxReverbEarlyReflections() const +{ + return fxReverbEarlyReflections.load(); +} + +void NinjamVst3AudioProcessor::setFxReverbTail(float tail) +{ + fxReverbTail.store(juce::jlimit(0.0f, 1.0f, tail)); +} + +float NinjamVst3AudioProcessor::getFxReverbTail() const +{ + return fxReverbTail.load(); +} + +void NinjamVst3AudioProcessor::setFxDelayTimeMs(float timeMs) +{ + fxDelayTimeMs.store(juce::jlimit(20.0f, 2000.0f, timeMs)); +} + +float NinjamVst3AudioProcessor::getFxDelayTimeMs() const +{ + return fxDelayTimeMs.load(); +} + +void NinjamVst3AudioProcessor::setFxDelaySyncToHost(bool enabled) +{ + fxDelaySyncToHost.store(enabled); +} + +bool NinjamVst3AudioProcessor::isFxDelaySyncToHost() const +{ + return fxDelaySyncToHost.load(); +} + +void NinjamVst3AudioProcessor::setFxDelayDivision(int division) +{ + if (division != 1 && division != 8 && division != 16) + division = 8; + fxDelayDivision.store(division); +} + +int NinjamVst3AudioProcessor::getFxDelayDivision() const +{ + return fxDelayDivision.load(); +} + +void NinjamVst3AudioProcessor::setFxDelayPingPong(bool enabled) +{ + fxDelayPingPong.store(enabled); +} + +bool NinjamVst3AudioProcessor::isFxDelayPingPong() const +{ + return fxDelayPingPong.load(); +} + +void NinjamVst3AudioProcessor::setFxDelayWetDryMix(float wetDryMix) +{ + fxDelayWetDryMix.store(juce::jlimit(0.0f, 1.0f, wetDryMix)); +} + +float NinjamVst3AudioProcessor::getFxDelayWetDryMix() const +{ + return fxDelayWetDryMix.load(); +} + +void NinjamVst3AudioProcessor::setFxDelayFeedback(float feedback) +{ + fxDelayFeedback.store(juce::jlimit(0.0f, 0.95f, feedback)); +} + +float NinjamVst3AudioProcessor::getFxDelayFeedback() const +{ + return fxDelayFeedback.load(); +} + +void NinjamVst3AudioProcessor::setLocalChannelReverbSend(int channel, float send) +{ + if (channel < 0 || channel >= maxLocalChannels) + return; + localChannelReverbSends[(size_t)channel].store(juce::jlimit(0.0f, 1.0f, send)); +} + +float NinjamVst3AudioProcessor::getLocalChannelReverbSend(int channel) const +{ + if (channel < 0 || channel >= maxLocalChannels) + return 0.0f; + return localChannelReverbSends[(size_t)channel].load(); +} + +void NinjamVst3AudioProcessor::setLocalChannelDelaySend(int channel, float send) +{ + if (channel < 0 || channel >= maxLocalChannels) + return; + localChannelDelaySends[(size_t)channel].store(juce::jlimit(0.0f, 1.0f, send)); +} + +float NinjamVst3AudioProcessor::getLocalChannelDelaySend(int channel) const +{ + if (channel < 0 || channel >= maxLocalChannels) + return 0.0f; + return localChannelDelaySends[(size_t)channel].load(); +} + +int NinjamVst3AudioProcessor::getBPI() +{ + return ninjamClient.GetBPI(); +} + +float NinjamVst3AudioProcessor::getIntervalProgress() +{ + if (isSyncToHostEnabled() && (!hostWasPlaying.load() || syncWaitForInterval.load())) + return 0.0f; + + int pos = 0; + int length = 0; + ninjamClient.GetPosition(&pos, &length); + if (length > 0) + { + if (isSyncToHostEnabled() && hostWasPlaying.load()) + { + int basePos = syncDisplayPositionOffset.load(); + int relativePos = pos - basePos; + if (relativePos < 0) + relativePos += length; + return (float)relativePos / (float)length; + } + return (float)pos / (float)length; + } + return 0.0f; +} + +float NinjamVst3AudioProcessor::getBPM() +{ + return ninjamClient.GetActualBPM(); +} + +int NinjamVst3AudioProcessor::getIntervalIndex() const +{ + return getDisplayIntervalIndex(); +} + +float NinjamVst3AudioProcessor::getLocalPeak() const +{ + return localPeak.load(); +} + +float NinjamVst3AudioProcessor::getLocalPeakLeft() const +{ + return localPeakL.load(); +} + +float NinjamVst3AudioProcessor::getLocalPeakRight() const +{ + return localPeakR.load(); +} + +void NinjamVst3AudioProcessor::sendSideSignal(const juce::String& target, const juce::String& type, const juce::String& payload) +{ + const char* tgt = target.isNotEmpty() ? target.toRawUTF8() : "*"; + ninjamClient.ChatMessage_Send("SIDE_SIGNAL", tgt, type.toRawUTF8(), payload.toRawUTF8()); +} + +void NinjamVst3AudioProcessor::sendIntervalSignal(const juce::String& type, const juce::String& payload) +{ + if (ninjamClient.GetStatus() != NJClient::NJC_STATUS_OK) return; + // Wrap in {"sig":type, "data":payload} so the receiver knows the type + juce::DynamicObject::Ptr wrapper = new juce::DynamicObject(); + wrapper->setProperty("sig", type); + wrapper->setProperty("data", payload); + const juce::String msg = juce::JSON::toString(juce::var(wrapper.get())); + ninjamClient.SendRawIntervalItem(0, kSyncSignalFourcc, msg.toRawUTF8(), (int)msg.getNumBytesAsUTF8()); +} + +void NinjamVst3AudioProcessor::setSpreadOutputsEnabled(bool shouldEnable) +{ + bool wasEnabled = spreadOutputsEnabled.load(); + spreadOutputsEnabled.store(shouldEnable); + + if (wasEnabled && !shouldEnable) + { + userOutputAssignment.clear(); + + int numUsers = ninjamClient.GetNumUsers(); + for (int userIdx = 0; userIdx < numUsers; ++userIdx) + { + for (int ch = 0; ch < 32; ++ch) + { + ninjamClient.SetUserChannelState(userIdx, ch, + false, false, + false, 0.0f, + false, 0.0f, + false, false, + false, false, + true, 0); + } + } + } +} + +bool NinjamVst3AudioProcessor::isSpreadOutputsEnabled() const +{ + return spreadOutputsEnabled.load(); +} + +int NinjamVst3AudioProcessor::getCodecMode() const +{ + const bool multiChanAuto = numLocalChannels.load() > 1 && opusSyncAvailable.load(); + if (!multiChanAuto) + return 0; + // multiChanAuto: always mixed mode (Vorbis ch0 + Opus ch1..N) + return 1; +} + +unsigned int NinjamVst3AudioProcessor::getVorbisMask() const +{ + return ninjamClient.GetCodecVorbisMask(); +} + +unsigned int NinjamVst3AudioProcessor::getOpusMask() const +{ + return ninjamClient.GetCodecOpusMask(); +} + +juce::String NinjamVst3AudioProcessor::translateText(const juce::String& text) +{ + if (!autoTranslate) + return text; + +#if defined(_WIN32) + const wchar_t* host = L"api.mymemory.translated.net"; + + HINTERNET hSession = WinHttpOpen(L"NINJAMVST3/1.0", + WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, + 0); + if (!hSession) + return text; + + HINTERNET hConnect = WinHttpConnect(hSession, host, INTERNET_DEFAULT_HTTPS_PORT, 0); + if (!hConnect) + { + WinHttpCloseHandle(hSession); + return text; + } + + juce::String target = translateTargetLang.isNotEmpty() ? translateTargetLang : "en"; + + const char* srcUtf8 = text.toRawUTF8(); + juce::String targetCode = target.toLowerCase(); + const char* tgtUtf8 = targetCode.toRawUTF8(); + + auto urlEncode = [](const char* s) -> std::string + { + std::string out; + const unsigned char* p = (const unsigned char*)s; + while (*p) + { + unsigned char c = *p++; + if ((c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_' || c == '.' || c == '~') + { + out.push_back((char)c); + } + else if (c == ' ') + { + out.push_back('+'); + } + else + { + char buf[4]; + std::snprintf(buf, sizeof(buf), "%%%02X", c); + out.append(buf); + } + } + return out; + }; + + std::string qParam = urlEncode(srcUtf8); + std::string langpair = "auto|"; + langpair += tgtUtf8; + std::string langpairParam = urlEncode(langpair.c_str()); + + std::string pathStr = "/get?q="; + pathStr += qParam; + pathStr += "&langpair="; + pathStr += langpairParam; + + std::wstring wpath(pathStr.begin(), pathStr.end()); + + HINTERNET hRequest = WinHttpOpenRequest(hConnect, + L"GET", + wpath.c_str(), + NULL, + WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, + WINHTTP_FLAG_SECURE); + if (!hRequest) + { + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return text; + } + + BOOL ok = WinHttpSendRequest(hRequest, + WINHTTP_NO_ADDITIONAL_HEADERS, + 0, + WINHTTP_NO_REQUEST_DATA, + 0, + 0, + 0); + if (!ok) + { + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return text; + } + + ok = WinHttpReceiveResponse(hRequest, NULL); + if (!ok) + { + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return text; + } + + std::string response; + DWORD dwSize = 0; + do + { + if (!WinHttpQueryDataAvailable(hRequest, &dwSize) || dwSize == 0) + break; + + std::string chunk; + chunk.resize(dwSize); + DWORD dwDownloaded = 0; + if (!WinHttpReadData(hRequest, &chunk[0], dwSize, &dwDownloaded) || dwDownloaded == 0) + break; + + response.append(chunk.data(), dwDownloaded); + } + while (dwSize > 0); + + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + + if (response.empty()) + return text; + + std::string translated; + std::size_t keyPos = response.find("\"translatedText\""); + if (keyPos != std::string::npos) + { + std::size_t colonPos = response.find(':', keyPos); + if (colonPos != std::string::npos) + { + std::size_t firstQuote = response.find('\"', colonPos); + if (firstQuote != std::string::npos) + { + std::size_t endQuote = firstQuote + 1; + while (endQuote < response.size()) + { + char c = response[endQuote]; + if (c == '\\') + { + if (endQuote + 1 < response.size()) + { + char next = response[endQuote + 1]; + if (next == '\\' || next == '\"') + { + translated.push_back(next); + endQuote += 2; + continue; + } + } + } + if (c == '\"') + break; + + translated.push_back(c); + ++endQuote; + } + } + } + } + + if (translated.empty()) + return text; + + return juce::String::fromUTF8(translated.c_str(), (int)translated.size()); +#else + return text; +#endif +} + +std::vector NinjamVst3AudioProcessor::getPublicServers() const +{ + std::vector copy; + const juce::ScopedLock lock(serverListLock); + copy = publicServers; + return copy; +} + +void NinjamVst3AudioProcessor::refreshPublicServers() +{ + std::vector result; + +#if defined(_WIN32) + const wchar_t* host = L"ninbot.com"; + const wchar_t* path = L"/app/servers.php"; + + HINTERNET hSession = WinHttpOpen(L"NINJAMVST3/1.0", + WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, + 0); + if (!hSession) + return; + + HINTERNET hConnect = WinHttpConnect(hSession, host, INTERNET_DEFAULT_HTTP_PORT, 0); + if (!hConnect) + { + WinHttpCloseHandle(hSession); + return; + } + + HINTERNET hRequest = WinHttpOpenRequest(hConnect, + L"GET", + path, + NULL, + WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, + 0); + if (!hRequest) + { + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return; + } + + BOOL ok = WinHttpSendRequest(hRequest, + WINHTTP_NO_ADDITIONAL_HEADERS, + 0, + WINHTTP_NO_REQUEST_DATA, + 0, + 0, + 0); + if (!ok) + { + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return; + } + + ok = WinHttpReceiveResponse(hRequest, NULL); + if (!ok) + { + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return; + } + + std::string response; + DWORD dwSize = 0; + do + { + if (!WinHttpQueryDataAvailable(hRequest, &dwSize) || dwSize == 0) + break; + + std::string chunk; + chunk.resize(dwSize); + DWORD dwDownloaded = 0; + if (!WinHttpReadData(hRequest, &chunk[0], dwSize, &dwDownloaded) || dwDownloaded == 0) + break; + + response.append(chunk.data(), dwDownloaded); + } + while (dwSize > 0); + + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + + if (response.empty()) + return; + + juce::String jsonText = juce::String::fromUTF8(response.c_str(), (int)response.size()); + + juce::var root; + juce::Result parseError = juce::JSON::parse(jsonText, root); + if (parseError.failed() || !root.isObject()) + return; + + auto* rootObj = root.getDynamicObject(); + if (!rootObj) + return; + + juce::var serversVar = rootObj->getProperty("servers"); + if (!serversVar.isArray()) + return; + + auto* serversArray = serversVar.getArray(); + if (!serversArray) + return; + + for (auto& serverVar : *serversArray) + { + if (!serverVar.isObject()) + continue; + auto* obj = serverVar.getDynamicObject(); + if (!obj) + continue; + + PublicServerInfo info; + juce::String nameText = obj->getProperty("name").toString(); + info.name = nameText; + + int colon = nameText.lastIndexOfChar(':'); + if (colon > 0) + { + info.host = nameText.substring(0, colon); + info.port = nameText.substring(colon + 1).getIntValue(); + } + else + { + info.host = nameText; + info.port = 2049; + } + + info.bpi = obj->getProperty("bpi").toString().getIntValue(); + info.bpm = (float)obj->getProperty("bpm").toString().getDoubleValue(); + + juce::var usersVar = obj->getProperty("users"); + if (usersVar.isArray() && usersVar.getArray() != nullptr) + info.userCount = usersVar.getArray()->size(); + else + info.userCount = obj->getProperty("user_count").toString().getIntValue(); + + info.userMax = obj->getProperty("user_max").toString().getIntValue(); + result.push_back(info); + } +#endif + + const juce::ScopedLock lock(serverListLock); + publicServers.swap(result); +} + +NinjamVst3AudioProcessor::~NinjamVst3AudioProcessor() +{ + stopTimer(); + stopAdvancedVideoClient(); + ninjamClient.Disconnect(); + JNL::close_socketlib(); +} + +const juce::String NinjamVst3AudioProcessor::getName() const +{ + return "NINJAM VST3"; +} + +bool NinjamVst3AudioProcessor::acceptsMidi() const +{ + return true; +} + +bool NinjamVst3AudioProcessor::producesMidi() const +{ + return true; +} + +bool NinjamVst3AudioProcessor::isMidiEffect() const +{ + return false; +} + +double NinjamVst3AudioProcessor::getTailLengthSeconds() const +{ + return 0.0; +} + +int NinjamVst3AudioProcessor::getNumPrograms() +{ + return 1; +} + +int NinjamVst3AudioProcessor::getCurrentProgram() +{ + return 0; +} + +void NinjamVst3AudioProcessor::setCurrentProgram (int index) +{ +} + +const juce::String NinjamVst3AudioProcessor::getProgramName (int index) +{ + return {}; +} + +void NinjamVst3AudioProcessor::changeProgramName (int index, const juce::String& newName) +{ +} + +void NinjamVst3AudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) +{ + intervalSyncSampleCounter.store(0, std::memory_order_relaxed); + processingSampleRate = sampleRate > 1.0 ? sampleRate : 44100.0; + juce::dsp::ProcessSpec spec; + spec.sampleRate = sampleRate; + spec.maximumBlockSize = (juce::uint32) samplesPerBlock; + spec.numChannels = (juce::uint32) getTotalNumOutputChannels(); + masterLimiter.prepare(spec); + masterLimiter.setThreshold(limiterThresholdDb.load()); + masterLimiter.setRelease(limiterReleaseMs.load()); + masterLimiter.reset(); + + fxReverb.reset(); + juce::Reverb::Parameters params; + params.roomSize = fxReverbRoomSize.load(); + params.damping = 0.45f; + params.width = 1.0f; + params.wetLevel = 0.35f; + params.dryLevel = 0.0f; + params.freezeMode = 0.0f; + fxReverb.setParameters(params); + + const int maxDelaySamples = juce::jmax(1, (int)std::ceil(processingSampleRate * 2.5)); + fxDelayBuffer.setSize(2, maxDelaySamples, false, true, true); + fxDelayBuffer.clear(); + fxDelayWritePosition = 0; + + fxReverbInputBuffer.setSize(1, juce::jmax(1, samplesPerBlock), false, true, true); + fxDelayInputBuffer.setSize(1, juce::jmax(1, samplesPerBlock), false, true, true); + fxReturnBuffer.setSize(2, juce::jmax(1, samplesPerBlock), false, true, true); +} + +void NinjamVst3AudioProcessor::releaseResources() +{ +} + +bool NinjamVst3AudioProcessor::isBusesLayoutSupported (const BusesLayout& layouts) const +{ + if (layouts.getMainOutputChannelSet() != juce::AudioChannelSet::stereo()) + return false; + + auto mainIn = layouts.getMainInputChannelSet(); + if (!mainIn.isDisabled() + && mainIn != juce::AudioChannelSet::stereo() + && mainIn != juce::AudioChannelSet::mono()) + return false; + + for (int i = 1; i < layouts.inputBuses.size(); ++i) + { + if (!layouts.inputBuses[i].isDisabled() && layouts.inputBuses[i] != juce::AudioChannelSet::stereo()) + return false; + } + + for (int i = 1; i < layouts.outputBuses.size(); ++i) + { + if (!layouts.outputBuses[i].isDisabled() && layouts.outputBuses[i] != juce::AudioChannelSet::stereo()) + return false; + } + + return true; +} + +int NinjamVst3AudioProcessor::LicenseAgreementCallback(void* userData, const char* licensetext) +{ + // Auto-accept license for now (or log it) + // Ideally, show a dialog to the user + // Since this is called from Run(), which we call from timerCallback (UI thread), + // we can show a message box. + // However, for automation/testing, we might want to auto-accept. + + // Simple auto-accept for this proof of concept: + juce::Logger::writeToLog("License Agreement Requested: " + juce::String(licensetext)); + return 1; +} + +void NinjamVst3AudioProcessor::processSyncSignal(const juce::String& sender, const juce::String& type, const juce::String& payload) +{ + if (type == "intervalLatencyReport") + return; + if (type == "intervalTransportProbe") + { + juce::String payloadUserId; + juce::String probeId; + const juce::var parsed = juce::JSON::parse(payload); + if (auto* obj = parsed.getDynamicObject()) + { + if (obj->hasProperty("userId")) + payloadUserId = obj->getProperty("userId").toString(); + if (obj->hasProperty("probeId")) + probeId = obj->getProperty("probeId").toString(); + } + const juce::String senderKey = normaliseOpusPeerId(payloadUserId.isNotEmpty() ? payloadUserId : sender); + const juce::String localUserKey = normaliseOpusPeerId(currentUser); + if (probeId.isEmpty() || sender.isEmpty() || senderKey.isEmpty() || senderKey == localUserKey) + return; + + juce::DynamicObject::Ptr ackObj = new juce::DynamicObject(); + ackObj->setProperty("type", "intervalTransportProbeAck"); + ackObj->setProperty("userId", localUserKey.isNotEmpty() ? localUserKey : currentUser); + ackObj->setProperty("probeId", probeId); + ackObj->setProperty("eventId", "transportProbeAck:" + probeId); + const juce::String ackPayload = juce::JSON::toString(juce::var(ackObj.get())); + sendIntervalSignal("intervalTransportProbeAck", ackPayload); + return; + } + if (type == "intervalTransportProbeAck") + { + juce::String payloadUserId; + juce::String probeId; + const juce::var parsed = juce::JSON::parse(payload); + if (auto* obj = parsed.getDynamicObject()) + { + if (obj->hasProperty("userId")) + payloadUserId = obj->getProperty("userId").toString(); + if (obj->hasProperty("probeId")) + probeId = obj->getProperty("probeId").toString(); + } + if (probeId.isEmpty()) + return; + const double nowMs = juce::Time::getMillisecondCounterHiRes(); + const juce::String senderKey = normaliseOpusPeerId(payloadUserId.isNotEmpty() ? payloadUserId : sender); + if (senderKey.isEmpty()) + return; + const juce::String canonicalSenderKey = canonicalDelayUserKey(senderKey); + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + auto sentIt = pendingTransportProbeSentMsById.find(probeId); + if (sentIt == pendingTransportProbeSentMsById.end()) + return; + const double rttMs = nowMs - sentIt->second; + pendingTransportProbeSentMsById.erase(sentIt); + if (rttMs <= 0.0 || rttMs > 3000.0) + return; + const auto updateRtt = [&](const juce::String& key) + { + if (key.isEmpty()) + return; + auto it = remoteTransportRttMsByUser.find(key); + if (it == remoteTransportRttMsByUser.end()) + remoteTransportRttMsByUser[key] = rttMs; + else + it->second = (it->second * 0.85) + (rttMs * 0.15); + }; + updateRtt(senderKey); + updateRtt(canonicalSenderKey); + return; + } + if (type == "midiRelay") + { + juce::String payloadUserId; + MidiControllerEvent event; + const juce::var parsed = juce::JSON::parse(payload); + if (auto* obj = parsed.getDynamicObject()) + { + if (obj->hasProperty("userId")) payloadUserId = obj->getProperty("userId").toString(); + if (obj->hasProperty("isController")) event.isController = (bool)obj->getProperty("isController"); + if (obj->hasProperty("midiChannel")) event.midiChannel = (int)obj->getProperty("midiChannel"); + if (obj->hasProperty("number")) event.number = (int)obj->getProperty("number"); + if (obj->hasProperty("value")) event.value = (int)obj->getProperty("value"); + if (obj->hasProperty("normalized")) event.normalized = (float)(double)obj->getProperty("normalized"); + if (obj->hasProperty("isNoteOn")) event.isNoteOn = (bool)obj->getProperty("isNoteOn"); + } + + const juce::String senderKey = normaliseOpusPeerId(payloadUserId.isNotEmpty() ? payloadUserId : sender); + const juce::String localUserKey = normaliseOpusPeerId(currentUser); + if (senderKey.isEmpty() || senderKey == localUserKey) + return; + + event.midiChannel = juce::jlimit(1, 16, event.midiChannel); + event.number = juce::jlimit(0, 127, event.number); + event.value = juce::jlimit(0, 127, event.value); + event.normalized = juce::jlimit(0.0f, 1.0f, event.normalized); + + const juce::SpinLock::ScopedLockType lock(inboundMidiRelayQueueLock); + pendingInboundMidiRelayEvents.push_back(event); + if (pendingInboundMidiRelayEvents.size() > 512) + pendingInboundMidiRelayEvents.erase(pendingInboundMidiRelayEvents.begin(), pendingInboundMidiRelayEvents.begin() + (long long)(pendingInboundMidiRelayEvents.size() - 512)); + return; + } + if (type == "localInputSelect") + { + const juce::var parsed = juce::JSON::parse(payload); + if (auto* obj = parsed.getDynamicObject()) + { + const int channel = obj->hasProperty("channel") ? (int)obj->getProperty("channel") : -1; + const int inputIndex = obj->hasProperty("inputIndex") ? (int)obj->getProperty("inputIndex") : 0; + if (channel >= 0 && channel < maxLocalChannels) + setLocalChannelInput(channel, inputIndex); + } + return; + } + if (type == "intervals") + { + // payload is expected to be either an array of objects or a single object + if (videoHelperRunning.load() && !intervalJsonFile.getFullPathName().isEmpty()) + { + // write incoming payload to the helper file path + intervalJsonFile.replaceWithText(payload); + vlogStr("[INTSYNC] Received intervals payload from=" + sender + " written to " + intervalJsonFile.getFullPathName()); + } + return; + } + if (type == "intervalSyncTag") + { + juce::String tag; + juce::String payloadUserId; + int remoteInterval = -1; + int remoteIntervalAbsolute = -1; + int remoteBpi = 0; + int remoteBeat = -1; + const juce::var parsed = juce::JSON::parse(payload); + if (auto* obj = parsed.getDynamicObject()) + { + if (obj->hasProperty("tag")) + tag = obj->getProperty("tag").toString(); + if (obj->hasProperty("userId")) + payloadUserId = obj->getProperty("userId").toString(); + if (obj->hasProperty("intervalIndex")) + remoteInterval = (int)obj->getProperty("intervalIndex"); + if (obj->hasProperty("intervalAbsolute")) + remoteIntervalAbsolute = (int)obj->getProperty("intervalAbsolute"); + if (obj->hasProperty("bpi")) + remoteBpi = (int)obj->getProperty("bpi"); + if (obj->hasProperty("beatIndex")) + remoteBeat = (int)obj->getProperty("beatIndex"); + } + const int localInterval = getIntervalIndex(); + juce::String status = "Interval Tag " + sender; + if (remoteInterval >= 0) + { + const int delta = remoteInterval - localInterval; + status << " remoteInt " << juce::String(remoteInterval) + << " localInt " << juce::String(localInterval) + << " d=" << juce::String(delta); + } + if (remoteBeat >= 0 && remoteBpi > 0) + status << " beat " << juce::String(remoteBeat + 1) << "/" << juce::String(remoteBpi); + if (tag.isNotEmpty()) + status << " tag " << tag; + setIntervalSyncStatusText(status); + + if (remoteInterval >= 0 && remoteBeat == 0) + { + const juce::String localUserKey = normaliseOpusPeerId(currentUser); + const juce::String senderKey = normaliseOpusPeerId(payloadUserId.isNotEmpty() ? payloadUserId : sender); + if (senderKey.isNotEmpty() && senderKey != localUserKey) + { + const int localBpi = juce::jmax(1, getBPI()); + const bool bpiMatches = (remoteBpi <= 0 || remoteBpi == localBpi); + if (!bpiMatches) + return; + bool shouldStorePending = false; + const juce::String displaySender = sender.isNotEmpty() ? sender : (payloadUserId.isNotEmpty() ? payloadUserId : senderKey); + const long long receivedSampleCount = intervalSyncSampleCounter.load(std::memory_order_relaxed); + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + auto it = lastAnnouncedRemoteIntervalByUser.find(senderKey); + if (it != lastAnnouncedRemoteIntervalByUser.end() && remoteInterval + 1 < it->second) + remoteLatencyAverageByUser.erase(senderKey); + if (it == lastAnnouncedRemoteIntervalByUser.end() || it->second != remoteInterval) + { + lastAnnouncedRemoteIntervalByUser[senderKey] = remoteInterval; + shouldStorePending = true; + } + } + + if (shouldStorePending) + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + auto& pending = pendingRemoteIntervalStartsByUser[senderKey]; + pending.remoteInterval = remoteInterval; + pending.remoteIntervalAbsolute = remoteIntervalAbsolute; + pending.displaySender = displaySender; + pending.receivedSampleCount = receivedSampleCount; + vlogStr("[INTTAG] Pending stored from=" + sender + " userId=" + (payloadUserId.isNotEmpty() ? payloadUserId : sender) + " senderKey=" + senderKey + " remoteInterval=" + juce::String(remoteInterval) + " remoteAbs=" + juce::String(remoteIntervalAbsolute) + " samples=" + juce::String(receivedSampleCount)); + } + } + } + return; + } +} + +void NinjamVst3AudioProcessor::ChatMessage_Callback(void* userData, NJClient* inst, const char** parms, int nparms) +{ + auto* self = static_cast(userData); + auto processOpusSyncSupport = [self](const juce::String& sender, const juce::String& payload, juce::String* outEventId) -> bool + { + juce::Logger::writeToLog("processOpusSyncSupport from sender='" + sender + "'"); + vlogStr("processOpusSyncSupport from sender='" + sender + "' payload=" + payload.substring(0, 80)); + juce::var parsed = juce::JSON::parse(payload); + bool supportsOpus = false; + bool multiChanEnabled = false; + int peerNumChannels = 1; + juce::String userId = normaliseOpusPeerId(sender); + juce::String clientId; + juce::String appFamily; + int handshakeVersion = 0; + juce::String runtimeFormat; + juce::String pluginVersion; + if (auto* obj = parsed.getDynamicObject()) + { + const juce::String supports = obj->getProperty("supportsOpus").toString(); + supportsOpus = supports == "1" || supports.equalsIgnoreCase("true"); + const juce::String enabledStr = obj->getProperty("enabled").toString(); + multiChanEnabled = enabledStr == "1" || enabledStr.equalsIgnoreCase("true"); + const juce::var numChVar = obj->getProperty("numChannels"); + if (!numChVar.isVoid()) peerNumChannels = juce::jmax(1, (int)numChVar); + juce::String payloadUserId = obj->getProperty("userId").toString(); + if (payloadUserId.isNotEmpty()) + userId = normaliseOpusPeerId(payloadUserId); + clientId = obj->getProperty("clientId").toString().trim(); + appFamily = obj->getProperty("appFamily").toString().trim(); + handshakeVersion = (int)obj->getProperty("handshakeVersion"); + runtimeFormat = obj->getProperty("runtimeFormat").toString().trim(); + pluginVersion = obj->getProperty("pluginVersion").toString().trim(); + if (outEventId != nullptr) + *outEventId = obj->getProperty("eventId").toString(); + } + else + return false; + + const bool isLocalClient = clientId.isNotEmpty() ? (clientId == self->opusSyncInstanceId) + : (userId == normaliseOpusPeerId(self->currentUser)); + const bool sameAppFamily = appFamily.isEmpty() || appFamily == opusSyncAppFamily; + const bool compatibleHandshake = handshakeVersion <= 0 || handshakeVersion == opusSyncHandshakeVersion; + const juce::String peerKey = clientId.isNotEmpty() ? clientId : userId; + if (peerKey.isNotEmpty() && userId.isNotEmpty() && !isLocalClient) + { + bool recognizedNow = false; + juce::String recognizedMessage; + { + juce::ScopedLock lock(self->opusSyncPeerLock); + if (supportsOpus && sameAppFamily && compatibleHandshake) + { + const bool wasKnown = self->opusSyncPeers.find(peerKey) != self->opusSyncPeers.end(); + auto& peer = self->opusSyncPeers[peerKey]; + const bool wasMultiChan = peer.multiChanEnabled; + peer.userId = userId; + peer.supportsOpus = true; + peer.multiChanEnabled = multiChanEnabled; + peer.numChannels = peerNumChannels; + peer.appFamily = appFamily; + peer.handshakeVersion = handshakeVersion; + peer.runtimeFormat = runtimeFormat; + peer.pluginVersion = pluginVersion; + peer.lastSeenMs = juce::Time::getMillisecondCounterHiRes(); + juce::String peerLabel = sender.isNotEmpty() ? sender : userId; + if (!wasKnown) + { + juce::String peerInfo = peer.runtimeFormat; + if (peer.pluginVersion.isNotEmpty()) + { + if (peerInfo.isNotEmpty()) + peerInfo << " "; + peerInfo << peer.pluginVersion; + } + recognizedMessage = "Multi Client Detected: " + peerLabel; + if (peerInfo.isNotEmpty()) + recognizedMessage << " (" << peerInfo << ")"; + if (multiChanEnabled) + recognizedMessage << " [MultiChannel ON]"; + recognizedNow = true; + } + else if (multiChanEnabled && !wasMultiChan) + { + recognizedMessage = "MultiChannel Detected: " + peerLabel; + recognizedNow = true; + } + else if (!multiChanEnabled && wasMultiChan) + { + recognizedMessage = "MultiChannel Off: " + peerLabel; + recognizedNow = true; + } + } + else + self->opusSyncPeers.erase(peerKey); + } + if (recognizedNow) + { + juce::ScopedLock lock(self->chatLock); + self->chatHistory.add(recognizedMessage); + self->chatSenders.add(""); + if (self->chatHistory.size() > 100) + { + self->chatHistory.removeRange(0, self->chatHistory.size() - 100); + self->chatSenders.removeRange(0, juce::jmax(0, self->chatSenders.size() - 100)); + } + } + vlogStr("[MCRefresh] processOpusSyncSupport calling refresh. sender='" + sender + "' userId='" + userId + "' multiChanEnabled=" + juce::String(multiChanEnabled ? 1 : 0) + " nCh=" + juce::String(peerNumChannels)); + self->refreshOpusSyncAvailabilityFromUsers(); + } + return true; + }; + auto processInboundSideSignal = [self, &processOpusSyncSupport](const juce::String& sender, const juce::String& type, const juce::String& payload, juce::String* outEventId) -> bool + { + if (type == "opusSyncSupport") + return processOpusSyncSupport(sender, payload, outEventId); + juce::ignoreUnused(outEventId); + self->processSyncSignal(sender, type, payload); + return true; + }; + // nparms is the static array size (always 5); count only non-null entries + { + int actualNparms = 0; + while (actualNparms < nparms && parms[actualNparms] != nullptr) + ++actualNparms; + nparms = actualNparms; + } + if (nparms > 0) + { + juce::String cmd = parms[0]; + vlogStr("ChatMsg cmd=" + cmd + " nparms=" + juce::String(nparms)); + juce::Logger::writeToLog("ChatMsg cmd=" + cmd + " nparms=" + juce::String(nparms)); + auto applyServerCaps = [self](const juce::String& capsText) + { + juce::Logger::writeToLog("SERVER_CAPS received: " + capsText); + const juce::String caps = capsText.toLowerCase(); + const bool hasOpusSyncCap = caps.contains("opus_sync_v2") + || caps.contains("hd_audio_v2") + || caps.contains("hd_sync_v2"); + self->opusSyncServerSupported.store(hasOpusSyncCap); + juce::Logger::writeToLog("opusSyncServerSupported -> " + juce::String(hasOpusSyncCap ? "true" : "false")); + if (!hasOpusSyncCap) + self->refreshOpusSyncAvailabilityFromUsers(); + }; + juce::String line; + if (cmd == "SERVER_CAPS" && nparms >= 2) + { + applyServerCaps(juce::String(parms[1])); + return; + } + bool isSideSignalCmd = (cmd == "SIDE_SIGNAL_FROM" && nparms >= 4) + || (cmd == "SIDE_SIGNAL" && nparms >= 4) + || (cmd == "VIDEO_SIGNAL_FROM" && nparms >= 4) + || (cmd == "VIDEO_SIGNAL" && nparms >= 4); + if (isSideSignalCmd) + { + juce::String sender; + juce::String type; + juce::String payload; + juce::String signalEventId; + sender = nparms >= 2 ? juce::String(parms[1]) : juce::String(); + type = nparms >= 3 ? juce::String(parms[nparms - 2]) : juce::String(); + payload = nparms >= 2 ? juce::String(parms[nparms - 1]) : juce::String(); + if (type.isEmpty() || payload.isEmpty()) + return; + + processInboundSideSignal(sender, type, payload, &signalEventId); + juce::String logLine = "NINJAM Side Signal From " + sender + " [" + type + "]"; + if (signalEventId.isNotEmpty()) + logLine += " eid=" + signalEventId; + juce::Logger::writeToLog(logLine); + return; + } + if ((cmd == "MSG" || cmd == "PRIVMSG") && nparms >= 3) + { + const juce::String sender = juce::String(parms[1]); + const juce::String messageText = juce::String(parms[2]); + if (messageText.startsWith(opusSyncChatPrefix)) + { + vlogStr("MSG opusSyncChatPrefix MATCHED from " + sender); + juce::Logger::writeToLog("MSG opusSyncChatPrefix received from " + sender); + } + const juce::String trimmedText = messageText.trim(); + if (sender == "*" && trimmedText.startsWithIgnoreCase("SERVER_CAPS")) + { + juce::String capsText = trimmedText.fromFirstOccurrenceOf("SERVER_CAPS", false, true).trim(); + if (capsText.startsWithChar(':')) + capsText = capsText.substring(1).trim(); + applyServerCaps(capsText); + return; + } + if (messageText.startsWith(opusSyncChatPrefix)) + { + juce::String signalEventId; + const juce::String payload = messageText.fromFirstOccurrenceOf(opusSyncChatPrefix, false, false); + if (processOpusSyncSupport(sender, payload, &signalEventId)) + { + juce::String logLine = "NINJAM Opus Sync Signal From " + sender + " [chat]"; + if (signalEventId.isNotEmpty()) + logLine += " eid=" + signalEventId; + juce::Logger::writeToLog(logLine); + return; + } + } + bool isSideSignalChat = messageText.startsWith(sideSignalChatPrefix); + if (isSideSignalChat) + { + const char* signalPrefix = sideSignalChatPrefix; + const juce::String wrapperJson = messageText.fromFirstOccurrenceOf(signalPrefix, false, false); + juce::var wrapped = juce::JSON::parse(wrapperJson); + if (auto* wrappedObj = wrapped.getDynamicObject()) + { + const juce::String type = wrappedObj->getProperty("type").toString(); + const juce::String payload = wrappedObj->getProperty("payload").toString(); + if (type.isNotEmpty() && payload.isNotEmpty()) + { + juce::String signalEventId; + if (processInboundSideSignal(sender, type, payload, &signalEventId)) + { + juce::String logLine = "NINJAM Side Signal From " + sender + " [" + type + " chat]"; + if (signalEventId.isNotEmpty()) + logLine += " eid=" + signalEventId; + juce::Logger::writeToLog(logLine); + return; + } + } + } + } + } + + auto cleanName = [](const char* raw) -> juce::String { + return normaliseChatTargetNick(juce::String(raw)); + }; + + juce::String lineSender; + if (cmd == "MSG" && nparms >= 3) + { + // Suppress server echo of our own messages + if (normaliseChatTargetNick(juce::String(parms[1])) == normaliseChatTargetNick(self->currentUser)) + return; + juce::String name = cleanName(parms[1]); + line = name + ": " + juce::String(parms[2]); + lineSender = name; + } + else if (cmd == "PRIVMSG" && nparms >= 3) + { + juce::String name = cleanName(parms[1]); + line = "(Private) " + name + ": " + juce::String(parms[2]); + lineSender = name; + } + else if (cmd == "TOPIC" && nparms >= 2) + line = "Topic: " + juce::String(parms[1]); + else if (cmd == "JOIN" && nparms >= 2) + { + self->broadcastOpusSyncSupport(juce::String(parms[1])); + self->broadcastIntervalSyncTag(juce::String(parms[1])); + line = cleanName(parms[1]) + " has joined."; + } + else if (cmd == "PART" && nparms >= 2) + line = cleanName(parms[1]) + " has left."; + else + { + line = cmd; + for (int i=1; itranslateText(line); + juce::ScopedLock lock(self->chatLock); + self->chatHistory.add(stored); + self->chatSenders.add(lineSender); + if (self->chatHistory.size() > 100) + { + self->chatHistory.removeRange(0, self->chatHistory.size() - 100); + self->chatSenders.removeRange(0, juce::jmax(0, self->chatSenders.size() - 100)); + } + } + + // Also log + juce::Logger::writeToLog("NINJAM Chat: " + line); + } +} + +void NinjamVst3AudioProcessor::processBlock (juce::AudioBuffer& buffer, juce::MidiBuffer& midiMessages) +{ + juce::ScopedNoDenormals noDenormals; + juce::AudioPlayHead::CurrentPositionInfo hostInfoAtBlock; + bool gotHostPosition = false; + if (auto* playHead = getPlayHead()) + { + juce::AudioPlayHead::CurrentPositionInfo info; + if (playHead->getCurrentPosition(info)) + { + gotHostPosition = true; + hostInfoAtBlock = info; + const juce::ScopedLock lock(transportLock); + lastHostPosition = info; + } + } + int numSamples = buffer.getNumSamples(); + intervalSyncSampleCounter.fetch_add((long long)numSamples, std::memory_order_relaxed); + const bool useHostMidiForLearn = getMidiLearnInputDeviceId().isEmpty(); + const bool useHostMidiForRelay = getMidiRelayInputDeviceId().isEmpty(); + { + const juce::SpinLock::ScopedLockType midiQueueLock(midiEventQueueLock); + const juce::SpinLock::ScopedLockType relayQueueLock(outboundMidiRelayQueueLock); + for (const auto metadata : midiMessages) + { + const auto& msg = metadata.getMessage(); + if (msg.isController()) + { + MidiControllerEvent event; + event.isController = true; + event.midiChannel = msg.getChannel(); + event.number = msg.getControllerNumber(); + event.value = msg.getControllerValue(); + event.normalized = (float)event.value / 127.0f; + event.isNoteOn = event.value >= 64; + if (useHostMidiForLearn) + pendingMidiControllerEvents.push_back(event); + if (useHostMidiForRelay) + pendingOutboundMidiRelayEvents.push_back(event); + } + else if (msg.isNoteOnOrOff()) + { + MidiControllerEvent event; + event.isController = false; + event.midiChannel = msg.getChannel(); + event.number = msg.getNoteNumber(); + event.value = msg.getVelocity(); + event.normalized = msg.isNoteOn() ? ((float)event.value / 127.0f) : 0.0f; + event.isNoteOn = msg.isNoteOn(); + if (useHostMidiForLearn) + pendingMidiControllerEvents.push_back(event); + if (useHostMidiForRelay) + pendingOutboundMidiRelayEvents.push_back(event); + } + } + if (pendingMidiControllerEvents.size() > 512) + pendingMidiControllerEvents.erase(pendingMidiControllerEvents.begin(), pendingMidiControllerEvents.begin() + (long long)(pendingMidiControllerEvents.size() - 512)); + if (pendingOutboundMidiRelayEvents.size() > 512) + pendingOutboundMidiRelayEvents.erase(pendingOutboundMidiRelayEvents.begin(), pendingOutboundMidiRelayEvents.begin() + (long long)(pendingOutboundMidiRelayEvents.size() - 512)); + } + injectInboundMidiRelayEvents(midiMessages); + + int totalInputChannels = 0; + int numInputBuses = getBusCount(true); + for (int bus = 0; bus < numInputBuses; ++bus) + { + int busChans = getChannelCountOfBus(true, bus); + if (busChans <= 0) + continue; + totalInputChannels += busChans; + } + + if (tempInputBuffer.getNumChannels() < totalInputChannels || tempInputBuffer.getNumSamples() < numSamples) + tempInputBuffer.setSize(totalInputChannels, numSamples, false, false, true); + + int inputChanIndex = 0; + for (int bus = 0; bus < numInputBuses; ++bus) + { + auto& busBuffer = getBusBuffer(buffer, true, bus); + int busChans = busBuffer.getNumChannels(); + if (busChans <= 0) + continue; + for (int ch = 0; ch < busChans; ++ch) + { + if (inputChanIndex < totalInputChannels) + { + tempInputBuffer.copyFrom(inputChanIndex, 0, busBuffer, ch, 0, numSamples); + ++inputChanIndex; + } + } + } + + if (localChannelBuffer.getNumChannels() < maxLocalChannels || localChannelBuffer.getNumSamples() < numSamples) + localChannelBuffer.setSize(maxLocalChannels, numSamples, false, false, true); + + int requestedLocal = numLocalChannels.load(); + int actualLocal = juce::jlimit(1, maxLocalChannels, requestedLocal); + actualLocal = juce::jmin(actualLocal, totalInputChannels); + std::array monitorSourceLeft{}; + std::array monitorSourceRight{}; + std::array monitorStereo{}; + monitorSourceLeft.fill(-1); + monitorSourceRight.fill(-1); + monitorStereo.fill(false); + + float globalLocalMax = 0.0f; + float globalLocalMaxL = 0.0f; + float globalLocalMaxR = 0.0f; + for (int ch = 0; ch < actualLocal; ++ch) + { + int srcIndex = localChannelInputs[(size_t)ch].load(); + int leftSource = -1; + int rightSource = -1; + + if (srcIndex >= 0) + { + if (srcIndex >= totalInputChannels) + srcIndex = juce::jlimit(0, totalInputChannels - 1, srcIndex); + + int left = juce::jlimit(0, juce::jmax(totalInputChannels - 1, 0), srcIndex); + int right = left; + + localChannelBuffer.clear(ch, 0, numSamples); + if (left < totalInputChannels) + localChannelBuffer.copyFrom(ch, 0, tempInputBuffer, left, 0, numSamples); + + leftSource = left; + rightSource = right; + } + else + { + int pairIndex = -1 - srcIndex; + int left = pairIndex * 2; + int right = left + 1; + + if (left < 0 || left >= totalInputChannels) + left = juce::jlimit(0, juce::jmax(totalInputChannels - 1, 0), left); + if (right < 0 || right >= totalInputChannels) + right = left; + + localChannelBuffer.clear(ch, 0, numSamples); + if (left < totalInputChannels) + localChannelBuffer.addFrom(ch, 0, tempInputBuffer, left, 0, numSamples, 0.5f); + if (right < totalInputChannels) + localChannelBuffer.addFrom(ch, 0, tempInputBuffer, right, 0, numSamples, 0.5f); + + leftSource = left; + rightSource = right; + monitorStereo[(size_t)ch] = (right != left); + } + + monitorSourceLeft[(size_t)ch] = leftSource; + monitorSourceRight[(size_t)ch] = rightSource; + + float gain = localChannelGains[(size_t)ch].load(); + if (gain != 1.0f) + localChannelBuffer.applyGain(ch, 0, numSamples, gain); + + const float* data = localChannelBuffer.getReadPointer(ch); + float localMax = 0.0f; + for (int i = 0; i < numSamples; ++i) + { + float a = std::abs(data[i]); + if (a > localMax) + localMax = a; + } + + float localMaxL = 0.0f; + float localMaxR = 0.0f; + + if (leftSource >= 0 && leftSource < totalInputChannels) + { + const float* leftData = tempInputBuffer.getReadPointer(leftSource); + for (int i = 0; i < numSamples; ++i) + { + float a = std::abs(leftData[i] * gain); + if (a > localMaxL) + localMaxL = a; + } + } + + if (rightSource >= 0 && rightSource < totalInputChannels) + { + const float* rightData = tempInputBuffer.getReadPointer(rightSource); + for (int i = 0; i < numSamples; ++i) + { + float a = std::abs(rightData[i] * gain); + if (a > localMaxR) + localMaxR = a; + } + } + + localChannelPeaks[(size_t)ch].store(localMax); + localChannelPeaksL[(size_t)ch].store(localMaxL); + localChannelPeaksR[(size_t)ch].store(localMaxR); + if (localMax > globalLocalMax) + globalLocalMax = localMax; + if (localMaxL > globalLocalMaxL) + globalLocalMaxL = localMaxL; + if (localMaxR > globalLocalMaxR) + globalLocalMaxR = localMaxR; + } + + if (totalInputChannels > 0 && numSamples > 0) + { + const float* dev0 = tempInputBuffer.getReadPointer(0); + const float* dev1 = tempInputBuffer.getNumChannels() > 1 ? tempInputBuffer.getReadPointer(1) : dev0; + float devMax = 0.0f; + float devMaxL = 0.0f; + float devMaxR = 0.0f; + for (int i = 0; i < numSamples; ++i) + { + float aL = std::abs(dev0[i]); + float aR = std::abs(dev1[i]); + float a = juce::jmax(aL, aR); + if (a > devMax) + devMax = a; + if (aL > devMaxL) + devMaxL = aL; + if (aR > devMaxR) + devMaxR = aR; + } + localChannelPeaks[0].store(devMax); + localChannelPeaksL[0].store(devMaxL); + localChannelPeaksR[0].store(devMaxR); + if (devMax > globalLocalMax) + globalLocalMax = devMax; + if (devMaxL > globalLocalMaxL) + globalLocalMaxL = devMaxL; + if (devMaxR > globalLocalMaxR) + globalLocalMaxR = devMaxR; + } + + localPeak.store(globalLocalMax); + localPeakL.store(globalLocalMaxL); + localPeakR.store(globalLocalMaxR); + + const bool reverbOn = fxReverbEnabled.load(); + const bool delayOn = fxDelayEnabled.load(); + const bool fxSendActive = reverbOn || delayOn; + + if (fxTransmitBuffer.getNumSamples() < numSamples) + fxTransmitBuffer.setSize(1, numSamples, false, true, true); + if (fxReturnBuffer.getNumSamples() < numSamples) + fxReturnBuffer.setSize(2, numSamples, false, true, true); + fxTransmitBuffer.clear(); + fxReturnBuffer.clear(); + + if (fxSendActive) + { + if (fxReverbInputBuffer.getNumSamples() < numSamples) + fxReverbInputBuffer.setSize(1, numSamples, false, true, true); + if (fxDelayInputBuffer.getNumSamples() < numSamples) + fxDelayInputBuffer.setSize(1, numSamples, false, true, true); + + fxReverbInputBuffer.clear(); + fxDelayInputBuffer.clear(); + + const int activeLocal = juce::jmin(actualLocal, numLocalChannels.load()); + for (int ch = 0; ch < activeLocal; ++ch) + { + const float reverbSend = localChannelReverbSends[(size_t)ch].load(); + const float delaySend = localChannelDelaySends[(size_t)ch].load(); + if (reverbSend <= 0.0001f && delaySend <= 0.0001f) + continue; + + const float* src = localChannelBuffer.getReadPointer(ch); + float* reverbDst = fxReverbInputBuffer.getWritePointer(0); + float* delayDst = fxDelayInputBuffer.getWritePointer(0); + for (int i = 0; i < numSamples; ++i) + { + const float v = src[i]; + if (reverbSend > 0.0001f) + reverbDst[i] += v * reverbSend; + if (delaySend > 0.0001f) + delayDst[i] += v * delaySend; + } + } + + float* fxSendMono = fxTransmitBuffer.getWritePointer(0); + float* fxLeft = fxReturnBuffer.getWritePointer(0); + float* fxRight = fxReturnBuffer.getWritePointer(1); + + if (reverbOn) + { + juce::Reverb::Parameters params; + params.roomSize = fxReverbRoomSize.load(); + params.damping = fxReverbDamping.load(); + params.width = 1.0f; + params.wetLevel = 1.0f; + params.dryLevel = 0.0f; + params.freezeMode = 0.0f; + fxReverb.setParameters(params); + + const float wetDryMix = fxReverbWetDryMix.load(); + const float earlyAmount = fxReverbEarlyReflections.load(); + const float tailAmount = fxReverbTail.load(); + const float* reverbIn = fxReverbInputBuffer.getReadPointer(0); + float* revMono = fxReverbInputBuffer.getWritePointer(0); + fxReverb.processMono(revMono, numSamples); + for (int i = 0; i < numSamples; ++i) + { + const float early = reverbIn[i] * earlyAmount; + const float tail = revMono[i] * tailAmount; + const float wet = early + tail; + const float mixed = wet * wetDryMix + reverbIn[i] * (1.0f - wetDryMix); + const float out = mixed * 0.8f; + fxLeft[i] += out; + fxRight[i] += out; + fxSendMono[i] += out * 0.5f; + } + } + + if (delayOn) + { + const int delayBufferSamples = fxDelayBuffer.getNumSamples(); + if (delayBufferSamples > 1) + { + const int division = fxDelayDivision.load(); + const double bpm = (double)getBPM(); + double targetDelaySeconds = fxDelayTimeMs.load() / 1000.0; + if (fxDelaySyncToHost.load() && bpm > 1.0) + targetDelaySeconds = (60.0 / bpm) * (4.0 / (double)division); + const int delaySamples = juce::jlimit(1, delayBufferSamples - 1, (int)std::round(targetDelaySeconds * processingSampleRate)); + + const bool pingPong = fxDelayPingPong.load(); + const float feedback = juce::jlimit(0.0f, 0.95f, fxDelayFeedback.load()); + const float wetDryMix = juce::jlimit(0.0f, 1.0f, fxDelayWetDryMix.load()); + const float delayWet = wetDryMix * 0.8f; + + float* delayMemoryL = fxDelayBuffer.getWritePointer(0); + float* delayMemoryR = fxDelayBuffer.getWritePointer(1); + const float* delayIn = fxDelayInputBuffer.getReadPointer(0); + + int writePos = fxDelayWritePosition; + for (int i = 0; i < numSamples; ++i) + { + int readPos = writePos - delaySamples; + if (readPos < 0) + readPos += delayBufferSamples; + + const float readL = delayMemoryL[readPos]; + const float readR = delayMemoryR[readPos]; + const float input = delayIn[i]; + const float wetL = readL * delayWet; + const float wetR = readR * delayWet; + + fxLeft[i] += wetL; + fxRight[i] += wetR; + fxSendMono[i] += (wetL + wetR) * 0.25f; + + if (pingPong) + { + delayMemoryL[writePos] = input + readR * feedback; + delayMemoryR[writePos] = input + readL * feedback; + } + else + { + const float mono = 0.5f * (readL + readR); + delayMemoryL[writePos] = input + mono * feedback; + delayMemoryR[writePos] = input + mono * feedback; + } + + ++writePos; + if (writePos >= delayBufferSamples) + writePos = 0; + } + fxDelayWritePosition = writePos; + } + } + } + + // Determine active encoding mode: + // - multiChanAuto: >1 local channels + VST3 peers → Vorbis mix on ch0, Opus per-ch on ch1..N + // - otherwise: Vorbis only, single channel (mix folded into ch0 above) + const bool multiChanAuto = numLocalChannels.load() > 1 && opusSyncAvailable.load() && isTransmittingLocal(); + + if (!multiChanAuto && actualLocal > 1) + { + float* dst = localChannelBuffer.getWritePointer(0); + for (int ch = 1; ch < actualLocal; ++ch) + { + const float* src = localChannelBuffer.getReadPointer(ch); + for (int s = 0; s < numSamples; ++s) + dst[s] += src[s]; + } + } + + if (!multiChanAuto && fxSendActive) + localChannelBuffer.addFrom(0, 0, fxTransmitBuffer, 0, 0, numSamples); + + if (multiChanAuto) + { + if (localMixBuffer.getNumSamples() < numSamples) + localMixBuffer.setSize(1, numSamples, false, true, true); + float* mix = localMixBuffer.getWritePointer(0); + const float* src0 = localChannelBuffer.getReadPointer(0); + for (int s = 0; s < numSamples; ++s) + mix[s] = src0[s]; + for (int ch = 1; ch < actualLocal; ++ch) + { + const float* src = localChannelBuffer.getReadPointer(ch); + for (int s = 0; s < numSamples; ++s) + mix[s] += src[s]; + } + if (fxSendActive) + localMixBuffer.addFrom(0, 0, fxTransmitBuffer, 0, 0, numSamples); + } + + float* inputs[32] = {}; + int actualInputChannels; + if (multiChanAuto) + { + const int n = juce::jmin(actualLocal, 30); + for (int i = 0; i < n; ++i) + inputs[i] = localChannelBuffer.getWritePointer(i); + inputs[n] = fxTransmitBuffer.getWritePointer(0); + inputs[n + 1] = localMixBuffer.getWritePointer(0); + actualInputChannels = n + 2; + } + else + { + inputs[0] = localChannelBuffer.getWritePointer(0); + actualInputChannels = 1; + } + + float* outputs[32]; + int totalOutputChannels = 0; + int numOutputBuses = getBusCount(false); + for (int bus = 0; bus < numOutputBuses; ++bus) + { + int busChans = getChannelCountOfBus(false, bus); + if (busChans <= 0) + continue; + totalOutputChannels += busChans; + } + + int actualOutputChannels = juce::jmin(totalOutputChannels, 32); + + int outputChanIndex = 0; + for (int bus = 0; bus < numOutputBuses; ++bus) + { + auto& busBuffer = getBusBuffer(buffer, false, bus); + int busChans = busBuffer.getNumChannels(); + if (busChans <= 0) + continue; + for (int ch = 0; ch < busChans; ++ch) + { + if (outputChanIndex < actualOutputChannels) + { + outputs[outputChanIndex] = busBuffer.getWritePointer(ch); + ++outputChanIndex; + } + } + } + + for (int bus = 0; bus < numOutputBuses; ++bus) + { + auto& busBuffer = getBusBuffer(buffer, false, bus); + int busChans = busBuffer.getNumChannels(); + if (busChans <= 0) + continue; + for (int ch = 0; ch < busChans; ++ch) + busBuffer.clear(ch, 0, numSamples); + } + + bool gateForSync = false; + bool runMonitorOnly = false; + if (isSyncToHostEnabled()) + { + bool hostValid = gotHostPosition; + bool hostPlaying = hostValid && hostInfoAtBlock.isPlaying; + + bool prev = hostWasPlaying.load(); + if (!hostValid || !hostPlaying) + { + hostWasPlaying.store(false); + syncWaitForInterval.store(false); + syncTargetInterval.store(-1); + syncDisplayPositionOffset.store(0); + } + else if (!prev) + { + hostWasPlaying.store(true); + ninjamClient.ResetTransportPhase(); + ninjamClient.ResetLocalBroadcastState(); + syncWaitForInterval.store(false); + syncTargetInterval.store(-1); + syncDisplayIntervalOffset.store(intervalIndex.load()); + syncDisplayPositionOffset.store(0); + } + + if (!hostValid || !hostPlaying) + { + gateForSync = true; + } + runMonitorOnly = gateForSync; + } + else + { + hostWasPlaying.store(false); + syncWaitForInterval.store(false); + syncTargetInterval.store(-1); + syncDisplayIntervalOffset.store(0); + syncDisplayPositionOffset.store(0); + } + + const bool monitorEnabled = localMonitorEnabled.load(); + const bool transmitEnabled = isTransmittingLocal(); + const bool allowEngineLocalInput = monitorEnabled || transmitEnabled; + float** engineInputs = allowEngineLocalInput ? inputs : nullptr; + int engineInputChannels = allowEngineLocalInput ? actualInputChannels : 0; + ninjamClient.AudioProc(engineInputs, engineInputChannels, outputs, actualOutputChannels, numSamples, (int)getSampleRate(), runMonitorOnly); + + if (monitorEnabled && !transmitEnabled) + { + int numOutputBusesOut = getBusCount(false); + if (numOutputBusesOut > 0) + { + auto& mainBus = getBusBuffer(buffer, false, 0); + int outChans = mainBus.getNumChannels(); + int numLocal = juce::jmin(numLocalChannels.load(), maxLocalChannels); + for (int ch = 0; ch < numLocal; ++ch) + { + const int outLeft = ch * 2; + const int outRight = outLeft + 1; + if (outChans <= 0) + break; + const int sourceLeft = monitorSourceLeft[(size_t)ch]; + const int sourceRight = monitorSourceRight[(size_t)ch]; + const float gain = localChannelGains[(size_t)ch].load(); + if (sourceLeft < 0 || sourceLeft >= totalInputChannels) + continue; + + if (monitorStereo[(size_t)ch] && sourceRight >= 0 && sourceRight < totalInputChannels) + { + if (outLeft < outChans) + mainBus.addFrom(outLeft, 0, tempInputBuffer, sourceLeft, 0, numSamples, gain); + if (outRight < outChans) + mainBus.addFrom(outRight, 0, tempInputBuffer, sourceRight, 0, numSamples, gain); + else if (outLeft == 0 && outChans == 1) + { + mainBus.addFrom(0, 0, tempInputBuffer, sourceLeft, 0, numSamples, gain * 0.5f); + mainBus.addFrom(0, 0, tempInputBuffer, sourceRight, 0, numSamples, gain * 0.5f); + } + } + else + { + if (outLeft < outChans) + mainBus.addFrom(outLeft, 0, tempInputBuffer, sourceLeft, 0, numSamples, gain); + if (outRight < outChans) + mainBus.addFrom(outRight, 0, tempInputBuffer, sourceLeft, 0, numSamples, gain); + else if (outLeft == 0 && outChans == 1) + mainBus.addFrom(0, 0, tempInputBuffer, sourceLeft, 0, numSamples, gain); + } + } + } + } + + int mtcPos = 0; + int mtcLength = 0; + ninjamClient.GetPosition(&mtcPos, &mtcLength); + emitMidiTimecode(midiMessages, numSamples, mtcPos, mtcLength); + + int numOutputBusesOut = getBusCount(false); + + if (gateForSync) + { + for (int bus = 0; bus < numOutputBusesOut; ++bus) + { + auto& busBuffer = getBusBuffer(buffer, false, bus); + int busChans = busBuffer.getNumChannels(); + if (busChans <= 0) + continue; + for (int ch = 0; ch < busChans; ++ch) + busBuffer.clear(ch, 0, numSamples); + } + masterPeak.store(0.0f); + masterPeakL.store(0.0f); + masterPeakR.store(0.0f); + return; + } + + if (numOutputBusesOut > 0 && fxSendActive) + { + auto& mainBus = getBusBuffer(buffer, false, 0); + const int mainChans = mainBus.getNumChannels(); + if (mainChans >= 2) + { + mainBus.addFrom(0, 0, fxReturnBuffer, 0, 0, numSamples); + mainBus.addFrom(1, 0, fxReturnBuffer, 1, 0, numSamples); + } + else if (mainChans == 1) + { + const float* l = fxReturnBuffer.getReadPointer(0); + const float* r = fxReturnBuffer.getReadPointer(1); + float* monoOut = mainBus.getWritePointer(0); + for (int i = 0; i < numSamples; ++i) + monoOut[i] += 0.5f * (l[i] + r[i]); + } + } + + float masterGain = masterOutputGain.load(); + if (masterGain != 1.0f) + { + for (int bus = 0; bus < numOutputBusesOut; ++bus) + { + auto& busBuffer = getBusBuffer(buffer, false, bus); + int busChans = busBuffer.getNumChannels(); + for (int ch = 0; ch < busChans; ++ch) + busBuffer.applyGain(ch, 0, numSamples, masterGain); + } + } + + bool limiter = dspLimiterEnabled.load() && (limiterThresholdDb.load() < 0.0f); + if (limiter) + { + juce::dsp::AudioBlock block(buffer); + juce::dsp::ProcessContextReplacing context(block); + masterLimiter.process(context); + } + + bool softClip = softLimiterEnabled.load(); + float maxSample = 0.0f; + float maxSampleL = 0.0f; + float maxSampleR = 0.0f; + for (int bus = 0; bus < numOutputBusesOut; ++bus) + { + auto& busBuffer = getBusBuffer(buffer, false, bus); + int busChans = busBuffer.getNumChannels(); + for (int ch = 0; ch < busChans; ++ch) + { + float* data = busBuffer.getWritePointer(ch); + for (int i = 0; i < numSamples; ++i) + { + float v = data[i]; + if (softClip) + v = softClipSample(v); + float a = std::abs(v); + if (a > maxSample) + maxSample = a; + if (bus == 0 && ch == 0 && a > maxSampleL) + maxSampleL = a; + if (bus == 0 && ch == 1 && a > maxSampleR) + maxSampleR = a; + data[i] = v; + } + } + } + if (numOutputBusesOut > 0) + { + auto& mainBus = getBusBuffer(buffer, false, 0); + if (mainBus.getNumChannels() == 1) + maxSampleR = maxSampleL; + else if (mainBus.getNumChannels() == 0) + { + maxSampleL = maxSample; + maxSampleR = maxSample; + } + } + else + { + maxSampleL = maxSample; + maxSampleR = maxSample; + } + masterPeak.store(maxSample); + masterPeakL.store(maxSampleL); + masterPeakR.store(maxSampleR); +} + +// Called from NJClient::on_new_interval() in the AUDIO THREAD at sample-accurate timing. +void NinjamVst3AudioProcessor::NewIntervalCallback_cb(void* /*userData*/, NJClient* /*inst*/) +{ +} + +void NinjamVst3AudioProcessor::IntervalChunkCallback_cb(void* /*userData*/, NJClient* /*inst*/, + const char* /*username*/, int /*chidx*/, unsigned int /*fourcc*/, + const unsigned char* /*guid*/, const void* /*data*/, int /*dataLen*/, int /*flags*/) +{ +} + +void NinjamVst3AudioProcessor::IntervalMediaItem_Callback(void* userData, NJClient* /*inst*/, + const char* username, int /*chidx*/, unsigned int fourcc, + const unsigned char* /*guid*/, const void* data, int dataLen) +{ + if (!username || !data || dataLen <= 0) return; + if (fourcc == kSyncSignalFourcc) + { + auto* self = static_cast(userData); + const juce::String sender = juce::String::fromUTF8(username); + const juce::String msg = juce::String::fromUTF8(static_cast(data), dataLen); + const juce::var parsed = juce::JSON::parse(msg); + if (auto* obj = parsed.getDynamicObject()) + { + const juce::String type = obj->getProperty("sig").toString(); + const juce::String payload = obj->getProperty("data").toString(); + if (type.isNotEmpty() && payload.isNotEmpty()) + self->processSyncSignal(sender, type, payload); + } + return; + } + if (fourcc != kOpusSyncFourcc) return; + auto* self = static_cast(userData); + const juce::String sender = juce::String::fromUTF8(username); + const juce::String payload = juce::String::fromUTF8(static_cast(data), dataLen); + vlogStr("IntervalMediaItem opusSyncFourcc from=" + sender); + + juce::var parsed = juce::JSON::parse(payload); + bool supportsOpus = false; + bool multiChanEnabled = false; + int peerNumChannels = 1; + juce::String userId = normaliseOpusPeerId(sender); + juce::String clientId; + juce::String appFamily; + int handshakeVersion = 0; + juce::String runtimeFormat; + juce::String pluginVersion; + if (auto* obj = parsed.getDynamicObject()) + { + const juce::String supports = obj->getProperty("supportsOpus").toString(); + supportsOpus = supports == "1" || supports.equalsIgnoreCase("true"); + const juce::String enabledStr = obj->getProperty("enabled").toString(); + multiChanEnabled = enabledStr == "1" || enabledStr.equalsIgnoreCase("true"); + const juce::var numChVar = obj->getProperty("numChannels"); + if (!numChVar.isVoid()) peerNumChannels = juce::jmax(1, (int)numChVar); + juce::String payloadUserId = obj->getProperty("userId").toString(); + if (payloadUserId.isNotEmpty()) + userId = normaliseOpusPeerId(payloadUserId); + clientId = obj->getProperty("clientId").toString().trim(); + appFamily = obj->getProperty("appFamily").toString().trim(); + handshakeVersion = (int)obj->getProperty("handshakeVersion"); + runtimeFormat = obj->getProperty("runtimeFormat").toString().trim(); + pluginVersion = obj->getProperty("pluginVersion").toString().trim(); + } + else { vlogStr("[MCExit1] JSON parse failed from=" + sender + " payloadLen=" + juce::String((int)payload.length()) + " first100=" + payload.substring(0, 100)); return; } + + const bool isLocalClient = clientId.isNotEmpty() ? (clientId == self->opusSyncInstanceId) + : (userId == normaliseOpusPeerId(self->currentUser)); + const bool sameAppFamily = appFamily.isEmpty() || appFamily == opusSyncAppFamily; + const bool compatibleHandshake = handshakeVersion <= 0 || handshakeVersion == opusSyncHandshakeVersion; + const juce::String peerKey = clientId.isNotEmpty() ? clientId : userId; + vlogStr("[MCGuard] peerKey='" + peerKey + "' userId='" + userId + "' isLocal=" + juce::String(isLocalClient ? 1 : 0) + " sameFamily=" + juce::String(sameAppFamily ? 1 : 0) + " compatHS=" + juce::String(compatibleHandshake ? 1 : 0) + " supportsOpus=" + juce::String(supportsOpus ? 1 : 0)); + if (peerKey.isEmpty() || userId.isEmpty() || isLocalClient) return; + + bool recognizedNow = false; + juce::String recognizedMessage; + { + juce::ScopedLock lock(self->opusSyncPeerLock); + if (supportsOpus && sameAppFamily && compatibleHandshake) + { + const bool wasKnown = self->opusSyncPeers.find(peerKey) != self->opusSyncPeers.end(); + auto& peer = self->opusSyncPeers[peerKey]; + const bool wasMultiChan = peer.multiChanEnabled; + peer.userId = userId; + peer.supportsOpus = true; + peer.multiChanEnabled = multiChanEnabled; + peer.numChannels = peerNumChannels; + peer.appFamily = appFamily; + peer.handshakeVersion = handshakeVersion; + peer.runtimeFormat = runtimeFormat; + peer.pluginVersion = pluginVersion; + peer.lastSeenMs = juce::Time::getMillisecondCounterHiRes(); + const juce::String peerLabel = sender.isNotEmpty() ? sender : userId; + if (!wasKnown) + { + juce::String peerInfo = peer.runtimeFormat; + if (peer.pluginVersion.isNotEmpty()) + { + if (peerInfo.isNotEmpty()) peerInfo << " "; + peerInfo << peer.pluginVersion; + } + recognizedMessage = "Multi Client Detected: " + peerLabel; + if (peerInfo.isNotEmpty()) recognizedMessage << " (" << peerInfo << ")"; + if (multiChanEnabled) recognizedMessage << " [MultiChannel ON]"; + recognizedNow = true; + } + else if (multiChanEnabled && !wasMultiChan) + { + recognizedMessage = "MultiChannel Detected: " + peerLabel; + recognizedNow = true; + } + else if (!multiChanEnabled && wasMultiChan) + { + recognizedMessage = "MultiChannel Off: " + peerLabel; + recognizedNow = true; + } + } + else + self->opusSyncPeers.erase(peerKey); + } + if (recognizedNow) + { + juce::ScopedLock lock(self->chatLock); + self->chatHistory.add(recognizedMessage); + self->chatSenders.add(""); + if (self->chatHistory.size() > 100) + { + self->chatHistory.removeRange(0, self->chatHistory.size() - 100); + self->chatSenders.removeRange(0, juce::jmax(0, self->chatSenders.size() - 100)); + } + } + vlogStr("[MCRefresh] IntervalMediaItem_Callback calling refresh. peerKey='" + peerKey + "' userId='" + userId + "' multiChanEnabled=" + juce::String(multiChanEnabled ? 1 : 0) + " nCh=" + juce::String(peerNumChannels)); + self->refreshOpusSyncAvailabilityFromUsers(); +} + +void NinjamVst3AudioProcessor::setSyncToHost(bool shouldSync) +{ + syncToHost = shouldSync; + hostWasPlaying.store(false); + syncWaitForInterval.store(false); + syncTargetInterval.store(-1); + syncDisplayIntervalOffset.store(intervalIndex.load()); + int pos = 0; + int length = 0; + ninjamClient.GetPosition(&pos, &length); + syncDisplayPositionOffset.store(length > 0 ? pos : 0); +} + +bool NinjamVst3AudioProcessor::isSyncToHostEnabled() const +{ + return syncToHost; +} + +bool NinjamVst3AudioProcessor::getHostPosition(juce::AudioPlayHead::CurrentPositionInfo& info) const +{ + const juce::ScopedLock lock(transportLock); + info = lastHostPosition; + return true; +} + +void NinjamVst3AudioProcessor::setMtcOutputEnabled(bool shouldEnable) +{ + mtcOutputEnabled.store(shouldEnable); +} + +bool NinjamVst3AudioProcessor::isMtcOutputEnabled() const +{ + return mtcOutputEnabled.load(); +} + +void NinjamVst3AudioProcessor::setMtcFrameRate(int fps) +{ + int mapped = 30; + if (fps == 24 || fps == 25 || fps == 30 || fps == 2997) + mapped = fps; + mtcFrameRateFps.store(mapped); +} + +int NinjamVst3AudioProcessor::getMtcFrameRate() const +{ + return mtcFrameRateFps.load(); +} + +bool NinjamVst3AudioProcessor::isStandaloneInstance() const +{ + return isStandaloneWrapper(); +} + +std::vector NinjamVst3AudioProcessor::popPendingMidiControllerEvents() +{ + std::vector events; + const juce::SpinLock::ScopedLockType midiQueueLock(midiEventQueueLock); + events.swap(pendingMidiControllerEvents); + return events; +} + +void NinjamVst3AudioProcessor::setMidiRelayTarget(const juce::String& targetUser) +{ + const juce::ScopedLock lock(midiRelayTargetLock); + midiRelayTarget = targetUser.isNotEmpty() ? targetUser : "*"; +} + +juce::String NinjamVst3AudioProcessor::getMidiRelayTarget() const +{ + const juce::ScopedLock lock(midiRelayTargetLock); + return midiRelayTarget.isNotEmpty() ? midiRelayTarget : "*"; +} + +void NinjamVst3AudioProcessor::setMidiLearnStateJson(const juce::String& json) +{ + const juce::ScopedLock lock(learnStateLock); + midiLearnStateJson = json; +} + +juce::String NinjamVst3AudioProcessor::getMidiLearnStateJson() const +{ + const juce::ScopedLock lock(learnStateLock); + return midiLearnStateJson; +} + +void NinjamVst3AudioProcessor::setOscLearnStateJson(const juce::String& json) +{ + const juce::ScopedLock lock(learnStateLock); + oscLearnStateJson = json; +} + +juce::String NinjamVst3AudioProcessor::getOscLearnStateJson() const +{ + const juce::ScopedLock lock(learnStateLock); + return oscLearnStateJson; +} + +void NinjamVst3AudioProcessor::setMidiLearnInputDeviceId(const juce::String& deviceId) +{ + const juce::ScopedLock lock(learnStateLock); + midiLearnInputDeviceId = deviceId; +} + +juce::String NinjamVst3AudioProcessor::getMidiLearnInputDeviceId() const +{ + const juce::ScopedLock lock(learnStateLock); + return midiLearnInputDeviceId; +} + +void NinjamVst3AudioProcessor::setMidiRelayInputDeviceId(const juce::String& deviceId) +{ + const juce::ScopedLock lock(learnStateLock); + midiRelayInputDeviceId = deviceId; +} + +juce::String NinjamVst3AudioProcessor::getMidiRelayInputDeviceId() const +{ + const juce::ScopedLock lock(learnStateLock); + return midiRelayInputDeviceId; +} + +void NinjamVst3AudioProcessor::enqueueExternalMidiControllerEvent(const MidiControllerEvent& event, bool forLearn, bool forRelay) +{ + if (forLearn) + { + const juce::SpinLock::ScopedLockType midiQueueLock(midiEventQueueLock); + pendingMidiControllerEvents.push_back(event); + if (pendingMidiControllerEvents.size() > 512) + pendingMidiControllerEvents.erase(pendingMidiControllerEvents.begin(), pendingMidiControllerEvents.begin() + (long long)(pendingMidiControllerEvents.size() - 512)); + } + + if (forRelay) + { + const juce::SpinLock::ScopedLockType relayQueueLock(outboundMidiRelayQueueLock); + pendingOutboundMidiRelayEvents.push_back(event); + if (pendingOutboundMidiRelayEvents.size() > 512) + pendingOutboundMidiRelayEvents.erase(pendingOutboundMidiRelayEvents.begin(), pendingOutboundMidiRelayEvents.begin() + (long long)(pendingOutboundMidiRelayEvents.size() - 512)); + } +} + +void NinjamVst3AudioProcessor::flushOutboundMidiRelayEvents() +{ + std::vector events; + { + const juce::SpinLock::ScopedLockType lock(outboundMidiRelayQueueLock); + events.swap(pendingOutboundMidiRelayEvents); + } + + if (events.empty()) + return; + + const juce::String target = getMidiRelayTarget(); + const juce::String userId = currentUser; + for (const auto& event : events) + { + juce::DynamicObject::Ptr obj = new juce::DynamicObject(); + obj->setProperty("userId", userId); + obj->setProperty("isController", event.isController); + obj->setProperty("midiChannel", event.midiChannel); + obj->setProperty("number", event.number); + obj->setProperty("value", event.value); + obj->setProperty("normalized", event.normalized); + obj->setProperty("isNoteOn", event.isNoteOn); + sendSideSignal(target, "midiRelay", juce::JSON::toString(juce::var(obj.get()))); + } +} + +void NinjamVst3AudioProcessor::injectInboundMidiRelayEvents(juce::MidiBuffer& midiMessages) +{ + std::vector events; + { + const juce::SpinLock::ScopedLockType lock(inboundMidiRelayQueueLock); + events.swap(pendingInboundMidiRelayEvents); + } + + for (const auto& event : events) + { + if (event.isController) + midiMessages.addEvent(juce::MidiMessage::controllerEvent(event.midiChannel, event.number, event.value), 0); + else if (event.isNoteOn) + midiMessages.addEvent(juce::MidiMessage::noteOn(event.midiChannel, event.number, (juce::uint8)event.value), 0); + else + midiMessages.addEvent(juce::MidiMessage::noteOff(event.midiChannel, event.number), 0); + } +} + +bool NinjamVst3AudioProcessor::isStandaloneWrapper() const +{ + return wrapperType == juce::AudioProcessor::wrapperType_Standalone; +} + +int NinjamVst3AudioProcessor::getDisplayIntervalIndex() const +{ + const int absolute = intervalIndex.load(); + if (!isSyncToHostEnabled()) + return absolute; + if (!hostWasPlaying.load()) + return 0; + const int base = syncDisplayIntervalOffset.load(); + return juce::jmax(0, absolute - base); +} + +void NinjamVst3AudioProcessor::emitMidiTimecode(juce::MidiBuffer& midiMessages, int numSamples, int pos, int length) +{ + const double sampleRate = getSampleRate(); + if (sampleRate <= 1.0 || numSamples <= 0) + return; + + const bool mtcEnabled = isMtcOutputEnabled(); + const int fpsSetting = getMtcFrameRate(); + const double fps = fpsSetting == 2997 ? 29.97 : (double)fpsSetting; + const juce::uint8 rateCode = fpsSetting == 24 ? 0x00 : fpsSetting == 25 ? 0x01 : fpsSetting == 2997 ? 0x02 : 0x03; + + const bool waitingForStart = isSyncToHostEnabled() && (!hostWasPlaying.load() || syncWaitForInterval.load()); + const bool shouldRun = (length > 0) && !waitingForStart; + + auto sendLocate = [&midiMessages, rateCode](int sampleOffset, int hours, int minutes, int seconds, int frames) + { + const juce::uint8 hr = (juce::uint8)(((rateCode & 0x03u) << 5) | ((juce::uint8)hours & 0x1Fu)); + const juce::uint8 sysex[] = { 0xF0, 0x7F, 0x7F, 0x01, 0x01, + hr, + (juce::uint8)minutes, + (juce::uint8)seconds, + (juce::uint8)frames, + 0xF7 }; + midiMessages.addEvent(juce::MidiMessage::createSysExMessage(sysex, (int)sizeof(sysex)), sampleOffset); + }; + + auto getTimecode = [sampleRate, fps](long long timelineSamples) + { + if (timelineSamples < 0) + timelineSamples = 0; + const double seconds = (double)timelineSamples / sampleRate; + const long long totalFrames = (long long)std::floor(seconds * fps); + const int frame = (int)(totalFrames % (long long)std::round(fps)); + const long long totalSeconds = (long long)std::floor((double)totalFrames / fps); + const int second = (int)(totalSeconds % 60); + const int minute = (int)((totalSeconds / 60) % 60); + const int hour = (int)((totalSeconds / 3600) % 24); + return std::array { hour, minute, second, frame }; + }; + + if (!mtcEnabled) + { + if (mtcWasRunning) + { + midiMessages.addEvent(juce::MidiMessage::midiStop(), 0); + sendLocate(0, 0, 0, 0, 0); + } + mtcWasRunning = false; + mtcSamplesUntilNextQuarterFrame = 0.0; + mtcQuarterFramePiece = 0; + return; + } + + if (mtcWasRunning && !shouldRun) + { + midiMessages.addEvent(juce::MidiMessage::midiStop(), 0); + sendLocate(0, 0, 0, 0, 0); + mtcSamplesUntilNextQuarterFrame = 0.0; + mtcQuarterFramePiece = 0; + } + + int displayInterval = getDisplayIntervalIndex(); + int timelinePos = 0; + if (length > 0) + { + if (!waitingForStart) + timelinePos = juce::jlimit(0, juce::jmax(0, length - 1), pos); + } + long long blockStartSamples = (long long)displayInterval * (long long)juce::jmax(0, length) + (long long)timelinePos; + + if (!mtcWasRunning && shouldRun) + { + const auto tc = getTimecode(blockStartSamples); + sendLocate(0, tc[0], tc[1], tc[2], tc[3]); + midiMessages.addEvent(juce::MidiMessage::midiStart(), 0); + mtcSamplesUntilNextQuarterFrame = 0.0; + mtcQuarterFramePiece = 0; + } + + mtcWasRunning = shouldRun; + if (!shouldRun) + return; + + const double qfPerSecond = fps * 4.0; + const double samplesPerQuarterFrame = sampleRate / qfPerSecond; + double sampleCursor = mtcSamplesUntilNextQuarterFrame; + if (sampleCursor <= 0.0) + sampleCursor = samplesPerQuarterFrame; + + while (sampleCursor < (double)numSamples) + { + const int eventSample = juce::jlimit(0, numSamples - 1, (int)std::floor(sampleCursor)); + const long long eventTimelineSamples = blockStartSamples + (long long)eventSample; + const auto tc = getTimecode(eventTimelineSamples); + + const int piece = mtcQuarterFramePiece & 0x07; + int value = 0; + switch (piece) + { + case 0: value = tc[3] & 0x0F; break; + case 1: value = (tc[3] >> 4) & 0x01; break; + case 2: value = tc[2] & 0x0F; break; + case 3: value = (tc[2] >> 4) & 0x03; break; + case 4: value = tc[1] & 0x0F; break; + case 5: value = (tc[1] >> 4) & 0x03; break; + case 6: value = tc[0] & 0x0F; break; + case 7: value = ((tc[0] >> 4) & 0x01) | (0x03 << 1); break; + default: break; + } + + const juce::uint8 data = (juce::uint8)(((piece & 0x07) << 4) | (value & 0x0F)); + midiMessages.addEvent(juce::MidiMessage(0xF1, data), eventSample); + mtcQuarterFramePiece = (piece + 1) & 0x07; + sampleCursor += samplesPerQuarterFrame; + } + + mtcSamplesUntilNextQuarterFrame = sampleCursor - (double)numSamples; +} + +bool NinjamVst3AudioProcessor::hasEditor() const +{ + return true; +} + +juce::AudioProcessorEditor* NinjamVst3AudioProcessor::createEditor() +{ + return new NinjamVst3AudioProcessorEditor (*this); +} + +void NinjamVst3AudioProcessor::getStateInformation (juce::MemoryBlock& destData) +{ + juce::ValueTree state("NINJAM_STATE"); + state.setProperty("midiRelayTarget", getMidiRelayTarget(), nullptr); + state.setProperty("midiLearnStateJson", getMidiLearnStateJson(), nullptr); + state.setProperty("oscLearnStateJson", getOscLearnStateJson(), nullptr); + state.setProperty("midiLearnInputDeviceId", getMidiLearnInputDeviceId(), nullptr); + state.setProperty("midiRelayInputDeviceId", getMidiRelayInputDeviceId(), nullptr); + state.setProperty("fxReverbWetDryMix", (double)getFxReverbWetDryMix(), nullptr); + state.setProperty("fxDelayWetDryMix", (double)getFxDelayWetDryMix(), nullptr); + for (int channel = 0; channel < maxLocalChannels; ++channel) + state.setProperty("localInput" + juce::String(channel), getLocalChannelInput(channel), nullptr); + + if (auto xml = state.createXml()) + copyXmlToBinary(*xml, destData); +} + +void NinjamVst3AudioProcessor::setStateInformation (const void* data, int sizeInBytes) +{ + std::unique_ptr xmlState(getXmlFromBinary(data, sizeInBytes)); + if (xmlState == nullptr) + return; + + const juce::ValueTree state = juce::ValueTree::fromXml(*xmlState); + if (!state.isValid()) + return; + + setMidiRelayTarget(state.getProperty("midiRelayTarget", "*").toString()); + setMidiLearnStateJson(state.getProperty("midiLearnStateJson", "").toString()); + setOscLearnStateJson(state.getProperty("oscLearnStateJson", "").toString()); + setMidiLearnInputDeviceId(state.getProperty("midiLearnInputDeviceId", "").toString()); + setMidiRelayInputDeviceId(state.getProperty("midiRelayInputDeviceId", "").toString()); + setFxReverbWetDryMix((float)(double)state.getProperty("fxReverbWetDryMix", 1.0)); + setFxDelayWetDryMix((float)(double)state.getProperty("fxDelayWetDryMix", 1.0)); + for (int channel = 0; channel < maxLocalChannels; ++channel) + setLocalChannelInput(channel, (int)state.getProperty("localInput" + juce::String(channel), -1)); +} + +void NinjamVst3AudioProcessor::timerCallback() +{ + int loopCount = 0; + while (!ninjamClient.Run() && loopCount < 50) + { + loopCount++; + } + + int status = ninjamClient.GetStatus(); + if (status != lastStatus) + { + if (status == NJClient::NJC_STATUS_CANTCONNECT || status == NJClient::NJC_STATUS_INVALIDAUTH) + { + juce::String err = juce::String::fromUTF8(ninjamClient.GetErrorStr()); + juce::Logger::writeToLog("NINJAM Error (" + juce::String(status) + "): " + err); + } + else if (status == NJClient::NJC_STATUS_OK) + { + juce::Logger::writeToLog("NINJAM Connected Successfully"); + opusSyncServerSupported.store(false); + juce::Logger::writeToLog("Sending VIDEO_CAP 1"); + ninjamClient.ChatMessage_Send("VIDEO_CAP", "1", nullptr, nullptr, nullptr); + { + const juce::ScopedLock lock(opusSyncPeerLock); + opusSyncPeers.clear(); + } + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + lastAnnouncedRemoteIntervalByUser.clear(); + localIntervalStartMsByInterval.clear(); + pendingRemoteIntervalStartsByUser.clear(); + remoteTransportRttMsByUser.clear(); + pendingTransportProbeSentMsById.clear(); + remoteLatencyLastAppliedIntervalByUser.clear(); + remoteLatencyAverageByUser.clear(); + remoteLatencyFirmDelayMsByUser.clear(); + } + opusSyncAvailable.store(false); + opusSyncHasLegacyClients.store(false); + lastOpusSupportBroadcastMs = 0.0; + lastTransportProbeBroadcastMs = 0.0; + if (!isSyncToHostEnabled()) + { + syncWaitForInterval.store(false); + syncTargetInterval.store(-1); + intervalIndex.store(0); + lastIntervalPos.store(0); + } + lastBroadcastIntervalTag.store(-1); + setIntervalSyncStatusText({}); + syncLocalIntervalChannelConfig(); + } + else if (lastStatus == NJClient::NJC_STATUS_OK) + { + opusSyncServerSupported.store(false); + { + const juce::ScopedLock lock(opusSyncPeerLock); + opusSyncPeers.clear(); + } + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + lastAnnouncedRemoteIntervalByUser.clear(); + localIntervalStartMsByInterval.clear(); + pendingRemoteIntervalStartsByUser.clear(); + remoteTransportRttMsByUser.clear(); + pendingTransportProbeSentMsById.clear(); + remoteLatencyLastAppliedIntervalByUser.clear(); + remoteLatencyAverageByUser.clear(); + remoteLatencyFirmDelayMsByUser.clear(); + } + opusSyncAvailable.store(false); + opusSyncHasLegacyClients.store(false); + setIntervalSyncStatusText({}); + lastBroadcastIntervalTag.store(-1); + applyCodecPreference(); + } + lastStatus = status; + } + + if (status == NJClient::NJC_STATUS_OK) + { + refreshOpusSyncAvailabilityFromUsers(); + const double nowMs = juce::Time::getMillisecondCounterHiRes(); + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + const int displayInterval = getDisplayIntervalIndex(); + if (localIntervalStartMsByInterval.find(displayInterval) == localIntervalStartMsByInterval.end()) + localIntervalStartMsByInterval[displayInterval] = nowMs; + } + if (nowMs - lastOpusSupportBroadcastMs >= 1500.0) + { + broadcastOpusSyncSupport(); + lastOpusSupportBroadcastMs = nowMs; + } + if (nowMs - lastTransportProbeBroadcastMs >= 5000.0) + { + broadcastTransportProbe(); + lastTransportProbeBroadcastMs = nowMs; + } + + flushOutboundMidiRelayEvents(); + + const int displayInterval = getDisplayIntervalIndex(); + if (lastBroadcastIntervalTag.load() != displayInterval) + { + broadcastIntervalSyncTag(); + lastBroadcastIntervalTag.store(displayInterval); + } + } + + int pos = 0; + int length = 0; + ninjamClient.GetPosition(&pos, &length); + if (length > 0) + { + int last = lastIntervalPos.load(); + if (pos < last) + { + intervalIndex.fetch_add(1); + const int localAbsoluteInterval = intervalIndex.load(); + const int localDisplayInterval = getDisplayIntervalIndex(); + const long long localIntervalStartSampleCount = intervalSyncSampleCounter.load(std::memory_order_relaxed); + const double localIntervalStartMs = juce::Time::getMillisecondCounterHiRes(); + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + localIntervalStartMsByInterval[localDisplayInterval] = localIntervalStartMs; + const int minIntervalToKeep = localDisplayInterval - 64; + for (auto it = localIntervalStartMsByInterval.begin(); it != localIntervalStartMsByInterval.end();) + { + if (it->first < minIntervalToKeep) + it = localIntervalStartMsByInterval.erase(it); + else + ++it; + } + } + if (status == NJClient::NJC_STATUS_OK) + { + const int localBpi = juce::jmax(1, getBPI()); + const double localBpm = juce::jmax(1.0, (double)getBPM()); + const double intervalDurationMs = (60.0 / localBpm) * (double)localBpi * 1000.0; + for (;;) + { + juce::String senderKey; + PendingRemoteIntervalStart pending; + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + if (pendingRemoteIntervalStartsByUser.empty()) + break; + for (auto staleIt = pendingRemoteIntervalStartsByUser.begin(); staleIt != pendingRemoteIntervalStartsByUser.end();) + { + const int targetAbsolute = staleIt->second.remoteIntervalAbsolute; + const int targetDisplay = staleIt->second.remoteInterval; + bool isStale = false; + if (targetAbsolute >= 0) + isStale = localAbsoluteInterval > (targetAbsolute + 2); + else if (targetDisplay >= 0) + isStale = localDisplayInterval > (targetDisplay + 2); + else + isStale = true; + if (isStale) + staleIt = pendingRemoteIntervalStartsByUser.erase(staleIt); + else + ++staleIt; + } + auto chosenIt = pendingRemoteIntervalStartsByUser.end(); + for (auto it = pendingRemoteIntervalStartsByUser.begin(); it != pendingRemoteIntervalStartsByUser.end(); ++it) + { + const bool absoluteMatch = it->second.remoteIntervalAbsolute >= 0 && it->second.remoteIntervalAbsolute == localAbsoluteInterval; + const bool displayMatch = it->second.remoteIntervalAbsolute < 0 && it->second.remoteInterval >= 0 && it->second.remoteInterval == localDisplayInterval; + if (absoluteMatch || displayMatch) + { + chosenIt = it; + break; + } + } + if (chosenIt == pendingRemoteIntervalStartsByUser.end()) + break; + senderKey = chosenIt->first; + pending = chosenIt->second; + pendingRemoteIntervalStartsByUser.erase(chosenIt); + } + if (pending.receivedSampleCount < 0) + continue; + const long long elapsedSamples = localIntervalStartSampleCount - pending.receivedSampleCount; + if (elapsedSamples < 0) + continue; + const double sampleRate = juce::jmax(1.0, getSampleRate()); + const double elapsedToNextLocalBpi1Ms = ((double)elapsedSamples / sampleRate) * 1000.0; + const double outlierLimitMs = intervalDurationMs * 2.0; + if (!std::isfinite(elapsedToNextLocalBpi1Ms) || elapsedToNextLocalBpi1Ms < 0.0 || elapsedToNextLocalBpi1Ms > outlierLimitMs) + continue; + const int elapsedMs = (int)std::llround(juce::jlimit(0.0, intervalDurationMs, elapsedToNextLocalBpi1Ms)); + int averageMs = -1; + int firmAverageMs = -1; + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + auto& avgState = remoteLatencyAverageByUser[senderKey]; + avgState.lastMeasurementMs = (double)elapsedMs; + bool includeInAverage = true; + if (avgState.sampleCount >= 3) + { + const double baselineMs = avgState.firmAverageMs > 0.0 ? avgState.firmAverageMs : avgState.averageMs; + const double deltaMs = std::abs((double)elapsedMs - baselineMs); + const double spikeThresholdMs = juce::jlimit(5.0, 20.0, baselineMs * 0.30 + 2.0); + if (deltaMs > spikeThresholdMs) + includeInAverage = false; + } + if (includeInAverage) + { + avgState.sampleCount += 1; + avgState.sumMs += (double)elapsedMs; + avgState.averageMs = avgState.sumMs / (double)juce::jmax(1, avgState.sampleCount); + if (avgState.sampleCount == 1) + avgState.firmAverageMs = (double)elapsedMs; + else + avgState.firmAverageMs = (avgState.firmAverageMs * 0.88) + ((double)elapsedMs * 0.12); + } + if (avgState.sampleCount >= 3) + { + averageMs = juce::jmax(0, (int)std::llround(avgState.averageMs)); + firmAverageMs = juce::jmax(0, (int)std::llround(avgState.firmAverageMs)); + } + else if (avgState.lastMeasurementMs >= 0.0) + { + averageMs = juce::jmax(0, (int)std::llround(avgState.lastMeasurementMs)); + } + } + if (firmAverageMs >= 0 || averageMs >= 0) + { + // Subtract half RTT to compensate for network transit time + double halfRttMs = 0.0; + { + auto rttIt = remoteTransportRttMsByUser.find(senderKey); + if (rttIt != remoteTransportRttMsByUser.end() && rttIt->second > 0.0) + halfRttMs = rttIt->second * 0.5; + if (halfRttMs <= 0.0) + { + const juce::String csKey = canonicalDelayUserKey(senderKey); + if (csKey.isNotEmpty()) + { + auto canonicalRttIt = remoteTransportRttMsByUser.find(csKey); + if (canonicalRttIt != remoteTransportRttMsByUser.end() && canonicalRttIt->second > 0.0) + halfRttMs = canonicalRttIt->second * 0.5; + } + } + } + const double rawDelayMs = (double)(firmAverageMs >= 0 ? firmAverageMs : averageMs); + const int correctedDelayMs = juce::jmax(0, (int)std::llround(rawDelayMs - halfRttMs)); + const int sourceInterval = pending.remoteIntervalAbsolute >= 0 ? pending.remoteIntervalAbsolute : pending.remoteInterval; + const juce::String canonicalSenderKey = canonicalDelayUserKey(senderKey); + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + int priorAppliedInterval = std::numeric_limits::min(); + auto appliedIt = remoteLatencyLastAppliedIntervalByUser.find(senderKey); + if (appliedIt != remoteLatencyLastAppliedIntervalByUser.end()) + priorAppliedInterval = appliedIt->second; + bool shouldApply = (appliedIt == remoteLatencyLastAppliedIntervalByUser.end()); + auto currentDelayIt = remoteLatencyFirmDelayMsByUser.find(senderKey); + if (!shouldApply) + { + const int intervalDelta = sourceInterval - priorAppliedInterval; + const bool cadenceReached = intervalDelta >= remoteLatencyUpdateCadenceIntervals; + shouldApply = cadenceReached; + } + if (shouldApply) + { + remoteLatencyFirmDelayMsByUser[senderKey] = correctedDelayMs; + if (canonicalSenderKey.isNotEmpty()) + remoteLatencyFirmDelayMsByUser[canonicalSenderKey] = correctedDelayMs; + remoteLatencyLastAppliedIntervalByUser[senderKey] = sourceInterval; + if (canonicalSenderKey.isNotEmpty()) + remoteLatencyLastAppliedIntervalByUser[canonicalSenderKey] = sourceInterval; + vlogStr("[MCGuard] Applied firm delay for=" + senderKey + " canonical=" + canonicalSenderKey + " delayMs=" + juce::String(correctedDelayMs) + " rawMs=" + juce::String((int)std::llround(rawDelayMs)) + " halfRtt=" + juce::String((int)std::llround(halfRttMs)) + " sourceInterval=" + juce::String(sourceInterval) + " priorApplied=" + juce::String(priorAppliedInterval)); + } + } + const juce::String displaySender = pending.displaySender.isNotEmpty() ? pending.displaySender : senderKey; + // Look up halfRtt for display (may not be in scope from block above if firmAverageMs < 0) + double displayHalfRttMs = 0.0; + { + auto rttDisplayIt = remoteTransportRttMsByUser.find(senderKey); + if (rttDisplayIt != remoteTransportRttMsByUser.end() && rttDisplayIt->second > 0.0) + displayHalfRttMs = rttDisplayIt->second * 0.5; + if (displayHalfRttMs <= 0.0) + { + const juce::String csDisplayKey = canonicalDelayUserKey(senderKey); + if (csDisplayKey.isNotEmpty()) + { + auto cRttIt = remoteTransportRttMsByUser.find(csDisplayKey); + if (cRttIt != remoteTransportRttMsByUser.end() && cRttIt->second > 0.0) + displayHalfRttMs = cRttIt->second * 0.5; + } + } + } + juce::String line = displaySender + " BPI1->our BPI1 " + juce::String(elapsedMs) + "ms"; + if (firmAverageMs >= 0) + line << " avg " << juce::String(firmAverageMs) << "ms"; + else if (averageMs >= 0) + line << " avg " << juce::String(averageMs) << "ms"; + if (displayHalfRttMs > 0.0) + line << " rtt/2 " << juce::String((int)std::llround(displayHalfRttMs)) << "ms"; + juce::DynamicObject::Ptr reportObj = new juce::DynamicObject(); + reportObj->setProperty("line", line); + reportObj->setProperty("interval", pending.remoteIntervalAbsolute >= 0 ? pending.remoteIntervalAbsolute : pending.remoteInterval); + reportObj->setProperty("targetUserId", canonicalDelayUserKey(displaySender)); + reportObj->setProperty("elapsedMs", elapsedMs); + if (averageMs >= 0) + reportObj->setProperty("avgMs", averageMs); + if (firmAverageMs >= 0) + reportObj->setProperty("firmMs", firmAverageMs); + if (displayHalfRttMs > 0.0) + reportObj->setProperty("halfRttMs", (int)std::llround(displayHalfRttMs)); + reportObj->setProperty("eventId", "latencyReport:" + senderKey + ":" + juce::String(++sideSignalEventCounter)); + const juce::String reportPayload = juce::JSON::toString(juce::var(reportObj.get())); + sendIntervalSignal("intervalLatencyReport", reportPayload); + } + } + if (status == NJClient::NJC_STATUS_OK) + lastBroadcastIntervalTag.store(-1); + } + lastIntervalPos.store(pos); + if (status == NJClient::NJC_STATUS_OK) + writeIntervalHelperJson(pos, length); + } +} + +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new NinjamVst3AudioProcessor(); +} diff --git a/extras/ninjam-vst3/Source - Copy/PluginProcessor.h b/extras/ninjam-vst3/Source - Copy/PluginProcessor.h new file mode 100644 index 00000000..231542fd --- /dev/null +++ b/extras/ninjam-vst3/Source - Copy/PluginProcessor.h @@ -0,0 +1,423 @@ +#pragma once + +#include +#include + +// Disable min/max macros before including ninjam headers +#ifdef WIN32 +#define NOMINMAX +#endif + +#include "ninjam/njclient.h" + +class NinjamVst3AudioProcessor : public juce::AudioProcessor, + public juce::Timer +{ + friend class NinjamVst3AudioProcessorEditor; +public: + NinjamVst3AudioProcessor(); + ~NinjamVst3AudioProcessor() override; + + void prepareToPlay (double sampleRate, int samplesPerBlock) override; + void releaseResources() override; + + bool isBusesLayoutSupported (const BusesLayout& layouts) const override; + + void processBlock (juce::AudioBuffer&, juce::MidiBuffer&) override; + + juce::AudioProcessorEditor* createEditor() override; + bool hasEditor() const override; + + const juce::String getName() const override; + + bool acceptsMidi() const override; + bool producesMidi() const override; + bool isMidiEffect() const override; + double getTailLengthSeconds() const override; + + int getNumPrograms() override; + int getCurrentProgram() override; + void setCurrentProgram (int index) override; + const juce::String getProgramName (int index) override; + void changeProgramName (int index, const juce::String& newName) override; + + void getStateInformation (juce::MemoryBlock& destData) override; + void setStateInformation (const void* data, int sizeInBytes) override; + + // Timer callback for NINJAM client Run() + void timerCallback() override; + + NJClient& getClient() { return ninjamClient; } + + // NINJAM actions + void connectToServer(juce::String host, juce::String user, juce::String pass); + void disconnectFromServer(); + void sendChatMessage(juce::String msg); + + // Metronome + void setMetronomeVolume(float vol); + float getMetronomeVolume() const; + + // Local Channel + void setTransmitLocal(bool shouldTransmit); + bool isTransmittingLocal() const; + void setLocalBitrate(int bitrate); + int getLocalBitrate() const; + void setVoiceChatMode(bool enabled); + bool isVoiceChatMode() const; + + // Chat + juce::StringArray getChatMessages(); + void setAutoTranslateEnabled(bool shouldEnable); + bool isAutoTranslateEnabled() const; + void setTranslateTargetLang(const juce::String& langCode); + juce::String getTranslateTargetLang() const; + + struct PublicServerInfo { + juce::String host; + int port; + juce::String name; + int bpi; + float bpm; + int userCount; + int userMax; + }; + + std::vector getPublicServers() const; + void refreshPublicServers(); + + // User List + struct UserInfo { + int index; + juce::String name; + float volume; + float pan; + bool isMuted; + int outputChannel; // 0=Main, 2=Out2, etc. + int numChannels = 1; // number of active NINJAM channels for this user + bool isMultiChanPeer = false; // has more than 1 NINJAM channel + juce::StringArray channelNames; // name of each NINJAM channel (index 0..numChannels-1) + }; + std::vector getConnectedUsers(); + void setUserOutput(int userIndex, int outputChannelIndex); + void setUserLevel(int userIndex, float volume, float pan, bool isMuted, bool isSolo); + void setUserVolume(int userIndex, float volume); + float getUserPeak(int userIndex, int channelIndex); // 0=L, 1=R + float getUserChannelPeak(int userIndex, int njChanIdx, int lrSide); // per NINJAM channel L/R peak + void setUserNjChannelVolume(int userIndex, int njChanIdx, float volume); // individual NINJAM channel volume + + void setMasterOutputGain(float gain); + float getMasterOutputGain() const; + float getMasterPeak() const; + float getMasterPeakLeft() const; + float getMasterPeakRight() const; + void setSoftLimiterEnabled(bool shouldEnable); + bool isSoftLimiterEnabled() const; + void setUserClipEnabled(int userIndex, bool enabled); + bool isUserClipEnabled(int userIndex) const; + void setMasterLimiterEnabled(bool shouldEnable); + bool isMasterLimiterEnabled() const; + float getLimiterThreshold() const { return limiterThresholdDb.load(); } + float getLimiterRelease() const { return limiterReleaseMs.load(); } + void setLimiterThreshold(float db); + void setLimiterRelease(float ms); + void setLocalInputGain(float gain); + float getLocalInputGain() const; + static constexpr int maxLocalChannels = 8; + void setNumLocalChannels(int num); + int getNumLocalChannels() const; + void setLocalChannelName(int channel, const juce::String& name); + juce::String getLocalChannelName(int channel) const; + void setLocalChannelGain(int channel, float gain); + float getLocalChannelGain(int channel) const; + NinjamVst3AudioProcessorEditor* getEditor() const { return (NinjamVst3AudioProcessorEditor*)getActiveEditor(); } + void setLocalChannelInput(int channel, int inputIndex); + int getLocalChannelInput(int channel) const; + float getLocalChannelPeak(int channel) const; + float getLocalChannelPeakLeft(int channel) const; + float getLocalChannelPeakRight(int channel) const; + void setLocalMonitorEnabled(bool enabled); + bool isLocalMonitorEnabled() const; + void setFxReverbEnabled(bool enabled); + bool isFxReverbEnabled() const; + void setFxDelayEnabled(bool enabled); + bool isFxDelayEnabled() const; + void setFxReverbRoomSize(float roomSize); + float getFxReverbRoomSize() const; + void setFxReverbDamping(float damping); + float getFxReverbDamping() const; + void setFxReverbWetDryMix(float wetDryMix); + float getFxReverbWetDryMix() const; + void setFxReverbEarlyReflections(float earlyReflections); + float getFxReverbEarlyReflections() const; + void setFxReverbTail(float tail); + float getFxReverbTail() const; + void setFxDelayTimeMs(float timeMs); + float getFxDelayTimeMs() const; + void setFxDelaySyncToHost(bool enabled); + bool isFxDelaySyncToHost() const; + void setFxDelayDivision(int division); + int getFxDelayDivision() const; + void setFxDelayPingPong(bool enabled); + bool isFxDelayPingPong() const; + void setFxDelayWetDryMix(float wetDryMix); + float getFxDelayWetDryMix() const; + void setFxDelayFeedback(float feedback); + float getFxDelayFeedback() const; + void setLocalChannelReverbSend(int channel, float send); + float getLocalChannelReverbSend(int channel) const; + void setLocalChannelDelaySend(int channel, float send); + float getLocalChannelDelaySend(int channel) const; + + // NINJAM callbacks + static int LicenseAgreementCallback(void* userData, const char* licensetext); + static void ChatMessage_Callback(void* userData, NJClient* inst, const char** parms, int nparms); + static void IntervalMediaItem_Callback(void* userData, NJClient* inst, const char* username, int chidx, unsigned int fourcc, const unsigned char* guid, const void* data, int dataLen); + static void IntervalChunkCallback_cb(void* userData, NJClient* inst, const char* username, int chidx, unsigned int fourcc, const unsigned char* guid, const void* data, int dataLen, int flags); + static void NewIntervalCallback_cb(void* userData, NJClient* inst); + + // Interval / BPI + int getBPI(); + float getIntervalProgress(); + float getBPM(); + int getIntervalIndex() const; + int getCodecMode() const; + unsigned int getVorbisMask() const; + unsigned int getOpusMask() const; + + float getLocalPeak() const; + float getLocalPeakLeft() const; + float getLocalPeakRight() const; + + void sendSideSignal(const juce::String& target, const juce::String& type, const juce::String& payload); + void sendIntervalSignal(const juce::String& type, const juce::String& payload); + void processSyncSignal(const juce::String& sender, const juce::String& type, const juce::String& payload); + void launchVideoSession(); + + void rememberUserVolume(int userIndex, float volume, const juce::String& name); + + void setSpreadOutputsEnabled(bool shouldEnable); + bool isSpreadOutputsEnabled() const; + + void setSyncToHost(bool shouldSync); + bool isSyncToHostEnabled() const; + bool getHostPosition(juce::AudioPlayHead::CurrentPositionInfo& info) const; + void setMtcOutputEnabled(bool shouldEnable); + bool isMtcOutputEnabled() const; + void setMtcFrameRate(int fps); + int getMtcFrameRate() const; + bool isStandaloneInstance() const; + struct MidiControllerEvent + { + bool isController = false; + int midiChannel = 1; + int number = 0; + int value = 0; + float normalized = 0.0f; + bool isNoteOn = false; + }; + std::vector popPendingMidiControllerEvents(); + void setMidiRelayTarget(const juce::String& targetUser); + juce::String getMidiRelayTarget() const; + void setMidiLearnStateJson(const juce::String& json); + juce::String getMidiLearnStateJson() const; + void setOscLearnStateJson(const juce::String& json); + juce::String getOscLearnStateJson() const; + void setMidiLearnInputDeviceId(const juce::String& deviceId); + juce::String getMidiLearnInputDeviceId() const; + void setMidiRelayInputDeviceId(const juce::String& deviceId); + juce::String getMidiRelayInputDeviceId() const; + void enqueueExternalMidiControllerEvent(const MidiControllerEvent& event, bool forLearn, bool forRelay); + + bool isOpusSyncAvailable() const; + juce::String getIntervalSyncStatusText() const; + +private: + NJClient ninjamClient; + juce::CriticalSection processLock; + mutable juce::CriticalSection serverListLock; + std::vector publicServers; + + // Chat storage + juce::CriticalSection chatLock; + juce::StringArray chatHistory; + juce::StringArray chatSenders; // parallel: "me", username, or "" for system + bool autoTranslate = false; + juce::String translateTargetLang = "en"; + + // Local state + bool isTransmitting = false; + int localBitrate = 128; + bool voiceChatMode = false; + int lastStatus = 0; + + juce::AudioBuffer tempInputBuffer; + juce::AudioBuffer localChannelBuffer; + juce::AudioBuffer localMixBuffer; // 1-ch mix used by multiChanAuto Vorbis slot + std::atomic masterOutputGain { 1.0f }; + std::atomic localInputGain { 1.0f }; + std::atomic masterPeak { 0.0f }; + std::atomic masterPeakL { 0.0f }; + std::atomic masterPeakR { 0.0f }; + std::atomic localPeak { 0.0f }; + std::atomic localPeakL { 0.0f }; + std::atomic localPeakR { 0.0f }; + std::array, maxLocalChannels> localChannelGains; + std::array, maxLocalChannels> localChannelPeaks; + std::array, maxLocalChannels> localChannelPeaksL; + std::array, maxLocalChannels> localChannelPeaksR; + std::array, maxLocalChannels> localChannelInputs; + std::array, maxLocalChannels> localChannelReverbSends; + std::array, maxLocalChannels> localChannelDelaySends; + juce::CriticalSection localChannelNamesLock; + std::array localChannelNames; // user-defined channel names + std::atomic numLocalChannels { 1 }; + std::atomic localMonitorEnabled { true }; + std::atomic fxReverbEnabled { true }; + std::atomic fxDelayEnabled { true }; + std::atomic fxReverbRoomSize { 0.45f }; + std::atomic fxReverbDamping { 0.45f }; + std::atomic fxReverbWetDryMix { 1.0f }; + std::atomic fxReverbEarlyReflections { 0.25f }; + std::atomic fxReverbTail { 0.75f }; + std::atomic fxDelayTimeMs { 320.0f }; + std::atomic fxDelaySyncToHost { true }; + std::atomic fxDelayDivision { 8 }; + std::atomic fxDelayPingPong { false }; + std::atomic fxDelayWetDryMix { 1.0f }; + std::atomic fxDelayFeedback { 0.38f }; + juce::Reverb fxReverb; + juce::AudioBuffer fxDelayBuffer; + juce::AudioBuffer fxReverbInputBuffer; + juce::AudioBuffer fxDelayInputBuffer; + juce::AudioBuffer fxTransmitBuffer; + juce::AudioBuffer fxReturnBuffer; + int fxDelayWritePosition = 0; + double processingSampleRate = 44100.0; + std::atomic spreadOutputsEnabled { false }; + std::atomic softLimiterEnabled { true }; + std::atomic dspLimiterEnabled { false }; + std::atomic limiterThresholdDb { 0.0f }; + std::atomic limiterReleaseMs { 100.0f }; + juce::dsp::Limiter masterLimiter; + + std::map userClipEnabled; + std::map userPanOverrides; + std::map userOutputAssignment; + std::map userBaseVolume; + std::map userVolumeByName; + + bool syncToHost = false; + std::atomic hostWasPlaying { false }; + std::atomic syncWaitForInterval { false }; + std::atomic syncTargetInterval { -1 }; + std::atomic syncDisplayIntervalOffset { 0 }; + std::atomic syncDisplayPositionOffset { 0 }; + std::atomic mtcOutputEnabled { true }; + std::atomic mtcFrameRateFps { 30 }; + bool mtcWasRunning = false; + double mtcSamplesUntilNextQuarterFrame = 0.0; + int mtcQuarterFramePiece = 0; + mutable juce::CriticalSection transportLock; + juce::AudioPlayHead::CurrentPositionInfo lastHostPosition; + + std::atomic intervalIndex { 0 }; + std::atomic lastIntervalPos { 0 }; + std::atomic sideSignalEventCounter { 0 }; + juce::String currentServer; + juce::String currentUser; + juce::File videoHelperRootDir; + juce::File intervalJsonFile; + std::atomic videoHelperRunning { false }; + std::unique_ptr advancedVideoProcess; + std::map remoteLatencyFirmDelayMsByUser; + + std::atomic opusSyncAvailable { false }; + std::atomic opusSyncHasLegacyClients { false }; + std::atomic opusSyncServerSupported { false }; + mutable juce::CriticalSection intervalSyncStatusLock; + juce::String intervalSyncStatusText; + std::atomic lastBroadcastIntervalTag { -1 }; + juce::CriticalSection intervalSyncAnnouncementLock; + std::map lastAnnouncedRemoteIntervalByUser; + std::map localIntervalStartMsByInterval; + struct PendingRemoteIntervalStart + { + int remoteInterval = -1; + int remoteIntervalAbsolute = -1; + juce::String displaySender; + long long receivedSampleCount = -1; + }; + std::map pendingRemoteIntervalStartsByUser; + std::map remoteTransportRttMsByUser; + std::map pendingTransportProbeSentMsById; + std::map remoteLatencyLastAppliedIntervalByUser; + struct RemoteLatencyAverageState + { + int sampleCount = 0; + double sumMs = 0.0; + double averageMs = 0.0; + double firmAverageMs = 0.0; + double lastMeasurementMs = -1.0; + }; + std::map remoteLatencyAverageByUser; + juce::CriticalSection opusSyncPeerLock; + struct OpusSyncPeerState + { + juce::String userId; + bool supportsOpus = false; + bool multiChanEnabled = false; + int numChannels = 1; // number of local channels the peer is sending + juce::String appFamily; + int handshakeVersion = 0; + juce::String runtimeFormat; + juce::String pluginVersion; + double lastSeenMs = 0.0; + }; + std::map opusSyncPeers; + // Simple username→{isMultiChan, numChannels} snapshot updated by refreshOpusSyncAvailabilityFromUsers(). + // Keyed by normalised username (no @host, lowercase). Read without holding opusSyncPeerLock. + struct PeerMultiChanInfo { bool isMultiChan = false; int numChannels = 1; }; + std::map peerMultiChanByName; + juce::CriticalSection peerMultiChanLock; + juce::String opusSyncInstanceId; + double lastOpusSupportBroadcastMs = 0.0; + std::atomic transportProbeCounter { 0 }; + double lastTransportProbeBroadcastMs = 0.0; + std::atomic intervalSyncSampleCounter { 0 }; + juce::SpinLock midiEventQueueLock; + std::vector pendingMidiControllerEvents; + juce::SpinLock outboundMidiRelayQueueLock; + std::vector pendingOutboundMidiRelayEvents; + juce::SpinLock inboundMidiRelayQueueLock; + std::vector pendingInboundMidiRelayEvents; + mutable juce::CriticalSection midiRelayTargetLock; + juce::String midiRelayTarget { "*" }; + mutable juce::CriticalSection learnStateLock; + juce::String midiLearnStateJson; + juce::String oscLearnStateJson; + juce::String midiLearnInputDeviceId; + juce::String midiRelayInputDeviceId; + + juce::String translateText(const juce::String& text); + bool isStandaloneWrapper() const; + int getDisplayIntervalIndex() const; + void emitMidiTimecode(juce::MidiBuffer& midiMessages, int numSamples, int pos, int length); + void broadcastOpusSyncSupport(const juce::String& target = "*"); + void refreshOpusSyncAvailabilityFromUsers(); + void applyCodecPreference(); + void setIntervalSyncStatusText(const juce::String& text); + void broadcastIntervalSyncTag(const juce::String& target = "*"); + void broadcastTransportProbe(const juce::String& target = "*"); + juce::String buildIntervalSyncTag(int interval, int length) const; + juce::File resolveVideoHelperRootDir() const; + bool isAdvancedVideoClientAvailable() const; + bool ensureAdvancedVideoClientStarted(); + void stopAdvancedVideoClient(); + void writeIntervalHelperJson(int pos, int length); + void syncLocalIntervalChannelConfig(); + void flushOutboundMidiRelayEvents(); + void injectInboundMidiRelayEvents(juce::MidiBuffer& midiMessages); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (NinjamVst3AudioProcessor) +}; diff --git a/extras/ninjam-vst3/Source - Copy/ProVideoCodec.cpp b/extras/ninjam-vst3/Source - Copy/ProVideoCodec.cpp new file mode 100644 index 00000000..9f1cebd6 --- /dev/null +++ b/extras/ninjam-vst3/Source - Copy/ProVideoCodec.cpp @@ -0,0 +1,358 @@ +#include "ProVideoCodec.h" + +//============================================================================== +// Internal helpers +//============================================================================== +namespace +{ + //-------------------------------------------------------------------------- + // Hardware encoder try-list (CPU libx264 always last) + //-------------------------------------------------------------------------- + static const char* const kH264EncoderNames[] = { +#if defined(_WIN32) + "h264_nvenc", + "h264_amf", + "h264_qsv", +#elif defined(__APPLE__) + "h264_videotoolbox", + "h264_nvenc", +#else // Linux + "h264_nvenc", + "h264_vaapi", +#endif + "libx264", + nullptr + }; + +} // namespace + +//============================================================================== +// ProVideoEncoder +//============================================================================== +//============================================================================== +// Annex-B NALU helpers (used by the encoder to cache and re-inject SPS+PPS) + +/// Returns the byte offset of the first IDR-slice start code (00 00 00 01 65) +/// within an Annex-B byte stream, or -1 if not found. +static int findFirstIdrOffset(const uint8_t* data, int len) +{ + for (int i = 0; i + 4 < len; ++i) + { + if (data[i] == 0 && data[i+1] == 0 && data[i+2] == 0 && data[i+3] == 1 + && (data[i+4] & 0x1f) == 5) + return i; + } + return -1; +} + +/// Returns true if the Annex-B stream begins with an SPS NALU (type 7). +static bool startsWithSps(const uint8_t* data, int len) +{ + if (len >= 5 && data[0]==0 && data[1]==0 && data[2]==0 && data[3]==1 && (data[4]&0x1f)==7) + return true; + if (len >= 4 && data[0]==0 && data[1]==0 && data[2]==1 && (data[3]&0x1f)==7) + return true; + return false; +} + +//============================================================================== +bool ProVideoEncoder::tryOpenEncoder(const char* codecName, int width, int height, int fps, int bitrate) +{ + const AVCodec* codec = avcodec_find_encoder_by_name(codecName); + if (codec == nullptr) + return false; + + AVCodecContext* ctx = avcodec_alloc_context3(codec); + if (ctx == nullptr) + return false; + + ctx->width = width; + ctx->height = height; + ctx->pix_fmt = AV_PIX_FMT_YUV420P; + ctx->time_base = { 1, fps }; + ctx->framerate = { fps, 1 }; + ctx->bit_rate = bitrate; + ctx->gop_size = 1; // Every frame is an IDR keyframe — each NINJAM interval is independent. + ctx->max_b_frames = 0; + + // Tune — errors from av_opt_set are non-fatal + av_opt_set(ctx->priv_data, "preset", "ultrafast", AV_OPT_SEARCH_CHILDREN); + av_opt_set(ctx->priv_data, "tune", "zerolatency", AV_OPT_SEARCH_CHILDREN); + // Embed SPS+PPS in every IDR frame so late-joining decoders can start anywhere. + av_opt_set_int(ctx->priv_data, "repeat-headers", 1, 0); // nvenc/amf/qsv + av_opt_set(ctx->priv_data, "x264-params", "repeat-headers=1", 0); // libx264 + + if (avcodec_open2(ctx, codec, nullptr) < 0) + { + avcodec_free_context(&ctx); + return false; + } + + AVFrame* f = av_frame_alloc(); + if (f == nullptr) + { + avcodec_free_context(&ctx); + return false; + } + f->format = ctx->pix_fmt; + f->width = width; + f->height = height; + if (av_frame_get_buffer(f, 32) < 0) + { + av_frame_free(&f); + avcodec_free_context(&ctx); + return false; + } + + SwsContext* sws = sws_getContext( + width, height, AV_PIX_FMT_RGB24, + width, height, AV_PIX_FMT_YUV420P, + SWS_BICUBIC, nullptr, nullptr, nullptr); + if (sws == nullptr) + { + av_frame_free(&f); + avcodec_free_context(&ctx); + return false; + } + + codecCtx = ctx; + frame = f; + swsCtx = sws; + openWidth = width; + openHeight = height; + pts = 0; + DBG("ProVideoEncoder: opened " << juce::String(codecName) << " " << width << "x" << height); + return true; +} + +bool ProVideoEncoder::open(int width, int height, int fps, int bitrate) +{ + if (isOpen() && openWidth == width && openHeight == height) + return true; + + close(); + + for (int i = 0; kH264EncoderNames[i] != nullptr; ++i) + { + if (tryOpenEncoder(kH264EncoderNames[i], width, height, fps, bitrate)) + return true; + } + DBG("ProVideoEncoder: all encoders failed for " << width << "x" << height); + return false; +} + +bool ProVideoEncoder::encodeFrame(const juce::Image& img, juce::MemoryBlock& outData) +{ + if (codecCtx == nullptr || frame == nullptr || swsCtx == nullptr) + return false; + + const juce::Image rgb = img.convertedToFormat(juce::Image::RGB); + juce::Image::BitmapData bd(rgb, juce::Image::BitmapData::readOnly); + + const int srcStride[1] = { bd.lineStride }; + const uint8_t* src[1] = { bd.getLinePointer(0) }; + + if (av_frame_make_writable(frame) < 0) + return false; + + sws_scale(swsCtx, src, srcStride, 0, img.getHeight(), + frame->data, frame->linesize); + + frame->pts = pts++; + + if (avcodec_send_frame(codecCtx, frame) < 0) + return false; + + bool gotData = false; + for (;;) + { + AVPacket* pkt = av_packet_alloc(); + if (pkt == nullptr) + break; + + const int ret = avcodec_receive_packet(codecCtx, pkt); + if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) + { + av_packet_free(&pkt); + break; + } + if (ret < 0) + { + av_packet_free(&pkt); + return false; + } + + // Cache the SPS+PPS block from the first packet (everything before the + // IDR slice). Prepend it to every subsequent IDR-only packet so that + // any decoder — including one that joins after the first packet was sent + // — can always decode the frame independently. + const uint8_t* pktData = pkt->data; + const int pktSize = pkt->size; + if (cachedSpsAndPps.isEmpty()) + { + const int idrOff = findFirstIdrOffset(pktData, pktSize); + if (idrOff > 0) + cachedSpsAndPps.append(pktData, (size_t) idrOff); + } + if (!cachedSpsAndPps.isEmpty() && !startsWithSps(pktData, pktSize)) + outData.append(cachedSpsAndPps.getData(), cachedSpsAndPps.getSize()); + outData.append(pktData, static_cast(pktSize)); + gotData = true; + av_packet_free(&pkt); + } + + return gotData; +} + +void ProVideoEncoder::closeInternal() +{ + cachedSpsAndPps.reset(); // clear cached headers so the next open starts fresh + if (swsCtx) { sws_freeContext(swsCtx); swsCtx = nullptr; } + if (frame) { av_frame_free(&frame); frame = nullptr; } + if (codecCtx) { avcodec_free_context(&codecCtx); codecCtx = nullptr; } + openWidth = 0; + openHeight = 0; + pts = 0; +} + +void ProVideoEncoder::close() +{ + closeInternal(); +} + +//============================================================================== +// ProVideoDecoder — stateful per-user H.264 decoder +// +// The encoder produces raw Annex-B H.264. With gop_size=1 the first packet +// contains SPS+PPS+IDR; subsequent packets contain IDR only (libx264 only +// emits SPS/PPS once unless repeat-headers is set). A fresh AVCodecContext +// per call would lose SPS/PPS context for packets 1+, so we keep the context +// alive for the lifetime of the decoder instance. +// +// We skip the AVCodecParser entirely: each packet we receive is already a +// complete Annex-B bitstream assembled by the encoder loop, so we pass it +// directly to avcodec_send_packet. +//============================================================================== + +bool ProVideoDecoder::ensureOpen() +{ + if (codecCtx != nullptr) + return true; + + const AVCodec* codec = avcodec_find_decoder(AV_CODEC_ID_H264); + if (codec == nullptr) + { + DBG("ProVideoDecoder: H.264 decoder not found"); + return false; + } + + codecCtx = avcodec_alloc_context3(codec); + if (codecCtx == nullptr) + return false; + + // Minimise latency — we don't use B-frames. + codecCtx->flags |= AV_CODEC_FLAG_LOW_DELAY; + + if (avcodec_open2(codecCtx, codec, nullptr) < 0) + { + avcodec_free_context(&codecCtx); + DBG("ProVideoDecoder: avcodec_open2 failed"); + return false; + } + + avFrame = av_frame_alloc(); + if (avFrame == nullptr) + { + avcodec_free_context(&codecCtx); + return false; + } + + return true; +} + +void ProVideoDecoder::close() +{ + if (swsCtx != nullptr) + { + sws_freeContext(swsCtx); + swsCtx = nullptr; + swsW = 0; + swsH = 0; + swsFmt = AV_PIX_FMT_NONE; + } + if (avFrame != nullptr) { av_frame_free(&avFrame); avFrame = nullptr; } + if (codecCtx != nullptr) { avcodec_free_context(&codecCtx); codecCtx = nullptr; } +} + +bool ProVideoDecoder::decode(const void* data, int dataLen, juce::Image& outImage) +{ + if (data == nullptr || dataLen <= 0) + return false; + + if (!ensureOpen()) + return false; + + AVPacket* pkt = av_packet_alloc(); + if (pkt == nullptr) + return false; + + // Pass the full Annex-B bitstream as one packet. The H.264 decoder + // handles embedded start codes (00 00 00 01 ...) natively. + pkt->data = const_cast(static_cast(data)); + pkt->size = dataLen; + pkt->flags = AV_PKT_FLAG_KEY; + + juce::Image result; + + if (avcodec_send_packet(codecCtx, pkt) == 0) + { + const int ret = avcodec_receive_frame(codecCtx, avFrame); + if (ret == 0) + { + const int w = avFrame->width; + const int h = avFrame->height; + const auto fmt = static_cast(avFrame->format); + + // Reuse the sws context if the frame geometry hasn't changed. + if (swsCtx == nullptr || swsW != w || swsH != h || swsFmt != fmt) + { + if (swsCtx != nullptr) + sws_freeContext(swsCtx); + swsCtx = sws_getContext(w, h, fmt, + w, h, AV_PIX_FMT_RGB24, + SWS_BICUBIC, nullptr, nullptr, nullptr); + swsW = w; + swsH = h; + swsFmt = fmt; + } + + if (swsCtx != nullptr) + { + juce::Image img(juce::Image::RGB, w, h, false); + { + juce::Image::BitmapData bd(img, juce::Image::BitmapData::writeOnly); + uint8_t* dstSlice[1] = { bd.getLinePointer(0) }; + const int dstStride[1] = { bd.lineStride }; + sws_scale(swsCtx, avFrame->data, avFrame->linesize, + 0, h, dstSlice, dstStride); + } + result = img; + } + av_frame_unref(avFrame); + } + } + + // Null out data so av_packet_free doesn't try to unref a non-owned buffer. + pkt->data = nullptr; + pkt->size = 0; + av_packet_free(&pkt); + + if (!result.isValid()) + { + DBG("ProVideoDecoder: decode failed for " << dataLen << " bytes"); + return false; + } + + outImage = std::move(result); + return true; +} diff --git a/extras/ninjam-vst3/Source - Copy/ProVideoCodec.h b/extras/ninjam-vst3/Source - Copy/ProVideoCodec.h new file mode 100644 index 00000000..6c3a39de --- /dev/null +++ b/extras/ninjam-vst3/Source - Copy/ProVideoCodec.h @@ -0,0 +1,90 @@ +#pragma once +#include +#include + +extern "C" +{ +#include +#include +#include +#include +#include +#include +} + +//============================================================================== +/** Encodes juce::Image frames to H.264 NAL byte-streams using FFmpeg. + Call open() once before the first encodeFrame(), close() when done. + Every frame is emitted as an IDR keyframe (gop_size=1) so that each + NINJAM interval fragment is independently decodeable. */ +class ProVideoEncoder +{ +public: + ProVideoEncoder() = default; + ~ProVideoEncoder() { close(); } + + /** Open (or re-open) the encoder for the given dimensions and frame rate. + Hardware encoders are tried in order: h264_nvenc → h264_amf → h264_qsv → + h264_videotoolbox → libx264. Returns true if a suitable encoder was found. */ + bool open(int width, int height, int fps, int bitrate); + + /** Encode one frame. On success returns true and appends the raw H.264 + NAL bytes (without any container) to @p outData. */ + bool encodeFrame(const juce::Image& img, juce::MemoryBlock& outData); + + /** Release all FFmpeg resources. Safe to call multiple times. */ + void close(); + + bool isOpen() const noexcept { return codecCtx != nullptr; } + int getWidth() const noexcept { return openWidth; } + int getHeight() const noexcept { return openHeight; } + +private: + AVCodecContext* codecCtx = nullptr; + AVFrame* frame = nullptr; + SwsContext* swsCtx = nullptr; + int64_t pts = 0; + int openWidth = 0; + int openHeight = 0; + + bool tryOpenEncoder(const char* codecName, int width, int height, int fps, int bitrate); + void closeInternal(); + + // SPS+PPS cached from the first encoded packet and prepended to every + // subsequent IDR-only packet so late-joining decoders can start anywhere. + juce::MemoryBlock cachedSpsAndPps; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ProVideoEncoder) +}; + +//============================================================================== +/** Decodes H.264 NAL byte-streams to juce::Image frames using FFmpeg. + Stateful — the codec context is kept open across calls so that SPS/PPS + information from earlier packets (frame 0) is available when decoding + subsequent IDR-only packets (frames 1+). Create one instance per remote + user and reuse it for the lifetime of the session. */ +class ProVideoDecoder +{ +public: + ProVideoDecoder() = default; + ~ProVideoDecoder() { close(); } + + /** Decode @p dataLen bytes of raw Annex-B H.264 data into @p outImage (RGB). + Returns true on success. Lazily opens the codec context on the first call. */ + bool decode(const void* data, int dataLen, juce::Image& outImage); + + /** Release all FFmpeg resources. Safe to call multiple times. */ + void close(); + +private: + AVCodecContext* codecCtx = nullptr; + AVFrame* avFrame = nullptr; + SwsContext* swsCtx = nullptr; + int swsW = 0; + int swsH = 0; + AVPixelFormat swsFmt = AV_PIX_FMT_NONE; + + bool ensureOpen(); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ProVideoDecoder) +}; diff --git a/extras/ninjam-vst3/Source - Copy/StandaloneApp.cpp b/extras/ninjam-vst3/Source - Copy/StandaloneApp.cpp new file mode 100644 index 00000000..e3247fc6 --- /dev/null +++ b/extras/ninjam-vst3/Source - Copy/StandaloneApp.cpp @@ -0,0 +1,461 @@ +#include +#include +#include "PluginProcessor.h" + +#if JucePlugin_Build_Standalone + +namespace juce +{ + +class NinjamStandalonePluginHolder : public StandalonePluginHolder +{ +public: + using StandalonePluginHolder::StandalonePluginHolder; +}; + +class NinjamAudioSettingsComponent : public Component +{ +public: + NinjamAudioSettingsComponent (NinjamStandalonePluginHolder& pluginHolder, + AudioDeviceManager& deviceManagerToUse, + int maxAudioInputChannels, + int maxAudioOutputChannels) + : owner (pluginHolder), + deviceSelector (deviceManagerToUse, + 0, maxAudioInputChannels, + 0, maxAudioOutputChannels, + true, + (pluginHolder.processor.get() != nullptr && pluginHolder.processor->producesMidi()), + true, false), + shouldMuteLabel ("Feedback Loop:", "Feedback Loop:"), + shouldMuteButton ("Mute audio input") + { + setOpaque (true); + + shouldMuteButton.setClickingTogglesState (true); + shouldMuteButton.getToggleStateValue().referTo (owner.shouldMuteInput); + + addAndMakeVisible (deviceSelector); + + if (owner.getProcessorHasPotentialFeedbackLoop()) + { + addAndMakeVisible (shouldMuteButton); + addAndMakeVisible (shouldMuteLabel); + shouldMuteLabel.attachToComponent (&shouldMuteButton, true); + } + + processor = dynamic_cast (owner.processor.get()); + + if (processor != nullptr) + { + addAndMakeVisible (mtcOutLabel); + mtcOutLabel.setText ("MTC Out:", dontSendNotification); + mtcOutLabel.attachToComponent (&mtcToggle, true); + + addAndMakeVisible (mtcToggle); + mtcToggle.setClickingTogglesState (true); + mtcToggle.setButtonText ("Enable"); + mtcToggle.setToggleState (processor->isMtcOutputEnabled(), dontSendNotification); + mtcToggle.onClick = [this] + { + if (processor != nullptr) + processor->setMtcOutputEnabled (mtcToggle.getToggleState()); + }; + + addAndMakeVisible (frameRateLabel); + frameRateLabel.setText ("MTC Frame Rate:", dontSendNotification); + frameRateLabel.attachToComponent (&frameRateBox, true); + + addAndMakeVisible (frameRateBox); + frameRateBox.addItem ("24 fps", 1); + frameRateBox.addItem ("25 fps", 2); + frameRateBox.addItem ("29.97 df", 3); + frameRateBox.addItem ("30 fps", 4); + + const int currentRate = processor->getMtcFrameRate(); + int selectedId = 4; + if (currentRate == 24) selectedId = 1; + else if (currentRate == 25) selectedId = 2; + else if (currentRate == 2997) selectedId = 3; + frameRateBox.setSelectedId (selectedId, dontSendNotification); + + frameRateBox.onChange = [this] + { + if (processor == nullptr) + return; + + const int id = frameRateBox.getSelectedId(); + int rate = 30; + if (id == 1) rate = 24; + else if (id == 2) rate = 25; + else if (id == 3) rate = 2997; + processor->setMtcFrameRate (rate); + }; + } + } + + void paint (Graphics& g) override + { + g.fillAll (getLookAndFeel().findColour (ResizableWindow::backgroundColourId)); + } + + void resized() override + { + const ScopedValueSetter scope (isResizing, true); + + auto r = getLocalBounds().reduced (10); + const auto itemHeight = deviceSelector.getItemHeight(); + const auto separatorHeight = (itemHeight >> 1); + auto makeControlBounds = [separatorHeight, itemHeight] (Rectangle row) + { + const auto controlX = row.getX() + roundToInt (row.getWidth() * 0.35f); + const auto controlW = roundToInt (row.getWidth() * 0.60f); + const auto controlY = row.getY() + separatorHeight; + return Rectangle (controlX, controlY, controlW, itemHeight); + }; + + if (owner.getProcessorHasPotentialFeedbackLoop()) + { + auto feedbackRow = r.removeFromTop (itemHeight); + shouldMuteButton.setBounds (makeControlBounds (feedbackRow)); + r.removeFromTop (separatorHeight); + } + + Rectangle mtcArea; + if (processor != nullptr) + mtcArea = r.removeFromBottom ((itemHeight + separatorHeight) * 2); + + deviceSelector.setBounds (r); + + if (processor != nullptr) + { + auto mtcToggleRow = mtcArea.removeFromTop (itemHeight); + mtcToggle.setBounds (makeControlBounds (mtcToggleRow)); + mtcArea.removeFromTop (separatorHeight); + + auto frameRateRow = mtcArea.removeFromTop (itemHeight); + frameRateBox.setBounds (makeControlBounds (frameRateRow)); + } + } + + void childBoundsChanged (Component* childComp) override + { + if (! isResizing && childComp == &deviceSelector) + setToRecommendedSize(); + } + + void setToRecommendedSize() + { + int extraHeight = 0; + + if (owner.getProcessorHasPotentialFeedbackLoop()) + { + const auto itemHeight = deviceSelector.getItemHeight(); + const auto separatorHeight = (itemHeight >> 1); + extraHeight += itemHeight + separatorHeight; + } + + if (processor != nullptr) + { + const auto itemHeight = deviceSelector.getItemHeight(); + const auto separatorHeight = (itemHeight >> 1); + extraHeight += (itemHeight + separatorHeight) * 2; + } + + setSize (getWidth(), deviceSelector.getHeight() + extraHeight + 20); + } + +private: + NinjamStandalonePluginHolder& owner; + AudioDeviceSelectorComponent deviceSelector; + Label shouldMuteLabel; + ToggleButton shouldMuteButton; + Label mtcOutLabel; + ToggleButton mtcToggle; + Label frameRateLabel; + ComboBox frameRateBox; + NinjamVst3AudioProcessor* processor = nullptr; + bool isResizing = false; +}; + +class NinjamStandaloneFilterWindow : public DocumentWindow, + private Button::Listener +{ +public: + using PluginInOuts = StandalonePluginHolder::PluginInOuts; + + NinjamStandaloneFilterWindow (const String& title, + Colour backgroundColour, + PropertySet* settingsToUse, + bool takeOwnershipOfSettings, + const String& preferredDefaultDeviceName = String(), + const AudioDeviceManager::AudioDeviceSetup* preferredSetupOptions = nullptr, + const Array& constrainToConfiguration = {}, + bool autoOpenMidiDevices = false) + : DocumentWindow (title, backgroundColour, DocumentWindow::minimiseButton | DocumentWindow::closeButton), + optionsButton ("Options") + { + pluginHolder = std::make_unique (settingsToUse, + takeOwnershipOfSettings, + preferredDefaultDeviceName, + preferredSetupOptions, + constrainToConfiguration, + autoOpenMidiDevices); + + #if JUCE_IOS || JUCE_ANDROID + setTitleBarHeight (0); + #else + setTitleBarButtonsRequired (DocumentWindow::minimiseButton | DocumentWindow::closeButton, false); + Component::addAndMakeVisible (optionsButton); + optionsButton.addListener (this); + optionsButton.setTriggeredOnMouseDown (true); + #endif + + updateContent(); + centreWithSize (getWidth(), getHeight()); + setVisible (true); + } + + AudioProcessor* getAudioProcessor() const + { + return pluginHolder != nullptr ? pluginHolder->processor.get() : nullptr; + } + + NinjamStandalonePluginHolder* getPluginHolder() const + { + return pluginHolder.get(); + } + + void closeButtonPressed() override + { + if (pluginHolder != nullptr) + pluginHolder->savePluginState(); + + JUCEApplicationBase::quit(); + } + + void resized() override + { + DocumentWindow::resized(); + optionsButton.setBounds (8, 6, 60, getTitleBarHeight() - 8); + } + +private: + class MainContentComponent : public Component, + private ComponentListener + { + public: + explicit MainContentComponent (NinjamStandaloneFilterWindow& filterWindow) + : owner (filterWindow) + { + if (auto* processor = owner.getAudioProcessor()) + editor.reset (processor->hasEditor() ? processor->createEditorIfNeeded() + : new GenericAudioProcessorEditor (*processor)); + + if (editor != nullptr) + { + editor->addComponentListener (this); + addAndMakeVisible (editor.get()); + handleMovedOrResized(); + } + } + + ~MainContentComponent() override + { + if (editor != nullptr) + { + editor->removeComponentListener (this); + if (owner.getPluginHolder() != nullptr && owner.getPluginHolder()->processor != nullptr) + owner.getPluginHolder()->processor->editorBeingDeleted (editor.get()); + editor = nullptr; + } + } + + void resized() override + { + if (editor != nullptr) + editor->setTopLeftPosition (0, 0); + } + + private: + void componentMovedOrResized (Component&, bool, bool) override + { + handleMovedOrResized(); + } + + void handleMovedOrResized() + { + if (editor == nullptr) + return; + + const auto rect = editor->getLocalArea (this, editor->getLocalBounds()); + setSize (rect.getWidth(), rect.getHeight()); + } + + NinjamStandaloneFilterWindow& owner; + std::unique_ptr editor; + }; + + void showAudioSettingsDialog() + { + if (pluginHolder == nullptr || pluginHolder->processor == nullptr) + return; + + DialogWindow::LaunchOptions o; + + int maxNumInputs = jmax (0, pluginHolder->getNumInputChannels()); + int maxNumOutputs = jmax (0, pluginHolder->getNumOutputChannels()); + + if (auto* bus = pluginHolder->processor->getBus (true, 0)) + maxNumInputs = jmax (0, bus->getDefaultLayout().size()); + + if (auto* bus = pluginHolder->processor->getBus (false, 0)) + maxNumOutputs = jmax (0, bus->getDefaultLayout().size()); + + auto content = std::make_unique (*pluginHolder, + pluginHolder->deviceManager, + maxNumInputs, + maxNumOutputs); + content->setSize (520, 620); + content->setToRecommendedSize(); + + o.content.setOwned (content.release()); + o.dialogTitle = TRANS ("Audio/MIDI Settings"); + o.dialogBackgroundColour = o.content->getLookAndFeel().findColour (ResizableWindow::backgroundColourId); + o.escapeKeyTriggersCloseButton = true; + o.useNativeTitleBar = true; + o.resizable = false; + o.launchAsync(); + } + + void resetToDefaultState() + { + if (pluginHolder == nullptr) + return; + + pluginHolder->stopPlaying(); + clearContentComponent(); + pluginHolder->deletePlugin(); + + if (auto* props = pluginHolder->settings.get()) + props->removeValue ("filterState"); + + pluginHolder->createPlugin(); + updateContent(); + pluginHolder->startPlaying(); + } + + void updateContent() + { + setContentOwned (new MainContentComponent (*this), true); + } + + void handleMenuResult (int result) + { + if (pluginHolder == nullptr) + return; + + switch (result) + { + case 1: showAudioSettingsDialog(); break; + case 2: pluginHolder->askUserToSaveState(); break; + case 3: pluginHolder->askUserToLoadState(); break; + case 4: resetToDefaultState(); break; + default: break; + } + } + + static void menuCallback (int result, NinjamStandaloneFilterWindow* button) + { + if (button != nullptr && result != 0) + button->handleMenuResult (result); + } + + void buttonClicked (Button*) override + { + PopupMenu m; + m.addItem (1, TRANS ("Audio/MIDI Settings...")); + m.addSeparator(); + m.addItem (2, TRANS ("Save current state...")); + m.addItem (3, TRANS ("Load a saved state...")); + m.addSeparator(); + m.addItem (4, TRANS ("Reset to default state")); + + m.showMenuAsync (PopupMenu::Options(), + ModalCallbackFunction::forComponent (menuCallback, this)); + } + + TextButton optionsButton; + std::unique_ptr pluginHolder; +}; + +class NinjamStandaloneApp final : public JUCEApplication +{ +public: + const String getApplicationName() override { return JucePlugin_Name; } + const String getApplicationVersion() override { return JucePlugin_VersionString; } + bool moreThanOneInstanceAllowed() override { return true; } + void anotherInstanceStarted (const String&) override {} + + void initialise (const String&) override + { + PropertiesFile::Options options; + options.applicationName = JucePlugin_Name; + options.filenameSuffix = ".settings"; + options.osxLibrarySubFolder = "Application Support"; + #if JUCE_LINUX || JUCE_BSD + options.folderName = "~/.config"; + #else + options.folderName = ""; + #endif + + appProperties.setStorageParameters (options); + mainWindow.reset (createWindow()); + } + + void shutdown() override + { + mainWindow = nullptr; + } + + void systemRequestedQuit() override + { + quit(); + } + +private: + NinjamStandaloneFilterWindow* createWindow() + { + #ifdef JucePlugin_PreferredChannelConfigurations + StandalonePluginHolder::PluginInOuts channels[] = { JucePlugin_PreferredChannelConfigurations }; + #endif + + return new NinjamStandaloneFilterWindow (getApplicationName(), + LookAndFeel::getDefaultLookAndFeel().findColour (ResizableWindow::backgroundColourId), + appProperties.getUserSettings(), + false, + {}, + nullptr + #ifdef JucePlugin_PreferredChannelConfigurations + , Array (channels, numElementsInArray (channels)) + #else + , {} + #endif + #if JUCE_DONT_AUTO_OPEN_MIDI_DEVICES_ON_MOBILE + , false + #endif + ); + } + + ApplicationProperties appProperties; + std::unique_ptr mainWindow; +}; + +} // namespace juce + +juce::JUCEApplicationBase* juce_CreateApplication() +{ + return new juce::NinjamStandaloneApp(); +} + +#endif diff --git a/extras/ninjam-vst3/Source/PluginEditor.cpp b/extras/ninjam-vst3/Source/PluginEditor.cpp new file mode 100644 index 00000000..71cbe260 --- /dev/null +++ b/extras/ninjam-vst3/Source/PluginEditor.cpp @@ -0,0 +1,4352 @@ +#include "PluginProcessor.h" +#include "PluginEditor.h" + +static juce::String normaliseColourPresetName(const juce::String& name); +static juce::Colour colourFromPresetName(const juce::String& preset, const juce::Colour& fallback); + +#if JUCE_WINDOWS +#include +#include +#include +#pragma comment(lib, "mfplat.lib") +#pragma comment(lib, "mfreadwrite.lib") +#pragma comment(lib, "mfuuid.lib") + +/** Decodes video frames on a background thread using Windows Media Foundation. + The main thread calls getLatestFrame() — it returns instantly (no blocking). */ +struct WinVideoReader : public juce::Thread +{ + WinVideoReader() : juce::Thread ("BgVideoDecoder") {} + + ~WinVideoReader() override + { + signalThreadShouldExit(); + stopThread (3000); + if (reader != nullptr) { reader->Release(); reader = nullptr; } + if (mfStarted) { MFShutdown(); mfStarted = false; } + } + + bool open (const juce::File& file) + { + if (FAILED (MFStartup (MF_VERSION))) return false; + mfStarted = true; + + IMFAttributes* attrs = nullptr; + MFCreateAttributes (&attrs, 1); + attrs->SetUINT32 (MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE); + HRESULT hr = MFCreateSourceReaderFromURL ( + file.getFullPathName().toWideCharPointer(), attrs, &reader); + attrs->Release(); + if (FAILED (hr) || reader == nullptr) return false; + + IMFMediaType* type = nullptr; + MFCreateMediaType (&type); + type->SetGUID (MF_MT_MAJOR_TYPE, MFMediaType_Video); + type->SetGUID (MF_MT_SUBTYPE, MFVideoFormat_RGB32); + reader->SetCurrentMediaType ((DWORD) MF_SOURCE_READER_FIRST_VIDEO_STREAM, nullptr, type); + type->Release(); + + IMFMediaType* outType = nullptr; + if (SUCCEEDED (reader->GetCurrentMediaType ( + (DWORD) MF_SOURCE_READER_FIRST_VIDEO_STREAM, &outType))) + { + UINT32 w = 0, h = 0; + MFGetAttributeSize (outType, MF_MT_FRAME_SIZE, &w, &h); + frameWidth = (int) w; + frameHeight = (int) h; + + UINT32 num = 0, den = 0; + MFGetAttributeRatio (outType, MF_MT_FRAME_RATE, &num, &den); + if (num > 0 && den > 0) + framePeriodMs = juce::jlimit (10, 200, (int) (1000.0 * den / num)); + + outType->Release(); + } + + if (frameWidth <= 0 || frameHeight <= 0) return false; + + startThread (juce::Thread::Priority::low); + return true; + } + + /** Called from the message thread. Returns a new frame image if one is ready, + or an invalid Image if nothing new has been decoded since last call. */ + juce::Image getLatestFrame() + { + juce::ScopedLock sl (frameLock); + auto f = pendingFrame; // ref-counted copy — cheap + pendingFrame = {}; + return f; + } + + //============================================================================== + void run() override + { + CoInitializeEx (nullptr, COINIT_MULTITHREADED); + + while (!threadShouldExit()) + { + if (eof) + { + seekToStart(); + continue; // go straight back to decode after looping + } + + auto img = decodeNextFrame(); + if (img.isValid()) + { + juce::ScopedLock sl (frameLock); + pendingFrame = std::move (img); + } + + // Sleep one frame period between decodes; wakes early on exit signal + wait (framePeriodMs); + } + + CoUninitialize(); + } + +private: + IMFSourceReader* reader = nullptr; + bool mfStarted = false; + int frameWidth = 0; + int frameHeight = 0; + int framePeriodMs = 33; // ~30 fps default + bool eof = false; + + juce::CriticalSection frameLock; + juce::Image pendingFrame; + + void seekToStart() + { + if (reader == nullptr) return; + PROPVARIANT pv; + PropVariantInit (&pv); + pv.vt = VT_I8; + pv.hVal.QuadPart = 0; + reader->SetCurrentPosition (GUID_NULL, pv); + PropVariantClear (&pv); + eof = false; + } + + juce::Image decodeNextFrame() + { + if (reader == nullptr) return {}; + + DWORD streamIdx = 0, flags = 0; + LONGLONG ts = 0; + IMFSample* sample = nullptr; + + HRESULT hr = reader->ReadSample ( + (DWORD) MF_SOURCE_READER_FIRST_VIDEO_STREAM, + 0, &streamIdx, &flags, &ts, &sample); + + if (FAILED (hr) || (flags & MF_SOURCE_READERF_ENDOFSTREAM)) + { + if (sample != nullptr) sample->Release(); + eof = true; + return {}; + } + if (sample == nullptr) return {}; + + IMFMediaBuffer* buf = nullptr; + sample->ConvertToContiguousBuffer (&buf); + sample->Release(); + if (buf == nullptr) return {}; + + BYTE* data = nullptr; + DWORD maxLen = 0, curLen = 0; + buf->Lock (&data, &maxLen, &curLen); + + juce::Image img (juce::Image::ARGB, frameWidth, frameHeight, false); + { + juce::Image::BitmapData bd (img, juce::Image::BitmapData::writeOnly); + const size_t srcRowBytes = (size_t) frameWidth * 4; + for (int y = 0; y < frameHeight; ++y) + { + auto* src = reinterpret_cast (data + y * srcRowBytes); + auto* dst = reinterpret_cast (bd.getLinePointer (y)); + for (int x = 0; x < frameWidth; ++x) + dst[x] = src[x] | 0xFF000000u; + } + } + + buf->Unlock(); + buf->Release(); + return img; + } +}; +#endif // JUCE_WINDOWS + +namespace +{ +class NoArrowCallOutLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + void drawCallOutBoxBackground(juce::CallOutBox& box, juce::Graphics& g, const juce::Path&, juce::Image&) override + { + auto bounds = box.getLocalBounds().toFloat().reduced(1.0f); + auto background = box.findColour(juce::ResizableWindow::backgroundColourId).withAlpha(0.97f); + g.setColour(background); + g.fillRoundedRectangle(bounds, 10.0f); + g.setColour(juce::Colours::lightblue.withAlpha(0.5f)); + g.drawRoundedRectangle(bounds, 10.0f, 1.0f); + } +}; + +static NoArrowCallOutLookAndFeel noArrowCallOutLookAndFeel; + +class ReverbSettingsPopupComponent : public juce::Component +{ +public: + explicit ReverbSettingsPopupComponent(NinjamVst3AudioProcessor& p) + : processor(p) + { + addAndMakeVisible(roomSizeLabel); + roomSizeLabel.setText("Room Size", juce::dontSendNotification); + addAndMakeVisible(roomSizeSlider); + roomSizeSlider.setSliderStyle(juce::Slider::LinearHorizontal); + roomSizeSlider.setTextBoxStyle(juce::Slider::TextBoxRight, false, 52, 18); + roomSizeSlider.setRange(0.0, 1.0, 0.01); + roomSizeSlider.setValue(processor.getFxReverbRoomSize(), juce::dontSendNotification); + roomSizeSlider.onValueChange = [this] { processor.setFxReverbRoomSize((float)roomSizeSlider.getValue()); }; + + addAndMakeVisible(dampingLabel); + dampingLabel.setText("Dampening", juce::dontSendNotification); + addAndMakeVisible(dampingSlider); + dampingSlider.setSliderStyle(juce::Slider::LinearHorizontal); + dampingSlider.setTextBoxStyle(juce::Slider::TextBoxRight, false, 52, 18); + dampingSlider.setRange(0.0, 1.0, 0.01); + dampingSlider.setValue(processor.getFxReverbDamping(), juce::dontSendNotification); + dampingSlider.onValueChange = [this] { processor.setFxReverbDamping((float)dampingSlider.getValue()); }; + + addAndMakeVisible(wetDryLabel); + wetDryLabel.setText("Wet/Dry", juce::dontSendNotification); + addAndMakeVisible(wetDrySlider); + wetDrySlider.setSliderStyle(juce::Slider::LinearHorizontal); + wetDrySlider.setTextBoxStyle(juce::Slider::TextBoxRight, false, 52, 18); + wetDrySlider.setRange(0.0, 1.0, 0.01); + wetDrySlider.setValue(processor.getFxReverbWetDryMix(), juce::dontSendNotification); + wetDrySlider.onValueChange = [this] { processor.setFxReverbWetDryMix((float)wetDrySlider.getValue()); }; + + addAndMakeVisible(earlyLabel); + earlyLabel.setText("Early Reflections", juce::dontSendNotification); + addAndMakeVisible(earlySlider); + earlySlider.setSliderStyle(juce::Slider::LinearHorizontal); + earlySlider.setTextBoxStyle(juce::Slider::TextBoxRight, false, 52, 18); + earlySlider.setRange(0.0, 1.0, 0.01); + earlySlider.setValue(processor.getFxReverbEarlyReflections(), juce::dontSendNotification); + earlySlider.onValueChange = [this] { processor.setFxReverbEarlyReflections((float)earlySlider.getValue()); }; + + addAndMakeVisible(tailLabel); + tailLabel.setText("Tail", juce::dontSendNotification); + addAndMakeVisible(tailSlider); + tailSlider.setSliderStyle(juce::Slider::LinearHorizontal); + tailSlider.setTextBoxStyle(juce::Slider::TextBoxRight, false, 52, 18); + tailSlider.setRange(0.0, 1.0, 0.01); + tailSlider.setValue(processor.getFxReverbTail(), juce::dontSendNotification); + tailSlider.onValueChange = [this] { processor.setFxReverbTail((float)tailSlider.getValue()); }; + + setSize(340, 180); + } + + void resized() override + { + auto area = getLocalBounds().reduced(8); + layoutRow(area, roomSizeLabel, roomSizeSlider); + layoutRow(area, earlyLabel, earlySlider); + layoutRow(area, tailLabel, tailSlider); + layoutRow(area, dampingLabel, dampingSlider); + layoutRow(area, wetDryLabel, wetDrySlider); + } + +private: + void layoutRow(juce::Rectangle& area, juce::Label& label, juce::Slider& slider) + { + auto row = area.removeFromTop(32); + label.setBounds(row.removeFromLeft(130)); + slider.setBounds(row); + } + + NinjamVst3AudioProcessor& processor; + juce::Label roomSizeLabel; + juce::Label dampingLabel; + juce::Label wetDryLabel; + juce::Label earlyLabel; + juce::Label tailLabel; + juce::Slider roomSizeSlider; + juce::Slider dampingSlider; + juce::Slider wetDrySlider; + juce::Slider earlySlider; + juce::Slider tailSlider; +}; + +class DelaySettingsPopupComponent : public juce::Component +{ +public: + explicit DelaySettingsPopupComponent(NinjamVst3AudioProcessor& p) + : processor(p) + { + addAndMakeVisible(modeLabel); + modeLabel.setText("Time Mode", juce::dontSendNotification); + addAndMakeVisible(modeSelector); + modeSelector.addItem("Milliseconds", 1); + modeSelector.addItem("Sync Host", 2); + modeSelector.setSelectedId(processor.isFxDelaySyncToHost() ? 2 : 1, juce::dontSendNotification); + modeSelector.onChange = [this] + { + processor.setFxDelaySyncToHost(modeSelector.getSelectedId() == 2); + updateEnabledState(); + }; + + addAndMakeVisible(timeMsLabel); + timeMsLabel.setText("Delay Time (ms)", juce::dontSendNotification); + addAndMakeVisible(timeMsSlider); + timeMsSlider.setSliderStyle(juce::Slider::LinearHorizontal); + timeMsSlider.setTextBoxStyle(juce::Slider::TextBoxRight, false, 52, 18); + timeMsSlider.setRange(20.0, 2000.0, 1.0); + timeMsSlider.setValue(processor.getFxDelayTimeMs(), juce::dontSendNotification); + timeMsSlider.onValueChange = [this] { processor.setFxDelayTimeMs((float)timeMsSlider.getValue()); }; + + addAndMakeVisible(syncLabel); + syncLabel.setText("Sync Division", juce::dontSendNotification); + addAndMakeVisible(syncSelector); + syncSelector.addItem("1/16", 16); + syncSelector.addItem("1/8", 8); + syncSelector.addItem("1/1", 1); + syncSelector.setSelectedId(processor.getFxDelayDivision(), juce::dontSendNotification); + syncSelector.onChange = [this] + { + int division = syncSelector.getSelectedId(); + if (division <= 0) + division = 8; + processor.setFxDelayDivision(division); + }; + + addAndMakeVisible(pingPongLabel); + pingPongLabel.setText("Ping Pong", juce::dontSendNotification); + addAndMakeVisible(pingPongToggle); + pingPongToggle.setClickingTogglesState(true); + pingPongToggle.setToggleState(processor.isFxDelayPingPong(), juce::dontSendNotification); + pingPongToggle.onClick = [this] { processor.setFxDelayPingPong(pingPongToggle.getToggleState()); }; + + addAndMakeVisible(wetDryLabel); + wetDryLabel.setText("Wet/Dry", juce::dontSendNotification); + addAndMakeVisible(wetDrySlider); + wetDrySlider.setSliderStyle(juce::Slider::LinearHorizontal); + wetDrySlider.setTextBoxStyle(juce::Slider::TextBoxRight, false, 52, 18); + wetDrySlider.setRange(0.0, 1.0, 0.01); + wetDrySlider.setValue(processor.getFxDelayWetDryMix(), juce::dontSendNotification); + wetDrySlider.onValueChange = [this] { processor.setFxDelayWetDryMix((float)wetDrySlider.getValue()); }; + + addAndMakeVisible(feedbackLabel); + feedbackLabel.setText("Feedback", juce::dontSendNotification); + addAndMakeVisible(feedbackSlider); + feedbackSlider.setSliderStyle(juce::Slider::LinearHorizontal); + feedbackSlider.setTextBoxStyle(juce::Slider::TextBoxRight, false, 52, 18); + feedbackSlider.setRange(0.0, 0.95, 0.01); + feedbackSlider.setValue(processor.getFxDelayFeedback(), juce::dontSendNotification); + feedbackSlider.onValueChange = [this] { processor.setFxDelayFeedback((float)feedbackSlider.getValue()); }; + + updateEnabledState(); + setSize(360, 220); + } + + void resized() override + { + auto area = getLocalBounds().reduced(8); + layoutRow(area, modeLabel, modeSelector); + layoutRow(area, timeMsLabel, timeMsSlider); + layoutRow(area, syncLabel, syncSelector); + layoutRow(area, pingPongLabel, pingPongToggle); + layoutRow(area, wetDryLabel, wetDrySlider); + layoutRow(area, feedbackLabel, feedbackSlider); + } + +private: + template + void layoutRow(juce::Rectangle& area, juce::Label& label, ControlType& control) + { + auto row = area.removeFromTop(34); + label.setBounds(row.removeFromLeft(130)); + control.setBounds(row); + } + + void updateEnabledState() + { + const bool syncEnabled = modeSelector.getSelectedId() == 2; + timeMsSlider.setEnabled(!syncEnabled); + syncSelector.setEnabled(syncEnabled); + } + + NinjamVst3AudioProcessor& processor; + juce::Label modeLabel; + juce::ComboBox modeSelector; + juce::Label timeMsLabel; + juce::Slider timeMsSlider; + juce::Label syncLabel; + juce::ComboBox syncSelector; + juce::Label pingPongLabel; + juce::ToggleButton pingPongToggle; + juce::Label wetDryLabel; + juce::Slider wetDrySlider; + juce::Label feedbackLabel; + juce::Slider feedbackSlider; +}; + +class MidiOptionsPopupComponent : public juce::Component +{ +public: + MidiOptionsPopupComponent(NinjamVst3AudioProcessor& p, std::function onChangedCallback) + : processor(p), onChanged(std::move(onChangedCallback)) + { + addAndMakeVisible(learnDeviceLabel); + learnDeviceLabel.setText("Midi Learn Device", juce::dontSendNotification); + addAndMakeVisible(learnDeviceSelector); + populateSelector(learnDeviceSelector, learnDeviceByMenuId, processor.getMidiLearnInputDeviceId()); + learnDeviceSelector.onChange = [this] + { + const int selected = learnDeviceSelector.getSelectedId(); + auto it = learnDeviceByMenuId.find(selected); + processor.setMidiLearnInputDeviceId(it != learnDeviceByMenuId.end() ? it->second : juce::String()); + if (onChanged) + onChanged(); + }; + + addAndMakeVisible(relayDeviceLabel); + relayDeviceLabel.setText("Midi Relay Device", juce::dontSendNotification); + addAndMakeVisible(relayDeviceSelector); + populateSelector(relayDeviceSelector, relayDeviceByMenuId, processor.getMidiRelayInputDeviceId()); + relayDeviceSelector.onChange = [this] + { + const int selected = relayDeviceSelector.getSelectedId(); + auto it = relayDeviceByMenuId.find(selected); + processor.setMidiRelayInputDeviceId(it != relayDeviceByMenuId.end() ? it->second : juce::String()); + if (onChanged) + onChanged(); + }; + + setSize(360, 104); + } + + void resized() override + { + auto area = getLocalBounds().reduced(8); + layoutRow(area, learnDeviceLabel, learnDeviceSelector); + layoutRow(area, relayDeviceLabel, relayDeviceSelector); + } + +private: + template + void layoutRow(juce::Rectangle& area, juce::Label& label, ControlType& control) + { + auto row = area.removeFromTop(42); + label.setBounds(row.removeFromLeft(140)); + control.setBounds(row.removeFromTop(28)); + } + + static void populateSelector(juce::ComboBox& selector, + std::map& idByMenuId, + const juce::String& selectedDeviceId) + { + selector.clear(juce::dontSendNotification); + idByMenuId.clear(); + int menuId = 1; + selector.addItem("Host MIDI / Any", menuId); + idByMenuId[menuId] = {}; + int selectedMenuId = selectedDeviceId.isEmpty() ? menuId : 0; + ++menuId; + + const auto devices = juce::MidiInput::getAvailableDevices(); + for (const auto& device : devices) + { + selector.addItem(device.name, menuId); + idByMenuId[menuId] = device.identifier; + if (device.identifier == selectedDeviceId) + selectedMenuId = menuId; + ++menuId; + } + + if (selectedMenuId == 0) + selectedMenuId = 1; + selector.setSelectedId(selectedMenuId, juce::dontSendNotification); + } + + NinjamVst3AudioProcessor& processor; + std::function onChanged; + juce::Label learnDeviceLabel; + juce::ComboBox learnDeviceSelector; + juce::Label relayDeviceLabel; + juce::ComboBox relayDeviceSelector; + std::map learnDeviceByMenuId; + std::map relayDeviceByMenuId; +}; +} + +void FaderLookAndFeel::drawLinearSliderBackground(juce::Graphics& g, int x, int y, int width, int height, + float sliderPos, float minSliderPos, float maxSliderPos, + const juce::Slider::SliderStyle style, juce::Slider& slider) +{ + juce::Rectangle bounds(x, y, width, height); + if (style == juce::Slider::LinearVertical) + { + int trackWidth = 4; + juce::Rectangle track(bounds.getCentreX() - trackWidth / 2, + bounds.getY() + 4, + trackWidth, + bounds.getHeight() - 8); + + juce::ColourGradient grad(juce::Colours::black.withAlpha(0.9f), (float)track.getCentreX(), (float)track.getY(), + juce::Colours::darkgrey.darker(), (float)track.getCentreX(), (float)track.getBottom(), false); + g.setGradientFill(grad); + g.fillRect(track); + + g.setColour(juce::Colours::black); + g.drawRect(track); + + int tickX = track.getRight() + 6; + + const int numTicksBelowZero = 3; + float zeroProp = slider.valueToProportionOfLength(1.0); + zeroProp = juce::jlimit(0.0f, 1.0f, zeroProp); + + for (int i = 0; i <= numTicksBelowZero + 1; ++i) + { + float prop = zeroProp * (float)i / (float)numTicksBelowZero; + if (i > numTicksBelowZero) prop = 1.0f; + double value = slider.proportionOfLengthToValue(prop); + float gain = (float)value; + float clampedGain = juce::jlimit(1.0e-6f, 2.0f, gain); + float db = 20.0f * std::log10(clampedGain); + + int yPos = track.getY() + (int)((1.0f - prop) * (float)track.getHeight()); + + float alpha = 0.7f; + if (i == numTicksBelowZero) alpha = 0.95f; + + g.setColour(juce::Colours::lightgrey.withAlpha(alpha)); + g.drawLine((float)(track.getX() - 6), (float)yPos, + (float)(tickX + 4), (float)yPos); + + juce::String label; + if (i == numTicksBelowZero) + label = "0 dB"; + else if (i > numTicksBelowZero) + label = "+6 dB"; + else + label = juce::String((int)std::round(db)) + " dB"; + + g.setFont(9.0f); + g.drawText(label, tickX + 4, yPos - 7, 40, 14, + juce::Justification::centredLeft, false); + } + } + else if (style == juce::Slider::LinearHorizontal) + { + int trackHeight = 4; + juce::Rectangle track(bounds.getX() + 4, + bounds.getCentreY() - trackHeight / 2, + bounds.getWidth() - 8, + trackHeight); + + juce::ColourGradient grad(juce::Colours::black.withAlpha(0.9f), (float)track.getX(), (float)track.getCentreY(), + juce::Colours::darkgrey.darker(), (float)track.getRight(), (float)track.getCentreY(), false); + g.setGradientFill(grad); + g.fillRect(track); + + g.setColour(juce::Colours::black); + g.drawRect(track); + + const int numTicksBelowZero = 3; + float zeroProp = slider.valueToProportionOfLength(1.0); + zeroProp = juce::jlimit(0.0f, 1.0f, zeroProp); + + for (int i = 0; i <= numTicksBelowZero + 1; ++i) + { + float prop = zeroProp * (float)i / (float)numTicksBelowZero; + if (i > numTicksBelowZero) prop = 1.0f; + double value = slider.proportionOfLengthToValue(prop); + float gain = (float)value; + float clampedGain = juce::jlimit(1.0e-6f, 2.0f, gain); + float db = 20.0f * std::log10(clampedGain); + + int xPos = track.getX() + (int)(prop * (float)track.getWidth()); + + float alpha = 0.7f; + if (i == numTicksBelowZero) alpha = 0.95f; + + g.setColour(juce::Colours::lightgrey.withAlpha(alpha)); + g.drawLine((float)xPos, (float)(track.getY() - 6), + (float)xPos, (float)(track.getBottom() + 6)); + + juce::String label; + if (i == numTicksBelowZero) + label = "0 dB"; + else if (i > numTicksBelowZero) + label = "+6 dB"; + else + label = juce::String((int)std::round(db)) + " dB"; + + g.setFont(10.0f); + g.drawText(label, + xPos - 20, + track.getBottom() + 8, + 40, + 14, + juce::Justification::centred, + false); + } + } + else + { + juce::LookAndFeel_V4::drawLinearSliderBackground(g, x, y, width, height, sliderPos, minSliderPos, maxSliderPos, style, slider); + } +} + +void FaderLookAndFeel::drawLinearSlider(juce::Graphics& g, int x, int y, int width, int height, + float sliderPos, float minSliderPos, float maxSliderPos, + const juce::Slider::SliderStyle style, juce::Slider& slider) +{ + // Walk parent hierarchy to find the editor (sliders may be inside sub-components) + NinjamVst3AudioProcessorEditor* editor = nullptr; + for (auto* p = slider.getParentComponent(); p != nullptr && editor == nullptr; p = p->getParentComponent()) + editor = dynamic_cast(p); + + if (editor != nullptr && editor->faderKnobImage.isValid()) + { + drawLinearSliderBackground(g, x, y, width, height, sliderPos, minSliderPos, maxSliderPos, style, slider); + bool isVert = (style == juce::Slider::LinearVertical); + float thumbW = isVert ? (float)width * 0.95f : 40.0f; + float thumbH = isVert ? 42.0f : (float)height * 0.95f; + float thumbX = isVert ? (float)x + (float)width * 0.025f : sliderPos - thumbW * 0.5f; + float thumbY = isVert ? sliderPos - thumbH * 0.5f : (float)y + (float)height * 0.025f; + // Clamp so thumb never clips outside the slider bounds + thumbX = juce::jlimit((float)x, (float)(x + width) - thumbW, thumbX); + thumbY = juce::jlimit((float)y, (float)(y + height) - thumbH, thumbY); + g.drawImageWithin(editor->faderKnobImage, (int)thumbX, (int)thumbY, (int)thumbW, (int)thumbH, + juce::RectanglePlacement::centred); + return; + } + + if (style == juce::Slider::LinearVertical) + { + drawLinearSliderBackground(g, x, y, width, height, sliderPos, minSliderPos, maxSliderPos, style, slider); + + juce::Rectangle bounds(x, y, width, height); + int trackWidth = 4; + juce::Rectangle track(bounds.getCentreX() - trackWidth / 2, + bounds.getY() + 4, + trackWidth, + bounds.getHeight() - 8); + + int thumbHeight = juce::jmin(52, track.getHeight() / 2); + int thumbWidth = 30; + int thumbY = juce::jlimit(bounds.getY(), bounds.getBottom() - thumbHeight, + (int)sliderPos - thumbHeight / 2); + juce::Rectangle thumb(track.getCentreX() - thumbWidth / 2, thumbY, thumbWidth, thumbHeight); + + const juce::Colour base = editor != nullptr ? editor->faderThemeColour : juce::Colour(0xff666666); + juce::ColourGradient grad(base.brighter(0.5f), (float)thumb.getX(), (float)thumb.getY(), + base.darker(0.4f), (float)thumb.getRight(), (float)thumb.getBottom(), false); + if (editor != nullptr && normaliseColourPresetName(editor->faderColourPreset).startsWith("multi")) + grad.addColour(0.4, juce::Colour::fromHSV((float)slider.getValue() * 0.8f, 0.8f, 1.0f, 1.0f)); + else + grad.addColour(0.4, base.brighter(0.2f)); + g.setGradientFill(grad); + g.fillRect(thumb); + + g.setColour(juce::Colours::black.withAlpha(0.7f)); + g.drawRect(thumb); + + g.setColour(juce::Colours::white.withAlpha(0.4f)); + int innerY = thumb.getY() + 4; + for (int i = 0; i < 4; ++i) + { + g.drawLine((float)(thumb.getX() + 2), (float)innerY, (float)(thumb.getRight() - 2), (float)innerY); + innerY += 4; + } + + double v = slider.getValue(); + double db = (v <= 1.0e-6) ? -60.0 : 20.0 * std::log10(v); + int dbInt = (int)std::round(db); + + juce::Rectangle box(thumb.getX() + 3, thumb.getY() + 6, thumb.getWidth() - 6, 14); + g.setColour(juce::Colours::black); + g.fillRect(box); + g.setColour(juce::Colours::white.withAlpha(0.9f)); + + juce::String text; + if (v <= 1.0e-6) + text = "-inf"; + else if (dbInt > 0) + text = "+" + juce::String(dbInt) + " dB"; + else + text = juce::String(dbInt) + " dB"; + + g.setFont(10.0f); + g.drawText(text, box, juce::Justification::centred, false); + } + else if (style == juce::Slider::LinearHorizontal) + { + drawLinearSliderBackground(g, x, y, width, height, sliderPos, minSliderPos, maxSliderPos, style, slider); + + juce::Rectangle bounds(x, y, width, height); + int trackHeight = 4; + juce::Rectangle track(bounds.getX() + 4, + bounds.getCentreY() - trackHeight / 2, + bounds.getWidth() - 8, + trackHeight); + + int thumbWidth = juce::jmin(52, track.getWidth() / 2); + int thumbHeight = 24; + int thumbX = juce::jlimit(bounds.getX(), bounds.getRight() - thumbWidth, + (int)sliderPos - thumbWidth / 2); + juce::Rectangle thumb(thumbX, track.getCentreY() - thumbHeight / 2, thumbWidth, thumbHeight); + + const juce::Colour base = editor != nullptr ? editor->faderThemeColour : juce::Colour(0xff666666); + juce::ColourGradient grad(base.brighter(0.5f), (float)thumb.getX(), (float)thumb.getY(), + base.darker(0.4f), (float)thumb.getRight(), (float)thumb.getBottom(), false); + if (editor != nullptr && normaliseColourPresetName(editor->faderColourPreset).startsWith("multi")) + grad.addColour(0.4, juce::Colour::fromHSV((float)slider.getValue() * 0.8f, 0.8f, 1.0f, 1.0f)); + else + grad.addColour(0.4, base.brighter(0.2f)); + g.setGradientFill(grad); + g.fillRect(thumb); + + g.setColour(juce::Colours::black.withAlpha(0.7f)); + g.drawRect(thumb); + + g.setColour(juce::Colours::white.withAlpha(0.4f)); + int innerX = thumb.getX() + 4; + for (int i = 0; i < 4; ++i) + { + g.drawLine((float)innerX, (float)(thumb.getY() + 2), + (float)innerX, (float)(thumb.getBottom() - 2)); + innerX += 4; + } + + double v = slider.getValue(); + double db = (v <= 1.0e-6) ? -60.0 : 20.0 * std::log10(v); + int dbInt = (int)std::round(db); + + juce::Rectangle box(thumb.getX(), thumb.getY() - 16, thumb.getWidth(), 14); + g.setColour(juce::Colours::black); + g.fillRect(box); + g.setColour(juce::Colours::white.withAlpha(0.9f)); + + juce::String text; + if (v <= 1.0e-6) + text = "-inf"; + else if (dbInt > 0) + text = "+" + juce::String(dbInt) + " dB"; + else + text = juce::String(dbInt) + " dB"; + + g.setFont(10.0f); + g.drawText(text, box, juce::Justification::centred, false); + } + else + { + juce::LookAndFeel_V4::drawLinearSlider(g, x, y, width, height, sliderPos, minSliderPos, maxSliderPos, style, slider); + } +} + +class ServerListComponent : public juce::Component, + public juce::ListBoxModel +{ +public: + ServerListComponent(NinjamVst3AudioProcessor& p, + std::function onChooseServer, + std::function onConnectServer) + : processor(p), + onServerChosen(std::move(onChooseServer)), + onServerConnect(std::move(onConnectServer)) + { + addAndMakeVisible(listBox); + listBox.setModel(this); + listBox.setRowHeight(24); + + addAndMakeVisible(refreshButton); + refreshButton.setButtonText("Refresh"); + refreshButton.onClick = [this] { refreshServers(); }; + + addAndMakeVisible(connectButton); + connectButton.setButtonText("Set Server"); + connectButton.onClick = [this] + { + int row = listBox.getSelectedRow(); + chooseServer(row); + }; + + refreshServers(); + } + + void paint(juce::Graphics& g) override + { + g.fillAll(juce::Colours::darkgrey); + } + + void resized() override + { + auto area = getLocalBounds().reduced(8); + auto controls = area.removeFromBottom(30); + refreshButton.setBounds(controls.removeFromLeft(100)); + controls.removeFromLeft(8); + connectButton.setBounds(controls.removeFromLeft(120)); + listBox.setBounds(area); + } + + int getNumRows() override { return (int)servers.size(); } + + void paintListBoxItem(int rowNumber, juce::Graphics& g, int width, int height, bool rowIsSelected) override + { + if (rowNumber < 0 || rowNumber >= (int)servers.size()) + return; + + auto& s = servers[(size_t)rowNumber]; + + if (rowIsSelected) + g.fillAll(juce::Colours::darkblue.withAlpha(0.6f)); + else + g.fillAll(juce::Colours::darkgrey); + + g.setColour(juce::Colours::white); + + juce::String text; + text << s.name << " " + << s.userCount << "/" << s.userMax + << " " << juce::String(s.bpm, 1) << " BPM" + << " / " << s.bpi << " BPI"; + + g.drawText(text, 4, 0, width - 8, height, juce::Justification::centredLeft, true); + } + + void listBoxItemDoubleClicked(int row, const juce::MouseEvent&) override { connectServer(row); } + +private: + NinjamVst3AudioProcessor& processor; + juce::ListBox listBox; + juce::TextButton refreshButton; + juce::TextButton connectButton; + std::vector servers; + std::function onServerChosen; + std::function onServerConnect; + + void refreshServers() + { + processor.refreshPublicServers(); + servers = processor.getPublicServers(); + listBox.updateContent(); + repaint(); + } + + void chooseServer(int row) + { + if (row < 0 || row >= (int)servers.size()) + return; + auto& s = servers[(size_t)row]; + juce::String hostPort = s.host + ":" + juce::String(s.port); + if (onServerChosen) + onServerChosen(hostPort); + } + + void connectServer(int row) + { + if (row < 0 || row >= (int)servers.size()) + return; + auto& s = servers[(size_t)row]; + juce::String hostPort = s.host + ":" + juce::String(s.port); + if (onServerConnect) + onServerConnect(hostPort); + } +}; + +class ServerListWindow : public juce::DocumentWindow +{ +public: + ServerListWindow(NinjamVst3AudioProcessor& p, + std::function onChooseServer, + std::function onConnectServer) + : DocumentWindow("NINJAM Servers", juce::Colours::black, DocumentWindow::closeButton) + { + setUsingNativeTitleBar(true); + setResizable(true, true); + setContentOwned(new ServerListComponent(p, + std::move(onChooseServer), + std::move(onConnectServer)), true); + centreWithSize(600, 400); + setVisible(true); + } + + void closeButtonPressed() override { setVisible(false); } +}; + +// Forward declarations (defined after ChatWindow) +static juce::Colour senderColour(const juce::String& sender); +static void applyColoredChat(juce::TextEditor&, const juce::StringArray&, const juce::StringArray&); + +class ChatPopupComponent : public juce::Component +{ +public: + ChatPopupComponent(NinjamVst3AudioProcessor& p) : processor(p) + { + addAndMakeVisible(chatDisplay); + chatDisplay.setMultiLine(true); + chatDisplay.setReadOnly(true); + chatDisplay.setFont(juce::Font(14.0f)); + + addAndMakeVisible(chatInput); + chatInput.onReturnKey = [this] { sendClicked(); }; + + addAndMakeVisible(sendButton); + sendButton.setButtonText("Send"); + sendButton.onClick = [this] { sendClicked(); }; + + addAndMakeVisible(atButton); + atButton.setClickingTogglesState(true); + atButton.setWantsKeyboardFocus(false); + atButton.setToggleState(false, juce::dontSendNotification); + atButton.setLookAndFeel(&atPopupBtnLAF); + atButton.onClick = [this] { atToggled(); }; + } + + ~ChatPopupComponent() override + { + atButton.setLookAndFeel(nullptr); + } + + void resized() override + { + auto area = getLocalBounds().reduced(8); + auto inputArea = area.removeFromBottom(30); + auto atArea = inputArea.removeFromRight(40); + auto sendArea = inputArea.removeFromRight(60); + chatInput.setBounds(inputArea); + sendButton.setBounds(sendArea); + atButton.setBounds(atArea); + chatDisplay.setBounds(area); + } + + void setChatText(const juce::StringArray& lines, const juce::StringArray& senders) + { + applyColoredChat(chatDisplay, lines, senders); + } + +private: + NinjamVst3AudioProcessor& processor; + juce::TextEditor chatDisplay; + juce::TextEditor chatInput; + juce::TextButton sendButton; + juce::TextButton atButton; + ATButtonLookAndFeel atPopupBtnLAF; + + void sendClicked() + { + auto msg = chatInput.getText(); + if (msg.isNotEmpty()) + { + processor.sendChatMessage(msg); + chatInput.clear(); + } + } + + void atToggled() { processor.setAutoTranslateEnabled(atButton.getToggleState()); } +}; + +class ChatWindow : public juce::DocumentWindow +{ +public: + ChatWindow(NinjamVst3AudioProcessor& p, std::function onClosedCallback) + : DocumentWindow("NINJAM Chat", juce::Colours::black, DocumentWindow::closeButton), + onClosed(std::move(onClosedCallback)) + { + setUsingNativeTitleBar(true); + setResizable(true, true); + setContentOwned(new ChatPopupComponent(p), true); + centreWithSize(500, 400); + setVisible(true); + } + + void closeButtonPressed() override + { + setVisible(false); + if (onClosed) + onClosed(); + } + +private: + std::function onClosed; +}; + +// --- Chat colour helpers --- +static juce::Colour senderColour(const juce::String& sender) +{ + if (sender == "me") + return juce::Colours::white; + if (sender.isEmpty()) + return juce::Colour::fromRGB(160, 160, 120); // dim amber – system + + // Deterministic hash → one of 8 distinct palette colours + uint32_t h = 5381u; + for (auto c : sender) + h = h * 33u ^ (uint32_t)juce::CharacterFunctions::toUpperCase(c); + + static const juce::Colour palette[] = { + juce::Colour::fromRGB(100, 180, 255), // blue + juce::Colour::fromRGB( 80, 210, 140), // green + juce::Colour::fromRGB(255, 165, 80), // orange + juce::Colour::fromRGB(190, 120, 255), // purple + juce::Colour::fromRGB( 80, 220, 215), // teal + juce::Colour::fromRGB(255, 130, 160), // pink + juce::Colour::fromRGB(230, 200, 80), // gold + juce::Colour::fromRGB(160, 200, 100), // lime + }; + return palette[h % 8u]; +} + +static juce::String normaliseColourPresetName(const juce::String& name) +{ + auto s = name.trim().toLowerCase(); + s = s.removeCharacters(" _-"); + return s; +} + +static juce::Colour colourFromPresetName(const juce::String& preset, const juce::Colour& fallback) +{ + const auto key = normaliseColourPresetName(preset); + if (key == "gold") return juce::Colour(0xffb8860b); + if (key == "grey" || key == "gray") return juce::Colour(0xff909090); + if (key == "sand" || key == "sandcolour" || key == "sandcolor") return juce::Colour(0xffc2b280); + if (key == "yellow") return juce::Colour(0xffffd700); + if (key == "orange") return juce::Colour(0xffff8c00); + if (key == "red") return juce::Colour(0xffdc143c); + if (key == "blue") return juce::Colour(0xff1e90ff); + if (key == "pink") return juce::Colour(0xffff69b4); + if (key == "purpleblue") return juce::Colour(0xff6a5acd); + if (key == "black") return juce::Colour(0xff202020); + if (key == "cream") return juce::Colour(0xfffff5dc); + if (key == "white") return juce::Colour(0xfff2f2f2); + return fallback; +} + +static void applyColoredChat(juce::TextEditor& display, + const juce::StringArray& lines, + const juce::StringArray& senders) +{ + display.setReadOnly(false); + display.clear(); + const int n = lines.size(); + for (int i = 0; i < n; ++i) + { + const juce::String& sndr = (i < senders.size()) ? senders[i] : juce::String(); + display.setColour(juce::TextEditor::textColourId, senderColour(sndr)); + display.insertTextAtCaret(lines[i] + "\n"); + } + display.setReadOnly(true); + display.moveCaretToEnd(); +} +// --- end chat helpers --- + +void CustomKnobLookAndFeel::drawRotarySlider(juce::Graphics& g, int x, int y, int width, int height, + float sliderPos, const float rotaryStartAngle, + const float rotaryEndAngle, juce::Slider& slider) +{ + auto centreX = (float)x + (float)width * 0.5f; + auto centreY = (float)y + (float)height * 0.5f; + + auto* editor = dynamic_cast(slider.getParentComponent()); + if (editor == nullptr) + { + auto* p = slider.getParentComponent(); + while (p != nullptr && editor == nullptr) + { + editor = dynamic_cast(p); + p = p->getParentComponent(); + } + } + + if (editor != nullptr && editor->radioKnobImage.isValid()) + { + const float angle = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle); + const float radius = (float)juce::jmin(width / 2, height / 2) - 1.0f; + g.saveState(); + g.addTransform(juce::AffineTransform::rotation(angle, centreX, centreY)); + g.drawImageWithin(editor->radioKnobImage, + (int)(centreX - radius), (int)(centreY - radius), + (int)(radius * 2.0f), (int)(radius * 2.0f), + juce::RectanglePlacement::fillDestination); + g.restoreState(); + return; + } + + const float angle = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle); + const float outerRadius = (float)juce::jmin(width / 2, height / 2) - 4.0f; + + const auto knobPreset = editor != nullptr ? normaliseColourPresetName(editor->knobColourPreset) : juce::String(); + const bool multiColourKnob = knobPreset.startsWith("multi"); + const juce::Colour knobBase = editor != nullptr ? editor->knobThemeColour : juce::Colours::grey; + const juce::Colour ringFill = knobBase.darker(1.1f); + const juce::Colour ringStroke = knobBase.darker(1.4f); + const juce::Colour tickColour = multiColourKnob + ? juce::Colour::fromHSV(sliderPos * 0.8f, 0.8f, 1.0f, 1.0f) + : knobBase.brighter(0.1f); + + // --- Tick marks --- + const int numTicks = 11; + for (int i = 0; i < numTicks; ++i) + { + float tickAngle = rotaryStartAngle + (float)i / (float)(numTicks - 1) * (rotaryEndAngle - rotaryStartAngle); + float s = std::sin(tickAngle), c = -std::cos(tickAngle); + float inner = outerRadius + 3.0f; + float outer = outerRadius + 7.0f; + g.setColour(tickColour); + g.drawLine(centreX + s * inner, centreY + c * inner, + centreX + s * outer, centreY + c * outer, 1.2f); + } + + // --- Knurled outer ring --- + const float ringRadius = outerRadius; + const int teeth = 24; + juce::Path ring; + for (int i = 0; i <= teeth * 2; ++i) + { + float a = (float)i / (float)(teeth * 2) * juce::MathConstants::twoPi; + float r = (i % 2 == 0) ? ringRadius : ringRadius - 3.0f; + float px = centreX + std::sin(a) * r; + float py = centreY - std::cos(a) * r; + if (i == 0) ring.startNewSubPath(px, py); + else ring.lineTo(px, py); + } + ring.closeSubPath(); + g.setColour(ringFill); + g.fillPath(ring); + g.setColour(ringStroke); + g.strokePath(ring, juce::PathStrokeType(0.8f)); + + // --- Inner cap with radial gradient --- + const float capRadius = ringRadius - 5.0f; + const juce::Colour capHighlight = multiColourKnob + ? juce::Colour::fromHSV(sliderPos * 0.8f, 0.7f, 1.0f, 1.0f) + : knobBase.brighter(0.75f); + const juce::Colour capShadow = multiColourKnob + ? juce::Colour::fromHSV(sliderPos * 0.8f, 0.9f, 0.45f, 1.0f) + : knobBase.darker(0.8f); + juce::ColourGradient capGrad(capHighlight, centreX - capRadius * 0.35f, centreY - capRadius * 0.35f, + capShadow, centreX + capRadius * 0.5f, centreY + capRadius * 0.6f, true); + capGrad.addColour(0.45, knobBase.brighter(0.35f)); + g.setGradientFill(capGrad); + g.fillEllipse(centreX - capRadius, centreY - capRadius, capRadius * 2.0f, capRadius * 2.0f); + + // Subtle rim shadow on cap + g.setColour(juce::Colours::black.withAlpha(0.35f)); + g.drawEllipse(centreX - capRadius, centreY - capRadius, capRadius * 2.0f, capRadius * 2.0f, 1.5f); + + // Specular highlight (top-left arc) + juce::Path highlight; + highlight.addArc(centreX - capRadius * 0.65f, centreY - capRadius * 0.65f, + capRadius * 1.3f, capRadius * 1.3f, + -juce::MathConstants::pi * 0.9f, + -juce::MathConstants::pi * 0.2f, true); + g.setColour(juce::Colours::white.withAlpha(multiColourKnob ? 0.35f : 0.28f)); + g.strokePath(highlight, juce::PathStrokeType(capRadius * 0.18f)); + + // --- Indicator line --- + const float lineStart = capRadius * 0.22f; + const float lineEnd = capRadius * 0.82f; + g.setColour(juce::Colours::white.withAlpha(0.95f)); + g.drawLine(centreX + std::sin(angle) * lineStart, centreY - std::cos(angle) * lineStart, + centreX + std::sin(angle) * lineEnd, centreY - std::cos(angle) * lineEnd, + 2.2f); + // Bright dot at tip + g.setColour(juce::Colours::white); + float dotX = centreX + std::sin(angle) * lineEnd; + float dotY = centreY - std::cos(angle) * lineEnd; + g.fillEllipse(dotX - 2.0f, dotY - 2.0f, 4.0f, 4.0f); +} + +NinjamVst3AudioProcessorEditor::NinjamVst3AudioProcessorEditor (NinjamVst3AudioProcessor& p) + : AudioProcessorEditor (&p), audioProcessor (p), intervalDisplay(p), userList(p) +{ + setSize (isAbletonLiveHost() ? 1240 : 1350, 600); + setResizable(true, true); + setResizeLimits(900, 500, 2200, 1500); + + juce::LookAndFeel::setDefaultLookAndFeel(&outlinedLabelLAF); + + serverLabel.setJustificationType(juce::Justification::centredRight); + addAndMakeVisible(serverLabel); + serverField.setText(""); + serverField.setIndents(4, 8); + serverField.onReturnKey = [this] { connectClicked(); }; + addAndMakeVisible(serverField); + + addAndMakeVisible(serverListButton); + serverListButton.setButtonText("Servers"); + serverListButton.setTooltip("Click to View Servers"); + serverListButton.onClick = [this] { serverListClicked(); }; + + userLabel.setJustificationType(juce::Justification::centredRight); + addAndMakeVisible(userLabel); + userField.setText("user" + juce::String(juce::Random::getSystemRandom().nextInt(100))); + userField.setIndents(4, 8); + addAndMakeVisible(userField); + + addAndMakeVisible(anonymousButton); + anonymousButton.setToggleState(true, juce::dontSendNotification); + anonymousButton.onClick = [this] { anonymousToggled(); }; + + passLabel.setJustificationType(juce::Justification::centredRight); + addAndMakeVisible(passLabel); + addAndMakeVisible(passField); + passField.setIndents(4, 8); + passField.setEnabled(false); + + addAndMakeVisible(connectButton); + connectButton.setButtonText("Connect"); + connectButton.onClick = [this] { connectClicked(); }; + + addAndMakeVisible(statusLabel); + + addAndMakeVisible(transmitButton); + transmitButton.setClickingTogglesState(true); + transmitButton.onClick = [this] { transmitToggled(); }; + updateTransmitButtonColor(); + + addAndMakeVisible(localMonitorButton); + localMonitorButton.setClickingTogglesState(true); + localMonitorButton.onClick = [this] + { + audioProcessor.setLocalMonitorEnabled(localMonitorButton.getToggleState()); + updateMonitorButtonColor(); + }; + updateMonitorButtonColor(); + + addAndMakeVisible(voiceChatButton); + voiceChatButton.setClickingTogglesState(true); + voiceChatButton.setToggleState(false, juce::dontSendNotification); + voiceChatButton.onClick = [this] + { + audioProcessor.setVoiceChatMode(voiceChatButton.getToggleState()); + voiceChatGlowPhase = 0.0f; + updateVoiceChatButtonColor(); + }; + updateVoiceChatButtonColor(); + + bitrateSelector.addItem("64 kbps", 1); + bitrateSelector.addItem("96 kbps", 2); + bitrateSelector.addItem("128 kbps", 3); + bitrateSelector.addItem("160 kbps", 4); + bitrateSelector.addItem("192 kbps", 5); + bitrateSelector.addItem("256 kbps", 6); + bitrateSelector.addItem("320 kbps", 7); + bitrateSelector.setSelectedId(3, juce::dontSendNotification); // 128 kbps default + bitrateSelector.onChange = [this] + { + const int bitrateValues[] = { 64, 96, 128, 160, 192, 256, 320 }; + int idx = bitrateSelector.getSelectedId() - 1; + if (idx >= 0 && idx < 7) + audioProcessor.setLocalBitrate(bitrateValues[idx]); + }; + addAndMakeVisible(bitrateSelector); + bitrateSelector.setTooltip("Quality"); + + addAndMakeVisible(midiRelayTargetSelector); + midiRelayTargetSelector.setTooltip("Send Midi"); + midiRelayTargetSelector.onChange = [this] + { + int id = midiRelayTargetSelector.getSelectedId(); + auto it = midiRelayTargetByMenuId.find(id); + if (it != midiRelayTargetByMenuId.end()) + audioProcessor.setMidiRelayTarget(it->second); + }; + refreshMidiRelayTargetSelector(); + addListener(this); + if (!connect(9001)) + for (int port = 9002; port <= 9010; ++port) + if (connect(port)) + break; + + addAndMakeVisible(videoButton); + videoButton.setTooltip("VDO Synced Video"); + videoButton.onClick = [this] { videoClicked(); }; + + addAndMakeVisible(layoutButton); + layoutButton.setClickingTogglesState(true); + layoutButton.setTooltip("Vertical Mixer"); + layoutButton.setLookAndFeel(&faderIconLookAndFeel); + layoutButton.onClick = [this] { layoutToggled(); updateLayoutButtonColor(); }; + updateLayoutButtonColor(); + + addAndMakeVisible(autoLevelButton); + autoLevelButton.setClickingTogglesState(true); + autoLevelButton.setTooltip("Auto Adjust Volume"); + autoLevelButton.onClick = [this] + { + bool newState = autoLevelButton.getToggleState(); + if (newState == autoLevelEnabled) + return; + + autoLevelEnabled = newState; + if (!autoLevelEnabled) + { + auto users = audioProcessor.getConnectedUsers(); + for (auto& u : users) + audioProcessor.setUserVolume(u.index, u.volume); + + autoLevelCurrentGains.clear(); + autoLevelPeakLevels.clear(); + autoLevelChannelActiveTicks.clear(); + } + updateAutoLevelButtonColor(); + }; + updateAutoLevelButtonColor(); + + addAndMakeVisible(metronomeLabel); + addAndMakeVisible(metronomeSlider); + metronomeSlider.setRange(0.0, 1.0); + metronomeSlider.setValue(0.5); + metronomeSlider.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + metronomeSlider.setTooltip("Metronome Volume"); + metronomeSlider.onValueChange = [this] { metronomeChanged(); }; + + addAndMakeVisible(metronomeMuteButton); + metronomeMuteButton.setClickingTogglesState(true); + metronomeMuteButton.setToggleState(true, juce::dontSendNotification); // starts unmuted + metronomeMuteButton.setTooltip("Mute Metronome"); + metronomeMuteButton.setLookAndFeel(&metronomeBtnLAF); + metronomeMuteButton.onClick = [this] + { + if (metronomeMuteButton.getToggleState()) + { + // unmuting: restore stored volume + metronomeSlider.setValue(storedMetronomeVolume, juce::dontSendNotification); + audioProcessor.setMetronomeVolume(storedMetronomeVolume); + } + else + { + // muting: store current volume and silence + storedMetronomeVolume = (float)metronomeSlider.getValue(); + audioProcessor.setMetronomeVolume(0.0f); + } + updateMetronomeButtonColor(); + }; + updateMetronomeButtonColor(); + + addAndMakeVisible(syncButton); + syncButton.setClickingTogglesState(true); + syncButton.setToggleState(false, juce::dontSendNotification); +#if JucePlugin_Build_Standalone + syncButton.setTooltip("Click to Sync to Midi Clock"); +#else + syncButton.setTooltip("Click to Sync to Host (vst)"); +#endif + syncButton.setLookAndFeel(&syncIconLAF); + syncButton.onClick = [this] { syncToggled(); updateSyncButtonColor(); }; + updateSyncButtonColor(); + + addAndMakeVisible(fxButton); + fxButton.onClick = [this] { showFxMenu(); }; + updateFxButtonLabel(); + addAndMakeVisible(optionsButton); + optionsButton.onClick = [this] { showOptionsMenu(); }; + + addAndMakeVisible(tempoLabel); + tempoLabel.setJustificationType(juce::Justification::centredLeft); + + addAndMakeVisible(chatButton); + chatButton.setClickingTogglesState(true); + chatButton.setWantsKeyboardFocus(false); + chatButton.setToggleState(false, juce::dontSendNotification); + chatButton.setTooltip("Open Chat"); + chatButton.setLookAndFeel(&chatBtnLAF); + chatButton.onClick = [this] { chatToggled(); }; + updateChatButtonColor(); + + addAndMakeVisible(usersLabel); + addAndMakeVisible(spreadOutputsButton); + spreadOutputsButton.setClickingTogglesState(true); + spreadOutputsButton.setToggleState(false, juce::dontSendNotification); + spreadOutputsButton.onClick = [this] + { + audioProcessor.setSpreadOutputsEnabled(spreadOutputsButton.getToggleState()); + }; + addAndMakeVisible(userList); + + addAndMakeVisible(addLocalChannelButton); + addLocalChannelButton.setTooltip("Add Channel"); + addAndMakeVisible(removeLocalChannelButton); + removeLocalChannelButton.setTooltip("Remove Channel"); + addAndMakeVisible(localFaderLabel); + localFaderLabel.setJustificationType(juce::Justification::centred); + + addLocalChannelButton.onClick = [this] + { + int current = audioProcessor.getNumLocalChannels(); + if (current < NinjamVst3AudioProcessor::maxLocalChannels) + { + audioProcessor.setNumLocalChannels(current + 1); + for (int i = 0; i < NinjamVst3AudioProcessor::maxLocalChannels; ++i) + localChannelNameLabels[(size_t)i].setText(audioProcessor.getLocalChannelName(i), juce::dontSendNotification); + resized(); + } + }; + + removeLocalChannelButton.onClick = [this] + { + int current = audioProcessor.getNumLocalChannels(); + if (current > 1) + { + audioProcessor.setNumLocalChannels(current - 1); + for (int i = 0; i < NinjamVst3AudioProcessor::maxLocalChannels; ++i) + localChannelNameLabels[(size_t)i].setText(audioProcessor.getLocalChannelName(i), juce::dontSendNotification); + resized(); + } + }; + + int totalInputs = audioProcessor.getTotalNumInputChannels(); + if (totalInputs <= 0) + totalInputs = 2; + int numPairs = totalInputs / 2; + + for (int i = 0; i < NinjamVst3AudioProcessor::maxLocalChannels; ++i) + { + addAndMakeVisible(localFaders[(size_t)i]); + addAndMakeVisible(localPeakMeters[(size_t)i]); + addAndMakeVisible(localInputSelectors[(size_t)i]); + addAndMakeVisible(localInputModeSelectors[(size_t)i]); + addAndMakeVisible(localDbLabels[(size_t)i]); + addAndMakeVisible(localReverbSendKnobs[(size_t)i]); + addAndMakeVisible(localDelaySendKnobs[(size_t)i]); + addAndMakeVisible(localReverbSendLabels[(size_t)i]); + addAndMakeVisible(localDelaySendLabels[(size_t)i]); + + // Editable channel name label + auto& nameLabel = localChannelNameLabels[(size_t)i]; + nameLabel.setText(audioProcessor.getLocalChannelName(i), juce::dontSendNotification); + nameLabel.setEditable(true, false); // single-click to edit + nameLabel.setJustificationType(juce::Justification::centred); + nameLabel.setColour(juce::Label::textColourId, juce::Colours::lightgrey); + nameLabel.setColour(juce::Label::backgroundColourId, juce::Colour(0xff1a1a1a)); + nameLabel.setColour(juce::Label::outlineColourId, juce::Colour(0xff333333)); + nameLabel.setTooltip("Click to name this channel"); + int ch = i; + nameLabel.onTextChange = [this, ch] + { + audioProcessor.setLocalChannelName(ch, localChannelNameLabels[(size_t)ch].getText()); + }; + addAndMakeVisible(nameLabel); + + auto& fader = localFaders[(size_t)i]; + fader.setSliderStyle(juce::Slider::LinearVertical); + fader.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + fader.setRange(0.0, 2.0); + fader.setSkewFactorFromMidPoint(0.25); + fader.setValue(audioProcessor.getLocalChannelGain(i), juce::dontSendNotification); + fader.setDoubleClickReturnValue(true, 1.0); + fader.setLookAndFeel(&mixerFaderLookAndFeel); + + fader.onValueChange = [this, i] + { + float value = (float)localFaders[(size_t)i].getValue(); + audioProcessor.setLocalChannelGain(i, value); + if (i == 0) + audioProcessor.setLocalInputGain(value); + }; + registerMidiLearnTarget(fader, "local.fader." + juce::String(i + 1), false); + + auto& revSend = localReverbSendKnobs[(size_t)i]; + revSend.setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag); + revSend.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + revSend.setRange(0.0, 1.0); + revSend.setValue(audioProcessor.getLocalChannelReverbSend(i), juce::dontSendNotification); + revSend.setLookAndFeel(&customKnobLookAndFeel); + revSend.setTooltip("Reverb Send"); + revSend.onValueChange = [this, i] + { + audioProcessor.setLocalChannelReverbSend(i, (float)localReverbSendKnobs[(size_t)i].getValue()); + }; + registerMidiLearnTarget(revSend, "local.send.reverb." + juce::String(i + 1), false); + + auto& dlySend = localDelaySendKnobs[(size_t)i]; + dlySend.setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag); + dlySend.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + dlySend.setRange(0.0, 1.0); + dlySend.setValue(audioProcessor.getLocalChannelDelaySend(i), juce::dontSendNotification); + dlySend.setLookAndFeel(&customKnobLookAndFeel); + dlySend.setTooltip("Delay Send"); + dlySend.onValueChange = [this, i] + { + audioProcessor.setLocalChannelDelaySend(i, (float)localDelaySendKnobs[(size_t)i].getValue()); + }; + registerMidiLearnTarget(dlySend, "local.send.delay." + juce::String(i + 1), false); + + auto& revLbl = localReverbSendLabels[(size_t)i]; + revLbl.setText("Rev", juce::dontSendNotification); + revLbl.setJustificationType(juce::Justification::centred); + revLbl.setFont(juce::Font(9.0f)); + + auto& dlyLbl = localDelaySendLabels[(size_t)i]; + dlyLbl.setText("Dly", juce::dontSendNotification); + dlyLbl.setJustificationType(juce::Justification::centred); + dlyLbl.setFont(juce::Font(9.0f)); + + auto& selector = localInputSelectors[(size_t)i]; + selector.clear(juce::dontSendNotification); + + for (int ch = 0; ch < totalInputs; ++ch) + selector.addItem("In " + juce::String(ch + 1), ch + 1); + + int stereoBaseId = 100; + for (int pair = 0; pair < numPairs; ++pair) + { + int left = pair * 2 + 1; + int right = left + 1; + selector.addItem(juce::String(left) + "/" + juce::String(right), stereoBaseId + pair); + } + + int currentInput = audioProcessor.getLocalChannelInput(i); + if (currentInput >= 0 && currentInput < totalInputs) + { + selector.setSelectedId(currentInput + 1, juce::dontSendNotification); + } + else if (currentInput < 0) + { + int pairIndex = -1 - currentInput; + if (numPairs > pairIndex) + { + selector.setSelectedId(stereoBaseId + pairIndex, juce::dontSendNotification); + } + else if (numPairs > 0) + { + // Preferred pair unavailable, use first available stereo pair + selector.setSelectedId(stereoBaseId, juce::dontSendNotification); + audioProcessor.setLocalChannelInput(i, -1); + } + else if (totalInputs > 0) + { + // No stereo pairs at all, fall back to mono channel 1 + selector.setSelectedId(1, juce::dontSendNotification); + audioProcessor.setLocalChannelInput(i, 0); + } + } + + selector.onChange = [this, i] + { + int id = localInputSelectors[(size_t)i].getSelectedId(); + if (id <= 0) + return; + + int total = audioProcessor.getTotalNumInputChannels(); + if (total <= 0) + total = 2; + int numPairsLocal = total / 2; + int stereoBase = 100; + + if (id >= 1 && id <= total) + { + audioProcessor.setLocalChannelInput(i, id - 1); + applyRemoteMidiRelaySelection(i, id - 1); + localInputModeSelectors[(size_t)i].setSelectedId(1, juce::dontSendNotification); + } + else if (id >= stereoBase && id < stereoBase + numPairsLocal) + { + int pairIndex = id - stereoBase; + audioProcessor.setLocalChannelInput(i, -1 - pairIndex); + applyRemoteMidiRelaySelection(i, -1 - pairIndex); + localInputModeSelectors[(size_t)i].setSelectedId(2, juce::dontSendNotification); + } + }; + + auto& modeSelector = localInputModeSelectors[(size_t)i]; + modeSelector.addItem("Mono", 1); + modeSelector.addItem("Stereo", 2); + modeSelector.setSelectedId(currentInput < 0 ? 2 : 1, juce::dontSendNotification); + } + + addAndMakeVisible(masterFaderLabel); + masterFaderLabel.setJustificationType(juce::Justification::centred); + addAndMakeVisible(masterFader); + masterFader.setSliderStyle(juce::Slider::LinearVertical); + masterFader.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + masterFader.setRange(0.0, 2.0); + masterFader.setSkewFactorFromMidPoint(0.25); + masterFader.setValue(1.0, juce::dontSendNotification); + masterFader.setDoubleClickReturnValue(true, 1.0); + masterFader.setLookAndFeel(&mixerFaderLookAndFeel); + masterFader.onValueChange = [this] + { + audioProcessor.setMasterOutputGain((float)masterFader.getValue()); + }; + registerMidiLearnTarget(masterFader, "master.fader", false); + addAndMakeVisible(masterPeakMeter); + addAndMakeVisible(masterDbLabel); + masterDbLabel.setFont(juce::Font(9.0f)); + masterDbLabel.setJustificationType(juce::Justification::centred); + addAndMakeVisible(limiterButton); + addAndMakeVisible(limiterReleaseLabel); + limiterReleaseLabel.setJustificationType(juce::Justification::centred); + limiterReleaseLabel.setTooltip("Limiter Release Amount"); + limiterButton.setClickingTogglesState(true); + limiterButton.setTooltip("Master Limiter Gain"); + limiterButton.setToggleState(false, juce::dontSendNotification); + limiterButton.onClick = [this] + { + audioProcessor.setMasterLimiterEnabled(limiterButton.getToggleState()); + updateLimiterButtonColor(); + }; + updateLimiterButtonColor(); + addAndMakeVisible(limiterThresholdSlider); + limiterThresholdSlider.setSliderStyle(juce::Slider::LinearVertical); + limiterThresholdSlider.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + limiterThresholdSlider.setRange(-6.0, 0.0); + limiterThresholdSlider.setValue(0.0, juce::dontSendNotification); + limiterThresholdSlider.setLookAndFeel(&mixerFaderLookAndFeel); + limiterThresholdSlider.onValueChange = [this] + { + audioProcessor.setLimiterThreshold((float)limiterThresholdSlider.getValue()); + }; + registerMidiLearnTarget(limiterThresholdSlider, "limiter.threshold", false); + + addAndMakeVisible(limiterReleaseSlider); + limiterReleaseSlider.setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag); + limiterReleaseSlider.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + limiterReleaseSlider.setRange(10.0, 1000.0); + limiterReleaseSlider.setValue(100.0, juce::dontSendNotification); + limiterReleaseSlider.setTooltip("Limiter Release Amount"); + limiterReleaseSlider.setLookAndFeel(&customKnobLookAndFeel); + limiterReleaseSlider.onValueChange = [this] + { + audioProcessor.setLimiterRelease((float)limiterReleaseSlider.getValue()); + }; + registerMidiLearnTarget(limiterReleaseSlider, "limiter.release", false); + + addAndMakeVisible(delayTimeLabel); + delayTimeLabel.setJustificationType(juce::Justification::centred); + addAndMakeVisible(delayTimeSlider); + delayTimeSlider.setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag); + delayTimeSlider.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + delayTimeSlider.setRange(20.0, 2000.0); + delayTimeSlider.setValue(audioProcessor.getFxDelayTimeMs(), juce::dontSendNotification); + delayTimeSlider.setLookAndFeel(&customKnobLookAndFeel); + delayTimeSlider.onValueChange = [this] + { + audioProcessor.setFxDelayTimeMs((float)delayTimeSlider.getValue()); + }; + registerMidiLearnTarget(delayTimeSlider, "fx.delay.time", false); + + addAndMakeVisible(delayDivisionSelector); + delayDivisionSelector.addItem("1/16", 16); + delayDivisionSelector.addItem("1/8", 8); + delayDivisionSelector.addItem("1/1", 1); + delayDivisionSelector.setSelectedId(audioProcessor.getFxDelayDivision(), juce::dontSendNotification); + delayDivisionSelector.onChange = [this] + { + int division = delayDivisionSelector.getSelectedId(); + if (division <= 0) + division = 8; + audioProcessor.setFxDelayDivision(division); + }; + + addAndMakeVisible(delayPingPongButton); + delayPingPongButton.setClickingTogglesState(true); + delayPingPongButton.setToggleState(audioProcessor.isFxDelayPingPong(), juce::dontSendNotification); + delayPingPongButton.onClick = [this] + { + audioProcessor.setFxDelayPingPong(delayPingPongButton.getToggleState()); + }; + registerMidiLearnTarget(delayPingPongButton, "fx.delay.pingpong", true); + + addAndMakeVisible(chatDisplay); + chatDisplay.setMultiLine(true); + chatDisplay.setReadOnly(true); + chatDisplay.setFont(juce::Font(14.0f)); + + addAndMakeVisible(chatInput); + chatInput.onReturnKey = [this] { sendClicked(); }; + + addAndMakeVisible(sendButton); + sendButton.onClick = [this] { sendClicked(); }; + + addAndMakeVisible(atButton); + atButton.setClickingTogglesState(true); + atButton.setWantsKeyboardFocus(false); + atButton.setToggleState(false, juce::dontSendNotification); + atButton.setLookAndFeel(&atBtnLAF); + atButton.onClick = [this] { atToggled(); }; + + addAndMakeVisible(chatPopoutButton); + chatPopoutButton.setButtonText("Popout"); + chatPopoutButton.onClick = [this] { chatPopoutClicked(); }; + + addAndMakeVisible(videoBgToggle); + videoBgToggle.setToggleState(true, juce::dontSendNotification); + videoBgToggle.onClick = [this] + { + int idx = backgroundSelector.getSelectedItemIndex(); + if (idx >= 0 && idx < textureFiles.size()) + loadControlImages(textureFiles[idx]); + }; + + addAndMakeVisible(backgroundSelector); + backgroundSelector.setTooltip("Skin"); + { + auto texturesDir = juce::File::getSpecialLocation(juce::File::currentExecutableFile) + .getParentDirectory().getChildFile("textures"); + if (texturesDir.isDirectory()) + { + // Each subdirectory is a theme; its name shows in the dropdown + auto dirs = texturesDir.findChildFiles(juce::File::findDirectories, false); + dirs.sort(); + for (int i = 0; i < dirs.size(); ++i) + { + if (dirs[i].getFileName().equalsIgnoreCase("Skin Template")) + continue; + // Only include dirs that contain a bg.* file + auto bgFiles = dirs[i].findChildFiles(juce::File::findFiles, false, "bg.*"); + if (bgFiles.isEmpty()) continue; + textureFiles.add(dirs[i]); + backgroundSelector.addItem(dirs[i].getFileName(), textureFiles.size()); + } + } + if (backgroundSelector.getNumItems() == 0) + backgroundSelector.addItem("Default", 1); + + // Determine which texture to select: saved preference > "Brushed Metal 1" > first item + juce::PropertiesFile::Options popts; + popts.applicationName = "NINJAM VST3"; + popts.filenameSuffix = "settings"; + popts.folderName = "NINJAM VST3"; + popts.osxLibrarySubFolder = "Application Support"; + juce::PropertiesFile props(popts); + juce::String savedTexture = props.getValue("texture", ""); + abletonWindowSizePreset = juce::jlimit(0, 2, props.getIntValue("abletonWindowSizePreset", 1)); + + int selectIdx = -1; + if (savedTexture.isNotEmpty()) + for (int i = 0; i < textureFiles.size(); ++i) + if (textureFiles[i].getFileName() == savedTexture) { selectIdx = i; break; } + if (selectIdx < 0) + for (int i = 0; i < textureFiles.size(); ++i) + if (textureFiles[i].getFileName() == "Brushed Metal 1") { selectIdx = i; break; } + if (selectIdx < 0 && backgroundSelector.getNumItems() > 0) + selectIdx = 0; + + if (selectIdx >= 0) + backgroundSelector.setSelectedId(backgroundSelector.getItemId(selectIdx), juce::dontSendNotification); + + // Load the selected texture immediately + if (selectIdx >= 0 && selectIdx < textureFiles.size()) + loadControlImages(textureFiles[selectIdx]); + } + backgroundSelector.onChange = [this] + { + int idx = backgroundSelector.getSelectedItemIndex(); + if (idx >= 0 && idx < textureFiles.size()) + { + // Persist the user's choice + juce::PropertiesFile::Options popts; + popts.applicationName = "NINJAM VST3"; + popts.filenameSuffix = "settings"; + popts.folderName = "NINJAM VST3"; + popts.osxLibrarySubFolder = "Application Support"; + juce::PropertiesFile props(popts); + props.setValue("texture", textureFiles[idx].getFileName()); + props.saveIfNeeded(); + + loadControlImages(textureFiles[idx]); + } + else + { + backgroundImage = juce::Image(); + radioKnobImage = juce::Image(); + faderKnobImage = juce::Image(); + metronomeThemeColour = juce::Colour::fromRGB(80, 185, 255); + windowThemeColour = juce::Colour(0x00000000); + applyThemeColours(); + } + repaint(); + }; + + if (isAbletonLiveHost() && !audioProcessor.isStandaloneWrapper()) + setAbletonWindowSizePreset(abletonWindowSizePreset); + + addAndMakeVisible(intervalDisplay); + registerMidiLearnTarget(metronomeSlider, "metronome.level", false); + registerMidiLearnTarget(transmitButton, "button.transmit", true); + registerMidiLearnTarget(localMonitorButton, "button.monitor", true); + registerMidiLearnTarget(voiceChatButton, "button.voicechat", true); + registerMidiLearnTarget(metronomeMuteButton, "button.metronomemute", true); + registerMidiLearnTarget(syncButton, "button.sync", true); + registerMidiLearnTarget(chatButton, "button.chat", true); + registerMidiLearnTarget(spreadOutputsButton, "button.spreadoutputs", true); + registerMidiLearnTarget(autoLevelButton, "button.autolevel", true); + registerMidiLearnTarget(limiterButton, "button.limiter", true); + syncUserStripMidiTargets(); + updateFxControlsVisibility(); + loadLearnMappingsFromProcessor(); + refreshExternalMidiInputDevices(); + + startTimer(30); +} + +NinjamVst3AudioProcessorEditor::~NinjamVst3AudioProcessorEditor() +{ +#if JUCE_WINDOWS + videoFrameReader.reset(); +#endif + for (auto& pair : midiTargetsByComponent) + if (pair.first != nullptr) + pair.first->removeMouseListener(this); + midiTargetsByComponent.clear(); + midiTargetsById.clear(); + midiSourceByTargetId.clear(); + oscSourceByTargetId.clear(); + midiLearnArmedTargetId.clear(); + oscLearnArmedTargetId.clear(); + midiLearnInputDevice.reset(); + midiRelayInputDevice.reset(); + openedMidiLearnInputDeviceId.clear(); + openedMidiRelayInputDeviceId.clear(); + stopTimer(); + disconnect(); + atButton.setLookAndFeel(nullptr); + chatButton.setLookAndFeel(nullptr); + metronomeMuteButton.setLookAndFeel(nullptr); + juce::LookAndFeel::setDefaultLookAndFeel(nullptr); +} + +void NinjamVst3AudioProcessorEditor::paint (juce::Graphics& g) +{ + if (backgroundImage.isValid()) + { + g.drawImageWithin(backgroundImage, 0, 0, getWidth(), getHeight(), juce::RectanglePlacement::fillDestination); + } + else + { + // Window Colour sets the app background; falls back to dark grey if not set + juce::Colour base = (windowThemeColour.getAlpha() > 0) ? windowThemeColour : juce::Colour(0xff222222); + g.fillAll(base); + } +} + +void NinjamVst3AudioProcessorEditor::paintOverChildren(juce::Graphics& g) +{ + // Helper: draw a small tight radial glow around any toggle button + auto drawGlow = [&](juce::Button& btn, juce::Colour onColour, juce::Colour offColour) + { + if (!btn.isVisible()) return; + bool isOn = btn.getToggleState(); + auto bc = btn.getBounds().toFloat(); + auto centre = bc.getCentre(); + // Glow starts a few px outside the button edge + float gap = 5.0f; + float r = bc.getWidth() * 0.55f + gap; // compact radius + juce::Colour col = isOn ? onColour : offColour; + juce::ColourGradient grad(col, centre.x, centre.y, + juce::Colours::transparentBlack, centre.x + r, centre.y, true); + g.setGradientFill(grad); + g.fillEllipse(centre.x - r, centre.y - r, r * 2.0f, r * 2.0f); + }; + + drawGlow(transmitButton, juce::Colour(0x5532cc60), juce::Colour(0x22154420)); // green + drawGlow(localMonitorButton, juce::Colour(0x55ff3232), juce::Colour(0x22441515)); // red + drawGlow(autoLevelButton, juce::Colour(0x55ffdd20), juce::Colour(0x22443a10)); // yellow + drawGlow(limiterButton, juce::Colour(0x55ff3232), juce::Colour(0x22441515)); // red + drawGlow(layoutButton, juce::Colour(0x5520c8e8), juce::Colour(0x220a3240)); // teal + drawGlow(metronomeMuteButton, + metronomeThemeColour.withAlpha(0.33f), + metronomeThemeColour.withMultipliedBrightness(0.10f).withAlpha(0.13f)); // themed + drawGlow(syncButton, juce::Colour(0x55ff9820), juce::Colour(0x22301808)); // orange + if (atButton.isVisible()) + drawGlow(atButton, juce::Colour(0x5550c8ff), juce::Colour(0x220a2840)); // sky blue + drawGlow(chatButton, juce::Colour(0x5550c8ff), juce::Colour(0x220a2840)); // sky blue +} + +void NinjamVst3AudioProcessorEditor::resized() +{ + if (!audioProcessor.isStandaloneWrapper() && !applyingDeferredResizeLayout) + { + pendingDeferredResizeLayout = true; + lastResizeEventMs = juce::Time::getMillisecondCounterHiRes(); + return; + } + + auto area = getLocalBounds().reduced(10); + + // Bottom: Interval Display + auto bottomRow = area.removeFromBottom(40); + intervalDisplay.setBounds(bottomRow); + area.removeFromBottom(10); + + auto topRow = area.removeFromTop(30); + // Right side of top row: texture / video-bg controls only + backgroundSelector.setBounds(topRow.removeFromRight(150)); + topRow.removeFromRight(4); + videoBgToggle.setBounds(topRow.removeFromRight(90)); + topRow.removeFromRight(10); + // Left side: server fields + serverLabel.setBounds(topRow.removeFromLeft(75)); + serverField.setBounds(topRow.removeFromLeft(160)); + topRow.removeFromLeft(6); + serverListButton.setBounds(topRow.removeFromLeft(72)); + topRow.removeFromLeft(6); + userLabel.setBounds(topRow.removeFromLeft(55)); + userField.setBounds(topRow.removeFromLeft(90)); + topRow.removeFromLeft(6); + anonymousButton.setBounds(topRow.removeFromLeft(110)); + topRow.removeFromLeft(6); + passLabel.setBounds(topRow.removeFromLeft(52)); + passField.setBounds(topRow.removeFromLeft(80)); + topRow.removeFromLeft(6); + connectButton.setBounds(topRow.removeFromLeft(80)); + topRow.removeFromLeft(10); + statusLabel.setBounds(topRow); + + area.removeFromTop(4); + + // Controls Row: layout, auto-level, metronome, tempo — chat+video buttons on the right + auto controlsRow = area.removeFromTop(30); + videoButton.setBounds(controlsRow.removeFromRight(100)); + controlsRow.removeFromRight(5); + chatButton.setBounds(controlsRow.removeFromRight(80)); + controlsRow.removeFromRight(10); + layoutButton.setBounds(controlsRow.removeFromLeft(40)); // icon-only button + controlsRow.removeFromLeft(10); + autoLevelButton.setBounds(controlsRow.removeFromLeft(110)); + controlsRow.removeFromLeft(10); + metronomeLabel.setBounds(controlsRow.removeFromLeft(90)); + metronomeSlider.setBounds(controlsRow.removeFromLeft(80)); + auto metBtn = controlsRow.removeFromLeft(24); + metronomeMuteButton.setBounds(metBtn.reduced(0, 3)); + controlsRow.removeFromLeft(6); + auto syncBtn = controlsRow.removeFromLeft(24); + syncButton.setBounds(syncBtn.reduced(0, 3)); + controlsRow.removeFromLeft(10); + fxButton.setBounds(controlsRow.removeFromLeft(70)); + controlsRow.removeFromLeft(8); + optionsButton.setBounds(controlsRow.removeFromLeft(78)); + controlsRow.removeFromLeft(8); + tempoLabel.setBounds(controlsRow); + + area.removeFromTop(10); + + bool showDockedChat = chatButton.getToggleState() && !chatPoppedOut; + juce::Rectangle chatArea; + if (showDockedChat) + { + auto chatWidth = (int)(area.getWidth() * 0.20f); + chatArea = area.removeFromRight(chatWidth); + area.removeFromRight(10); + } + + int numLocal = audioProcessor.getNumLocalChannels(); + numLocal = juce::jlimit(1, NinjamVst3AudioProcessor::maxLocalChannels, numLocal); + + int baseLocalWidth = 110; + int extraPerTrack = 40; + int localWidth = baseLocalWidth + (numLocal - 1) * extraPerTrack; + int maxLocalWidth = area.getWidth() / 2; + if (localWidth > maxLocalWidth) + localWidth = maxLocalWidth; + + int masterWidth = 190; + + auto localArea = area.removeFromLeft(localWidth); + auto masterArea = area.removeFromRight(masterWidth); + auto userArea = area; + + auto usersHeader = userArea.removeFromTop(20); + spreadOutputsButton.setBounds(usersHeader.removeFromLeft(110)); + usersLabel.setBounds(usersHeader); + userList.setBounds(userArea); + + // Transmit above local channels, monitor below it + transmitButton.setBounds(localArea.removeFromTop(26)); + localArea.removeFromTop(3); + localMonitorButton.setBounds(localArea.removeFromTop(26)); + localArea.removeFromTop(3); + { + auto row = localArea.removeFromTop(26); + auto third = row.getWidth() / 3; + voiceChatButton.setBounds(row.removeFromLeft(third)); + bitrateSelector.setBounds(row.removeFromLeft(third)); + midiRelayTargetSelector.setBounds(row); + } + localArea.removeFromTop(3); + + auto localHeader = localArea.removeFromTop(20); + addLocalChannelButton.setBounds(localHeader.removeFromLeft(20)); + removeLocalChannelButton.setBounds(localHeader.removeFromLeft(20)); + localFaderLabel.setBounds(localHeader); + auto localInner = localArea.reduced(4); + + int meterWidth = 10; + int totalWidth = localInner.getWidth(); + int columnWidth = totalWidth / numLocal; + + for (int i = 0; i < NinjamVst3AudioProcessor::maxLocalChannels; ++i) + { + bool visible = i < numLocal; + localFaders[(size_t)i].setVisible(visible); + localPeakMeters[(size_t)i].setVisible(visible); + localInputSelectors[(size_t)i].setVisible(visible); + localInputModeSelectors[(size_t)i].setVisible(visible); + localDbLabels[(size_t)i].setVisible(visible); + localChannelNameLabels[(size_t)i].setVisible(visible); + localReverbSendKnobs[(size_t)i].setVisible(visible); + localDelaySendKnobs[(size_t)i].setVisible(visible); + localReverbSendLabels[(size_t)i].setVisible(visible); + localDelaySendLabels[(size_t)i].setVisible(visible); + } + + for (int i = 0; i < numLocal; ++i) + { + juce::Rectangle col = localInner.removeFromLeft(columnWidth); + auto meterArea = col.removeFromLeft(meterWidth); + auto nameArea = col.removeFromTop(18); + auto dbArea = col.removeFromBottom(16); + auto inputArea = col.removeFromBottom(20); + auto inputModeArea = col.removeFromBottom(20); + auto sendArea = col.removeFromBottom(36); + auto revArea = sendArea.removeFromLeft(sendArea.getWidth() / 2); + auto dlyArea = sendArea; + auto revLabelArea = revArea.removeFromTop(10); + auto dlyLabelArea = dlyArea.removeFromTop(10); + localFaders[(size_t)i].setBounds(col); + localPeakMeters[(size_t)i].setBounds(meterArea); + localInputSelectors[(size_t)i].setBounds(inputArea); + localInputModeSelectors[(size_t)i].setBounds(inputModeArea); + localDbLabels[(size_t)i].setBounds(dbArea); + localChannelNameLabels[(size_t)i].setBounds(nameArea); + localReverbSendLabels[(size_t)i].setBounds(revLabelArea); + localDelaySendLabels[(size_t)i].setBounds(dlyLabelArea); + localReverbSendKnobs[(size_t)i].setBounds(revArea.reduced(2)); + localDelaySendKnobs[(size_t)i].setBounds(dlyArea.reduced(2)); + } + + masterFaderLabel.setBounds(masterArea.removeFromTop(20)); + auto masterInner = masterArea.reduced(4); + auto masterMeterWidth = 10; + auto masterMeterArea = masterInner.removeFromRight(masterMeterWidth); + auto controlColumn = masterInner.removeFromLeft(70); + auto fxColumn = masterInner; + + limiterButton.setBounds(controlColumn.removeFromTop(20)); + + int bottomHeight = 70; + if (bottomHeight > controlColumn.getHeight()) + bottomHeight = controlColumn.getHeight(); + + auto threshArea = controlColumn.removeFromTop(controlColumn.getHeight() - bottomHeight); + limiterThresholdSlider.setBounds(threshArea); + + auto releaseBlock = controlColumn; + limiterReleaseLabel.setBounds(releaseBlock.removeFromTop(18)); + + auto knobArea = releaseBlock.reduced(6, 0); + int knobSize = juce::jmin(knobArea.getWidth(), knobArea.getHeight()); + juce::Rectangle knobRect(0, 0, knobSize, knobSize); + knobRect = knobRect.withCentre(knobArea.getCentre()); + limiterReleaseSlider.setBounds(knobRect); + + auto delayBlock = fxColumn.removeFromTop(70); + delayTimeLabel.setBounds(delayBlock.removeFromTop(16)); + auto delayKnobBounds = delayBlock.reduced(4); + int delayKnobSize = juce::jmin(delayKnobBounds.getWidth(), delayKnobBounds.getHeight()); + delayTimeSlider.setBounds(juce::Rectangle(delayKnobSize, delayKnobSize).withCentre(delayKnobBounds.getCentre())); + + fxColumn.removeFromTop(2); + delayDivisionSelector.setBounds(fxColumn.removeFromTop(22)); + fxColumn.removeFromTop(2); + delayPingPongButton.setBounds(fxColumn.removeFromTop(22)); + + masterFader.setBounds(masterInner.removeFromTop(masterInner.getHeight() - 16)); + masterDbLabel.setBounds(masterInner); + masterPeakMeter.setBounds(masterMeterArea); + + if (showDockedChat) + { + chatDisplay.setVisible(true); + chatInput.setVisible(true); + sendButton.setVisible(true); + atButton.setVisible(true); + chatPopoutButton.setVisible(true); + + chatPopoutButton.setBounds(chatArea.removeFromTop(20).removeFromRight(70)); + + auto chatInputArea = chatArea.removeFromBottom(30); + chatArea.removeFromBottom(5); + chatDisplay.setBounds(chatArea); + + sendButton.setBounds(chatInputArea.removeFromRight(60)); + chatInputArea.removeFromRight(5); + atButton.setBounds(chatInputArea.removeFromRight(40)); + chatInputArea.removeFromRight(5); + chatInput.setBounds(chatInputArea); + } + else + { + chatDisplay.setVisible(false); + chatInput.setVisible(false); + sendButton.setVisible(false); + atButton.setVisible(false); + chatPopoutButton.setVisible(false); + } +} + +void NinjamVst3AudioProcessorEditor::timerCallback() +{ + const double nowMs = juce::Time::getMillisecondCounterHiRes(); + + if (pendingDeferredResizeLayout && !audioProcessor.isStandaloneWrapper()) + { + if (nowMs - lastResizeEventMs >= 85.0) + { + pendingDeferredResizeLayout = false; + applyingDeferredResizeLayout = true; + resized(); + applyingDeferredResizeLayout = false; + suppressHeavyUiUntilMs = nowMs + 350.0; + repaint(); + } + else + { + return; + } + } + + int status = audioProcessor.getClient().GetStatus(); + updateHostResizeModeForConnectionStatus(status); + juce::String statusStr; + switch (status) + { + case NJClient::NJC_STATUS_DISCONNECTED: statusStr = "Disconnected"; break; + case NJClient::NJC_STATUS_INVALIDAUTH: statusStr = "Invalid Auth"; break; + case NJClient::NJC_STATUS_CANTCONNECT: statusStr = "Can't Connect"; break; + case NJClient::NJC_STATUS_OK: statusStr = "Connected"; break; + case NJClient::NJC_STATUS_PRECONNECT: statusStr = "Connecting..."; break; + default: statusStr = "Unknown (" + juce::String(status) + ")"; break; + } + statusLabel.setText(statusStr, juce::dontSendNotification); + + if (status == NJClient::NJC_STATUS_OK || status == NJClient::NJC_STATUS_PRECONNECT) + connectButton.setButtonText("Disconnect"); + else + connectButton.setButtonText("Connect"); + + if (shouldDeferHeavyUiWork()) + return; + + // Chat + { + const juce::ScopedTryLock lock(audioProcessor.chatLock); + if (lock.isLocked()) + { + const auto& history = audioProcessor.chatHistory; + const auto& senders = audioProcessor.chatSenders; + + if (history.size() != lastChatSize) + { + applyColoredChat(chatDisplay, history, senders); + lastChatSize = history.size(); + + if (chatWindow) + { + if (auto* popup = dynamic_cast(chatWindow->getContentComponent())) + popup->setChatText(history, senders); + } + } + } + } + + const bool heavyUiAllowed = nowMs >= suppressHeavyUiUntilMs; + const bool runHeavyUiTick = ((++heavyUiTickCounter % 6) == 0); + if (heavyUiAllowed && runHeavyUiTick) + { + userList.updateContent(); + syncUserStripMidiTargets(); + refreshMidiRelayTargetSelector(); + } + applyMidiMappings(); + applyOscMappings(); + + int numLocal = audioProcessor.getNumLocalChannels(); + numLocal = juce::jlimit(1, NinjamVst3AudioProcessor::maxLocalChannels, numLocal); + for (int i = 0; i < numLocal; ++i) + { + float peak = audioProcessor.getLocalChannelPeak(i); + localPeakMeters[(size_t)i].setPeak(peak); + float db = -60.0f; + if (peak > 1.0e-6f) + db = juce::jlimit(-60.0f, 6.0f, 20.0f * std::log10(peak)); + localDbLabels[(size_t)i].setText(juce::String(db, 1) + " dB", juce::dontSendNotification); + } + + float masterPk = audioProcessor.getMasterPeak(); + masterPeakMeter.setPeak(masterPk); + { + float db = -60.0f; + if (masterPk > 1.0e-6f) + db = juce::jlimit(-60.0f, 6.0f, 20.0f * std::log10(masterPk)); + masterDbLabel.setText(juce::String((int)std::round(db)) + " dB", juce::dontSendNotification); + } + + if (autoLevelEnabled && runHeavyUiTick) + { + std::vector users = audioProcessor.getConnectedUsers(); + if (!users.empty()) + { + const float timerIntervalMs = 50.0f; + const float noiseFloor = 0.04f; + const float baseTargetLevel = 0.4f; + const float attackCoeff = 1.0f - std::exp(-timerIntervalMs / 200.0f); + const float releaseCoeff = 1.0f - std::exp(-timerIntervalMs / 1350.0f); + const float longTermDecayCoeff = 1.0f - std::exp(-timerIntervalMs / 2000.0f); + + float masterPeak = audioProcessor.getMasterPeak(); + const float targetMasterLevel = 0.4f; + float maxGain = 3.0f; + if (masterPeak < 0.25f) maxGain = 4.0f; + else if (masterPeak < 0.5f) maxGain = 3.5f; + + float globalGain = 1.0f; + if (masterPeak > 0.0001f) + globalGain = juce::jlimit(0.5f, 2.0f, targetMasterLevel / masterPeak); + + std::set activeIds; + + for (auto& u : users) + { + int id = u.index; + activeIds.insert(id); + + float peakL = audioProcessor.getUserPeak(id, 0); + float peakR = audioProcessor.getUserPeak(id, 1); + float currentLevel = juce::jmax(peakL, peakR); + + bool clipEnabled = audioProcessor.isUserClipEnabled(id); + if (clipEnabled) + { + auto softClipLevel = [](float x) + { + const float k = 2.0f; + const float d = std::tanh(k); + const float c = d / k; + const float target = 0.630957f; + float y = std::tanh(k * c * x); + if (d != 0.0f) y = (y / d) * target; + return y; + }; + currentLevel = softClipLevel(currentLevel); + } + + if (!autoLevelCurrentGains.count(id)) autoLevelCurrentGains[id] = u.volume; + if (!autoLevelPeakLevels.count(id)) autoLevelPeakLevels[id] = 0.0f; + if (!autoLevelChannelActiveTicks.count(id)) autoLevelChannelActiveTicks[id] = 0; + else autoLevelChannelActiveTicks[id]++; + + bool isNew = autoLevelChannelActiveTicks[id] < 40; + float& longTermPeak = autoLevelPeakLevels[id]; + + if (currentLevel >= noiseFloor) + longTermPeak += (currentLevel - longTermPeak) * longTermDecayCoeff; + else if (longTermPeak > 0.0f) + longTermPeak -= longTermPeak * (longTermDecayCoeff * 0.5f); + + longTermPeak = juce::jlimit(0.0f, 1.0f, longTermPeak); + + if (longTermPeak < noiseFloor) + { + autoLevelCurrentGains[id] += (1.0f - autoLevelCurrentGains[id]) * releaseCoeff; + audioProcessor.rememberUserVolume(id, autoLevelCurrentGains[id], u.name); + audioProcessor.setUserVolume(id, autoLevelCurrentGains[id]); + continue; + } + + float targetGain = juce::jlimit(0.1f, maxGain, (baseTargetLevel / longTermPeak) * globalGain); + + float estimatedOutput = currentLevel * targetGain; + if (!clipEnabled && estimatedOutput > 0.99f && currentLevel > noiseFloor) + targetGain = juce::jlimit(0.1f, maxGain, 0.95f / currentLevel); + + bool reducing = targetGain < autoLevelCurrentGains[id]; + float smoothingCoeff = reducing ? releaseCoeff : attackCoeff; + if (isNew) smoothingCoeff *= 0.5f; + + autoLevelCurrentGains[id] += (targetGain - autoLevelCurrentGains[id]) * smoothingCoeff; + autoLevelCurrentGains[id] = juce::jlimit(0.0f, maxGain, autoLevelCurrentGains[id]); + + audioProcessor.rememberUserVolume(id, autoLevelCurrentGains[id], u.name); + audioProcessor.setUserVolume(id, autoLevelCurrentGains[id]); + } + + for (auto it = autoLevelCurrentGains.begin(); it != autoLevelCurrentGains.end();) + { + if (!activeIds.count(it->first)) + { + int id = it->first; + autoLevelPeakLevels.erase(id); + autoLevelChannelActiveTicks.erase(id); + it = autoLevelCurrentGains.erase(it); + } + else { ++it; } + } + } + } + + intervalDisplay.repaint(); + + // Advance video background frame if active (Windows only) +#if JUCE_WINDOWS + if (videoFrameReader != nullptr) + { + auto frame = videoFrameReader->getLatestFrame(); + if (frame.isValid()) + { + backgroundImage = std::move(frame); + repaint(); + } + } +#endif + + updateVoiceChatButtonColor(); + + double hostBpm = 0.0; + bool hostPlaying = false; + { + juce::AudioPlayHead::CurrentPositionInfo info; + if (audioProcessor.getHostPosition(info)) + { + hostBpm = info.bpm; + hostPlaying = info.isPlaying; + } + } + + float njBpm = audioProcessor.getBPM(); + int bpi = audioProcessor.getBPI(); + + juce::String text; + text << "NJ " << juce::String(njBpm, 1) << " / " << bpi << " BPI"; + if (hostBpm > 0.0) + text << " | Host " << juce::String(hostBpm, 1) << (hostPlaying ? " (Play)" : " (Stop)"); + + int codecMode = audioProcessor.getCodecMode(); + juce::String codec; + if (codecMode == 2) codec = "Opus"; + else if (codecMode == 1) codec = "Vorbis+Opus"; + else codec = "Vorbis"; + text << " | Codec " << codec; + + tempoLabel.setText(text, juce::dontSendNotification); + + if (!delayTimeSlider.isMouseButtonDown()) + delayTimeSlider.setValue(audioProcessor.getFxDelayTimeMs(), juce::dontSendNotification); + delayDivisionSelector.setSelectedId(audioProcessor.getFxDelayDivision(), juce::dontSendNotification); + delayPingPongButton.setToggleState(audioProcessor.isFxDelayPingPong(), juce::dontSendNotification); + updateFxButtonLabel(); + updateFxControlsVisibility(); +} + +void NinjamVst3AudioProcessorEditor::mouseDown(const juce::MouseEvent& event) +{ + if (!(event.mods.isPopupMenu() || event.mods.isRightButtonDown())) + return; + + juce::Component* start = event.originalComponent != nullptr ? event.originalComponent : event.eventComponent; + for (auto* c = start; c != nullptr; c = c->getParentComponent()) + { + auto it = midiTargetsByComponent.find(c); + if (it != midiTargetsByComponent.end()) + { + showMidiLearnMenuForComponent(*c, event.getScreenPosition()); + return; + } + } +} + +void NinjamVst3AudioProcessorEditor::registerMidiLearnTarget(juce::Component& component, const juce::String& targetId, bool isToggle) +{ + auto existing = midiTargetsByComponent.find(&component); + if (existing != midiTargetsByComponent.end() && existing->second.id != targetId) + midiTargetsById.erase(existing->second.id); + + MidiLearnTarget target; + target.id = targetId; + target.component = &component; + target.isToggle = isToggle; + + midiTargetsByComponent[&component] = target; + midiTargetsById[targetId] = target; + component.addMouseListener(this, false); +} + +void NinjamVst3AudioProcessorEditor::syncUserStripMidiTargets() +{ + std::vector componentsToRemove; + for (auto it = midiTargetsById.begin(); it != midiTargetsById.end();) + { + if (it->first.startsWith("user.")) + { + if (it->second.component != nullptr) + componentsToRemove.push_back(it->second.component); + it = midiTargetsById.erase(it); + } + else + { + ++it; + } + } + + for (auto* component : componentsToRemove) + { + if (component != nullptr) + component->removeMouseListener(this); + midiTargetsByComponent.erase(component); + } + + auto strips = userList.getStripPointers(); + for (auto* strip : strips) + { + const int userIdx = strip->getUserIndex(); + const juce::String prefix = "user." + juce::String(userIdx) + "."; + registerMidiLearnTarget(strip->getVolumeSlider(), prefix + "volume", false); + registerMidiLearnTarget(strip->getPanSlider(), prefix + "pan", false); + registerMidiLearnTarget(strip->getMuteButton(), prefix + "mute", true); + registerMidiLearnTarget(strip->getSoloButton(), prefix + "solo", true); + for (int i = 0; i < 8; ++i) + registerMidiLearnTarget(strip->getChannelSlider(i), prefix + "channel." + juce::String(i + 1), false); + } +} + +void NinjamVst3AudioProcessorEditor::showMidiLearnMenuForComponent(juce::Component& component, juce::Point screenPos) +{ + auto it = midiTargetsByComponent.find(&component); + if (it == midiTargetsByComponent.end()) + return; + + const juce::String targetId = it->second.id; + juce::PopupMenu menu; + menu.addItem(3, "OSC Learn"); + menu.addItem(4, "OSC Forget", oscSourceByTargetId.find(targetId) != oscSourceByTargetId.end()); + menu.addSeparator(); + menu.addItem(1, "MIDI Learn"); + menu.addItem(2, "MIDI Forget", midiSourceByTargetId.find(targetId) != midiSourceByTargetId.end()); + menu.addSeparator(); + menu.addItem(10, "Save Learn Mappings"); + menu.addItem(11, "Load Learn Mappings"); + menu.addItem(12, "Forget All Learn Mappings"); + juce::Rectangle popupAnchor(screenPos.x, screenPos.y, 1, 1); + menu.showMenuAsync(juce::PopupMenu::Options().withTargetComponent(&component).withTargetScreenArea(popupAnchor), + [this, targetId](int result) + { + if (result == 3) + { + oscLearnArmedTargetId = targetId; + } + else if (result == 4) + { + oscSourceByTargetId.erase(targetId); + if (oscLearnArmedTargetId == targetId) + oscLearnArmedTargetId.clear(); + syncLearnMappingsToProcessor(); + } + else if (result == 1) + { + midiLearnArmedTargetId = targetId; + } + else if (result == 2) + { + midiSourceByTargetId.erase(targetId); + if (midiLearnArmedTargetId == targetId) + midiLearnArmedTargetId.clear(); + syncLearnMappingsToProcessor(); + } + else if (result == 10) + { + saveLearnMappingsToDisk(); + } + else if (result == 11) + { + loadLearnMappingsFromDisk(); + } + else if (result == 12) + { + clearLearnMappings(); + } + }); +} + +void NinjamVst3AudioProcessorEditor::applyMidiMappings() +{ + auto events = audioProcessor.popPendingMidiControllerEvents(); + if (events.empty()) + return; + + for (const auto& event : events) + { + if (midiLearnArmedTargetId.isNotEmpty() && midiTargetsById.find(midiLearnArmedTargetId) != midiTargetsById.end()) + { + MidiSourceMapping mapping; + mapping.isController = event.isController; + mapping.midiChannel = event.midiChannel; + mapping.number = event.number; + mapping.lastBinaryState = event.isNoteOn ? 1 : 0; + midiSourceByTargetId[midiLearnArmedTargetId] = mapping; + midiLearnArmedTargetId.clear(); + syncLearnMappingsToProcessor(); + } + + for (auto& pair : midiSourceByTargetId) + { + auto targetIt = midiTargetsById.find(pair.first); + if (targetIt == midiTargetsById.end()) + continue; + + auto& mapping = pair.second; + if (mapping.isController != event.isController) + continue; + if (mapping.midiChannel != event.midiChannel || mapping.number != event.number) + continue; + + auto* component = targetIt->second.component; + if (component == nullptr) + continue; + + if (targetIt->second.isToggle) + { + int binaryState = event.isNoteOn ? 1 : (event.value >= 64 ? 1 : 0); + if (binaryState == 1 && mapping.lastBinaryState != 1) + if (auto* button = dynamic_cast(component)) + button->triggerClick(); + mapping.lastBinaryState = binaryState; + } + else + { + if (auto* slider = dynamic_cast(component)) + { + if (slider->isMouseButtonDown()) + continue; + auto range = slider->getRange(); + const double norm = juce::jlimit(0.0, 1.0, (double)event.normalized); + const double value = juce::jlimit(range.getStart(), range.getEnd(), range.getStart() + norm * range.getLength()); + slider->setValue(value, juce::sendNotificationSync); + } + } + } + } +} + +void NinjamVst3AudioProcessorEditor::applyOscMappings() +{ + std::vector events; + { + const juce::SpinLock::ScopedLockType lock(oscEventQueueLock); + events.swap(pendingOscEvents); + } + if (events.empty()) + return; + + for (const auto& event : events) + { + if (oscLearnArmedTargetId.isNotEmpty() && midiTargetsById.find(oscLearnArmedTargetId) != midiTargetsById.end()) + { + OscSourceMapping mapping; + mapping.address = event.address; + mapping.lastBinaryState = event.binaryOn ? 1 : 0; + oscSourceByTargetId[oscLearnArmedTargetId] = mapping; + oscLearnArmedTargetId.clear(); + syncLearnMappingsToProcessor(); + } + + for (auto& pair : oscSourceByTargetId) + { + auto targetIt = midiTargetsById.find(pair.first); + if (targetIt == midiTargetsById.end()) + continue; + auto* component = targetIt->second.component; + if (component == nullptr) + continue; + auto& mapping = pair.second; + if (mapping.address != event.address) + continue; + + if (targetIt->second.isToggle) + { + const int binaryState = event.binaryOn ? 1 : 0; + if (binaryState == 1 && mapping.lastBinaryState != 1) + if (auto* button = dynamic_cast(component)) + button->triggerClick(); + mapping.lastBinaryState = binaryState; + } + else if (auto* slider = dynamic_cast(component)) + { + if (slider->isMouseButtonDown()) + continue; + auto range = slider->getRange(); + const double norm = juce::jlimit(0.0, 1.0, (double)event.normalized); + const double value = juce::jlimit(range.getStart(), range.getEnd(), range.getStart() + norm * range.getLength()); + slider->setValue(value, juce::sendNotificationSync); + } + } + } +} + +void NinjamVst3AudioProcessorEditor::applyRemoteMidiRelaySelection(int channel, int inputIndex) +{ + juce::DynamicObject::Ptr obj = new juce::DynamicObject(); + obj->setProperty("channel", channel); + obj->setProperty("inputIndex", inputIndex); + const juce::String payload = juce::JSON::toString(juce::var(obj.get())); + audioProcessor.sendSideSignal(audioProcessor.getMidiRelayTarget(), "localInputSelect", payload); +} + +void NinjamVst3AudioProcessorEditor::syncLearnMappingsToProcessor() +{ + juce::Array midiArray; + for (const auto& pair : midiSourceByTargetId) + { + juce::DynamicObject::Ptr obj = new juce::DynamicObject(); + obj->setProperty("target", pair.first); + obj->setProperty("isController", pair.second.isController); + obj->setProperty("midiChannel", pair.second.midiChannel); + obj->setProperty("number", pair.second.number); + obj->setProperty("lastBinaryState", pair.second.lastBinaryState); + midiArray.add(juce::var(obj.get())); + } + + juce::Array oscArray; + for (const auto& pair : oscSourceByTargetId) + { + juce::DynamicObject::Ptr obj = new juce::DynamicObject(); + obj->setProperty("target", pair.first); + obj->setProperty("address", pair.second.address); + obj->setProperty("lastBinaryState", pair.second.lastBinaryState); + oscArray.add(juce::var(obj.get())); + } + + audioProcessor.setMidiLearnStateJson(juce::JSON::toString(juce::var(midiArray))); + audioProcessor.setOscLearnStateJson(juce::JSON::toString(juce::var(oscArray))); +} + +void NinjamVst3AudioProcessorEditor::loadLearnMappingsFromProcessor() +{ + midiSourceByTargetId.clear(); + oscSourceByTargetId.clear(); + + const juce::var midiParsed = juce::JSON::parse(audioProcessor.getMidiLearnStateJson()); + if (auto* midiArray = midiParsed.getArray()) + { + for (const auto& entry : *midiArray) + { + auto* obj = entry.getDynamicObject(); + if (obj == nullptr || !obj->hasProperty("target")) + continue; + MidiSourceMapping mapping; + mapping.isController = obj->hasProperty("isController") ? (bool)obj->getProperty("isController") : true; + mapping.midiChannel = obj->hasProperty("midiChannel") ? (int)obj->getProperty("midiChannel") : 1; + mapping.number = obj->hasProperty("number") ? (int)obj->getProperty("number") : 0; + mapping.lastBinaryState = obj->hasProperty("lastBinaryState") ? (int)obj->getProperty("lastBinaryState") : -1; + midiSourceByTargetId[obj->getProperty("target").toString()] = mapping; + } + } + + const juce::var oscParsed = juce::JSON::parse(audioProcessor.getOscLearnStateJson()); + if (auto* oscArray = oscParsed.getArray()) + { + for (const auto& entry : *oscArray) + { + auto* obj = entry.getDynamicObject(); + if (obj == nullptr || !obj->hasProperty("target") || !obj->hasProperty("address")) + continue; + OscSourceMapping mapping; + mapping.address = obj->getProperty("address").toString(); + mapping.lastBinaryState = obj->hasProperty("lastBinaryState") ? (int)obj->getProperty("lastBinaryState") : -1; + oscSourceByTargetId[obj->getProperty("target").toString()] = mapping; + } + } +} + +void NinjamVst3AudioProcessorEditor::saveLearnMappingsToDisk() +{ + syncLearnMappingsToProcessor(); + juce::PropertiesFile::Options popts; + popts.applicationName = "NINJAM VST3"; + popts.filenameSuffix = "settings"; + popts.folderName = "NINJAM VST3"; + popts.osxLibrarySubFolder = "Application Support"; + juce::PropertiesFile props(popts); + props.setValue("midiLearnStateJson", audioProcessor.getMidiLearnStateJson()); + props.setValue("oscLearnStateJson", audioProcessor.getOscLearnStateJson()); + props.saveIfNeeded(); +} + +void NinjamVst3AudioProcessorEditor::loadLearnMappingsFromDisk() +{ + juce::PropertiesFile::Options popts; + popts.applicationName = "NINJAM VST3"; + popts.filenameSuffix = "settings"; + popts.folderName = "NINJAM VST3"; + popts.osxLibrarySubFolder = "Application Support"; + juce::PropertiesFile props(popts); + audioProcessor.setMidiLearnStateJson(props.getValue("midiLearnStateJson", {})); + audioProcessor.setOscLearnStateJson(props.getValue("oscLearnStateJson", {})); + loadLearnMappingsFromProcessor(); +} + +void NinjamVst3AudioProcessorEditor::clearLearnMappings() +{ + midiSourceByTargetId.clear(); + oscSourceByTargetId.clear(); + midiLearnArmedTargetId.clear(); + oscLearnArmedTargetId.clear(); + syncLearnMappingsToProcessor(); +} + +void NinjamVst3AudioProcessorEditor::connectClicked() +{ + const int status = audioProcessor.getClient().GetStatus(); + const bool isConnectedOrConnecting = (status == NJClient::NJC_STATUS_OK || status == NJClient::NJC_STATUS_PRECONNECT); + if (!isConnectedOrConnecting) + { + juce::String user = userField.getText(); + juce::String pass = passField.getText(); + + if (anonymousButton.getToggleState()) + { + if (!user.startsWith("anonymous:")) + user = "anonymous:" + user; + pass = ""; + } + + audioProcessor.connectToServer(serverField.getText(), user, pass); + } + else + { + audioProcessor.disconnectFromServer(); + clearLearnMappings(); + } +} + +void NinjamVst3AudioProcessorEditor::sendClicked() +{ + juce::String msg = chatInput.getText(); + if (msg.isNotEmpty()) + { + audioProcessor.sendChatMessage(msg); + chatInput.clear(); + } +} + +void NinjamVst3AudioProcessorEditor::transmitToggled() +{ + audioProcessor.setTransmitLocal(transmitButton.getToggleState()); + updateTransmitButtonColor(); +} + +void NinjamVst3AudioProcessorEditor::layoutToggled() +{ + userList.setLayoutMode(layoutButton.getToggleState()); +} + +void NinjamVst3AudioProcessorEditor::metronomeChanged() +{ + // only update volume when not muted + if (metronomeMuteButton.getToggleState()) + audioProcessor.setMetronomeVolume((float)metronomeSlider.getValue()); + else + storedMetronomeVolume = (float)metronomeSlider.getValue(); // update stored value silently +} + +void NinjamVst3AudioProcessorEditor::chatToggled() +{ + if (!chatButton.getToggleState()) + { + if (chatWindow) + { + chatWindow->setVisible(false); + chatWindow.reset(); + } + chatPoppedOut = false; + } + updateChatButtonColor(); + resized(); +} + +void NinjamVst3AudioProcessorEditor::chatPopoutClicked() +{ + if (!chatButton.getToggleState()) + { + chatButton.setToggleState(true, juce::dontSendNotification); + updateChatButtonColor(); + } + + if (!chatPoppedOut) + { + chatPoppedOut = true; + if (!chatWindow) + { + chatWindow.reset(new ChatWindow(audioProcessor, [this]() + { + chatWindow.reset(); + chatPoppedOut = false; + chatButton.setToggleState(false, juce::dontSendNotification); + updateChatButtonColor(); + resized(); + })); + } + else + { + chatWindow->setVisible(true); + } + } + else + { + chatPoppedOut = false; + if (chatWindow) + { + chatWindow->setVisible(false); + chatWindow.reset(); + } + } + + resized(); +} + +void NinjamVst3AudioProcessorEditor::anonymousToggled() +{ + passField.setEnabled(!anonymousButton.getToggleState()); +} + +void NinjamVst3AudioProcessorEditor::atToggled() +{ + audioProcessor.setAutoTranslateEnabled(atButton.getToggleState()); +} + +void NinjamVst3AudioProcessorEditor::syncToggled() +{ + bool enabled = syncButton.getToggleState(); + audioProcessor.setSyncToHost(enabled); + + if (!enabled) + return; + + double hostBpm = 0.0; + bool hostPlaying = false; + { + juce::AudioPlayHead::CurrentPositionInfo info; + if (audioProcessor.getHostPosition(info)) + { + hostBpm = info.bpm; + hostPlaying = info.isPlaying; + } + } + + float njBpm = audioProcessor.getBPM(); + juce::String message; + bool anyWarning = false; + + if (hostBpm > 0.0 && njBpm > 0.0f) + { + double diff = std::abs(hostBpm - (double)njBpm); + if (diff > 0.5) + { + anyWarning = true; + message << "Host BPM (" << juce::String(hostBpm, 1) + << ") is different from NINJAM BPM (" + << juce::String(njBpm, 1) << ").\n"; + } + } + + if (hostPlaying) + { + anyWarning = true; + message << "The DAW is currently playing.\n" + << "Stop the DAW, move the playhead to your desired start bar,\n" + << "then press Play to hear NINJAM in sync with the host."; + } + + if (!anyWarning) + return; + + juce::AlertWindow::showMessageBoxAsync(juce::AlertWindow::WarningIcon, "Sync to Host", message); +} + +void NinjamVst3AudioProcessorEditor::videoClicked() +{ + audioProcessor.launchVideoSession(); +} + +void NinjamVst3AudioProcessorEditor::serverListClicked() +{ + if (serverListWindow == nullptr) + { + serverListWindow.reset(new ServerListWindow( + audioProcessor, + [this](const juce::String& hostPort) + { + serverField.setText(hostPort, juce::dontSendNotification); + }, + [this](const juce::String& hostPort) + { + serverField.setText(hostPort, juce::dontSendNotification); + if (audioProcessor.getClient().GetStatus() != NJClient::NJC_STATUS_DISCONNECTED) + audioProcessor.disconnectFromServer(); + + juce::String user = userField.getText(); + juce::String pass = passField.getText(); + if (anonymousButton.getToggleState()) + { + if (!user.startsWith("anonymous:")) + user = "anonymous:" + user; + pass = ""; + } + + audioProcessor.connectToServer(serverField.getText(), user, pass); + if (serverListWindow != nullptr) + serverListWindow->setVisible(false); + })); + } + else + { + serverListWindow->setVisible(true); + serverListWindow->toFront(true); + } +} + +void NinjamVst3AudioProcessorEditor::refreshLocalInputSelectors() +{ + for (int i = 0; i < NinjamVst3AudioProcessor::maxLocalChannels; ++i) + refreshLocalInputSelector(i); +} + +void NinjamVst3AudioProcessorEditor::refreshMidiRelayTargetSelector() +{ + const juce::String selectedTarget = audioProcessor.getMidiRelayTarget(); + std::set seen; + midiRelayTargetByMenuId.clear(); + midiRelayTargetSelector.clear(juce::dontSendNotification); + + int id = 1; + midiRelayTargetSelector.addItem("MIDI->All", id); + midiRelayTargetByMenuId[id] = "*"; + ++id; + + for (const auto& user : audioProcessor.getConnectedUsers()) + { + if (user.name.isEmpty() || seen.find(user.name) != seen.end()) + continue; + seen.insert(user.name); + midiRelayTargetSelector.addItem("MIDI->" + user.name, id); + midiRelayTargetByMenuId[id] = user.name; + ++id; + } + + int selectedId = 1; + for (const auto& pair : midiRelayTargetByMenuId) + if (pair.second.equalsIgnoreCase(selectedTarget)) + selectedId = pair.first; + + midiRelayTargetSelector.setSelectedId(selectedId, juce::dontSendNotification); +} + +void NinjamVst3AudioProcessorEditor::oscMessageReceived(const juce::OSCMessage& message) +{ + PendingOscEvent event; + event.address = message.getAddressPattern().toString(); + if (message.size() > 0) + { + const auto arg = message[0]; + float raw = 1.0f; + if (arg.isFloat32()) raw = arg.getFloat32(); + else if (arg.isInt32()) raw = (float)arg.getInt32(); + event.normalized = raw > 1.0f ? juce::jlimit(0.0f, 1.0f, raw / 127.0f) : juce::jlimit(0.0f, 1.0f, raw); + event.binaryOn = raw >= 0.5f; + } + else + { + event.normalized = 1.0f; + event.binaryOn = true; + } + + const juce::SpinLock::ScopedLockType lock(oscEventQueueLock); + pendingOscEvents.push_back(event); + if (pendingOscEvents.size() > 512) + pendingOscEvents.erase(pendingOscEvents.begin(), pendingOscEvents.begin() + (long long)(pendingOscEvents.size() - 512)); +} + +void NinjamVst3AudioProcessorEditor::refreshLocalInputSelector(int channel) +{ + if (channel < 0 || channel >= NinjamVst3AudioProcessor::maxLocalChannels) + return; + + auto& selector = localInputSelectors[(size_t)channel]; + selector.clear(juce::dontSendNotification); + + int total = audioProcessor.getTotalNumInputChannels(); + if (total <= 0) total = 2; + int numPairs = total / 2; + + for (int ch = 0; ch < total; ++ch) + selector.addItem("In " + juce::String(ch + 1), ch + 1); + + int stereoBaseId = 100; + for (int pair = 0; pair < numPairs; ++pair) + { + int left = pair * 2 + 1; + int right = left + 1; + selector.addItem(juce::String(left) + "/" + juce::String(right), stereoBaseId + pair); + } + + int currentInput = audioProcessor.getLocalChannelInput(channel); + if (currentInput >= 0 && currentInput < total) + { + selector.setSelectedId(currentInput + 1, juce::dontSendNotification); + } + else if (currentInput < 0) + { + int pairIndex = -1 - currentInput; + if (numPairs > pairIndex) + { + selector.setSelectedId(stereoBaseId + pairIndex, juce::dontSendNotification); + } + else if (numPairs > 0) + { + selector.setSelectedId(stereoBaseId, juce::dontSendNotification); + audioProcessor.setLocalChannelInput(channel, -1); + } + else if (total > 0) + { + selector.setSelectedId(1, juce::dontSendNotification); + audioProcessor.setLocalChannelInput(channel, 0); + } + } + + if (channel >= 0 && channel < NinjamVst3AudioProcessor::maxLocalChannels) + localInputModeSelectors[(size_t)channel].setSelectedId(currentInput < 0 ? 2 : 1, juce::dontSendNotification); +} + +bool NinjamVst3AudioProcessorEditor::isSidechainInputActive() const +{ + return audioProcessor.getTotalNumInputChannels() > 2; +} + +void NinjamVst3AudioProcessorEditor::loadControlImages(const juce::File& themeDir) +{ + backgroundImage = juce::Image(); + + // Try bg.mp4 when the Video BG toggle is on (Windows only) + bool videoLoaded = false; + +#if JUCE_WINDOWS + videoFrameReader.reset(); + if (videoBgToggle.getToggleState()) + { + auto videoFile = themeDir.getChildFile("bg.mp4"); + if (videoFile.existsAsFile()) + { + videoFrameReader = std::make_unique(); + if (videoFrameReader->open(videoFile)) + videoLoaded = true; + else + videoFrameReader.reset(); + } + } +#endif + + if (!videoLoaded) + { + // Fall back to bg.* image files (bg.jpg, bg.png, bg.gif, etc.) + auto bgFiles = themeDir.findChildFiles(juce::File::findFiles, false, "bg.*"); + if (!bgFiles.isEmpty()) + backgroundImage = juce::ImageFileFormat::loadFrom(bgFiles[0]); + } + + // fknob.png — fader knob image + faderKnobImage = juce::ImageFileFormat::loadFrom(themeDir.getChildFile("fknob.png")); + + // rknob.png — radio/release knob image + radioKnobImage = juce::ImageFileFormat::loadFrom(themeDir.getChildFile("rknob.png")); + + // Reset theme colours to defaults before reading cfg + metronomeThemeColour = juce::Colour::fromRGB(80, 185, 255); + windowThemeColour = juce::Colour(0x00000000); + buttonThemeColour = juce::Colour(0x00000000); + menuBarThemeColour = juce::Colour(0x00000000); + knobColourPreset = "grey"; + faderColourPreset = "grey"; + knobThemeColour = juce::Colours::grey; + faderThemeColour = juce::Colour(0xff666666); + + // Parse companion skin.cfg if present + auto cfgFile = themeDir.getChildFile("skin.cfg"); + if (cfgFile.existsAsFile()) + { + auto lines = juce::StringArray::fromLines(cfgFile.loadFileAsString()); + auto parseHex = [](const juce::String& val, juce::Colour& out) -> bool + { + auto s = val.trim().trimCharactersAtStart("#"); + if (s.length() == 6 && s.containsOnly("0123456789abcdefABCDEF")) + { + out = juce::Colour::fromString("ff" + s); + return true; + } + return false; + }; + for (const auto& line : lines) + { + auto trimmed = line.trim(); + if (trimmed.startsWith("#") || trimmed.isEmpty()) continue; + auto val = trimmed.fromFirstOccurrenceOf(":", false, false).trim(); + if (trimmed.startsWithIgnoreCase("Metronome Colour:")) + parseHex(val, metronomeThemeColour); + else if (trimmed.startsWithIgnoreCase("Window Colour:")) + parseHex(val, windowThemeColour); + else if (trimmed.startsWithIgnoreCase("Button Colour:")) + parseHex(val, buttonThemeColour); + else if (trimmed.startsWithIgnoreCase("MenuBar Colour:")) + parseHex(val, menuBarThemeColour); + else if (trimmed.startsWithIgnoreCase("Knobs:")) + { + knobColourPreset = val; + knobThemeColour = colourFromPresetName(knobColourPreset, juce::Colours::grey); + } + else if (trimmed.startsWithIgnoreCase("Faders:")) + { + faderColourPreset = val; + faderThemeColour = colourFromPresetName(faderColourPreset, juce::Colour(0xff666666)); + } + } + } + + applyThemeColours(); + repaint(); +} + +void NinjamVst3AudioProcessorEditor::applyThemeColours() +{ + metronomeBtnLAF.themeColour = metronomeThemeColour; + metronomeMuteButton.repaint(); + + // Apply Window Colour to the global LAF palette so all component backgrounds pick it up. + // If no Window Colour is set, restore JUCE defaults. + if (windowThemeColour.getAlpha() > 0) + { + auto bg = windowThemeColour; + auto bgDk = bg.darker(0.25f); + auto bgLt = bg.brighter(0.15f); + + // drawDocumentWindowTitleBar reads widgetBackground from the colour scheme directly + // (bypasses colour IDs entirely), so we must patch the scheme to change the title bar. + auto titleBarBg = menuBarThemeColour.getAlpha() > 0 ? menuBarThemeColour : bg; + auto scheme = outlinedLabelLAF.getCurrentColourScheme(); + scheme.setUIColour(juce::LookAndFeel_V4::ColourScheme::UIColour::windowBackground, titleBarBg); + scheme.setUIColour(juce::LookAndFeel_V4::ColourScheme::UIColour::widgetBackground, titleBarBg); + outlinedLabelLAF.setColourScheme(scheme); // also calls initialiseColours(), resetting colour IDs + + // Now apply per-component colour ID overrides (override what setColourScheme just initialised) + outlinedLabelLAF.setColour(juce::ResizableWindow::backgroundColourId, bg); + outlinedLabelLAF.setColour(juce::DocumentWindow::backgroundColourId, bg); + outlinedLabelLAF.setColour(juce::ComboBox::backgroundColourId, bgDk); + outlinedLabelLAF.setColour(juce::ListBox::backgroundColourId, bgDk); + outlinedLabelLAF.setColour(juce::TextEditor::backgroundColourId, bgDk); + outlinedLabelLAF.setColour(juce::TextButton::buttonColourId, + buttonThemeColour.getAlpha() > 0 ? buttonThemeColour : bgLt); + outlinedLabelLAF.setColour(juce::Slider::backgroundColourId, bgDk); + outlinedLabelLAF.setColour(juce::GroupComponent::outlineColourId, bg.brighter(0.3f)); + outlinedLabelLAF.setColour(juce::PopupMenu::backgroundColourId, bgDk); + outlinedLabelLAF.setColour(juce::PopupMenu::highlightedBackgroundColourId, bgLt); + } + else + { + // Restore the dark colour scheme (JUCE default); this also resets all colour IDs. + // If a MenuBar Colour is set without a Window Colour, still patch widgetBackground. + if (menuBarThemeColour.getAlpha() > 0) + { + auto scheme = juce::LookAndFeel_V4::getDarkColourScheme(); + scheme.setUIColour(juce::LookAndFeel_V4::ColourScheme::UIColour::windowBackground, menuBarThemeColour); + scheme.setUIColour(juce::LookAndFeel_V4::ColourScheme::UIColour::widgetBackground, menuBarThemeColour); + outlinedLabelLAF.setColourScheme(scheme); + } + else + { + outlinedLabelLAF.setColourScheme(juce::LookAndFeel_V4::getDarkColourScheme()); + } + if (buttonThemeColour.getAlpha() > 0) + outlinedLabelLAF.setColour(juce::TextButton::buttonColourId, buttonThemeColour); + } + + repaint(); + sendLookAndFeelChange(); + + // Force the DocumentWindow title bar to repaint using the updated scheme. + if (auto* dw = dynamic_cast(getTopLevelComponent())) + { + auto effectiveTitleCol = menuBarThemeColour.getAlpha() > 0 ? menuBarThemeColour + : windowThemeColour.getAlpha() > 0 ? windowThemeColour + : juce::LookAndFeel_V4::getDarkColourScheme() + .getUIColour(juce::LookAndFeel_V4::ColourScheme::UIColour::windowBackground); + dw->setBackgroundColour(effectiveTitleCol); + dw->repaint(); + } +} + +void NinjamVst3AudioProcessorEditor::parentHierarchyChanged() +{ + // Re-apply title bar colour now that we may be properly parented under the DocumentWindow + if (auto* dw = dynamic_cast(getTopLevelComponent())) + { + auto effectiveTitleCol = menuBarThemeColour.getAlpha() > 0 ? menuBarThemeColour + : windowThemeColour.getAlpha() > 0 ? windowThemeColour + : juce::LookAndFeel_V4::getDarkColourScheme() + .getUIColour(juce::LookAndFeel_V4::ColourScheme::UIColour::windowBackground); + dw->setBackgroundColour(effectiveTitleCol); + dw->repaint(); + } +} + +bool NinjamVst3AudioProcessorEditor::shouldDeferHeavyUiWork() const +{ + if (audioProcessor.isStandaloneWrapper()) + return false; + if (pendingDeferredResizeLayout || applyingDeferredResizeLayout) + return true; + return juce::Time::getMillisecondCounterHiRes() < suppressHeavyUiUntilMs; +} + +bool NinjamVst3AudioProcessorEditor::isAbletonLiveHost() const +{ + return juce::PluginHostType().isAbletonLive(); +} + +void NinjamVst3AudioProcessorEditor::setAbletonWindowSizePreset(int presetIndex) +{ + if (audioProcessor.isStandaloneWrapper() || !isAbletonLiveHost()) + return; + + abletonWindowSizePreset = juce::jlimit(0, 2, presetIndex); + + int targetWidth = 1240; + int targetHeight = 600; + if (abletonWindowSizePreset == 0) targetWidth = 1100; + if (abletonWindowSizePreset == 0) targetHeight = 540; + if (abletonWindowSizePreset == 2) targetWidth = 1380; + if (abletonWindowSizePreset == 2) targetHeight = 700; + + if (hostResizeLockedForConnection) + { + pendingDeferredResizeLayout = false; + applyingDeferredResizeLayout = false; + setResizable(false, false); + setResizeLimits(targetWidth, targetHeight, targetWidth, targetHeight); + setSize(targetWidth, targetHeight); + suppressHeavyUiUntilMs = juce::Time::getMillisecondCounterHiRes() + 400.0; + } + else + { + setResizable(true, true); + setResizeLimits(900, 500, 2200, 1500); + setSize(targetWidth, juce::jlimit(500, 1500, targetHeight)); + } + + juce::PropertiesFile::Options popts; + popts.applicationName = "NINJAM VST3"; + popts.filenameSuffix = "settings"; + popts.folderName = "NINJAM VST3"; + popts.osxLibrarySubFolder = "Application Support"; + juce::PropertiesFile props(popts); + props.setValue("abletonWindowSizePreset", abletonWindowSizePreset); + props.saveIfNeeded(); +} + +void NinjamVst3AudioProcessorEditor::updateHostResizeModeForConnectionStatus(int status) +{ + if (audioProcessor.isStandaloneWrapper()) + return; + + const bool shouldLock = isAbletonLiveHost() + && (status == NJClient::NJC_STATUS_OK || status == NJClient::NJC_STATUS_PRECONNECT); + if (shouldLock == hostResizeLockedForConnection) + return; + + if (shouldLock) + { + const int currentWidth = getWidth(); + const int currentHeight = getHeight(); + pendingDeferredResizeLayout = false; + applyingDeferredResizeLayout = false; + setResizable(false, false); + setResizeLimits(currentWidth, currentHeight, currentWidth, currentHeight); + suppressHeavyUiUntilMs = juce::Time::getMillisecondCounterHiRes() + 500.0; + } + else + { + setResizable(true, true); + setResizeLimits(900, 500, 2200, 1500); + } + + hostResizeLockedForConnection = shouldLock; +} + +void NinjamVst3AudioProcessorEditor::updateAutoLevelButtonColor() +{ + if (autoLevelButton.getToggleState()) + { + juce::Colour on = juce::Colour::fromRGB(240, 220, 30); // bright yellow + autoLevelButton.setColour(juce::TextButton::buttonColourId, on); + autoLevelButton.setColour(juce::TextButton::buttonOnColourId, on); + autoLevelButton.setColour(juce::TextButton::textColourOnId, juce::Colours::black); + autoLevelButton.setColour(juce::TextButton::textColourOffId, juce::Colours::black); + } + else + { + juce::Colour off = juce::Colour::fromRGB(55, 50, 10); // dim yellow + autoLevelButton.setColour(juce::TextButton::buttonColourId, off); + autoLevelButton.setColour(juce::TextButton::buttonOnColourId, off); + autoLevelButton.setColour(juce::TextButton::textColourOnId, juce::Colours::grey); + autoLevelButton.setColour(juce::TextButton::textColourOffId, juce::Colours::grey); + } + repaint(); +} + +void NinjamVst3AudioProcessorEditor::updateChatButtonColor() +{ + chatButton.repaint(); +} + +void NinjamVst3AudioProcessorEditor::updateTransmitButtonColor() +{ + if (transmitButton.getToggleState()) + { + juce::Colour on = juce::Colour::fromRGB(50, 200, 80); // bright green when transmitting + transmitButton.setColour(juce::TextButton::buttonColourId, on); + transmitButton.setColour(juce::TextButton::buttonOnColourId, on); + transmitButton.setColour(juce::TextButton::textColourOnId, juce::Colours::black); + transmitButton.setColour(juce::TextButton::textColourOffId, juce::Colours::black); + } + else + { + juce::Colour off = juce::Colour::fromRGB(12, 50, 18); // dim green + transmitButton.setColour(juce::TextButton::buttonColourId, off); + transmitButton.setColour(juce::TextButton::buttonOnColourId, off); + transmitButton.setColour(juce::TextButton::textColourOnId, juce::Colours::grey); + transmitButton.setColour(juce::TextButton::textColourOffId, juce::Colours::grey); + } + repaint(); +} + +void NinjamVst3AudioProcessorEditor::updateMonitorButtonColor() +{ + if (localMonitorButton.getToggleState()) + { + juce::Colour on = juce::Colour::fromRGB(220, 55, 55); // bright red when monitoring + localMonitorButton.setColour(juce::TextButton::buttonColourId, on); + localMonitorButton.setColour(juce::TextButton::buttonOnColourId, on); + localMonitorButton.setColour(juce::TextButton::textColourOnId, juce::Colours::white); + localMonitorButton.setColour(juce::TextButton::textColourOffId, juce::Colours::white); + } + else + { + juce::Colour off = juce::Colour::fromRGB(60, 15, 15); // dim red + localMonitorButton.setColour(juce::TextButton::buttonColourId, off); + localMonitorButton.setColour(juce::TextButton::buttonOnColourId, off); + localMonitorButton.setColour(juce::TextButton::textColourOnId, juce::Colours::grey); + localMonitorButton.setColour(juce::TextButton::textColourOffId, juce::Colours::grey); + } + repaint(); +} + +void NinjamVst3AudioProcessorEditor::updateLimiterButtonColor() +{ + if (limiterButton.getToggleState()) + { + juce::Colour on = juce::Colour::fromRGB(220, 55, 55); // bright red when active + limiterButton.setColour(juce::TextButton::buttonColourId, on); + limiterButton.setColour(juce::TextButton::buttonOnColourId, on); + limiterButton.setColour(juce::TextButton::textColourOnId, juce::Colours::white); + limiterButton.setColour(juce::TextButton::textColourOffId, juce::Colours::white); + } + else + { + juce::Colour off = juce::Colour::fromRGB(60, 15, 15); // dim red + limiterButton.setColour(juce::TextButton::buttonColourId, off); + limiterButton.setColour(juce::TextButton::buttonOnColourId, off); + limiterButton.setColour(juce::TextButton::textColourOnId, juce::Colours::grey); + limiterButton.setColour(juce::TextButton::textColourOffId, juce::Colours::grey); + } + repaint(); +} + +void NinjamVst3AudioProcessorEditor::updateVoiceChatButtonColor() +{ + if (voiceChatButton.getToggleState()) + { + // Pulse between dim amber and bright amber (~1 s cycle) + voiceChatGlowPhase += juce::MathConstants::twoPi * 30.0f / 1000.0f; + float t = (std::sin(voiceChatGlowPhase) + 1.0f) * 0.5f; // 0..1 + uint8 r = (uint8)(100 + (uint8)(155 * t)); + uint8 g = (uint8)(50 + (uint8)(100 * t)); + juce::Colour pulse = juce::Colour::fromRGB(r, g, 0); + voiceChatButton.setColour(juce::TextButton::buttonColourId, pulse); + voiceChatButton.setColour(juce::TextButton::buttonOnColourId, pulse); + voiceChatButton.setColour(juce::TextButton::textColourOnId, juce::Colours::black); + voiceChatButton.setColour(juce::TextButton::textColourOffId, juce::Colours::black); + } + else + { + voiceChatGlowPhase = 0.0f; + juce::Colour off = juce::Colour::fromRGB(50, 30, 0); + voiceChatButton.setColour(juce::TextButton::buttonColourId, off); + voiceChatButton.setColour(juce::TextButton::buttonOnColourId, off); + voiceChatButton.setColour(juce::TextButton::textColourOnId, juce::Colours::orange); + voiceChatButton.setColour(juce::TextButton::textColourOffId, juce::Colours::orange); + } +} + +void NinjamVst3AudioProcessorEditor::updateLayoutButtonColor() +{ + repaint(); +} + +void NinjamVst3AudioProcessorEditor::updateMetronomeButtonColor() +{ + repaint(); +} + +void NinjamVst3AudioProcessorEditor::updateSyncButtonColor() +{ + repaint(); +} + +void NinjamVst3AudioProcessorEditor::updateFxButtonLabel() +{ + fxButton.setButtonText("FX"); +} + +void NinjamVst3AudioProcessorEditor::showFxMenu() +{ + audioProcessor.setFxReverbEnabled(true); + audioProcessor.setFxDelayEnabled(true); + + juce::PopupMenu menu; + menu.addItem(2, "Reverb"); + menu.addItem(3, "Delay"); + menu.showMenuAsync(juce::PopupMenu::Options().withTargetComponent(&fxButton), + [this](int result) + { + if (result == 0) + return; + + audioProcessor.setFxReverbEnabled(true); + audioProcessor.setFxDelayEnabled(true); + if (result == 2) + showReverbSettingsPopup(); + if (result == 3) + showDelaySettingsPopup(); + updateFxButtonLabel(); + updateFxControlsVisibility(); + repaint(); + }); +} + +void NinjamVst3AudioProcessorEditor::showOptionsMenu() +{ + juce::PopupMenu menu; + menu.addItem(41, "Midi Settings"); + if (isAbletonLiveHost() && !audioProcessor.isStandaloneWrapper()) + { + juce::PopupMenu sizeMenu; + sizeMenu.addItem(51, "Small", true, abletonWindowSizePreset == 0); + sizeMenu.addItem(52, "Medium", true, abletonWindowSizePreset == 1); + sizeMenu.addItem(53, "Large", true, abletonWindowSizePreset == 2); + menu.addSubMenu("Window Size", sizeMenu); + } + + menu.showMenuAsync(juce::PopupMenu::Options().withTargetComponent(&optionsButton), + [this](int result) + { + if (result == 0) + return; + if (result == 41) + showMidiOptionsPopup(); + if (result == 51) setAbletonWindowSizePreset(0); + if (result == 52) setAbletonWindowSizePreset(1); + if (result == 53) setAbletonWindowSizePreset(2); + }); +} + +void NinjamVst3AudioProcessorEditor::showReverbSettingsPopup() +{ + showSettingsCallout(std::make_unique(audioProcessor), fxButton); +} + +void NinjamVst3AudioProcessorEditor::showDelaySettingsPopup() +{ + showSettingsCallout(std::make_unique(audioProcessor), fxButton); +} + +void NinjamVst3AudioProcessorEditor::showMidiOptionsPopup() +{ + showSettingsCallout(std::make_unique(audioProcessor, [this] { refreshExternalMidiInputDevices(); }), + optionsButton.isShowing() ? static_cast(optionsButton) + : static_cast(fxButton)); +} + +void NinjamVst3AudioProcessorEditor::showSettingsCallout(std::unique_ptr content, juce::Component& anchorComponent) +{ + auto anchorOnScreen = anchorComponent.getScreenBounds(); + juce::Rectangle target(anchorOnScreen.getX() + 8, anchorOnScreen.getBottom() + 2, 2, 2); + auto& box = juce::CallOutBox::launchAsynchronously(std::move(content), target, nullptr); + box.setLookAndFeel(&noArrowCallOutLookAndFeel); + box.setArrowSize(0.0f); + box.setTopLeftPosition(anchorOnScreen.getX(), anchorOnScreen.getBottom() + 4); +} + +void NinjamVst3AudioProcessorEditor::refreshExternalMidiInputDevices() +{ + const juce::String desiredLearnId = audioProcessor.getMidiLearnInputDeviceId(); + const juce::String desiredRelayId = audioProcessor.getMidiRelayInputDeviceId(); + + if (desiredLearnId != openedMidiLearnInputDeviceId) + { + midiLearnInputDevice.reset(); + openedMidiLearnInputDeviceId.clear(); + if (desiredLearnId.isNotEmpty()) + { + midiLearnInputDevice = juce::MidiInput::openDevice(desiredLearnId, this); + if (midiLearnInputDevice != nullptr) + { + midiLearnInputDevice->start(); + openedMidiLearnInputDeviceId = desiredLearnId; + } + } + } + + if (desiredRelayId == openedMidiLearnInputDeviceId && desiredRelayId.isNotEmpty()) + { + midiRelayInputDevice.reset(); + openedMidiRelayInputDeviceId = desiredRelayId; + return; + } + + if (desiredRelayId != openedMidiRelayInputDeviceId) + { + midiRelayInputDevice.reset(); + openedMidiRelayInputDeviceId.clear(); + if (desiredRelayId.isNotEmpty()) + { + midiRelayInputDevice = juce::MidiInput::openDevice(desiredRelayId, this); + if (midiRelayInputDevice != nullptr) + { + midiRelayInputDevice->start(); + openedMidiRelayInputDeviceId = desiredRelayId; + } + } + } +} + +void NinjamVst3AudioProcessorEditor::handleIncomingMidiMessage(juce::MidiInput* source, const juce::MidiMessage& message) +{ + if (source == nullptr) + return; + + const juce::String sourceId = source->getIdentifier(); + const juce::String learnDeviceId = audioProcessor.getMidiLearnInputDeviceId(); + const juce::String relayDeviceId = audioProcessor.getMidiRelayInputDeviceId(); + const bool forLearn = learnDeviceId.isNotEmpty() && sourceId == learnDeviceId; + const bool forRelay = relayDeviceId.isNotEmpty() && sourceId == relayDeviceId; + if (!forLearn && !forRelay) + return; + + NinjamVst3AudioProcessor::MidiControllerEvent event; + if (message.isController()) + { + event.isController = true; + event.midiChannel = message.getChannel(); + event.number = message.getControllerNumber(); + event.value = message.getControllerValue(); + event.normalized = (float)event.value / 127.0f; + event.isNoteOn = event.value >= 64; + } + else if (message.isNoteOnOrOff()) + { + event.isController = false; + event.midiChannel = message.getChannel(); + event.number = message.getNoteNumber(); + event.value = message.getVelocity(); + event.normalized = message.isNoteOn() ? ((float)event.value / 127.0f) : 0.0f; + event.isNoteOn = message.isNoteOn(); + } + else + { + return; + } + + audioProcessor.enqueueExternalMidiControllerEvent(event, forLearn, forRelay); +} + +void NinjamVst3AudioProcessorEditor::updateFxControlsVisibility() +{ + reverbRoomLabel.setVisible(false); + reverbRoomSlider.setVisible(false); + delayTimeLabel.setVisible(false); + delayTimeSlider.setVisible(false); + delayDivisionSelector.setVisible(false); + delayPingPongButton.setVisible(false); +} + +// ============================================================================== +// UserChannelStrip Implementation +// ============================================================================== + +UserChannelStrip::UserChannelStrip(NinjamVst3AudioProcessor& p, int userIdx) + : processor(p), userIndex(userIdx) +{ + // Initialise per-channel state + for (int i = 0; i < kMaxRemoteCh; ++i) + { + perChannelGain[i] = 1.0f; + channelPeaks[i] = 0.0f; + } + + setOpaque(false); + addAndMakeVisible(nameLabel); + nameLabel.setJustificationType(juce::Justification::centred); + nameLabel.setColour(juce::Label::textColourId, juce::Colours::white); + + addAndMakeVisible(volumeSlider); + volumeSlider.setSliderStyle(juce::Slider::LinearVertical); + volumeSlider.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + volumeSlider.setRange(0.0, 2.0); + volumeSlider.setSkewFactorFromMidPoint(0.25); + volumeSlider.setValue(1.0, juce::dontSendNotification); + volumeSlider.setDoubleClickReturnValue(true, 1.0); + volumeSlider.setLookAndFeel(&faderLookAndFeel); + volumeSlider.onValueChange = [this] { volumeChanged(); }; + + addAndMakeVisible(panSlider); + panSlider.setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag); + panSlider.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + panSlider.setRange(-1.0, 1.0); + panSlider.setValue(0.0, juce::dontSendNotification); + panSlider.setDoubleClickReturnValue(true, 0.0); + panSlider.onValueChange = [this] { panChanged(); }; + + addAndMakeVisible(muteButton); + muteButton.setClickingTogglesState(true); + muteBtnLAF.isMute = true; + muteButton.setLookAndFeel(&muteBtnLAF); + muteButton.onClick = [this] { muteChanged(); }; + + addAndMakeVisible(soloButton); + soloButton.setClickingTogglesState(true); + soloBtnLAF.isMute = false; + soloButton.setLookAndFeel(&soloBtnLAF); + soloButton.onClick = [this] { soloChanged(); }; + + addAndMakeVisible(outputSelector); + + addAndMakeVisible(dbLabel); + dbLabel.setJustificationType(juce::Justification::centred); + dbLabel.setColour(juce::Label::backgroundColourId, juce::Colours::black); + dbLabel.setColour(juce::Label::textColourId, juce::Colours::white); + dbLabel.setFont(juce::Font(11.0f)); + + int totalOutputs = processor.getTotalNumOutputChannels(); + if (totalOutputs <= 0) totalOutputs = 2; + int numPairs = totalOutputs / 2; + + for (int ch = 0; ch < totalOutputs; ++ch) + outputSelector.addItem("Out " + juce::String(ch + 1), ch + 1); + + int stereoBaseId = 100; + for (int pair = 0; pair < numPairs; ++pair) + { + int left = pair * 2 + 1; + int right = left + 1; + outputSelector.addItem("Out " + juce::String(left) + "/" + juce::String(right), + stereoBaseId + pair); + } + + outputSelector.onChange = [this] { outputChanged(); }; + + // Expand button — shows ">" in list layout for multichan peers + expandButton.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff333333)); + expandButton.setColour(juce::TextButton::textColourOffId, juce::Colours::white); + addChildComponent(expandButton); // hidden until isMultiChanPeer + expandButton.onClick = [this] { toggleExpanded(); }; + + // Per-channel volume sliders and name labels (hidden until expanded) + for (int i = 0; i < kMaxRemoteCh; ++i) + { + channelSliders[i].setSliderStyle(juce::Slider::LinearHorizontal); + channelSliders[i].setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + channelSliders[i].setRange(0.0, 2.0); + channelSliders[i].setSkewFactorFromMidPoint(0.25); + channelSliders[i].setValue(1.0, juce::dontSendNotification); + channelSliders[i].setDoubleClickReturnValue(true, 1.0); + channelSliders[i].setLookAndFeel(&faderLookAndFeel); + int ch = i; + channelSliders[i].onValueChange = [this, ch] + { + perChannelGain[ch] = (float)channelSliders[ch].getValue(); + float master = (float)volumeSlider.getValue(); + // NINJAM ch0 = Vorbis mixdown; individual channels start at ch1 + processor.setUserNjChannelVolume(userIndex, ch + 1, master * perChannelGain[ch]); + }; + addChildComponent(channelSliders[i]); + + channelNameLabels[i].setFont(juce::Font(9.0f)); + channelNameLabels[i].setJustificationType(juce::Justification::centredLeft); + channelNameLabels[i].setColour(juce::Label::textColourId, juce::Colours::lightgrey); + addChildComponent(channelNameLabels[i]); + } + + startTimer(50); +} + +UserChannelStrip::~UserChannelStrip() +{ + volumeSlider.setLookAndFeel(nullptr); + panSlider.setLookAndFeel(nullptr); + muteButton.setLookAndFeel(nullptr); + soloButton.setLookAndFeel(nullptr); + for (int i = 0; i < kMaxRemoteCh; ++i) + channelSliders[i].setLookAndFeel(nullptr); + stopTimer(); +} + +int UserChannelStrip::getUserIndex() const +{ + return userIndex; +} + +juce::Slider& UserChannelStrip::getVolumeSlider() +{ + return volumeSlider; +} + +juce::Slider& UserChannelStrip::getPanSlider() +{ + return panSlider; +} + +juce::Button& UserChannelStrip::getMuteButton() +{ + return muteButton; +} + +juce::Button& UserChannelStrip::getSoloButton() +{ + return soloButton; +} + +juce::Slider& UserChannelStrip::getChannelSlider(int channel) +{ + return channelSliders[(size_t)juce::jlimit(0, kMaxRemoteCh - 1, channel)]; +} + +void UserChannelStrip::paintOverChildren(juce::Graphics& g) +{ + auto drawGlow = [&](juce::Button& btn, juce::Colour onColour, juce::Colour offColour) + { + bool isOn = btn.getToggleState(); + auto bc = btn.getBounds().toFloat(); + auto centre = bc.getCentre(); + float gap = 5.0f; + float r = bc.getWidth() * 0.55f + gap; + juce::Colour col = isOn ? onColour : offColour; + juce::ColourGradient grad(col, centre.x, centre.y, + juce::Colours::transparentBlack, centre.x + r, centre.y, true); + g.setGradientFill(grad); + g.fillEllipse(centre.x - r, centre.y - r, r * 2.0f, r * 2.0f); + }; + + drawGlow(muteButton, juce::Colour(0x55ff3030), juce::Colour(0x22200808)); + drawGlow(soloButton, juce::Colour(0x55ffd030), juce::Colour(0x22281e04)); +} + +void UserChannelStrip::paint(juce::Graphics& g) +{ + auto dbFromPeak = [](float peak) + { + float p = juce::jlimit(1.0e-6f, 1.0f, peak); + return 20.0f * std::log10(p); + }; + auto colourForPeak = [&](float peak) + { + float db = dbFromPeak(peak); + if (peak >= 0.999f) return juce::Colours::red; + if (db > -3.0f) return juce::Colours::orange; + return juce::Colours::green; + }; + + g.fillAll(juce::Colours::black.withAlpha(0.45f)); + g.setColour(juce::Colours::black.withAlpha(0.60f)); + g.drawRect(getLocalBounds(), 1); + + const bool multiChan = isMultiChanPeer && numRemoteChannels > 1; + // Only show wide per-channel meter bars when collapsed; when expanded the sub-faders show peaks + const bool showMultiMeter = multiChan && !isExpanded; + + if (isHorizontalLayout) + { + auto sliderBounds = volumeSlider.getBounds(); + int meterWidth = 10; // Fixed width to prevent growing/shrinking + juce::Rectangle meterBounds(sliderBounds.getRight(), sliderBounds.getY(), + meterWidth, sliderBounds.getHeight()); + + g.setColour(juce::Colours::black); + g.fillRect(meterBounds); + + if (showMultiMeter) + { + int n = numRemoteChannels; + int bw = juce::jmax(1, meterBounds.getWidth() / n); + int totalH = meterBounds.getHeight(); + for (int ch = 0; ch < n; ++ch) + { + float peak = channelPeaks[ch]; + int h = (int)(totalH * juce::jmin(peak, 1.0f)); + if (h > 0) + { + juce::Rectangle bar(meterBounds.getX() + ch * bw, + meterBounds.getBottom() - h, + bw - 1, h); + g.setColour(colourForPeak(peak)); + g.fillRect(bar); + } + } + } + else + { + auto meterL = meterBounds.removeFromLeft(meterBounds.getWidth() / 2); + auto meterR = meterBounds; + + int hL = (int)(meterL.getHeight() * juce::jmin(currentPeakL, 1.0f)); + int hR = (int)(meterR.getHeight() * juce::jmin(currentPeakR, 1.0f)); + + if (hL > 0) { auto bar = meterL.removeFromBottom(hL); g.setColour(colourForPeak(currentPeakL)); g.fillRect(bar); } + if (hR > 0) { auto bar = meterR.removeFromBottom(hR); g.setColour(colourForPeak(currentPeakR)); g.fillRect(bar); } + } + } + else + { + auto sliderBounds = volumeSlider.getBounds(); + int meterHeight = 6; // Fixed height to prevent growing/shrinking + juce::Rectangle meterBounds(sliderBounds.getX(), sliderBounds.getBottom(), + sliderBounds.getWidth(), meterHeight); + + g.setColour(juce::Colours::black); + g.fillRect(meterBounds); + + if (showMultiMeter) + { + int n = numRemoteChannels; + int bh = juce::jmax(1, meterBounds.getHeight() / n); + int totalW = meterBounds.getWidth(); + for (int ch = 0; ch < n; ++ch) + { + float peak = channelPeaks[ch]; + int w = (int)(totalW * juce::jmin(peak, 1.0f)); + if (w > 0) + { + juce::Rectangle bar(meterBounds.getX(), + meterBounds.getY() + ch * bh, + w, bh - 1); + g.setColour(colourForPeak(peak)); + g.fillRect(bar); + } + } + } + else + { + int w = meterBounds.getWidth(); + float maxP = juce::jmax(currentPeakL, currentPeakR); + int wP = (int)(w * juce::jmin(maxP, 1.0f)); + + if (wP > 0) + { + auto bar = meterBounds.removeFromLeft(wP); + g.setColour(colourForPeak(maxP)); + g.fillRect(bar); + } + } + } +} + +void UserChannelStrip::resized() +{ + auto area = getLocalBounds().reduced(2); + + if (isHorizontalLayout) + { + // When multichan is expanded, restrict main strip to the left column + if (isExpanded && isMultiChanPeer && numRemoteChannels > 1) + area.setWidth(56); // 60px column minus 2px margin each side + + nameLabel.setBounds(area.removeFromTop(20)); + outputSelector.setBounds(area.removeFromBottom(20)); + auto dbArea = area.removeFromBottom(16); + auto ctrlArea = area.removeFromBottom(20); + muteButton.setBounds(ctrlArea.removeFromLeft(area.getWidth() / 2)); + soloButton.setBounds(ctrlArea); + panSlider.setBounds(area.removeFromTop(20).reduced(4, 2)); + + int sliderWidth = juce::jmin(20, area.getWidth()); + int sliderHeight = (int)(area.getHeight() * 0.85f); + int sliderY = area.getY() + (area.getHeight() - sliderHeight) / 2; + volumeSlider.setBounds(area.getCentreX() - sliderWidth / 2, sliderY, sliderWidth, sliderHeight); + volumeSlider.setSliderStyle(juce::Slider::LinearVertical); + dbLabel.setBounds(dbArea); + dbLabel.setVisible(true); + + // Expand button at top-right of strip when multichannel peer + if (isMultiChanPeer) + { + auto nameBounds = nameLabel.getBounds(); + expandButton.setBounds(nameBounds.getRight() - 14, nameBounds.getY(), 14, nameBounds.getHeight()); + expandButton.setVisible(true); + } + else + { + expandButton.setVisible(false); + } + + // Per-channel faders as side columns to the right of main strip when expanded + if (isExpanded && isMultiChanPeer && numRemoteChannels > 1) + { + auto subArea = getLocalBounds().reduced(2); + subArea.removeFromLeft(60); // skip the narrower main column when expanded + int colW = 36; + for (int i = 0; i < numRemoteChannels; ++i) + { + auto col = subArea.removeFromLeft(colW); + // Name label at top (18px), slider fills rest + channelNameLabels[i].setBounds(col.removeFromTop(18).reduced(1, 0)); + channelNameLabels[i].setVisible(true); + col.reduce(2, 4); + channelSliders[i].setBounds(col); + channelSliders[i].setSliderStyle(juce::Slider::LinearVertical); + channelSliders[i].setVisible(true); + } + for (int i = numRemoteChannels; i < kMaxRemoteCh; ++i) + { + channelSliders[i].setVisible(false); + channelNameLabels[i].setVisible(false); + } + } + else + { + for (int i = 0; i < kMaxRemoteCh; ++i) + { + channelSliders[i].setVisible(false); + channelNameLabels[i].setVisible(false); + } + } + } + else + { + // List layout (strip is horizontal) + // When multichan is expanded, restrict main strip to the top row + if (isExpanded && isMultiChanPeer && numRemoteChannels > 1) + area.setHeight(36); // 40px base minus 2px padding top/bottom + // Reserve 18px at right for expand button if this is a multichan peer + if (isMultiChanPeer) + { + expandButton.setBounds(area.removeFromRight(18)); + expandButton.setVisible(true); + } + else + { + expandButton.setVisible(false); + } + + nameLabel.setBounds(area.removeFromLeft(80)); + outputSelector.setBounds(area.removeFromRight(60)); + auto ctrlArea = area.removeFromRight(40); + muteButton.setBounds(ctrlArea.removeFromTop(ctrlArea.getHeight() / 2)); + soloButton.setBounds(ctrlArea); + panSlider.setBounds(area.removeFromRight(40)); + + // Leave a fixed room for the meter rows at the bottom of the main strip + int meterH = 6; + area.removeFromBottom(meterH); + int sliderHeight = juce::jmin(18, area.getHeight()); + volumeSlider.setBounds(area.getX(), area.getCentreY() - sliderHeight / 2, area.getWidth(), sliderHeight); + volumeSlider.setSliderStyle(juce::Slider::LinearHorizontal); + dbLabel.setVisible(false); + + // Per-channel rows below the main strip when expanded + if (isExpanded && isMultiChanPeer && numRemoteChannels > 1) + { + auto expandArea = getLocalBounds().reduced(2); + expandArea.removeFromTop(40); // skip the main strip row + int rowH = 36; + for (int i = 0; i < numRemoteChannels; ++i) + { + auto row = expandArea.removeFromTop(rowH); + row.removeFromLeft(6); + // Left 80px: channel name; rest: slider + channelNameLabels[i].setBounds(row.removeFromLeft(80)); + channelNameLabels[i].setVisible(true); + channelSliders[i].setBounds(row.reduced(0, 2)); + channelSliders[i].setSliderStyle(juce::Slider::LinearHorizontal); + channelSliders[i].setVisible(true); + } + for (int i = numRemoteChannels; i < kMaxRemoteCh; ++i) + { + channelSliders[i].setVisible(false); + channelNameLabels[i].setVisible(false); + } + } + else + { + for (int i = 0; i < kMaxRemoteCh; ++i) + { + channelSliders[i].setVisible(false); + channelNameLabels[i].setVisible(false); + } + } + } +} + +int UserChannelStrip::getPreferredHeight() const +{ + int base = 40; + if (!isHorizontalLayout && isExpanded && isMultiChanPeer && numRemoteChannels > 1) + return base + numRemoteChannels * 36; + return base; +} + +int UserChannelStrip::getPreferredWidth() const +{ + if (isHorizontalLayout && isExpanded && isMultiChanPeer && numRemoteChannels > 1) + return 60 + numRemoteChannels * 36; // narrower main + wider sub-channels + return 80; +} + +void UserChannelStrip::setOrientation(bool isHorizontal) +{ + isHorizontalLayout = isHorizontal; + + if (isHorizontalLayout) + { + panSlider.setSliderStyle(juce::Slider::LinearHorizontal); + panSlider.setLookAndFeel(&panLookAndFeel); + } + else + { + panSlider.setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag); + panSlider.setLookAndFeel(nullptr); + } + + resized(); + repaint(); +} + +void UserChannelStrip::updateInfo(const NinjamVst3AudioProcessor::UserInfo& info) +{ + userIndex = info.index; + userInfo = info; + nameLabel.setText(info.name, juce::dontSendNotification); + + if (!volumeSlider.isMouseOverOrDragging()) + volumeSlider.setValue(juce::jmin(info.volume, 2.0f), juce::dontSendNotification); + + if (!panSlider.isMouseOverOrDragging()) + panSlider.setValue(info.pan, juce::dontSendNotification); + + muteButton.setToggleState(info.isMuted, juce::dontSendNotification); + + // Sync multichan state — trigger layout refresh if anything changed + const int newNCh = juce::jlimit(1, kMaxRemoteCh, info.numChannels); + const bool multiStateChanged = (info.isMultiChanPeer != isMultiChanPeer) || (newNCh != numRemoteChannels); + isMultiChanPeer = info.isMultiChanPeer; + numRemoteChannels = newNCh; + + // Update channel name labels + for (int i = 0; i < kMaxRemoteCh; ++i) + { + juce::String name = i < info.channelNames.size() ? info.channelNames[i] : ""; + channelNameLabels[i].setText(name, juce::dontSendNotification); + } + if (multiStateChanged) + { + // Set button text to match current state + if (isMultiChanPeer) + expandButton.setButtonText(isHorizontalLayout ? ">" : "v"); + // If we lost multichan, collapse + if (!isMultiChanPeer && isExpanded) + { + isExpanded = false; + expandButton.setButtonText(isHorizontalLayout ? ">" : "v"); + } + resized(); + repaint(); + // Walk up to UserListComponent so it recalculates strip heights + for (auto* p = getParentComponent(); p != nullptr; p = p->getParentComponent()) + { + if (auto* list = dynamic_cast(p)) + { + list->resized(); + break; + } + } + } + + int totalOutputs = processor.getTotalNumOutputChannels(); + if (totalOutputs <= 0) totalOutputs = 2; + int numPairs = totalOutputs / 2; + int stereoBaseId = 100; + + int ch = info.outputChannel; + int id = 0; + bool isMono = (ch & 1024) != 0; + int chanIdx = ch & 1023; + if (isMono) + { + if (chanIdx >= 0 && chanIdx < totalOutputs) + id = chanIdx + 1; + } + else + { + int pair = chanIdx / 2; + if (pair >= 0 && pair < numPairs) + id = stereoBaseId + pair; + } + + if (id > 0 && outputSelector.getSelectedId() != id) + outputSelector.setSelectedId(id, juce::dontSendNotification); +} + +void UserChannelStrip::setClipEnabled(bool enabled) +{ + clipButton.setToggleState(enabled, juce::dontSendNotification); + processor.setUserClipEnabled(userIndex, enabled); +} + +void UserChannelStrip::timerCallback() +{ + for (auto* c = getParentComponent(); c != nullptr; c = c->getParentComponent()) + if (auto* editor = dynamic_cast(c)) + if (editor->shouldDeferHeavyUiWork()) + return; + + auto peakL = processor.getUserPeak(userIndex, 0); + auto peakR = processor.getUserPeak(userIndex, 1); + + bool needRepaint = false; + + if (std::abs(peakL - currentPeakL) > 0.001f || std::abs(peakR - currentPeakR) > 0.001f) + { + currentPeakL = peakL; + currentPeakR = peakR; + float peak = juce::jmax(currentPeakL, currentPeakR); + float db = -60.0f; + if (peak > 1.0e-6f) + db = juce::jlimit(-60.0f, 6.0f, 20.0f * std::log10(peak)); + dbLabel.setText(juce::String(db, 1) + " dB", juce::dontSendNotification); + needRepaint = true; + } + + // Update per-NINJAM-channel peaks for multichan peers (used by collapsed multi-meter + expanded rows) + if (isMultiChanPeer && numRemoteChannels > 1) + { + for (int ch = 0; ch < numRemoteChannels; ++ch) + { + // NINJAM ch0 = Vorbis mixdown; individual channels start at ch1 + float chPeak = processor.getUserChannelPeak(userIndex, ch + 1, -1); // -1 = both/max + if (std::abs(chPeak - channelPeaks[ch]) > 0.001f) + { + channelPeaks[ch] = chPeak; + needRepaint = true; + } + } + } + + if (needRepaint) + repaint(); +} + +void UserChannelStrip::volumeChanged() +{ + applyVolumesToProcessor(); +} + +void UserChannelStrip::panChanged() +{ + applyVolumesToProcessor(); +} + +void UserChannelStrip::outputChanged() +{ + int selectedId = outputSelector.getSelectedId(); + if (selectedId <= 0) + return; + + int totalOutputs = processor.getTotalNumOutputChannels(); + if (totalOutputs <= 0) totalOutputs = 2; + int numPairs = totalOutputs / 2; + int stereoBaseId = 100; + + if (selectedId >= 1 && selectedId <= totalOutputs) + // Single (mono) channel: set the 1024 mono bit so njclient outputs to one channel only + processor.setUserOutput(userIndex, (selectedId - 1) | 1024); + else if (selectedId >= stereoBaseId && selectedId < stereoBaseId + numPairs) + // Stereo pair: no mono bit, base channel is pair * 2 + processor.setUserOutput(userIndex, (selectedId - stereoBaseId) * 2); +} + +void UserChannelStrip::muteChanged() +{ + applyVolumesToProcessor(); +} + +void UserChannelStrip::soloChanged() +{ + applyVolumesToProcessor(); +} + +void UserChannelStrip::clipChanged() +{ + processor.setUserClipEnabled(userIndex, clipButton.getToggleState()); +} + +void UserChannelStrip::toggleExpanded() +{ + isExpanded = !isExpanded; + if (isHorizontalLayout) + expandButton.setButtonText(isExpanded ? "<" : ">"); + else + expandButton.setButtonText(isExpanded ? "^" : "v"); + resized(); + // Walk up to UserListComponent to trigger full height recalculation + for (auto* p = getParentComponent(); p != nullptr; p = p->getParentComponent()) + { + if (auto* list = dynamic_cast(p)) + { + list->resized(); + break; + } + } +} + +void UserChannelStrip::applyVolumesToProcessor() +{ + float mv = (float)volumeSlider.getValue(); + float pan = (float)panSlider.getValue(); + bool mute = muteButton.getToggleState(); + bool solo = soloButton.getToggleState(); + processor.setUserLevel(userIndex, mv, pan, mute, solo); + // Re-apply per-channel gain overrides for multichan peers + if (isMultiChanPeer && numRemoteChannels > 1) + { + for (int ch = 0; ch < numRemoteChannels; ++ch) + // NINJAM ch0 = Vorbis mixdown; individual channels start at ch1 + processor.setUserNjChannelVolume(userIndex, ch + 1, mv * perChannelGain[ch]); + } +} + +// ============================================================================== +// UserListComponent Implementation +// ============================================================================== + +UserListComponent::UserListComponent(NinjamVst3AudioProcessor& p) + : processor(p) +{ + setOpaque(false); + addAndMakeVisible(viewport); + viewport.setViewedComponent(&contentComponent, false); + viewport.setScrollBarsShown(true, true); + contentComponent.setOpaque(false); +} + +UserListComponent::~UserListComponent() +{ + strips.clear(); +} + +void UserListComponent::paint(juce::Graphics& g) +{ + g.fillAll(juce::Colours::black.withAlpha(0.30f)); +} + +void UserListComponent::resized() +{ + viewport.setBounds(getLocalBounds()); + + int stripWidth = isHorizontal ? 80 : viewport.getWidth() - 15; + int defHeight = isHorizontal ? viewport.getHeight() - 20 : 40; + if (stripWidth < 10) stripWidth = 10; + if (defHeight < 10) defHeight = 10; + + int x = 0, y = 0; + for (auto& strip : strips) + { + int sw = isHorizontal ? strip->getPreferredWidth() : stripWidth; + int sh = isHorizontal ? defHeight : strip->getPreferredHeight(); + strip->setBounds(x, y, sw, sh); + if (isHorizontal) x += sw; + else y += sh; + } + + if (isHorizontal) + contentComponent.setBounds(0, 0, x, viewport.getHeight() - 20); + else + contentComponent.setBounds(0, 0, viewport.getWidth() - 15, juce::jmax(y, viewport.getHeight() - 20)); +} + +void UserListComponent::updateContent() +{ + auto users = processor.getConnectedUsers(); + + if (users.size() != strips.size()) + { + strips.clear(); + contentComponent.removeAllChildren(); + + for (const auto& u : users) + { + auto strip = std::make_unique(processor, u.index); + strip->setOrientation(isHorizontal); + strip->updateInfo(u); + strip->setClipEnabled(processor.isSoftLimiterEnabled()); + contentComponent.addAndMakeVisible(strip.get()); + strips.push_back(std::move(strip)); + } + resized(); + } + else + { + for (size_t i = 0; i < users.size(); ++i) + strips[i]->updateInfo(users[i]); + } +} + +void UserListComponent::setLayoutMode(bool horizontal) +{ + isHorizontal = horizontal; + for (auto& strip : strips) + strip->setOrientation(horizontal); + resized(); +} + +void UserListComponent::setAllClipEnabled(bool enabled) +{ + for (auto& strip : strips) + strip->setClipEnabled(enabled); +} + +std::vector UserListComponent::getStripPointers() const +{ + std::vector pointers; + pointers.reserve(strips.size()); + for (const auto& strip : strips) + pointers.push_back(strip.get()); + return pointers; +} +#if 0 +void FaderLookAndFeel::drawLinearSlider(juce::Graphics& g, int x, int y, int width, int height, float sliderPos, float minSliderPos, float maxSliderPos, const juce::Slider::SliderStyle style, juce::Slider& slider) { if (auto* editor = dynamic_cast(slider.getParentComponent())) { if (editor->faderKnobImage.isValid()) { auto isVertical = style == juce::Slider::LinearVertical; auto thumbWidth = isVertical ? width * 0.8f : 30.0f; auto thumbHeight = isVertical ? 30.0f : height * 0.8f; auto thumbX = isVertical ? (float)x + (float)width * 0.1f : sliderPos - thumbWidth * 0.5f; auto thumbY = isVertical ? sliderPos - thumbHeight * 0.5f : (float)y + (float)height * 0.1f; g.drawImageWithin(editor->faderKnobImage, (int)thumbX, (int)thumbY, (int)thumbWidth, (int)thumbHeight, juce::RectanglePlacement::centred); return; } } auto thumbWidth = (style == juce::Slider::LinearVertical) ? width * 0.8f : 12.0f; auto thumbHeight = (style == juce::Slider::LinearVertical) ? 12.0f : height * 0.8f; auto thumbX = (style == juce::Slider::LinearVertical) ? (float)x + (float)width * 0.1f : sliderPos - thumbWidth * 0.5f; auto thumbY = (style == juce::Slider::LinearVertical) ? sliderPos - thumbHeight * 0.5f : (float)y + (float)height * 0.1f; g.setColour(juce::Colour(0xff666666)); g.fillRoundedRectangle(thumbX, thumbY, thumbWidth, thumbHeight, 2.0f); } +void CustomKnobLookAndFeel::drawRotarySlider(juce::Graphics& g, int x, int y, int width, int height, float sliderPos, const float rotaryStartAngle, const float rotaryEndAngle, juce::Slider& slider) { auto centreX = (float)x + (float)width * 0.5f; auto centreY = (float)y + (float)height * 0.5f; auto* editor = dynamic_cast(slider.getParentComponent()); if (editor == nullptr) { auto* p = slider.getParentComponent(); while (p != nullptr && editor == nullptr) { editor = dynamic_cast(p); p = p->getParentComponent(); } } if (editor != nullptr && editor->radioKnobImage.isValid()) { const float radius = (float)juce::jmin(width / 2, height / 2) - 4.0f; auto angle = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle); g.drawImageWithin(editor->radioKnobImage, (int)(centreX - radius), (int)(centreY - radius), (int)(radius * 2.0f), (int)(radius * 2.0f), juce::RectanglePlacement::fillDestination); return; } auto radius = (float)juce::jmin(width / 2, height / 2) - 10.0f; g.setColour(juce::Colour(0xffdddddd)); g.fillEllipse(centreX - radius, centreY - radius, radius * 2.0f, radius * 2.0f); } +NinjamVst3AudioProcessorEditor::NinjamVst3AudioProcessorEditor (NinjamVst3AudioProcessor& p) : AudioProcessorEditor (&p), audioProcessor (p), intervalDisplay(p), userList(p) { setSize (1120, 620); setResizable(true, true); addAndMakeVisible(serverField); addAndMakeVisible(serverListButton); addAndMakeVisible(backgroundSelector); backgroundSelector.onChange = [this] { auto files = juce::File(\ C:\\\Users\\\mcand\\\Pictures\\\textures\).findChildFiles(juce::File::findFiles, false, \*.jpg\); int idx = backgroundSelector.getSelectedItemIndex(); if (idx >= 0 ; idx < files.size()) { backgroundImage = juce::ImageFileFormat::loadFrom(files[idx]); loadControlImages(files[idx]); repaint(); } }; loadControlImages(juce::File(\C:\\\Users\\\mcand\\\Pictures\\\textures\\\Brushed Metal 1.jpg\)); } NinjamVst3AudioProcessorEditor::~NinjamVst3AudioProcessorEditor() {} void NinjamVst3AudioProcessorEditor::paint (juce::Graphics& g) { if (backgroundImage.isValid()) g.drawImageWithin(backgroundImage, 0, 0, getWidth(), getHeight(), juce::RectanglePlacement::fillDestination); else g.fillAll(juce::Colour(0xff222222)); } void NinjamVst3AudioProcessorEditor::resized() { auto header = getLocalBounds().removeFromTop(40); backgroundSelector.setBounds(header.removeFromRight(150).reduced(2)); } void NinjamVst3AudioProcessorEditor::loadControlImages(const juce::File& f) { auto dir = f.getParentDirectory(); auto base = f.getFileNameWithoutExtension(); radioKnobImage = juce::ImageFileFormat::loadFrom(dir.getChildFile(base + \_radioknob.png\)); faderKnobImage = juce::ImageFileFormat::loadFrom(dir.getChildFile(base + \_faderknob.png\)); repaint(); } +#endif diff --git a/extras/ninjam-vst3/Source/PluginEditor.h b/extras/ninjam-vst3/Source/PluginEditor.h new file mode 100644 index 00000000..4c763295 --- /dev/null +++ b/extras/ninjam-vst3/Source/PluginEditor.h @@ -0,0 +1,1131 @@ +#pragma once + +#include +#include "PluginProcessor.h" + +class NinjamVst3AudioProcessorEditor; // forward declaration for LAF classes + +#if JUCE_WINDOWS +struct WinVideoReader; // Windows Media Foundation frame reader (defined in PluginEditor.cpp) +#endif + +class IntervalDisplayComponent : public juce::Component +{ +public: + IntervalDisplayComponent(NinjamVst3AudioProcessor& p) : processor(p) {} + + void paint(juce::Graphics& g) override + { + int bpi = processor.getBPI(); + if (bpi <= 0) + bpi = 4; + + const float progress = juce::jlimit(0.0f, 1.0f, processor.getIntervalProgress()); + const float totalBeats = progress * (float)bpi; + const int currentBeat = (int)totalBeats; + + auto bounds = getLocalBounds().toFloat(); + const float blockWidth = bounds.getWidth() / (float)bpi; + const float blockHeight = bounds.getHeight(); + + const juce::Colour onColor = juce::Colour(0xFFFFFDD0); + const juce::Colour offColor = juce::Colours::black.withAlpha(0.3f); + + for (int i = 0; i < bpi; ++i) + { + auto blockArea = juce::Rectangle(i * blockWidth, 0.0f, blockWidth, blockHeight).reduced(2.0f); + if (i < currentBeat) + { + g.setColour(onColor); + g.fillRect(blockArea); + } + else if (i == currentBeat && currentBeat < bpi) + { + const float subBeat = totalBeats - (float)currentBeat; + const float alpha = 0.6f + 0.4f * std::sin(subBeat * juce::MathConstants::pi); + g.setColour(onColor.withAlpha(alpha)); + g.fillRect(blockArea); + } + else + { + g.setColour(offColor); + g.drawRect(blockArea, 1.0f); + } + } + } + +private: + NinjamVst3AudioProcessor& processor; +}; + +class OutlinedLabelLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + void drawLabel(juce::Graphics& g, juce::Label& label) override + { + if (!label.isBeingEdited()) + { + auto alpha = label.isEnabled() ? 1.0f : 0.5f; + auto font = getLabelFont(label); + g.setFont(font); + + auto textArea = getLabelBorderSize(label).subtractedFrom(label.getLocalBounds()); + juce::String text = label.getText(); + if (text.isEmpty()) return; + + auto just = label.getJustificationType(); + + // black outline: draw at radius-1 and radius-2 for heavier weight + g.setColour(juce::Colours::black.withAlpha(alpha * 0.80f)); + for (int r = 1; r <= 2; ++r) + for (int dx = -r; dx <= r; ++dx) + for (int dy = -r; dy <= r; ++dy) + if (dx != 0 || dy != 0) + g.drawFittedText(text, + textArea.translated(dx, dy), + just, 1, 1.0f); + + // main text on top + g.setColour(label.findColour(juce::Label::textColourId).withMultipliedAlpha(alpha)); + g.drawFittedText(text, textArea, just, 1, 1.0f); + } + else + { + LookAndFeel_V4::drawLabel(g, label); + } + } + + void drawToggleButton(juce::Graphics& g, juce::ToggleButton& button, + bool shouldDrawButtonAsHighlighted, bool shouldDrawButtonAsDown) override + { + // Draw the tick box using the default implementation first (painted separately) + auto tickWidth = juce::jmin(20.0f, (float)button.getHeight() * 0.8f); + drawTickBox(g, button, 4.0f, ((float)button.getHeight() - tickWidth) * 0.5f, + tickWidth, tickWidth, button.getToggleState(), + button.isEnabled(), shouldDrawButtonAsHighlighted, shouldDrawButtonAsDown); + + // Draw label text with black outline + auto alpha = button.isEnabled() ? 1.0f : 0.5f; + auto textX = (int)(tickWidth + 8.0f); + auto textArea = button.getLocalBounds().withTrimmedLeft(textX); + juce::String text = button.getButtonText(); + g.setFont(13.0f); + + g.setColour(juce::Colours::black.withAlpha(alpha * 0.80f)); + for (int r = 1; r <= 2; ++r) + for (int dx = -r; dx <= r; ++dx) + for (int dy = -r; dy <= r; ++dy) + if (dx != 0 || dy != 0) + g.drawFittedText(text, textArea.translated(dx, dy), + juce::Justification::centredLeft, 1, 1.0f); + + g.setColour(button.findColour(juce::ToggleButton::textColourId).withMultipliedAlpha(alpha)); + g.drawFittedText(text, textArea, juce::Justification::centredLeft, 1, 1.0f); + } +}; + +class FaderLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + void drawLinearSlider(juce::Graphics&, int x, int y, int width, int height, + float sliderPos, float minSliderPos, float maxSliderPos, + const juce::Slider::SliderStyle, juce::Slider&) override; + void drawLinearSliderBackground(juce::Graphics&, int x, int y, int width, int height, + float sliderPos, float minSliderPos, float maxSliderPos, + const juce::Slider::SliderStyle, juce::Slider&) override; +}; + +class NonlinearFaderSlider : public juce::Slider +{ +public: + NonlinearFaderSlider() = default; + + double valueToProportionOfLength(double value) override + { + auto range = getRange(); + double minV = range.getStart(); + double maxV = range.getEnd(); + if (maxV <= minV) + return 0.0; + + double norm = (value - minV) / (maxV - minV); + norm = juce::jlimit(0.0, 1.0, norm); + + double midNorm = (1.0 - minV) / (maxV - minV); + double p0 = 0.8; + + if (norm <= midNorm) + { + if (midNorm <= 0.0) + return 0.0; + double p = (norm / midNorm) * p0; + return p; + } + else + { + double xProp = (norm - midNorm) / (1.0 - midNorm); + double p = p0 + xProp * (1.0 - p0); + return p; + } + } + + double proportionOfLengthToValue(double proportion) override + { + auto range = getRange(); + double minV = range.getStart(); + double maxV = range.getEnd(); + if (maxV <= minV) + return minV; + + double p = juce::jlimit(0.0, 1.0, (double)proportion); + double midNorm = (1.0 - minV) / (maxV - minV); + double p0 = 0.8; + + double norm; + if (p <= p0) + { + if (p0 <= 0.0) + norm = 0.0; + else + norm = (p / p0) * midNorm; + } + else + { + double xProp = (p - p0) / (1.0 - p0); + norm = midNorm + xProp * (1.0 - midNorm); + } + + return minV + norm * (maxV - minV); + } + + void mouseDown(const juce::MouseEvent& e) override + { + leftInteractionActive = e.mods.isLeftButtonDown(); + if (leftInteractionActive) + juce::Slider::mouseDown(e); + } + + void mouseDrag(const juce::MouseEvent& e) override + { + if (leftInteractionActive) + juce::Slider::mouseDrag(e); + } + + void mouseUp(const juce::MouseEvent& e) override + { + if (leftInteractionActive) + juce::Slider::mouseUp(e); + leftInteractionActive = false; + } + + void mouseDoubleClick(const juce::MouseEvent& e) override + { + if (e.mods.isLeftButtonDown()) + juce::Slider::mouseDoubleClick(e); + } + +private: + bool leftInteractionActive = false; +}; + +class LeftClickOnlySlider : public juce::Slider +{ +public: + using juce::Slider::Slider; + + void mouseDown(const juce::MouseEvent& e) override + { + leftInteractionActive = e.mods.isLeftButtonDown(); + if (leftInteractionActive) + juce::Slider::mouseDown(e); + } + + void mouseDrag(const juce::MouseEvent& e) override + { + if (leftInteractionActive) + juce::Slider::mouseDrag(e); + } + + void mouseUp(const juce::MouseEvent& e) override + { + if (leftInteractionActive) + juce::Slider::mouseUp(e); + leftInteractionActive = false; + } + + void mouseDoubleClick(const juce::MouseEvent& e) override + { + if (e.mods.isLeftButtonDown()) + juce::Slider::mouseDoubleClick(e); + } + +private: + bool leftInteractionActive = false; +}; + +class LeftClickOnlyTextButton : public juce::TextButton +{ +public: + using juce::TextButton::TextButton; + + void mouseDown(const juce::MouseEvent& e) override + { + leftInteractionActive = e.mods.isLeftButtonDown(); + if (leftInteractionActive) + juce::TextButton::mouseDown(e); + } + + void mouseDrag(const juce::MouseEvent& e) override + { + if (leftInteractionActive) + juce::TextButton::mouseDrag(e); + } + + void mouseUp(const juce::MouseEvent& e) override + { + if (leftInteractionActive) + juce::TextButton::mouseUp(e); + leftInteractionActive = false; + } + +private: + bool leftInteractionActive = false; +}; + +class LeftClickOnlyToggleButton : public juce::ToggleButton +{ +public: + using juce::ToggleButton::ToggleButton; + + void mouseDown(const juce::MouseEvent& e) override + { + leftInteractionActive = e.mods.isLeftButtonDown(); + if (leftInteractionActive) + juce::ToggleButton::mouseDown(e); + } + + void mouseDrag(const juce::MouseEvent& e) override + { + if (leftInteractionActive) + juce::ToggleButton::mouseDrag(e); + } + + void mouseUp(const juce::MouseEvent& e) override + { + if (leftInteractionActive) + juce::ToggleButton::mouseUp(e); + leftInteractionActive = false; + } + +private: + bool leftInteractionActive = false; +}; + +class MuteSoloBtnLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + bool isMute = true; // true = red (mute), false = yellow-orange (solo) + + void drawButtonBackground(juce::Graphics& g, juce::Button& button, const juce::Colour&, + bool shouldDrawButtonAsHighlighted, bool) override + { + auto bounds = button.getLocalBounds().toFloat().reduced(1.0f); + bool isOn = button.getToggleState(); + const float r = 4.0f; + + juce::Colour bg, rim; + if (isMute) + { + bg = isOn ? juce::Colour::fromRGB(130, 20, 20) : juce::Colour::fromRGB(35, 8, 8); + rim = isOn ? juce::Colour::fromRGB(255, 80, 80) + : juce::Colour::fromRGB(255, 80, 80).withAlpha(0.25f); + } + else + { + bg = isOn ? juce::Colour::fromRGB(155, 100, 5) : juce::Colour::fromRGB(42, 25, 3); + rim = isOn ? juce::Colour::fromRGB(255, 210, 60) + : juce::Colour::fromRGB(255, 210, 60).withAlpha(0.25f); + } + + g.setColour(bg); + g.fillRoundedRectangle(bounds, r); + g.setColour(rim); + g.drawRoundedRectangle(bounds, r, 1.5f); + + if (shouldDrawButtonAsHighlighted) + { + g.setColour(juce::Colours::white.withAlpha(0.06f)); + g.fillRoundedRectangle(bounds, r); + } + } + + void drawButtonText(juce::Graphics& g, juce::TextButton& button, bool, bool) override + { + bool isOn = button.getToggleState(); + auto bounds = button.getLocalBounds(); + float fontSize = juce::jmin(14.0f, (float)bounds.getHeight() * 0.65f); + g.setFont(juce::Font(fontSize, juce::Font::bold)); + + juce::Colour tc = isOn ? juce::Colours::white : juce::Colours::white.withAlpha(0.30f); + + g.setColour(juce::Colours::black.withAlpha(0.75f)); + for (int dx = -1; dx <= 1; ++dx) + for (int dy = -1; dy <= 1; ++dy) + if (dx != 0 || dy != 0) + g.drawFittedText(button.getButtonText(), bounds.translated(dx, dy), + juce::Justification::centred, 1); + g.setColour(tc); + g.drawFittedText(button.getButtonText(), bounds, juce::Justification::centred, 1); + } +}; + +class UserChannelStrip : public juce::Component, public juce::Timer +{ +public: + UserChannelStrip(NinjamVst3AudioProcessor& p, int userIdx); + ~UserChannelStrip() override; + + void paint(juce::Graphics& g) override; + void paintOverChildren(juce::Graphics& g) override; + void resized() override; + void timerCallback() override; + + void updateInfo(const NinjamVst3AudioProcessor::UserInfo& info); + void setOrientation(bool isHorizontal); // True = Mixer layout (Strip is vertical), False = List layout (Strip is horizontal) + void setClipEnabled(bool enabled); + int getPreferredHeight() const; // For dynamic height in list layout when expanded + int getPreferredWidth() const; // For dynamic width in mixer layout when expanded + int getUserIndex() const; + juce::Slider& getVolumeSlider(); + juce::Slider& getPanSlider(); + juce::Button& getMuteButton(); + juce::Button& getSoloButton(); + juce::Slider& getChannelSlider(int channel); + +private: + class PanSliderLookAndFeel : public juce::LookAndFeel_V4 + { + public: + void drawLinearSlider(juce::Graphics& g, int x, int y, int width, int height, + float sliderPos, float minSliderPos, float maxSliderPos, + const juce::Slider::SliderStyle style, juce::Slider& slider) override + { + if (style != juce::Slider::LinearHorizontal) + { + juce::LookAndFeel_V4::drawLinearSlider(g, x, y, width, height, sliderPos, minSliderPos, maxSliderPos, style, slider); + return; + } + + juce::Rectangle bounds(x, y, width, height); + int trackHeight = 6; + juce::Rectangle track(bounds.getX() + 4, + bounds.getCentreY() - trackHeight / 2, + bounds.getWidth() - 8, + trackHeight); + + juce::Colour base = slider.findColour(juce::Slider::backgroundColourId, true); + if (base == juce::Colour()) + base = juce::Colours::darkgrey.darker(); + + g.setColour(base); + g.fillRect(track); + + g.setColour(juce::Colours::black.withAlpha(0.7f)); + g.drawRect(track); + + double v = slider.getValue(); + double norm = juce::jlimit(-1.0, 1.0, v); + + int centreX = track.getCentreX(); + + if (std::abs(norm) > 0.001) + { + bool toRight = norm > 0.0; + float amount = (float)std::abs(norm); + const int leftEdge = track.getX(); + const int rightEdge = track.getRight(); + const int halfWidth = juce::jmax(1, track.getWidth() / 2); + const int activeWidth = juce::jmax(1, (int)std::round(halfWidth * amount)); + + juce::Rectangle active(track); + if (toRight) + { + active.setLeft(centreX); + active.setRight(juce::jmin(rightEdge, centreX + activeWidth)); + } + else + { + active.setRight(centreX); + active.setLeft(juce::jmax(leftEdge, centreX - activeWidth)); + } + + juce::Colour endColour = toRight ? juce::Colours::red : juce::Colours::white; + juce::ColourGradient grad(juce::Colours::black, (float)centreX, (float)track.getCentreY(), + endColour, toRight ? (float)active.getRight() : (float)active.getX(), (float)track.getCentreY(), false); + + g.setGradientFill(grad); + g.setOpacity(1.0f); + g.fillRect(active); + } + + int thumbWidth = 6; + int thumbHeight = trackHeight + 6; + int thumbX = (int)sliderPos - thumbWidth / 2; + juce::Rectangle thumb(thumbX, track.getCentreY() - thumbHeight / 2, thumbWidth, thumbHeight); + + g.setColour(juce::Colours::white); + g.fillRect(thumb); + g.setColour(juce::Colours::black); + g.drawRect(thumb); + } + }; + + NinjamVst3AudioProcessor& processor; + int userIndex; + NinjamVst3AudioProcessor::UserInfo userInfo; + + juce::Label nameLabel; + NonlinearFaderSlider volumeSlider; + LeftClickOnlySlider panSlider; + PanSliderLookAndFeel panLookAndFeel; + LeftClickOnlyToggleButton clipButton{"No-Clip"}; + LeftClickOnlyTextButton muteButton{"M"}; + LeftClickOnlyTextButton soloButton{"S"}; + MuteSoloBtnLookAndFeel muteBtnLAF; + MuteSoloBtnLookAndFeel soloBtnLAF; + juce::ComboBox outputSelector; + FaderLookAndFeel faderLookAndFeel; + juce::Label dbLabel; + bool showOutputSelector = true; + + float currentPeakL = 0.0f; + float currentPeakR = 0.0f; + bool isHorizontalLayout = false; // Default List view (strip is horizontal) + + // Multi-channel remote support + static constexpr int kMaxRemoteCh = 8; + LeftClickOnlyTextButton expandButton{ ">" }; + bool isExpanded = false; + int numRemoteChannels = 1; + bool isMultiChanPeer = false; + float perChannelGain[kMaxRemoteCh]; + float channelPeaks[kMaxRemoteCh]; + LeftClickOnlySlider channelSliders[kMaxRemoteCh]; + juce::Label channelNameLabels[kMaxRemoteCh]; // shows remote channel names + + void applyVolumesToProcessor(); + void toggleExpanded(); + void volumeChanged(); + void panChanged(); + void outputChanged(); + void muteChanged(); + void soloChanged(); + void clipChanged(); +}; + +class MasterPeakMeter : public juce::Component +{ +public: + void setPeak(float newPeak) + { + peakL = juce::jlimit(0.0f, 1.0f, newPeak); + peakR = peakL; + repaint(); + } + + void setPeak(float newPeakL, float newPeakR) + { + peakL = juce::jlimit(0.0f, 1.0f, newPeakL); + peakR = juce::jlimit(0.0f, 1.0f, newPeakR); + repaint(); + } + + void paint(juce::Graphics& g) override + { + auto bounds = getLocalBounds(); + g.fillAll(juce::Colours::black); + + auto gap = 1; + auto barWidth = juce::jmax(1, (bounds.getWidth() - gap) / 2); + auto leftBar = bounds.removeFromLeft(barWidth); + bounds.removeFromLeft(gap); + auto rightBar = bounds; + + auto drawBar = [&g] (juce::Rectangle barBounds, float peak) + { + float safePeak = juce::jlimit(1.0e-6f, 1.0f, peak); + float db = 20.0f * std::log10(safePeak); + + juce::Colour colour; + if (db >= 0.0f) colour = juce::Colours::red; + else if (db > -6.0f) colour = juce::Colours::yellow; + else colour = juce::Colours::green; + + int filled = (int)(barBounds.getHeight() * safePeak); + if (filled <= 0) + return; + + juce::Rectangle fill(barBounds.getX(), barBounds.getBottom() - filled, barBounds.getWidth(), filled); + g.setColour(colour); + g.fillRect(fill); + }; + + drawBar(leftBar, peakL); + drawBar(rightBar, peakR); + } + +private: + float peakL = 0.0f; + float peakR = 0.0f; +}; + +class UserListComponent : public juce::Component +{ +public: + UserListComponent(NinjamVst3AudioProcessor& p); + ~UserListComponent() override; + + void paint(juce::Graphics& g) override; + void resized() override; + + void updateContent(); + void setLayoutMode(bool horizontal); // True = Horizontal Mixer, False = Vertical List + void setAllClipEnabled(bool enabled); + std::vector getStripPointers() const; + +private: + NinjamVst3AudioProcessor& processor; + juce::Viewport viewport; + juce::Component contentComponent; + std::vector> strips; + bool isHorizontal = false; +}; + +class CustomKnobLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + void drawRotarySlider(juce::Graphics& g, int x, int y, int width, int height, float sliderPos, + const float rotaryStartAngle, const float rotaryEndAngle, juce::Slider& slider) override; +}; + +class SyncIconLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + void drawButtonBackground(juce::Graphics& g, juce::Button& button, const juce::Colour&, + bool shouldDrawButtonAsHighlighted, bool /*shouldDrawButtonAsDown*/) override + { + auto bounds = button.getLocalBounds().toFloat().reduced(1.0f); + bool isOn = button.getToggleState(); + const float r = 4.0f; + + juce::Colour bg = isOn ? juce::Colour::fromRGB(110, 60, 10) + : juce::Colour::fromRGB(35, 18, 4); + juce::Colour rim = isOn ? juce::Colour::fromRGB(255, 160, 60) + : juce::Colour::fromRGB(255, 160, 60).withAlpha(0.25f); + juce::Colour ic = isOn ? juce::Colour::fromRGB(255, 185, 90) + : juce::Colour::fromRGB(255, 185, 90).withAlpha(0.22f); + + g.setColour(bg); + g.fillRoundedRectangle(bounds, r); + g.setColour(rim); + g.drawRoundedRectangle(bounds, r, 1.5f); + + // --- sync / refresh icon: two circular arrows forming a circle --- + float cx = bounds.getCentreX(); + float cy = bounds.getCentreY(); + float ir = bounds.getWidth() * 0.34f; // arc radius + float sw = juce::jmax(1.4f, bounds.getWidth() * 0.115f); + float ahw = sw * 1.5f; // arrowhead half-width + float ahl = sw * 2.0f; // arrowhead length + + g.setColour(ic); + + using M = juce::MathConstants; + const float deg = M::pi / 180.0f; + + // Draw arc from startA to endA (clockwise), with filled arrowhead at end + auto drawArcArrow = [&](float startA, float endA) + { + juce::Path arc; + arc.addCentredArc(cx, cy, ir, ir, 0.0f, startA, endA, true); + g.strokePath(arc, juce::PathStrokeType(sw, juce::PathStrokeType::curved, + juce::PathStrokeType::butt)); + + // Arrowhead tip at end of arc; base recessed along clockwise tangent + float tipX = cx + std::cos(endA) * ir; + float tipY = cy + std::sin(endA) * ir; + float tanA = endA + M::halfPi; // clockwise tangent direction at endA + float bx1 = tipX - std::cos(tanA) * ahl - std::cos(endA) * ahw; + float by1 = tipY - std::sin(tanA) * ahl - std::sin(endA) * ahw; + float bx2 = tipX - std::cos(tanA) * ahl + std::cos(endA) * ahw; + float by2 = tipY - std::sin(tanA) * ahl + std::sin(endA) * ahw; + juce::Path arrowHead; + arrowHead.startNewSubPath(tipX, tipY); + arrowHead.lineTo(bx1, by1); + arrowHead.lineTo(bx2, by2); + arrowHead.closeSubPath(); + g.fillPath(arrowHead); + }; + + // Arc 1: 210° → 370°(=10°), sweeps clockwise over the TOP of the circle + drawArcArrow(210.0f * deg, 370.0f * deg); + // Arc 2: 30° → 190°, sweeps clockwise over the BOTTOM of the circle + drawArcArrow(30.0f * deg, 190.0f * deg); + + if (shouldDrawButtonAsHighlighted) + { + g.setColour(juce::Colours::white.withAlpha(0.06f)); + g.fillRoundedRectangle(bounds, r); + } + } +}; + +class MetronomeButtonLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + juce::Colour themeColour { juce::Colour::fromRGB(80, 185, 255) }; + + void drawButtonBackground(juce::Graphics& g, juce::Button& button, const juce::Colour&, + bool shouldDrawButtonAsHighlighted, bool /*shouldDrawButtonAsDown*/) override + { + auto bounds = button.getLocalBounds().toFloat().reduced(1.0f); + bool isOn = button.getToggleState(); + const float r = 4.0f; + + juce::Colour bg = isOn ? themeColour.withMultipliedBrightness(0.55f) + : themeColour.withMultipliedBrightness(0.09f); + juce::Colour rim = isOn ? themeColour + : themeColour.withAlpha(0.25f); + juce::Colour ic = isOn ? juce::Colours::white + : juce::Colours::white.withAlpha(0.30f); + + g.setColour(bg); + g.fillRoundedRectangle(bounds, r); + g.setColour(rim); + g.drawRoundedRectangle(bounds, r, 1.5f); + + // --- metronome icon (scaled to 72% of button, centred) --- + float cx = bounds.getCentreX(); + float cy = bounds.getCentreY(); + float bw = bounds.getWidth() * 0.72f; + float bh = bounds.getHeight() * 0.72f; + float bx = cx - bw * 0.5f; + float by = cy - bh * 0.5f; + + float sw = juce::jmax(1.2f, bw * 0.085f); // stroke width scales with size + + float baseH = bh * 0.16f; + float baseY = by + bh - baseH; + float bodyBot = baseY; // trapezoid bottom (top of base) + float bodyTop = by; + float topRad = bw * 0.28f; // half-width at top + float botRad = bw * 0.46f; // half-width at bottom + + // --- outer body: trapezoid with rounded arch top --- + juce::Path body; + // arc at top (rounded cap) + body.startNewSubPath(cx - topRad, bodyTop + topRad * 0.6f); + body.quadraticTo(cx - topRad, bodyTop, cx, bodyTop); + body.quadraticTo(cx + topRad, bodyTop, cx + topRad, bodyTop + topRad * 0.6f); + // right slant down to base + body.lineTo(cx + botRad, bodyBot); + // straight bottom + body.lineTo(cx - botRad, bodyBot); + body.closeSubPath(); + + g.setColour(ic); + g.strokePath(body, juce::PathStrokeType(sw, juce::PathStrokeType::curved, juce::PathStrokeType::rounded)); + + // --- solid thick base bar --- + float baseCorner = juce::jmax(1.0f, baseH * 0.35f); + g.fillRoundedRectangle(cx - botRad, baseY, botRad * 2.0f, baseH, baseCorner); + + // --- 3 small pill tick marks on left interior --- + float innerTop = bodyTop + bh * 0.18f; + float innerBot = bodyBot - bh * 0.06f; + float innerH = innerBot - innerTop; + float pillW = bw * 0.26f; + float pillH = juce::jmax(1.5f, bh * 0.075f); + float pillR = pillH * 0.5f; + float pillX = cx - botRad + (botRad - topRad) * 0.3f + bw * 0.03f; // left interior + for (int i = 0; i < 3; ++i) + { + float py = innerTop + innerH * (float)i / 2.5f + innerH * 0.05f; + g.fillRoundedRectangle(pillX, py - pillH * 0.5f, pillW, pillH, pillR); + } + + // --- pendulum arm: pivots at bottom-centre, swings up to upper-right --- + float armX0 = cx; + float armY0 = bodyBot; + float armX1 = cx + botRad * 0.85f; + float armY1 = bodyTop + bh * 0.08f; + g.drawLine(armX0, armY0, armX1, armY1, sw * 1.1f); + + if (shouldDrawButtonAsHighlighted) + { + g.setColour(juce::Colours::white.withAlpha(0.06f)); + g.fillRoundedRectangle(bounds, r); + } + } +}; + +class ATButtonLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + void drawButtonBackground(juce::Graphics& g, juce::Button& button, const juce::Colour&, + bool shouldDrawButtonAsHighlighted, bool) override + { + auto bounds = button.getLocalBounds().toFloat().reduced(1.0f); + bool isOn = button.getToggleState(); + const float r = 4.0f; + juce::Colour bg = isOn ? juce::Colour::fromRGB(10, 90, 160) : juce::Colour::fromRGB(5, 22, 42); + juce::Colour rim = isOn ? juce::Colour::fromRGB(80, 185, 255) + : juce::Colour::fromRGB(80, 185, 255).withAlpha(0.25f); + g.setColour(bg); + g.fillRoundedRectangle(bounds, r); + g.setColour(rim); + g.drawRoundedRectangle(bounds, r, 1.5f); + if (shouldDrawButtonAsHighlighted) + { + g.setColour(juce::Colours::white.withAlpha(0.06f)); + g.fillRoundedRectangle(bounds, r); + } + } + void drawButtonText(juce::Graphics& g, juce::TextButton& button, bool, bool) override + { + bool isOn = button.getToggleState(); + auto bounds = button.getLocalBounds(); + float fontSize = juce::jmin(13.0f, (float)bounds.getHeight() * 0.65f); + g.setFont(juce::Font(fontSize, juce::Font::bold)); + juce::Colour tc = isOn ? juce::Colours::white : juce::Colours::white.withAlpha(0.30f); + g.setColour(juce::Colours::black.withAlpha(0.75f)); + for (int dx = -1; dx <= 1; ++dx) + for (int dy = -1; dy <= 1; ++dy) + if (dx != 0 || dy != 0) + g.drawFittedText(button.getButtonText(), bounds.translated(dx, dy), + juce::Justification::centred, 1); + g.setColour(tc); + g.drawFittedText(button.getButtonText(), bounds, juce::Justification::centred, 1); + } +}; + +class FaderIconLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + void drawButtonBackground(juce::Graphics& g, juce::Button& button, const juce::Colour&, + bool shouldDrawButtonAsHighlighted, bool /*shouldDrawButtonAsDown*/) override + { + auto bounds = button.getLocalBounds().toFloat().reduced(1.0f); + bool isOn = button.getToggleState(); + const float r = 4.0f; + + g.setColour(isOn ? juce::Colour::fromRGB(15, 55, 60) + : juce::Colour::fromRGB(10, 22, 26)); + g.fillRoundedRectangle(bounds, r); + + g.setColour(isOn ? juce::Colour::fromRGB(30, 180, 200) + : juce::Colour::fromRGB(30, 180, 200).withAlpha(0.22f)); + g.drawRoundedRectangle(bounds, r, 1.0f); + + // 5 fader tracks + handles + juce::Colour iconCol = isOn ? juce::Colour::fromRGB(40, 210, 230) + : juce::Colour::fromRGB(40, 210, 230).withAlpha(0.22f); + g.setColour(iconCol); + + float ix = bounds.getX() + bounds.getWidth() * 0.09f; + float iw = bounds.getWidth() * 0.82f; + float iy = bounds.getY() + bounds.getHeight() * 0.12f; + float ih = bounds.getHeight() * 0.76f; + + const float pos[5] = { 0.35f, 0.65f, 0.2f, 0.55f, 0.45f }; + float fw = iw / 5.0f; + for (int i = 0; i < 5; ++i) + { + float cx = ix + fw * (i + 0.5f); + g.drawLine(cx, iy, cx, iy + ih, 1.2f); + float hy = iy + ih * pos[i]; + float hw = fw * 0.62f; + float hh = juce::jmax(3.0f, ih * 0.18f); + g.fillRect(cx - hw * 0.5f, hy - hh * 0.5f, hw, hh); + } + + if (shouldDrawButtonAsHighlighted) + { + g.setColour(juce::Colours::white.withAlpha(0.06f)); + g.fillRoundedRectangle(bounds, r); + } + } +}; + +class NinjamVst3AudioProcessorEditor : public juce::AudioProcessorEditor, + public juce::Timer, + private juce::OSCReceiver, + private juce::OSCReceiver::Listener, + private juce::MidiInputCallback +{ +public: + NinjamVst3AudioProcessorEditor (NinjamVst3AudioProcessor&); + ~NinjamVst3AudioProcessorEditor() override; + + void paint (juce::Graphics&) override; + void paintOverChildren (juce::Graphics&) override; + void resized() override; + void timerCallback() override; + void parentHierarchyChanged() override; + void mouseDown(const juce::MouseEvent& event) override; + bool shouldDeferHeavyUiWork() const; + + juce::Image backgroundImage; + juce::Image radioKnobImage; + juce::Image faderKnobImage; + juce::Array textureFiles; +#if JUCE_WINDOWS + std::unique_ptr videoFrameReader; +#endif + juce::String knobColourPreset { "grey" }; + juce::String faderColourPreset { "grey" }; + juce::Colour knobThemeColour { juce::Colours::grey }; + juce::Colour faderThemeColour { juce::Colour(0xff666666) }; + juce::Colour metronomeThemeColour { juce::Colour::fromRGB(80, 185, 255) }; + juce::Colour windowThemeColour { juce::Colour(0x00000000) }; // transparent = no override + juce::Colour buttonThemeColour { juce::Colour(0x00000000) }; // transparent = no override + juce::Colour menuBarThemeColour { juce::Colour(0x00000000) }; // transparent = no override + CustomKnobLookAndFeel customKnobLookAndFeel; + FaderIconLookAndFeel faderIconLookAndFeel; + MetronomeButtonLookAndFeel metronomeBtnLAF; + SyncIconLookAndFeel syncIconLAF; + ATButtonLookAndFeel atBtnLAF; + ATButtonLookAndFeel chatBtnLAF; + OutlinedLabelLookAndFeel outlinedLabelLAF; + +private: + NinjamVst3AudioProcessor& audioProcessor; + IntervalDisplayComponent intervalDisplay; + juce::TooltipWindow tooltipWindow{ this, 600 }; + + // UI components + juce::Label statusLabel; + + // Login + juce::Label serverLabel{ "Server", "Server:" }; + juce::TextEditor serverField; + LeftClickOnlyTextButton serverListButton; + juce::Label userLabel{ "User", "User:" }; + juce::TextEditor userField; + LeftClickOnlyToggleButton anonymousButton{ "Anonymous" }; + juce::Label passLabel{ "Pass", "Pass:" }; + juce::TextEditor passField; + LeftClickOnlyTextButton connectButton; + + // Controls + LeftClickOnlyTextButton transmitButton{ "Transmit" }; + LeftClickOnlyTextButton localMonitorButton{ "Monitor Local" }; + LeftClickOnlyTextButton voiceChatButton{ "Voice Chat" }; + juce::ComboBox bitrateSelector; + juce::ComboBox midiRelayTargetSelector; + LeftClickOnlyTextButton layoutButton{ "" }; + LeftClickOnlyTextButton opusSyncToggle{ "HD" }; + juce::Label metronomeLabel{ "Metro", "Metronome:" }; + LeftClickOnlySlider metronomeSlider; + LeftClickOnlyTextButton metronomeMuteButton{ "" }; + LeftClickOnlyTextButton autoLevelButton{ "Auto Level" }; + LeftClickOnlyTextButton syncButton{ "" }; + LeftClickOnlyTextButton fxButton{ "FX" }; + LeftClickOnlyTextButton optionsButton{ "Options" }; + juce::Label tempoLabel; + juce::ComboBox backgroundSelector{ "Background" }; + LeftClickOnlyToggleButton videoBgToggle{ "Video BG" }; + LeftClickOnlyTextButton videoButton{ "Video Room" }; + LeftClickOnlyTextButton chatButton{ "Chat" }; + + // Chat + juce::TextEditor chatDisplay; + juce::TextEditor chatInput; + LeftClickOnlyTextButton sendButton{ "Send" }; + LeftClickOnlyTextButton atButton{ "AT" }; + LeftClickOnlyTextButton chatPopoutButton{ "Popout" }; + + // Users + juce::Label usersLabel{ "Users", "Connected Users:" }; + LeftClickOnlyToggleButton spreadOutputsButton{ "Spread Outputs" }; + UserListComponent userList; + + FaderLookAndFeel mixerFaderLookAndFeel; + juce::Label localFaderLabel{ "Local", "Local" }; + LeftClickOnlyTextButton addLocalChannelButton{ "+" }; + LeftClickOnlyTextButton removeLocalChannelButton{ "-" }; + std::array localFaders; + std::array localPeakMeters; + std::array localInputModeSelectors; + std::array localInputSelectors; + std::array localReverbSendKnobs; + std::array localDelaySendKnobs; + std::array localReverbSendLabels; + std::array localDelaySendLabels; + juce::Label masterFaderLabel{ "Master", "Master" }; + NonlinearFaderSlider masterFader; + MasterPeakMeter masterPeakMeter; + std::array localDbLabels; + std::array localChannelNameLabels; // editable channel name + juce::Label masterDbLabel; + LeftClickOnlyTextButton limiterButton{ "Limiter" }; + juce::Label limiterReleaseLabel{ "Release", "Release" }; + class LimiterThresholdLookAndFeel : public juce::LookAndFeel_V4 + { + public: + void drawLinearSlider(juce::Graphics& g, int x, int y, int width, int height, + float sliderPos, float minSliderPos, float maxSliderPos, + const juce::Slider::SliderStyle style, juce::Slider& slider) override + { + juce::Rectangle bounds(x, y, width, height); + auto track = bounds.reduced(width / 3, 6); + g.setColour(juce::Colours::black); + g.fillRect(track); + g.setColour(juce::Colours::darkgrey.brighter(0.2f)); + g.drawRect(track); + + int handleHeight = 10; + int handleWidth = track.getWidth() + 4; + int clampedY = juce::jlimit(track.getY() + handleHeight / 2, + track.getBottom() - handleHeight / 2, + (int)sliderPos); + juce::Rectangle handle(track.getCentreX() - handleWidth / 2, + clampedY - handleHeight / 2, + handleWidth, + handleHeight); + + g.setColour(juce::Colours::lightblue); + g.fillRect(handle); + g.setColour(juce::Colours::black); + g.drawRect(handle); + } + } limiterThresholdLookAndFeel; + + LeftClickOnlySlider limiterThresholdSlider; + LeftClickOnlySlider limiterReleaseSlider; + juce::Label reverbRoomLabel{ "Reverb", "Reverb" }; + LeftClickOnlySlider reverbRoomSlider; + juce::Label delayTimeLabel{ "Delay", "Delay" }; + LeftClickOnlySlider delayTimeSlider; + juce::ComboBox delayDivisionSelector; + LeftClickOnlyToggleButton delayPingPongButton{ "PingPong" }; + + void connectClicked(); + void sendClicked(); + void transmitToggled(); + void layoutToggled(); + void metronomeChanged(); + void anonymousToggled(); + void atToggled(); + void syncToggled(); + void chatToggled(); + void chatPopoutClicked(); + void videoClicked(); + + void serverListClicked(); + void updateAutoLevelButtonColor(); + void updateChatButtonColor(); + void updateTransmitButtonColor(); + void updateMonitorButtonColor(); + void updateLimiterButtonColor(); + void updateVoiceChatButtonColor(); + void updateLayoutButtonColor(); + void updateMetronomeButtonColor(); + void updateSyncButtonColor(); + void updateFxButtonLabel(); + void showFxMenu(); + void showOptionsMenu(); + void showSettingsCallout(std::unique_ptr content, juce::Component& anchorComponent); + void showReverbSettingsPopup(); + void showDelaySettingsPopup(); + void updateFxControlsVisibility(); + void refreshLocalInputSelectors(); + void refreshMidiRelayTargetSelector(); + void oscMessageReceived(const juce::OSCMessage& message) override; + void applyOscMappings(); + void applyRemoteMidiRelaySelection(int channel, int inputIndex); + void refreshLocalInputSelector(int channel); + void showMidiOptionsPopup(); + void refreshExternalMidiInputDevices(); + void handleIncomingMidiMessage(juce::MidiInput* source, const juce::MidiMessage& message) override; + void syncLearnMappingsToProcessor(); + void loadLearnMappingsFromProcessor(); + void saveLearnMappingsToDisk(); + void loadLearnMappingsFromDisk(); + void clearLearnMappings(); + bool isSidechainInputActive() const; + bool isAbletonLiveHost() const; + void setAbletonWindowSizePreset(int presetIndex); + void updateHostResizeModeForConnectionStatus(int status); + void loadControlImages(const juce::File& themeDir); + void applyThemeColours(); + void registerMidiLearnTarget(juce::Component& component, const juce::String& targetId, bool isToggle); + void syncUserStripMidiTargets(); + void showMidiLearnMenuForComponent(juce::Component& component, juce::Point screenPos); + void applyMidiMappings(); + + struct MidiLearnTarget + { + juce::String id; + juce::Component* component = nullptr; + bool isToggle = false; + }; + + struct MidiSourceMapping + { + bool isController = true; + int midiChannel = 1; + int number = 0; + int lastBinaryState = -1; + }; + + struct OscSourceMapping + { + juce::String address; + int lastBinaryState = -1; + }; + + struct PendingOscEvent + { + juce::String address; + float normalized = 0.0f; + bool binaryOn = false; + }; + + int lastChatSize = 0; + + std::unique_ptr serverListWindow; + std::unique_ptr chatWindow; + + bool autoLevelEnabled = false; + bool chatPoppedOut = false; + bool pendingDeferredResizeLayout = false; + bool applyingDeferredResizeLayout = false; + bool hostResizeLockedForConnection = false; + int abletonWindowSizePreset = 1; + double lastResizeEventMs = 0.0; + double suppressHeavyUiUntilMs = 0.0; + int heavyUiTickCounter = 0; + float voiceChatGlowPhase = 0.0f; + float storedMetronomeVolume = 0.5f; + std::map autoLevelCurrentGains; + std::map autoLevelPeakLevels; + std::map autoLevelChannelActiveTicks; + std::map autoLevelMeasureTicks; + std::map autoLevelOverTargetTicks; + std::map midiTargetsByComponent; + std::map midiTargetsById; + std::map midiSourceByTargetId; + std::map oscSourceByTargetId; + juce::String midiLearnArmedTargetId; + juce::String oscLearnArmedTargetId; + juce::SpinLock oscEventQueueLock; + std::vector pendingOscEvents; + std::map midiRelayTargetByMenuId; + std::unique_ptr midiLearnInputDevice; + std::unique_ptr midiRelayInputDevice; + juce::String openedMidiLearnInputDeviceId; + juce::String openedMidiRelayInputDeviceId; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (NinjamVst3AudioProcessorEditor) +}; diff --git a/extras/ninjam-vst3/Source/PluginProcessor.cpp b/extras/ninjam-vst3/Source/PluginProcessor.cpp new file mode 100644 index 00000000..4aafac5f --- /dev/null +++ b/extras/ninjam-vst3/Source/PluginProcessor.cpp @@ -0,0 +1,4248 @@ +#include "PluginProcessor.h" +#include "PluginEditor.h" +#include + +// ---- Video pipeline debug log ---- +static void vlog(const char* msg) +{ + juce::File f = juce::File::getSpecialLocation(juce::File::tempDirectory) + .getChildFile("ninjam_video_debug.txt"); + const juce::String line = juce::Time::getCurrentTime().toString(true, true, true, true) + + " " + juce::String::fromUTF8(msg) + "\n"; + f.appendText(line, false, false); +} +static void vlogStr(const juce::String& msg) { vlog(msg.toRawUTF8()); } +// ---------------------------------- + +#ifdef _WIN32 +#include +#include +#pragma comment(lib, "winhttp.lib") +#endif + + +namespace +{ + constexpr unsigned int makeNjFourcc(const char a, const char b, const char c, const char d) + { + return ((unsigned int)(unsigned char)a) | + ((unsigned int)(unsigned char)b << 8) | + ((unsigned int)(unsigned char)c << 16) | + ((unsigned int)(unsigned char)d << 24); + } + constexpr const char* opusSyncAppFamily = "ninjam-vst3"; + constexpr int opusSyncHandshakeVersion = 1; + constexpr const char* opusSyncChatPrefix = "__NINJAM_VST3_OPUSSYNC__ "; + // Custom FOURCC for opusSyncSupport broadcast via NINJAM interval channel + // Any server routes it transparently; other clients ignore unknown FOURCCs + constexpr unsigned int kOpusSyncFourcc = makeNjFourcc('N','J','S','3'); + // Custom FOURCC for interval sync signals (intervalSyncTag, transportProbe, latencyReport) + constexpr unsigned int kSyncSignalFourcc = makeNjFourcc('N','J','S','4'); + constexpr const char* sideSignalChatPrefix = "__NINJAM_VST3_SIDESIGNAL__ "; + constexpr int remoteLatencyUpdateCadenceIntervals = 1; + + juce::String normaliseOpusPeerId(juce::String userId) + { + userId = userId.trim(); + const int atPos = userId.indexOfChar('@'); + if (atPos > 0) + userId = userId.substring(0, atPos); + return userId.toLowerCase(); + } + + juce::String normaliseChatTargetNick(juce::String userId) + { + userId = userId.trim(); + const int atPos = userId.indexOfChar('@'); + if (atPos > 0) + userId = userId.substring(0, atPos); + const int colonPos = userId.lastIndexOfChar(':'); + if (colonPos >= 0 && colonPos < userId.length() - 1) + userId = userId.substring(colonPos + 1); + return userId.trim(); + } + + juce::String canonicalDelayUserKey(juce::String userId) + { + userId = normaliseOpusPeerId(userId); + if (userId.startsWith("anonymous:")) + userId = userId.substring(10); + userId = userId.trim().toLowerCase(); + return userId; + } + + juce::String getWrapperTypeName(juce::AudioProcessor::WrapperType wrapperType) + { + using WrapperType = juce::AudioProcessor::WrapperType; + switch (wrapperType) + { + case WrapperType::wrapperType_Standalone: return "standalone"; + case WrapperType::wrapperType_VST: return "vst"; + case WrapperType::wrapperType_VST3: return "vst3"; + case WrapperType::wrapperType_AudioUnit: return "au"; + case WrapperType::wrapperType_AudioUnitv3: return "auv3"; + case WrapperType::wrapperType_AAX: return "aax"; + case WrapperType::wrapperType_LV2: return "lv2"; + default: break; + } + return "unknown"; + } + + inline float softClipSample(float x) + { + const float k = 2.0f; + const float d = std::tanh(k); + const float c = d / k; + const float target = 0.891251f; + + float y = std::tanh(k * c * x); + if (d != 0.0f) + y = (y / d) * target; + return y; + } + + inline juce::String buildDefaultLocalChannelName(int channelIndex) + { + return "Ch" + juce::String(channelIndex + 1); + } + + inline bool isDefaultLocalChannelName(const juce::String& name) + { + auto trimmed = name.trim(); + if (!trimmed.startsWithIgnoreCase("ch")) + return false; + + auto numberPart = trimmed.substring(2).trim(); + if (numberPart.isEmpty() || !numberPart.containsOnly("0123456789")) + return false; + + return numberPart.getIntValue() > 0; + } + +} + +NinjamVst3AudioProcessor::NinjamVst3AudioProcessor() + : AudioProcessor (BusesProperties() + .withInput ("Input", juce::AudioChannelSet::stereo(), true) + .withInput ("Input 2", juce::AudioChannelSet::stereo(), false) + .withInput ("Input 3", juce::AudioChannelSet::stereo(), false) + .withInput ("Input 4", juce::AudioChannelSet::stereo(), false) + .withInput ("Input 5", juce::AudioChannelSet::stereo(), false) + .withInput ("Input 6", juce::AudioChannelSet::stereo(), false) + .withInput ("Input 7", juce::AudioChannelSet::stereo(), false) + .withInput ("Input 8", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output Main", juce::AudioChannelSet::stereo(), true) + .withOutput ("Output 2", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 3", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 4", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 5", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 6", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 7", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 8", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 9", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 10", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 11", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 12", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 13", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 14", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 15", juce::AudioChannelSet::stereo(), false) + .withOutput ("Output 16", juce::AudioChannelSet::stereo(), false) + ) +{ + for (int i = 0; i < maxLocalChannels; ++i) + { + localChannelGains[(size_t)i].store(1.0f); + localChannelPeaks[(size_t)i].store(0.0f); + localChannelPeaksL[(size_t)i].store(0.0f); + localChannelPeaksR[(size_t)i].store(0.0f); + localChannelInputs[(size_t)i].store(-1); + localChannelReverbSends[(size_t)i].store(0.0f); + localChannelDelaySends[(size_t)i].store(0.0f); + localChannelNames[(size_t)i] = buildDefaultLocalChannelName(i); + } + + startTimer(20); // Run NINJAM client loop every 20ms + + // Set callbacks + ninjamClient.LicenseAgreementCallback = LicenseAgreementCallback; + ninjamClient.LicenseAgreement_User = this; + + ninjamClient.ChatMessage_Callback = ChatMessage_Callback; + ninjamClient.ChatMessage_User = this; + ninjamClient.IntervalMediaItem_Callback = IntervalMediaItem_Callback; + ninjamClient.IntervalMediaItem_User = this; + ninjamClient.IntervalChunkCallback = IntervalChunkCallback_cb; + ninjamClient.IntervalChunkCallbackUser = this; + ninjamClient.NewIntervalCallback = NewIntervalCallback_cb; + ninjamClient.NewIntervalCallbackUser = this; + opusSyncInstanceId = juce::Uuid().toString(); + + // Default Metronome + ninjamClient.config_metronome = 1.0f; // -12dB or similar? 1.0 is 0dB + + // Ensure disconnected state + ninjamClient.Disconnect(); + + // Initialize JNetLib (WSAStartup on Windows) + JNL::open_socketlib(); + + videoHelperRootDir = resolveVideoHelperRootDir(); + if (videoHelperRootDir.isDirectory()) + intervalJsonFile = videoHelperRootDir.getChildFile("intervals.json"); +} + +void NinjamVst3AudioProcessor::connectToServer(juce::String host, juce::String user, juce::String pass) +{ + host = host.trim(); + user = user.trim(); + pass = pass.trim(); + + if (host.isEmpty()) + host = "127.0.0.1"; + + if (user.isEmpty()) + { + user = "anonymous:jammer"; + pass = "anon"; + } + + { + const juce::ScopedLock lock(opusSyncPeerLock); + opusSyncPeers.clear(); + } + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + lastAnnouncedRemoteIntervalByUser.clear(); + localIntervalStartMsByInterval.clear(); + pendingRemoteIntervalStartsByUser.clear(); + remoteTransportRttMsByUser.clear(); + pendingTransportProbeSentMsById.clear(); + remoteLatencyLastAppliedIntervalByUser.clear(); + remoteLatencyAverageByUser.clear(); + remoteLatencyFirmDelayMsByUser.clear(); + } + opusSyncAvailable.store(false); + opusSyncHasLegacyClients.store(false); + lastOpusSupportBroadcastMs = 0.0; + lastTransportProbeBroadcastMs = 0.0; + + applyCodecPreference(); + + ninjamClient.Connect(host.toRawUTF8(), user.toRawUTF8(), pass.toRawUTF8()); + currentServer = host; + currentUser = user; + + // Do NOT reset isTransmitting here — the user may have toggled it before + // connecting. The NJC_STATUS_OK handler calls syncLocalIntervalChannelConfig() + // which re-applies the current isTransmitting state to NJClient. +} + +void NinjamVst3AudioProcessor::disconnectFromServer() +{ + ninjamClient.Disconnect(); + currentServer = {}; + currentUser = {}; + { + const juce::ScopedLock lock(opusSyncPeerLock); + opusSyncPeers.clear(); + } + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + lastAnnouncedRemoteIntervalByUser.clear(); + localIntervalStartMsByInterval.clear(); + pendingRemoteIntervalStartsByUser.clear(); + remoteTransportRttMsByUser.clear(); + pendingTransportProbeSentMsById.clear(); + remoteLatencyLastAppliedIntervalByUser.clear(); + remoteLatencyAverageByUser.clear(); + remoteLatencyFirmDelayMsByUser.clear(); + } + opusSyncAvailable.store(false); + opusSyncHasLegacyClients.store(false); + applyCodecPreference(); +} + +void NinjamVst3AudioProcessor::sendChatMessage(juce::String msg) +{ + msg = msg.trim(); + if (msg.isEmpty()) + return; + + { + juce::ScopedLock lock(chatLock); + juce::String localLine = "Me: " + msg; + chatHistory.add(localLine); + chatSenders.add("me"); + if (chatHistory.size() > 100) + { + chatHistory.removeRange(0, chatHistory.size() - 100); + chatSenders.removeRange(0, juce::jmax(0, chatSenders.size() - 100)); + } + } + + if (ninjamClient.GetStatus() == NJClient::NJC_STATUS_OK) + ninjamClient.ChatMessage_Send("MSG", msg.toRawUTF8()); + juce::Logger::writeToLog("NINJAM Chat (local): " + msg); +} + +void NinjamVst3AudioProcessor::setMetronomeVolume(float vol) +{ + ninjamClient.config_metronome = vol; +} + +float NinjamVst3AudioProcessor::getMetronomeVolume() const +{ + return ninjamClient.config_metronome; +} + +bool NinjamVst3AudioProcessor::isOpusSyncAvailable() const +{ + return opusSyncAvailable.load(); +} + +juce::String NinjamVst3AudioProcessor::getIntervalSyncStatusText() const +{ + const juce::ScopedLock lock(intervalSyncStatusLock); + return intervalSyncStatusText; +} + +void NinjamVst3AudioProcessor::setIntervalSyncStatusText(const juce::String& text) +{ + const juce::ScopedLock lock(intervalSyncStatusLock); + intervalSyncStatusText = text; +} + +void NinjamVst3AudioProcessor::broadcastIntervalSyncTag(const juce::String& target) +{ + if (ninjamClient.GetStatus() != NJClient::NJC_STATUS_OK) + return; + + const int displayInterval = getDisplayIntervalIndex(); + const int bpi = juce::jmax(1, getBPI()); + const float intervalProgress = juce::jlimit(0.0f, 1.0f, getIntervalProgress()); + const int beatIndex = juce::jlimit(0, bpi - 1, (int)std::floor(intervalProgress * (float)bpi)); + const juce::String userId = normaliseOpusPeerId(currentUser); + const juce::String tag = buildIntervalSyncTag(displayInterval, bpi); + + juce::DynamicObject::Ptr obj = new juce::DynamicObject(); + obj->setProperty("type", "intervalSyncTag"); + obj->setProperty("userId", userId.isNotEmpty() ? userId : currentUser); + obj->setProperty("tag", tag); + obj->setProperty("intervalIndex", displayInterval); + obj->setProperty("intervalAbsolute", intervalIndex.load()); + obj->setProperty("bpi", bpi); + obj->setProperty("beatIndex", beatIndex); + obj->setProperty("intervalProgress", intervalProgress); + obj->setProperty("eventId", "intervalTag:" + (userId.isNotEmpty() ? userId : currentUser) + ":" + juce::String(++sideSignalEventCounter)); + const juce::String payload = juce::JSON::toString(juce::var(obj.get())); + const juce::String safeTarget = target.isNotEmpty() ? target : "*"; + sendIntervalSignal("intervalSyncTag", payload); + return; +} + +void NinjamVst3AudioProcessor::broadcastTransportProbe(const juce::String& target) +{ + if (ninjamClient.GetStatus() != NJClient::NJC_STATUS_OK) + return; + + const juce::String userId = normaliseOpusPeerId(currentUser); + const juce::String probeId = "probe:" + (userId.isNotEmpty() ? userId : currentUser) + ":" + juce::String(++transportProbeCounter); + const double nowMs = juce::Time::getMillisecondCounterHiRes(); + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + pendingTransportProbeSentMsById[probeId] = nowMs; + while ((int)pendingTransportProbeSentMsById.size() > 256) + pendingTransportProbeSentMsById.erase(pendingTransportProbeSentMsById.begin()); + } + + juce::DynamicObject::Ptr obj = new juce::DynamicObject(); + obj->setProperty("type", "intervalTransportProbe"); + obj->setProperty("userId", userId.isNotEmpty() ? userId : currentUser); + obj->setProperty("probeId", probeId); + obj->setProperty("eventId", "transportProbe:" + probeId); + const juce::String payload = juce::JSON::toString(juce::var(obj.get())); + const juce::String safeTarget = target.isNotEmpty() ? target : "*"; + sendIntervalSignal("intervalTransportProbe", payload); +} + +void NinjamVst3AudioProcessor::broadcastOpusSyncSupport(const juce::String& target) +{ + if (ninjamClient.GetStatus() != NJClient::NJC_STATUS_OK) + return; + + const juce::String userId = normaliseOpusPeerId(currentUser); + juce::DynamicObject::Ptr obj = new juce::DynamicObject(); + obj->setProperty("type", "opusSyncSupport"); + obj->setProperty("userId", userId.isNotEmpty() ? userId : currentUser); + obj->setProperty("clientId", opusSyncInstanceId); + obj->setProperty("appFamily", opusSyncAppFamily); + obj->setProperty("handshakeVersion", opusSyncHandshakeVersion); + obj->setProperty("runtimeFormat", getWrapperTypeName(wrapperType)); + obj->setProperty("pluginName", juce::String(JucePlugin_Name)); + obj->setProperty("pluginVersion", juce::String(JucePlugin_VersionString)); + obj->setProperty("supportsOpus", true); + obj->setProperty("enabled", numLocalChannels.load() > 1); + obj->setProperty("numChannels", numLocalChannels.load()); + obj->setProperty("eventId", "opusSupport:" + (userId.isNotEmpty() ? userId : currentUser) + ":" + juce::String(++sideSignalEventCounter)); + const juce::String payload = juce::JSON::toString(juce::var(obj.get())); + vlogStr("broadcastOpusSyncSupport -> target=" + (target.isNotEmpty() ? target : "*") + " enabled=" + juce::String(numLocalChannels.load() > 1 ? "true" : "false")); + // Use NINJAM interval channel with custom FOURCC — works on any standard server, + // routed like audio data, other clients silently ignore unknown FOURCCs. + // Target is ignored here (interval data goes to all subscribers of our channel 0). + juce::ignoreUnused(target); + ninjamClient.SendRawIntervalItem(0, kOpusSyncFourcc, payload.toRawUTF8(), (int)payload.getNumBytesAsUTF8()); +} + +void NinjamVst3AudioProcessor::refreshOpusSyncAvailabilityFromUsers() +{ + const double nowMs = juce::Time::getMillisecondCounterHiRes(); + bool available = false; + int freshPeerCount = 0; + { + const juce::ScopedLock lock(opusSyncPeerLock); + for (auto it = opusSyncPeers.begin(); it != opusSyncPeers.end();) + { + const auto& peer = it->second; + const bool isFresh = (nowMs - peer.lastSeenMs) <= 6500.0; + if (peer.supportsOpus && isFresh) + ++it; + else + it = opusSyncPeers.erase(it); + } + available = !opusSyncPeers.empty(); + freshPeerCount = (int)opusSyncPeers.size(); + } + + // Rebuild the quick username→multiChan snapshot (separate lock, no njclient calls) + { + const juce::ScopedLock lock2(opusSyncPeerLock); + const juce::ScopedLock mcLock(peerMultiChanLock); + peerMultiChanByName.clear(); + for (auto& [key, peer] : opusSyncPeers) + { + if (peer.supportsOpus && !peer.userId.isEmpty()) + { + const juce::String snapKey = canonicalDelayUserKey(peer.userId); + peerMultiChanByName[snapKey] = { peer.multiChanEnabled, peer.numChannels }; + vlogStr("[MCSnap] stored snapKey='" + snapKey + "' (userId='" + peer.userId + "') multiChan=" + juce::String(peer.multiChanEnabled ? 1 : 0) + " nCh=" + juce::String(peer.numChannels)); + } + } + vlogStr("[MCSnap] rebuild done mapSize=" + juce::String((int)peerMultiChanByName.size())); + } + + const int remoteUserCount = juce::jmax(0, ninjamClient.GetNumUsers()); + const bool hasLegacyClients = remoteUserCount > freshPeerCount; + + const bool previous = opusSyncAvailable.exchange(available); + const bool previousLegacy = opusSyncHasLegacyClients.exchange(hasLegacyClients); + if (!previous && available) + { + juce::ScopedLock lock(chatLock); + chatHistory.add("Multi-Channel Audio Detected."); + chatSenders.add(""); + if (chatHistory.size() > 100) + { + chatHistory.removeRange(0, chatHistory.size() - 100); + chatSenders.removeRange(0, juce::jmax(0, chatSenders.size() - 100)); + } + } + if (previous != available || previousLegacy != hasLegacyClients) + { + applyCodecPreference(); + syncLocalIntervalChannelConfig(); + } +} + +void NinjamVst3AudioProcessor::setTransmitLocal(bool shouldTransmit) +{ + isTransmitting = shouldTransmit; + syncLocalIntervalChannelConfig(); +} + +void NinjamVst3AudioProcessor::syncLocalIntervalChannelConfig() +{ + const bool shouldTransmit = isTransmitting; + const int bitrate = shouldTransmit ? localBitrate : 24; + const int flags = voiceChatMode ? 2 : 0; + const int numCh = juce::jlimit(1, maxLocalChannels, numLocalChannels.load()); + const bool multiChanAuto = numCh > 1 && opusSyncAvailable.load() && shouldTransmit; + + if (multiChanAuto) + { + // NINJAM ch 0: Vorbis mixdown (for all clients including legacy) + // NINJAM ch 1..N: Opus per-channel (for our VST3 clients only) + juce::String ch0Name = getLocalChannelName(0); + if (ch0Name.isEmpty()) ch0Name = "Mix"; + ninjamClient.SetLocalChannelInfo(0, ch0Name.toRawUTF8(), + true, numCh, // srcch = mix buffer at inputs[numCh] + true, bitrate, true, true, false, 0, true, flags); + for (int i = 0; i < numCh; ++i) + { + juce::String chName = getLocalChannelName(i); + if (chName.isEmpty()) chName = "Ch " + juce::String(i + 1); + ninjamClient.SetLocalChannelInfo(i + 1, chName.toRawUTF8(), + true, i, // srcch = original buffer slot i + true, bitrate, true, true, false, 0, true, flags); + } + for (int i = numCh + 1; i <= maxLocalChannels; ++i) + ninjamClient.DeleteLocalChannel(i); + } + else + { + // Vorbis only: single channel + juce::String ch0Name = getLocalChannelName(0); + if (ch0Name.isEmpty()) ch0Name = "Input"; + const int sourceChannel = shouldTransmit ? 0 : 1023; + ninjamClient.SetLocalChannelInfo(0, ch0Name.toRawUTF8(), + true, sourceChannel, true, bitrate, true, true, false, 0, true, flags); + for (int i = 1; i <= maxLocalChannels; ++i) + ninjamClient.DeleteLocalChannel(i); + } + + if (ninjamClient.GetStatus() == NJClient::NJC_STATUS_OK) + ninjamClient.NotifyServerOfChannelChange(); +} + +void NinjamVst3AudioProcessor::setLocalBitrate(int bitrate) +{ + localBitrate = bitrate; + syncLocalIntervalChannelConfig(); +} + +int NinjamVst3AudioProcessor::getLocalBitrate() const +{ + return localBitrate; +} + +void NinjamVst3AudioProcessor::setVoiceChatMode(bool enabled) +{ + voiceChatMode = enabled; + syncLocalIntervalChannelConfig(); +} + +bool NinjamVst3AudioProcessor::isVoiceChatMode() const +{ + return voiceChatMode; +} + +void NinjamVst3AudioProcessor::applyCodecPreference() +{ + const int numCh = juce::jlimit(1, maxLocalChannels, numLocalChannels.load()); + const bool multiChanAuto = numCh > 1 && opusSyncAvailable.load(); + const int decodeCaps = NJClient::NJCLIENT_CAP_DECODE_VORBIS | NJClient::NJCLIENT_CAP_DECODE_OPUS; + + if (multiChanAuto) + { + // ch 0: Vorbis only (mixdown for all clients) + // ch 1..N: Opus only (per-channel for our VST3 clients) + unsigned int vorbisMask = 0x1u; + unsigned int opusMask = 0u; + for (int i = 0; i < numCh; ++i) + opusMask |= (1u << (i + 1)); + ninjamClient.SetCodecCapabilities( + NJClient::NJCLIENT_CAP_ENCODE_VORBIS | NJClient::NJCLIENT_CAP_ENCODE_OPUS, decodeCaps); + ninjamClient.SetCodecConfig(vorbisMask, opusMask); + } + else + { + // Single channel or no VST3 peers: Vorbis only + ninjamClient.SetCodecCapabilities(NJClient::NJCLIENT_CAP_ENCODE_VORBIS, decodeCaps); + ninjamClient.SetCodecConfig(0x1u, 0u); + } +} + +juce::String NinjamVst3AudioProcessor::buildIntervalSyncTag(int interval, int length) const +{ + const juce::String userPart = currentUser.isNotEmpty() ? currentUser : "unknown"; + return userPart + ":" + juce::String(interval) + ":" + juce::String(length); +} + +juce::File NinjamVst3AudioProcessor::resolveVideoHelperRootDir() const +{ + juce::Array candidates; + const juce::File exe = juce::File::getSpecialLocation(juce::File::currentExecutableFile); + juce::File probe = exe.getParentDirectory(); + for (int i = 0; i < 8; ++i) + { + candidates.add(probe.getChildFile("advanced-vdo-client")); + probe = probe.getParentDirectory(); + } + candidates.add(juce::File("E:\\Web stuff\\NINJAM VST3\\advanced-vdo-client")); + + for (const auto& dir : candidates) + { + if (dir.isDirectory() && dir.getChildFile("index.html").existsAsFile() && dir.getChildFile("server.js").existsAsFile()) + return dir; + } + + return {}; +} + +bool NinjamVst3AudioProcessor::isAdvancedVideoClientAvailable() const +{ +#ifdef _WIN32 + HINTERNET hSession = WinHttpOpen(L"NINJAM_VST3/1.0", + WINHTTP_ACCESS_TYPE_NO_PROXY, + WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, + 0); + if (!hSession) + return false; + + HINTERNET hConnect = WinHttpConnect(hSession, L"127.0.0.1", 8100, 0); + if (!hConnect) + { + WinHttpCloseHandle(hSession); + return false; + } + + HINTERNET hRequest = WinHttpOpenRequest(hConnect, + L"HEAD", + L"/", + nullptr, + WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, + 0); + if (!hRequest) + { + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return false; + } + + const bool ok = WinHttpSendRequest(hRequest, + WINHTTP_NO_ADDITIONAL_HEADERS, + 0, + WINHTTP_NO_REQUEST_DATA, + 0, + 0, + 0) && + WinHttpReceiveResponse(hRequest, nullptr); + + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return ok; +#else + // Use JUCE's cross-platform TCP socket to probe 127.0.0.1:8100 + juce::StreamingSocket sock; + if (!sock.connect("127.0.0.1", 8100, 500)) + return false; + const juce::String req = "HEAD / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n"; + sock.write(req.toRawUTF8(), (int)req.getNumBytesAsUTF8()); + char buf[16] = {}; + sock.read(buf, sizeof(buf) - 1, false); + return juce::String(buf).startsWith("HTTP/"); +#endif +} + +bool NinjamVst3AudioProcessor::ensureAdvancedVideoClientStarted() +{ + if (isAdvancedVideoClientAvailable()) + { + videoHelperRunning.store(true); + return true; + } + + if (advancedVideoProcess && advancedVideoProcess->isRunning()) + { + for (int i = 0; i < 30; ++i) + { + juce::Thread::sleep(100); + if (isAdvancedVideoClientAvailable()) + { + videoHelperRunning.store(true); + return true; + } + } + return false; + } + + const juce::File rootDir = resolveVideoHelperRootDir(); + const juce::File script = rootDir.getChildFile("server.js"); + if (!script.existsAsFile()) + return false; + + juce::StringArray nodeCandidates; + const juce::File exeDir = juce::File::getSpecialLocation(juce::File::currentExecutableFile).getParentDirectory(); +#ifdef _WIN32 + const juce::String nodeFilename = "node.exe"; +#else + const juce::String nodeFilename = "node"; +#endif + const juce::File rootNode = rootDir.getChildFile(nodeFilename); + const juce::File rootParentNode = rootDir.getParentDirectory().getChildFile(nodeFilename); + const juce::File exeNode = exeDir.getChildFile(nodeFilename); + if (rootNode.existsAsFile()) + nodeCandidates.add("\"" + rootNode.getFullPathName() + "\""); + if (rootParentNode.existsAsFile()) + nodeCandidates.add("\"" + rootParentNode.getFullPathName() + "\""); + if (exeNode.existsAsFile()) + nodeCandidates.add("\"" + exeNode.getFullPathName() + "\""); + nodeCandidates.add("node"); + + advancedVideoProcess = std::make_unique(); + bool started = false; + for (const auto& nodeCmd : nodeCandidates) + { + const juce::String cmd = nodeCmd + " \"" + script.getFullPathName() + "\""; + if (advancedVideoProcess->start(cmd)) + { + started = true; + break; + } + } + if (!started) + { + { + juce::ScopedLock lock(chatLock); + chatHistory.add("Video helper failed to start (node not found). Place node beside NINJAM executable or inside advanced-vdo-client."); + chatSenders.add(""); + if (chatHistory.size() > 100) + { + chatHistory.removeRange(0, chatHistory.size() - 100); + chatSenders.removeRange(0, juce::jmax(0, chatSenders.size() - 100)); + } + } + advancedVideoProcess.reset(); + return false; + } + + for (int i = 0; i < 40; ++i) + { + juce::Thread::sleep(100); + if (isAdvancedVideoClientAvailable()) + { + videoHelperRunning.store(true); + return true; + } + } + return false; +} + +void NinjamVst3AudioProcessor::stopAdvancedVideoClient() +{ + videoHelperRunning.store(false); + if (advancedVideoProcess && advancedVideoProcess->isRunning()) + advancedVideoProcess->kill(); + advancedVideoProcess.reset(); +} + + + +void NinjamVst3AudioProcessor::launchVideoSession() +{ + if (ninjamClient.GetStatus() != NJClient::NJC_STATUS_OK) + return; + + juce::String roomSource = currentServer.trim(); + const int schemePos = roomSource.indexOf("://"); + if (schemePos >= 0) + roomSource = roomSource.substring(schemePos + 3); + const int slashPos = roomSource.indexOfChar('/'); + if (slashPos >= 0) + roomSource = roomSource.substring(0, slashPos); + const int atPos = roomSource.lastIndexOfChar('@'); + if (atPos >= 0 && atPos + 1 < roomSource.length()) + roomSource = roomSource.substring(atPos + 1); + + juce::String hostPart = roomSource.trim(); + juce::String portPart; + const int lastColonPos = hostPart.lastIndexOfChar(':'); + if (lastColonPos > 0 && lastColonPos + 1 < hostPart.length()) + { + const juce::String candidatePort = hostPart.substring(lastColonPos + 1).trim(); + bool allDigits = candidatePort.isNotEmpty(); + for (int i = 0; i < candidatePort.length() && allDigits; ++i) + allDigits = juce::CharacterFunctions::isDigit(candidatePort[i]); + if (allDigits) + { + hostPart = hostPart.substring(0, lastColonPos); + portPart = candidatePort; + } + } + + const int firstDotPos = hostPart.indexOfChar('.'); + if (firstDotPos > 0) + hostPart = hostPart.substring(0, firstDotPos); + + juce::String roomRaw = hostPart; + if (portPart.isNotEmpty()) + roomRaw << "_" << portPart; + + juce::String room; + bool lastWasUnderscore = false; + for (int i = 0; i < roomRaw.length(); ++i) + { + const juce_wchar ch = roomRaw[i]; + if (juce::CharacterFunctions::isLetterOrDigit(ch)) + { + room << juce::String::charToString((juce_wchar) juce::CharacterFunctions::toLowerCase(ch)); + lastWasUnderscore = false; + } + else if (!lastWasUnderscore) + { + room << "_"; + lastWasUnderscore = true; + } + } + room = room.trimCharactersAtStart("_").trimCharactersAtEnd("_"); + if (room.isEmpty()) + room = "ninjam_room"; + const juce::String label = currentUser.isNotEmpty() ? currentUser : "NINJAM"; + int viewDelayMs = 0; + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + for (const auto& entry : remoteLatencyFirmDelayMsByUser) + viewDelayMs = juce::jmax(viewDelayMs, juce::jmax(0, entry.second)); + } + const int chunkMs = juce::jlimit(60, 800, viewDelayMs > 0 ? (int)std::llround((double)viewDelayMs * 0.25) : 120); + + if (ensureAdvancedVideoClientStarted()) + { + juce::URL helperUrl("http://127.0.0.1:8100/sync-buffer-room"); + helperUrl = helperUrl.withParameter("room", room) + .withParameter("label", label) + .withParameter("intervalSource", "ws://127.0.0.1:8100/ws") + .withParameter("chunked", juce::String(chunkMs)); + if (viewDelayMs > 0) + helperUrl = helperUrl.withParameter("buffer", juce::String(viewDelayMs)); + { + juce::ScopedLock lock(chatLock); + chatHistory.add("Tip: If your cam isn't showing, refresh the video page and select your camera before entering the room."); + chatSenders.add(""); + if (chatHistory.size() > 100) + { + chatHistory.removeRange(0, chatHistory.size() - 100); + chatSenders.removeRange(0, juce::jmax(0, chatSenders.size() - 100)); + } + } + helperUrl.launchInDefaultBrowser(); + return; + } + + juce::URL url("https://vdo.ninja/"); + url = url.withParameter("room", room) + .withParameter("label", label) + .withParameter("chunked", juce::String(chunkMs)) + .withParameter("chunkbufferadaptive", "0") + .withParameter("chunkbufferceil", "180000") + .withParameter("noaudio", "1") + .withParameter("buffer2", "0"); + if (viewDelayMs > 0) + url = url.withParameter("buffer", juce::String(viewDelayMs)); + { + juce::ScopedLock lock(chatLock); + chatHistory.add("Advanced sync helper unavailable on this machine; opening direct VDO view without live auto-buffer updates."); + chatSenders.add(""); + chatHistory.add("Tip: If your cam isn't showing, refresh the video page and select your camera before entering the room."); + chatSenders.add(""); + if (chatHistory.size() > 100) + { + chatHistory.removeRange(0, chatHistory.size() - 100); + chatSenders.removeRange(0, juce::jmax(0, chatSenders.size() - 100)); + } + } + url.launchInDefaultBrowser(); +} + +void NinjamVst3AudioProcessor::writeIntervalHelperJson(int pos, int length) +{ + if (!videoHelperRunning.load()) + return; + if (intervalJsonFile.getFullPathName().isEmpty()) + return; + + if (!intervalJsonFile.getParentDirectory().isDirectory()) + intervalJsonFile.getParentDirectory().createDirectory(); + + const int safeLength = juce::jmax(1, length); + const int displayInterval = getDisplayIntervalIndex(); + const int bpi = juce::jmax(1, getBPI()); + const double bpm = juce::jmax(1.0, (double)getBPM()); + const double nowMs = juce::Time::getMillisecondCounterHiRes(); + const double globalUnit = (double)displayInterval * (double)safeLength + (double)juce::jlimit(0, safeLength, pos); + const double beatLength = (double)safeLength / (double)bpi; + const double globalBeat = beatLength > 0.0 ? std::floor(globalUnit / beatLength) : 0.0; + const juce::String syncTag = buildIntervalSyncTag(displayInterval, safeLength); + + juce::Array entries; + { + juce::DynamicObject::Ptr infoObj = new juce::DynamicObject(); + infoObj->setProperty("type", "intervalInfo"); + infoObj->setProperty("interval", displayInterval); + infoObj->setProperty("pos", pos); + infoObj->setProperty("length", safeLength); + infoObj->setProperty("bpm", bpm); + infoObj->setProperty("bpi", bpi); + infoObj->setProperty("globalUnit", globalUnit); + infoObj->setProperty("globalBeat", globalBeat); + infoObj->setProperty("videoClockMs", nowMs); + infoObj->setProperty("syncTag", syncTag); + infoObj->setProperty("bufferMode", "remote"); + entries.add(juce::var(infoObj.get())); + } + + const int numUsers = ninjamClient.GetNumUsers(); + for (int userIdx = 0; userIdx < numUsers; ++userIdx) + { + const char* userNameChars = ninjamClient.GetUserState(userIdx, nullptr, nullptr, nullptr); + if (!userNameChars || !userNameChars[0]) + continue; + + const juce::String userName = juce::String::fromUTF8(userNameChars); + const juce::String senderKey = normaliseOpusPeerId(userName); + const juce::String canonicalUserKey = canonicalDelayUserKey(userName); + time_t lastUpdate = 0; + double maxLen = 0.0; + const double userPos = ninjamClient.GetUserSessionPos(userIdx, &lastUpdate, &maxLen); + + int bufferMs = -1; + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + auto firmIt = remoteLatencyFirmDelayMsByUser.find(senderKey); + if (firmIt != remoteLatencyFirmDelayMsByUser.end()) + bufferMs = juce::jmax(0, firmIt->second); + if (bufferMs < 0 && canonicalUserKey.isNotEmpty()) + { + auto canonicalFirmIt = remoteLatencyFirmDelayMsByUser.find(canonicalUserKey); + if (canonicalFirmIt != remoteLatencyFirmDelayMsByUser.end()) + bufferMs = juce::jmax(0, canonicalFirmIt->second); + } + if (bufferMs < 0) + { + auto avgIt = remoteLatencyAverageByUser.find(senderKey); + if (avgIt != remoteLatencyAverageByUser.end()) + { + const auto& state = avgIt->second; + double fallback = state.firmAverageMs; + if (!(fallback > 0.0)) + fallback = state.averageMs; + if (!(fallback > 0.0)) + fallback = state.lastMeasurementMs; + if (fallback > 0.0) + bufferMs = juce::jmax(0, (int)std::llround(fallback)); + } + } + if (bufferMs < 0 && canonicalUserKey.isNotEmpty()) + { + auto canonicalAvgIt = remoteLatencyAverageByUser.find(canonicalUserKey); + if (canonicalAvgIt != remoteLatencyAverageByUser.end()) + { + const auto& state = canonicalAvgIt->second; + double fallback = state.firmAverageMs; + if (!(fallback > 0.0)) + fallback = state.averageMs; + if (!(fallback > 0.0)) + fallback = state.lastMeasurementMs; + if (fallback > 0.0) + bufferMs = juce::jmax(0, (int)std::llround(fallback)); + } + } + // Diagnostic log per-user buffer decision + } + + juce::DynamicObject::Ptr userObj = new juce::DynamicObject(); + userObj->setProperty("type", "videoTimecode"); + userObj->setProperty("userId", userName); + userObj->setProperty("userKey", canonicalUserKey); + userObj->setProperty("interval", displayInterval); + userObj->setProperty("timecode", userPos); + userObj->setProperty("globalUnit", (double)displayInterval * (double)safeLength + userPos); + userObj->setProperty("globalBeat", globalBeat); + userObj->setProperty("videoClockMs", nowMs); + userObj->setProperty("syncTag", syncTag); + userObj->setProperty("bufferMode", "remote"); + if (bufferMs >= 0) + { + userObj->setProperty("bufferTotalMs", (double)bufferMs); + userObj->setProperty("senderBufferMs", 0.0); + userObj->setProperty("receiverBufferMs", (double)bufferMs); + userObj->setProperty("measuredAudioDelayMs", (double)bufferMs); + } + entries.add(juce::var(userObj.get())); + } + + const juce::String payload = juce::JSON::toString(juce::var(entries), false); + intervalJsonFile.replaceWithText(payload); + // Also broadcast the interval payload over the sync interval channel so + // other instances of our client can receive and write the same JSON + // locally (avoids chat leakage on non-aware clients). + sendIntervalSignal("intervals", payload); +} + +bool NinjamVst3AudioProcessor::isTransmittingLocal() const +{ + return isTransmitting; +} + +juce::StringArray NinjamVst3AudioProcessor::getChatMessages() +{ + juce::ScopedLock lock(chatLock); + return chatHistory; +} + +void NinjamVst3AudioProcessor::setAutoTranslateEnabled(bool shouldEnable) +{ + { + juce::ScopedLock lock(chatLock); + autoTranslate = shouldEnable; + } +} + +bool NinjamVst3AudioProcessor::isAutoTranslateEnabled() const +{ + return autoTranslate; +} + +void NinjamVst3AudioProcessor::setTranslateTargetLang(const juce::String& langCode) +{ + juce::ScopedLock lock(chatLock); + translateTargetLang = langCode; +} + +juce::String NinjamVst3AudioProcessor::getTranslateTargetLang() const +{ + return translateTargetLang; +} + +std::vector NinjamVst3AudioProcessor::getConnectedUsers() +{ + std::vector users; + int numUsers = ninjamClient.GetNumUsers(); + bool spread = spreadOutputsEnabled.load(); + + const int maxOutputPairs = 16; + std::set reservedPairs; + if (spread) + { + for (auto& kv : userOutputAssignment) + { + int pair = kv.second; + if (pair >= 0 && pair < maxOutputPairs) + reservedPairs.insert(pair); + } + } + + std::set usedPairsThisCall; + + for (int i=0; i 0) + u.name = fullName.substring(0, atPos); + else + u.name = fullName; + + bool sub = false; + float chVol = 1.0f, chPan = 0.0f; + bool chMute = false, chSolo = false; + int outCh = 0, flags = 0; + const char* chName = ninjamClient.GetUserChannelState(i, 0, &sub, &chVol, &chPan, &chMute, &chSolo, &outCh, &flags); + if (chName) + { + float baseVol = chVol; + bool hasStored = false; + auto byNameIt = userVolumeByName.find(u.name); + if (byNameIt != userVolumeByName.end()) + { + baseVol = byNameIt->second; + hasStored = true; + } + + auto volIt = userBaseVolume.find(i); + if (volIt != userBaseVolume.end()) + { + baseVol = volIt->second; + hasStored = true; + } + + if (!hasStored) + baseVol = 1.0f; + + u.volume = baseVol; + + auto panIt = userPanOverrides.find(i); + if (panIt != userPanOverrides.end()) + u.pan = panIt->second; + else + u.pan = chPan; + + u.isMuted = chMute; + u.outputChannel = outCh; + + if (!hasStored || std::abs(baseVol - chVol) > 1.0e-4f) + setUserVolume(i, baseVol); + } + else + { + float baseVol = 1.0f; + bool hasStored = false; + auto byNameIt = userVolumeByName.find(u.name); + if (byNameIt != userVolumeByName.end()) + { + baseVol = byNameIt->second; + hasStored = true; + } + + auto volIt = userBaseVolume.find(i); + if (volIt != userBaseVolume.end()) + { + baseVol = volIt->second; + hasStored = true; + } + + u.volume = baseVol; + + u.pan = 0.0f; + u.isMuted = false; + u.outputChannel = ninjamClient.GetUserChannelOutput(i, 0); + + if (!hasStored) + setUserVolume(i, baseVol); + } + + if (spread) + { + juce::String shortName = u.name; + auto itAssign = userOutputAssignment.find(shortName); + int desiredPair = -1; + + if (itAssign != userOutputAssignment.end()) + { + desiredPair = itAssign->second; + } + else + { + if ((int)reservedPairs.size() < maxOutputPairs) + { + for (int cand = 0; cand < maxOutputPairs; ++cand) + { + if (!reservedPairs.count(cand)) + { + desiredPair = cand; + reservedPairs.insert(cand); + break; + } + } + } + else + { + std::set connectedNow = usedPairsThisCall; + int fallback = -1; + for (int cand = 0; cand < maxOutputPairs; ++cand) + { + if (!connectedNow.count(cand)) + { + fallback = cand; + break; + } + } + if (fallback < 0) + fallback = 0; + desiredPair = fallback; + } + + userOutputAssignment[shortName] = desiredPair; + } + + if (desiredPair >= 0) + { + int desiredChannel = desiredPair * 2; + if (u.outputChannel != desiredChannel) + setUserOutput(i, desiredChannel); + u.outputChannel = desiredChannel; + usedPairsThisCall.insert(desiredPair); + } + } + + userBaseVolume[i] = u.volume; + userVolumeByName[u.name] = u.volume; + + // Look up multichannel state from the snapshot updated by refreshOpusSyncAvailabilityFromUsers(). + // This map is keyed by normalised username and never holds njclient locks. + { + const juce::String normName = canonicalDelayUserKey(u.name); + const juce::ScopedLock mcLock(peerMultiChanLock); + vlogStr("[MCLookup] normName='" + normName + "' mapSize=" + juce::String((int)peerMultiChanByName.size())); + for (auto& [mk, mv] : peerMultiChanByName) + vlogStr(" key='" + mk + "' isMultiChan=" + juce::String(mv.isMultiChan ? 1 : 0)); + auto it = peerMultiChanByName.find(normName); + if (it != peerMultiChanByName.end()) + { + u.isMultiChanPeer = it->second.isMultiChan; + if (u.isMultiChanPeer) + u.numChannels = juce::jmax(2, it->second.numChannels); + } + } + + // Populate channel names from NINJAM state (safe: no locks held here) + if (u.isMultiChanPeer) + { + u.channelNames.clear(); + for (int ch = 0; ch < u.numChannels; ++ch) + { + const char* chName = ninjamClient.GetUserChannelState(i, ch + 1); // ch0=mix, ch1..N=individual + if (chName != nullptr && *chName != '\0') + u.channelNames.add(juce::String::fromUTF8(chName)); + else + u.channelNames.add("Ch " + juce::String(ch + 1)); + } + } + else + { + // Count basic NINJAM channel names for non-VST3 peers (display only, no expand button) + u.channelNames.clear(); + for (int ch = 0; ch < 32; ++ch) + { + const char* chName = ninjamClient.GetUserChannelState(i, ch); + if (chName != nullptr) + { + ++u.numChannels; + u.channelNames.add(juce::String::fromUTF8(chName)); + } + } + if (u.numChannels < 1) { u.numChannels = 1; u.channelNames.add(""); } + } + + users.push_back(u); + } + } + // Log final result for each user + for (const auto& u : users) + vlogStr("[GCU] user='" + u.name + "' isMultiChanPeer=" + juce::String(u.isMultiChanPeer ? 1 : 0) + " nCh=" + juce::String(u.numChannels)); + return users; +} + +void NinjamVst3AudioProcessor::rememberUserVolume(int userIndex, float volume, const juce::String& name) +{ + userBaseVolume[userIndex] = volume; + juce::String shortName = name; + int atPos = shortName.indexOfChar('@'); + if (atPos > 0) + shortName = shortName.substring(0, atPos); + userVolumeByName[shortName] = volume; +} + +void NinjamVst3AudioProcessor::setUserOutput(int userIndex, int outputChannelIndex) +{ + // Update all channels for this user to the new output + // Iterate through all potential channels (MAX_USER_CHANNELS is 32) + for (int i = 0; i < 32; ++i) + { + // SetUserChannelState arguments: useridx, channelidx, setsub, sub, setvol, vol, setpan, pan, setmute, mute, setsolo, solo, setoutch, outchannel + ninjamClient.SetUserChannelState(userIndex, i, false, false, false, 0, false, 0, false, false, false, false, true, outputChannelIndex); + } + + const char* name = ninjamClient.GetUserState(userIndex, nullptr, nullptr, nullptr); + if (name) + { + juce::String fullName = juce::String::fromUTF8(name); + int atPos = fullName.indexOfChar('@'); + juce::String shortName; + if (atPos > 0) + shortName = fullName.substring(0, atPos); + else + shortName = fullName; + int pairIndex = (outputChannelIndex & 1023) / 2; + userOutputAssignment[shortName] = pairIndex; + } +} + +void NinjamVst3AudioProcessor::setUserLevel(int userIndex, float volume, float pan, bool isMuted, bool isSolo) +{ + userBaseVolume[userIndex] = volume; + int numUsers = ninjamClient.GetNumUsers(); + if (userIndex >= 0 && userIndex < numUsers) + { + const char* name = ninjamClient.GetUserState(userIndex, nullptr, nullptr, nullptr); + if (name) + { + juce::String fullName = juce::String::fromUTF8(name); + int atPos = fullName.indexOfChar('@'); + juce::String shortName; + if (atPos > 0) + shortName = fullName.substring(0, atPos); + else + shortName = fullName; + userVolumeByName[shortName] = volume; + } + } + userPanOverrides[userIndex] = pan; + for (int i = 0; i < 32; ++i) + { + ninjamClient.SetUserChannelState(userIndex, i, false, false, true, volume, true, pan, true, isMuted, true, isSolo); + } +} + +void NinjamVst3AudioProcessor::setUserVolume(int userIndex, float volume) +{ + for (int i = 0; i < 32; ++i) + { + ninjamClient.SetUserChannelState(userIndex, i, false, false, true, volume, false, 0, false, false, false, false, false, 0); + } +} + +float NinjamVst3AudioProcessor::getUserPeak(int userIndex, int channelIndex) +{ + if (isSyncToHostEnabled() && (!hostWasPlaying.load() || syncWaitForInterval.load())) + return 0.0f; + + float maxPeak = 0.0f; + for (int i = 0; i < 32; ++i) + { + float p = ninjamClient.GetUserChannelPeak(userIndex, i, channelIndex); + if (p > maxPeak) maxPeak = p; + } + return maxPeak; +} + +float NinjamVst3AudioProcessor::getUserChannelPeak(int userIndex, int njChanIdx, int lrSide) +{ + return ninjamClient.GetUserChannelPeak(userIndex, njChanIdx, lrSide); +} + +void NinjamVst3AudioProcessor::setUserNjChannelVolume(int userIndex, int njChanIdx, float volume) +{ + ninjamClient.SetUserChannelState(userIndex, njChanIdx, false, false, true, volume, false, 0, false, false, false, false); +} + +void NinjamVst3AudioProcessor::setMasterOutputGain(float gain) +{ + masterOutputGain.store(gain); +} + +float NinjamVst3AudioProcessor::getMasterOutputGain() const +{ + return masterOutputGain.load(); +} + +float NinjamVst3AudioProcessor::getMasterPeak() const +{ + return masterPeak.load(); +} + +float NinjamVst3AudioProcessor::getMasterPeakLeft() const +{ + return masterPeakL.load(); +} + +float NinjamVst3AudioProcessor::getMasterPeakRight() const +{ + return masterPeakR.load(); +} + +void NinjamVst3AudioProcessor::setSoftLimiterEnabled(bool shouldEnable) +{ + softLimiterEnabled.store(shouldEnable); +} + +bool NinjamVst3AudioProcessor::isSoftLimiterEnabled() const +{ + return softLimiterEnabled.load(); +} + +void NinjamVst3AudioProcessor::setUserClipEnabled(int userIndex, bool enabled) +{ + userClipEnabled[userIndex] = enabled; +} + +bool NinjamVst3AudioProcessor::isUserClipEnabled(int userIndex) const +{ + return true; +} + +void NinjamVst3AudioProcessor::setMasterLimiterEnabled(bool shouldEnable) +{ + dspLimiterEnabled.store(shouldEnable); +} + +bool NinjamVst3AudioProcessor::isMasterLimiterEnabled() const +{ + return dspLimiterEnabled.load(); +} + +void NinjamVst3AudioProcessor::setLimiterThreshold(float db) +{ + limiterThresholdDb.store(db); + masterLimiter.setThreshold(db); +} + +void NinjamVst3AudioProcessor::setLimiterRelease(float ms) +{ + limiterReleaseMs.store(ms); + masterLimiter.setRelease(ms); +} + +void NinjamVst3AudioProcessor::setLocalInputGain(float gain) +{ + localInputGain.store(gain); + setLocalChannelGain(0, gain); +} + +float NinjamVst3AudioProcessor::getLocalInputGain() const +{ + return localChannelGains[0].load(); +} + +void NinjamVst3AudioProcessor::setNumLocalChannels(int num) +{ + const int previous = numLocalChannels.load(); + int clamped = juce::jlimit(1, maxLocalChannels, num); + + { + juce::ScopedLock lock(localChannelNamesLock); + for (int i = 0; i < maxLocalChannels; ++i) + { + auto& name = localChannelNames[(size_t)i]; + if (name.isEmpty() || isDefaultLocalChannelName(name)) + name = buildDefaultLocalChannelName(i); + } + } + + numLocalChannels.store(clamped); + syncLocalIntervalChannelConfig(); + applyCodecPreference(); + + // Post a local status message when transitioning into or out of multichannel + if (previous != clamped) + { + juce::String msg; + if (clamped > 1 && previous <= 1) + msg = "MultiChannel mode enabled (" + juce::String(clamped) + " channels). Waiting for peer detection."; + else if (clamped > 1) + msg = "Local channels: " + juce::String(clamped) + "."; + else + msg = "MultiChannel mode disabled (single channel)."; + juce::ScopedLock lock(chatLock); + chatHistory.add(msg); + chatSenders.add(""); + if (chatHistory.size() > 100) + { + chatHistory.removeRange(0, chatHistory.size() - 100); + chatSenders.removeRange(0, juce::jmax(0, chatSenders.size() - 100)); + } + } + + // Immediately tell peers about the change so they update their expand buttons + if (ninjamClient.GetStatus() == NJClient::NJC_STATUS_OK) + broadcastOpusSyncSupport(); +} + +int NinjamVst3AudioProcessor::getNumLocalChannels() const +{ + return numLocalChannels.load(); +} + +void NinjamVst3AudioProcessor::setLocalChannelName(int channel, const juce::String& name) +{ + if (channel < 0 || channel >= maxLocalChannels) return; + { juce::ScopedLock lock(localChannelNamesLock); localChannelNames[(size_t)channel] = name; } + syncLocalIntervalChannelConfig(); +} + +juce::String NinjamVst3AudioProcessor::getLocalChannelName(int channel) const +{ + if (channel < 0 || channel >= maxLocalChannels) return {}; + juce::ScopedLock lock(localChannelNamesLock); + return localChannelNames[(size_t)channel]; +} + +void NinjamVst3AudioProcessor::setLocalChannelGain(int channel, float gain) +{ + if (channel < 0 || channel >= maxLocalChannels) + return; + localChannelGains[(size_t)channel].store(gain); +} + +float NinjamVst3AudioProcessor::getLocalChannelGain(int channel) const +{ + if (channel < 0 || channel >= maxLocalChannels) + return 1.0f; + return localChannelGains[(size_t)channel].load(); +} + +void NinjamVst3AudioProcessor::setLocalChannelInput(int channel, int inputIndex) +{ + if (channel < 0 || channel >= maxLocalChannels) + return; + localChannelInputs[(size_t)channel].store(inputIndex); +} + +int NinjamVst3AudioProcessor::getLocalChannelInput(int channel) const +{ + if (channel < 0 || channel >= maxLocalChannels) + return 0; + return localChannelInputs[(size_t)channel].load(); +} + +float NinjamVst3AudioProcessor::getLocalChannelPeak(int channel) const +{ + if (channel < 0 || channel >= maxLocalChannels) + return 0.0f; + return localChannelPeaks[(size_t)channel].load(); +} + +float NinjamVst3AudioProcessor::getLocalChannelPeakLeft(int channel) const +{ + if (channel < 0 || channel >= maxLocalChannels) + return 0.0f; + return localChannelPeaksL[(size_t)channel].load(); +} + +float NinjamVst3AudioProcessor::getLocalChannelPeakRight(int channel) const +{ + if (channel < 0 || channel >= maxLocalChannels) + return 0.0f; + return localChannelPeaksR[(size_t)channel].load(); +} + +void NinjamVst3AudioProcessor::setLocalMonitorEnabled(bool enabled) +{ + localMonitorEnabled.store(enabled); +} + +bool NinjamVst3AudioProcessor::isLocalMonitorEnabled() const +{ + return localMonitorEnabled.load(); +} + +void NinjamVst3AudioProcessor::setFxReverbEnabled(bool enabled) +{ + fxReverbEnabled.store(enabled); +} + +bool NinjamVst3AudioProcessor::isFxReverbEnabled() const +{ + return fxReverbEnabled.load(); +} + +void NinjamVst3AudioProcessor::setFxDelayEnabled(bool enabled) +{ + fxDelayEnabled.store(enabled); +} + +bool NinjamVst3AudioProcessor::isFxDelayEnabled() const +{ + return fxDelayEnabled.load(); +} + +void NinjamVst3AudioProcessor::setFxReverbRoomSize(float roomSize) +{ + fxReverbRoomSize.store(juce::jlimit(0.0f, 1.0f, roomSize)); +} + +float NinjamVst3AudioProcessor::getFxReverbRoomSize() const +{ + return fxReverbRoomSize.load(); +} + +void NinjamVst3AudioProcessor::setFxReverbDamping(float damping) +{ + fxReverbDamping.store(juce::jlimit(0.0f, 1.0f, damping)); +} + +float NinjamVst3AudioProcessor::getFxReverbDamping() const +{ + return fxReverbDamping.load(); +} + +void NinjamVst3AudioProcessor::setFxReverbWetDryMix(float wetDryMix) +{ + fxReverbWetDryMix.store(juce::jlimit(0.0f, 1.0f, wetDryMix)); +} + +float NinjamVst3AudioProcessor::getFxReverbWetDryMix() const +{ + return fxReverbWetDryMix.load(); +} + +void NinjamVst3AudioProcessor::setFxReverbEarlyReflections(float earlyReflections) +{ + fxReverbEarlyReflections.store(juce::jlimit(0.0f, 1.0f, earlyReflections)); +} + +float NinjamVst3AudioProcessor::getFxReverbEarlyReflections() const +{ + return fxReverbEarlyReflections.load(); +} + +void NinjamVst3AudioProcessor::setFxReverbTail(float tail) +{ + fxReverbTail.store(juce::jlimit(0.0f, 1.0f, tail)); +} + +float NinjamVst3AudioProcessor::getFxReverbTail() const +{ + return fxReverbTail.load(); +} + +void NinjamVst3AudioProcessor::setFxDelayTimeMs(float timeMs) +{ + fxDelayTimeMs.store(juce::jlimit(20.0f, 2000.0f, timeMs)); +} + +float NinjamVst3AudioProcessor::getFxDelayTimeMs() const +{ + return fxDelayTimeMs.load(); +} + +void NinjamVst3AudioProcessor::setFxDelaySyncToHost(bool enabled) +{ + fxDelaySyncToHost.store(enabled); +} + +bool NinjamVst3AudioProcessor::isFxDelaySyncToHost() const +{ + return fxDelaySyncToHost.load(); +} + +void NinjamVst3AudioProcessor::setFxDelayDivision(int division) +{ + if (division != 1 && division != 8 && division != 16) + division = 8; + fxDelayDivision.store(division); +} + +int NinjamVst3AudioProcessor::getFxDelayDivision() const +{ + return fxDelayDivision.load(); +} + +void NinjamVst3AudioProcessor::setFxDelayPingPong(bool enabled) +{ + fxDelayPingPong.store(enabled); +} + +bool NinjamVst3AudioProcessor::isFxDelayPingPong() const +{ + return fxDelayPingPong.load(); +} + +void NinjamVst3AudioProcessor::setFxDelayWetDryMix(float wetDryMix) +{ + fxDelayWetDryMix.store(juce::jlimit(0.0f, 1.0f, wetDryMix)); +} + +float NinjamVst3AudioProcessor::getFxDelayWetDryMix() const +{ + return fxDelayWetDryMix.load(); +} + +void NinjamVst3AudioProcessor::setFxDelayFeedback(float feedback) +{ + fxDelayFeedback.store(juce::jlimit(0.0f, 0.95f, feedback)); +} + +float NinjamVst3AudioProcessor::getFxDelayFeedback() const +{ + return fxDelayFeedback.load(); +} + +void NinjamVst3AudioProcessor::setLocalChannelReverbSend(int channel, float send) +{ + if (channel < 0 || channel >= maxLocalChannels) + return; + localChannelReverbSends[(size_t)channel].store(juce::jlimit(0.0f, 1.0f, send)); +} + +float NinjamVst3AudioProcessor::getLocalChannelReverbSend(int channel) const +{ + if (channel < 0 || channel >= maxLocalChannels) + return 0.0f; + return localChannelReverbSends[(size_t)channel].load(); +} + +void NinjamVst3AudioProcessor::setLocalChannelDelaySend(int channel, float send) +{ + if (channel < 0 || channel >= maxLocalChannels) + return; + localChannelDelaySends[(size_t)channel].store(juce::jlimit(0.0f, 1.0f, send)); +} + +float NinjamVst3AudioProcessor::getLocalChannelDelaySend(int channel) const +{ + if (channel < 0 || channel >= maxLocalChannels) + return 0.0f; + return localChannelDelaySends[(size_t)channel].load(); +} + +int NinjamVst3AudioProcessor::getBPI() +{ + return ninjamClient.GetBPI(); +} + +float NinjamVst3AudioProcessor::getIntervalProgress() +{ + if (isSyncToHostEnabled() && (!hostWasPlaying.load() || syncWaitForInterval.load())) + return 0.0f; + + int pos = 0; + int length = 0; + ninjamClient.GetPosition(&pos, &length); + if (length > 0) + { + if (isSyncToHostEnabled() && hostWasPlaying.load()) + { + int basePos = syncDisplayPositionOffset.load(); + int relativePos = pos - basePos; + if (relativePos < 0) + relativePos += length; + return (float)relativePos / (float)length; + } + return (float)pos / (float)length; + } + return 0.0f; +} + +float NinjamVst3AudioProcessor::getBPM() +{ + return ninjamClient.GetActualBPM(); +} + +int NinjamVst3AudioProcessor::getIntervalIndex() const +{ + return getDisplayIntervalIndex(); +} + +float NinjamVst3AudioProcessor::getLocalPeak() const +{ + return localPeak.load(); +} + +float NinjamVst3AudioProcessor::getLocalPeakLeft() const +{ + return localPeakL.load(); +} + +float NinjamVst3AudioProcessor::getLocalPeakRight() const +{ + return localPeakR.load(); +} + +void NinjamVst3AudioProcessor::sendSideSignal(const juce::String& target, const juce::String& type, const juce::String& payload) +{ + const char* tgt = target.isNotEmpty() ? target.toRawUTF8() : "*"; + ninjamClient.ChatMessage_Send("SIDE_SIGNAL", tgt, type.toRawUTF8(), payload.toRawUTF8()); +} + +void NinjamVst3AudioProcessor::sendIntervalSignal(const juce::String& type, const juce::String& payload) +{ + if (ninjamClient.GetStatus() != NJClient::NJC_STATUS_OK) return; + // Wrap in {"sig":type, "data":payload} so the receiver knows the type + juce::DynamicObject::Ptr wrapper = new juce::DynamicObject(); + wrapper->setProperty("sig", type); + wrapper->setProperty("data", payload); + const juce::String msg = juce::JSON::toString(juce::var(wrapper.get())); + ninjamClient.SendRawIntervalItem(0, kSyncSignalFourcc, msg.toRawUTF8(), (int)msg.getNumBytesAsUTF8()); +} + +void NinjamVst3AudioProcessor::setSpreadOutputsEnabled(bool shouldEnable) +{ + bool wasEnabled = spreadOutputsEnabled.load(); + spreadOutputsEnabled.store(shouldEnable); + + if (wasEnabled && !shouldEnable) + { + userOutputAssignment.clear(); + + int numUsers = ninjamClient.GetNumUsers(); + for (int userIdx = 0; userIdx < numUsers; ++userIdx) + { + for (int ch = 0; ch < 32; ++ch) + { + ninjamClient.SetUserChannelState(userIdx, ch, + false, false, + false, 0.0f, + false, 0.0f, + false, false, + false, false, + true, 0); + } + } + } +} + +bool NinjamVst3AudioProcessor::isSpreadOutputsEnabled() const +{ + return spreadOutputsEnabled.load(); +} + +int NinjamVst3AudioProcessor::getCodecMode() const +{ + const bool multiChanAuto = numLocalChannels.load() > 1 && opusSyncAvailable.load(); + if (!multiChanAuto) + return 0; + // multiChanAuto: always mixed mode (Vorbis ch0 + Opus ch1..N) + return 1; +} + +unsigned int NinjamVst3AudioProcessor::getVorbisMask() const +{ + return ninjamClient.GetCodecVorbisMask(); +} + +unsigned int NinjamVst3AudioProcessor::getOpusMask() const +{ + return ninjamClient.GetCodecOpusMask(); +} + +juce::String NinjamVst3AudioProcessor::translateText(const juce::String& text) +{ + if (!autoTranslate) + return text; + +#if defined(_WIN32) + const wchar_t* host = L"api.mymemory.translated.net"; + + HINTERNET hSession = WinHttpOpen(L"NINJAMVST3/1.0", + WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, + 0); + if (!hSession) + return text; + + HINTERNET hConnect = WinHttpConnect(hSession, host, INTERNET_DEFAULT_HTTPS_PORT, 0); + if (!hConnect) + { + WinHttpCloseHandle(hSession); + return text; + } + + juce::String target = translateTargetLang.isNotEmpty() ? translateTargetLang : "en"; + + const char* srcUtf8 = text.toRawUTF8(); + juce::String targetCode = target.toLowerCase(); + const char* tgtUtf8 = targetCode.toRawUTF8(); + + auto urlEncode = [](const char* s) -> std::string + { + std::string out; + const unsigned char* p = (const unsigned char*)s; + while (*p) + { + unsigned char c = *p++; + if ((c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_' || c == '.' || c == '~') + { + out.push_back((char)c); + } + else if (c == ' ') + { + out.push_back('+'); + } + else + { + char buf[4]; + std::snprintf(buf, sizeof(buf), "%%%02X", c); + out.append(buf); + } + } + return out; + }; + + std::string qParam = urlEncode(srcUtf8); + std::string langpair = "auto|"; + langpair += tgtUtf8; + std::string langpairParam = urlEncode(langpair.c_str()); + + std::string pathStr = "/get?q="; + pathStr += qParam; + pathStr += "&langpair="; + pathStr += langpairParam; + + std::wstring wpath(pathStr.begin(), pathStr.end()); + + HINTERNET hRequest = WinHttpOpenRequest(hConnect, + L"GET", + wpath.c_str(), + NULL, + WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, + WINHTTP_FLAG_SECURE); + if (!hRequest) + { + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return text; + } + + BOOL ok = WinHttpSendRequest(hRequest, + WINHTTP_NO_ADDITIONAL_HEADERS, + 0, + WINHTTP_NO_REQUEST_DATA, + 0, + 0, + 0); + if (!ok) + { + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return text; + } + + ok = WinHttpReceiveResponse(hRequest, NULL); + if (!ok) + { + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return text; + } + + std::string response; + DWORD dwSize = 0; + do + { + if (!WinHttpQueryDataAvailable(hRequest, &dwSize) || dwSize == 0) + break; + + std::string chunk; + chunk.resize(dwSize); + DWORD dwDownloaded = 0; + if (!WinHttpReadData(hRequest, &chunk[0], dwSize, &dwDownloaded) || dwDownloaded == 0) + break; + + response.append(chunk.data(), dwDownloaded); + } + while (dwSize > 0); + + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + + if (response.empty()) + return text; + + std::string translated; + std::size_t keyPos = response.find("\"translatedText\""); + if (keyPos != std::string::npos) + { + std::size_t colonPos = response.find(':', keyPos); + if (colonPos != std::string::npos) + { + std::size_t firstQuote = response.find('\"', colonPos); + if (firstQuote != std::string::npos) + { + std::size_t endQuote = firstQuote + 1; + while (endQuote < response.size()) + { + char c = response[endQuote]; + if (c == '\\') + { + if (endQuote + 1 < response.size()) + { + char next = response[endQuote + 1]; + if (next == '\\' || next == '\"') + { + translated.push_back(next); + endQuote += 2; + continue; + } + } + } + if (c == '\"') + break; + + translated.push_back(c); + ++endQuote; + } + } + } + } + + if (translated.empty()) + return text; + + return juce::String::fromUTF8(translated.c_str(), (int)translated.size()); +#else + return text; +#endif +} + +std::vector NinjamVst3AudioProcessor::getPublicServers() const +{ + std::vector copy; + const juce::ScopedLock lock(serverListLock); + copy = publicServers; + return copy; +} + +void NinjamVst3AudioProcessor::refreshPublicServers() +{ + std::vector result; + +#if defined(_WIN32) + const wchar_t* host = L"ninbot.com"; + const wchar_t* path = L"/app/servers.php"; + + HINTERNET hSession = WinHttpOpen(L"NINJAMVST3/1.0", + WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, + 0); + if (!hSession) + return; + + HINTERNET hConnect = WinHttpConnect(hSession, host, INTERNET_DEFAULT_HTTP_PORT, 0); + if (!hConnect) + { + WinHttpCloseHandle(hSession); + return; + } + + HINTERNET hRequest = WinHttpOpenRequest(hConnect, + L"GET", + path, + NULL, + WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, + 0); + if (!hRequest) + { + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return; + } + + BOOL ok = WinHttpSendRequest(hRequest, + WINHTTP_NO_ADDITIONAL_HEADERS, + 0, + WINHTTP_NO_REQUEST_DATA, + 0, + 0, + 0); + if (!ok) + { + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return; + } + + ok = WinHttpReceiveResponse(hRequest, NULL); + if (!ok) + { + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return; + } + + std::string response; + DWORD dwSize = 0; + do + { + if (!WinHttpQueryDataAvailable(hRequest, &dwSize) || dwSize == 0) + break; + + std::string chunk; + chunk.resize(dwSize); + DWORD dwDownloaded = 0; + if (!WinHttpReadData(hRequest, &chunk[0], dwSize, &dwDownloaded) || dwDownloaded == 0) + break; + + response.append(chunk.data(), dwDownloaded); + } + while (dwSize > 0); + + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + + if (response.empty()) + return; + + juce::String jsonText = juce::String::fromUTF8(response.c_str(), (int)response.size()); + + juce::var root; + juce::Result parseError = juce::JSON::parse(jsonText, root); + if (parseError.failed() || !root.isObject()) + return; + + auto* rootObj = root.getDynamicObject(); + if (!rootObj) + return; + + juce::var serversVar = rootObj->getProperty("servers"); + if (!serversVar.isArray()) + return; + + auto* serversArray = serversVar.getArray(); + if (!serversArray) + return; + + for (auto& serverVar : *serversArray) + { + if (!serverVar.isObject()) + continue; + auto* obj = serverVar.getDynamicObject(); + if (!obj) + continue; + + PublicServerInfo info; + juce::String nameText = obj->getProperty("name").toString(); + info.name = nameText; + + int colon = nameText.lastIndexOfChar(':'); + if (colon > 0) + { + info.host = nameText.substring(0, colon); + info.port = nameText.substring(colon + 1).getIntValue(); + } + else + { + info.host = nameText; + info.port = 2049; + } + + info.bpi = obj->getProperty("bpi").toString().getIntValue(); + info.bpm = (float)obj->getProperty("bpm").toString().getDoubleValue(); + + juce::var usersVar = obj->getProperty("users"); + if (usersVar.isArray() && usersVar.getArray() != nullptr) + info.userCount = usersVar.getArray()->size(); + else + info.userCount = obj->getProperty("user_count").toString().getIntValue(); + + info.userMax = obj->getProperty("user_max").toString().getIntValue(); + result.push_back(info); + } +#endif + + const juce::ScopedLock lock(serverListLock); + publicServers.swap(result); +} + +NinjamVst3AudioProcessor::~NinjamVst3AudioProcessor() +{ + stopTimer(); + stopAdvancedVideoClient(); + ninjamClient.Disconnect(); + JNL::close_socketlib(); +} + +const juce::String NinjamVst3AudioProcessor::getName() const +{ + return "NINJAM VST3"; +} + +bool NinjamVst3AudioProcessor::acceptsMidi() const +{ + return true; +} + +bool NinjamVst3AudioProcessor::producesMidi() const +{ + return true; +} + +bool NinjamVst3AudioProcessor::isMidiEffect() const +{ + return false; +} + +double NinjamVst3AudioProcessor::getTailLengthSeconds() const +{ + return 0.0; +} + +int NinjamVst3AudioProcessor::getNumPrograms() +{ + return 1; +} + +int NinjamVst3AudioProcessor::getCurrentProgram() +{ + return 0; +} + +void NinjamVst3AudioProcessor::setCurrentProgram (int index) +{ +} + +const juce::String NinjamVst3AudioProcessor::getProgramName (int index) +{ + return {}; +} + +void NinjamVst3AudioProcessor::changeProgramName (int index, const juce::String& newName) +{ +} + +void NinjamVst3AudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) +{ + intervalSyncSampleCounter.store(0, std::memory_order_relaxed); + processingSampleRate = sampleRate > 1.0 ? sampleRate : 44100.0; + juce::dsp::ProcessSpec spec; + spec.sampleRate = sampleRate; + spec.maximumBlockSize = (juce::uint32) samplesPerBlock; + spec.numChannels = (juce::uint32) getTotalNumOutputChannels(); + masterLimiter.prepare(spec); + masterLimiter.setThreshold(limiterThresholdDb.load()); + masterLimiter.setRelease(limiterReleaseMs.load()); + masterLimiter.reset(); + + fxReverb.reset(); + juce::Reverb::Parameters params; + params.roomSize = fxReverbRoomSize.load(); + params.damping = 0.45f; + params.width = 1.0f; + params.wetLevel = 0.35f; + params.dryLevel = 0.0f; + params.freezeMode = 0.0f; + fxReverb.setParameters(params); + + const int maxDelaySamples = juce::jmax(1, (int)std::ceil(processingSampleRate * 2.5)); + fxDelayBuffer.setSize(2, maxDelaySamples, false, true, true); + fxDelayBuffer.clear(); + fxDelayWritePosition = 0; + + fxReverbInputBuffer.setSize(1, juce::jmax(1, samplesPerBlock), false, true, true); + fxDelayInputBuffer.setSize(1, juce::jmax(1, samplesPerBlock), false, true, true); + fxReturnBuffer.setSize(2, juce::jmax(1, samplesPerBlock), false, true, true); +} + +void NinjamVst3AudioProcessor::releaseResources() +{ +} + +bool NinjamVst3AudioProcessor::isBusesLayoutSupported (const BusesLayout& layouts) const +{ + if (layouts.getMainOutputChannelSet() != juce::AudioChannelSet::stereo()) + return false; + + auto mainIn = layouts.getMainInputChannelSet(); + if (!mainIn.isDisabled() + && mainIn != juce::AudioChannelSet::stereo() + && mainIn != juce::AudioChannelSet::mono()) + return false; + + for (int i = 1; i < layouts.inputBuses.size(); ++i) + { + if (!layouts.inputBuses[i].isDisabled() && layouts.inputBuses[i] != juce::AudioChannelSet::stereo()) + return false; + } + + for (int i = 1; i < layouts.outputBuses.size(); ++i) + { + if (!layouts.outputBuses[i].isDisabled() && layouts.outputBuses[i] != juce::AudioChannelSet::stereo()) + return false; + } + + return true; +} + +int NinjamVst3AudioProcessor::LicenseAgreementCallback(void* userData, const char* licensetext) +{ + // Auto-accept license for now (or log it) + // Ideally, show a dialog to the user + // Since this is called from Run(), which we call from timerCallback (UI thread), + // we can show a message box. + // However, for automation/testing, we might want to auto-accept. + + // Simple auto-accept for this proof of concept: + juce::Logger::writeToLog("License Agreement Requested: " + juce::String(licensetext)); + return 1; +} + +void NinjamVst3AudioProcessor::processSyncSignal(const juce::String& sender, const juce::String& type, const juce::String& payload) +{ + if (type == "intervalLatencyReport") + return; + if (type == "intervalTransportProbe") + { + juce::String payloadUserId; + juce::String probeId; + const juce::var parsed = juce::JSON::parse(payload); + if (auto* obj = parsed.getDynamicObject()) + { + if (obj->hasProperty("userId")) + payloadUserId = obj->getProperty("userId").toString(); + if (obj->hasProperty("probeId")) + probeId = obj->getProperty("probeId").toString(); + } + const juce::String senderKey = normaliseOpusPeerId(payloadUserId.isNotEmpty() ? payloadUserId : sender); + const juce::String localUserKey = normaliseOpusPeerId(currentUser); + if (probeId.isEmpty() || sender.isEmpty() || senderKey.isEmpty() || senderKey == localUserKey) + return; + + juce::DynamicObject::Ptr ackObj = new juce::DynamicObject(); + ackObj->setProperty("type", "intervalTransportProbeAck"); + ackObj->setProperty("userId", localUserKey.isNotEmpty() ? localUserKey : currentUser); + ackObj->setProperty("probeId", probeId); + ackObj->setProperty("eventId", "transportProbeAck:" + probeId); + const juce::String ackPayload = juce::JSON::toString(juce::var(ackObj.get())); + sendIntervalSignal("intervalTransportProbeAck", ackPayload); + return; + } + if (type == "intervalTransportProbeAck") + { + juce::String payloadUserId; + juce::String probeId; + const juce::var parsed = juce::JSON::parse(payload); + if (auto* obj = parsed.getDynamicObject()) + { + if (obj->hasProperty("userId")) + payloadUserId = obj->getProperty("userId").toString(); + if (obj->hasProperty("probeId")) + probeId = obj->getProperty("probeId").toString(); + } + if (probeId.isEmpty()) + return; + const double nowMs = juce::Time::getMillisecondCounterHiRes(); + const juce::String senderKey = normaliseOpusPeerId(payloadUserId.isNotEmpty() ? payloadUserId : sender); + if (senderKey.isEmpty()) + return; + const juce::String canonicalSenderKey = canonicalDelayUserKey(senderKey); + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + auto sentIt = pendingTransportProbeSentMsById.find(probeId); + if (sentIt == pendingTransportProbeSentMsById.end()) + return; + const double rttMs = nowMs - sentIt->second; + pendingTransportProbeSentMsById.erase(sentIt); + if (rttMs <= 0.0 || rttMs > 3000.0) + return; + const auto updateRtt = [&](const juce::String& key) + { + if (key.isEmpty()) + return; + auto it = remoteTransportRttMsByUser.find(key); + if (it == remoteTransportRttMsByUser.end()) + remoteTransportRttMsByUser[key] = rttMs; + else + it->second = (it->second * 0.85) + (rttMs * 0.15); + }; + updateRtt(senderKey); + updateRtt(canonicalSenderKey); + return; + } + if (type == "midiRelay") + { + juce::String payloadUserId; + MidiControllerEvent event; + const juce::var parsed = juce::JSON::parse(payload); + if (auto* obj = parsed.getDynamicObject()) + { + if (obj->hasProperty("userId")) payloadUserId = obj->getProperty("userId").toString(); + if (obj->hasProperty("isController")) event.isController = (bool)obj->getProperty("isController"); + if (obj->hasProperty("midiChannel")) event.midiChannel = (int)obj->getProperty("midiChannel"); + if (obj->hasProperty("number")) event.number = (int)obj->getProperty("number"); + if (obj->hasProperty("value")) event.value = (int)obj->getProperty("value"); + if (obj->hasProperty("normalized")) event.normalized = (float)(double)obj->getProperty("normalized"); + if (obj->hasProperty("isNoteOn")) event.isNoteOn = (bool)obj->getProperty("isNoteOn"); + } + + const juce::String senderKey = normaliseOpusPeerId(payloadUserId.isNotEmpty() ? payloadUserId : sender); + const juce::String localUserKey = normaliseOpusPeerId(currentUser); + if (senderKey.isEmpty() || senderKey == localUserKey) + return; + + event.midiChannel = juce::jlimit(1, 16, event.midiChannel); + event.number = juce::jlimit(0, 127, event.number); + event.value = juce::jlimit(0, 127, event.value); + event.normalized = juce::jlimit(0.0f, 1.0f, event.normalized); + + const juce::SpinLock::ScopedLockType lock(inboundMidiRelayQueueLock); + pendingInboundMidiRelayEvents.push_back(event); + if (pendingInboundMidiRelayEvents.size() > 512) + pendingInboundMidiRelayEvents.erase(pendingInboundMidiRelayEvents.begin(), pendingInboundMidiRelayEvents.begin() + (long long)(pendingInboundMidiRelayEvents.size() - 512)); + return; + } + if (type == "localInputSelect") + { + const juce::var parsed = juce::JSON::parse(payload); + if (auto* obj = parsed.getDynamicObject()) + { + const int channel = obj->hasProperty("channel") ? (int)obj->getProperty("channel") : -1; + const int inputIndex = obj->hasProperty("inputIndex") ? (int)obj->getProperty("inputIndex") : 0; + if (channel >= 0 && channel < maxLocalChannels) + setLocalChannelInput(channel, inputIndex); + } + return; + } + if (type == "intervals") + { + // payload is expected to be either an array of objects or a single object + if (videoHelperRunning.load() && !intervalJsonFile.getFullPathName().isEmpty()) + { + // write incoming payload to the helper file path + intervalJsonFile.replaceWithText(payload); + vlogStr("[INTSYNC] Received intervals payload from=" + sender + " written to " + intervalJsonFile.getFullPathName()); + } + return; + } + if (type == "intervalSyncTag") + { + juce::String tag; + juce::String payloadUserId; + int remoteInterval = -1; + int remoteIntervalAbsolute = -1; + int remoteBpi = 0; + int remoteBeat = -1; + const juce::var parsed = juce::JSON::parse(payload); + if (auto* obj = parsed.getDynamicObject()) + { + if (obj->hasProperty("tag")) + tag = obj->getProperty("tag").toString(); + if (obj->hasProperty("userId")) + payloadUserId = obj->getProperty("userId").toString(); + if (obj->hasProperty("intervalIndex")) + remoteInterval = (int)obj->getProperty("intervalIndex"); + if (obj->hasProperty("intervalAbsolute")) + remoteIntervalAbsolute = (int)obj->getProperty("intervalAbsolute"); + if (obj->hasProperty("bpi")) + remoteBpi = (int)obj->getProperty("bpi"); + if (obj->hasProperty("beatIndex")) + remoteBeat = (int)obj->getProperty("beatIndex"); + } + const int localInterval = getIntervalIndex(); + juce::String status = "Interval Tag " + sender; + if (remoteInterval >= 0) + { + const int delta = remoteInterval - localInterval; + status << " remoteInt " << juce::String(remoteInterval) + << " localInt " << juce::String(localInterval) + << " d=" << juce::String(delta); + } + if (remoteBeat >= 0 && remoteBpi > 0) + status << " beat " << juce::String(remoteBeat + 1) << "/" << juce::String(remoteBpi); + if (tag.isNotEmpty()) + status << " tag " << tag; + setIntervalSyncStatusText(status); + + if (remoteInterval >= 0 && remoteBeat == 0) + { + const juce::String localUserKey = normaliseOpusPeerId(currentUser); + const juce::String senderKey = normaliseOpusPeerId(payloadUserId.isNotEmpty() ? payloadUserId : sender); + if (senderKey.isNotEmpty() && senderKey != localUserKey) + { + const int localBpi = juce::jmax(1, getBPI()); + const bool bpiMatches = (remoteBpi <= 0 || remoteBpi == localBpi); + if (!bpiMatches) + return; + bool shouldStorePending = false; + const juce::String displaySender = sender.isNotEmpty() ? sender : (payloadUserId.isNotEmpty() ? payloadUserId : senderKey); + const long long receivedSampleCount = intervalSyncSampleCounter.load(std::memory_order_relaxed); + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + auto it = lastAnnouncedRemoteIntervalByUser.find(senderKey); + if (it != lastAnnouncedRemoteIntervalByUser.end() && remoteInterval + 1 < it->second) + remoteLatencyAverageByUser.erase(senderKey); + if (it == lastAnnouncedRemoteIntervalByUser.end() || it->second != remoteInterval) + { + lastAnnouncedRemoteIntervalByUser[senderKey] = remoteInterval; + shouldStorePending = true; + } + } + + if (shouldStorePending) + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + auto& pending = pendingRemoteIntervalStartsByUser[senderKey]; + pending.remoteInterval = remoteInterval; + pending.remoteIntervalAbsolute = remoteIntervalAbsolute; + pending.displaySender = displaySender; + pending.receivedSampleCount = receivedSampleCount; + vlogStr("[INTTAG] Pending stored from=" + sender + " userId=" + (payloadUserId.isNotEmpty() ? payloadUserId : sender) + " senderKey=" + senderKey + " remoteInterval=" + juce::String(remoteInterval) + " remoteAbs=" + juce::String(remoteIntervalAbsolute) + " samples=" + juce::String(receivedSampleCount)); + } + } + } + return; + } +} + +void NinjamVst3AudioProcessor::ChatMessage_Callback(void* userData, NJClient* inst, const char** parms, int nparms) +{ + auto* self = static_cast(userData); + auto processOpusSyncSupport = [self](const juce::String& sender, const juce::String& payload, juce::String* outEventId) -> bool + { + juce::Logger::writeToLog("processOpusSyncSupport from sender='" + sender + "'"); + vlogStr("processOpusSyncSupport from sender='" + sender + "' payload=" + payload.substring(0, 80)); + juce::var parsed = juce::JSON::parse(payload); + bool supportsOpus = false; + bool multiChanEnabled = false; + int peerNumChannels = 1; + juce::String userId = normaliseOpusPeerId(sender); + juce::String clientId; + juce::String appFamily; + int handshakeVersion = 0; + juce::String runtimeFormat; + juce::String pluginVersion; + if (auto* obj = parsed.getDynamicObject()) + { + const juce::String supports = obj->getProperty("supportsOpus").toString(); + supportsOpus = supports == "1" || supports.equalsIgnoreCase("true"); + const juce::String enabledStr = obj->getProperty("enabled").toString(); + multiChanEnabled = enabledStr == "1" || enabledStr.equalsIgnoreCase("true"); + const juce::var numChVar = obj->getProperty("numChannels"); + if (!numChVar.isVoid()) peerNumChannels = juce::jmax(1, (int)numChVar); + juce::String payloadUserId = obj->getProperty("userId").toString(); + if (payloadUserId.isNotEmpty()) + userId = normaliseOpusPeerId(payloadUserId); + clientId = obj->getProperty("clientId").toString().trim(); + appFamily = obj->getProperty("appFamily").toString().trim(); + handshakeVersion = (int)obj->getProperty("handshakeVersion"); + runtimeFormat = obj->getProperty("runtimeFormat").toString().trim(); + pluginVersion = obj->getProperty("pluginVersion").toString().trim(); + if (outEventId != nullptr) + *outEventId = obj->getProperty("eventId").toString(); + } + else + return false; + + const bool isLocalClient = clientId.isNotEmpty() ? (clientId == self->opusSyncInstanceId) + : (userId == normaliseOpusPeerId(self->currentUser)); + const bool sameAppFamily = appFamily.isEmpty() || appFamily == opusSyncAppFamily; + const bool compatibleHandshake = handshakeVersion <= 0 || handshakeVersion == opusSyncHandshakeVersion; + const juce::String peerKey = clientId.isNotEmpty() ? clientId : userId; + if (peerKey.isNotEmpty() && userId.isNotEmpty() && !isLocalClient) + { + bool recognizedNow = false; + juce::String recognizedMessage; + { + juce::ScopedLock lock(self->opusSyncPeerLock); + if (supportsOpus && sameAppFamily && compatibleHandshake) + { + const bool wasKnown = self->opusSyncPeers.find(peerKey) != self->opusSyncPeers.end(); + auto& peer = self->opusSyncPeers[peerKey]; + const bool wasMultiChan = peer.multiChanEnabled; + peer.userId = userId; + peer.supportsOpus = true; + peer.multiChanEnabled = multiChanEnabled; + peer.numChannels = peerNumChannels; + peer.appFamily = appFamily; + peer.handshakeVersion = handshakeVersion; + peer.runtimeFormat = runtimeFormat; + peer.pluginVersion = pluginVersion; + peer.lastSeenMs = juce::Time::getMillisecondCounterHiRes(); + juce::String peerLabel = sender.isNotEmpty() ? sender : userId; + if (!wasKnown) + { + juce::String peerInfo = peer.runtimeFormat; + if (peer.pluginVersion.isNotEmpty()) + { + if (peerInfo.isNotEmpty()) + peerInfo << " "; + peerInfo << peer.pluginVersion; + } + recognizedMessage = "Multi Client Detected: " + peerLabel; + if (peerInfo.isNotEmpty()) + recognizedMessage << " (" << peerInfo << ")"; + if (multiChanEnabled) + recognizedMessage << " [MultiChannel ON]"; + recognizedNow = true; + } + else if (multiChanEnabled && !wasMultiChan) + { + recognizedMessage = "MultiChannel Detected: " + peerLabel; + recognizedNow = true; + } + else if (!multiChanEnabled && wasMultiChan) + { + recognizedMessage = "MultiChannel Off: " + peerLabel; + recognizedNow = true; + } + } + else + self->opusSyncPeers.erase(peerKey); + } + if (recognizedNow) + { + juce::ScopedLock lock(self->chatLock); + self->chatHistory.add(recognizedMessage); + self->chatSenders.add(""); + if (self->chatHistory.size() > 100) + { + self->chatHistory.removeRange(0, self->chatHistory.size() - 100); + self->chatSenders.removeRange(0, juce::jmax(0, self->chatSenders.size() - 100)); + } + } + vlogStr("[MCRefresh] processOpusSyncSupport calling refresh. sender='" + sender + "' userId='" + userId + "' multiChanEnabled=" + juce::String(multiChanEnabled ? 1 : 0) + " nCh=" + juce::String(peerNumChannels)); + self->refreshOpusSyncAvailabilityFromUsers(); + } + return true; + }; + auto processInboundSideSignal = [self, &processOpusSyncSupport](const juce::String& sender, const juce::String& type, const juce::String& payload, juce::String* outEventId) -> bool + { + if (type == "opusSyncSupport") + return processOpusSyncSupport(sender, payload, outEventId); + juce::ignoreUnused(outEventId); + self->processSyncSignal(sender, type, payload); + return true; + }; + // nparms is the static array size (always 5); count only non-null entries + { + int actualNparms = 0; + while (actualNparms < nparms && parms[actualNparms] != nullptr) + ++actualNparms; + nparms = actualNparms; + } + if (nparms > 0) + { + juce::String cmd = parms[0]; + vlogStr("ChatMsg cmd=" + cmd + " nparms=" + juce::String(nparms)); + juce::Logger::writeToLog("ChatMsg cmd=" + cmd + " nparms=" + juce::String(nparms)); + auto applyServerCaps = [self](const juce::String& capsText) + { + juce::Logger::writeToLog("SERVER_CAPS received: " + capsText); + const juce::String caps = capsText.toLowerCase(); + const bool hasOpusSyncCap = caps.contains("opus_sync_v2") + || caps.contains("hd_audio_v2") + || caps.contains("hd_sync_v2"); + self->opusSyncServerSupported.store(hasOpusSyncCap); + juce::Logger::writeToLog("opusSyncServerSupported -> " + juce::String(hasOpusSyncCap ? "true" : "false")); + if (!hasOpusSyncCap) + self->refreshOpusSyncAvailabilityFromUsers(); + }; + juce::String line; + if (cmd == "SERVER_CAPS" && nparms >= 2) + { + applyServerCaps(juce::String(parms[1])); + return; + } + bool isSideSignalCmd = (cmd == "SIDE_SIGNAL_FROM" && nparms >= 4) + || (cmd == "SIDE_SIGNAL" && nparms >= 4) + || (cmd == "VIDEO_SIGNAL_FROM" && nparms >= 4) + || (cmd == "VIDEO_SIGNAL" && nparms >= 4); + if (isSideSignalCmd) + { + juce::String sender; + juce::String type; + juce::String payload; + juce::String signalEventId; + sender = nparms >= 2 ? juce::String(parms[1]) : juce::String(); + type = nparms >= 3 ? juce::String(parms[nparms - 2]) : juce::String(); + payload = nparms >= 2 ? juce::String(parms[nparms - 1]) : juce::String(); + if (type.isEmpty() || payload.isEmpty()) + return; + + processInboundSideSignal(sender, type, payload, &signalEventId); + juce::String logLine = "NINJAM Side Signal From " + sender + " [" + type + "]"; + if (signalEventId.isNotEmpty()) + logLine += " eid=" + signalEventId; + juce::Logger::writeToLog(logLine); + return; + } + if ((cmd == "MSG" || cmd == "PRIVMSG") && nparms >= 3) + { + const juce::String sender = juce::String(parms[1]); + const juce::String messageText = juce::String(parms[2]); + if (messageText.startsWith(opusSyncChatPrefix)) + { + vlogStr("MSG opusSyncChatPrefix MATCHED from " + sender); + juce::Logger::writeToLog("MSG opusSyncChatPrefix received from " + sender); + } + const juce::String trimmedText = messageText.trim(); + if (sender == "*" && trimmedText.startsWithIgnoreCase("SERVER_CAPS")) + { + juce::String capsText = trimmedText.fromFirstOccurrenceOf("SERVER_CAPS", false, true).trim(); + if (capsText.startsWithChar(':')) + capsText = capsText.substring(1).trim(); + applyServerCaps(capsText); + return; + } + if (messageText.startsWith(opusSyncChatPrefix)) + { + juce::String signalEventId; + const juce::String payload = messageText.fromFirstOccurrenceOf(opusSyncChatPrefix, false, false); + if (processOpusSyncSupport(sender, payload, &signalEventId)) + { + juce::String logLine = "NINJAM Opus Sync Signal From " + sender + " [chat]"; + if (signalEventId.isNotEmpty()) + logLine += " eid=" + signalEventId; + juce::Logger::writeToLog(logLine); + return; + } + } + bool isSideSignalChat = messageText.startsWith(sideSignalChatPrefix); + if (isSideSignalChat) + { + const char* signalPrefix = sideSignalChatPrefix; + const juce::String wrapperJson = messageText.fromFirstOccurrenceOf(signalPrefix, false, false); + juce::var wrapped = juce::JSON::parse(wrapperJson); + if (auto* wrappedObj = wrapped.getDynamicObject()) + { + const juce::String type = wrappedObj->getProperty("type").toString(); + const juce::String payload = wrappedObj->getProperty("payload").toString(); + if (type.isNotEmpty() && payload.isNotEmpty()) + { + juce::String signalEventId; + if (processInboundSideSignal(sender, type, payload, &signalEventId)) + { + juce::String logLine = "NINJAM Side Signal From " + sender + " [" + type + " chat]"; + if (signalEventId.isNotEmpty()) + logLine += " eid=" + signalEventId; + juce::Logger::writeToLog(logLine); + return; + } + } + } + } + } + + auto cleanName = [](const char* raw) -> juce::String { + return normaliseChatTargetNick(juce::String(raw)); + }; + + juce::String lineSender; + if (cmd == "MSG" && nparms >= 3) + { + // Suppress server echo of our own messages + if (normaliseChatTargetNick(juce::String(parms[1])) == normaliseChatTargetNick(self->currentUser)) + return; + juce::String name = cleanName(parms[1]); + line = name + ": " + juce::String(parms[2]); + lineSender = name; + } + else if (cmd == "PRIVMSG" && nparms >= 3) + { + juce::String name = cleanName(parms[1]); + line = "(Private) " + name + ": " + juce::String(parms[2]); + lineSender = name; + } + else if (cmd == "TOPIC" && nparms >= 2) + line = "Topic: " + juce::String(parms[1]); + else if (cmd == "JOIN" && nparms >= 2) + { + self->broadcastOpusSyncSupport(juce::String(parms[1])); + self->broadcastIntervalSyncTag(juce::String(parms[1])); + line = cleanName(parms[1]) + " has joined."; + } + else if (cmd == "PART" && nparms >= 2) + line = cleanName(parms[1]) + " has left."; + else + { + line = cmd; + for (int i=1; itranslateText(line); + juce::ScopedLock lock(self->chatLock); + self->chatHistory.add(stored); + self->chatSenders.add(lineSender); + if (self->chatHistory.size() > 100) + { + self->chatHistory.removeRange(0, self->chatHistory.size() - 100); + self->chatSenders.removeRange(0, juce::jmax(0, self->chatSenders.size() - 100)); + } + } + + // Also log + juce::Logger::writeToLog("NINJAM Chat: " + line); + } +} + +void NinjamVst3AudioProcessor::processBlock (juce::AudioBuffer& buffer, juce::MidiBuffer& midiMessages) +{ + juce::ScopedNoDenormals noDenormals; + juce::AudioPlayHead::CurrentPositionInfo hostInfoAtBlock; + bool gotHostPosition = false; + if (auto* playHead = getPlayHead()) + { + juce::AudioPlayHead::CurrentPositionInfo info; + if (playHead->getCurrentPosition(info)) + { + gotHostPosition = true; + hostInfoAtBlock = info; + const juce::ScopedLock lock(transportLock); + lastHostPosition = info; + } + } + int numSamples = buffer.getNumSamples(); + intervalSyncSampleCounter.fetch_add((long long)numSamples, std::memory_order_relaxed); + const bool useHostMidiForLearn = getMidiLearnInputDeviceId().isEmpty(); + const bool useHostMidiForRelay = getMidiRelayInputDeviceId().isEmpty(); + { + const juce::SpinLock::ScopedLockType midiQueueLock(midiEventQueueLock); + const juce::SpinLock::ScopedLockType relayQueueLock(outboundMidiRelayQueueLock); + for (const auto metadata : midiMessages) + { + const auto& msg = metadata.getMessage(); + if (msg.isController()) + { + MidiControllerEvent event; + event.isController = true; + event.midiChannel = msg.getChannel(); + event.number = msg.getControllerNumber(); + event.value = msg.getControllerValue(); + event.normalized = (float)event.value / 127.0f; + event.isNoteOn = event.value >= 64; + if (useHostMidiForLearn) + pendingMidiControllerEvents.push_back(event); + if (useHostMidiForRelay) + pendingOutboundMidiRelayEvents.push_back(event); + } + else if (msg.isNoteOnOrOff()) + { + MidiControllerEvent event; + event.isController = false; + event.midiChannel = msg.getChannel(); + event.number = msg.getNoteNumber(); + event.value = msg.getVelocity(); + event.normalized = msg.isNoteOn() ? ((float)event.value / 127.0f) : 0.0f; + event.isNoteOn = msg.isNoteOn(); + if (useHostMidiForLearn) + pendingMidiControllerEvents.push_back(event); + if (useHostMidiForRelay) + pendingOutboundMidiRelayEvents.push_back(event); + } + } + if (pendingMidiControllerEvents.size() > 512) + pendingMidiControllerEvents.erase(pendingMidiControllerEvents.begin(), pendingMidiControllerEvents.begin() + (long long)(pendingMidiControllerEvents.size() - 512)); + if (pendingOutboundMidiRelayEvents.size() > 512) + pendingOutboundMidiRelayEvents.erase(pendingOutboundMidiRelayEvents.begin(), pendingOutboundMidiRelayEvents.begin() + (long long)(pendingOutboundMidiRelayEvents.size() - 512)); + } + injectInboundMidiRelayEvents(midiMessages); + + int totalInputChannels = 0; + int numInputBuses = getBusCount(true); + for (int bus = 0; bus < numInputBuses; ++bus) + { + int busChans = getChannelCountOfBus(true, bus); + if (busChans <= 0) + continue; + totalInputChannels += busChans; + } + + if (tempInputBuffer.getNumChannels() < totalInputChannels || tempInputBuffer.getNumSamples() < numSamples) + tempInputBuffer.setSize(totalInputChannels, numSamples, false, false, true); + + int inputChanIndex = 0; + for (int bus = 0; bus < numInputBuses; ++bus) + { + auto& busBuffer = getBusBuffer(buffer, true, bus); + int busChans = busBuffer.getNumChannels(); + if (busChans <= 0) + continue; + for (int ch = 0; ch < busChans; ++ch) + { + if (inputChanIndex < totalInputChannels) + { + tempInputBuffer.copyFrom(inputChanIndex, 0, busBuffer, ch, 0, numSamples); + ++inputChanIndex; + } + } + } + + if (localChannelBuffer.getNumChannels() < maxLocalChannels || localChannelBuffer.getNumSamples() < numSamples) + localChannelBuffer.setSize(maxLocalChannels, numSamples, false, false, true); + + int requestedLocal = numLocalChannels.load(); + int actualLocal = juce::jlimit(1, maxLocalChannels, requestedLocal); + actualLocal = juce::jmin(actualLocal, totalInputChannels); + std::array monitorSourceLeft{}; + std::array monitorSourceRight{}; + std::array monitorStereo{}; + monitorSourceLeft.fill(-1); + monitorSourceRight.fill(-1); + monitorStereo.fill(false); + + float globalLocalMax = 0.0f; + float globalLocalMaxL = 0.0f; + float globalLocalMaxR = 0.0f; + for (int ch = 0; ch < actualLocal; ++ch) + { + int srcIndex = localChannelInputs[(size_t)ch].load(); + int leftSource = -1; + int rightSource = -1; + + if (srcIndex >= 0) + { + if (srcIndex >= totalInputChannels) + srcIndex = juce::jlimit(0, totalInputChannels - 1, srcIndex); + + int left = juce::jlimit(0, juce::jmax(totalInputChannels - 1, 0), srcIndex); + int right = left; + + localChannelBuffer.clear(ch, 0, numSamples); + if (left < totalInputChannels) + localChannelBuffer.copyFrom(ch, 0, tempInputBuffer, left, 0, numSamples); + + leftSource = left; + rightSource = right; + } + else + { + int pairIndex = -1 - srcIndex; + int left = pairIndex * 2; + int right = left + 1; + + if (left < 0 || left >= totalInputChannels) + left = juce::jlimit(0, juce::jmax(totalInputChannels - 1, 0), left); + if (right < 0 || right >= totalInputChannels) + right = left; + + localChannelBuffer.clear(ch, 0, numSamples); + if (left < totalInputChannels) + localChannelBuffer.addFrom(ch, 0, tempInputBuffer, left, 0, numSamples, 0.5f); + if (right < totalInputChannels) + localChannelBuffer.addFrom(ch, 0, tempInputBuffer, right, 0, numSamples, 0.5f); + + leftSource = left; + rightSource = right; + monitorStereo[(size_t)ch] = (right != left); + } + + monitorSourceLeft[(size_t)ch] = leftSource; + monitorSourceRight[(size_t)ch] = rightSource; + + float gain = localChannelGains[(size_t)ch].load(); + if (gain != 1.0f) + localChannelBuffer.applyGain(ch, 0, numSamples, gain); + + const float* data = localChannelBuffer.getReadPointer(ch); + float localMax = 0.0f; + for (int i = 0; i < numSamples; ++i) + { + float a = std::abs(data[i]); + if (a > localMax) + localMax = a; + } + + float localMaxL = 0.0f; + float localMaxR = 0.0f; + + if (leftSource >= 0 && leftSource < totalInputChannels) + { + const float* leftData = tempInputBuffer.getReadPointer(leftSource); + for (int i = 0; i < numSamples; ++i) + { + float a = std::abs(leftData[i] * gain); + if (a > localMaxL) + localMaxL = a; + } + } + + if (rightSource >= 0 && rightSource < totalInputChannels) + { + const float* rightData = tempInputBuffer.getReadPointer(rightSource); + for (int i = 0; i < numSamples; ++i) + { + float a = std::abs(rightData[i] * gain); + if (a > localMaxR) + localMaxR = a; + } + } + + localChannelPeaks[(size_t)ch].store(localMax); + localChannelPeaksL[(size_t)ch].store(localMaxL); + localChannelPeaksR[(size_t)ch].store(localMaxR); + if (localMax > globalLocalMax) + globalLocalMax = localMax; + if (localMaxL > globalLocalMaxL) + globalLocalMaxL = localMaxL; + if (localMaxR > globalLocalMaxR) + globalLocalMaxR = localMaxR; + } + + if (totalInputChannels > 0 && numSamples > 0) + { + const float* dev0 = tempInputBuffer.getReadPointer(0); + const float* dev1 = tempInputBuffer.getNumChannels() > 1 ? tempInputBuffer.getReadPointer(1) : dev0; + float devMax = 0.0f; + float devMaxL = 0.0f; + float devMaxR = 0.0f; + for (int i = 0; i < numSamples; ++i) + { + float aL = std::abs(dev0[i]); + float aR = std::abs(dev1[i]); + float a = juce::jmax(aL, aR); + if (a > devMax) + devMax = a; + if (aL > devMaxL) + devMaxL = aL; + if (aR > devMaxR) + devMaxR = aR; + } + localChannelPeaks[0].store(devMax); + localChannelPeaksL[0].store(devMaxL); + localChannelPeaksR[0].store(devMaxR); + if (devMax > globalLocalMax) + globalLocalMax = devMax; + if (devMaxL > globalLocalMaxL) + globalLocalMaxL = devMaxL; + if (devMaxR > globalLocalMaxR) + globalLocalMaxR = devMaxR; + } + + localPeak.store(globalLocalMax); + localPeakL.store(globalLocalMaxL); + localPeakR.store(globalLocalMaxR); + + const bool reverbOn = fxReverbEnabled.load(); + const bool delayOn = fxDelayEnabled.load(); + const bool fxSendActive = reverbOn || delayOn; + + if (fxTransmitBuffer.getNumSamples() < numSamples) + fxTransmitBuffer.setSize(1, numSamples, false, true, true); + if (fxReturnBuffer.getNumSamples() < numSamples) + fxReturnBuffer.setSize(2, numSamples, false, true, true); + fxTransmitBuffer.clear(); + fxReturnBuffer.clear(); + + if (fxSendActive) + { + if (fxReverbInputBuffer.getNumSamples() < numSamples) + fxReverbInputBuffer.setSize(1, numSamples, false, true, true); + if (fxDelayInputBuffer.getNumSamples() < numSamples) + fxDelayInputBuffer.setSize(1, numSamples, false, true, true); + + fxReverbInputBuffer.clear(); + fxDelayInputBuffer.clear(); + + const int activeLocal = juce::jmin(actualLocal, numLocalChannels.load()); + for (int ch = 0; ch < activeLocal; ++ch) + { + const float reverbSend = localChannelReverbSends[(size_t)ch].load(); + const float delaySend = localChannelDelaySends[(size_t)ch].load(); + if (reverbSend <= 0.0001f && delaySend <= 0.0001f) + continue; + + const float* src = localChannelBuffer.getReadPointer(ch); + float* reverbDst = fxReverbInputBuffer.getWritePointer(0); + float* delayDst = fxDelayInputBuffer.getWritePointer(0); + for (int i = 0; i < numSamples; ++i) + { + const float v = src[i]; + if (reverbSend > 0.0001f) + reverbDst[i] += v * reverbSend; + if (delaySend > 0.0001f) + delayDst[i] += v * delaySend; + } + } + + float* fxSendMono = fxTransmitBuffer.getWritePointer(0); + float* fxLeft = fxReturnBuffer.getWritePointer(0); + float* fxRight = fxReturnBuffer.getWritePointer(1); + + if (reverbOn) + { + juce::Reverb::Parameters params; + params.roomSize = fxReverbRoomSize.load(); + params.damping = fxReverbDamping.load(); + params.width = 1.0f; + params.wetLevel = 1.0f; + params.dryLevel = 0.0f; + params.freezeMode = 0.0f; + fxReverb.setParameters(params); + + const float wetDryMix = fxReverbWetDryMix.load(); + const float earlyAmount = fxReverbEarlyReflections.load(); + const float tailAmount = fxReverbTail.load(); + const float* reverbIn = fxReverbInputBuffer.getReadPointer(0); + float* revMono = fxReverbInputBuffer.getWritePointer(0); + fxReverb.processMono(revMono, numSamples); + for (int i = 0; i < numSamples; ++i) + { + const float early = reverbIn[i] * earlyAmount; + const float tail = revMono[i] * tailAmount; + const float wet = early + tail; + const float mixed = wet * wetDryMix + reverbIn[i] * (1.0f - wetDryMix); + const float out = mixed * 0.8f; + fxLeft[i] += out; + fxRight[i] += out; + fxSendMono[i] += out * 0.5f; + } + } + + if (delayOn) + { + const int delayBufferSamples = fxDelayBuffer.getNumSamples(); + if (delayBufferSamples > 1) + { + const int division = fxDelayDivision.load(); + const double bpm = (double)getBPM(); + double targetDelaySeconds = fxDelayTimeMs.load() / 1000.0; + if (fxDelaySyncToHost.load() && bpm > 1.0) + targetDelaySeconds = (60.0 / bpm) * (4.0 / (double)division); + const int delaySamples = juce::jlimit(1, delayBufferSamples - 1, (int)std::round(targetDelaySeconds * processingSampleRate)); + + const bool pingPong = fxDelayPingPong.load(); + const float feedback = juce::jlimit(0.0f, 0.95f, fxDelayFeedback.load()); + const float wetDryMix = juce::jlimit(0.0f, 1.0f, fxDelayWetDryMix.load()); + const float delayWet = wetDryMix * 0.8f; + + float* delayMemoryL = fxDelayBuffer.getWritePointer(0); + float* delayMemoryR = fxDelayBuffer.getWritePointer(1); + const float* delayIn = fxDelayInputBuffer.getReadPointer(0); + + int writePos = fxDelayWritePosition; + for (int i = 0; i < numSamples; ++i) + { + int readPos = writePos - delaySamples; + if (readPos < 0) + readPos += delayBufferSamples; + + const float readL = delayMemoryL[readPos]; + const float readR = delayMemoryR[readPos]; + const float input = delayIn[i]; + const float wetL = readL * delayWet; + const float wetR = readR * delayWet; + + fxLeft[i] += wetL; + fxRight[i] += wetR; + fxSendMono[i] += (wetL + wetR) * 0.25f; + + if (pingPong) + { + delayMemoryL[writePos] = input + readR * feedback; + delayMemoryR[writePos] = input + readL * feedback; + } + else + { + const float mono = 0.5f * (readL + readR); + delayMemoryL[writePos] = input + mono * feedback; + delayMemoryR[writePos] = input + mono * feedback; + } + + ++writePos; + if (writePos >= delayBufferSamples) + writePos = 0; + } + fxDelayWritePosition = writePos; + } + } + } + + // Determine active encoding mode: + // - multiChanAuto: >1 local channels + VST3 peers → Vorbis mix on ch0, Opus per-ch on ch1..N + // - otherwise: Vorbis only, single channel (mix folded into ch0 above) + const bool multiChanAuto = numLocalChannels.load() > 1 && opusSyncAvailable.load() && isTransmittingLocal(); + + if (!multiChanAuto && actualLocal > 1) + { + float* dst = localChannelBuffer.getWritePointer(0); + for (int ch = 1; ch < actualLocal; ++ch) + { + const float* src = localChannelBuffer.getReadPointer(ch); + for (int s = 0; s < numSamples; ++s) + dst[s] += src[s]; + } + } + + if (!multiChanAuto && fxSendActive) + localChannelBuffer.addFrom(0, 0, fxTransmitBuffer, 0, 0, numSamples); + + if (multiChanAuto) + { + if (localMixBuffer.getNumSamples() < numSamples) + localMixBuffer.setSize(1, numSamples, false, true, true); + float* mix = localMixBuffer.getWritePointer(0); + const float* src0 = localChannelBuffer.getReadPointer(0); + for (int s = 0; s < numSamples; ++s) + mix[s] = src0[s]; + for (int ch = 1; ch < actualLocal; ++ch) + { + const float* src = localChannelBuffer.getReadPointer(ch); + for (int s = 0; s < numSamples; ++s) + mix[s] += src[s]; + } + if (fxSendActive) + localMixBuffer.addFrom(0, 0, fxTransmitBuffer, 0, 0, numSamples); + } + + float* inputs[32] = {}; + int actualInputChannels; + if (multiChanAuto) + { + const int n = juce::jmin(actualLocal, 30); + for (int i = 0; i < n; ++i) + inputs[i] = localChannelBuffer.getWritePointer(i); + inputs[n] = fxTransmitBuffer.getWritePointer(0); + inputs[n + 1] = localMixBuffer.getWritePointer(0); + actualInputChannels = n + 2; + } + else + { + inputs[0] = localChannelBuffer.getWritePointer(0); + actualInputChannels = 1; + } + + float* outputs[32]; + int totalOutputChannels = 0; + int numOutputBuses = getBusCount(false); + for (int bus = 0; bus < numOutputBuses; ++bus) + { + int busChans = getChannelCountOfBus(false, bus); + if (busChans <= 0) + continue; + totalOutputChannels += busChans; + } + + int actualOutputChannels = juce::jmin(totalOutputChannels, 32); + + int outputChanIndex = 0; + for (int bus = 0; bus < numOutputBuses; ++bus) + { + auto& busBuffer = getBusBuffer(buffer, false, bus); + int busChans = busBuffer.getNumChannels(); + if (busChans <= 0) + continue; + for (int ch = 0; ch < busChans; ++ch) + { + if (outputChanIndex < actualOutputChannels) + { + outputs[outputChanIndex] = busBuffer.getWritePointer(ch); + ++outputChanIndex; + } + } + } + + for (int bus = 0; bus < numOutputBuses; ++bus) + { + auto& busBuffer = getBusBuffer(buffer, false, bus); + int busChans = busBuffer.getNumChannels(); + if (busChans <= 0) + continue; + for (int ch = 0; ch < busChans; ++ch) + busBuffer.clear(ch, 0, numSamples); + } + + bool gateForSync = false; + bool runMonitorOnly = false; + if (isSyncToHostEnabled()) + { + bool hostValid = gotHostPosition; + bool hostPlaying = hostValid && hostInfoAtBlock.isPlaying; + + bool prev = hostWasPlaying.load(); + if (!hostValid || !hostPlaying) + { + hostWasPlaying.store(false); + syncWaitForInterval.store(false); + syncTargetInterval.store(-1); + syncDisplayPositionOffset.store(0); + } + else if (!prev) + { + hostWasPlaying.store(true); + ninjamClient.ResetTransportPhase(); + ninjamClient.ResetLocalBroadcastState(); + syncWaitForInterval.store(false); + syncTargetInterval.store(-1); + syncDisplayIntervalOffset.store(intervalIndex.load()); + syncDisplayPositionOffset.store(0); + } + + if (!hostValid || !hostPlaying) + { + gateForSync = true; + } + runMonitorOnly = gateForSync; + } + else + { + hostWasPlaying.store(false); + syncWaitForInterval.store(false); + syncTargetInterval.store(-1); + syncDisplayIntervalOffset.store(0); + syncDisplayPositionOffset.store(0); + } + + const bool monitorEnabled = localMonitorEnabled.load(); + const bool transmitEnabled = isTransmittingLocal(); + const bool allowEngineLocalInput = monitorEnabled || transmitEnabled; + float** engineInputs = allowEngineLocalInput ? inputs : nullptr; + int engineInputChannels = allowEngineLocalInput ? actualInputChannels : 0; + ninjamClient.AudioProc(engineInputs, engineInputChannels, outputs, actualOutputChannels, numSamples, (int)getSampleRate(), runMonitorOnly); + + if (monitorEnabled && !transmitEnabled) + { + int numOutputBusesOut = getBusCount(false); + if (numOutputBusesOut > 0) + { + auto& mainBus = getBusBuffer(buffer, false, 0); + int outChans = mainBus.getNumChannels(); + int numLocal = juce::jmin(numLocalChannels.load(), maxLocalChannels); + for (int ch = 0; ch < numLocal; ++ch) + { + const int outLeft = ch * 2; + const int outRight = outLeft + 1; + if (outChans <= 0) + break; + const int sourceLeft = monitorSourceLeft[(size_t)ch]; + const int sourceRight = monitorSourceRight[(size_t)ch]; + const float gain = localChannelGains[(size_t)ch].load(); + if (sourceLeft < 0 || sourceLeft >= totalInputChannels) + continue; + + if (monitorStereo[(size_t)ch] && sourceRight >= 0 && sourceRight < totalInputChannels) + { + if (outLeft < outChans) + mainBus.addFrom(outLeft, 0, tempInputBuffer, sourceLeft, 0, numSamples, gain); + if (outRight < outChans) + mainBus.addFrom(outRight, 0, tempInputBuffer, sourceRight, 0, numSamples, gain); + else if (outLeft == 0 && outChans == 1) + { + mainBus.addFrom(0, 0, tempInputBuffer, sourceLeft, 0, numSamples, gain * 0.5f); + mainBus.addFrom(0, 0, tempInputBuffer, sourceRight, 0, numSamples, gain * 0.5f); + } + } + else + { + if (outLeft < outChans) + mainBus.addFrom(outLeft, 0, tempInputBuffer, sourceLeft, 0, numSamples, gain); + if (outRight < outChans) + mainBus.addFrom(outRight, 0, tempInputBuffer, sourceLeft, 0, numSamples, gain); + else if (outLeft == 0 && outChans == 1) + mainBus.addFrom(0, 0, tempInputBuffer, sourceLeft, 0, numSamples, gain); + } + } + } + } + + int mtcPos = 0; + int mtcLength = 0; + ninjamClient.GetPosition(&mtcPos, &mtcLength); + emitMidiTimecode(midiMessages, numSamples, mtcPos, mtcLength); + + int numOutputBusesOut = getBusCount(false); + + if (gateForSync) + { + for (int bus = 0; bus < numOutputBusesOut; ++bus) + { + auto& busBuffer = getBusBuffer(buffer, false, bus); + int busChans = busBuffer.getNumChannels(); + if (busChans <= 0) + continue; + for (int ch = 0; ch < busChans; ++ch) + busBuffer.clear(ch, 0, numSamples); + } + masterPeak.store(0.0f); + masterPeakL.store(0.0f); + masterPeakR.store(0.0f); + return; + } + + if (numOutputBusesOut > 0 && fxSendActive) + { + auto& mainBus = getBusBuffer(buffer, false, 0); + const int mainChans = mainBus.getNumChannels(); + if (mainChans >= 2) + { + mainBus.addFrom(0, 0, fxReturnBuffer, 0, 0, numSamples); + mainBus.addFrom(1, 0, fxReturnBuffer, 1, 0, numSamples); + } + else if (mainChans == 1) + { + const float* l = fxReturnBuffer.getReadPointer(0); + const float* r = fxReturnBuffer.getReadPointer(1); + float* monoOut = mainBus.getWritePointer(0); + for (int i = 0; i < numSamples; ++i) + monoOut[i] += 0.5f * (l[i] + r[i]); + } + } + + float masterGain = masterOutputGain.load(); + if (masterGain != 1.0f) + { + for (int bus = 0; bus < numOutputBusesOut; ++bus) + { + auto& busBuffer = getBusBuffer(buffer, false, bus); + int busChans = busBuffer.getNumChannels(); + for (int ch = 0; ch < busChans; ++ch) + busBuffer.applyGain(ch, 0, numSamples, masterGain); + } + } + + bool limiter = dspLimiterEnabled.load() && (limiterThresholdDb.load() < 0.0f); + if (limiter) + { + juce::dsp::AudioBlock block(buffer); + juce::dsp::ProcessContextReplacing context(block); + masterLimiter.process(context); + } + + bool softClip = softLimiterEnabled.load(); + float maxSample = 0.0f; + float maxSampleL = 0.0f; + float maxSampleR = 0.0f; + for (int bus = 0; bus < numOutputBusesOut; ++bus) + { + auto& busBuffer = getBusBuffer(buffer, false, bus); + int busChans = busBuffer.getNumChannels(); + for (int ch = 0; ch < busChans; ++ch) + { + float* data = busBuffer.getWritePointer(ch); + for (int i = 0; i < numSamples; ++i) + { + float v = data[i]; + if (softClip) + v = softClipSample(v); + float a = std::abs(v); + if (a > maxSample) + maxSample = a; + if (bus == 0 && ch == 0 && a > maxSampleL) + maxSampleL = a; + if (bus == 0 && ch == 1 && a > maxSampleR) + maxSampleR = a; + data[i] = v; + } + } + } + if (numOutputBusesOut > 0) + { + auto& mainBus = getBusBuffer(buffer, false, 0); + if (mainBus.getNumChannels() == 1) + maxSampleR = maxSampleL; + else if (mainBus.getNumChannels() == 0) + { + maxSampleL = maxSample; + maxSampleR = maxSample; + } + } + else + { + maxSampleL = maxSample; + maxSampleR = maxSample; + } + masterPeak.store(maxSample); + masterPeakL.store(maxSampleL); + masterPeakR.store(maxSampleR); +} + +// Called from NJClient::on_new_interval() in the AUDIO THREAD at sample-accurate timing. +void NinjamVst3AudioProcessor::NewIntervalCallback_cb(void* /*userData*/, NJClient* /*inst*/) +{ +} + +void NinjamVst3AudioProcessor::IntervalChunkCallback_cb(void* /*userData*/, NJClient* /*inst*/, + const char* /*username*/, int /*chidx*/, unsigned int /*fourcc*/, + const unsigned char* /*guid*/, const void* /*data*/, int /*dataLen*/, int /*flags*/) +{ +} + +void NinjamVst3AudioProcessor::IntervalMediaItem_Callback(void* userData, NJClient* /*inst*/, + const char* username, int /*chidx*/, unsigned int fourcc, + const unsigned char* /*guid*/, const void* data, int dataLen) +{ + if (!username || !data || dataLen <= 0) return; + if (fourcc == kSyncSignalFourcc) + { + auto* self = static_cast(userData); + const juce::String sender = juce::String::fromUTF8(username); + const juce::String msg = juce::String::fromUTF8(static_cast(data), dataLen); + const juce::var parsed = juce::JSON::parse(msg); + if (auto* obj = parsed.getDynamicObject()) + { + const juce::String type = obj->getProperty("sig").toString(); + const juce::String payload = obj->getProperty("data").toString(); + if (type.isNotEmpty() && payload.isNotEmpty()) + self->processSyncSignal(sender, type, payload); + } + return; + } + if (fourcc != kOpusSyncFourcc) return; + auto* self = static_cast(userData); + const juce::String sender = juce::String::fromUTF8(username); + const juce::String payload = juce::String::fromUTF8(static_cast(data), dataLen); + vlogStr("IntervalMediaItem opusSyncFourcc from=" + sender); + + juce::var parsed = juce::JSON::parse(payload); + bool supportsOpus = false; + bool multiChanEnabled = false; + int peerNumChannels = 1; + juce::String userId = normaliseOpusPeerId(sender); + juce::String clientId; + juce::String appFamily; + int handshakeVersion = 0; + juce::String runtimeFormat; + juce::String pluginVersion; + if (auto* obj = parsed.getDynamicObject()) + { + const juce::String supports = obj->getProperty("supportsOpus").toString(); + supportsOpus = supports == "1" || supports.equalsIgnoreCase("true"); + const juce::String enabledStr = obj->getProperty("enabled").toString(); + multiChanEnabled = enabledStr == "1" || enabledStr.equalsIgnoreCase("true"); + const juce::var numChVar = obj->getProperty("numChannels"); + if (!numChVar.isVoid()) peerNumChannels = juce::jmax(1, (int)numChVar); + juce::String payloadUserId = obj->getProperty("userId").toString(); + if (payloadUserId.isNotEmpty()) + userId = normaliseOpusPeerId(payloadUserId); + clientId = obj->getProperty("clientId").toString().trim(); + appFamily = obj->getProperty("appFamily").toString().trim(); + handshakeVersion = (int)obj->getProperty("handshakeVersion"); + runtimeFormat = obj->getProperty("runtimeFormat").toString().trim(); + pluginVersion = obj->getProperty("pluginVersion").toString().trim(); + } + else { vlogStr("[MCExit1] JSON parse failed from=" + sender + " payloadLen=" + juce::String((int)payload.length()) + " first100=" + payload.substring(0, 100)); return; } + + const bool isLocalClient = clientId.isNotEmpty() ? (clientId == self->opusSyncInstanceId) + : (userId == normaliseOpusPeerId(self->currentUser)); + const bool sameAppFamily = appFamily.isEmpty() || appFamily == opusSyncAppFamily; + const bool compatibleHandshake = handshakeVersion <= 0 || handshakeVersion == opusSyncHandshakeVersion; + const juce::String peerKey = clientId.isNotEmpty() ? clientId : userId; + vlogStr("[MCGuard] peerKey='" + peerKey + "' userId='" + userId + "' isLocal=" + juce::String(isLocalClient ? 1 : 0) + " sameFamily=" + juce::String(sameAppFamily ? 1 : 0) + " compatHS=" + juce::String(compatibleHandshake ? 1 : 0) + " supportsOpus=" + juce::String(supportsOpus ? 1 : 0)); + if (peerKey.isEmpty() || userId.isEmpty() || isLocalClient) return; + + bool recognizedNow = false; + juce::String recognizedMessage; + { + juce::ScopedLock lock(self->opusSyncPeerLock); + if (supportsOpus && sameAppFamily && compatibleHandshake) + { + const bool wasKnown = self->opusSyncPeers.find(peerKey) != self->opusSyncPeers.end(); + auto& peer = self->opusSyncPeers[peerKey]; + const bool wasMultiChan = peer.multiChanEnabled; + peer.userId = userId; + peer.supportsOpus = true; + peer.multiChanEnabled = multiChanEnabled; + peer.numChannels = peerNumChannels; + peer.appFamily = appFamily; + peer.handshakeVersion = handshakeVersion; + peer.runtimeFormat = runtimeFormat; + peer.pluginVersion = pluginVersion; + peer.lastSeenMs = juce::Time::getMillisecondCounterHiRes(); + const juce::String peerLabel = sender.isNotEmpty() ? sender : userId; + if (!wasKnown) + { + juce::String peerInfo = peer.runtimeFormat; + if (peer.pluginVersion.isNotEmpty()) + { + if (peerInfo.isNotEmpty()) peerInfo << " "; + peerInfo << peer.pluginVersion; + } + recognizedMessage = "Multi Client Detected: " + peerLabel; + if (peerInfo.isNotEmpty()) recognizedMessage << " (" << peerInfo << ")"; + if (multiChanEnabled) recognizedMessage << " [MultiChannel ON]"; + recognizedNow = true; + } + else if (multiChanEnabled && !wasMultiChan) + { + recognizedMessage = "MultiChannel Detected: " + peerLabel; + recognizedNow = true; + } + else if (!multiChanEnabled && wasMultiChan) + { + recognizedMessage = "MultiChannel Off: " + peerLabel; + recognizedNow = true; + } + } + else + self->opusSyncPeers.erase(peerKey); + } + if (recognizedNow) + { + juce::ScopedLock lock(self->chatLock); + self->chatHistory.add(recognizedMessage); + self->chatSenders.add(""); + if (self->chatHistory.size() > 100) + { + self->chatHistory.removeRange(0, self->chatHistory.size() - 100); + self->chatSenders.removeRange(0, juce::jmax(0, self->chatSenders.size() - 100)); + } + } + vlogStr("[MCRefresh] IntervalMediaItem_Callback calling refresh. peerKey='" + peerKey + "' userId='" + userId + "' multiChanEnabled=" + juce::String(multiChanEnabled ? 1 : 0) + " nCh=" + juce::String(peerNumChannels)); + self->refreshOpusSyncAvailabilityFromUsers(); +} + +void NinjamVst3AudioProcessor::setSyncToHost(bool shouldSync) +{ + syncToHost = shouldSync; + hostWasPlaying.store(false); + syncWaitForInterval.store(false); + syncTargetInterval.store(-1); + syncDisplayIntervalOffset.store(intervalIndex.load()); + int pos = 0; + int length = 0; + ninjamClient.GetPosition(&pos, &length); + syncDisplayPositionOffset.store(length > 0 ? pos : 0); +} + +bool NinjamVst3AudioProcessor::isSyncToHostEnabled() const +{ + return syncToHost; +} + +bool NinjamVst3AudioProcessor::getHostPosition(juce::AudioPlayHead::CurrentPositionInfo& info) const +{ + const juce::ScopedLock lock(transportLock); + info = lastHostPosition; + return true; +} + +void NinjamVst3AudioProcessor::setMtcOutputEnabled(bool shouldEnable) +{ + mtcOutputEnabled.store(shouldEnable); +} + +bool NinjamVst3AudioProcessor::isMtcOutputEnabled() const +{ + return mtcOutputEnabled.load(); +} + +void NinjamVst3AudioProcessor::setMtcFrameRate(int fps) +{ + int mapped = 30; + if (fps == 24 || fps == 25 || fps == 30 || fps == 2997) + mapped = fps; + mtcFrameRateFps.store(mapped); +} + +int NinjamVst3AudioProcessor::getMtcFrameRate() const +{ + return mtcFrameRateFps.load(); +} + +bool NinjamVst3AudioProcessor::isStandaloneInstance() const +{ + return isStandaloneWrapper(); +} + +std::vector NinjamVst3AudioProcessor::popPendingMidiControllerEvents() +{ + std::vector events; + const juce::SpinLock::ScopedLockType midiQueueLock(midiEventQueueLock); + events.swap(pendingMidiControllerEvents); + return events; +} + +void NinjamVst3AudioProcessor::setMidiRelayTarget(const juce::String& targetUser) +{ + const juce::ScopedLock lock(midiRelayTargetLock); + midiRelayTarget = targetUser.isNotEmpty() ? targetUser : "*"; +} + +juce::String NinjamVst3AudioProcessor::getMidiRelayTarget() const +{ + const juce::ScopedLock lock(midiRelayTargetLock); + return midiRelayTarget.isNotEmpty() ? midiRelayTarget : "*"; +} + +void NinjamVst3AudioProcessor::setMidiLearnStateJson(const juce::String& json) +{ + const juce::ScopedLock lock(learnStateLock); + midiLearnStateJson = json; +} + +juce::String NinjamVst3AudioProcessor::getMidiLearnStateJson() const +{ + const juce::ScopedLock lock(learnStateLock); + return midiLearnStateJson; +} + +void NinjamVst3AudioProcessor::setOscLearnStateJson(const juce::String& json) +{ + const juce::ScopedLock lock(learnStateLock); + oscLearnStateJson = json; +} + +juce::String NinjamVst3AudioProcessor::getOscLearnStateJson() const +{ + const juce::ScopedLock lock(learnStateLock); + return oscLearnStateJson; +} + +void NinjamVst3AudioProcessor::setMidiLearnInputDeviceId(const juce::String& deviceId) +{ + const juce::ScopedLock lock(learnStateLock); + midiLearnInputDeviceId = deviceId; +} + +juce::String NinjamVst3AudioProcessor::getMidiLearnInputDeviceId() const +{ + const juce::ScopedLock lock(learnStateLock); + return midiLearnInputDeviceId; +} + +void NinjamVst3AudioProcessor::setMidiRelayInputDeviceId(const juce::String& deviceId) +{ + const juce::ScopedLock lock(learnStateLock); + midiRelayInputDeviceId = deviceId; +} + +juce::String NinjamVst3AudioProcessor::getMidiRelayInputDeviceId() const +{ + const juce::ScopedLock lock(learnStateLock); + return midiRelayInputDeviceId; +} + +void NinjamVst3AudioProcessor::enqueueExternalMidiControllerEvent(const MidiControllerEvent& event, bool forLearn, bool forRelay) +{ + if (forLearn) + { + const juce::SpinLock::ScopedLockType midiQueueLock(midiEventQueueLock); + pendingMidiControllerEvents.push_back(event); + if (pendingMidiControllerEvents.size() > 512) + pendingMidiControllerEvents.erase(pendingMidiControllerEvents.begin(), pendingMidiControllerEvents.begin() + (long long)(pendingMidiControllerEvents.size() - 512)); + } + + if (forRelay) + { + const juce::SpinLock::ScopedLockType relayQueueLock(outboundMidiRelayQueueLock); + pendingOutboundMidiRelayEvents.push_back(event); + if (pendingOutboundMidiRelayEvents.size() > 512) + pendingOutboundMidiRelayEvents.erase(pendingOutboundMidiRelayEvents.begin(), pendingOutboundMidiRelayEvents.begin() + (long long)(pendingOutboundMidiRelayEvents.size() - 512)); + } +} + +void NinjamVst3AudioProcessor::flushOutboundMidiRelayEvents() +{ + std::vector events; + { + const juce::SpinLock::ScopedLockType lock(outboundMidiRelayQueueLock); + events.swap(pendingOutboundMidiRelayEvents); + } + + if (events.empty()) + return; + + const juce::String target = getMidiRelayTarget(); + const juce::String userId = currentUser; + for (const auto& event : events) + { + juce::DynamicObject::Ptr obj = new juce::DynamicObject(); + obj->setProperty("userId", userId); + obj->setProperty("isController", event.isController); + obj->setProperty("midiChannel", event.midiChannel); + obj->setProperty("number", event.number); + obj->setProperty("value", event.value); + obj->setProperty("normalized", event.normalized); + obj->setProperty("isNoteOn", event.isNoteOn); + sendSideSignal(target, "midiRelay", juce::JSON::toString(juce::var(obj.get()))); + } +} + +void NinjamVst3AudioProcessor::injectInboundMidiRelayEvents(juce::MidiBuffer& midiMessages) +{ + std::vector events; + { + const juce::SpinLock::ScopedLockType lock(inboundMidiRelayQueueLock); + events.swap(pendingInboundMidiRelayEvents); + } + + for (const auto& event : events) + { + if (event.isController) + midiMessages.addEvent(juce::MidiMessage::controllerEvent(event.midiChannel, event.number, event.value), 0); + else if (event.isNoteOn) + midiMessages.addEvent(juce::MidiMessage::noteOn(event.midiChannel, event.number, (juce::uint8)event.value), 0); + else + midiMessages.addEvent(juce::MidiMessage::noteOff(event.midiChannel, event.number), 0); + } +} + +bool NinjamVst3AudioProcessor::isStandaloneWrapper() const +{ + return wrapperType == juce::AudioProcessor::wrapperType_Standalone; +} + +int NinjamVst3AudioProcessor::getDisplayIntervalIndex() const +{ + const int absolute = intervalIndex.load(); + if (!isSyncToHostEnabled()) + return absolute; + if (!hostWasPlaying.load()) + return 0; + const int base = syncDisplayIntervalOffset.load(); + return juce::jmax(0, absolute - base); +} + +void NinjamVst3AudioProcessor::emitMidiTimecode(juce::MidiBuffer& midiMessages, int numSamples, int pos, int length) +{ + const double sampleRate = getSampleRate(); + if (sampleRate <= 1.0 || numSamples <= 0) + return; + + const bool mtcEnabled = isMtcOutputEnabled(); + const int fpsSetting = getMtcFrameRate(); + const double fps = fpsSetting == 2997 ? 29.97 : (double)fpsSetting; + const juce::uint8 rateCode = fpsSetting == 24 ? 0x00 : fpsSetting == 25 ? 0x01 : fpsSetting == 2997 ? 0x02 : 0x03; + + const bool waitingForStart = isSyncToHostEnabled() && (!hostWasPlaying.load() || syncWaitForInterval.load()); + const bool shouldRun = (length > 0) && !waitingForStart; + + auto sendLocate = [&midiMessages, rateCode](int sampleOffset, int hours, int minutes, int seconds, int frames) + { + const juce::uint8 hr = (juce::uint8)(((rateCode & 0x03u) << 5) | ((juce::uint8)hours & 0x1Fu)); + const juce::uint8 sysex[] = { 0xF0, 0x7F, 0x7F, 0x01, 0x01, + hr, + (juce::uint8)minutes, + (juce::uint8)seconds, + (juce::uint8)frames, + 0xF7 }; + midiMessages.addEvent(juce::MidiMessage::createSysExMessage(sysex, (int)sizeof(sysex)), sampleOffset); + }; + + auto getTimecode = [sampleRate, fps](long long timelineSamples) + { + if (timelineSamples < 0) + timelineSamples = 0; + const double seconds = (double)timelineSamples / sampleRate; + const long long totalFrames = (long long)std::floor(seconds * fps); + const int frame = (int)(totalFrames % (long long)std::round(fps)); + const long long totalSeconds = (long long)std::floor((double)totalFrames / fps); + const int second = (int)(totalSeconds % 60); + const int minute = (int)((totalSeconds / 60) % 60); + const int hour = (int)((totalSeconds / 3600) % 24); + return std::array { hour, minute, second, frame }; + }; + + if (!mtcEnabled) + { + if (mtcWasRunning) + { + midiMessages.addEvent(juce::MidiMessage::midiStop(), 0); + sendLocate(0, 0, 0, 0, 0); + } + mtcWasRunning = false; + mtcSamplesUntilNextQuarterFrame = 0.0; + mtcQuarterFramePiece = 0; + return; + } + + if (mtcWasRunning && !shouldRun) + { + midiMessages.addEvent(juce::MidiMessage::midiStop(), 0); + sendLocate(0, 0, 0, 0, 0); + mtcSamplesUntilNextQuarterFrame = 0.0; + mtcQuarterFramePiece = 0; + } + + int displayInterval = getDisplayIntervalIndex(); + int timelinePos = 0; + if (length > 0) + { + if (!waitingForStart) + timelinePos = juce::jlimit(0, juce::jmax(0, length - 1), pos); + } + long long blockStartSamples = (long long)displayInterval * (long long)juce::jmax(0, length) + (long long)timelinePos; + + if (!mtcWasRunning && shouldRun) + { + const auto tc = getTimecode(blockStartSamples); + sendLocate(0, tc[0], tc[1], tc[2], tc[3]); + midiMessages.addEvent(juce::MidiMessage::midiStart(), 0); + mtcSamplesUntilNextQuarterFrame = 0.0; + mtcQuarterFramePiece = 0; + } + + mtcWasRunning = shouldRun; + if (!shouldRun) + return; + + const double qfPerSecond = fps * 4.0; + const double samplesPerQuarterFrame = sampleRate / qfPerSecond; + double sampleCursor = mtcSamplesUntilNextQuarterFrame; + if (sampleCursor <= 0.0) + sampleCursor = samplesPerQuarterFrame; + + while (sampleCursor < (double)numSamples) + { + const int eventSample = juce::jlimit(0, numSamples - 1, (int)std::floor(sampleCursor)); + const long long eventTimelineSamples = blockStartSamples + (long long)eventSample; + const auto tc = getTimecode(eventTimelineSamples); + + const int piece = mtcQuarterFramePiece & 0x07; + int value = 0; + switch (piece) + { + case 0: value = tc[3] & 0x0F; break; + case 1: value = (tc[3] >> 4) & 0x01; break; + case 2: value = tc[2] & 0x0F; break; + case 3: value = (tc[2] >> 4) & 0x03; break; + case 4: value = tc[1] & 0x0F; break; + case 5: value = (tc[1] >> 4) & 0x03; break; + case 6: value = tc[0] & 0x0F; break; + case 7: value = ((tc[0] >> 4) & 0x01) | (0x03 << 1); break; + default: break; + } + + const juce::uint8 data = (juce::uint8)(((piece & 0x07) << 4) | (value & 0x0F)); + midiMessages.addEvent(juce::MidiMessage(0xF1, data), eventSample); + mtcQuarterFramePiece = (piece + 1) & 0x07; + sampleCursor += samplesPerQuarterFrame; + } + + mtcSamplesUntilNextQuarterFrame = sampleCursor - (double)numSamples; +} + +bool NinjamVst3AudioProcessor::hasEditor() const +{ + return true; +} + +juce::AudioProcessorEditor* NinjamVst3AudioProcessor::createEditor() +{ + return new NinjamVst3AudioProcessorEditor (*this); +} + +void NinjamVst3AudioProcessor::getStateInformation (juce::MemoryBlock& destData) +{ + juce::ValueTree state("NINJAM_STATE"); + state.setProperty("midiRelayTarget", getMidiRelayTarget(), nullptr); + state.setProperty("midiLearnStateJson", getMidiLearnStateJson(), nullptr); + state.setProperty("oscLearnStateJson", getOscLearnStateJson(), nullptr); + state.setProperty("midiLearnInputDeviceId", getMidiLearnInputDeviceId(), nullptr); + state.setProperty("midiRelayInputDeviceId", getMidiRelayInputDeviceId(), nullptr); + state.setProperty("fxReverbWetDryMix", (double)getFxReverbWetDryMix(), nullptr); + state.setProperty("fxDelayWetDryMix", (double)getFxDelayWetDryMix(), nullptr); + for (int channel = 0; channel < maxLocalChannels; ++channel) + state.setProperty("localInput" + juce::String(channel), getLocalChannelInput(channel), nullptr); + + if (auto xml = state.createXml()) + copyXmlToBinary(*xml, destData); +} + +void NinjamVst3AudioProcessor::setStateInformation (const void* data, int sizeInBytes) +{ + std::unique_ptr xmlState(getXmlFromBinary(data, sizeInBytes)); + if (xmlState == nullptr) + return; + + const juce::ValueTree state = juce::ValueTree::fromXml(*xmlState); + if (!state.isValid()) + return; + + setMidiRelayTarget(state.getProperty("midiRelayTarget", "*").toString()); + setMidiLearnStateJson(state.getProperty("midiLearnStateJson", "").toString()); + setOscLearnStateJson(state.getProperty("oscLearnStateJson", "").toString()); + setMidiLearnInputDeviceId(state.getProperty("midiLearnInputDeviceId", "").toString()); + setMidiRelayInputDeviceId(state.getProperty("midiRelayInputDeviceId", "").toString()); + setFxReverbWetDryMix((float)(double)state.getProperty("fxReverbWetDryMix", 1.0)); + setFxDelayWetDryMix((float)(double)state.getProperty("fxDelayWetDryMix", 1.0)); + for (int channel = 0; channel < maxLocalChannels; ++channel) + setLocalChannelInput(channel, (int)state.getProperty("localInput" + juce::String(channel), -1)); +} + +void NinjamVst3AudioProcessor::timerCallback() +{ + int loopCount = 0; + while (!ninjamClient.Run() && loopCount < 50) + { + loopCount++; + } + + int status = ninjamClient.GetStatus(); + if (status != lastStatus) + { + if (status == NJClient::NJC_STATUS_CANTCONNECT || status == NJClient::NJC_STATUS_INVALIDAUTH) + { + juce::String err = juce::String::fromUTF8(ninjamClient.GetErrorStr()); + juce::Logger::writeToLog("NINJAM Error (" + juce::String(status) + "): " + err); + } + else if (status == NJClient::NJC_STATUS_OK) + { + juce::Logger::writeToLog("NINJAM Connected Successfully"); + opusSyncServerSupported.store(false); + juce::Logger::writeToLog("Sending VIDEO_CAP 1"); + ninjamClient.ChatMessage_Send("VIDEO_CAP", "1", nullptr, nullptr, nullptr); + { + const juce::ScopedLock lock(opusSyncPeerLock); + opusSyncPeers.clear(); + } + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + lastAnnouncedRemoteIntervalByUser.clear(); + localIntervalStartMsByInterval.clear(); + pendingRemoteIntervalStartsByUser.clear(); + remoteTransportRttMsByUser.clear(); + pendingTransportProbeSentMsById.clear(); + remoteLatencyLastAppliedIntervalByUser.clear(); + remoteLatencyAverageByUser.clear(); + remoteLatencyFirmDelayMsByUser.clear(); + } + opusSyncAvailable.store(false); + opusSyncHasLegacyClients.store(false); + lastOpusSupportBroadcastMs = 0.0; + lastTransportProbeBroadcastMs = 0.0; + if (!isSyncToHostEnabled()) + { + syncWaitForInterval.store(false); + syncTargetInterval.store(-1); + intervalIndex.store(0); + lastIntervalPos.store(0); + } + lastBroadcastIntervalTag.store(-1); + setIntervalSyncStatusText({}); + syncLocalIntervalChannelConfig(); + } + else if (lastStatus == NJClient::NJC_STATUS_OK) + { + opusSyncServerSupported.store(false); + { + const juce::ScopedLock lock(opusSyncPeerLock); + opusSyncPeers.clear(); + } + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + lastAnnouncedRemoteIntervalByUser.clear(); + localIntervalStartMsByInterval.clear(); + pendingRemoteIntervalStartsByUser.clear(); + remoteTransportRttMsByUser.clear(); + pendingTransportProbeSentMsById.clear(); + remoteLatencyLastAppliedIntervalByUser.clear(); + remoteLatencyAverageByUser.clear(); + remoteLatencyFirmDelayMsByUser.clear(); + } + opusSyncAvailable.store(false); + opusSyncHasLegacyClients.store(false); + setIntervalSyncStatusText({}); + lastBroadcastIntervalTag.store(-1); + applyCodecPreference(); + } + lastStatus = status; + } + + if (status == NJClient::NJC_STATUS_OK) + { + refreshOpusSyncAvailabilityFromUsers(); + const double nowMs = juce::Time::getMillisecondCounterHiRes(); + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + const int displayInterval = getDisplayIntervalIndex(); + if (localIntervalStartMsByInterval.find(displayInterval) == localIntervalStartMsByInterval.end()) + localIntervalStartMsByInterval[displayInterval] = nowMs; + } + if (nowMs - lastOpusSupportBroadcastMs >= 1500.0) + { + broadcastOpusSyncSupport(); + lastOpusSupportBroadcastMs = nowMs; + } + if (nowMs - lastTransportProbeBroadcastMs >= 5000.0) + { + broadcastTransportProbe(); + lastTransportProbeBroadcastMs = nowMs; + } + + flushOutboundMidiRelayEvents(); + + const int displayInterval = getDisplayIntervalIndex(); + if (lastBroadcastIntervalTag.load() != displayInterval) + { + broadcastIntervalSyncTag(); + lastBroadcastIntervalTag.store(displayInterval); + } + } + + int pos = 0; + int length = 0; + ninjamClient.GetPosition(&pos, &length); + if (length > 0) + { + int last = lastIntervalPos.load(); + if (pos < last) + { + intervalIndex.fetch_add(1); + const int localAbsoluteInterval = intervalIndex.load(); + const int localDisplayInterval = getDisplayIntervalIndex(); + const long long localIntervalStartSampleCount = intervalSyncSampleCounter.load(std::memory_order_relaxed); + const double localIntervalStartMs = juce::Time::getMillisecondCounterHiRes(); + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + localIntervalStartMsByInterval[localDisplayInterval] = localIntervalStartMs; + const int minIntervalToKeep = localDisplayInterval - 64; + for (auto it = localIntervalStartMsByInterval.begin(); it != localIntervalStartMsByInterval.end();) + { + if (it->first < minIntervalToKeep) + it = localIntervalStartMsByInterval.erase(it); + else + ++it; + } + } + if (status == NJClient::NJC_STATUS_OK) + { + const int localBpi = juce::jmax(1, getBPI()); + const double localBpm = juce::jmax(1.0, (double)getBPM()); + const double intervalDurationMs = (60.0 / localBpm) * (double)localBpi * 1000.0; + for (;;) + { + juce::String senderKey; + PendingRemoteIntervalStart pending; + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + if (pendingRemoteIntervalStartsByUser.empty()) + break; + for (auto staleIt = pendingRemoteIntervalStartsByUser.begin(); staleIt != pendingRemoteIntervalStartsByUser.end();) + { + const int targetAbsolute = staleIt->second.remoteIntervalAbsolute; + const int targetDisplay = staleIt->second.remoteInterval; + bool isStale = false; + if (targetAbsolute >= 0) + isStale = localAbsoluteInterval > (targetAbsolute + 2); + else if (targetDisplay >= 0) + isStale = localDisplayInterval > (targetDisplay + 2); + else + isStale = true; + if (isStale) + staleIt = pendingRemoteIntervalStartsByUser.erase(staleIt); + else + ++staleIt; + } + auto chosenIt = pendingRemoteIntervalStartsByUser.end(); + for (auto it = pendingRemoteIntervalStartsByUser.begin(); it != pendingRemoteIntervalStartsByUser.end(); ++it) + { + const bool absoluteMatch = it->second.remoteIntervalAbsolute >= 0 && it->second.remoteIntervalAbsolute == localAbsoluteInterval; + const bool displayMatch = it->second.remoteIntervalAbsolute < 0 && it->second.remoteInterval >= 0 && it->second.remoteInterval == localDisplayInterval; + if (absoluteMatch || displayMatch) + { + chosenIt = it; + break; + } + } + if (chosenIt == pendingRemoteIntervalStartsByUser.end()) + break; + senderKey = chosenIt->first; + pending = chosenIt->second; + pendingRemoteIntervalStartsByUser.erase(chosenIt); + } + if (pending.receivedSampleCount < 0) + continue; + const long long elapsedSamples = localIntervalStartSampleCount - pending.receivedSampleCount; + if (elapsedSamples < 0) + continue; + const double sampleRate = juce::jmax(1.0, getSampleRate()); + const double elapsedToNextLocalBpi1Ms = ((double)elapsedSamples / sampleRate) * 1000.0; + const double outlierLimitMs = intervalDurationMs * 2.0; + if (!std::isfinite(elapsedToNextLocalBpi1Ms) || elapsedToNextLocalBpi1Ms < 0.0 || elapsedToNextLocalBpi1Ms > outlierLimitMs) + continue; + const int elapsedMs = (int)std::llround(juce::jlimit(0.0, intervalDurationMs, elapsedToNextLocalBpi1Ms)); + int averageMs = -1; + int firmAverageMs = -1; + { + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + auto& avgState = remoteLatencyAverageByUser[senderKey]; + avgState.lastMeasurementMs = (double)elapsedMs; + bool includeInAverage = true; + if (avgState.sampleCount >= 3) + { + const double baselineMs = avgState.firmAverageMs > 0.0 ? avgState.firmAverageMs : avgState.averageMs; + const double deltaMs = std::abs((double)elapsedMs - baselineMs); + const double spikeThresholdMs = juce::jlimit(5.0, 20.0, baselineMs * 0.30 + 2.0); + if (deltaMs > spikeThresholdMs) + includeInAverage = false; + } + if (includeInAverage) + { + avgState.sampleCount += 1; + avgState.sumMs += (double)elapsedMs; + avgState.averageMs = avgState.sumMs / (double)juce::jmax(1, avgState.sampleCount); + if (avgState.sampleCount == 1) + avgState.firmAverageMs = (double)elapsedMs; + else + avgState.firmAverageMs = (avgState.firmAverageMs * 0.88) + ((double)elapsedMs * 0.12); + } + if (avgState.sampleCount >= 3) + { + averageMs = juce::jmax(0, (int)std::llround(avgState.averageMs)); + firmAverageMs = juce::jmax(0, (int)std::llround(avgState.firmAverageMs)); + } + else if (avgState.lastMeasurementMs >= 0.0) + { + averageMs = juce::jmax(0, (int)std::llround(avgState.lastMeasurementMs)); + } + } + if (firmAverageMs >= 0 || averageMs >= 0) + { + // Subtract half RTT to compensate for network transit time + double halfRttMs = 0.0; + { + auto rttIt = remoteTransportRttMsByUser.find(senderKey); + if (rttIt != remoteTransportRttMsByUser.end() && rttIt->second > 0.0) + halfRttMs = rttIt->second * 0.5; + if (halfRttMs <= 0.0) + { + const juce::String csKey = canonicalDelayUserKey(senderKey); + if (csKey.isNotEmpty()) + { + auto canonicalRttIt = remoteTransportRttMsByUser.find(csKey); + if (canonicalRttIt != remoteTransportRttMsByUser.end() && canonicalRttIt->second > 0.0) + halfRttMs = canonicalRttIt->second * 0.5; + } + } + } + const double rawDelayMs = (double)(firmAverageMs >= 0 ? firmAverageMs : averageMs); + const int correctedDelayMs = juce::jmax(0, (int)std::llround(rawDelayMs - halfRttMs)); + const int sourceInterval = pending.remoteIntervalAbsolute >= 0 ? pending.remoteIntervalAbsolute : pending.remoteInterval; + const juce::String canonicalSenderKey = canonicalDelayUserKey(senderKey); + const juce::ScopedLock lock(intervalSyncAnnouncementLock); + int priorAppliedInterval = std::numeric_limits::min(); + auto appliedIt = remoteLatencyLastAppliedIntervalByUser.find(senderKey); + if (appliedIt != remoteLatencyLastAppliedIntervalByUser.end()) + priorAppliedInterval = appliedIt->second; + bool shouldApply = (appliedIt == remoteLatencyLastAppliedIntervalByUser.end()); + auto currentDelayIt = remoteLatencyFirmDelayMsByUser.find(senderKey); + if (!shouldApply) + { + const int intervalDelta = sourceInterval - priorAppliedInterval; + const bool cadenceReached = intervalDelta >= remoteLatencyUpdateCadenceIntervals; + shouldApply = cadenceReached; + } + if (shouldApply) + { + remoteLatencyFirmDelayMsByUser[senderKey] = correctedDelayMs; + if (canonicalSenderKey.isNotEmpty()) + remoteLatencyFirmDelayMsByUser[canonicalSenderKey] = correctedDelayMs; + remoteLatencyLastAppliedIntervalByUser[senderKey] = sourceInterval; + if (canonicalSenderKey.isNotEmpty()) + remoteLatencyLastAppliedIntervalByUser[canonicalSenderKey] = sourceInterval; + vlogStr("[MCGuard] Applied firm delay for=" + senderKey + " canonical=" + canonicalSenderKey + " delayMs=" + juce::String(correctedDelayMs) + " rawMs=" + juce::String((int)std::llround(rawDelayMs)) + " halfRtt=" + juce::String((int)std::llround(halfRttMs)) + " sourceInterval=" + juce::String(sourceInterval) + " priorApplied=" + juce::String(priorAppliedInterval)); + } + } + const juce::String displaySender = pending.displaySender.isNotEmpty() ? pending.displaySender : senderKey; + // Look up halfRtt for display (may not be in scope from block above if firmAverageMs < 0) + double displayHalfRttMs = 0.0; + { + auto rttDisplayIt = remoteTransportRttMsByUser.find(senderKey); + if (rttDisplayIt != remoteTransportRttMsByUser.end() && rttDisplayIt->second > 0.0) + displayHalfRttMs = rttDisplayIt->second * 0.5; + if (displayHalfRttMs <= 0.0) + { + const juce::String csDisplayKey = canonicalDelayUserKey(senderKey); + if (csDisplayKey.isNotEmpty()) + { + auto cRttIt = remoteTransportRttMsByUser.find(csDisplayKey); + if (cRttIt != remoteTransportRttMsByUser.end() && cRttIt->second > 0.0) + displayHalfRttMs = cRttIt->second * 0.5; + } + } + } + juce::String line = displaySender + " BPI1->our BPI1 " + juce::String(elapsedMs) + "ms"; + if (firmAverageMs >= 0) + line << " avg " << juce::String(firmAverageMs) << "ms"; + else if (averageMs >= 0) + line << " avg " << juce::String(averageMs) << "ms"; + if (displayHalfRttMs > 0.0) + line << " rtt/2 " << juce::String((int)std::llround(displayHalfRttMs)) << "ms"; + juce::DynamicObject::Ptr reportObj = new juce::DynamicObject(); + reportObj->setProperty("line", line); + reportObj->setProperty("interval", pending.remoteIntervalAbsolute >= 0 ? pending.remoteIntervalAbsolute : pending.remoteInterval); + reportObj->setProperty("targetUserId", canonicalDelayUserKey(displaySender)); + reportObj->setProperty("elapsedMs", elapsedMs); + if (averageMs >= 0) + reportObj->setProperty("avgMs", averageMs); + if (firmAverageMs >= 0) + reportObj->setProperty("firmMs", firmAverageMs); + if (displayHalfRttMs > 0.0) + reportObj->setProperty("halfRttMs", (int)std::llround(displayHalfRttMs)); + reportObj->setProperty("eventId", "latencyReport:" + senderKey + ":" + juce::String(++sideSignalEventCounter)); + const juce::String reportPayload = juce::JSON::toString(juce::var(reportObj.get())); + sendIntervalSignal("intervalLatencyReport", reportPayload); + } + } + if (status == NJClient::NJC_STATUS_OK) + lastBroadcastIntervalTag.store(-1); + } + lastIntervalPos.store(pos); + if (status == NJClient::NJC_STATUS_OK) + writeIntervalHelperJson(pos, length); + } +} + +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new NinjamVst3AudioProcessor(); +} diff --git a/extras/ninjam-vst3/Source/PluginProcessor.h b/extras/ninjam-vst3/Source/PluginProcessor.h new file mode 100644 index 00000000..231542fd --- /dev/null +++ b/extras/ninjam-vst3/Source/PluginProcessor.h @@ -0,0 +1,423 @@ +#pragma once + +#include +#include + +// Disable min/max macros before including ninjam headers +#ifdef WIN32 +#define NOMINMAX +#endif + +#include "ninjam/njclient.h" + +class NinjamVst3AudioProcessor : public juce::AudioProcessor, + public juce::Timer +{ + friend class NinjamVst3AudioProcessorEditor; +public: + NinjamVst3AudioProcessor(); + ~NinjamVst3AudioProcessor() override; + + void prepareToPlay (double sampleRate, int samplesPerBlock) override; + void releaseResources() override; + + bool isBusesLayoutSupported (const BusesLayout& layouts) const override; + + void processBlock (juce::AudioBuffer&, juce::MidiBuffer&) override; + + juce::AudioProcessorEditor* createEditor() override; + bool hasEditor() const override; + + const juce::String getName() const override; + + bool acceptsMidi() const override; + bool producesMidi() const override; + bool isMidiEffect() const override; + double getTailLengthSeconds() const override; + + int getNumPrograms() override; + int getCurrentProgram() override; + void setCurrentProgram (int index) override; + const juce::String getProgramName (int index) override; + void changeProgramName (int index, const juce::String& newName) override; + + void getStateInformation (juce::MemoryBlock& destData) override; + void setStateInformation (const void* data, int sizeInBytes) override; + + // Timer callback for NINJAM client Run() + void timerCallback() override; + + NJClient& getClient() { return ninjamClient; } + + // NINJAM actions + void connectToServer(juce::String host, juce::String user, juce::String pass); + void disconnectFromServer(); + void sendChatMessage(juce::String msg); + + // Metronome + void setMetronomeVolume(float vol); + float getMetronomeVolume() const; + + // Local Channel + void setTransmitLocal(bool shouldTransmit); + bool isTransmittingLocal() const; + void setLocalBitrate(int bitrate); + int getLocalBitrate() const; + void setVoiceChatMode(bool enabled); + bool isVoiceChatMode() const; + + // Chat + juce::StringArray getChatMessages(); + void setAutoTranslateEnabled(bool shouldEnable); + bool isAutoTranslateEnabled() const; + void setTranslateTargetLang(const juce::String& langCode); + juce::String getTranslateTargetLang() const; + + struct PublicServerInfo { + juce::String host; + int port; + juce::String name; + int bpi; + float bpm; + int userCount; + int userMax; + }; + + std::vector getPublicServers() const; + void refreshPublicServers(); + + // User List + struct UserInfo { + int index; + juce::String name; + float volume; + float pan; + bool isMuted; + int outputChannel; // 0=Main, 2=Out2, etc. + int numChannels = 1; // number of active NINJAM channels for this user + bool isMultiChanPeer = false; // has more than 1 NINJAM channel + juce::StringArray channelNames; // name of each NINJAM channel (index 0..numChannels-1) + }; + std::vector getConnectedUsers(); + void setUserOutput(int userIndex, int outputChannelIndex); + void setUserLevel(int userIndex, float volume, float pan, bool isMuted, bool isSolo); + void setUserVolume(int userIndex, float volume); + float getUserPeak(int userIndex, int channelIndex); // 0=L, 1=R + float getUserChannelPeak(int userIndex, int njChanIdx, int lrSide); // per NINJAM channel L/R peak + void setUserNjChannelVolume(int userIndex, int njChanIdx, float volume); // individual NINJAM channel volume + + void setMasterOutputGain(float gain); + float getMasterOutputGain() const; + float getMasterPeak() const; + float getMasterPeakLeft() const; + float getMasterPeakRight() const; + void setSoftLimiterEnabled(bool shouldEnable); + bool isSoftLimiterEnabled() const; + void setUserClipEnabled(int userIndex, bool enabled); + bool isUserClipEnabled(int userIndex) const; + void setMasterLimiterEnabled(bool shouldEnable); + bool isMasterLimiterEnabled() const; + float getLimiterThreshold() const { return limiterThresholdDb.load(); } + float getLimiterRelease() const { return limiterReleaseMs.load(); } + void setLimiterThreshold(float db); + void setLimiterRelease(float ms); + void setLocalInputGain(float gain); + float getLocalInputGain() const; + static constexpr int maxLocalChannels = 8; + void setNumLocalChannels(int num); + int getNumLocalChannels() const; + void setLocalChannelName(int channel, const juce::String& name); + juce::String getLocalChannelName(int channel) const; + void setLocalChannelGain(int channel, float gain); + float getLocalChannelGain(int channel) const; + NinjamVst3AudioProcessorEditor* getEditor() const { return (NinjamVst3AudioProcessorEditor*)getActiveEditor(); } + void setLocalChannelInput(int channel, int inputIndex); + int getLocalChannelInput(int channel) const; + float getLocalChannelPeak(int channel) const; + float getLocalChannelPeakLeft(int channel) const; + float getLocalChannelPeakRight(int channel) const; + void setLocalMonitorEnabled(bool enabled); + bool isLocalMonitorEnabled() const; + void setFxReverbEnabled(bool enabled); + bool isFxReverbEnabled() const; + void setFxDelayEnabled(bool enabled); + bool isFxDelayEnabled() const; + void setFxReverbRoomSize(float roomSize); + float getFxReverbRoomSize() const; + void setFxReverbDamping(float damping); + float getFxReverbDamping() const; + void setFxReverbWetDryMix(float wetDryMix); + float getFxReverbWetDryMix() const; + void setFxReverbEarlyReflections(float earlyReflections); + float getFxReverbEarlyReflections() const; + void setFxReverbTail(float tail); + float getFxReverbTail() const; + void setFxDelayTimeMs(float timeMs); + float getFxDelayTimeMs() const; + void setFxDelaySyncToHost(bool enabled); + bool isFxDelaySyncToHost() const; + void setFxDelayDivision(int division); + int getFxDelayDivision() const; + void setFxDelayPingPong(bool enabled); + bool isFxDelayPingPong() const; + void setFxDelayWetDryMix(float wetDryMix); + float getFxDelayWetDryMix() const; + void setFxDelayFeedback(float feedback); + float getFxDelayFeedback() const; + void setLocalChannelReverbSend(int channel, float send); + float getLocalChannelReverbSend(int channel) const; + void setLocalChannelDelaySend(int channel, float send); + float getLocalChannelDelaySend(int channel) const; + + // NINJAM callbacks + static int LicenseAgreementCallback(void* userData, const char* licensetext); + static void ChatMessage_Callback(void* userData, NJClient* inst, const char** parms, int nparms); + static void IntervalMediaItem_Callback(void* userData, NJClient* inst, const char* username, int chidx, unsigned int fourcc, const unsigned char* guid, const void* data, int dataLen); + static void IntervalChunkCallback_cb(void* userData, NJClient* inst, const char* username, int chidx, unsigned int fourcc, const unsigned char* guid, const void* data, int dataLen, int flags); + static void NewIntervalCallback_cb(void* userData, NJClient* inst); + + // Interval / BPI + int getBPI(); + float getIntervalProgress(); + float getBPM(); + int getIntervalIndex() const; + int getCodecMode() const; + unsigned int getVorbisMask() const; + unsigned int getOpusMask() const; + + float getLocalPeak() const; + float getLocalPeakLeft() const; + float getLocalPeakRight() const; + + void sendSideSignal(const juce::String& target, const juce::String& type, const juce::String& payload); + void sendIntervalSignal(const juce::String& type, const juce::String& payload); + void processSyncSignal(const juce::String& sender, const juce::String& type, const juce::String& payload); + void launchVideoSession(); + + void rememberUserVolume(int userIndex, float volume, const juce::String& name); + + void setSpreadOutputsEnabled(bool shouldEnable); + bool isSpreadOutputsEnabled() const; + + void setSyncToHost(bool shouldSync); + bool isSyncToHostEnabled() const; + bool getHostPosition(juce::AudioPlayHead::CurrentPositionInfo& info) const; + void setMtcOutputEnabled(bool shouldEnable); + bool isMtcOutputEnabled() const; + void setMtcFrameRate(int fps); + int getMtcFrameRate() const; + bool isStandaloneInstance() const; + struct MidiControllerEvent + { + bool isController = false; + int midiChannel = 1; + int number = 0; + int value = 0; + float normalized = 0.0f; + bool isNoteOn = false; + }; + std::vector popPendingMidiControllerEvents(); + void setMidiRelayTarget(const juce::String& targetUser); + juce::String getMidiRelayTarget() const; + void setMidiLearnStateJson(const juce::String& json); + juce::String getMidiLearnStateJson() const; + void setOscLearnStateJson(const juce::String& json); + juce::String getOscLearnStateJson() const; + void setMidiLearnInputDeviceId(const juce::String& deviceId); + juce::String getMidiLearnInputDeviceId() const; + void setMidiRelayInputDeviceId(const juce::String& deviceId); + juce::String getMidiRelayInputDeviceId() const; + void enqueueExternalMidiControllerEvent(const MidiControllerEvent& event, bool forLearn, bool forRelay); + + bool isOpusSyncAvailable() const; + juce::String getIntervalSyncStatusText() const; + +private: + NJClient ninjamClient; + juce::CriticalSection processLock; + mutable juce::CriticalSection serverListLock; + std::vector publicServers; + + // Chat storage + juce::CriticalSection chatLock; + juce::StringArray chatHistory; + juce::StringArray chatSenders; // parallel: "me", username, or "" for system + bool autoTranslate = false; + juce::String translateTargetLang = "en"; + + // Local state + bool isTransmitting = false; + int localBitrate = 128; + bool voiceChatMode = false; + int lastStatus = 0; + + juce::AudioBuffer tempInputBuffer; + juce::AudioBuffer localChannelBuffer; + juce::AudioBuffer localMixBuffer; // 1-ch mix used by multiChanAuto Vorbis slot + std::atomic masterOutputGain { 1.0f }; + std::atomic localInputGain { 1.0f }; + std::atomic masterPeak { 0.0f }; + std::atomic masterPeakL { 0.0f }; + std::atomic masterPeakR { 0.0f }; + std::atomic localPeak { 0.0f }; + std::atomic localPeakL { 0.0f }; + std::atomic localPeakR { 0.0f }; + std::array, maxLocalChannels> localChannelGains; + std::array, maxLocalChannels> localChannelPeaks; + std::array, maxLocalChannels> localChannelPeaksL; + std::array, maxLocalChannels> localChannelPeaksR; + std::array, maxLocalChannels> localChannelInputs; + std::array, maxLocalChannels> localChannelReverbSends; + std::array, maxLocalChannels> localChannelDelaySends; + juce::CriticalSection localChannelNamesLock; + std::array localChannelNames; // user-defined channel names + std::atomic numLocalChannels { 1 }; + std::atomic localMonitorEnabled { true }; + std::atomic fxReverbEnabled { true }; + std::atomic fxDelayEnabled { true }; + std::atomic fxReverbRoomSize { 0.45f }; + std::atomic fxReverbDamping { 0.45f }; + std::atomic fxReverbWetDryMix { 1.0f }; + std::atomic fxReverbEarlyReflections { 0.25f }; + std::atomic fxReverbTail { 0.75f }; + std::atomic fxDelayTimeMs { 320.0f }; + std::atomic fxDelaySyncToHost { true }; + std::atomic fxDelayDivision { 8 }; + std::atomic fxDelayPingPong { false }; + std::atomic fxDelayWetDryMix { 1.0f }; + std::atomic fxDelayFeedback { 0.38f }; + juce::Reverb fxReverb; + juce::AudioBuffer fxDelayBuffer; + juce::AudioBuffer fxReverbInputBuffer; + juce::AudioBuffer fxDelayInputBuffer; + juce::AudioBuffer fxTransmitBuffer; + juce::AudioBuffer fxReturnBuffer; + int fxDelayWritePosition = 0; + double processingSampleRate = 44100.0; + std::atomic spreadOutputsEnabled { false }; + std::atomic softLimiterEnabled { true }; + std::atomic dspLimiterEnabled { false }; + std::atomic limiterThresholdDb { 0.0f }; + std::atomic limiterReleaseMs { 100.0f }; + juce::dsp::Limiter masterLimiter; + + std::map userClipEnabled; + std::map userPanOverrides; + std::map userOutputAssignment; + std::map userBaseVolume; + std::map userVolumeByName; + + bool syncToHost = false; + std::atomic hostWasPlaying { false }; + std::atomic syncWaitForInterval { false }; + std::atomic syncTargetInterval { -1 }; + std::atomic syncDisplayIntervalOffset { 0 }; + std::atomic syncDisplayPositionOffset { 0 }; + std::atomic mtcOutputEnabled { true }; + std::atomic mtcFrameRateFps { 30 }; + bool mtcWasRunning = false; + double mtcSamplesUntilNextQuarterFrame = 0.0; + int mtcQuarterFramePiece = 0; + mutable juce::CriticalSection transportLock; + juce::AudioPlayHead::CurrentPositionInfo lastHostPosition; + + std::atomic intervalIndex { 0 }; + std::atomic lastIntervalPos { 0 }; + std::atomic sideSignalEventCounter { 0 }; + juce::String currentServer; + juce::String currentUser; + juce::File videoHelperRootDir; + juce::File intervalJsonFile; + std::atomic videoHelperRunning { false }; + std::unique_ptr advancedVideoProcess; + std::map remoteLatencyFirmDelayMsByUser; + + std::atomic opusSyncAvailable { false }; + std::atomic opusSyncHasLegacyClients { false }; + std::atomic opusSyncServerSupported { false }; + mutable juce::CriticalSection intervalSyncStatusLock; + juce::String intervalSyncStatusText; + std::atomic lastBroadcastIntervalTag { -1 }; + juce::CriticalSection intervalSyncAnnouncementLock; + std::map lastAnnouncedRemoteIntervalByUser; + std::map localIntervalStartMsByInterval; + struct PendingRemoteIntervalStart + { + int remoteInterval = -1; + int remoteIntervalAbsolute = -1; + juce::String displaySender; + long long receivedSampleCount = -1; + }; + std::map pendingRemoteIntervalStartsByUser; + std::map remoteTransportRttMsByUser; + std::map pendingTransportProbeSentMsById; + std::map remoteLatencyLastAppliedIntervalByUser; + struct RemoteLatencyAverageState + { + int sampleCount = 0; + double sumMs = 0.0; + double averageMs = 0.0; + double firmAverageMs = 0.0; + double lastMeasurementMs = -1.0; + }; + std::map remoteLatencyAverageByUser; + juce::CriticalSection opusSyncPeerLock; + struct OpusSyncPeerState + { + juce::String userId; + bool supportsOpus = false; + bool multiChanEnabled = false; + int numChannels = 1; // number of local channels the peer is sending + juce::String appFamily; + int handshakeVersion = 0; + juce::String runtimeFormat; + juce::String pluginVersion; + double lastSeenMs = 0.0; + }; + std::map opusSyncPeers; + // Simple username→{isMultiChan, numChannels} snapshot updated by refreshOpusSyncAvailabilityFromUsers(). + // Keyed by normalised username (no @host, lowercase). Read without holding opusSyncPeerLock. + struct PeerMultiChanInfo { bool isMultiChan = false; int numChannels = 1; }; + std::map peerMultiChanByName; + juce::CriticalSection peerMultiChanLock; + juce::String opusSyncInstanceId; + double lastOpusSupportBroadcastMs = 0.0; + std::atomic transportProbeCounter { 0 }; + double lastTransportProbeBroadcastMs = 0.0; + std::atomic intervalSyncSampleCounter { 0 }; + juce::SpinLock midiEventQueueLock; + std::vector pendingMidiControllerEvents; + juce::SpinLock outboundMidiRelayQueueLock; + std::vector pendingOutboundMidiRelayEvents; + juce::SpinLock inboundMidiRelayQueueLock; + std::vector pendingInboundMidiRelayEvents; + mutable juce::CriticalSection midiRelayTargetLock; + juce::String midiRelayTarget { "*" }; + mutable juce::CriticalSection learnStateLock; + juce::String midiLearnStateJson; + juce::String oscLearnStateJson; + juce::String midiLearnInputDeviceId; + juce::String midiRelayInputDeviceId; + + juce::String translateText(const juce::String& text); + bool isStandaloneWrapper() const; + int getDisplayIntervalIndex() const; + void emitMidiTimecode(juce::MidiBuffer& midiMessages, int numSamples, int pos, int length); + void broadcastOpusSyncSupport(const juce::String& target = "*"); + void refreshOpusSyncAvailabilityFromUsers(); + void applyCodecPreference(); + void setIntervalSyncStatusText(const juce::String& text); + void broadcastIntervalSyncTag(const juce::String& target = "*"); + void broadcastTransportProbe(const juce::String& target = "*"); + juce::String buildIntervalSyncTag(int interval, int length) const; + juce::File resolveVideoHelperRootDir() const; + bool isAdvancedVideoClientAvailable() const; + bool ensureAdvancedVideoClientStarted(); + void stopAdvancedVideoClient(); + void writeIntervalHelperJson(int pos, int length); + void syncLocalIntervalChannelConfig(); + void flushOutboundMidiRelayEvents(); + void injectInboundMidiRelayEvents(juce::MidiBuffer& midiMessages); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (NinjamVst3AudioProcessor) +}; diff --git a/extras/ninjam-vst3/Source/ProVideoCodec.cpp b/extras/ninjam-vst3/Source/ProVideoCodec.cpp new file mode 100644 index 00000000..9f1cebd6 --- /dev/null +++ b/extras/ninjam-vst3/Source/ProVideoCodec.cpp @@ -0,0 +1,358 @@ +#include "ProVideoCodec.h" + +//============================================================================== +// Internal helpers +//============================================================================== +namespace +{ + //-------------------------------------------------------------------------- + // Hardware encoder try-list (CPU libx264 always last) + //-------------------------------------------------------------------------- + static const char* const kH264EncoderNames[] = { +#if defined(_WIN32) + "h264_nvenc", + "h264_amf", + "h264_qsv", +#elif defined(__APPLE__) + "h264_videotoolbox", + "h264_nvenc", +#else // Linux + "h264_nvenc", + "h264_vaapi", +#endif + "libx264", + nullptr + }; + +} // namespace + +//============================================================================== +// ProVideoEncoder +//============================================================================== +//============================================================================== +// Annex-B NALU helpers (used by the encoder to cache and re-inject SPS+PPS) + +/// Returns the byte offset of the first IDR-slice start code (00 00 00 01 65) +/// within an Annex-B byte stream, or -1 if not found. +static int findFirstIdrOffset(const uint8_t* data, int len) +{ + for (int i = 0; i + 4 < len; ++i) + { + if (data[i] == 0 && data[i+1] == 0 && data[i+2] == 0 && data[i+3] == 1 + && (data[i+4] & 0x1f) == 5) + return i; + } + return -1; +} + +/// Returns true if the Annex-B stream begins with an SPS NALU (type 7). +static bool startsWithSps(const uint8_t* data, int len) +{ + if (len >= 5 && data[0]==0 && data[1]==0 && data[2]==0 && data[3]==1 && (data[4]&0x1f)==7) + return true; + if (len >= 4 && data[0]==0 && data[1]==0 && data[2]==1 && (data[3]&0x1f)==7) + return true; + return false; +} + +//============================================================================== +bool ProVideoEncoder::tryOpenEncoder(const char* codecName, int width, int height, int fps, int bitrate) +{ + const AVCodec* codec = avcodec_find_encoder_by_name(codecName); + if (codec == nullptr) + return false; + + AVCodecContext* ctx = avcodec_alloc_context3(codec); + if (ctx == nullptr) + return false; + + ctx->width = width; + ctx->height = height; + ctx->pix_fmt = AV_PIX_FMT_YUV420P; + ctx->time_base = { 1, fps }; + ctx->framerate = { fps, 1 }; + ctx->bit_rate = bitrate; + ctx->gop_size = 1; // Every frame is an IDR keyframe — each NINJAM interval is independent. + ctx->max_b_frames = 0; + + // Tune — errors from av_opt_set are non-fatal + av_opt_set(ctx->priv_data, "preset", "ultrafast", AV_OPT_SEARCH_CHILDREN); + av_opt_set(ctx->priv_data, "tune", "zerolatency", AV_OPT_SEARCH_CHILDREN); + // Embed SPS+PPS in every IDR frame so late-joining decoders can start anywhere. + av_opt_set_int(ctx->priv_data, "repeat-headers", 1, 0); // nvenc/amf/qsv + av_opt_set(ctx->priv_data, "x264-params", "repeat-headers=1", 0); // libx264 + + if (avcodec_open2(ctx, codec, nullptr) < 0) + { + avcodec_free_context(&ctx); + return false; + } + + AVFrame* f = av_frame_alloc(); + if (f == nullptr) + { + avcodec_free_context(&ctx); + return false; + } + f->format = ctx->pix_fmt; + f->width = width; + f->height = height; + if (av_frame_get_buffer(f, 32) < 0) + { + av_frame_free(&f); + avcodec_free_context(&ctx); + return false; + } + + SwsContext* sws = sws_getContext( + width, height, AV_PIX_FMT_RGB24, + width, height, AV_PIX_FMT_YUV420P, + SWS_BICUBIC, nullptr, nullptr, nullptr); + if (sws == nullptr) + { + av_frame_free(&f); + avcodec_free_context(&ctx); + return false; + } + + codecCtx = ctx; + frame = f; + swsCtx = sws; + openWidth = width; + openHeight = height; + pts = 0; + DBG("ProVideoEncoder: opened " << juce::String(codecName) << " " << width << "x" << height); + return true; +} + +bool ProVideoEncoder::open(int width, int height, int fps, int bitrate) +{ + if (isOpen() && openWidth == width && openHeight == height) + return true; + + close(); + + for (int i = 0; kH264EncoderNames[i] != nullptr; ++i) + { + if (tryOpenEncoder(kH264EncoderNames[i], width, height, fps, bitrate)) + return true; + } + DBG("ProVideoEncoder: all encoders failed for " << width << "x" << height); + return false; +} + +bool ProVideoEncoder::encodeFrame(const juce::Image& img, juce::MemoryBlock& outData) +{ + if (codecCtx == nullptr || frame == nullptr || swsCtx == nullptr) + return false; + + const juce::Image rgb = img.convertedToFormat(juce::Image::RGB); + juce::Image::BitmapData bd(rgb, juce::Image::BitmapData::readOnly); + + const int srcStride[1] = { bd.lineStride }; + const uint8_t* src[1] = { bd.getLinePointer(0) }; + + if (av_frame_make_writable(frame) < 0) + return false; + + sws_scale(swsCtx, src, srcStride, 0, img.getHeight(), + frame->data, frame->linesize); + + frame->pts = pts++; + + if (avcodec_send_frame(codecCtx, frame) < 0) + return false; + + bool gotData = false; + for (;;) + { + AVPacket* pkt = av_packet_alloc(); + if (pkt == nullptr) + break; + + const int ret = avcodec_receive_packet(codecCtx, pkt); + if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) + { + av_packet_free(&pkt); + break; + } + if (ret < 0) + { + av_packet_free(&pkt); + return false; + } + + // Cache the SPS+PPS block from the first packet (everything before the + // IDR slice). Prepend it to every subsequent IDR-only packet so that + // any decoder — including one that joins after the first packet was sent + // — can always decode the frame independently. + const uint8_t* pktData = pkt->data; + const int pktSize = pkt->size; + if (cachedSpsAndPps.isEmpty()) + { + const int idrOff = findFirstIdrOffset(pktData, pktSize); + if (idrOff > 0) + cachedSpsAndPps.append(pktData, (size_t) idrOff); + } + if (!cachedSpsAndPps.isEmpty() && !startsWithSps(pktData, pktSize)) + outData.append(cachedSpsAndPps.getData(), cachedSpsAndPps.getSize()); + outData.append(pktData, static_cast(pktSize)); + gotData = true; + av_packet_free(&pkt); + } + + return gotData; +} + +void ProVideoEncoder::closeInternal() +{ + cachedSpsAndPps.reset(); // clear cached headers so the next open starts fresh + if (swsCtx) { sws_freeContext(swsCtx); swsCtx = nullptr; } + if (frame) { av_frame_free(&frame); frame = nullptr; } + if (codecCtx) { avcodec_free_context(&codecCtx); codecCtx = nullptr; } + openWidth = 0; + openHeight = 0; + pts = 0; +} + +void ProVideoEncoder::close() +{ + closeInternal(); +} + +//============================================================================== +// ProVideoDecoder — stateful per-user H.264 decoder +// +// The encoder produces raw Annex-B H.264. With gop_size=1 the first packet +// contains SPS+PPS+IDR; subsequent packets contain IDR only (libx264 only +// emits SPS/PPS once unless repeat-headers is set). A fresh AVCodecContext +// per call would lose SPS/PPS context for packets 1+, so we keep the context +// alive for the lifetime of the decoder instance. +// +// We skip the AVCodecParser entirely: each packet we receive is already a +// complete Annex-B bitstream assembled by the encoder loop, so we pass it +// directly to avcodec_send_packet. +//============================================================================== + +bool ProVideoDecoder::ensureOpen() +{ + if (codecCtx != nullptr) + return true; + + const AVCodec* codec = avcodec_find_decoder(AV_CODEC_ID_H264); + if (codec == nullptr) + { + DBG("ProVideoDecoder: H.264 decoder not found"); + return false; + } + + codecCtx = avcodec_alloc_context3(codec); + if (codecCtx == nullptr) + return false; + + // Minimise latency — we don't use B-frames. + codecCtx->flags |= AV_CODEC_FLAG_LOW_DELAY; + + if (avcodec_open2(codecCtx, codec, nullptr) < 0) + { + avcodec_free_context(&codecCtx); + DBG("ProVideoDecoder: avcodec_open2 failed"); + return false; + } + + avFrame = av_frame_alloc(); + if (avFrame == nullptr) + { + avcodec_free_context(&codecCtx); + return false; + } + + return true; +} + +void ProVideoDecoder::close() +{ + if (swsCtx != nullptr) + { + sws_freeContext(swsCtx); + swsCtx = nullptr; + swsW = 0; + swsH = 0; + swsFmt = AV_PIX_FMT_NONE; + } + if (avFrame != nullptr) { av_frame_free(&avFrame); avFrame = nullptr; } + if (codecCtx != nullptr) { avcodec_free_context(&codecCtx); codecCtx = nullptr; } +} + +bool ProVideoDecoder::decode(const void* data, int dataLen, juce::Image& outImage) +{ + if (data == nullptr || dataLen <= 0) + return false; + + if (!ensureOpen()) + return false; + + AVPacket* pkt = av_packet_alloc(); + if (pkt == nullptr) + return false; + + // Pass the full Annex-B bitstream as one packet. The H.264 decoder + // handles embedded start codes (00 00 00 01 ...) natively. + pkt->data = const_cast(static_cast(data)); + pkt->size = dataLen; + pkt->flags = AV_PKT_FLAG_KEY; + + juce::Image result; + + if (avcodec_send_packet(codecCtx, pkt) == 0) + { + const int ret = avcodec_receive_frame(codecCtx, avFrame); + if (ret == 0) + { + const int w = avFrame->width; + const int h = avFrame->height; + const auto fmt = static_cast(avFrame->format); + + // Reuse the sws context if the frame geometry hasn't changed. + if (swsCtx == nullptr || swsW != w || swsH != h || swsFmt != fmt) + { + if (swsCtx != nullptr) + sws_freeContext(swsCtx); + swsCtx = sws_getContext(w, h, fmt, + w, h, AV_PIX_FMT_RGB24, + SWS_BICUBIC, nullptr, nullptr, nullptr); + swsW = w; + swsH = h; + swsFmt = fmt; + } + + if (swsCtx != nullptr) + { + juce::Image img(juce::Image::RGB, w, h, false); + { + juce::Image::BitmapData bd(img, juce::Image::BitmapData::writeOnly); + uint8_t* dstSlice[1] = { bd.getLinePointer(0) }; + const int dstStride[1] = { bd.lineStride }; + sws_scale(swsCtx, avFrame->data, avFrame->linesize, + 0, h, dstSlice, dstStride); + } + result = img; + } + av_frame_unref(avFrame); + } + } + + // Null out data so av_packet_free doesn't try to unref a non-owned buffer. + pkt->data = nullptr; + pkt->size = 0; + av_packet_free(&pkt); + + if (!result.isValid()) + { + DBG("ProVideoDecoder: decode failed for " << dataLen << " bytes"); + return false; + } + + outImage = std::move(result); + return true; +} diff --git a/extras/ninjam-vst3/Source/ProVideoCodec.h b/extras/ninjam-vst3/Source/ProVideoCodec.h new file mode 100644 index 00000000..6c3a39de --- /dev/null +++ b/extras/ninjam-vst3/Source/ProVideoCodec.h @@ -0,0 +1,90 @@ +#pragma once +#include +#include + +extern "C" +{ +#include +#include +#include +#include +#include +#include +} + +//============================================================================== +/** Encodes juce::Image frames to H.264 NAL byte-streams using FFmpeg. + Call open() once before the first encodeFrame(), close() when done. + Every frame is emitted as an IDR keyframe (gop_size=1) so that each + NINJAM interval fragment is independently decodeable. */ +class ProVideoEncoder +{ +public: + ProVideoEncoder() = default; + ~ProVideoEncoder() { close(); } + + /** Open (or re-open) the encoder for the given dimensions and frame rate. + Hardware encoders are tried in order: h264_nvenc → h264_amf → h264_qsv → + h264_videotoolbox → libx264. Returns true if a suitable encoder was found. */ + bool open(int width, int height, int fps, int bitrate); + + /** Encode one frame. On success returns true and appends the raw H.264 + NAL bytes (without any container) to @p outData. */ + bool encodeFrame(const juce::Image& img, juce::MemoryBlock& outData); + + /** Release all FFmpeg resources. Safe to call multiple times. */ + void close(); + + bool isOpen() const noexcept { return codecCtx != nullptr; } + int getWidth() const noexcept { return openWidth; } + int getHeight() const noexcept { return openHeight; } + +private: + AVCodecContext* codecCtx = nullptr; + AVFrame* frame = nullptr; + SwsContext* swsCtx = nullptr; + int64_t pts = 0; + int openWidth = 0; + int openHeight = 0; + + bool tryOpenEncoder(const char* codecName, int width, int height, int fps, int bitrate); + void closeInternal(); + + // SPS+PPS cached from the first encoded packet and prepended to every + // subsequent IDR-only packet so late-joining decoders can start anywhere. + juce::MemoryBlock cachedSpsAndPps; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ProVideoEncoder) +}; + +//============================================================================== +/** Decodes H.264 NAL byte-streams to juce::Image frames using FFmpeg. + Stateful — the codec context is kept open across calls so that SPS/PPS + information from earlier packets (frame 0) is available when decoding + subsequent IDR-only packets (frames 1+). Create one instance per remote + user and reuse it for the lifetime of the session. */ +class ProVideoDecoder +{ +public: + ProVideoDecoder() = default; + ~ProVideoDecoder() { close(); } + + /** Decode @p dataLen bytes of raw Annex-B H.264 data into @p outImage (RGB). + Returns true on success. Lazily opens the codec context on the first call. */ + bool decode(const void* data, int dataLen, juce::Image& outImage); + + /** Release all FFmpeg resources. Safe to call multiple times. */ + void close(); + +private: + AVCodecContext* codecCtx = nullptr; + AVFrame* avFrame = nullptr; + SwsContext* swsCtx = nullptr; + int swsW = 0; + int swsH = 0; + AVPixelFormat swsFmt = AV_PIX_FMT_NONE; + + bool ensureOpen(); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ProVideoDecoder) +}; diff --git a/extras/ninjam-vst3/Source/StandaloneApp.cpp b/extras/ninjam-vst3/Source/StandaloneApp.cpp new file mode 100644 index 00000000..e3247fc6 --- /dev/null +++ b/extras/ninjam-vst3/Source/StandaloneApp.cpp @@ -0,0 +1,461 @@ +#include +#include +#include "PluginProcessor.h" + +#if JucePlugin_Build_Standalone + +namespace juce +{ + +class NinjamStandalonePluginHolder : public StandalonePluginHolder +{ +public: + using StandalonePluginHolder::StandalonePluginHolder; +}; + +class NinjamAudioSettingsComponent : public Component +{ +public: + NinjamAudioSettingsComponent (NinjamStandalonePluginHolder& pluginHolder, + AudioDeviceManager& deviceManagerToUse, + int maxAudioInputChannels, + int maxAudioOutputChannels) + : owner (pluginHolder), + deviceSelector (deviceManagerToUse, + 0, maxAudioInputChannels, + 0, maxAudioOutputChannels, + true, + (pluginHolder.processor.get() != nullptr && pluginHolder.processor->producesMidi()), + true, false), + shouldMuteLabel ("Feedback Loop:", "Feedback Loop:"), + shouldMuteButton ("Mute audio input") + { + setOpaque (true); + + shouldMuteButton.setClickingTogglesState (true); + shouldMuteButton.getToggleStateValue().referTo (owner.shouldMuteInput); + + addAndMakeVisible (deviceSelector); + + if (owner.getProcessorHasPotentialFeedbackLoop()) + { + addAndMakeVisible (shouldMuteButton); + addAndMakeVisible (shouldMuteLabel); + shouldMuteLabel.attachToComponent (&shouldMuteButton, true); + } + + processor = dynamic_cast (owner.processor.get()); + + if (processor != nullptr) + { + addAndMakeVisible (mtcOutLabel); + mtcOutLabel.setText ("MTC Out:", dontSendNotification); + mtcOutLabel.attachToComponent (&mtcToggle, true); + + addAndMakeVisible (mtcToggle); + mtcToggle.setClickingTogglesState (true); + mtcToggle.setButtonText ("Enable"); + mtcToggle.setToggleState (processor->isMtcOutputEnabled(), dontSendNotification); + mtcToggle.onClick = [this] + { + if (processor != nullptr) + processor->setMtcOutputEnabled (mtcToggle.getToggleState()); + }; + + addAndMakeVisible (frameRateLabel); + frameRateLabel.setText ("MTC Frame Rate:", dontSendNotification); + frameRateLabel.attachToComponent (&frameRateBox, true); + + addAndMakeVisible (frameRateBox); + frameRateBox.addItem ("24 fps", 1); + frameRateBox.addItem ("25 fps", 2); + frameRateBox.addItem ("29.97 df", 3); + frameRateBox.addItem ("30 fps", 4); + + const int currentRate = processor->getMtcFrameRate(); + int selectedId = 4; + if (currentRate == 24) selectedId = 1; + else if (currentRate == 25) selectedId = 2; + else if (currentRate == 2997) selectedId = 3; + frameRateBox.setSelectedId (selectedId, dontSendNotification); + + frameRateBox.onChange = [this] + { + if (processor == nullptr) + return; + + const int id = frameRateBox.getSelectedId(); + int rate = 30; + if (id == 1) rate = 24; + else if (id == 2) rate = 25; + else if (id == 3) rate = 2997; + processor->setMtcFrameRate (rate); + }; + } + } + + void paint (Graphics& g) override + { + g.fillAll (getLookAndFeel().findColour (ResizableWindow::backgroundColourId)); + } + + void resized() override + { + const ScopedValueSetter scope (isResizing, true); + + auto r = getLocalBounds().reduced (10); + const auto itemHeight = deviceSelector.getItemHeight(); + const auto separatorHeight = (itemHeight >> 1); + auto makeControlBounds = [separatorHeight, itemHeight] (Rectangle row) + { + const auto controlX = row.getX() + roundToInt (row.getWidth() * 0.35f); + const auto controlW = roundToInt (row.getWidth() * 0.60f); + const auto controlY = row.getY() + separatorHeight; + return Rectangle (controlX, controlY, controlW, itemHeight); + }; + + if (owner.getProcessorHasPotentialFeedbackLoop()) + { + auto feedbackRow = r.removeFromTop (itemHeight); + shouldMuteButton.setBounds (makeControlBounds (feedbackRow)); + r.removeFromTop (separatorHeight); + } + + Rectangle mtcArea; + if (processor != nullptr) + mtcArea = r.removeFromBottom ((itemHeight + separatorHeight) * 2); + + deviceSelector.setBounds (r); + + if (processor != nullptr) + { + auto mtcToggleRow = mtcArea.removeFromTop (itemHeight); + mtcToggle.setBounds (makeControlBounds (mtcToggleRow)); + mtcArea.removeFromTop (separatorHeight); + + auto frameRateRow = mtcArea.removeFromTop (itemHeight); + frameRateBox.setBounds (makeControlBounds (frameRateRow)); + } + } + + void childBoundsChanged (Component* childComp) override + { + if (! isResizing && childComp == &deviceSelector) + setToRecommendedSize(); + } + + void setToRecommendedSize() + { + int extraHeight = 0; + + if (owner.getProcessorHasPotentialFeedbackLoop()) + { + const auto itemHeight = deviceSelector.getItemHeight(); + const auto separatorHeight = (itemHeight >> 1); + extraHeight += itemHeight + separatorHeight; + } + + if (processor != nullptr) + { + const auto itemHeight = deviceSelector.getItemHeight(); + const auto separatorHeight = (itemHeight >> 1); + extraHeight += (itemHeight + separatorHeight) * 2; + } + + setSize (getWidth(), deviceSelector.getHeight() + extraHeight + 20); + } + +private: + NinjamStandalonePluginHolder& owner; + AudioDeviceSelectorComponent deviceSelector; + Label shouldMuteLabel; + ToggleButton shouldMuteButton; + Label mtcOutLabel; + ToggleButton mtcToggle; + Label frameRateLabel; + ComboBox frameRateBox; + NinjamVst3AudioProcessor* processor = nullptr; + bool isResizing = false; +}; + +class NinjamStandaloneFilterWindow : public DocumentWindow, + private Button::Listener +{ +public: + using PluginInOuts = StandalonePluginHolder::PluginInOuts; + + NinjamStandaloneFilterWindow (const String& title, + Colour backgroundColour, + PropertySet* settingsToUse, + bool takeOwnershipOfSettings, + const String& preferredDefaultDeviceName = String(), + const AudioDeviceManager::AudioDeviceSetup* preferredSetupOptions = nullptr, + const Array& constrainToConfiguration = {}, + bool autoOpenMidiDevices = false) + : DocumentWindow (title, backgroundColour, DocumentWindow::minimiseButton | DocumentWindow::closeButton), + optionsButton ("Options") + { + pluginHolder = std::make_unique (settingsToUse, + takeOwnershipOfSettings, + preferredDefaultDeviceName, + preferredSetupOptions, + constrainToConfiguration, + autoOpenMidiDevices); + + #if JUCE_IOS || JUCE_ANDROID + setTitleBarHeight (0); + #else + setTitleBarButtonsRequired (DocumentWindow::minimiseButton | DocumentWindow::closeButton, false); + Component::addAndMakeVisible (optionsButton); + optionsButton.addListener (this); + optionsButton.setTriggeredOnMouseDown (true); + #endif + + updateContent(); + centreWithSize (getWidth(), getHeight()); + setVisible (true); + } + + AudioProcessor* getAudioProcessor() const + { + return pluginHolder != nullptr ? pluginHolder->processor.get() : nullptr; + } + + NinjamStandalonePluginHolder* getPluginHolder() const + { + return pluginHolder.get(); + } + + void closeButtonPressed() override + { + if (pluginHolder != nullptr) + pluginHolder->savePluginState(); + + JUCEApplicationBase::quit(); + } + + void resized() override + { + DocumentWindow::resized(); + optionsButton.setBounds (8, 6, 60, getTitleBarHeight() - 8); + } + +private: + class MainContentComponent : public Component, + private ComponentListener + { + public: + explicit MainContentComponent (NinjamStandaloneFilterWindow& filterWindow) + : owner (filterWindow) + { + if (auto* processor = owner.getAudioProcessor()) + editor.reset (processor->hasEditor() ? processor->createEditorIfNeeded() + : new GenericAudioProcessorEditor (*processor)); + + if (editor != nullptr) + { + editor->addComponentListener (this); + addAndMakeVisible (editor.get()); + handleMovedOrResized(); + } + } + + ~MainContentComponent() override + { + if (editor != nullptr) + { + editor->removeComponentListener (this); + if (owner.getPluginHolder() != nullptr && owner.getPluginHolder()->processor != nullptr) + owner.getPluginHolder()->processor->editorBeingDeleted (editor.get()); + editor = nullptr; + } + } + + void resized() override + { + if (editor != nullptr) + editor->setTopLeftPosition (0, 0); + } + + private: + void componentMovedOrResized (Component&, bool, bool) override + { + handleMovedOrResized(); + } + + void handleMovedOrResized() + { + if (editor == nullptr) + return; + + const auto rect = editor->getLocalArea (this, editor->getLocalBounds()); + setSize (rect.getWidth(), rect.getHeight()); + } + + NinjamStandaloneFilterWindow& owner; + std::unique_ptr editor; + }; + + void showAudioSettingsDialog() + { + if (pluginHolder == nullptr || pluginHolder->processor == nullptr) + return; + + DialogWindow::LaunchOptions o; + + int maxNumInputs = jmax (0, pluginHolder->getNumInputChannels()); + int maxNumOutputs = jmax (0, pluginHolder->getNumOutputChannels()); + + if (auto* bus = pluginHolder->processor->getBus (true, 0)) + maxNumInputs = jmax (0, bus->getDefaultLayout().size()); + + if (auto* bus = pluginHolder->processor->getBus (false, 0)) + maxNumOutputs = jmax (0, bus->getDefaultLayout().size()); + + auto content = std::make_unique (*pluginHolder, + pluginHolder->deviceManager, + maxNumInputs, + maxNumOutputs); + content->setSize (520, 620); + content->setToRecommendedSize(); + + o.content.setOwned (content.release()); + o.dialogTitle = TRANS ("Audio/MIDI Settings"); + o.dialogBackgroundColour = o.content->getLookAndFeel().findColour (ResizableWindow::backgroundColourId); + o.escapeKeyTriggersCloseButton = true; + o.useNativeTitleBar = true; + o.resizable = false; + o.launchAsync(); + } + + void resetToDefaultState() + { + if (pluginHolder == nullptr) + return; + + pluginHolder->stopPlaying(); + clearContentComponent(); + pluginHolder->deletePlugin(); + + if (auto* props = pluginHolder->settings.get()) + props->removeValue ("filterState"); + + pluginHolder->createPlugin(); + updateContent(); + pluginHolder->startPlaying(); + } + + void updateContent() + { + setContentOwned (new MainContentComponent (*this), true); + } + + void handleMenuResult (int result) + { + if (pluginHolder == nullptr) + return; + + switch (result) + { + case 1: showAudioSettingsDialog(); break; + case 2: pluginHolder->askUserToSaveState(); break; + case 3: pluginHolder->askUserToLoadState(); break; + case 4: resetToDefaultState(); break; + default: break; + } + } + + static void menuCallback (int result, NinjamStandaloneFilterWindow* button) + { + if (button != nullptr && result != 0) + button->handleMenuResult (result); + } + + void buttonClicked (Button*) override + { + PopupMenu m; + m.addItem (1, TRANS ("Audio/MIDI Settings...")); + m.addSeparator(); + m.addItem (2, TRANS ("Save current state...")); + m.addItem (3, TRANS ("Load a saved state...")); + m.addSeparator(); + m.addItem (4, TRANS ("Reset to default state")); + + m.showMenuAsync (PopupMenu::Options(), + ModalCallbackFunction::forComponent (menuCallback, this)); + } + + TextButton optionsButton; + std::unique_ptr pluginHolder; +}; + +class NinjamStandaloneApp final : public JUCEApplication +{ +public: + const String getApplicationName() override { return JucePlugin_Name; } + const String getApplicationVersion() override { return JucePlugin_VersionString; } + bool moreThanOneInstanceAllowed() override { return true; } + void anotherInstanceStarted (const String&) override {} + + void initialise (const String&) override + { + PropertiesFile::Options options; + options.applicationName = JucePlugin_Name; + options.filenameSuffix = ".settings"; + options.osxLibrarySubFolder = "Application Support"; + #if JUCE_LINUX || JUCE_BSD + options.folderName = "~/.config"; + #else + options.folderName = ""; + #endif + + appProperties.setStorageParameters (options); + mainWindow.reset (createWindow()); + } + + void shutdown() override + { + mainWindow = nullptr; + } + + void systemRequestedQuit() override + { + quit(); + } + +private: + NinjamStandaloneFilterWindow* createWindow() + { + #ifdef JucePlugin_PreferredChannelConfigurations + StandalonePluginHolder::PluginInOuts channels[] = { JucePlugin_PreferredChannelConfigurations }; + #endif + + return new NinjamStandaloneFilterWindow (getApplicationName(), + LookAndFeel::getDefaultLookAndFeel().findColour (ResizableWindow::backgroundColourId), + appProperties.getUserSettings(), + false, + {}, + nullptr + #ifdef JucePlugin_PreferredChannelConfigurations + , Array (channels, numElementsInArray (channels)) + #else + , {} + #endif + #if JUCE_DONT_AUTO_OPEN_MIDI_DEVICES_ON_MOBILE + , false + #endif + ); + } + + ApplicationProperties appProperties; + std::unique_ptr mainWindow; +}; + +} // namespace juce + +juce::JUCEApplicationBase* juce_CreateApplication() +{ + return new juce::NinjamStandaloneApp(); +} + +#endif diff --git a/extras/ninjam-vst3/advanced-vdo-client/icon.png b/extras/ninjam-vst3/advanced-vdo-client/icon.png new file mode 100644 index 00000000..da1cdcc3 Binary files /dev/null and b/extras/ninjam-vst3/advanced-vdo-client/icon.png differ diff --git a/extras/ninjam-vst3/advanced-vdo-client/index.html b/extras/ninjam-vst3/advanced-vdo-client/index.html new file mode 100644 index 00000000..19ce1f41 --- /dev/null +++ b/extras/ninjam-vst3/advanced-vdo-client/index.html @@ -0,0 +1,442 @@ + + + + + NINJAM Launcher + + + +
+ + +
+ +
+
+ +
+ + + diff --git a/extras/ninjam-vst3/package-lock.json b/extras/ninjam-vst3/package-lock.json new file mode 100644 index 00000000..e0857965 --- /dev/null +++ b/extras/ninjam-vst3/package-lock.json @@ -0,0 +1,37 @@ +{ + "name": "ninjam-vst3", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ninjam-vst3", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "ws": "^8.19.0" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/extras/ninjam-vst3/package.json b/extras/ninjam-vst3/package.json new file mode 100644 index 00000000..d93f9f34 --- /dev/null +++ b/extras/ninjam-vst3/package.json @@ -0,0 +1,16 @@ +{ + "name": "ninjam-vst3", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "ws": "^8.19.0" + } +} diff --git a/extras/ninjam-vst3/textures/Abstract Pattern/bg.jpg b/extras/ninjam-vst3/textures/Abstract Pattern/bg.jpg new file mode 100644 index 00000000..e9189d22 Binary files /dev/null and b/extras/ninjam-vst3/textures/Abstract Pattern/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/American Flag/bg.jpg b/extras/ninjam-vst3/textures/American Flag/bg.jpg new file mode 100644 index 00000000..0523a62f Binary files /dev/null and b/extras/ninjam-vst3/textures/American Flag/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/American Flag/fknob.png b/extras/ninjam-vst3/textures/American Flag/fknob.png new file mode 100644 index 00000000..18b0c040 Binary files /dev/null and b/extras/ninjam-vst3/textures/American Flag/fknob.png differ diff --git a/extras/ninjam-vst3/textures/American Flag/rknob.png b/extras/ninjam-vst3/textures/American Flag/rknob.png new file mode 100644 index 00000000..7c48c68a Binary files /dev/null and b/extras/ninjam-vst3/textures/American Flag/rknob.png differ diff --git a/extras/ninjam-vst3/textures/American Flag/skin.cfg b/extras/ninjam-vst3/textures/American Flag/skin.cfg new file mode 100644 index 00000000..adf3b1a5 --- /dev/null +++ b/extras/ninjam-vst3/textures/American Flag/skin.cfg @@ -0,0 +1,9 @@ +# This is a comment - ignored by the parser +# knobs: grey or gold +knobs: + +# Colours use 6-digit hex without the # +Metronome Colour: FFFFFF +Window Colour: 5E1600 +Button Colour: 002F5E +MenuBar Colour: 002F5E diff --git a/extras/ninjam-vst3/textures/Brushed Metal 1/bg.jpg b/extras/ninjam-vst3/textures/Brushed Metal 1/bg.jpg new file mode 100644 index 00000000..4cdd75d6 Binary files /dev/null and b/extras/ninjam-vst3/textures/Brushed Metal 1/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Brushed Metal 2/bg.jpg b/extras/ninjam-vst3/textures/Brushed Metal 2/bg.jpg new file mode 100644 index 00000000..b21f0373 Binary files /dev/null and b/extras/ninjam-vst3/textures/Brushed Metal 2/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Brushed Metal 3/bg.jpg b/extras/ninjam-vst3/textures/Brushed Metal 3/bg.jpg new file mode 100644 index 00000000..e22375b4 Binary files /dev/null and b/extras/ninjam-vst3/textures/Brushed Metal 3/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Brushed Metal 4/bg.jpg b/extras/ninjam-vst3/textures/Brushed Metal 4/bg.jpg new file mode 100644 index 00000000..fcae35ce Binary files /dev/null and b/extras/ninjam-vst3/textures/Brushed Metal 4/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Brushed Metal 5/bg.jpg b/extras/ninjam-vst3/textures/Brushed Metal 5/bg.jpg new file mode 100644 index 00000000..f864ee10 Binary files /dev/null and b/extras/ninjam-vst3/textures/Brushed Metal 5/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Brushed Metal 6/bg.jpg b/extras/ninjam-vst3/textures/Brushed Metal 6/bg.jpg new file mode 100644 index 00000000..52775ab3 Binary files /dev/null and b/extras/ninjam-vst3/textures/Brushed Metal 6/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Fabric 1/bg.jpg b/extras/ninjam-vst3/textures/Fabric 1/bg.jpg new file mode 100644 index 00000000..f54d312f Binary files /dev/null and b/extras/ninjam-vst3/textures/Fabric 1/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Fabric 1/fknob.png b/extras/ninjam-vst3/textures/Fabric 1/fknob.png new file mode 100644 index 00000000..bd6bed6c Binary files /dev/null and b/extras/ninjam-vst3/textures/Fabric 1/fknob.png differ diff --git a/extras/ninjam-vst3/textures/Fabric 1/rknob.png b/extras/ninjam-vst3/textures/Fabric 1/rknob.png new file mode 100644 index 00000000..9507e1f0 Binary files /dev/null and b/extras/ninjam-vst3/textures/Fabric 1/rknob.png differ diff --git a/extras/ninjam-vst3/textures/Gold Dust/bg.jpg b/extras/ninjam-vst3/textures/Gold Dust/bg.jpg new file mode 100644 index 00000000..a1bd8e7b Binary files /dev/null and b/extras/ninjam-vst3/textures/Gold Dust/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Gold Dust/bg.mp4 b/extras/ninjam-vst3/textures/Gold Dust/bg.mp4 new file mode 100644 index 00000000..8a461c9a Binary files /dev/null and b/extras/ninjam-vst3/textures/Gold Dust/bg.mp4 differ diff --git a/extras/ninjam-vst3/textures/Gold Dust/skin.cfg b/extras/ninjam-vst3/textures/Gold Dust/skin.cfg new file mode 100644 index 00000000..e7895e02 --- /dev/null +++ b/extras/ninjam-vst3/textures/Gold Dust/skin.cfg @@ -0,0 +1,9 @@ +# This is a comment - ignored by the parser +# knobs: grey or gold +knobs: gold + +# Colours use 6-digit hex without the # +Metronome Colour: FCDC44 +Window Colour: FFC61C +Button Colour: D19D00 +MenuBar Colour:D16C00 \ No newline at end of file diff --git a/extras/ninjam-vst3/textures/Grafiti Woman/bg.jpg b/extras/ninjam-vst3/textures/Grafiti Woman/bg.jpg new file mode 100644 index 00000000..a90b9b05 Binary files /dev/null and b/extras/ninjam-vst3/textures/Grafiti Woman/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Green Abstract/bg.jpg b/extras/ninjam-vst3/textures/Green Abstract/bg.jpg new file mode 100644 index 00000000..cecb49aa Binary files /dev/null and b/extras/ninjam-vst3/textures/Green Abstract/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Green Paint/bg.jpg b/extras/ninjam-vst3/textures/Green Paint/bg.jpg new file mode 100644 index 00000000..3e60f768 Binary files /dev/null and b/extras/ninjam-vst3/textures/Green Paint/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Pebbles/bg.jpg b/extras/ninjam-vst3/textures/Pebbles/bg.jpg new file mode 100644 index 00000000..dbde33bd Binary files /dev/null and b/extras/ninjam-vst3/textures/Pebbles/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Pebbles/fknob.png b/extras/ninjam-vst3/textures/Pebbles/fknob.png new file mode 100644 index 00000000..b07da7c6 Binary files /dev/null and b/extras/ninjam-vst3/textures/Pebbles/fknob.png differ diff --git a/extras/ninjam-vst3/textures/Pebbles/rknob.png b/extras/ninjam-vst3/textures/Pebbles/rknob.png new file mode 100644 index 00000000..61e86d1f Binary files /dev/null and b/extras/ninjam-vst3/textures/Pebbles/rknob.png differ diff --git a/extras/ninjam-vst3/textures/Pencils Yellow/bg.jpg b/extras/ninjam-vst3/textures/Pencils Yellow/bg.jpg new file mode 100644 index 00000000..23344fde Binary files /dev/null and b/extras/ninjam-vst3/textures/Pencils Yellow/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Pencils Yellow/skin.cfg b/extras/ninjam-vst3/textures/Pencils Yellow/skin.cfg new file mode 100644 index 00000000..5630ad5f --- /dev/null +++ b/extras/ninjam-vst3/textures/Pencils Yellow/skin.cfg @@ -0,0 +1,9 @@ +# This is a comment - ignored by the parser +# knobs: grey or gold +knobs: + +# Colours use 6-digit hex without the # +Metronome Colour: f5f2b8 +Window Colour: c9c569 +Button Colour: adab5c +MenuBar Colour:d1ce6f \ No newline at end of file diff --git a/extras/ninjam-vst3/textures/Pink Glitter 1/bg.jpg b/extras/ninjam-vst3/textures/Pink Glitter 1/bg.jpg new file mode 100644 index 00000000..28a7b77a Binary files /dev/null and b/extras/ninjam-vst3/textures/Pink Glitter 1/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Pink Glitter 1/bg.mp4 b/extras/ninjam-vst3/textures/Pink Glitter 1/bg.mp4 new file mode 100644 index 00000000..af801d8e Binary files /dev/null and b/extras/ninjam-vst3/textures/Pink Glitter 1/bg.mp4 differ diff --git a/extras/ninjam-vst3/textures/Pink Glitter 1/fknob.png b/extras/ninjam-vst3/textures/Pink Glitter 1/fknob.png new file mode 100644 index 00000000..90a43857 Binary files /dev/null and b/extras/ninjam-vst3/textures/Pink Glitter 1/fknob.png differ diff --git a/extras/ninjam-vst3/textures/Pink Glitter 1/rknob.png b/extras/ninjam-vst3/textures/Pink Glitter 1/rknob.png new file mode 100644 index 00000000..c474bb27 Binary files /dev/null and b/extras/ninjam-vst3/textures/Pink Glitter 1/rknob.png differ diff --git a/extras/ninjam-vst3/textures/Pink Glitter 1/skin.cfg b/extras/ninjam-vst3/textures/Pink Glitter 1/skin.cfg new file mode 100644 index 00000000..124ac90e --- /dev/null +++ b/extras/ninjam-vst3/textures/Pink Glitter 1/skin.cfg @@ -0,0 +1,9 @@ +# This is a comment - ignored by the parser +# knobs: grey or gold +knobs: + +# Colours use 6-digit hex without the # +Metronome Colour: f5027c +Window Colour: 94014b +Button Colour: 820142 +MenuBar Colour:450e42 \ No newline at end of file diff --git a/extras/ninjam-vst3/textures/Pink Glitter 2/Attributions.txt b/extras/ninjam-vst3/textures/Pink Glitter 2/Attributions.txt new file mode 100644 index 00000000..be4ab55b --- /dev/null +++ b/extras/ninjam-vst3/textures/Pink Glitter 2/Attributions.txt @@ -0,0 +1,3 @@ + +Bhupinder Singh - Cinematic Pink Particles Glittering mp4 +https://www.vecteezy.com/video/7412640-cinematic-pink-particles-glittering \ No newline at end of file diff --git a/extras/ninjam-vst3/textures/Pink Glitter 2/bg.jpg b/extras/ninjam-vst3/textures/Pink Glitter 2/bg.jpg new file mode 100644 index 00000000..7293de95 Binary files /dev/null and b/extras/ninjam-vst3/textures/Pink Glitter 2/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Pink Glitter 2/bg.mp4 b/extras/ninjam-vst3/textures/Pink Glitter 2/bg.mp4 new file mode 100644 index 00000000..7ddf63f0 Binary files /dev/null and b/extras/ninjam-vst3/textures/Pink Glitter 2/bg.mp4 differ diff --git a/extras/ninjam-vst3/textures/Pink Glitter 2/fknob.png b/extras/ninjam-vst3/textures/Pink Glitter 2/fknob.png new file mode 100644 index 00000000..90a43857 Binary files /dev/null and b/extras/ninjam-vst3/textures/Pink Glitter 2/fknob.png differ diff --git a/extras/ninjam-vst3/textures/Pink Glitter 2/rknob.png b/extras/ninjam-vst3/textures/Pink Glitter 2/rknob.png new file mode 100644 index 00000000..c474bb27 Binary files /dev/null and b/extras/ninjam-vst3/textures/Pink Glitter 2/rknob.png differ diff --git a/extras/ninjam-vst3/textures/Pink Glitter 2/skin.cfg b/extras/ninjam-vst3/textures/Pink Glitter 2/skin.cfg new file mode 100644 index 00000000..7ff01806 --- /dev/null +++ b/extras/ninjam-vst3/textures/Pink Glitter 2/skin.cfg @@ -0,0 +1,9 @@ +# This is a comment - ignored by the parser +# knobs: grey or gold +knobs: + +# Colours use 6-digit hex without the # +Metronome Colour: fa0276 +Window Colour: 780239 +Button Colour: 80013c +MenuBar Colour:4f0125 \ No newline at end of file diff --git a/extras/ninjam-vst3/textures/Sand 1/bg.jpg b/extras/ninjam-vst3/textures/Sand 1/bg.jpg new file mode 100644 index 00000000..539d8fe7 Binary files /dev/null and b/extras/ninjam-vst3/textures/Sand 1/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Sand 1/fknob.png b/extras/ninjam-vst3/textures/Sand 1/fknob.png new file mode 100644 index 00000000..48fa8027 Binary files /dev/null and b/extras/ninjam-vst3/textures/Sand 1/fknob.png differ diff --git a/extras/ninjam-vst3/textures/Sand 1/rknob.png b/extras/ninjam-vst3/textures/Sand 1/rknob.png new file mode 100644 index 00000000..191f3776 Binary files /dev/null and b/extras/ninjam-vst3/textures/Sand 1/rknob.png differ diff --git a/extras/ninjam-vst3/textures/Sand 1/skin.cfg b/extras/ninjam-vst3/textures/Sand 1/skin.cfg new file mode 100644 index 00000000..a8d3199d --- /dev/null +++ b/extras/ninjam-vst3/textures/Sand 1/skin.cfg @@ -0,0 +1,9 @@ +# This is a comment - ignored by the parser +# knobs: grey or gold +knobs: + +# Colours use 6-digit hex without the # +Metronome Colour: FFF4C2 +Window Colour: F0EBBD +Button Colour: D0C02F +MenuBar Colour:F0EBBD \ No newline at end of file diff --git a/extras/ninjam-vst3/textures/Street Art City/bg.jpg b/extras/ninjam-vst3/textures/Street Art City/bg.jpg new file mode 100644 index 00000000..67d88ac8 Binary files /dev/null and b/extras/ninjam-vst3/textures/Street Art City/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Water Droplets/bg.jpg b/extras/ninjam-vst3/textures/Water Droplets/bg.jpg new file mode 100644 index 00000000..cd38ee85 Binary files /dev/null and b/extras/ninjam-vst3/textures/Water Droplets/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Water Fish Tank 2/bg.jpg b/extras/ninjam-vst3/textures/Water Fish Tank 2/bg.jpg new file mode 100644 index 00000000..765e80d3 Binary files /dev/null and b/extras/ninjam-vst3/textures/Water Fish Tank 2/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Water Fish Tank 2/bg.mp4 b/extras/ninjam-vst3/textures/Water Fish Tank 2/bg.mp4 new file mode 100644 index 00000000..ae2d85f5 Binary files /dev/null and b/extras/ninjam-vst3/textures/Water Fish Tank 2/bg.mp4 differ diff --git a/extras/ninjam-vst3/textures/Water Fish Tank 2/fknob.png b/extras/ninjam-vst3/textures/Water Fish Tank 2/fknob.png new file mode 100644 index 00000000..48fa8027 Binary files /dev/null and b/extras/ninjam-vst3/textures/Water Fish Tank 2/fknob.png differ diff --git a/extras/ninjam-vst3/textures/Water Fish Tank 2/rknob.png b/extras/ninjam-vst3/textures/Water Fish Tank 2/rknob.png new file mode 100644 index 00000000..191f3776 Binary files /dev/null and b/extras/ninjam-vst3/textures/Water Fish Tank 2/rknob.png differ diff --git a/extras/ninjam-vst3/textures/Water Fish Tank 2/skin.cfg b/extras/ninjam-vst3/textures/Water Fish Tank 2/skin.cfg new file mode 100644 index 00000000..b695a0cd --- /dev/null +++ b/extras/ninjam-vst3/textures/Water Fish Tank 2/skin.cfg @@ -0,0 +1,9 @@ +# This is a comment - ignored by the parser +# knobs: grey or gold +knobs: + +# Colours use 6-digit hex without the # +Metronome Colour: adff85 +Window Colour: 437a27 +Button Colour: 529630 +MenuBar Colour:2f571b \ No newline at end of file diff --git a/extras/ninjam-vst3/textures/Water Fish Tank/bg.jpg b/extras/ninjam-vst3/textures/Water Fish Tank/bg.jpg new file mode 100644 index 00000000..765e80d3 Binary files /dev/null and b/extras/ninjam-vst3/textures/Water Fish Tank/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Water Fish Tank/bg.mp4 b/extras/ninjam-vst3/textures/Water Fish Tank/bg.mp4 new file mode 100644 index 00000000..8d6584be Binary files /dev/null and b/extras/ninjam-vst3/textures/Water Fish Tank/bg.mp4 differ diff --git a/extras/ninjam-vst3/textures/Water Fish Tank/fknob.png b/extras/ninjam-vst3/textures/Water Fish Tank/fknob.png new file mode 100644 index 00000000..48fa8027 Binary files /dev/null and b/extras/ninjam-vst3/textures/Water Fish Tank/fknob.png differ diff --git a/extras/ninjam-vst3/textures/Water Fish Tank/rknob.png b/extras/ninjam-vst3/textures/Water Fish Tank/rknob.png new file mode 100644 index 00000000..191f3776 Binary files /dev/null and b/extras/ninjam-vst3/textures/Water Fish Tank/rknob.png differ diff --git a/extras/ninjam-vst3/textures/Water Fish Tank/skin.cfg b/extras/ninjam-vst3/textures/Water Fish Tank/skin.cfg new file mode 100644 index 00000000..f6473d38 --- /dev/null +++ b/extras/ninjam-vst3/textures/Water Fish Tank/skin.cfg @@ -0,0 +1,9 @@ +# This is a comment - ignored by the parser +# knobs: grey or gold +knobs: + +# Colours use 6-digit hex without the # +Metronome Colour: 35c1e82 +Window Colour: 185566 +Button Colour: #24849e +MenuBar Colour:144452 \ No newline at end of file diff --git a/extras/ninjam-vst3/textures/Water Sea/Attributions.txt b/extras/ninjam-vst3/textures/Water Sea/Attributions.txt new file mode 100644 index 00000000..9d799d88 --- /dev/null +++ b/extras/ninjam-vst3/textures/Water Sea/Attributions.txt @@ -0,0 +1,2 @@ +Mp4 Video by Mindaugas Volkis - Beautiful transparent blue sea on a paradise beach +https://www.vecteezy.com/video/1627130-beautiful-transparent-blue-sea-on-a-paradise-beach \ No newline at end of file diff --git a/extras/ninjam-vst3/textures/Water Sea/bg.jpg b/extras/ninjam-vst3/textures/Water Sea/bg.jpg new file mode 100644 index 00000000..3daac71b Binary files /dev/null and b/extras/ninjam-vst3/textures/Water Sea/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Water Sea/bg.mp4 b/extras/ninjam-vst3/textures/Water Sea/bg.mp4 new file mode 100644 index 00000000..f03eb51e Binary files /dev/null and b/extras/ninjam-vst3/textures/Water Sea/bg.mp4 differ diff --git a/extras/ninjam-vst3/textures/Water Sea/fknob.png b/extras/ninjam-vst3/textures/Water Sea/fknob.png new file mode 100644 index 00000000..48fa8027 Binary files /dev/null and b/extras/ninjam-vst3/textures/Water Sea/fknob.png differ diff --git a/extras/ninjam-vst3/textures/Water Sea/rknob.png b/extras/ninjam-vst3/textures/Water Sea/rknob.png new file mode 100644 index 00000000..191f3776 Binary files /dev/null and b/extras/ninjam-vst3/textures/Water Sea/rknob.png differ diff --git a/extras/ninjam-vst3/textures/Wood Monochrome/bg.png b/extras/ninjam-vst3/textures/Wood Monochrome/bg.png new file mode 100644 index 00000000..93756cab Binary files /dev/null and b/extras/ninjam-vst3/textures/Wood Monochrome/bg.png differ diff --git a/extras/ninjam-vst3/textures/Wood Monochrome/bgbak.jpg b/extras/ninjam-vst3/textures/Wood Monochrome/bgbak.jpg new file mode 100644 index 00000000..d4ca2596 Binary files /dev/null and b/extras/ninjam-vst3/textures/Wood Monochrome/bgbak.jpg differ diff --git a/extras/ninjam-vst3/textures/Wood Monochrome/fknob.png b/extras/ninjam-vst3/textures/Wood Monochrome/fknob.png new file mode 100644 index 00000000..d768ac7d Binary files /dev/null and b/extras/ninjam-vst3/textures/Wood Monochrome/fknob.png differ diff --git a/extras/ninjam-vst3/textures/Wood Monochrome/rknob.png b/extras/ninjam-vst3/textures/Wood Monochrome/rknob.png new file mode 100644 index 00000000..f0647b3e Binary files /dev/null and b/extras/ninjam-vst3/textures/Wood Monochrome/rknob.png differ diff --git a/extras/ninjam-vst3/textures/Wood Rustic/bg.jpg b/extras/ninjam-vst3/textures/Wood Rustic/bg.jpg new file mode 100644 index 00000000..ecf4570e Binary files /dev/null and b/extras/ninjam-vst3/textures/Wood Rustic/bg.jpg differ diff --git a/extras/ninjam-vst3/textures/Wood Rustic/fknob.png b/extras/ninjam-vst3/textures/Wood Rustic/fknob.png new file mode 100644 index 00000000..b79019dd Binary files /dev/null and b/extras/ninjam-vst3/textures/Wood Rustic/fknob.png differ diff --git a/extras/ninjam-vst3/textures/Wood Rustic/rknob.png b/extras/ninjam-vst3/textures/Wood Rustic/rknob.png new file mode 100644 index 00000000..fecc7afa Binary files /dev/null and b/extras/ninjam-vst3/textures/Wood Rustic/rknob.png differ diff --git a/extras/ninjam-vst3/textures/Wood Rustic/skin.cfg b/extras/ninjam-vst3/textures/Wood Rustic/skin.cfg new file mode 100644 index 00000000..9317cac6 --- /dev/null +++ b/extras/ninjam-vst3/textures/Wood Rustic/skin.cfg @@ -0,0 +1,2 @@ +Metronome Colour: #FFAF52 +Window Colour: #9E6921 \ No newline at end of file diff --git a/extras/ninjam-vst3/vcpkg.json b/extras/ninjam-vst3/vcpkg.json new file mode 100644 index 00000000..4f75a455 --- /dev/null +++ b/extras/ninjam-vst3/vcpkg.json @@ -0,0 +1,18 @@ +{ + "name": "ninjam-vst3", + "version": "1.0.0", + "dependencies": [ + { + "name": "ffmpeg", + "features": [ + "avcodec", + "avformat", + "avutil", + "swscale", + "x264", + "nvcodec", + "amf" + ] + } + ] +} diff --git a/jmde/fx/reaninjam/locchn.cpp b/jmde/fx/reaninjam/locchn.cpp index dcb0bd1c..dbdea5a1 100644 --- a/jmde/fx/reaninjam/locchn.cpp +++ b/jmde/fx/reaninjam/locchn.cpp @@ -28,7 +28,7 @@ #include #include #else -#include "../../../WDL/swell/swell.h" +#include "swell/swell.h" #endif #define SWAP(a,b,t) { t __tmp = (a); (a)=(b); (b)=__tmp; } @@ -38,11 +38,18 @@ #include "winclient.h" #include "resource.h" -#include "../../../WDL/wingui/wndsize.h" +#include "wingui/wndsize.h" extern HWND (*GetMainHwnd)(); extern HANDLE * (*GetIconThemePointer)(const char *name); +static INT_PTR ForwardToHostMainWindow(HWND self, UINT uMsg, WPARAM wParam, LPARAM lParam) +{ + HWND h = GetMainHwnd ? GetMainHwnd() : NULL; + if (h && h != self) return SendMessage(h,uMsg,wParam,lParam); + return 0; +} + class LocalChannelRec { @@ -144,7 +151,7 @@ static WDL_DLGRET LocalChannelItemProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, L case WM_CTLCOLORDLG: case WM_CTLCOLORSTATIC : case WM_DRAWITEM: - return SendMessage(GetMainHwnd(),uMsg,wParam,lParam); + return ForwardToHostMainWindow(hwndDlg,uMsg,wParam,lParam); case WM_INITDIALOG: { if (_this) @@ -602,7 +609,7 @@ static WDL_DLGRET LocalChannelListProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, L case WM_CTLCOLORDLG: case WM_CTLCOLORSTATIC : case WM_DRAWITEM: - return SendMessage(GetMainHwnd(),uMsg,wParam,lParam);; + return ForwardToHostMainWindow(hwndDlg,uMsg,wParam,lParam); } return 0; } @@ -809,7 +816,7 @@ WDL_DLGRET LocalOuterChannelListProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPA case WM_CTLCOLORDLG: case WM_CTLCOLORSTATIC : case WM_DRAWITEM: - return SendMessage(GetMainHwnd(),uMsg,wParam,lParam);; + return ForwardToHostMainWindow(hwndDlg,uMsg,wParam,lParam); case WM_DESTROY: g_local_channel_wnd=NULL; UninitializeCoolSB(hwndDlg); diff --git a/jmde/fx/reaninjam/remchn.cpp b/jmde/fx/reaninjam/remchn.cpp index 92da17fa..c0d50a6d 100644 --- a/jmde/fx/reaninjam/remchn.cpp +++ b/jmde/fx/reaninjam/remchn.cpp @@ -27,7 +27,7 @@ #include #include #else -#include "../../../WDL/swell/swell.h" +#include "swell/swell.h" #endif #define SWAP(a,b,t) { t __tmp = (a); (a)=(b); (b)=__tmp; } @@ -43,6 +43,13 @@ extern void (*format_timestr_pos)(double tpos, char *buf, int buflen, int modeov extern HWND (*GetMainHwnd)(); extern HANDLE * (*GetIconThemePointer)(const char *name); +static INT_PTR ForwardToHostMainWindow(HWND self, UINT uMsg, WPARAM wParam, LPARAM lParam) +{ + HWND h = GetMainHwnd ? GetMainHwnd() : NULL; + if (h && h != self) return SendMessage(h,uMsg,wParam,lParam); + return 0; +} + static WDL_DLGRET RemoteUserItemProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam) { static const struct { int id; const char *str; } s_accesslist[] = { @@ -77,7 +84,7 @@ static WDL_DLGRET RemoteUserItemProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPA case WM_CTLCOLORDLG: case WM_CTLCOLORSTATIC : case WM_DRAWITEM: - return SendMessage(GetMainHwnd(),uMsg,wParam,lParam);; + return ForwardToHostMainWindow(hwndDlg,uMsg,wParam,lParam); case WM_RCUSER_UPDATE: // update the items { g_client->m_remotechannel_rd_mutex.Enter(); @@ -171,7 +178,7 @@ static WDL_DLGRET RemoteChannelItemProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, case WM_CTLCOLORDLG: case WM_CTLCOLORSTATIC : case WM_DRAWITEM: - return SendMessage(GetMainHwnd(),uMsg,wParam,lParam);; + return ForwardToHostMainWindow(hwndDlg,uMsg,wParam,lParam); case WM_INITDIALOG: SetWindowLongPtr(hwndDlg,GWLP_USERDATA,0x0fffffff); @@ -519,7 +526,7 @@ static WDL_DLGRET RemoteChannelListProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, case WM_CTLCOLORDLG: case WM_CTLCOLORSTATIC : case WM_DRAWITEM: - return SendMessage(GetMainHwnd(),uMsg,wParam,lParam);; + return ForwardToHostMainWindow(hwndDlg,uMsg,wParam,lParam); case WM_DESTROY: m_children.Empty(); break; @@ -711,7 +718,7 @@ WDL_DLGRET RemoteOuterChannelListProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LP case WM_CTLCOLORDLG: case WM_CTLCOLORSTATIC : case WM_DRAWITEM: - return SendMessage(GetMainHwnd(),uMsg,wParam,lParam);; + return ForwardToHostMainWindow(hwndDlg,uMsg,wParam,lParam); case WM_DESTROY: UninitializeCoolSB(hwndDlg); g_remote_channel_wnd=NULL; diff --git a/jmde/fx/reaninjam/res.rc b/jmde/fx/reaninjam/res.rc index 27525d29..9d8d18d0 100644 --- a/jmde/fx/reaninjam/res.rc +++ b/jmde/fx/reaninjam/res.rc @@ -7,7 +7,7 @@ // // Generated from the TEXTINCLUDE 2 resource. // -#include "afxres.h" +#include ///////////////////////////////////////////////////////////////////////////// #undef APSTUDIO_READONLY_SYMBOLS @@ -32,7 +32,7 @@ END 2 TEXTINCLUDE BEGIN - "#include ""afxres.h""\r\n" + "#include \r\n" "\0" END @@ -119,13 +119,13 @@ BEGIN CONTROL "",IDC_LICENSETEXT,"RichEditChild",WS_BORDER | WS_VSCROLL | WS_TABSTOP | 0x2804,7,7,302,183 END -IDD_PREFS DIALOGEX 0, 0, 295, 137 +IDD_PREFS DIALOGEX 0, 0, 295, 170 STYLE DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | WS_POPUP | WS_CAPTION | WS_SYSMENU CAPTION "ReaNINJAM Preferences" FONT 8, "MS Shell Dlg", 0, 0, 0x0 BEGIN - DEFPUSHBUTTON "OK",IDOK,182,116,50,14 - PUSHBUTTON "Cancel",IDCANCEL,238,116,50,14 + DEFPUSHBUTTON "OK",IDOK,182,148,50,14 + PUSHBUTTON "Cancel",IDCANCEL,238,148,50,14 CONTROL "Save multitrack recordings for remixing later",IDC_SAVELOCAL, "Button",BS_AUTOCHECKBOX | WS_TABSTOP,7,7,174,10 CONTROL "Save uncompressed .WAV recordings too",IDC_SAVELOCALWAV, @@ -134,13 +134,21 @@ BEGIN EDITTEXT IDC_SESSIONDIR,7,45,235,12,ES_AUTOHSCROLL PUSHBUTTON "Browse...",IDC_BROWSE,246,45,42,12 LTEXT "Changes to the above settings will take effect on the next connection.",IDC_CHNOTE,7,61,281,8 - RTEXT "Input channels:",IDC_STATIC,7,76,69,8 - EDITTEXT IDC_INCH,78,74,20,12,ES_AUTOHSCROLL - RTEXT "Output channels:",IDC_STATIC,7,91,69,8 - EDITTEXT IDC_OUTCH,78,89,20,12,ES_AUTOHSCROLL CONTROL "Flash beat counter at start and every 16 beats",IDC_FLASH, - "Button",BS_AUTOCHECKBOX | WS_TABSTOP,7,105,200,10 + "Button",BS_AUTOCHECKBOX | WS_TABSTOP,7,76,200,10 + RTEXT "Input channels:",IDC_STATIC,7,90,69,8 + EDITTEXT IDC_INCH,78,88,20,12,ES_AUTOHSCROLL + RTEXT "Output channels:",IDC_STATIC,7,105,69,8 + EDITTEXT IDC_OUTCH,78,103,20,12,ES_AUTOHSCROLL COMBOBOX IDC_COMBO1,103,89,185,81,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP + LTEXT "Color theme:",IDC_STATIC,103,76,53,8 + COMBOBOX IDC_THEME,158,74,130,80,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP + LTEXT "Custom hue:",IDC_STATIC,103,120,44,8 + CONTROL "",IDC_THEME_HUE,"msctls_trackbar32",TBS_HORZ | WS_TABSTOP,149,117,106,18 + LTEXT "0",IDC_THEME_HUEVAL,258,120,30,8 + LTEXT "Brightness:",IDC_STATIC,103,138,44,8 + CONTROL "",IDC_THEME_BRIGHT,"msctls_trackbar32",TBS_HORZ | WS_TABSTOP,149,135,106,18 + LTEXT "70",IDC_THEME_BRIGHTVAL,258,138,30,8 END IDD_LOCALCHANNEL DIALOGEX 0, 0, 202, 49 diff --git a/jmde/fx/reaninjam/resource.h b/jmde/fx/reaninjam/resource.h index bd643ba6..2cf13434 100644 --- a/jmde/fx/reaninjam/resource.h +++ b/jmde/fx/reaninjam/resource.h @@ -91,6 +91,11 @@ #define IDC_INCH 1085 #define IDC_OUTCH 1086 #define IDC_COMBO1 1087 +#define IDC_THEME 1088 +#define IDC_THEME_HUE 1089 +#define IDC_THEME_BRIGHT 1090 +#define IDC_THEME_HUEVAL 1091 +#define IDC_THEME_BRIGHTVAL 1092 #define ID_LOCAL_CHANNEL_1 2011 #define ID_LOCAL_CHANNEL_2 2012 #define ID_LOCAL_CHANNEL_3 2013 @@ -135,7 +140,7 @@ #ifndef APSTUDIO_READONLY_SYMBOLS #define _APS_NEXT_RESOURCE_VALUE 123 #define _APS_NEXT_COMMAND_VALUE 40009 -#define _APS_NEXT_CONTROL_VALUE 1088 +#define _APS_NEXT_CONTROL_VALUE 1093 #define _APS_NEXT_SYMED_VALUE 101 #endif #endif diff --git a/jmde/fx/reaninjam/winclient.cpp b/jmde/fx/reaninjam/winclient.cpp index a77410fd..adf14cc2 100644 --- a/jmde/fx/reaninjam/winclient.cpp +++ b/jmde/fx/reaninjam/winclient.cpp @@ -36,7 +36,7 @@ #define strncasecmp strnicmp #else #define PREF_DIRSTR "/" -#include "../../../WDL/swell/swell.h" +#include "swell/swell.h" #define RemoveDirectory(x) (!rmdir(x)) #define GetDesktopWindow() ((HWND)0) #endif @@ -46,20 +46,21 @@ #include "resource.h" -#include "../../../WDL/wingui/wndsize.h" -#include "../../../WDL/dirscan.h" -#include "../../../WDL/lineparse.h" +#include "wingui/wndsize.h" +#include "dirscan.h" +#include "lineparse.h" -#include "../../../WDL/jnetlib/httpget.h" -#include "../../../WDL/wdlcstring.h" +#include "jnetlib/httpget.h" +#include "wdlcstring.h" +#define WDL_WIN32_HIDPI_IMPL #include "winclient.h" #ifdef _WIN32 -#include "../../../WDL/win32_utf8.c" +#include "win32_utf8.c" #endif -#include "../../../WDL/setthreadname.h" +#include "setthreadname.h" extern HWND (*GetMainHwnd)(); extern HANDLE * (*GetIconThemePointer)(const char *name); @@ -74,6 +75,7 @@ extern void (*GetSet_LoopTimeRange2)(void* proj, bool isSet, bool isLoop, double extern int (*GetSetRepeatEx)(void* proj, int val); extern double (*GetCursorPositionEx)(void *proj); extern void (*Main_OnCommandEx)(int command, int flag, void *proj); +extern void request_vst3_io_change(); class VSTEffectClass; extern VSTEffectClass *g_vst_object; @@ -95,7 +97,109 @@ static int g_connect_passremember, g_connect_anon; static RECT g_last_wndpos; static int g_last_wndpos_state; static int g_config_appear=0; // &1=don't flash beat counter on !(beat%16) +static int g_config_theme=0; +static int g_config_custom_hue=210; +static int g_config_custom_bright=70; static bool s_want_sync; +static bool s_has_connect_attempt = false; + +struct ThemeDef +{ + const char *name; + COLORREF ui_bg; + COLORREF ui_text; + COLORREF status_bg; + COLORREF status_fg; + COLORREF status_info; + COLORREF div_hi; + COLORREF div_lo; +}; + +static const ThemeDef s_themes[] = { + { "Classic Green", RGB(236,236,236), RGB(0,0,0), RGB(0,0,0), RGB(128,255,128), RGB(0,128,255), RGB(255,255,255), RGB(96,96,96) }, + { "Ocean Blue", RGB(224,234,246), RGB(16,28,44), RGB(8,18,34), RGB(120,210,255), RGB(80,170,255), RGB(248,252,255), RGB(70,96,126) }, + { "Sunset Orange", RGB(248,232,220), RGB(46,24,14), RGB(28,12,6), RGB(255,190,120), RGB(255,130,90), RGB(255,250,238), RGB(150,96,70) }, + { "Forest", RGB(224,238,224), RGB(12,36,12), RGB(8,22,10), RGB(156,242,158), RGB(104,210,146), RGB(242,255,242), RGB(74,110,74) }, + { "Plum Night", RGB(228,220,236), RGB(28,16,40), RGB(16,8,28), RGB(224,170,255), RGB(176,124,255), RGB(252,244,255), RGB(110,86,138) }, +}; + +static ThemeDef g_theme = s_themes[0]; +static HBRUSH g_theme_brush = NULL; + +static int GetPresetThemeCount() +{ + return (int)(sizeof(s_themes)/sizeof(s_themes[0])); +} + +static int GetCustomThemeIndex() +{ + return GetPresetThemeCount(); +} + +static int GetThemeCount() +{ + return GetPresetThemeCount()+1; +} + +static COLORREF MakeColor(double r, double g, double b) +{ + int ir = wdl_clamp((int)(r*255.0 + 0.5),0,255); + int ig = wdl_clamp((int)(g*255.0 + 0.5),0,255); + int ib = wdl_clamp((int)(b*255.0 + 0.5),0,255); + return RGB(ir,ig,ib); +} + +static COLORREF HSVToRGB(double h, double s, double v) +{ + while (h < 0.0) h += 360.0; + while (h >= 360.0) h -= 360.0; + s = wdl_clamp(s,0.0,1.0); + v = wdl_clamp(v,0.0,1.0); + if (s <= 0.00001) return MakeColor(v,v,v); + + const double hh = h/60.0; + const int i = ((int)hh) % 6; + const double ff = hh - (int)hh; + const double p = v*(1.0-s); + const double q = v*(1.0-(s*ff)); + const double t = v*(1.0-(s*(1.0-ff))); + + switch (i) + { + case 0: return MakeColor(v,t,p); + case 1: return MakeColor(q,v,p); + case 2: return MakeColor(p,v,t); + case 3: return MakeColor(p,q,v); + case 4: return MakeColor(t,p,v); + default:return MakeColor(v,p,q); + } +} + +static void BuildCustomTheme(ThemeDef *outTheme) +{ + if (!outTheme) return; + const double hue = (double)wdl_clamp(g_config_custom_hue,0,359); + const double bright = (double)wdl_clamp(g_config_custom_bright,35,100) / 100.0; + + outTheme->name = "Custom"; + outTheme->ui_bg = HSVToRGB(hue, 0.16, 0.78 + bright*0.2); + outTheme->ui_text = HSVToRGB(hue, 0.28, 0.16 + (1.0-bright)*0.06); + outTheme->status_bg = HSVToRGB(hue, 0.72, 0.10 + bright*0.14); + outTheme->status_fg = HSVToRGB(hue, 0.44, 0.78 + bright*0.22); + outTheme->status_info = HSVToRGB(hue+32.0, 0.56, 0.62 + bright*0.30); + outTheme->div_hi = HSVToRGB(hue, 0.08, 0.92 + bright*0.06); + outTheme->div_lo = HSVToRGB(hue, 0.24, 0.42 + bright*0.20); +} + +static void ApplyTheme(int idx) +{ + idx = wdl_clamp(idx,0,GetThemeCount()-1); + g_config_theme = idx; + if (idx == GetCustomThemeIndex()) BuildCustomTheme(&g_theme); + else g_theme = s_themes[idx]; + if (g_theme_brush) DeleteObject(g_theme_brush); + g_theme_brush = CreateSolidBrush(g_theme.ui_bg); +} int g_config_num_inputs = 8, g_config_num_outputs = 6; @@ -111,6 +215,22 @@ static void GetDefaultSessionDir(char *str, int strsize) strcat(str,PREF_DIRSTR "NINJAMsessions"); } +#ifdef _WIN32 +static void SetIniPathToLocalAppData() +{ + char appData[MAX_PATH] = {0}; + if (SHGetFolderPath(NULL, CSIDL_LOCAL_APPDATA, NULL, SHGFP_TYPE_CURRENT, appData) != S_OK || !appData[0]) + return; + + lstrcpyn(g_inipath, appData, sizeof(g_inipath)); + WDL_remove_trailing_dirchars(g_inipath); + lstrcatn(g_inipath, PREF_DIRSTR "ReaNINJAM", (int)sizeof(g_inipath)); + CreateDirectory(g_inipath, NULL); + WDL_remove_trailing_dirchars(g_inipath); + lstrcatn(g_inipath, PREF_DIRSTR, (int)sizeof(g_inipath)); +} +#endif + void audiostream_onsamples(float **inbuf, int innch, float **outbuf, int outnch, int len, int srate, bool isPlaying, bool isSeek, double curpos) { if (/*!g_audio_enable||*/!g_client) @@ -143,14 +263,68 @@ void audiostream_onsamples(float **inbuf, int innch, float **outbuf, int outnch, g_client->AudioProc(inbuf,innch, outbuf, outnch, len,srate,!g_audio_enable, isPlaying, isSeek,curpos); } +static void Prefs_UpdateThemeSliderLabels(HWND hwndDlg) +{ + char buf[64]; + snprintf(buf,sizeof(buf), "%d deg", wdl_clamp(g_config_custom_hue,0,359)); + SetDlgItemText(hwndDlg,IDC_THEME_HUEVAL,buf); + snprintf(buf,sizeof(buf), "%d%%", wdl_clamp(g_config_custom_bright,35,100)); + SetDlgItemText(hwndDlg,IDC_THEME_BRIGHTVAL,buf); +} + +static void Prefs_SetThemeSliderEnabled(HWND hwndDlg, bool enabled) +{ + EnableWindow(GetDlgItem(hwndDlg,IDC_THEME_HUE),enabled); + EnableWindow(GetDlgItem(hwndDlg,IDC_THEME_BRIGHT),enabled); + EnableWindow(GetDlgItem(hwndDlg,IDC_THEME_HUEVAL),enabled); + EnableWindow(GetDlgItem(hwndDlg,IDC_THEME_BRIGHTVAL),enabled); +} + +static void RefreshThemePaint() +{ + if (g_hwnd) InvalidateRect(g_hwnd,NULL,TRUE); + if (m_locwnd) InvalidateRect(m_locwnd,NULL,TRUE); + if (m_remwnd) InvalidateRect(m_remwnd,NULL,TRUE); +} + +static void Prefs_ApplyThemePreview(HWND hwndDlg) +{ + const int custom_idx = GetCustomThemeIndex(); + int theme = (int)SendDlgItemMessage(hwndDlg,IDC_THEME,CB_GETCURSEL,0,0); + if (theme < 0) theme = g_config_theme; + + if (theme == custom_idx) + { + g_config_custom_hue = (int)SendDlgItemMessage(hwndDlg,IDC_THEME_HUE,TBM_GETPOS,0,0); + g_config_custom_hue = wdl_clamp(g_config_custom_hue,0,359); + g_config_custom_bright = (int)SendDlgItemMessage(hwndDlg,IDC_THEME_BRIGHT,TBM_GETPOS,0,0); + g_config_custom_bright = wdl_clamp(g_config_custom_bright,35,100); + Prefs_UpdateThemeSliderLabels(hwndDlg); + } + ApplyTheme(theme); + RefreshThemePaint(); +} + +static INT_PTR ForwardToHostMainWindow(HWND self, UINT uMsg, WPARAM wParam, LPARAM lParam) +{ + HWND h = GetMainHwnd ? GetMainHwnd() : NULL; + if (h && h != self) return SendMessage(h,uMsg,wParam,lParam); + return 0; +} + static WDL_DLGRET PrefsProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam) { + static int s_prev_theme, s_prev_hue, s_prev_bright; switch (uMsg) { case WM_INITDIALOG: { + s_prev_theme = g_config_theme; + s_prev_hue = g_config_custom_hue; + s_prev_bright = g_config_custom_bright; + if (GetPrivateProfileInt(CONFSEC,"savelocal",1,g_ini_file.Get())) CheckDlgButton(hwndDlg,IDC_SAVELOCAL,BST_CHECKED); if (GetPrivateProfileInt(CONFSEC,"savelocalwav",0,g_ini_file.Get())) @@ -166,6 +340,18 @@ static WDL_DLGRET PrefsProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lPara SendDlgItemMessage(hwndDlg,IDC_COMBO1,CB_ADDSTRING, 0, (LPARAM)__LOCALIZE("Auto assign remote users to pairs 3-n","reaninjam")); SendDlgItemMessage(hwndDlg,IDC_COMBO1,CB_SETCURSEL, g_client->config_remote_autochan, 0); + WDL_UTF8_HookComboBox(GetDlgItem(hwndDlg,IDC_THEME)); + for (int i = 0; i < GetPresetThemeCount(); ++i) + SendDlgItemMessage(hwndDlg,IDC_THEME,CB_ADDSTRING, 0, (LPARAM)s_themes[i].name); + SendDlgItemMessage(hwndDlg,IDC_THEME,CB_ADDSTRING, 0, (LPARAM)"Custom"); + SendDlgItemMessage(hwndDlg,IDC_THEME,CB_SETCURSEL, g_config_theme, 0); + SendDlgItemMessage(hwndDlg,IDC_THEME_HUE,TBM_SETRANGE,TRUE,MAKELONG(0,359)); + SendDlgItemMessage(hwndDlg,IDC_THEME_HUE,TBM_SETPOS,TRUE,wdl_clamp(g_config_custom_hue,0,359)); + SendDlgItemMessage(hwndDlg,IDC_THEME_BRIGHT,TBM_SETRANGE,TRUE,MAKELONG(35,100)); + SendDlgItemMessage(hwndDlg,IDC_THEME_BRIGHT,TBM_SETPOS,TRUE,wdl_clamp(g_config_custom_bright,35,100)); + Prefs_UpdateThemeSliderLabels(hwndDlg); + Prefs_SetThemeSliderEnabled(hwndDlg,g_config_theme==GetCustomThemeIndex()); + char str[2048]; GetPrivateProfileString(CONFSEC,"sessiondir","",str,sizeof(str),g_ini_file.Get()); if (!str[0]) @@ -209,6 +395,14 @@ static WDL_DLGRET PrefsProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lPara // todo: macport #endif break; + case IDC_THEME: + if (HIWORD(wParam) == CBN_SELCHANGE) + { + int theme = (int)SendDlgItemMessage(hwndDlg,IDC_THEME,CB_GETCURSEL,0,0); + Prefs_SetThemeSliderEnabled(hwndDlg,theme==GetCustomThemeIndex()); + Prefs_ApplyThemePreview(hwndDlg); + } + break; case IDOK: { WritePrivateProfileString(CONFSEC,"savelocal",IsDlgButtonChecked(hwndDlg,IDC_SAVELOCAL)?"1":"0",g_ini_file.Get()); @@ -228,6 +422,22 @@ static WDL_DLGRET PrefsProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lPara snprintf(buf, sizeof(buf), "%d", g_config_appear); WritePrivateProfileString(CONFSEC, "config_appear", buf, g_ini_file.Get()); + int theme = (int)SendDlgItemMessage(hwndDlg,IDC_THEME,CB_GETCURSEL,0,0); + if (theme >= 0) + { + g_config_custom_hue = (int)SendDlgItemMessage(hwndDlg,IDC_THEME_HUE,TBM_GETPOS,0,0); + g_config_custom_hue = wdl_clamp(g_config_custom_hue,0,359); + g_config_custom_bright = (int)SendDlgItemMessage(hwndDlg,IDC_THEME_BRIGHT,TBM_GETPOS,0,0); + g_config_custom_bright = wdl_clamp(g_config_custom_bright,35,100); + ApplyTheme(theme); + snprintf(buf, sizeof(buf), "%d", g_config_theme); + WritePrivateProfileString(CONFSEC, "color_theme", buf, g_ini_file.Get()); + snprintf(buf, sizeof(buf), "%d", g_config_custom_hue); + WritePrivateProfileString(CONFSEC, "color_custom_hue", buf, g_ini_file.Get()); + snprintf(buf, sizeof(buf), "%d", g_config_custom_bright); + WritePrivateProfileString(CONFSEC, "color_custom_bright", buf, g_ini_file.Get()); + } + int a = (int)SendDlgItemMessage(hwndDlg,IDC_COMBO1,CB_GETCURSEL,0,0); if (a >= 0) { @@ -267,16 +477,48 @@ static WDL_DLGRET PrefsProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lPara } } if (localref) SendMessage(m_locwnd, WM_LCUSER_REPOP_CH,0,0); + if (localref) request_vst3_io_change(); EndDialog(hwndDlg,1); } break; case IDCANCEL: + g_config_theme = s_prev_theme; + g_config_custom_hue = s_prev_hue; + g_config_custom_bright = s_prev_bright; + ApplyTheme(g_config_theme); + RefreshThemePaint(); EndDialog(hwndDlg,0); break; } return 0; + case WM_HSCROLL: + { + HWND hs = (HWND)lParam; + if (hs == GetDlgItem(hwndDlg,IDC_THEME_HUE)) + { + g_config_custom_hue = (int)SendMessage(hs,TBM_GETPOS,0,0); + g_config_custom_hue = wdl_clamp(g_config_custom_hue,0,359); + Prefs_UpdateThemeSliderLabels(hwndDlg); + if ((int)SendDlgItemMessage(hwndDlg,IDC_THEME,CB_GETCURSEL,0,0)==GetCustomThemeIndex()) + Prefs_ApplyThemePreview(hwndDlg); + } + else if (hs == GetDlgItem(hwndDlg,IDC_THEME_BRIGHT)) + { + g_config_custom_bright = (int)SendMessage(hs,TBM_GETPOS,0,0); + g_config_custom_bright = wdl_clamp(g_config_custom_bright,35,100); + Prefs_UpdateThemeSliderLabels(hwndDlg); + if ((int)SendDlgItemMessage(hwndDlg,IDC_THEME,CB_GETCURSEL,0,0)==GetCustomThemeIndex()) + Prefs_ApplyThemePreview(hwndDlg); + } + } + return 0; case WM_CLOSE: + g_config_theme = s_prev_theme; + g_config_custom_hue = s_prev_hue; + g_config_custom_bright = s_prev_bright; + ApplyTheme(g_config_theme); + RefreshThemePaint(); EndDialog(hwndDlg,0); return 0; } @@ -703,6 +945,7 @@ WDL_FastString g_last_status("---"); static void do_connect() { + s_has_connect_attempt = true; WDL_String userstr; if (g_connect_anon) { @@ -903,8 +1146,8 @@ LRESULT WINAPI ninjamDividerProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lPar PAINTSTRUCT ps; if (BeginPaint(hwnd,&ps)) { - HBRUSH br1 = CreateSolidBrush(GetSysColor(COLOR_3DHILIGHT)); - HBRUSH br2 = CreateSolidBrush(GetSysColor(COLOR_3DSHADOW)); + HBRUSH br1 = CreateSolidBrush(g_theme.div_hi); + HBRUSH br2 = CreateSolidBrush(g_theme.div_lo); RECT cr,r1; GetClientRect(hwnd,&cr); cr.bottom = cr.top+1; @@ -978,7 +1221,7 @@ LRESULT WINAPI ninjamStatusProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lPara GetClientRect(hwnd,&r); bool flip = !(g_config_appear&1) && want_numbers && last_bpm_i>0 && (last_interval_pos == 0 || (last_interval_len > 16 && (last_interval_len&15)==0 && !(last_interval_pos&15))); - int fg = RGB(128,255,128), bg=RGB(0,0,0); + int fg = g_theme.status_fg, bg = g_theme.status_bg; if (flip) { int tmp=fg; fg=bg; bg=tmp; } { @@ -1001,7 +1244,7 @@ LRESULT WINAPI ninjamStatusProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lPara font2 = CreateFontIndirect(&lf); } SetBkMode(ps.hdc,TRANSPARENT); - SetTextColor(ps.hdc,RGB(0,128,255)); + SetTextColor(ps.hdc,g_theme.status_info); HGDIOBJ oldfont = SelectObject(ps.hdc,font1); const int pad = fontsz > 12 ? 3 : 1; RECT tr = { r.left+pad,r.top+pad,r.right-pad,r.bottom-pad}; @@ -1103,11 +1346,20 @@ static WDL_DLGRET MainProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam { case WM_CTLCOLOREDIT: case WM_CTLCOLORLISTBOX: + return ForwardToHostMainWindow(hwndDlg,uMsg,wParam,lParam); case WM_CTLCOLORBTN: case WM_CTLCOLORDLG: case WM_CTLCOLORSTATIC : + { + if (!g_theme_brush) ApplyTheme(g_config_theme); + HDC hdc = (HDC)wParam; + SetBkMode(hdc,TRANSPARENT); + SetBkColor(hdc,g_theme.ui_bg); + SetTextColor(hdc,g_theme.ui_text); + return (INT_PTR)g_theme_brush; + } case WM_DRAWITEM: - return SendMessage(GetMainHwnd(),uMsg,wParam,lParam);; + return ForwardToHostMainWindow(hwndDlg,uMsg,wParam,lParam); case WM_INITDIALOG: { if (SetWindowAccessibilityString) @@ -1197,6 +1449,11 @@ static WDL_DLGRET MainProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam g_client->config_remote_autochan = GetPrivateProfileInt(CONFSEC,"remote_autochan", g_client->config_remote_autochan,g_ini_file.Get()); g_config_appear = GetPrivateProfileInt(CONFSEC, "config_appear", g_config_appear, g_ini_file.Get()); + g_config_custom_hue = GetPrivateProfileInt(CONFSEC, "color_custom_hue", g_config_custom_hue, g_ini_file.Get()); + g_config_custom_hue = wdl_clamp(g_config_custom_hue,0,359); + g_config_custom_bright = GetPrivateProfileInt(CONFSEC, "color_custom_bright", g_config_custom_bright, g_ini_file.Get()); + g_config_custom_bright = wdl_clamp(g_config_custom_bright,35,100); + ApplyTheme(GetPrivateProfileInt(CONFSEC, "color_theme", g_config_theme, g_ini_file.Get())); char tmp[512]; SendDlgItemMessage(hwndDlg,IDC_MASTERVOL,TBM_SETTIC,FALSE,-1); @@ -1436,7 +1693,11 @@ static WDL_DLGRET MainProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam InvalidateRect(GetDlgItem(hwndDlg,IDC_INTERVALPOS),NULL,FALSE); } - else if (errstr.Get()[0]) + else if (errstr.Get()[0] && + (s_has_connect_attempt || + (ns != NJClient::NJC_STATUS_DISCONNECTED && + ns != NJClient::NJC_STATUS_INVALIDAUTH && + ns != NJClient::NJC_STATUS_CANTCONNECT))) { g_last_status.Set(__LOCALIZE("Status: ","reaninjam")); g_last_status.Append(errstr.Get()); @@ -1446,24 +1707,33 @@ static WDL_DLGRET MainProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam { if (ns == NJClient::NJC_STATUS_DISCONNECTED) { - g_last_status.Set(__LOCALIZE("Status: disconnected from host.","reaninjam")); + g_last_status.Set(s_has_connect_attempt ? + __LOCALIZE("Status: disconnected from host.","reaninjam") : + __LOCALIZE("Status: disconnected.","reaninjam")); InvalidateRect(GetDlgItem(hwndDlg,IDC_INTERVALPOS),NULL,FALSE); - MessageBox(g_hwnd,__LOCALIZE("Disconnected from host!","reaninjam"), - __LOCALIZE("NINJAM Notice","reaninjam"), MB_OK); + if (s_has_connect_attempt) + MessageBox(g_hwnd,__LOCALIZE("Disconnected from host!","reaninjam"), + __LOCALIZE("NINJAM Notice","reaninjam"), MB_OK); } if (ns == NJClient::NJC_STATUS_INVALIDAUTH) { - g_last_status.Set(__LOCALIZE("Status: invalid authentication info.","reaninjam")); - InvalidateRect(GetDlgItem(hwndDlg,IDC_INTERVALPOS),NULL,FALSE); - MessageBox(g_hwnd,__LOCALIZE("Error connecting: invalid authentication information!","reaninjam"), - __LOCALIZE("NINJAM Error","reaninjam"), MB_OK); + if (s_has_connect_attempt) + { + g_last_status.Set(__LOCALIZE("Status: invalid authentication info.","reaninjam")); + InvalidateRect(GetDlgItem(hwndDlg,IDC_INTERVALPOS),NULL,FALSE); + MessageBox(g_hwnd,__LOCALIZE("Error connecting: invalid authentication information!","reaninjam"), + __LOCALIZE("NINJAM Error","reaninjam"), MB_OK); + } } if (ns == NJClient::NJC_STATUS_CANTCONNECT) { - g_last_status.Set(__LOCALIZE("Status: can't connect to host.","reaninjam")); - InvalidateRect(GetDlgItem(hwndDlg,IDC_INTERVALPOS),NULL,FALSE); - MessageBox(g_hwnd,__LOCALIZE("Error connecting: can't connect to host!","reaninjam"), - __LOCALIZE("NINJAM Error","reaninjam"), MB_OK); + if (s_has_connect_attempt) + { + g_last_status.Set(__LOCALIZE("Status: can't connect to host.","reaninjam")); + InvalidateRect(GetDlgItem(hwndDlg,IDC_INTERVALPOS),NULL,FALSE); + MessageBox(g_hwnd,__LOCALIZE("Error connecting: can't connect to host!","reaninjam"), + __LOCALIZE("NINJAM Error","reaninjam"), MB_OK); + } } } } @@ -1896,6 +2166,9 @@ static WDL_DLGRET MainProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam break; case ID_OPTIONS_PREFERENCES: DialogBox(g_hInst,MAKEINTRESOURCE(IDD_PREFS),hwndDlg,PrefsProc); + InvalidateRect(hwndDlg,NULL,TRUE); + if (m_locwnd) InvalidateRect(m_locwnd,NULL,TRUE); + if (m_remwnd) InvalidateRect(m_remwnd,NULL,TRUE); break; case IDC_CHATOK: { @@ -2283,6 +2556,14 @@ void InitializeInstance() lstrcpyn(g_inipath,g_ini_file.Get(),sizeof(g_inipath)); g_ini_file.Append(PREF_DIRSTR "reaninjam.ini"); } + else + { +#ifdef _WIN32 + SetIniPathToLocalAppData(); + g_ini_file.Set(g_inipath); + g_ini_file.Append("reaninjam.ini"); +#endif + } // use reaper.ini path } @@ -2363,6 +2644,11 @@ void QuitInstance() g_done=0; m_locwnd=m_remwnd=0; g_audio_enable=0; + if (g_theme_brush) + { + DeleteObject(g_theme_brush); + g_theme_brush = NULL; + } } //UnregisterClass("RichEditChild",NULL); } diff --git a/ninjam/mpb.cpp b/ninjam/mpb.cpp index 17590561..6a9281a7 100644 --- a/ninjam/mpb.cpp +++ b/ninjam/mpb.cpp @@ -473,6 +473,54 @@ Net_Message *mpb_server_download_interval_write::build() return nm; } +int mpb_server_codec_config::parse(Net_Message *msg) +{ + if (msg->get_type() != MESSAGE_SERVER_CODEC_CONFIG) return -1; + if (msg->get_size() < 8) return 1; + const unsigned char *p=(const unsigned char *)msg->get_data(); + if (!p) return 2; + + vorbis_mask = ((unsigned int)p[0]); + vorbis_mask |= ((unsigned int)p[1])<<8; + vorbis_mask |= ((unsigned int)p[2])<<16; + vorbis_mask |= ((unsigned int)p[3])<<24; + + opus_mask = ((unsigned int)p[4]); + opus_mask |= ((unsigned int)p[5])<<8; + opus_mask |= ((unsigned int)p[6])<<16; + opus_mask |= ((unsigned int)p[7])<<24; + + return 0; +} + +Net_Message *mpb_server_codec_config::build() +{ + Net_Message *nm=new Net_Message; + nm->set_type(MESSAGE_SERVER_CODEC_CONFIG); + + nm->set_size(8); + + unsigned char *p=(unsigned char *)nm->get_data(); + + if (!p) + { + delete nm; + return 0; + } + + p[0] = (unsigned char)(vorbis_mask & 0xff); + p[1] = (unsigned char)((vorbis_mask >> 8) & 0xff); + p[2] = (unsigned char)((vorbis_mask >> 16) & 0xff); + p[3] = (unsigned char)((vorbis_mask >> 24) & 0xff); + + p[4] = (unsigned char)(opus_mask & 0xff); + p[5] = (unsigned char)((opus_mask >> 8) & 0xff); + p[6] = (unsigned char)((opus_mask >> 16) & 0xff); + p[7] = (unsigned char)((opus_mask >> 24) & 0xff); + + return nm; +} + diff --git a/ninjam/mpb.h b/ninjam/mpb.h index eaff3f88..92b5a1b5 100644 --- a/ninjam/mpb.h +++ b/ninjam/mpb.h @@ -156,6 +156,20 @@ class mpb_server_download_interval_write int audio_data_len; // not encoded in, just used internally }; +#define MESSAGE_SERVER_CODEC_CONFIG 0x06 +class mpb_server_codec_config +{ + public: + mpb_server_codec_config() : vorbis_mask(0), opus_mask(0) { } + ~mpb_server_codec_config() { } + + int parse(Net_Message *msg); // return 0 on success + Net_Message *build(); + + unsigned int vorbis_mask; + unsigned int opus_mask; +}; + diff --git a/ninjam/netmsg.h b/ninjam/netmsg.h index 583dcc94..cfc362b1 100644 --- a/ninjam/netmsg.h +++ b/ninjam/netmsg.h @@ -35,7 +35,7 @@ #include #endif -#define NET_MESSAGE_MAX_SIZE 16384 +#define NET_MESSAGE_MAX_SIZE 1048576 #define NET_CON_MAX_MESSAGES 512 diff --git a/ninjam/njclient.cpp b/ninjam/njclient.cpp index bfcc808a..03193991 100644 --- a/ninjam/njclient.cpp +++ b/ninjam/njclient.cpp @@ -33,8 +33,21 @@ #include "../WDL/wdlcstring.h" #include "../WDL/win32_utf8.h" +#include "opus.h" #define NJ_ENCODER_FMT_TYPE MAKE_NJ_FOURCC('O','G','G','v') +#define NJ_ENCODER_FMT_TYPE_OPUS MAKE_NJ_FOURCC('O','P','U','S') + +static inline float softclip_to_minus2db(float x) +{ + const float k = 2.0f; + const float d = tanhf(k); + const float c = d / k; + const float target = 0.794328f; + float y = tanhf(k * c * x); + if (d != 0.0f) y = (y / d) * target; + return y; +} #ifdef REANINJAM #define WDL_VORBIS_INTERFACE_ONLY @@ -46,6 +59,282 @@ #undef VorbisEncoderInterface #undef VorbisDecoderInterface +class OpusEncoderCodec : public I_NJEncoder +{ +public: + OpusEncoderCodec(int srate, int nch, int bitrate, int) + { + int err = 0; + enc = opus_encoder_create(srate, nch, OPUS_APPLICATION_AUDIO, &err); + if (err != OPUS_OK || !enc) + { + enc = NULL; + errFlag = 1; + return; + } + channels = nch; + errFlag = 0; + frameSize = srate / 50; + if (frameSize <= 0) frameSize = 960; + pcmBuf.Resize(frameSize * channels * (int)sizeof(float)); + pcmWritePos = 0; + opus_encoder_ctl(enc, OPUS_SET_BITRATE(bitrate * 1000)); + } + + ~OpusEncoderCodec() + { + if (enc) opus_encoder_destroy(enc); + } + + void Encode(float *in, int inlen, int advance=1, int spacing=1) + { + if (!enc || errFlag) return; + if (!in || inlen <= 0) return; + + int samples = inlen; + int pos = 0; + while (samples > 0) + { + int space = frameSize - pcmWritePos; + int toCopy = samples; + if (toCopy > space) toCopy = space; + + float *dst = (float *)pcmBuf.Get() + pcmWritePos * channels; + if (channels == 1) + { + for (int i = 0; i < toCopy; ++i) + { + dst[i] = in[pos]; + pos += advance; + } + } + else if (channels == 2) + { + for (int i = 0; i < toCopy; ++i) + { + dst[i*2] = in[pos]; + dst[i*2+1] = in[pos + spacing]; + pos += advance * 2; + } + } + else + { + for (int i = 0; i < toCopy; ++i) + { + for (int c = 0; c < channels; ++c) + { + dst[i*channels + c] = in[pos + c*spacing]; + } + pos += advance * channels; + } + } + + pcmWritePos += toCopy; + samples -= toCopy; + + if (pcmWritePos == frameSize) + { + encodeFrame(); + pcmWritePos = 0; + } + } + } + + int isError() + { + return errFlag; + } + + int Available() + { + return outQueue.Available(); + } + + void *Get() + { + return outQueue.Get(); + } + + void Advance(int amt) + { + outQueue.Advance(amt); + } + + void Compact() + { + outQueue.Compact(); + } + + void reinit(int=0) + { + outQueue.Advance(outQueue.Available()); + outQueue.Compact(); + pcmWritePos = 0; + } + +private: + void encodeFrame() + { + if (!enc) return; + unsigned char packet[4096]; + int bytes = opus_encode_float(enc, (const float *)pcmBuf.Get(), frameSize, packet, (opus_int32)sizeof(packet)); + if (bytes <= 0) return; + unsigned char header[2]; + header[0] = (unsigned char)(bytes & 0xff); + header[1] = (unsigned char)((bytes >> 8) & 0xff); + outQueue.Add(header, (int)sizeof(header)); + outQueue.Add(packet, bytes); + } + + OpusEncoder *enc; + int channels; + int frameSize; + int pcmWritePos; + int errFlag; + WDL_HeapBuf pcmBuf; + WDL_Queue outQueue; +}; + +class OpusDecoderCodec : public I_NJDecoder +{ +public: + OpusDecoderCodec() + { + dec = NULL; + channels = 0; + srate = 48000; + srcLen = 0; + errFlag = 0; + srcBuf.Resize(4096); + } + + ~OpusDecoderCodec() + { + if (dec) opus_decoder_destroy(dec); + } + + int GetSampleRate() + { + return srate; + } + + int GetNumChannels() + { + return channels ? channels : 1; + } + + void *DecodeGetSrcBuffer(int srclen) + { + if (srclen <= 0) return NULL; + if (srcBuf.GetSize() < srclen) srcBuf.Resize(srclen); + srcLen = srclen; + return srcBuf.Get(); + } + + void DecodeWrote(int) + { + if (srcLen <= 0) return; + + if (!dec) + { + int err = 0; + dec = opus_decoder_create(srate, 2, &err); + if (err != OPUS_OK || !dec) + { + dec = NULL; + errFlag = 1; + return; + } + channels = 2; + } + + packetQueue.Add(srcBuf.Get(), srcLen); + + while (packetQueue.Available() >= 2) + { + const unsigned char *raw = (const unsigned char *)packetQueue.Get(); + if (!raw) break; + + const int packetLen = (int)raw[0] | ((int)raw[1] << 8); + if (packetLen <= 0 || packetLen > 4000) + { + const int avail = packetQueue.Available(); + if (avail <= 0) break; + decodePacket(raw, wdl_min(avail, 4000)); + packetQueue.Advance(wdl_min(avail, 4000)); + continue; + } + + if (packetQueue.Available() < packetLen + 2) + break; + + decodePacket(raw + 2, packetLen); + packetQueue.Advance(packetLen + 2); + } + + packetQueue.Compact(); + } + + void Reset() + { + pcmQueue.Clear(); + packetQueue.Clear(); + srcLen = 0; + } + + int Available() + { + return pcmQueue.Available(); + } + + float *Get() + { + return (float *)pcmQueue.Get(); + } + + void Skip(int amt) + { + pcmQueue.Advance(amt); + pcmQueue.Compact(); + } + + int GenerateLappingSamples() + { + return 0; + } + +private: + void decodePacket(const unsigned char *data, int len) + { + if (!dec || !data || len <= 0) return; + + int maxSamples = srate / 50; + if (maxSamples <= 0) maxSamples = 960; + + float *buf = pcmQueue.Add(NULL, maxSamples * channels); + if (!buf) return; + + int decoded = opus_decode_float(dec, data, len, buf, maxSamples, 0); + if (decoded <= 0) + { + pcmQueue.Advance(-maxSamples * channels); + return; + } + + if (decoded < maxSamples) + pcmQueue.Advance(-(maxSamples - decoded) * channels); + } + + OpusDecoder *dec; + int channels; + int srate; + int srcLen; + int errFlag; + WDL_HeapBuf srcBuf; + WDL_Queue packetQueue; + WDL_TypedQueue pcmQueue; +}; + #ifdef REANINJAM extern void *(*CreateVorbisEncoder)(int srate, int nch, int serno, float qv, int cbr, int minbr, int maxbr); extern void *(*CreateVorbisDecoder)(); @@ -362,6 +651,25 @@ class RemoteDownload DecodeMediaBuffer *m_decbuf; }; +class CustomIntervalDownload +{ +public: + CustomIntervalDownload() + { + memset(guid,0,sizeof(guid)); + chidx = 0; + fourcc = 0; + last_time = 0; + } + + unsigned char guid[16]; + int chidx; + unsigned int fourcc; + WDL_String username; + WDL_HeapBuf data; + time_t last_time; +}; + class BufferQueue @@ -595,6 +903,12 @@ NJClient::NJClient() LicenseAgreementCallback=0; ChatMessage_Callback=0; ChatMessage_User=0; + IntervalMediaItem_Callback=0; + IntervalMediaItem_User=0; + NewIntervalCallback=0; + NewIntervalCallbackUser=0; + IntervalChunkCallback=0; + IntervalChunkCallbackUser=0; ChannelMixer=0; ChannelMixer_User=0; @@ -612,6 +926,11 @@ NJClient::NJClient() m_remote_chanoffs = 0; m_local_chanoffs = 0; + m_codec_caps_encode = NJCLIENT_CAP_ENCODE_VORBIS | NJCLIENT_CAP_ENCODE_OPUS; + m_codec_caps_decode = NJCLIENT_CAP_DECODE_VORBIS | NJCLIENT_CAP_DECODE_OPUS; + m_codec_vorbis_mask = 0xffffffffu; + m_codec_opus_mask = 0; + _reinit(); m_session_pos_ms=m_session_pos_samples=0; @@ -623,7 +942,7 @@ void NJClient::_reinit() output_peaklevel[0]=output_peaklevel[1]=0.0; m_connection_keepalive=0; - m_status=-1; + m_status=1002; m_in_auth=0; @@ -715,6 +1034,8 @@ NJClient::~NJClient() m_remoteusers.Empty(); for (x = 0; x < m_downloads.GetSize(); x ++) delete m_downloads.Get(x); m_downloads.Empty(); + for (x = 0; x < m_customIntervalDownloads.GetSize(); x ++) delete m_customIntervalDownloads.Get(x); + m_customIntervalDownloads.Empty(); for (x = 0; x < m_locchannels.GetSize(); x ++) delete m_locchannels.Get(x); m_locchannels.Empty(); @@ -851,6 +1172,7 @@ void NJClient::Disconnect() if (x) m_userinfochange=1; // if we removed users, notify parent for (x = 0; x < m_downloads.GetSize(); x ++) delete m_downloads.Get(x); + for (x = 0; x < m_customIntervalDownloads.GetSize(); x ++) delete m_customIntervalDownloads.Get(x); for (x = 0; x < m_locchannels.GetSize(); x ++) @@ -870,6 +1192,7 @@ void NJClient::Disconnect() c->m_bq.Clear(); } m_downloads.Empty(); + m_customIntervalDownloads.Empty(); m_wavebq->Clear(); @@ -1058,6 +1381,8 @@ int NJClient::Run() // nonzero if sleep ok repl.client_caps|=1; } } + repl.client_caps |= m_codec_caps_encode; + repl.client_caps |= m_codec_caps_decode; m_netcon->SetKeepAlive(m_connection_keepalive); WDL_SHA1 tmp; @@ -1120,6 +1445,15 @@ int NJClient::Run() // nonzero if sleep ok } break; + case MESSAGE_SERVER_CODEC_CONFIG: + { + mpb_server_codec_config cc; + if (!cc.parse(msg)) + { + SetCodecConfig(cc.vorbis_mask, cc.opus_mask); + } + } + break; case MESSAGE_SERVER_USERINFO_CHANGE_NOTIFY: { mpb_server_userinfo_change_notify ucn; @@ -1284,21 +1618,46 @@ int NJClient::Run() // nonzero if sleep ok } else if (dib.fourcc) // download coming { - if (config_debug_level>1) printf("RECV BLOCK %s\n",guidtostr_tmp(dib.guid)); - RemoteDownload *ds=new RemoteDownload; - memcpy(ds->guid,dib.guid,sizeof(ds->guid)); - ds->Open(this,dib.fourcc,!!(theuser->channels[dib.chidx].flags&4)); + const bool isAudioCodec = dib.fourcc == NJ_ENCODER_FMT_TYPE || dib.fourcc == NJ_ENCODER_FMT_TYPE_OPUS; + if (!isAudioCodec) + { + int i; + for (i = 0; i < m_customIntervalDownloads.GetSize(); ++i) + { + CustomIntervalDownload *cs = m_customIntervalDownloads.Get(i); + if (cs && !memcmp(cs->guid,dib.guid,sizeof(cs->guid))) + { + delete cs; + m_customIntervalDownloads.Delete(i); + break; + } + } + CustomIntervalDownload *cs = new CustomIntervalDownload; + memcpy(cs->guid,dib.guid,sizeof(cs->guid)); + cs->chidx = dib.chidx; + cs->fourcc = dib.fourcc; + cs->username.Set(dib.username); + time(&cs->last_time); + m_customIntervalDownloads.Add(cs); + } + else + { + if (config_debug_level>1) printf("RECV BLOCK %s\n",guidtostr_tmp(dib.guid)); + RemoteDownload *ds=new RemoteDownload; + memcpy(ds->guid,dib.guid,sizeof(ds->guid)); + ds->Open(this,dib.fourcc,!!(theuser->channels[dib.chidx].flags&4)); - ds->playtime=(theuser->channels[dib.chidx].flags&2)?LIVE_PREBUFFER:config_play_prebuffer; - ds->chidx=dib.chidx; - ds->username.Set(dib.username); + ds->playtime=(theuser->channels[dib.chidx].flags&2)?LIVE_PREBUFFER:config_play_prebuffer; + ds->chidx=dib.chidx; + ds->username.Set(dib.username); - m_downloads.Add(ds); + m_downloads.Add(ds); + } } else if (!(theuser->channels[dib.chidx].flags&4)) { // OutputDebugString("added free-guid to channel\n"); - DecodeState *tmp=start_decode(dib.guid, theuser->channels[dib.chidx].flags, 0, NULL); + DecodeState *tmp=start_decode(dib.guid, theuser->channels[dib.chidx].flags, dib.chidx, 0, NULL); m_users_cs.Enter(); int useidx=!!theuser->channels[dib.chidx].next_ds[0]; DecodeState *t2=theuser->channels[dib.chidx].next_ds[useidx]; @@ -1318,7 +1677,53 @@ int NJClient::Run() // nonzero if sleep ok { time_t now; time(&now); + bool handledCustom = false; int x; + for (x = 0; x < m_customIntervalDownloads.GetSize(); x ++) + { + CustomIntervalDownload *cs = m_customIntervalDownloads.Get(x); + if (!cs) continue; + if (!memcmp(cs->guid,diw.guid,sizeof(cs->guid))) + { + cs->last_time = now; + // Per-chunk callback: fires on every write so the host can + // stream/decode frames as they arrive throughout the interval. + if (IntervalChunkCallback) + IntervalChunkCallback(IntervalChunkCallbackUser, this, cs->username.Get(), cs->chidx, cs->fourcc, cs->guid, diw.audio_data, diw.audio_data_len, (int)diw.flags); + if (diw.audio_data_len > 0 && diw.audio_data) + { + const int cur = (int)cs->data.GetSize(); + cs->data.Resize(cur + diw.audio_data_len); + memcpy((char *)cs->data.Get() + cur, diw.audio_data, diw.audio_data_len); + } + if (diw.flags & 1) + { + if (IntervalMediaItem_Callback) + IntervalMediaItem_Callback(IntervalMediaItem_User, this, cs->username.Get(), cs->chidx, cs->fourcc, cs->guid, cs->data.Get(), (int)cs->data.GetSize()); + delete cs; + m_customIntervalDownloads.Delete(x); + } + handledCustom = true; + break; + } + if (now - cs->last_time > DOWNLOAD_TIMEOUT) + { + delete cs; + m_customIntervalDownloads.Delete(x--); + } + } + if (handledCustom) + break; + for (x = 0; x < m_customIntervalDownloads.GetSize(); x ++) + { + CustomIntervalDownload *cs = m_customIntervalDownloads.Get(x); + if (!cs) continue; + if (now - cs->last_time > DOWNLOAD_TIMEOUT) + { + delete cs; + m_customIntervalDownloads.Delete(x--); + } + } for (x = 0; x < m_downloads.GetSize(); x ++) { RemoteDownload *ds=m_downloads.Get(x); @@ -1481,6 +1886,14 @@ int NJClient::Run() // nonzero if sleep ok continue; } + if (!ShouldEncodeVorbis(lc->channel_idx) && !ShouldEncodeOpus(lc->channel_idx)) + { + if (p && p != (WDL_HeapBuf*)-1) + lc->m_bq.DisposeBlock(p); + p=0; + continue; + } + if (p == (WDL_HeapBuf*)-1) { // context @@ -1501,7 +1914,7 @@ int NJClient::Run() // nonzero if sleep ok // encode data if (!lc->m_enc) { - lc->m_enc = CreateNJEncoder(m_srate,lc->m_enc_nch_used=block_nch,lc->m_enc_bitrate_used = lc->bitrate+(block_nch>1?lc->bitrate/3:0),WDL_RNG_int32()); + lc->m_enc = createEncoderForChannel(lc->channel_idx, m_srate, lc->m_enc_nch_used=block_nch, lc->m_enc_bitrate_used = lc->bitrate+(block_nch>1?lc->bitrate/3:0), WDL_RNG_int32()); } if (lc->m_need_header) @@ -1514,7 +1927,8 @@ int NJClient::Run() // nonzero if sleep ok if (!(lc->flags&4)) writeLog("local %s %d%s\n",guidstr,lc->channel_idx,(lc->flags&2)?"v":""); if (config_savelocalaudio>0) { - lc->m_curwritefile.Open(this,NJ_ENCODER_FMT_TYPE,false); + const unsigned int encoderFourcc = ShouldEncodeOpus(lc->channel_idx) ? NJ_ENCODER_FMT_TYPE_OPUS : NJ_ENCODER_FMT_TYPE; + lc->m_curwritefile.Open(this,encoderFourcc,false); if (lc->m_wavewritefile) delete lc->m_wavewritefile; lc->m_wavewritefile=0; if (config_savelocalaudio>1) @@ -1538,7 +1952,7 @@ int NJClient::Run() // nonzero if sleep ok mpb_client_upload_interval_begin cuib; cuib.chidx=lc->channel_idx; memcpy(cuib.guid,lc->m_curwritefile.guid,sizeof(cuib.guid)); - cuib.fourcc=NJ_ENCODER_FMT_TYPE; + cuib.fourcc=ShouldEncodeOpus(lc->channel_idx) ? NJ_ENCODER_FMT_TYPE_OPUS : NJ_ENCODER_FMT_TYPE; cuib.estsize=0; delete lc->m_enc_header_needsend; lc->m_enc_header_needsend=cuib.build(); @@ -1701,7 +2115,38 @@ int NJClient::Run() // nonzero if sleep ok } -DecodeState *NJClient::start_decode(unsigned char *guid, int chanflags, unsigned int fourcc, DecodeMediaBuffer *decbuf) +I_NJEncoder *NJClient::createEncoderForChannel(int chidx, int srate, int nch, int bitrate, int serno) +{ + bool useOpus = ShouldEncodeOpus(chidx); + + if (useOpus) + { + I_NJEncoder *enc = new OpusEncoderCodec(srate, nch, bitrate, serno); + if (enc && !enc->isError()) + return enc; + delete enc; + } + + return (I_NJEncoder *)new VorbisEncoder(srate, nch, bitrate, serno); +} + +I_NJDecoder *NJClient::createDecoderForChannel(int chidx, unsigned int fourcc, DecodeMediaBuffer *decbuf) +{ + bool useOpus = (fourcc == NJ_ENCODER_FMT_TYPE_OPUS); + + if (!useOpus && !fourcc && decbuf && chidx >= 0 && chidx < MAX_USER_CHANNELS) + { + unsigned int bit = (1u << chidx); + useOpus = (m_codec_opus_mask & bit) != 0; + } + + if (useOpus) + return new OpusDecoderCodec(); + + return CreateNJDecoder(); +} + +DecodeState *NJClient::start_decode(unsigned char *guid, int chanflags, int chidx, unsigned int fourcc, DecodeMediaBuffer *decbuf) { DecodeState *newstate=new DecodeState; if (decbuf) @@ -1711,10 +2156,14 @@ DecodeState *NJClient::start_decode(unsigned char *guid, int chanflags, unsigned } memcpy(newstate->guid,guid,sizeof(newstate->guid)); + unsigned int types[2]; + int types_cnt = 0; - // todo: make plug-in system to allow encoders to add types allowed - // todo: with a preference for 'fourcc' if specified - unsigned int types[]={MAKE_NJ_FOURCC('O','G','G','v')}; // only types we understand + if (fourcc) + { + types[types_cnt++] = fourcc; + } + types[types_cnt++] = MAKE_NJ_FOURCC('O','G','G','v'); if (!newstate->decode_buf) { @@ -1724,7 +2173,7 @@ DecodeState *NJClient::start_decode(unsigned char *guid, int chanflags, unsigned const int oldl=s.GetLength()+1; s.Append(".XXXXXXXXX"); unsigned int x; - for (x = 0; !newstate->decode_fp && x < sizeof(types)/sizeof(types[0]); x ++) + for (x = 0; !newstate->decode_fp && x < (unsigned int)types_cnt; x ++) { char tmp[8]; s.SetLen(oldl); @@ -1736,7 +2185,7 @@ DecodeState *NJClient::start_decode(unsigned char *guid, int chanflags, unsigned if (newstate->decode_fp||newstate->decode_buf) { - newstate->decode_codec= CreateNJDecoder(); + newstate->decode_codec = createDecoderForChannel(chidx, fourcc, decbuf); // run some decoding if (newstate->decode_codec) @@ -1774,6 +2223,128 @@ void NJClient::ChatMessage_Send(const char *parm1, const char *parm2, const char } } +int NJClient::SendRawIntervalItem(int chidx, unsigned int fourcc, const void *data, int dataLen, int maxChunkBytes) +{ + if (!m_netcon || GetStatus() != NJC_STATUS_OK || chidx < 0 || chidx >= MAX_USER_CHANNELS || fourcc == 0) + return -1; + + if (maxChunkBytes < 1024) maxChunkBytes = 1024; + if (maxChunkBytes > 60000) maxChunkBytes = 60000; + + unsigned char guid[16]; + WDL_RNG_bytes(guid,sizeof(guid)); + + mpb_client_upload_interval_begin cuib; + cuib.chidx = chidx; + memcpy(cuib.guid,guid,sizeof(guid)); + cuib.fourcc = fourcc; + cuib.estsize = dataLen > 0 ? dataLen : 0; + m_netcon->Send(cuib.build()); + + if (!data || dataLen <= 0) + { + mpb_client_upload_interval_write wh; + memcpy(wh.guid,guid,sizeof(guid)); + wh.flags = 1; + wh.audio_data = NULL; + wh.audio_data_len = 0; + m_netcon->Send(wh.build()); + return 0; + } + + const unsigned char *p = (const unsigned char *)data; + int offs = 0; + while (offs < dataLen) + { + int n = dataLen - offs; + if (n > maxChunkBytes) n = maxChunkBytes; + mpb_client_upload_interval_write wh; + memcpy(wh.guid,guid,sizeof(guid)); + wh.flags = (offs + n >= dataLen) ? 1 : 0; + wh.audio_data = p + offs; + wh.audio_data_len = n; + m_netcon->Send(wh.build()); + offs += n; + } + + return 0; +} + +int NJClient::BeginRawIntervalStream(int chidx, unsigned int fourcc, unsigned char outGuid[16]) +{ + if (!m_netcon || GetStatus() != NJC_STATUS_OK || chidx < 0 || chidx >= MAX_USER_CHANNELS || fourcc == 0) + return -1; + + WDL_RNG_bytes(outGuid, 16); + + mpb_client_upload_interval_begin cuib; + cuib.chidx = chidx; + memcpy(cuib.guid, outGuid, 16); + cuib.fourcc = fourcc; + cuib.estsize = 0; + m_netcon->Send(cuib.build()); + return 0; +} + +int NJClient::WriteRawIntervalChunk(const unsigned char guid[16], const void *data, int dataLen) +{ + if (!m_netcon || GetStatus() != NJC_STATUS_OK || !data || dataLen <= 0) + return -1; + + mpb_client_upload_interval_write wh; + memcpy(wh.guid, guid, 16); + wh.flags = 0; + wh.audio_data = data; + wh.audio_data_len = dataLen; + m_netcon->Send(wh.build()); + return 0; +} + +int NJClient::EndRawIntervalStream(const unsigned char guid[16]) +{ + if (!m_netcon || GetStatus() != NJC_STATUS_OK) + return -1; + + mpb_client_upload_interval_write wh; + memcpy(wh.guid, guid, 16); + wh.flags = 1; + wh.audio_data = NULL; + wh.audio_data_len = 0; + m_netcon->Send(wh.build()); + return 0; +} + +void NJClient::SetCodecCapabilities(int encodeCaps, int decodeCaps) +{ + m_codec_caps_encode = encodeCaps; + m_codec_caps_decode = decodeCaps; +} + +void NJClient::SetCodecConfig(unsigned int vorbisMask, unsigned int opusMask) +{ + m_codec_vorbis_mask = vorbisMask; + m_codec_opus_mask = opusMask; +} + +bool NJClient::ShouldEncodeVorbis(int chidx) const +{ + if (!(m_codec_caps_encode & NJCLIENT_CAP_ENCODE_VORBIS)) return false; + if (chidx < 0) return false; + if (chidx >= 32) return false; + unsigned int bit = (1u << chidx); + if (m_codec_vorbis_mask & bit) return true; + if (m_codec_opus_mask & bit) return true; + return false; +} + +bool NJClient::ShouldEncodeOpus(int chidx) const +{ + if (!(m_codec_caps_encode & NJCLIENT_CAP_ENCODE_OPUS)) return false; + if (chidx < 0) return false; + if (chidx >= 32) return false; + return (m_codec_opus_mask & (1u << chidx)) != 0; +} + void NJClient::process_samples(float **inbuf, int innch, float **outbuf, int outnch, int len, int srate, int offset, int justmonitor, bool isPlaying, bool isSeek, double cursessionpos) { // -36dB/sec @@ -2213,7 +2784,7 @@ void NJClient::mixInChannel(RemoteUser *user, int chanidx, double mediasr=m_srate; if (userchan->GetSessionInfo(playPos,guid,&offs,&userchan->curds_lenleft,1.0/srate) && userchan->curds_lenleft > 16.0/srate) { - userchan->ds=start_decode(guid, userchan->flags, 0, NULL); + userchan->ds=start_decode(guid, userchan->flags, chanidx, 0, NULL); if (userchan->ds&&userchan->ds->decode_codec) { userchan->ds->applyOverlap(&fade_state); @@ -2368,15 +2939,13 @@ void NJClient::mixInChannel(RemoteUser *user, int chanidx, l/=2; while (l--) { - float f=*p; - if (f<-1.0f) f=*p=-1.0f; - else if (f>1.0f) f=*p=1.0f; + float f = softclip_to_minus2db(*p); + *p = f; if (f > maxf) maxf=f; else if (f < -maxf) maxf=-f; - f=*++p; - if (f<-1.0f) f=*p=-1.0f; - else if (f>1.0f) f=*p=1.0f; + f = softclip_to_minus2db(*++p); + *p = f; if (f > maxf2) maxf2=f; else if (f < -maxf2) maxf2=-f; p++; @@ -2386,9 +2955,8 @@ void NJClient::mixInChannel(RemoteUser *user, int chanidx, { while (l--) { - float f=*p; - if (f<-1.0f) f=*p=-1.0f; - else if (f>1.0f) f=*p=1.0f; + float f = softclip_to_minus2db(*p); + *p = f; if (f > maxf) maxf=f; else if (f < -maxf) maxf=-f; p++; @@ -2477,6 +3045,12 @@ void NJClient::on_new_interval() m_loopcnt++; writeLog("interval %d %.2f %d\n",m_loopcnt,GetActualBPM(),m_active_bpi); + // Notify host at sample-accurate timing — same point audio ds/next_ds + // promotion happens, allowing video (or other interval data) to be + // synchronised without poll jitter. + if (NewIntervalCallback) + NewIntervalCallback(NewIntervalCallbackUser, this); + m_metronome_pos=0.0; int u; @@ -2599,6 +3173,14 @@ const char *NJClient::GetUserChannelState(int useridx, int channelidx, bool *sub } +int NJClient::GetUserChannelOutput(int useridx, int channelidx) +{ + WDL_MutexLock lock(&m_remotechannel_rd_mutex); + if (useridx<0 || useridx>=m_remoteusers.GetSize()||channelidx<0||channelidx>=MAX_USER_CHANNELS) return 0; + RemoteUser *user=m_remoteusers.Get(useridx); + return user->channels[channelidx].out_chan_index; +} + void NJClient::SetUserChannelState(int useridx, int channelidx, bool setsub, bool sub, bool setvol, float vol, bool setpan, float pan, bool setmute, bool mute, bool setsolo, bool solo, bool setoutch, int outchannel) { @@ -2879,6 +3461,30 @@ int NJClient::GetLocalChannelMonitoring(int ch, float *vol, float *pan, bool *mu return 0; } +void NJClient::ResetLocalBroadcastState() +{ + m_locchan_cs.Enter(); + for (int x = 0; x < m_locchannels.GetSize(); ++x) + { + Local_Channel *c = m_locchannels.Get(x); + c->m_bq.Clear(); + c->bcast_active = c->broadcasting; + c->m_need_header = true; + c->m_curwritefile_curbuflen = 0.0; + } + m_locchan_cs.Leave(); +} + +void NJClient::ResetTransportPhase() +{ + m_misc_cs.Enter(); + m_interval_pos = -1; + m_metronome_pos = 0.0; + m_metronome_state = 0; + m_metronome_tmp = 0; + m_misc_cs.Leave(); +} + void NJClient::NotifyServerOfChannelChange() @@ -3144,7 +3750,7 @@ void RemoteDownload::startPlaying(int force) if (!(theuser->channels[chidx].flags&4)) // only "play" if not a session channel { - DecodeState *tmp=m_parent->start_decode(guid,theuser->channels[chidx].flags,m_fourcc,m_decbuf); + DecodeState *tmp=m_parent->start_decode(guid,theuser->channels[chidx].flags,chidx,m_fourcc,m_decbuf); // OutputDebugString(tmp?"started new decde\n":"tried to start new decode\n"); diff --git a/ninjam/njclient.h b/ninjam/njclient.h index 1484089a..bfbbc795 100644 --- a/ninjam/njclient.h +++ b/ninjam/njclient.h @@ -79,6 +79,7 @@ class I_NJEncoder; +class I_NJDecoder; class RemoteDownload; class RemoteUser; class RemoteUser_Channel; @@ -86,6 +87,7 @@ class Local_Channel; class DecodeState; class BufferQueue; class DecodeMediaBuffer; +class CustomIntervalDownload; // #define NJCLIENT_NO_XMIT_SUPPORT // might want to do this for njcast :) // it also removes mixed ogg writing support @@ -94,6 +96,14 @@ class NJClient { friend class RemoteDownload; public: + enum + { + NJCLIENT_CAP_DECODE_VORBIS = 1 << 8, + NJCLIENT_CAP_DECODE_OPUS = 1 << 9, + NJCLIENT_CAP_ENCODE_VORBIS = 1 << 10, + NJCLIENT_CAP_ENCODE_OPUS = 1 << 11 + }; + NJClient(); ~NJClient(); @@ -147,6 +157,7 @@ class NJClient const char *GetUserState(int idx, float *vol=0, float *pan=0, bool *mute=0); void SetUserState(int idx, bool setvol, float vol, bool setpan, float pan, bool setmute, bool mute); + int GetUserChannelOutput(int useridx, int channelidx); float GetUserChannelPeak(int useridx, int channelidx, int whichch=-1); double GetUserSessionPos(int useridx, time_t *lastupdatetime, double *maxlen); const char *GetUserChannelState(int useridx, int channelidx, bool *sub=0, float *vol=0, float *pan=0, bool *mute=0, bool *solo=0, int *outchannel=0, int *flags=0); @@ -163,6 +174,8 @@ class NJClient const char *GetLocalChannelInfo(int ch, int *srcch, int *bitrate, bool *broadcast, int *outch=0, int *flags=0); void SetLocalChannelMonitoring(int ch, bool setvol, float vol, bool setpan, float pan, bool setmute, bool mute, bool setsolo, bool solo); int GetLocalChannelMonitoring(int ch, float *vol, float *pan, bool *mute, bool *solo); // 0 on success + void ResetLocalBroadcastState(); + void ResetTransportPhase(); void NotifyServerOfChannelChange(); // call after any SetLocalChannel* that occur after initial connect void SetMetronomeChannel(int chidx) { m_metro_chidx=chidx; } // chidx&255 is stereo pair index, add 1024 for mono only @@ -187,6 +200,22 @@ class NJClient // "MSG" "text" - broadcast "text" to everybody // "PRIVMSG" "username" "text" - send text to "username" void ChatMessage_Send(const char *parm1, const char *parm2, const char *parm3=NULL, const char *parm4=NULL, const char *parm5=NULL); + int SendRawIntervalItem(int chidx, unsigned int fourcc, const void *data, int dataLen, int maxChunkBytes=60000); + + // Streaming interval API — mirrors the internal audio pipeline. + // Call BeginRawIntervalStream once, then WriteRawIntervalChunk for each piece of data, + // then EndRawIntervalStream at the interval boundary. This streams data to the + // server during the interval (like audio) rather than bursting it all at once. + int BeginRawIntervalStream(int chidx, unsigned int fourcc, unsigned char outGuid[16]); + int WriteRawIntervalChunk(const unsigned char guid[16], const void *data, int dataLen); + int EndRawIntervalStream(const unsigned char guid[16]); + + void SetCodecCapabilities(int encodeCaps, int decodeCaps); + int GetCodecEncodeCaps() const { return m_codec_caps_encode; } + int GetCodecDecodeCaps() const { return m_codec_caps_decode; } + void SetCodecConfig(unsigned int vorbisMask, unsigned int opusMask); + unsigned int GetCodecVorbisMask() const { return m_codec_vorbis_mask; } + unsigned int GetCodecOpusMask() const { return m_codec_opus_mask; } // messages you can receive from this: // "MSG" "user" "text" - message from user to everybody (including you!), or if user is empty, from the server @@ -197,6 +226,20 @@ class NJClient // note that nparms is the MAX number of parms, you still can get NULL parms entries in there (though rarely) void (*ChatMessage_Callback)(void *userData, NJClient *inst, const char **parms, int nparms); void *ChatMessage_User; + // Fired from inside on_new_interval() (audio thread, sample-accurate). + // Allows the host to synchronise non-audio interval data at the exact same + // moment as audio's ds/next_ds promotion, with no poll jitter. + void (*NewIntervalCallback)(void *userData, NJClient *inst); + void *NewIntervalCallbackUser; + + void (*IntervalMediaItem_Callback)(void *userData, NJClient *inst, const char *username, int chidx, unsigned int fourcc, const unsigned char *guid, const void *data, int dataLen); + void *IntervalMediaItem_User; + + // Fired for EVERY download_interval_write (flag=0 and flag=1), allowing + // the host to decode/stream chunks as they arrive rather than waiting for flag=1. + // data/dataLen may be 0 for a flag=1 end-of-stream marker with no payload. + void (*IntervalChunkCallback)(void *userData, NJClient *inst, const char *username, int chidx, unsigned int fourcc, const unsigned char *guid, const void *data, int dataLen, int flags); + void *IntervalChunkCallbackUser; // set these if you want to mix multiple channels into the output channel @@ -232,6 +275,10 @@ class NJClient int m_status; int m_max_localch; int m_connection_keepalive; + int m_codec_caps_encode; + int m_codec_caps_decode; + unsigned int m_codec_vorbis_mask; + unsigned int m_codec_opus_mask; FILE *m_logFile; #ifndef NJCLIENT_NO_XMIT_SUPPORT FILE *m_oggWrite; @@ -258,7 +305,13 @@ class NJClient int m_metro_chidx, m_remote_chanoffs, m_local_chanoffs; - DecodeState *start_decode(unsigned char *guid, int chanflags, unsigned int fourcc, DecodeMediaBuffer *decbuf); + DecodeState *start_decode(unsigned char *guid, int chanflags, int chidx, unsigned int fourcc, DecodeMediaBuffer *decbuf); + + bool ShouldEncodeVorbis(int chidx) const; + bool ShouldEncodeOpus(int chidx) const; + + I_NJEncoder *createEncoderForChannel(int chidx, int srate, int nch, int bitrate, int serno); + I_NJDecoder *createDecoderForChannel(int chidx, unsigned int fourcc, DecodeMediaBuffer *decbuf); BufferQueue *m_wavebq; @@ -272,6 +325,7 @@ class NJClient Net_Connection *m_netcon; WDL_PtrList m_remoteusers; WDL_PtrList m_downloads; + WDL_PtrList m_customIntervalDownloads; WDL_HeapBuf tmpblock; diff --git a/ninjam/server/CMakeLists.txt b/ninjam/server/CMakeLists.txt new file mode 100644 index 00000000..88f85581 --- /dev/null +++ b/ninjam/server/CMakeLists.txt @@ -0,0 +1,30 @@ +cmake_minimum_required(VERSION 3.16) +project(ninjamsrv LANGUAGES C CXX) + +add_executable(ninjamsrv + ninjamsrv.cpp + usercon.cpp + ../mpb.cpp + ../netmsg.cpp + ../../WDL/jnetlib/asyncdns.cpp + ../../WDL/jnetlib/connection.cpp + ../../WDL/jnetlib/listen.cpp + ../../WDL/jnetlib/util.cpp + ../../WDL/rng.cpp + ../../WDL/sha.cpp +) + +target_include_directories(ninjamsrv PRIVATE + ../../WDL + ../../WDL/jnetlib +) + +if (MSVC) + target_compile_definitions(ninjamsrv PRIVATE WIN32 _WINDOWS) +endif() + +target_link_libraries(ninjamsrv PRIVATE + wsock32 + ws2_32 + advapi32 +) diff --git a/ninjam/server/ninjamserver.log b/ninjam/server/ninjamserver.log new file mode 100644 index 00000000..5beece08 --- /dev/null +++ b/ninjam/server/ninjamserver.log @@ -0,0 +1,239 @@ +[2026/03/27 16:54:11] Opened log. NINJAM Server v0.080 built on Mar 27 2026 at 16:51:07 +[2026/03/27 16:54:11] Server starting up... +[2026/03/27 16:54:11] Port: 2049 +[2026/03/27 16:54:11] Using defaults 120 BPM 8 BPI +[2026/03/27 16:57:05] Opened log. NINJAM Server v0.080 built on Mar 27 2026 at 16:51:07 +[2026/03/27 16:57:05] Server starting up... +[2026/03/27 16:57:05] Port: 2049 +[2026/03/27 16:57:05] Using defaults 120 BPM 8 BPI +[2026/03/27 16:57:18] Incoming connection from 127.0.0.1! +[2026/03/27 16:57:18] 127.0.0.1: Refusing user, invalid authorization reply +[2026/03/27 16:57:18] 127.0.0.1: disconnected (username:'', code=1) +[2026/03/27 16:58:58] Opened log. NINJAM Server v0.080 built on Mar 27 2026 at 16:51:07 +[2026/03/27 16:58:58] Server starting up... +[2026/03/27 16:58:58] Port: 2049 +[2026/03/27 16:58:58] Using defaults 120 BPM 8 BPI +[2026/03/27 16:59:09] Incoming connection from 127.0.0.1! +[2026/03/27 16:59:09] DEBUG: get_type=192, parse_res=-1, username='NULL' +[2026/03/27 16:59:09] 127.0.0.1: Refusing user, invalid authorization reply +[2026/03/27 16:59:09] 127.0.0.1: disconnected (username:'', code=1) +[2026/03/27 17:05:35] Incoming connection from 127.0.0.1! +[2026/03/27 17:05:35] DEBUG: get_type=192, parse_res=-1, username='NULL' +[2026/03/27 17:05:35] 127.0.0.1: Refusing user, invalid authorization reply +[2026/03/27 17:05:35] 127.0.0.1: disconnected (username:'', code=1) +[2026/03/27 17:05:55] Incoming connection from 127.0.0.1! +[2026/03/27 17:05:55] DEBUG: get_type=192, parse_res=-1, username='NULL' +[2026/03/27 17:05:55] 127.0.0.1: Refusing user, invalid authorization reply +[2026/03/27 17:05:55] 127.0.0.1: disconnected (username:'', code=1) +[2026/03/27 17:09:40] Incoming connection from 127.0.0.1! +[2026/03/27 17:09:40] got anonymous request (allowing) +[2026/03/27 17:09:40] 127.0.0.1: Accepted user: user8@127.0.0.x +[2026/03/27 17:10:10] Incoming connection from 127.0.0.1! +[2026/03/27 17:10:10] got anonymous request (allowing) +[2026/03/27 17:10:10] 127.0.0.1: Accepted user: user63@127.0.0.x +[2026/03/27 17:10:31] 127.0.0.1: disconnected (username:'user63@127.0.0.x', code=1) +[2026/03/27 17:10:33] 127.0.0.1: disconnected (username:'user8@127.0.0.x', code=1) +[2026/03/27 17:47:50] Incoming connection from 192.168.1.17! +[2026/03/27 17:47:50] got anonymous request (allowing) +[2026/03/27 17:47:50] 192.168.1.17: Accepted user: user27@192.168.1.x +[2026/03/27 17:48:03] Incoming connection from 192.168.1.13! +[2026/03/27 17:48:03] got anonymous request (allowing) +[2026/03/27 17:48:03] 192.168.1.13: Accepted user: user61@192.168.1.x +[2026/03/27 17:51:09] 192.168.1.13: disconnected (username:'user61@192.168.1.x', code=1) +[2026/03/27 17:51:59] Incoming connection from 192.168.1.13! +[2026/03/27 17:51:59] got anonymous request (allowing) +[2026/03/27 17:51:59] 192.168.1.13: Accepted user: user38@192.168.1.x +[2026/03/27 17:52:19] 192.168.1.13: disconnected (username:'user38@192.168.1.x', code=1) +[2026/03/27 17:52:23] 192.168.1.17: disconnected (username:'user27@192.168.1.x', code=1) +[2026/03/27 18:38:54] Incoming connection from 192.168.1.17! +[2026/03/27 18:38:54] got anonymous request (allowing) +[2026/03/27 18:38:54] 192.168.1.17: Accepted user: user76@192.168.1.x +[2026/03/27 18:39:14] Incoming connection from 192.168.1.13! +[2026/03/27 18:39:14] got anonymous request (allowing) +[2026/03/27 18:39:14] 192.168.1.13: Accepted user: user46@192.168.1.x +[2026/03/27 18:40:09] 192.168.1.13: disconnected (username:'user46@192.168.1.x', code=1) +[2026/03/27 18:40:11] 192.168.1.17: disconnected (username:'user76@192.168.1.x', code=1) +[2026/03/27 18:44:36] Incoming connection from 192.168.1.17! +[2026/03/27 18:44:36] got anonymous request (allowing) +[2026/03/27 18:44:36] 192.168.1.17: Accepted user: user72@192.168.1.x +[2026/03/27 18:44:45] Incoming connection from 192.168.1.13! +[2026/03/27 18:44:45] got anonymous request (allowing) +[2026/03/27 18:44:45] 192.168.1.13: Accepted user: user14@192.168.1.x +[2026/03/27 18:45:03] 192.168.1.17: disconnected (username:'user72@192.168.1.x', code=1) +[2026/03/27 18:45:20] Incoming connection from 192.168.1.13! +[2026/03/27 18:45:20] got anonymous request (allowing) +[2026/03/27 18:45:20] 192.168.1.13: Accepted user: user30@192.168.1.x +[2026/03/27 18:45:30] 192.168.1.13: disconnected (username:'user14@192.168.1.x', code=1) +[2026/03/27 18:45:31] 192.168.1.13: disconnected (username:'user30@192.168.1.x', code=1) +[2026/03/27 18:49:14] Incoming connection from 127.0.0.1! +[2026/03/27 18:49:14] got anonymous request (allowing) +[2026/03/27 18:49:14] 127.0.0.1: Accepted user: user46@127.0.0.x +[2026/03/27 18:49:15] Incoming connection from 127.0.0.1! +[2026/03/27 18:49:15] got anonymous request (allowing) +[2026/03/27 18:49:15] 127.0.0.1: Accepted user: user16@127.0.0.x +[2026/03/27 18:49:43] 127.0.0.1: disconnected (username:'user16@127.0.0.x', code=1) +[2026/03/27 18:49:45] 127.0.0.1: disconnected (username:'user46@127.0.0.x', code=1) +[2026/03/27 18:51:57] Incoming connection from 127.0.0.1! +[2026/03/27 18:51:57] got anonymous request (allowing) +[2026/03/27 18:51:57] 127.0.0.1: Accepted user: user81@127.0.0.x +[2026/03/27 18:51:58] Incoming connection from 127.0.0.1! +[2026/03/27 18:51:58] got anonymous request (allowing) +[2026/03/27 18:51:58] 127.0.0.1: Accepted user: user71@127.0.0.x +[2026/03/27 19:06:49] 127.0.0.1: disconnected (username:'user81@127.0.0.x', code=1) +[2026/03/27 19:06:50] 127.0.0.1: disconnected (username:'user71@127.0.0.x', code=1) +[2026/03/27 19:50:51] Incoming connection from 127.0.0.1! +[2026/03/27 19:50:51] got anonymous request (allowing) +[2026/03/27 19:50:51] 127.0.0.1: Accepted user: user55@127.0.0.x +[2026/03/27 19:50:53] Incoming connection from 127.0.0.1! +[2026/03/27 19:50:53] got anonymous request (allowing) +[2026/03/27 19:50:53] 127.0.0.1: Accepted user: user23@127.0.0.x +[2026/03/27 19:51:41] 127.0.0.1: disconnected (username:'user55@127.0.0.x', code=1) +[2026/03/27 19:51:42] 127.0.0.1: disconnected (username:'user23@127.0.0.x', code=1) +[2026/03/27 20:42:22] Incoming connection from 127.0.0.1! +[2026/03/27 20:42:22] got anonymous request (allowing) +[2026/03/27 20:42:22] 127.0.0.1: Accepted user: user0@127.0.0.x +[2026/03/27 20:42:24] Incoming connection from 127.0.0.1! +[2026/03/27 20:42:24] got anonymous request (allowing) +[2026/03/27 20:42:24] 127.0.0.1: Accepted user: user88@127.0.0.x +[2026/03/27 20:43:07] 127.0.0.1: disconnected (username:'user88@127.0.0.x', code=1) +[2026/03/27 20:43:08] 127.0.0.1: disconnected (username:'user0@127.0.0.x', code=1) +[2026/03/27 20:47:01] Incoming connection from 127.0.0.1! +[2026/03/27 20:47:01] got anonymous request (allowing) +[2026/03/27 20:47:01] 127.0.0.1: Accepted user: user57@127.0.0.x +[2026/03/27 20:47:05] Incoming connection from 127.0.0.1! +[2026/03/27 20:47:05] got anonymous request (allowing) +[2026/03/27 20:47:05] 127.0.0.1: Accepted user: user85@127.0.0.x +[2026/03/27 20:47:51] 127.0.0.1: disconnected (username:'user85@127.0.0.x', code=1) +[2026/03/27 20:47:53] 127.0.0.1: disconnected (username:'user57@127.0.0.x', code=1) +[2026/03/27 21:19:19] Incoming connection from 192.168.1.17! +[2026/03/27 21:19:19] got anonymous request (allowing) +[2026/03/27 21:19:19] 192.168.1.17: Accepted user: user86@192.168.1.x +[2026/03/27 21:19:28] Incoming connection from 192.168.1.13! +[2026/03/27 21:19:28] got anonymous request (allowing) +[2026/03/27 21:19:28] 192.168.1.13: Accepted user: user84@192.168.1.x +[2026/03/27 21:20:54] 192.168.1.13: disconnected (username:'user84@192.168.1.x', code=1) +[2026/03/27 21:21:02] 192.168.1.17: disconnected (username:'user86@192.168.1.x', code=1) +[2026/03/27 21:26:22] Incoming connection from 127.0.0.1! +[2026/03/27 21:26:22] got anonymous request (allowing) +[2026/03/27 21:26:22] 127.0.0.1: Accepted user: user18@127.0.0.x +[2026/03/27 21:26:24] Incoming connection from 127.0.0.1! +[2026/03/27 21:26:24] got anonymous request (allowing) +[2026/03/27 21:26:24] 127.0.0.1: Accepted user: user60@127.0.0.x +[2026/03/27 21:27:42] 127.0.0.1: disconnected (username:'user60@127.0.0.x', code=1) +[2026/03/27 21:27:44] 127.0.0.1: disconnected (username:'user18@127.0.0.x', code=1) +[2026/03/27 21:29:33] Incoming connection from 127.0.0.1! +[2026/03/27 21:29:33] got anonymous request (allowing) +[2026/03/27 21:29:33] 127.0.0.1: Accepted user: user76@127.0.0.x +[2026/03/27 21:29:35] Incoming connection from 127.0.0.1! +[2026/03/27 21:29:35] got anonymous request (allowing) +[2026/03/27 21:29:35] 127.0.0.1: Accepted user: user68@127.0.0.x +[2026/03/27 21:30:36] 127.0.0.1: disconnected (username:'user76@127.0.0.x', code=1) +[2026/03/27 21:30:38] 127.0.0.1: disconnected (username:'user68@127.0.0.x', code=1) +[2026/03/27 21:33:48] Incoming connection from 127.0.0.1! +[2026/03/27 21:33:48] got anonymous request (allowing) +[2026/03/27 21:33:48] 127.0.0.1: Accepted user: user60@127.0.0.x +[2026/03/27 21:33:50] Incoming connection from 127.0.0.1! +[2026/03/27 21:33:50] got anonymous request (allowing) +[2026/03/27 21:33:50] 127.0.0.1: Accepted user: user83@127.0.0.x +[2026/03/27 21:34:16] 127.0.0.1: disconnected (username:'user60@127.0.0.x', code=1) +[2026/03/27 21:34:18] 127.0.0.1: disconnected (username:'user83@127.0.0.x', code=1) +[2026/03/27 21:39:01] Incoming connection from 127.0.0.1! +[2026/03/27 21:39:01] DEBUG: get_type=192, parse_res=-1, username='NULL' +[2026/03/27 21:39:01] 127.0.0.1: Refusing user, invalid authorization reply +[2026/03/27 21:39:01] 127.0.0.1: disconnected (username:'', code=1) +[2026/03/27 21:39:05] Incoming connection from 127.0.0.1! +[2026/03/27 21:39:05] DEBUG: get_type=192, parse_res=-1, username='NULL' +[2026/03/27 21:39:05] 127.0.0.1: Refusing user, invalid authorization reply +[2026/03/27 21:39:05] 127.0.0.1: disconnected (username:'', code=1) +[2026/03/27 21:39:06] Incoming connection from 127.0.0.1! +[2026/03/27 21:39:07] DEBUG: get_type=192, parse_res=-1, username='NULL' +[2026/03/27 21:39:07] 127.0.0.1: Refusing user, invalid authorization reply +[2026/03/27 21:39:07] 127.0.0.1: disconnected (username:'', code=1) +[2026/03/27 21:39:11] Incoming connection from 127.0.0.1! +[2026/03/27 21:39:11] DEBUG: get_type=192, parse_res=-1, username='NULL' +[2026/03/27 21:39:11] 127.0.0.1: Refusing user, invalid authorization reply +[2026/03/27 21:39:11] 127.0.0.1: disconnected (username:'', code=1) +[2026/03/27 21:39:49] Incoming connection from 127.0.0.1! +[2026/03/27 21:39:49] DEBUG: get_type=192, parse_res=-1, username='NULL' +[2026/03/27 21:39:49] 127.0.0.1: Refusing user, invalid authorization reply +[2026/03/27 21:39:49] 127.0.0.1: disconnected (username:'', code=1) +[2026/03/27 21:41:25] Incoming connection from 127.0.0.1! +[2026/03/27 21:41:25] got anonymous request (allowing) +[2026/03/27 21:41:25] 127.0.0.1: Accepted user: user74@127.0.0.x +[2026/03/27 21:42:09] Incoming connection from 127.0.0.1! +[2026/03/27 21:42:09] got anonymous request (allowing) +[2026/03/27 21:42:09] 127.0.0.1: Accepted user: user92@127.0.0.x +[2026/03/27 21:42:11] 127.0.0.1: disconnected (username:'user92@127.0.0.x', code=1) +[2026/03/27 21:42:12] Incoming connection from 127.0.0.1! +[2026/03/27 21:42:12] got anonymous request (allowing) +[2026/03/27 21:42:12] 127.0.0.1: Accepted user: user92@127.0.0.x +[2026/03/27 21:43:45] 127.0.0.1: disconnected (username:'user92@127.0.0.x', code=1) +[2026/03/27 21:43:47] 127.0.0.1: disconnected (username:'user74@127.0.0.x', code=1) +[2026/03/27 21:44:31] Incoming connection from 192.168.1.13! +[2026/03/27 21:44:31] got anonymous request (allowing) +[2026/03/27 21:44:31] 192.168.1.13: Accepted user: user69@192.168.1.x +[2026/03/27 21:44:38] Incoming connection from 192.168.1.17! +[2026/03/27 21:44:38] got anonymous request (allowing) +[2026/03/27 21:44:38] 192.168.1.17: Accepted user: user87@192.168.1.x +[2026/03/27 21:50:00] 192.168.1.13: disconnected (username:'user69@192.168.1.x', code=1) +[2026/03/27 21:50:01] 192.168.1.17: disconnected (username:'user87@192.168.1.x', code=1) +[2026/03/27 22:08:41] Incoming connection from 192.168.1.13! +[2026/03/27 22:08:41] got anonymous request (allowing) +[2026/03/27 22:08:41] 192.168.1.13: Accepted user: user61@192.168.1.x +[2026/03/27 22:08:48] Incoming connection from 192.168.1.17! +[2026/03/27 22:08:48] got anonymous request (allowing) +[2026/03/27 22:08:48] 192.168.1.17: Accepted user: user0@192.168.1.x +[2026/03/27 22:10:55] 192.168.1.17: disconnected (username:'user0@192.168.1.x', code=1) +[2026/03/27 22:10:59] 192.168.1.13: disconnected (username:'user61@192.168.1.x', code=1) +[2026/03/27 22:18:57] Incoming connection from 192.168.1.17! +[2026/03/27 22:18:57] got anonymous request (allowing) +[2026/03/27 22:18:57] 192.168.1.17: Accepted user: user28@192.168.1.x +[2026/03/27 22:19:08] Incoming connection from 192.168.1.13! +[2026/03/27 22:19:08] got anonymous request (allowing) +[2026/03/27 22:19:08] 192.168.1.13: Accepted user: user30@192.168.1.x +[2026/03/27 22:20:35] 192.168.1.17: disconnected (username:'user28@192.168.1.x', code=1) +[2026/03/27 22:20:37] 192.168.1.13: disconnected (username:'user30@192.168.1.x', code=1) +[2026/03/27 22:23:22] Incoming connection from 192.168.1.13! +[2026/03/27 22:23:22] got anonymous request (allowing) +[2026/03/27 22:23:22] 192.168.1.13: Accepted user: user62@192.168.1.x +[2026/03/27 22:23:29] Incoming connection from 192.168.1.17! +[2026/03/27 22:23:29] got anonymous request (allowing) +[2026/03/27 22:23:29] 192.168.1.17: Accepted user: user81@192.168.1.x +[2026/03/27 22:25:40] 192.168.1.13: disconnected (username:'user62@192.168.1.x', code=1) +[2026/03/27 22:25:44] 192.168.1.17: disconnected (username:'user81@192.168.1.x', code=1) +[2026/03/27 23:03:20] Incoming connection from 192.168.1.13! +[2026/03/27 23:03:20] got anonymous request (allowing) +[2026/03/27 23:03:20] 192.168.1.13: Accepted user: user41@192.168.1.x +[2026/03/27 23:03:32] Incoming connection from 192.168.1.17! +[2026/03/27 23:03:32] got anonymous request (allowing) +[2026/03/27 23:03:32] 192.168.1.17: Accepted user: user89@192.168.1.x +[2026/03/27 23:05:24] 192.168.1.13: disconnected (username:'user41@192.168.1.x', code=1) +[2026/03/27 23:05:28] 192.168.1.17: disconnected (username:'user89@192.168.1.x', code=1) +[2026/03/27 23:12:31] Incoming connection from 192.168.1.17! +[2026/03/27 23:12:31] got anonymous request (allowing) +[2026/03/27 23:12:31] 192.168.1.17: Accepted user: user94@192.168.1.x +[2026/03/27 23:12:44] Incoming connection from 192.168.1.13! +[2026/03/27 23:12:44] got anonymous request (allowing) +[2026/03/27 23:12:44] 192.168.1.13: Accepted user: user93@192.168.1.x +[2026/03/27 23:15:17] 192.168.1.13: disconnected (username:'user93@192.168.1.x', code=1) +[2026/03/27 23:15:19] 192.168.1.17: disconnected (username:'user94@192.168.1.x', code=1) +[2026/03/27 23:19:21] Incoming connection from 192.168.1.13! +[2026/03/27 23:19:21] got anonymous request (allowing) +[2026/03/27 23:19:21] 192.168.1.13: Accepted user: user9@192.168.1.x +[2026/03/27 23:19:37] Incoming connection from 192.168.1.17! +[2026/03/27 23:19:37] got anonymous request (allowing) +[2026/03/27 23:19:37] 192.168.1.17: Accepted user: user92@192.168.1.x +[2026/03/27 23:21:02] 192.168.1.13: disconnected (username:'user9@192.168.1.x', code=1) +[2026/03/27 23:21:05] 192.168.1.17: disconnected (username:'user92@192.168.1.x', code=1) +[2026/03/27 23:58:48] Incoming connection from 127.0.0.1! +[2026/03/27 23:58:48] got anonymous request (allowing) +[2026/03/27 23:58:48] 127.0.0.1: Accepted user: user83@127.0.0.x +[2026/03/27 23:59:06] 127.0.0.1: disconnected (username:'user83@127.0.0.x', code=1) +[2026/03/27 23:59:28] Incoming connection from 127.0.0.1! +[2026/03/27 23:59:28] got anonymous request (allowing) +[2026/03/27 23:59:28] 127.0.0.1: Accepted user: user89@127.0.0.x +[2026/03/28 00:00:07] 127.0.0.1: disconnected (username:'user89@127.0.0.x', code=1) +[2026/03/28 01:05:04] Incoming connection from 127.0.0.1! +[2026/03/28 01:05:04] got anonymous request (allowing) +[2026/03/28 01:05:04] 127.0.0.1: Accepted user: user44@127.0.0.x +[2026/03/28 01:06:22] 127.0.0.1: disconnected (username:'user44@127.0.0.x', code=1) diff --git a/ninjam/server/usercon.cpp b/ninjam/server/usercon.cpp index c3f2a3a1..866e0c4c 100644 --- a/ninjam/server/usercon.cpp +++ b/ninjam/server/usercon.cpp @@ -98,7 +98,7 @@ extern void logText(const char *s, ...); #define TRANSFER_TIMEOUT 8 -User_Connection::User_Connection(JNL_IConnection *con, User_Group *grp) : m_auth_state(0), m_clientcaps(0), m_auth_privs(0), m_reserved(0), m_max_channels(0), +User_Connection::User_Connection(JNL_IConnection *con, User_Group *grp) : m_auth_state(0), m_clientcaps(0), m_supports_video(false), m_auth_privs(0), m_reserved(0), m_max_channels(0), m_vote_bpm(0), m_vote_bpm_lasttime(0), m_vote_bpi(0), m_vote_bpi_lasttime(0) { m_netcon.attach(con); @@ -155,6 +155,76 @@ User_Connection::~User_Connection() m_lookup=0; } +void User_Connection::SendCodecConfig(User_Group *group) +{ + mpb_server_codec_config cfg; + + int ch = m_max_channels; + if (ch > MAX_USER_CHANNELS) ch = MAX_USER_CHANNELS; + if (ch < 0 || group->m_is_lobby_mode) ch = 0; + + unsigned int mask = ch ? ((1u << ch) - 1u) : 0u; + + bool any_requires_vorbis = false; + bool any_can_decode_opus = false; + + int i; + for (i = 0; i < group->m_users.GetSize(); ++i) + { + User_Connection *u = group->m_users.Get(i); + if (!u || u == this) continue; + + if (u->ClientCanDecodeOpus()) any_can_decode_opus = true; + if (u->ClientCanDecodeVorbis() && !u->ClientCanDecodeOpus()) any_requires_vorbis = true; + } + + if (!any_can_decode_opus) + { + cfg.vorbis_mask = mask; + cfg.opus_mask = 0; + } + else if (any_requires_vorbis) + { + cfg.vorbis_mask = mask; + cfg.opus_mask = mask; + } + else + { + cfg.vorbis_mask = 0; + cfg.opus_mask = mask; + } + + Send(cfg.build()); +} + +bool User_Connection::ClientCanDecodeVorbis() const +{ + int mask = CLIENT_CAP_DECODE_VORBIS | CLIENT_CAP_DECODE_OPUS | CLIENT_CAP_ENCODE_VORBIS | CLIENT_CAP_ENCODE_OPUS; + if (!(m_clientcaps & mask)) return true; + return (m_clientcaps & CLIENT_CAP_DECODE_VORBIS) != 0; +} + +bool User_Connection::ClientCanDecodeOpus() const +{ + int mask = CLIENT_CAP_DECODE_VORBIS | CLIENT_CAP_DECODE_OPUS | CLIENT_CAP_ENCODE_VORBIS | CLIENT_CAP_ENCODE_OPUS; + if (!(m_clientcaps & mask)) return false; + return (m_clientcaps & CLIENT_CAP_DECODE_OPUS) != 0; +} + +bool User_Connection::ClientCanEncodeVorbis() const +{ + int mask = CLIENT_CAP_DECODE_VORBIS | CLIENT_CAP_DECODE_OPUS | CLIENT_CAP_ENCODE_VORBIS | CLIENT_CAP_ENCODE_OPUS; + if (!(m_clientcaps & mask)) return true; + return (m_clientcaps & CLIENT_CAP_ENCODE_VORBIS) != 0; +} + +bool User_Connection::ClientCanEncodeOpus() const +{ + int mask = CLIENT_CAP_DECODE_VORBIS | CLIENT_CAP_DECODE_OPUS | CLIENT_CAP_ENCODE_VORBIS | CLIENT_CAP_ENCODE_OPUS; + if (!(m_clientcaps & mask)) return false; + return (m_clientcaps & CLIENT_CAP_ENCODE_OPUS) != 0; +} + void User_Connection::SendConfigChangeNotify(int bpm, int bpi) { @@ -197,6 +267,19 @@ void User_Connection::SendConnectInfo(User_Group *group) newmsg.parms[2]=group->m_topictext.Get(); Send(newmsg.build()); } + { + mpb_chat_message newmsg; + newmsg.parms[0]="SERVER_CAPS"; + newmsg.parms[1]="video_signal_v2 pro_video_v2 opus_sync_v2"; + Send(newmsg.build()); + } + { + mpb_chat_message newmsg; + newmsg.parms[0]="MSG"; + newmsg.parms[1]="*"; + newmsg.parms[2]="SERVER_CAPS video_signal_v2 pro_video_v2 opus_sync_v2"; + Send(newmsg.build()); + } { int cnt=0; @@ -389,12 +472,14 @@ int User_Connection::OnRunAuth(User_Group *group) - logText("%s: Accepted user: %s\n",addrbuf,m_username.Get()); + logText("%s: Accepted user: %s (privs=%d)\n",addrbuf,m_username.Get(),m_auth_privs); SendAuthReply(group); m_auth_state=1; + group->UpdateCodecConfig(); + SendConfigChangeNotify(group->m_last_bpm,group->m_last_bpi); @@ -966,6 +1051,21 @@ void User_Group::Broadcast(Net_Message *msg, User_Connection *nosend) } } +void User_Group::UpdateCodecConfig() +{ + if (m_is_lobby_mode) return; + + int x; + for (x = 0; x < m_users.GetSize(); ++x) + { + User_Connection *p = m_users.Get(x); + if (p && p->m_auth_state > 0) + { + p->SendCodecConfig(this); + } + } +} + int User_Group::Run() { int wantsleep=1; @@ -1059,6 +1159,8 @@ int User_Group::Run() delete p; m_users.Delete(thispos); x--; + + UpdateCodecConfig(); } } } @@ -1292,16 +1394,22 @@ void User_Group::onChatMessage(User_Connection *con, mpb_chat_message *msg) if (!(con->m_auth_privs & PRIV_CHATSEND)) { + logText("MSG from %s DROPPED: no PRIV_CHATSEND (privs=%d)\n", con->m_username.Get(), con->m_auth_privs); errormsg = "No MSG permission"; } else if (*p) { + logText("MSG from %s broadcasting: %.60s\n", con->m_username.Get(), p); mpb_chat_message newmsg; newmsg.parms[0]="MSG"; newmsg.parms[1]=con->m_username.Get(); newmsg.parms[2]=msg->parms[1]; // send leading whitespace Broadcast(newmsg.build()); } + else + { + logText("MSG from %s DROPPED: empty message after trim\n", con->m_username.Get()); + } int x; for (x = 0; x < need_bcast.GetSize(); x ++) Broadcast(need_bcast.Get(x)); @@ -1319,6 +1427,98 @@ void User_Group::onChatMessage(User_Connection *con, mpb_chat_message *msg) newmsg.parms[4]=msg->parms[3]; // offset, length Broadcast(newmsg.build(),con); } + else if (!strcmp(msg->parms[0],"VIDEO_CAP")) + { + const char *p = msg->parms[1]; + if (!p) return; + while (*p == ' ') p++; + int v = atoi(p); + con->m_supports_video = v != 0; + if (v != 0) + { + mpb_chat_message capsmsg; + capsmsg.parms[0]="SERVER_CAPS"; + capsmsg.parms[1]="video_signal_v2 pro_video_v2 opus_sync_v2"; + con->Send(capsmsg.build()); + mpb_chat_message fallback; + fallback.parms[0]="MSG"; + fallback.parms[1]="*"; + fallback.parms[2]="SERVER_CAPS video_signal_v2 pro_video_v2 opus_sync_v2"; + con->Send(fallback.build()); + } + } + else if (!strcmp(msg->parms[0],"VIDEO_SIGNAL") || !strcmp(msg->parms[0],"SIDE_SIGNAL")) + { + if (!con->m_supports_video) return; + const bool useSideSignal = !strcmp(msg->parms[0],"SIDE_SIGNAL"); + const char *signalFromCommand = useSideSignal ? "SIDE_SIGNAL_FROM" : "VIDEO_SIGNAL_FROM"; + const char *target = msg->parms[1]; + const char *stype = msg->parms[2]; + const char *spayload = msg->parms[3]; + if (!stype || !spayload) return; + if (useSideSignal && !strcmp(stype,"proVideoFrame")) return; + if (target && *target && strcmp(target,"*")) + { + int x; + int pmatch = -1; + int l1 = (int) strlen(target); + for (x = 0; x < m_users.GetSize(); x ++) + { + User_Connection *u = m_users.Get(x); + if (!u || u == con) continue; + if (!u->m_supports_video) continue; + if (u->m_auth_state <= 0) continue; + const char *tu = u->m_username.Get(); + if (!strcasecmp(target,tu)) + { + mpb_chat_message newmsg; + newmsg.parms[0]=signalFromCommand; + newmsg.parms[1]=con->m_username.Get(); + newmsg.parms[2]=stype; + newmsg.parms[3]=spayload; + u->Send(newmsg.build()); + return; + } + else if (pmatch > -2) + { + if (!strncasecmp(target,tu,l1)) + { + pmatch = pmatch >= 0 ? -2 : x; + } + } + } + if (pmatch >= 0) + { + User_Connection *u = m_users.Get(pmatch); + if (u && u != con && u->m_supports_video && u->m_auth_state > 0) + { + mpb_chat_message newmsg; + newmsg.parms[0]=signalFromCommand; + newmsg.parms[1]=con->m_username.Get(); + newmsg.parms[2]=stype; + newmsg.parms[3]=spayload; + u->Send(newmsg.build()); + } + } + } + else + { + int x; + for (x = 0; x < m_users.GetSize(); x ++) + { + User_Connection *u = m_users.Get(x); + if (!u || u == con) continue; + if (!u->m_supports_video) continue; + if (u->m_auth_state <= 0) continue; + mpb_chat_message newmsg; + newmsg.parms[0]=signalFromCommand; + newmsg.parms[1]=con->m_username.Get(); + newmsg.parms[2]=stype; + newmsg.parms[3]=spayload; + u->Send(newmsg.build()); + } + } + } else if (!strcmp(msg->parms[0],"PRIVMSG")) // chat message { if (m_is_lobby_mode && !(m_is_lobby_mode & LOBBY_ALLOW_CHAT)) return; diff --git a/ninjam/server/usercon.h b/ninjam/server/usercon.h index 30faead7..3f7afa4b 100644 --- a/ninjam/server/usercon.h +++ b/ninjam/server/usercon.h @@ -96,6 +96,7 @@ class User_Group void SetConfig(int bpi, int bpm); void SetLicenseText(char *text) { m_licensetext.Set(text); } void Broadcast(Net_Message *msg, User_Connection *nosend=0); + void UpdateCodecConfig(); void SetLogDir(char *path); // NULL to not log @@ -195,11 +196,20 @@ class User_TransferState class User_Connection { public: + enum + { + CLIENT_CAP_DECODE_VORBIS = 1 << 8, + CLIENT_CAP_DECODE_OPUS = 1 << 9, + CLIENT_CAP_ENCODE_VORBIS = 1 << 10, + CLIENT_CAP_ENCODE_OPUS = 1 << 11 + }; + User_Connection(JNL_IConnection *con, User_Group *grp); ~User_Connection(); int Run(User_Group *group, int *wantsleep=0); // returns 1 if disconnected, -1 if error in data. 0 if ok. void SendConfigChangeNotify(int bpm, int bpi); + void SendCodecConfig(User_Group *group); void Send(Net_Message *msg); @@ -220,6 +230,12 @@ class User_Connection int m_auth_state; // 1 if authorized, 0 if not yet, -1 if auth pending unsigned char m_challenge[8]; int m_clientcaps; + bool m_supports_video; + + bool ClientCanDecodeVorbis() const; + bool ClientCanDecodeOpus() const; + bool ClientCanEncodeVorbis() const; + bool ClientCanEncodeOpus() const; int m_auth_privs;