diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fb3928c..b37a0d1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -269,6 +269,14 @@ jobs: for /f "usebackq tokens=*" %%i in (`vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set VSINSTALL=%%i call "%VSINSTALL%\VC\Auxiliary\Build\vcvars64.bat" cmake --build plugin/build --target PrismInstallerPlugins --config Release --parallel 4 + cmake --build plugin/build --target PrismBridgeTests --config Release --parallel 4 + ctest --test-dir plugin/build -C Release --output-on-failure -R PrismBridgeProcessorTests + + - name: Test Prism Bridge processor + if: runner.os != 'Windows' + run: | + cmake --build plugin/build --target PrismBridgeTests --config Release --parallel 4 + ctest --test-dir plugin/build -C Release --output-on-failure -R PrismBridgeProcessorTests - name: Stage plugins for Windows installer if: runner.os == 'Windows' @@ -276,13 +284,13 @@ jobs: run: | rm -rf plugin/dist-installer mkdir -p plugin/dist-installer/VST3 - for art in PrismSpectrum PrismOscilloscope PrismVUMeter PrismLUFSMeter PrismVectorscope PrismSpectrogram PrismWaveform; do + for art in PrismSpectrum PrismOscilloscope PrismVUMeter PrismLUFSMeter PrismVectorscope PrismSpectrogram PrismWaveform PrismBridge; do vst3_dir="plugin/build/${art}_artefacts/Release/VST3" [ -d "$vst3_dir" ] && find "$vst3_dir" -maxdepth 1 -name "*.vst3" -exec cp -R {} plugin/dist-installer/VST3/ \; done vst3_count="$(find plugin/dist-installer/VST3 -maxdepth 1 -name "*.vst3" | wc -l | tr -d ' ')" - if [ "$vst3_count" -ne 7 ]; then - echo "ERROR: expected 7 VST3 plugins, found $vst3_count" + if [ "$vst3_count" -ne 8 ]; then + echo "ERROR: expected 8 VST3 plugins, found $vst3_count" exit 1 fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8cf3513..94ef743 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -214,6 +214,14 @@ jobs: for /f "usebackq tokens=*" %%i in (`vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set VSINSTALL=%%i call "%VSINSTALL%\VC\Auxiliary\Build\vcvars64.bat" cmake --build plugin/build --target PrismInstallerPlugins --config Release --parallel 4 + cmake --build plugin/build --target PrismBridgeTests --config Release --parallel 4 + ctest --test-dir plugin/build -C Release --output-on-failure -R PrismBridgeProcessorTests + + - name: Test Prism Bridge processor + if: runner.os != 'Windows' + run: | + cmake --build plugin/build --target PrismBridgeTests --config Release --parallel 4 + ctest --test-dir plugin/build -C Release --output-on-failure -R PrismBridgeProcessorTests - name: Show sccache stats if: runner.os != 'Linux' @@ -226,7 +234,7 @@ jobs: mkdir -p plugin/dist-installer mkdir -p plugin/dist-installer/VST3 [ "$RUNNER_OS" = "macOS" ] && mkdir -p plugin/dist-installer/AU - for art in PrismSpectrum PrismOscilloscope PrismVUMeter PrismLUFSMeter PrismVectorscope PrismSpectrogram PrismWaveform; do + for art in PrismSpectrum PrismOscilloscope PrismVUMeter PrismLUFSMeter PrismVectorscope PrismSpectrogram PrismWaveform PrismBridge; do vst3_dir="plugin/build/${art}_artefacts/Release/VST3" [ -d "$vst3_dir" ] && find "$vst3_dir" -maxdepth 1 -name "*.vst3" -exec cp -R {} plugin/dist-installer/VST3/ \; if [ "$RUNNER_OS" = "macOS" ]; then @@ -237,8 +245,8 @@ jobs: echo "--- staged VST3 ---" ls -la plugin/dist-installer/VST3 vst3_count="$(find plugin/dist-installer/VST3 -maxdepth 1 -name "*.vst3" | wc -l | tr -d ' ')" - if [ "$vst3_count" -ne 7 ]; then - echo "ERROR: expected 7 VST3 plugins, found $vst3_count" + if [ "$vst3_count" -ne 8 ]; then + echo "ERROR: expected 8 VST3 plugins, found $vst3_count" exit 1 fi if [ "$RUNNER_OS" = "Linux" ]; then @@ -255,8 +263,8 @@ jobs: echo "--- staged AU ---" ls -la plugin/dist-installer/AU au_count="$(find plugin/dist-installer/AU -maxdepth 1 -name "*.component" | wc -l | tr -d ' ')" - if [ "$au_count" -ne 7 ]; then - echo "ERROR: expected 7 AU plugins, found $au_count" + if [ "$au_count" -ne 8 ]; then + echo "ERROR: expected 8 AU plugins, found $au_count" exit 1 fi fi diff --git a/README.md b/README.md index 9d59ade..c8f5b8d 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ Most of Prism's analysis runs in native C++, with the same DSP implementations r ### DAW plugins * All seven Prism analyzers are built from the same native DSP source used by the desktop application +* Prism Bridge streams one DAW track or bus into the standalone app without changing the track audio * VST3 on Windows, macOS, and Linux * AU on macOS * Mono and stereo host layouts are supported @@ -203,6 +204,17 @@ Drop a **Spectrum**, **Oscilloscope**, **Vectorscope**, **Spectrogram**, **VU Me * Plugins follow your Prism themes and profiles * Audio passes through untouched +Prism also installs **Prism Bridge**, a lightweight pass-through plugin for sending +one DAW track or bus to the standalone application. Keep Prism open, insert Bridge +on the source you want to inspect, then choose it under **DAW Bridges** in Prism's +audio selector. Multiple Bridge instances can be available at once; only the one +you select streams audio. Waveform and Spectrogram can optionally show host-derived +Bars + Beats or Seconds rulers, including loop, seek, and packet-gap seams. + +Bridge is available as VST3 on Windows, macOS, and Linux and as AU on macOS. The AU +is intentionally not marked sandbox-safe because it requires localhost access. +Protocol details and host-validation notes are in [docs/daw-bridge.md](docs/daw-bridge.md). + Tested in Ableton Live, FL Studio, and Reaper. The plugins install alongside Prism, so there is no separate download. diff --git a/docs/daw-bridge.md b/docs/daw-bridge.md new file mode 100644 index 0000000..0d888ac --- /dev/null +++ b/docs/daw-bridge.md @@ -0,0 +1,65 @@ +# Prism Bridge protocol and host notes + +Prism Bridge is a mono/stereo pass-through effect that sends one selected DAW +track or bus to the standalone Prism application. Prism owns the server and +binds only `127.0.0.1:51789`; Bridge instances reconnect with exponential +backoff from 250 ms to 5 seconds. + +## Using Bridge + +1. Open the standalone Prism application. +2. Insert **Prism Bridge** on each track or bus you may want to inspect. +3. Optionally give each instance a custom source name in its native editor. +4. In Prism's audio selector, choose an instance under **DAW Bridges**. + +Only the selected instance transmits audio. If it disconnects, Prism stays in +DAW mode and displays **Waiting** without falling back to another input. A +same-format reconnect preserves scope history; a sample-rate or channel-layout +change begins a new analysis session. + +Waveform and Spectrogram have a **Timeline** setting with Off, Bars + Beats, and +Seconds choices. The ruler is drawn only for DAW Bridge input. Bars + Beats uses +the host PPQ, tempo, meter, and bar anchor; when beat metadata is missing it +shows a labeled seconds fallback. Loop, seek, and packet discontinuities are +kept in history as dashed Loop, Jump, and Gap seams. + +## Wire format (version 1) + +Every frame starts with a 12-byte little-endian header: + +| Offset | Type | Meaning | +| ---: | --- | --- | +| 0 | `uint32` | Magic `0x4d535250` | +| 4 | `uint16` | Protocol version (`1`) | +| 6 | `uint16` | Message type | +| 8 | `uint32` | Payload byte length | + +Hello (`1`) and source update (`3`) payloads are UTF-8 JSON. Heartbeat (`2`) +has no payload. Prism sends a JSON subscription (`10`) containing `selected`. +Audio (`20`) uses a 76-byte binary metadata header followed by little-endian +planar Float32 samples (all left samples, then all right samples). Metadata +includes sequence, frame count, channels, sample rate, sample/seconds/PPQ +position, tempo, last-bar PPQ, loop points, time signature, and play, record, +and loop flags. + +Payloads larger than 256 KiB and unknown or mismatched protocol versions are +rejected. Prism caps live connections, batches renderer IPC, and expires clients +after three seconds without traffic. Bridge packetizes into preallocated +512-frame records on the audio thread and keeps only the newest approximately +50 ms before transmission. Socket work, JSON, allocation, and locking stay on +the background or UI threads. + +The stable source UUID and custom name are stored in plug-in state. If a DAW +duplicates that state, Prism assigns distinct live keys to all duplicates and +requires an explicit choice instead of guessing which instance was intended. + +## Formats and validation + +Bridge ships as VST3 on macOS, Windows, and Linux, and as AU on macOS. It has no +Standalone target. `AU_SANDBOX_SAFE FALSE` is intentional because the AU needs +loopback networking. Logic may load Audio Units in `AUHostingServiceXPC`, so a +successful build or `auval` scan is not enough: live Logic-to-Prism loopback is +a release blocker. + +See Apple's [out-of-process Audio Unit debugging documentation](https://developer.apple.com/documentation/audiotoolbox/debugging-out-of-process-audio-units-on-apple-silicon?language=objc) +and [Audio Unit sandboxing guide](https://developer.apple.com/library/archive/technotes/tn2312/_index.html). diff --git a/package.json b/package.json index be920d7..2a4ca27 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test:audio-store": "node scripts/run-audio-store-tests.mjs", "test:astra": "node scripts/run-astra-integration-tests.mjs", "test:capture-support": "node scripts/run-capture-support-tests.mjs", + "test:daw-bridge": "node scripts/run-daw-bridge-tests.mjs", "test:spotify-provider": "node scripts/run-mac-spotify-provider-tests.mjs", "test:now-playing": "node scripts/run-now-playing-manager-tests.mjs", "test:profiles": "node scripts/run-profile-library-tests.mjs", diff --git a/plugin/CMakeLists.txt b/plugin/CMakeLists.txt index 27a061b..6b72411 100644 --- a/plugin/CMakeLists.txt +++ b/plugin/CMakeLists.txt @@ -11,6 +11,8 @@ endif() project(PrismPlugins VERSION 0.1.0 LANGUAGES C CXX) +include(CTest) + set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -197,6 +199,66 @@ add_prism_scope(PrismVectorscope "Prism Vectorscope" Pvct "PRISM_SCOPE_VECTORS add_prism_scope(PrismSpectrogram "Prism Spectrogram" Pspg "PRISM_SCOPE_SPECTROGRAM=1") add_prism_scope(PrismWaveform "Prism Waveform" Pwav "PRISM_SCOPE_WAVEFORM=1") +# Prism Bridge is intentionally a small native-only pass-through plug-in. It has +# no standalone target and never creates a webview or performs networking on the +# audio thread. +set(PRISM_BRIDGE_FORMATS ${PRISM_PLUGIN_FORMATS}) +list(REMOVE_ITEM PRISM_BRIDGE_FORMATS Standalone) + +juce_add_plugin(PrismBridge + PRODUCT_NAME "Prism Bridge" + COMPANY_NAME "Prism" + BUNDLE_ID com.astra.prism.PrismBridge + PLUGIN_MANUFACTURER_CODE Prsm + PLUGIN_CODE Pbrg + FORMATS ${PRISM_BRIDGE_FORMATS} + IS_SYNTH FALSE + NEEDS_MIDI_INPUT FALSE + NEEDS_MIDI_OUTPUT FALSE + IS_MIDI_EFFECT FALSE + AU_SANDBOX_SAFE FALSE + COPY_PLUGIN_AFTER_BUILD ${PRISM_COPY_PLUGIN_AFTER_BUILD}) + +target_sources(PrismBridge PRIVATE + Source/BridgeProcessor.cpp + Source/BridgeEditor.cpp) + +target_include_directories(PrismBridge PRIVATE Source) +target_compile_definitions(PrismBridge PRIVATE + JUCE_USE_CURL=0 + JUCE_VST3_CAN_REPLACE_VST2=0) +target_link_libraries(PrismBridge PRIVATE + juce::juce_audio_utils + juce::juce_recommended_config_flags + juce::juce_recommended_lto_flags + juce::juce_recommended_warning_flags) + +if(APPLE AND TARGET PrismBridge_AU) + # Keep local/CI AU bundles structurally valid before the release pipeline + # replaces this ad-hoc signature with the distribution identity. + add_custom_command(TARGET PrismBridge_AU POST_BUILD + COMMAND codesign --force --deep --sign - "$" + VERBATIM) +endif() + +if(BUILD_TESTING) + juce_add_console_app(PrismBridgeTests PRODUCT_NAME "Prism Bridge Tests") + target_sources(PrismBridgeTests PRIVATE + Tests/BridgeProcessorTests.cpp + Source/BridgeProcessor.cpp + Source/BridgeEditor.cpp) + target_include_directories(PrismBridgeTests PRIVATE Source) + target_compile_definitions(PrismBridgeTests PRIVATE + PRISM_BRIDGE_TESTING=1 + JucePlugin_Name="Prism Bridge" + JUCE_USE_CURL=0) + target_link_libraries(PrismBridgeTests PRIVATE + juce::juce_audio_utils + juce::juce_recommended_config_flags + juce::juce_recommended_warning_flags) + add_test(NAME PrismBridgeProcessorTests COMMAND PrismBridgeTests) +endif() + set(PRISM_SCOPE_TARGETS PrismSpectrum PrismOscilloscope @@ -204,7 +266,8 @@ set(PRISM_SCOPE_TARGETS PrismLUFSMeter PrismVectorscope PrismSpectrogram - PrismWaveform) + PrismWaveform + PrismBridge) add_custom_target(PrismInstallerPlugins) foreach(PRISM_SCOPE_TARGET IN LISTS PRISM_SCOPE_TARGETS) diff --git a/plugin/README.md b/plugin/README.md index d6efe80..17dc52d 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -2,7 +2,8 @@ JUCE 8 plugins (VST3 / AU / Standalone) that render Prism's scopes inside a DAW — one plugin per scope: **spectrum, oscilloscope, vectorscope, spectrogram, VU meter, -loudness meter, waveform**. They **reuse Prism's existing C++ DSP** (`native/src/*.cpp`) +loudness meter, waveform** — plus the native **Prism Bridge** pass-through plugin. +The analyzers **reuse Prism's existing C++ DSP** (`native/src/*.cpp`) and the **existing React canvas UI** (`src/plugin-ui`, importing the unchanged visualizers from `src/renderer/visualizers/`). VST3 is built for macOS, Windows, and Linux; AU is macOS-only. @@ -35,13 +36,14 @@ cmake -B plugin/build -S plugin -DCMAKE_BUILD_TYPE=Release # embeds the bundle ( cmake --build plugin/build --config Release ``` -`COPY_PLUGIN_AFTER_BUILD` installs all seven plugins into your user plugin folders: +`COPY_PLUGIN_AFTER_BUILD` installs all seven analyzers plus Prism Bridge into your user plugin folders: - AU: `~/Library/Audio/Plug-Ins/Components/Prism *.component` - VST3: `~/Library/Audio/Plug-Ins/VST3/Prism *.vst3` Load any `Prism *` AU / VST3 on a track in Ableton / FL / Logic / Reaper (or run the matching **Standalone** from `plugin/build/Prism*_artefacts/Release/Standalone/`), play audio, and the scope animates. +(Prism Bridge has AU/VST3 targets only and no Standalone target.) (To use a local JUCE checkout instead of fetching: add `-DJUCE_PATH=/path/to/JUCE`.) ### UI development: dev-server mode (hot reload) @@ -100,20 +102,31 @@ cmake --build plugin/build --target PrismInstallerPlugins --parallel ``` Built bundles land under `plugin/build/Prism*_artefacts/Release/VST3/`. Copy the -seven `Prism *.vst3` directories to one of the standard Linux VST3 scan paths: +eight `Prism *.vst3` directories to one of the standard Linux VST3 scan paths: - User-local: `$HOME/.vst3` - System-wide: `/usr/lib/vst3` - System-wide local: `/usr/local/lib/vst3` The Linux `.deb` and `.rpm` release packages install Prism's VST3 bundles to -`/usr/lib/vst3` and remove only those seven bundles on package removal. The Linux +`/usr/lib/vst3` and remove only those eight bundles on package removal. The Linux `tar.gz` release includes `resources/plugins/install-vst3.sh`, which installs to `$HOME/.vst3` by default or `/usr/lib/vst3` with `--system`. The AppImage is portable app-only and does not install DAW plugins. ## Notes +- **Prism Bridge:** start the standalone Prism application, insert Bridge on a + mono or stereo track/bus, then select that instance in Prism's **DAW Bridges** + input group. Bridge is bit-transparent, ignores offline rendering, and sends + audio and transport metadata only over `127.0.0.1:51789`. Its audio callback + only packetizes into a bounded lock-free queue; a background thread owns the + socket. The source UUID and custom name are saved in DAW state. +- **Logic/AU validation:** `AU_SANDBOX_SAFE FALSE` is explicit because Bridge needs + loopback networking. Apple may host Audio Units out of process; AU loopback in + Logic is therefore a release-blocking host validation, not something the build + alone can prove. + - **Refresh rate (macOS):** frames are emitted on `juce::VBlankAttachment` (synced to the display, adapts to 60/120/144 Hz) and the webview renders via the `display-sync` FrameScheduler. WKWebView otherwise throttles `requestAnimationFrame` to 60 fps regardless diff --git a/plugin/Source/BridgeEditor.cpp b/plugin/Source/BridgeEditor.cpp new file mode 100644 index 0000000..3493c27 --- /dev/null +++ b/plugin/Source/BridgeEditor.cpp @@ -0,0 +1,77 @@ +#include "BridgeEditor.h" +#include "BridgeProcessor.h" + +PrismBridgeEditor::PrismBridgeEditor(PrismBridgeProcessor& p) + : juce::AudioProcessorEditor(&p), bridgeProcessor(p) +{ + title.setText("PRISM BRIDGE", juce::dontSendNotification); + title.setFont(juce::Font(juce::FontOptions(22.0f, juce::Font::bold))); + title.setColour(juce::Label::textColourId, juce::Colour(0xfff1f5f9)); + addAndMakeVisible(title); + + nameLabel.setText("Source name", juce::dontSendNotification); + nameLabel.setColour(juce::Label::textColourId, juce::Colour(0xff94a3b8)); + addAndMakeVisible(nameLabel); + + nameEditor.setText(bridgeProcessor.getCustomName(), false); + nameEditor.setTextToShowWhenEmpty(bridgeProcessor.getDisplayName(), juce::Colour(0xff64748b)); + nameEditor.setColour(juce::TextEditor::backgroundColourId, juce::Colour(0xff111827)); + nameEditor.setColour(juce::TextEditor::textColourId, juce::Colour(0xfff8fafc)); + nameEditor.setColour(juce::TextEditor::outlineColourId, juce::Colour(0xff334155)); + nameEditor.onReturnKey = [this] { commitName(); }; + nameEditor.onFocusLost = [this] { commitName(); }; + addAndMakeVisible(nameEditor); + + for (auto* label : { &sourceInfo, &connectionInfo, &droppedInfo }) + { + label->setColour(juce::Label::textColourId, juce::Colour(0xffcbd5e1)); + addAndMakeVisible(*label); + } + connectionInfo.setColour(juce::Label::textColourId, juce::Colour(0xff38bdf8)); + + setResizable(false, false); + setSize(440, 220); + timerCallback(); + startTimerHz(4); +} + +void PrismBridgeEditor::paint(juce::Graphics& g) +{ + g.fillAll(juce::Colour(0xff080d18)); + g.setColour(juce::Colour(0xff1e293b)); + g.drawRoundedRectangle(getLocalBounds().toFloat().reduced(10.0f), 10.0f, 1.0f); +} + +void PrismBridgeEditor::resized() +{ + auto bounds = getLocalBounds().reduced(24); + title.setBounds(bounds.removeFromTop(34)); + bounds.removeFromTop(8); + nameLabel.setBounds(bounds.removeFromTop(20)); + nameEditor.setBounds(bounds.removeFromTop(32)); + bounds.removeFromTop(12); + sourceInfo.setBounds(bounds.removeFromTop(24)); + connectionInfo.setBounds(bounds.removeFromTop(24)); + droppedInfo.setBounds(bounds.removeFromTop(24)); +} + +void PrismBridgeEditor::timerCallback() +{ + const auto track = bridgeProcessor.getTrackName(); + const auto host = bridgeProcessor.getHostName(); + sourceInfo.setText( + "Host: " + (host.isNotEmpty() ? host : "Unknown") + + " Track: " + (track.isNotEmpty() ? track : "Not provided"), + juce::dontSendNotification); + connectionInfo.setText(bridgeProcessor.getConnectionDescription(), juce::dontSendNotification); + droppedInfo.setText( + "Dropped frames: " + juce::String(bridgeProcessor.getDroppedFrameCount()), + juce::dontSendNotification); +} + +void PrismBridgeEditor::commitName() +{ + bridgeProcessor.setCustomName(nameEditor.getText()); + nameEditor.setText(bridgeProcessor.getCustomName(), false); + nameEditor.setTextToShowWhenEmpty(bridgeProcessor.getDisplayName(), juce::Colour(0xff64748b)); +} diff --git a/plugin/Source/BridgeEditor.h b/plugin/Source/BridgeEditor.h new file mode 100644 index 0000000..1b9f5e3 --- /dev/null +++ b/plugin/Source/BridgeEditor.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +class PrismBridgeProcessor; + +class PrismBridgeEditor final : public juce::AudioProcessorEditor, + private juce::Timer +{ +public: + explicit PrismBridgeEditor(PrismBridgeProcessor&); + ~PrismBridgeEditor() override = default; + void paint(juce::Graphics&) override; + void resized() override; + +private: + void timerCallback() override; + void commitName(); + + PrismBridgeProcessor& bridgeProcessor; + juce::Label title; + juce::Label nameLabel; + juce::TextEditor nameEditor; + juce::Label sourceInfo; + juce::Label connectionInfo; + juce::Label droppedInfo; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PrismBridgeEditor) +}; diff --git a/plugin/Source/BridgeProcessor.cpp b/plugin/Source/BridgeProcessor.cpp new file mode 100644 index 0000000..2c770f9 --- /dev/null +++ b/plugin/Source/BridgeProcessor.cpp @@ -0,0 +1,586 @@ +#include "BridgeProcessor.h" +#include "BridgeEditor.h" +#include +#include +#include +#include + +namespace +{ +constexpr uint32_t frameMagic = 0x4d535250; +constexpr uint16_t protocolVersion = 1; +constexpr uint16_t messageHello = 1; +constexpr uint16_t messageHeartbeat = 2; +constexpr uint16_t messageSourceUpdate = 3; +constexpr uint16_t messageSubscribe = 10; +constexpr uint16_t messageAudio = 20; +constexpr size_t frameHeaderBytes = 12; +constexpr size_t audioHeaderBytes = 76; +constexpr size_t maxPayloadBytes = 256 * 1024; +constexpr int serverPort = 51789; + +template +void writeLittleEndian(uint8_t* destination, Integer value) noexcept +{ + const auto little = juce::ByteOrder::swapIfBigEndian(value); + std::memcpy(destination, &little, sizeof(little)); +} + +void writeDoubleLittleEndian(uint8_t* destination, double value) noexcept +{ + uint64_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + writeLittleEndian(destination, bits); +} + +uint16_t readU16(const uint8_t* source) noexcept +{ + uint16_t value = 0; + std::memcpy(&value, source, sizeof(value)); + return juce::ByteOrder::swapIfBigEndian(value); +} + +uint32_t readU32(const uint8_t* source) noexcept +{ + uint32_t value = 0; + std::memcpy(&value, source, sizeof(value)); + return juce::ByteOrder::swapIfBigEndian(value); +} +} + +PrismBridgeProcessor::PrismBridgeProcessor() + : juce::AudioProcessor(BusesProperties() + .withInput("Input", juce::AudioChannelSet::stereo(), true) + .withOutput("Output", juce::AudioChannelSet::stereo(), true)), + juce::Thread("Prism Bridge network"), + sourceId(juce::Uuid().toString()), + instanceId(juce::Uuid().toString()), + hostName(juce::PluginHostType().getHostDescription()) +{ +} + +PrismBridgeProcessor::~PrismBridgeProcessor() +{ + signalThreadShouldExit(); + stopThread(1500); + closeSocket(); +} + +void PrismBridgeProcessor::prepareToPlay(double sampleRate, int) +{ + currentSampleRate.store(sampleRate > 0.0 ? sampleRate : 48000.0); + nextSequence.store(0); + fifo.reset(); +#if ! defined(PRISM_BRIDGE_TESTING) || ! PRISM_BRIDGE_TESTING + if (!isThreadRunning()) + startThread(juce::Thread::Priority::normal); +#endif + identityDirty.store(true); +} + +void PrismBridgeProcessor::releaseResources() +{ + signalThreadShouldExit(); + stopThread(1000); + closeSocket(); + connected.store(false); + selected.store(false); + discardQueuedPackets(); +} + +bool PrismBridgeProcessor::isBusesLayoutSupported(const BusesLayout& layouts) const +{ + const auto input = layouts.getMainInputChannelSet(); + const auto output = layouts.getMainOutputChannelSet(); + return input == output + && (input == juce::AudioChannelSet::mono() || input == juce::AudioChannelSet::stereo()); +} + +PrismBridgeProcessor::TransportData PrismBridgeProcessor::readTransport() const noexcept +{ + TransportData result; + if (const auto* playHead = getPlayHead()) + { + if (const auto position = playHead->getPosition()) + { + result.isPlaying = position->getIsPlaying(); + result.isRecording = position->getIsRecording(); + result.isLooping = position->getIsLooping(); + if (const auto value = position->getTimeInSamples()) + { + result.timeInSamples = *value; + result.hasTimeInSamples = true; + } + if (const auto value = position->getTimeInSeconds()) + { + result.timeInSeconds = *value; + result.hasTimeInSeconds = true; + } + if (const auto value = position->getPpqPosition()) + { + result.ppqPosition = *value; + result.hasPpq = true; + } + if (const auto value = position->getBpm()) + { + result.bpm = *value; + result.hasBpm = std::isfinite(result.bpm) && result.bpm > 0.0; + } + if (const auto value = position->getPpqPositionOfLastBarStart()) + result.lastBarPpq = *value; + else + result.lastBarPpq = std::numeric_limits::quiet_NaN(); + if (const auto value = position->getTimeSignature()) + { + result.numerator = static_cast(value->numerator); + result.denominator = static_cast(value->denominator); + result.hasTimeSignature = value->numerator > 0 && value->denominator > 0; + } + if (const auto value = position->getLoopPoints()) + { + result.loopStartPpq = value->ppqStart; + result.loopEndPpq = value->ppqEnd; + } + else + { + result.loopStartPpq = std::numeric_limits::quiet_NaN(); + result.loopEndPpq = std::numeric_limits::quiet_NaN(); + } + } + } + return result; +} + +PrismBridgeProcessor::TransportData PrismBridgeProcessor::offsetTransport( + const TransportData& base, + int sampleOffset) const noexcept +{ + auto result = base; + const double sampleRate = std::max(1.0, currentSampleRate.load()); + const double seconds = static_cast(sampleOffset) / sampleRate; + if (result.hasTimeInSamples) result.timeInSamples += sampleOffset; + if (result.hasTimeInSeconds) result.timeInSeconds += seconds; + if (result.hasPpq && result.hasBpm) result.ppqPosition += seconds * result.bpm / 60.0; + return result; +} + +void PrismBridgeProcessor::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer&) +{ + juce::ScopedNoDenormals noDenormals; + if (isNonRealtime() || !selected.load(std::memory_order_relaxed)) + return; + + const int frameCount = buffer.getNumSamples(); + const int channels = buffer.getNumChannels(); + if (frameCount <= 0 || channels <= 0) + return; + + const auto transport = readTransport(); + const float* left = buffer.getReadPointer(0); + const float* right = channels > 1 ? buffer.getReadPointer(1) : left; + for (int offset = 0; offset < frameCount; offset += packetFrames) + { + const int count = std::min(packetFrames, frameCount - offset); + enqueuePacket(left + offset, right + offset, count, channels, offsetTransport(transport, offset)); + } +} + +void PrismBridgeProcessor::enqueuePacket( + const float* left, + const float* right, + int frameCount, + int channelCount, + const TransportData& transport) noexcept +{ + int start1 = 0, size1 = 0, start2 = 0, size2 = 0; + fifo.prepareToWrite(1, start1, size1, start2, size2); + if (size1 == 0) + { + droppedFrames.fetch_add(static_cast(frameCount), std::memory_order_relaxed); + return; + } + + auto& packet = packets[static_cast(start1)]; + std::memcpy(packet.left.data(), left, static_cast(frameCount) * sizeof(float)); + std::memcpy(packet.right.data(), right, static_cast(frameCount) * sizeof(float)); + packet.transport = transport; + packet.sequence = nextSequence.fetch_add(1, std::memory_order_relaxed) + 1; + packet.frameCount = static_cast(frameCount); + packet.channelCount = static_cast(channelCount > 1 ? 2 : 1); + packet.sampleRate = currentSampleRate.load(std::memory_order_relaxed); + fifo.finishedWrite(1); +} + +bool PrismBridgeProcessor::popPacket(AudioPacket& destination) noexcept +{ + int start1 = 0, size1 = 0, start2 = 0, size2 = 0; + fifo.prepareToRead(1, start1, size1, start2, size2); + if (size1 == 0) return false; + destination = packets[static_cast(start1)]; + fifo.finishedRead(1); + return true; +} + +void PrismBridgeProcessor::discardQueuedPackets() noexcept +{ + AudioPacket scratch; + while (popPacket(scratch)) {} +} + +void PrismBridgeProcessor::run() +{ + int reconnectDelayMs = 250; + int64_t lastHeartbeat = 0; + AudioPacket packet; + + while (!threadShouldExit()) + { + if (!socket || !socket->isConnected()) + { + connected.store(false); + selected.store(false); + discardQueuedPackets(); + if (!connectSocket()) + { + wait(reconnectDelayMs); + reconnectDelayMs = std::min(5000, reconnectDelayMs * 2); + continue; + } + reconnectDelayMs = 250; + lastHeartbeat = static_cast(juce::Time::getMillisecondCounterHiRes()); + } + + if (!readControlMessages()) + { + closeSocket(); + continue; + } + + const auto currentDroppedFrames = droppedFrames.load(); + if ((identityDirty.exchange(false) || currentDroppedFrames != reportedDroppedFrames) + && !sendHello(messageSourceUpdate)) + { + closeSocket(); + continue; + } + reportedDroppedFrames = currentDroppedFrames; + + const auto now = static_cast(juce::Time::getMillisecondCounterHiRes()); + if (now - lastHeartbeat >= 1000) + { + if (!sendHeartbeat()) + { + closeSocket(); + continue; + } + lastHeartbeat = now; + } + + if (!selected.load()) + { + discardQueuedPackets(); + wait(5); + continue; + } + + const auto maximumLivePackets = std::max( + 1, + static_cast(std::floor(currentSampleRate.load() * 0.05 / packetFrames))); + if (fifo.getNumReady() > maximumLivePackets) + { + while (fifo.getNumReady() > maximumLivePackets && popPacket(packet)) + droppedFrames.fetch_add(packet.frameCount, std::memory_order_relaxed); + } + + if (popPacket(packet)) + { + if (!sendAudioPacket(packet)) + closeSocket(); + } + else + { + wait(2); + } + } + + closeSocket(); +} + +bool PrismBridgeProcessor::connectSocket() +{ + socket = std::make_unique(); + if (!socket->connect("127.0.0.1", serverPort, 250)) + { + socket.reset(); + return false; + } + connected.store(true); + controlBuffer.setSize(0); + if (!sendHello(messageHello)) + { + closeSocket(); + return false; + } + reportedDroppedFrames = droppedFrames.load(); + return true; +} + +void PrismBridgeProcessor::closeSocket() +{ + connected.store(false); + selected.store(false); + if (socket) socket->close(); + socket.reset(); +} + +juce::String PrismBridgeProcessor::buildIdentityJson() const +{ + const juce::ScopedLock lock(identityLock); + auto object = new juce::DynamicObject(); + object->setProperty("sourceId", sourceId); + object->setProperty("instanceId", instanceId); + object->setProperty("customName", customName); + object->setProperty("trackName", trackName); + object->setProperty("hostName", hostName); + object->setProperty("sampleRate", currentSampleRate.load()); + object->setProperty("channelCount", getTotalNumInputChannels()); + object->setProperty("droppedFrames", static_cast(droppedFrames.load())); + return juce::JSON::toString(juce::var(object), true); +} + +bool PrismBridgeProcessor::sendHello(uint16_t messageType) +{ + const auto json = buildIdentityJson(); + return sendFrame(messageType, json.toRawUTF8(), static_cast(json.getNumBytesAsUTF8())); +} + +bool PrismBridgeProcessor::sendHeartbeat() +{ + return sendFrame(messageHeartbeat, nullptr, 0); +} + +bool PrismBridgeProcessor::sendAudioPacket(const AudioPacket& packet) +{ + std::array payload {}; + const auto payloadSize = encodeAudioPayload(packet, payload.data(), payload.size()); + return payloadSize > 0 && sendFrame(messageAudio, payload.data(), payloadSize); +} + +size_t PrismBridgeProcessor::encodeAudioPayload( + const AudioPacket& packet, + uint8_t* payload, + size_t capacity) const noexcept +{ + const auto requiredSize = audioHeaderBytes + + static_cast(packet.frameCount) * packet.channelCount * sizeof(float); + if (!payload || packet.frameCount == 0 || packet.frameCount > packetFrames + || (packet.channelCount != 1 && packet.channelCount != 2) || capacity < requiredSize) + return 0; + + std::memset(payload, 0, requiredSize); + writeLittleEndian(payload + 0, packet.sequence); + writeLittleEndian(payload + 4, packet.frameCount); + payload[6] = packet.channelCount; + uint8_t flags = 0; + if (packet.transport.isPlaying) flags |= 1 << 0; + if (packet.transport.isRecording) flags |= 1 << 1; + if (packet.transport.isLooping) flags |= 1 << 2; + if (packet.transport.hasTimeInSamples) flags |= 1 << 3; + if (packet.transport.hasTimeInSeconds) flags |= 1 << 4; + if (packet.transport.hasPpq) flags |= 1 << 5; + if (packet.transport.hasBpm) flags |= 1 << 6; + if (packet.transport.hasTimeSignature) flags |= 1 << 7; + payload[7] = flags; + writeDoubleLittleEndian(payload + 8, packet.sampleRate); + writeLittleEndian(payload + 16, packet.transport.timeInSamples); + writeDoubleLittleEndian(payload + 24, packet.transport.timeInSeconds); + writeDoubleLittleEndian(payload + 32, packet.transport.ppqPosition); + writeDoubleLittleEndian(payload + 40, packet.transport.bpm); + writeDoubleLittleEndian(payload + 48, packet.transport.lastBarPpq); + writeDoubleLittleEndian(payload + 56, packet.transport.loopStartPpq); + writeDoubleLittleEndian(payload + 64, packet.transport.loopEndPpq); + writeLittleEndian(payload + 72, packet.transport.numerator); + writeLittleEndian(payload + 74, packet.transport.denominator); + + size_t offset = audioHeaderBytes; + for (size_t index = 0; index < packet.frameCount; ++index) + { + uint32_t bits = 0; + std::memcpy(&bits, &packet.left[index], sizeof(bits)); + writeLittleEndian(payload + offset, bits); + offset += sizeof(float); + } + if (packet.channelCount > 1) + { + for (size_t index = 0; index < packet.frameCount; ++index) + { + uint32_t bits = 0; + std::memcpy(&bits, &packet.right[index], sizeof(bits)); + writeLittleEndian(payload + offset, bits); + offset += sizeof(float); + } + } + return offset; +} + +bool PrismBridgeProcessor::sendFrame(uint16_t messageType, const void* payload, size_t payloadSize) +{ + if (payloadSize > maxPayloadBytes) return false; + std::array header {}; + writeLittleEndian(header.data() + 0, frameMagic); + writeLittleEndian(header.data() + 4, protocolVersion); + writeLittleEndian(header.data() + 6, messageType); + writeLittleEndian(header.data() + 8, static_cast(payloadSize)); + return writeAll(header.data(), header.size()) + && (payloadSize == 0 || writeAll(payload, payloadSize)); +} + +bool PrismBridgeProcessor::writeAll(const void* source, size_t byteCount) +{ + if (!socket || !socket->isConnected()) return false; + const auto* bytes = static_cast(source); + size_t written = 0; + while (written < byteCount && !threadShouldExit()) + { + if (socket->waitUntilReady(false, 50) < 1) return false; + const int result = socket->write(bytes + written, static_cast(byteCount - written)); + if (result <= 0) return false; + written += static_cast(result); + } + return written == byteCount; +} + +bool PrismBridgeProcessor::readControlMessages() +{ + if (!socket || !socket->isConnected()) return false; + while (socket->waitUntilReady(true, 0) > 0) + { + std::array buffer {}; + const int bytesRead = socket->read(buffer.data(), static_cast(buffer.size()), false); + if (bytesRead <= 0) return false; + controlBuffer.append(buffer.data(), static_cast(bytesRead)); + if (controlBuffer.getSize() > maxPayloadBytes + frameHeaderBytes) return false; + parseControlBuffer(); + } + return true; +} + +void PrismBridgeProcessor::parseControlBuffer() +{ + while (controlBuffer.getSize() >= frameHeaderBytes) + { + const auto* data = static_cast(controlBuffer.getData()); + const uint32_t magic = readU32(data + 0); + const uint16_t version = readU16(data + 4); + const uint16_t messageType = readU16(data + 6); + const uint32_t payloadSize = readU32(data + 8); + if (magic != frameMagic || version != protocolVersion || payloadSize > maxPayloadBytes) + { + closeSocket(); + return; + } + const size_t frameSize = frameHeaderBytes + payloadSize; + if (controlBuffer.getSize() < frameSize) return; + + if (messageType == messageSubscribe) + { + const auto json = juce::String::fromUTF8( + reinterpret_cast(data + frameHeaderBytes), + static_cast(payloadSize)); + const auto parsed = juce::JSON::parse(json); + if (const auto* object = parsed.getDynamicObject()) + selected.store(static_cast(object->getProperty("selected"))); + } + + juce::MemoryBlock remainder; + if (controlBuffer.getSize() > frameSize) + remainder.append(data + frameSize, controlBuffer.getSize() - frameSize); + controlBuffer.swapWith(remainder); + } +} + +void PrismBridgeProcessor::updateTrackProperties(const TrackProperties& properties) +{ + { + const juce::ScopedLock lock(identityLock); + trackName = properties.name; + } + identityDirty.store(true); +} + +void PrismBridgeProcessor::setCustomName(const juce::String& value) +{ + { + const juce::ScopedLock lock(identityLock); + customName = value.trim().substring(0, 160); + } + identityDirty.store(true); + updateHostDisplay(ChangeDetails().withNonParameterStateChanged(true)); +} + +juce::String PrismBridgeProcessor::getCustomName() const +{ + const juce::ScopedLock lock(identityLock); + return customName; +} + +juce::String PrismBridgeProcessor::getDisplayName() const +{ + const juce::ScopedLock lock(identityLock); + if (customName.isNotEmpty()) return customName; + if (trackName.isNotEmpty()) return trackName; + return "Prism Bridge " + sourceId.substring(0, 8); +} + +juce::String PrismBridgeProcessor::getHostName() const +{ + const juce::ScopedLock lock(identityLock); + return hostName; +} + +juce::String PrismBridgeProcessor::getTrackName() const +{ + const juce::ScopedLock lock(identityLock); + return trackName; +} + +juce::String PrismBridgeProcessor::getConnectionDescription() const +{ + if (!connected.load()) return "Waiting for Prism"; + return selected.load() ? "Connected · selected in Prism" : "Connected · available in Prism"; +} + +void PrismBridgeProcessor::getStateInformation(juce::MemoryBlock& destination) +{ + const juce::ScopedLock lock(identityLock); + auto object = new juce::DynamicObject(); + object->setProperty("sourceId", sourceId); + object->setProperty("customName", customName); + const auto json = juce::JSON::toString(juce::var(object)); + destination.setSize(0); + destination.append(json.toRawUTF8(), static_cast(json.getNumBytesAsUTF8())); +} + +void PrismBridgeProcessor::setStateInformation(const void* data, int sizeInBytes) +{ + if (!data || sizeInBytes <= 0) return; + const auto parsed = juce::JSON::parse(juce::String::fromUTF8(static_cast(data), sizeInBytes)); + if (const auto* object = parsed.getDynamicObject()) + { + const juce::ScopedLock lock(identityLock); + const auto restoredId = object->getProperty("sourceId").toString(); + if (restoredId.isNotEmpty()) sourceId = restoredId; + customName = object->getProperty("customName").toString().trim().substring(0, 160); + } + identityDirty.store(true); +} + +juce::AudioProcessorEditor* PrismBridgeProcessor::createEditor() +{ + return new PrismBridgeEditor(*this); +} + +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new PrismBridgeProcessor(); +} diff --git a/plugin/Source/BridgeProcessor.h b/plugin/Source/BridgeProcessor.h new file mode 100644 index 0000000..bbb5ae9 --- /dev/null +++ b/plugin/Source/BridgeProcessor.h @@ -0,0 +1,147 @@ +#pragma once + +#include +#include +#include +#include +#include + +class PrismBridgeEditor; + +class PrismBridgeProcessor final : public juce::AudioProcessor, + private juce::Thread +{ +public: + static constexpr int packetFrames = 512; + static constexpr int packetCapacity = 128; + + PrismBridgeProcessor(); + ~PrismBridgeProcessor() 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; + void updateTrackProperties(const TrackProperties&) override; + + juce::AudioProcessorEditor* createEditor() override; + bool hasEditor() const override { return true; } + const juce::String getName() const override { return JucePlugin_Name; } + bool acceptsMidi() const override { return false; } + bool producesMidi() const override { return false; } + bool isMidiEffect() const override { return false; } + double getTailLengthSeconds() const override { return 0.0; } + int getNumPrograms() override { return 1; } + int getCurrentProgram() override { return 0; } + void setCurrentProgram(int) override {} + const juce::String getProgramName(int) override { return {}; } + void changeProgramName(int, const juce::String&) override {} + + void getStateInformation(juce::MemoryBlock&) override; + void setStateInformation(const void*, int) override; + + void setCustomName(const juce::String&); + juce::String getCustomName() const; + juce::String getDisplayName() const; + juce::String getHostName() const; + juce::String getTrackName() const; + juce::String getConnectionDescription() const; + uint64_t getDroppedFrameCount() const noexcept { return droppedFrames.load(); } + +#if defined(PRISM_BRIDGE_TESTING) && PRISM_BRIDGE_TESTING + void setSelectedForTesting(bool value) noexcept { selected.store(value); } + int getQueuedPacketCountForTesting() const noexcept { return fifo.getNumReady(); } + void clearQueuedPacketsForTesting() noexcept { discardQueuedPackets(); } +#endif + +private: +#if defined(PRISM_BRIDGE_TESTING) && PRISM_BRIDGE_TESTING +public: +#endif + struct TransportData + { + int64_t timeInSamples = 0; + double timeInSeconds = 0.0; + double ppqPosition = 0.0; + double bpm = 0.0; + double lastBarPpq = 0.0; + double loopStartPpq = 0.0; + double loopEndPpq = 0.0; + int16_t numerator = 4; + int16_t denominator = 4; + bool hasTimeInSamples = false; + bool hasTimeInSeconds = false; + bool hasPpq = false; + bool hasBpm = false; + bool hasTimeSignature = false; + bool isPlaying = false; + bool isRecording = false; + bool isLooping = false; + }; + + struct AudioPacket + { + std::array left {}; + std::array right {}; + TransportData transport; + uint32_t sequence = 0; + uint16_t frameCount = 0; + uint8_t channelCount = 2; + double sampleRate = 48000.0; + }; + +#if defined(PRISM_BRIDGE_TESTING) && PRISM_BRIDGE_TESTING + TransportData offsetTransportForTesting(const TransportData& value, int offset) const noexcept + { + return offsetTransport(value, offset); + } + size_t encodeAudioPayloadForTesting(const AudioPacket& packet, uint8_t* destination, size_t capacity) const noexcept + { + return encodeAudioPayload(packet, destination, capacity); + } +#endif + +private: + + void run() override; + void enqueuePacket(const float*, const float*, int, int, const TransportData&) noexcept; + bool popPacket(AudioPacket&) noexcept; + void discardQueuedPackets() noexcept; + TransportData readTransport() const noexcept; + TransportData offsetTransport(const TransportData&, int sampleOffset) const noexcept; + bool connectSocket(); + void closeSocket(); + bool sendHello(uint16_t messageType = 1); + bool sendHeartbeat(); + bool sendAudioPacket(const AudioPacket&); + size_t encodeAudioPayload(const AudioPacket&, uint8_t*, size_t) const noexcept; + bool readControlMessages(); + bool sendFrame(uint16_t messageType, const void*, size_t); + bool writeAll(const void*, size_t); + void parseControlBuffer(); + juce::String buildIdentityJson() const; + + // AbstractFifo reserves one sentinel slot, so allocate one extra element to + // make packetCapacity the actual writable packet count. + juce::AbstractFifo fifo { packetCapacity + 1 }; + std::array packets; + std::atomic currentSampleRate { 48000.0 }; + std::atomic nextSequence { 0 }; + std::atomic droppedFrames { 0 }; + std::atomic connected { false }; + std::atomic selected { false }; + std::atomic identityDirty { true }; + + mutable juce::CriticalSection identityLock; + juce::String sourceId; + const juce::String instanceId; + juce::String customName; + juce::String trackName; + juce::String hostName; + + std::unique_ptr socket; + juce::MemoryBlock controlBuffer; + uint64_t reportedDroppedFrames = 0; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PrismBridgeProcessor) +}; diff --git a/plugin/Tests/BridgeProcessorTests.cpp b/plugin/Tests/BridgeProcessorTests.cpp new file mode 100644 index 0000000..d4a882e --- /dev/null +++ b/plugin/Tests/BridgeProcessorTests.cpp @@ -0,0 +1,131 @@ +#include "BridgeProcessor.h" +#include +#include +#include + +namespace +{ +int failures = 0; + +void expect(bool condition, const char* message) +{ + if (condition) return; + std::cerr << "FAIL: " << message << '\n'; + ++failures; +} + +void fillTestSignal(juce::AudioBuffer& buffer) +{ + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + for (int sample = 0; sample < buffer.getNumSamples(); ++sample) + buffer.setSample(channel, sample, std::sin(static_cast(sample + channel * 7) * 0.013f)); +} + +void expectBitIdentical(const juce::AudioBuffer& actual, + const juce::AudioBuffer& expected, + const char* message) +{ + bool equal = actual.getNumChannels() == expected.getNumChannels() + && actual.getNumSamples() == expected.getNumSamples(); + for (int channel = 0; equal && channel < actual.getNumChannels(); ++channel) + equal = std::memcmp(actual.getReadPointer(channel), + expected.getReadPointer(channel), + static_cast(actual.getNumSamples()) * sizeof(float)) == 0; + expect(equal, message); +} +} + +int main() +{ + PrismBridgeProcessor processor; + processor.prepareToPlay(48000.0, 1024); + processor.setSelectedForTesting(true); + juce::MidiBuffer midi; + + juce::AudioBuffer stereo(2, 1025); + fillTestSignal(stereo); + juce::AudioBuffer stereoCopy; + stereoCopy.makeCopyOf(stereo); + processor.processBlock(stereo, midi); + expectBitIdentical(stereo, stereoCopy, "stereo processing must be bit-identical"); + expect(processor.getQueuedPacketCountForTesting() == 3, "1025 stereo frames must produce three packets"); + + processor.clearQueuedPacketsForTesting(); + juce::AudioBuffer mono(1, PrismBridgeProcessor::packetFrames); + fillTestSignal(mono); + juce::AudioBuffer monoCopy; + monoCopy.makeCopyOf(mono); + processor.processBlock(mono, midi); + expectBitIdentical(mono, monoCopy, "mono processing must be bit-identical"); + expect(processor.getQueuedPacketCountForTesting() == 1, "one mono packet must be queued"); + + processor.clearQueuedPacketsForTesting(); + processor.setNonRealtime(true); + processor.processBlock(mono, midi); + expect(processor.getQueuedPacketCountForTesting() == 0, "offline rendering must not enqueue audio"); + processor.setNonRealtime(false); + + for (int index = 0; index < PrismBridgeProcessor::packetCapacity + 2; ++index) + processor.processBlock(mono, midi); + expect(processor.getQueuedPacketCountForTesting() == PrismBridgeProcessor::packetCapacity, + "the audio queue must remain bounded"); + expect(processor.getDroppedFrameCount() >= 2 * PrismBridgeProcessor::packetFrames, + "overflow must report dropped frames"); + + PrismBridgeProcessor::TransportData transport; + transport.timeInSamples = 24000; + transport.timeInSeconds = 0.5; + transport.ppqPosition = 4.0; + transport.bpm = 120.0; + transport.hasTimeInSamples = true; + transport.hasTimeInSeconds = true; + transport.hasPpq = true; + transport.hasBpm = true; + const auto interpolated = processor.offsetTransportForTesting(transport, 24000); + expect(interpolated.timeInSamples == 48000, "sample transport must interpolate by packet offset"); + expect(std::abs(interpolated.timeInSeconds - 1.0) < 1.0e-9, + "seconds transport must interpolate by packet offset"); + expect(std::abs(interpolated.ppqPosition - 5.0) < 1.0e-9, + "PPQ transport must interpolate using tempo"); + + PrismBridgeProcessor::AudioPacket encodedPacket; + encodedPacket.sequence = 0x01020304; + encodedPacket.frameCount = 2; + encodedPacket.channelCount = 2; + encodedPacket.sampleRate = 48000.0; + encodedPacket.transport = transport; + encodedPacket.transport.isPlaying = true; + encodedPacket.transport.hasTimeSignature = true; + encodedPacket.transport.numerator = 7; + encodedPacket.transport.denominator = 8; + encodedPacket.left[0] = 0.25f; + encodedPacket.left[1] = -0.5f; + encodedPacket.right[0] = 0.75f; + encodedPacket.right[1] = -1.0f; + std::array encoded {}; + const auto encodedBytes = processor.encodeAudioPayloadForTesting( + encodedPacket, encoded.data(), encoded.size()); + expect(encodedBytes == 92, "protocol payload must contain the fixed header and planar samples"); + expect(encoded[0] == 0x04 && encoded[1] == 0x03 && encoded[2] == 0x02 && encoded[3] == 0x01, + "protocol sequence must be little-endian"); + expect(encoded[4] == 2 && encoded[5] == 0 && encoded[6] == 2, + "protocol frame and channel counts must be encoded"); + expect(encoded[72] == 7 && encoded[74] == 8, + "protocol time signature must be little-endian"); + float decodedLeft = 0.0f; + std::memcpy(&decodedLeft, encoded.data() + 76, sizeof(decodedLeft)); + decodedLeft = juce::ByteOrder::swapIfBigEndian(decodedLeft); + expect(decodedLeft == 0.25f, "protocol audio must be planar Float32"); + + processor.setCustomName("Drum Bus"); + juce::MemoryBlock state; + processor.getStateInformation(state); + PrismBridgeProcessor restored; + restored.setStateInformation(state.getData(), static_cast(state.getSize())); + expect(restored.getCustomName() == "Drum Bus", "custom source names must restore from DAW state"); + + processor.releaseResources(); + if (failures == 0) + std::cout << "Prism Bridge processor tests passed\n"; + return failures == 0 ? 0 : 1; +} diff --git a/resources/installer/linux/install-vst3-from-package.sh b/resources/installer/linux/install-vst3-from-package.sh index 2d70165..5efbc2b 100755 --- a/resources/installer/linux/install-vst3-from-package.sh +++ b/resources/installer/linux/install-vst3-from-package.sh @@ -97,6 +97,7 @@ install_plugin "Prism Loudness Meter.vst3" install_plugin "Prism Vectorscope.vst3" install_plugin "Prism Spectrogram.vst3" install_plugin "Prism Waveform.vst3" +install_plugin "Prism Bridge.vst3" chmod -R a+rX "$DEST_DIR"/Prism*.vst3 2>/dev/null || true echo "Prism VST3 plugins installed to $DEST_DIR" diff --git a/resources/installer/linux/install-vst3.sh b/resources/installer/linux/install-vst3.sh index 4509ccd..55935c4 100755 --- a/resources/installer/linux/install-vst3.sh +++ b/resources/installer/linux/install-vst3.sh @@ -94,6 +94,7 @@ install_plugin "Prism Loudness Meter.vst3" install_plugin "Prism Vectorscope.vst3" install_plugin "Prism Spectrogram.vst3" install_plugin "Prism Waveform.vst3" +install_plugin "Prism Bridge.vst3" chmod -R u+rwX,go+rX "$DEST_DIR"/Prism*.vst3 2>/dev/null || true diff --git a/resources/installer/linux/remove-vst3-from-system.sh b/resources/installer/linux/remove-vst3-from-system.sh index c751394..10559b5 100755 --- a/resources/installer/linux/remove-vst3-from-system.sh +++ b/resources/installer/linux/remove-vst3-from-system.sh @@ -52,6 +52,7 @@ remove_plugin "Prism Loudness Meter.vst3" remove_plugin "Prism Vectorscope.vst3" remove_plugin "Prism Spectrogram.vst3" remove_plugin "Prism Waveform.vst3" +remove_plugin "Prism Bridge.vst3" remove_tui_link echo "Prism VST3 plugins removed from $DEST_DIR" diff --git a/resources/installer/windows-vst3.nsh b/resources/installer/windows-vst3.nsh index fb5b587..c66f47f 100644 --- a/resources/installer/windows-vst3.nsh +++ b/resources/installer/windows-vst3.nsh @@ -60,7 +60,7 @@ Pop $PRISM_VST_CHECKBOX ${NSD_Check} $PRISM_VST_CHECKBOX - ${NSD_CreateLabel} 0 22u 100% 80u "Installs the seven Prism scope plugins (Spectrum, Oscilloscope, Vectorscope, Spectrogram, VU Meter, Loudness Meter, Waveform) to:$\r$\n$\r$\n $COMMONFILES64\VST3$\r$\n$\r$\nDAWs (FL Studio, Ableton, Logic, Reaper, etc.) will find them on the next plugin rescan.$\r$\n$\r$\nUncheck to install only the Prism desktop app. You can re-run this installer at any time to add the plugins later." + ${NSD_CreateLabel} 0 22u 100% 80u "Installs seven Prism analyzer plugins plus Prism Bridge to:$\r$\n$\r$\n $COMMONFILES64\VST3$\r$\n$\r$\nDAWs (FL Studio, Ableton, Logic, Reaper, etc.) will find them on the next plugin rescan.$\r$\n$\r$\nPrism Bridge sends one inserted track or bus to the standalone Prism app. Uncheck to install only the desktop app." Pop $0 nsDialogs::Show @@ -159,4 +159,5 @@ RMDir /r "$COMMONFILES64\VST3\Prism Vectorscope.vst3" RMDir /r "$COMMONFILES64\VST3\Prism Spectrogram.vst3" RMDir /r "$COMMONFILES64\VST3\Prism Waveform.vst3" + RMDir /r "$COMMONFILES64\VST3\Prism Bridge.vst3" !macroend diff --git a/scripts/run-daw-bridge-tests.mjs b/scripts/run-daw-bridge-tests.mjs new file mode 100644 index 0000000..07366d3 --- /dev/null +++ b/scripts/run-daw-bridge-tests.mjs @@ -0,0 +1,31 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { build } from 'esbuild' + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) +const tempDir = await mkdtemp(join(tmpdir(), 'prism-daw-bridge-tests-')) +const bundledTestPath = join(tempDir, 'daw-bridge-service.test.mjs') + +let exitCode = 1 +try { + await build({ + entryPoints: [join(rootDir, 'test', 'daw-bridge-service.test.ts')], + outfile: bundledTestPath, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node23', + sourcemap: 'inline', + }) + exitCode = await new Promise((resolve) => { + const child = spawn(process.execPath, ['--test', bundledTestPath], { stdio: 'inherit', cwd: rootDir }) + child.on('exit', (code) => resolve(code ?? 1)) + child.on('error', () => resolve(1)) + }) +} finally { + await rm(tempDir, { recursive: true, force: true }) +} +process.exit(exitCode) diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 1c77f16..a3f9569 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -6,6 +6,7 @@ const testScripts = [ 'test:audio-store', 'test:astra', 'test:capture-support', + 'test:daw-bridge', 'test:spotify-provider', 'test:now-playing', 'test:profiles', diff --git a/src/main/index.ts b/src/main/index.ts index 6293727..9e77429 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -82,6 +82,8 @@ import { } from './services/trayMenu' import { TrayRendererCommandQueue } from './services/trayRendererCommandQueue' import { resolveTrayAssetPath } from './services/trayAssets' +import { DawBridgeService } from './services/dawBridgeService' +import type { DawBridgeAudioBatch, DawBridgeSnapshot } from '../types/dawBridge' let mainWindow: BrowserWindow | null = null let moveInterval: ReturnType | null = null @@ -130,6 +132,7 @@ let nativeWindowsMediaApi: NativeWindowsMediaAPI | null | undefined let nativeWindowChromeApi: NativeWindowChromeAPI | null | undefined let loginItemService: LoginItemService | null = null let audioClipLibrary: AudioClipLibrary | null = null +let dawBridgeService: DawBridgeService | null = null let desktopIntegrationPreferences: DesktopIntegrationPreferences = { ...DEFAULT_DESKTOP_INTEGRATION_PREFERENCES, } @@ -686,6 +689,19 @@ function createNativeTrayMenu(model: ReturnType): Ele deviceId: source.id || null, }), })), + { type: 'separator' }, + { label: 'DAW Bridges', enabled: false }, + ...model.rendererState.dawSources.map((source): MenuItemConstructorOptions => ({ + label: source.label, + type: 'radio', + checked: model.rendererState.captureMode === 'daw' + && source.id === model.rendererState.selectedDawSourceId, + enabled: model.rendererReady, + click: () => sendTrayRendererCommand({ + type: 'select-daw-source', + sourceId: source.id, + }), + })), ] if (model.rendererState.systemSources.length === 0) { audioSourceItems.splice(1, 0, { label: 'No outputs available', enabled: false }) @@ -693,6 +709,9 @@ function createNativeTrayMenu(model: ReturnType): Ele if (model.rendererState.inputSources.length === 0) { audioSourceItems.push({ label: 'No inputs available', enabled: false }) } + if (model.rendererState.dawSources.length === 0) { + audioSourceItems.push({ label: 'No DAW bridges connected', enabled: false }) + } const loginStatus = loginItemStatusLabel(model.desktopIntegration) const rollingCaptureItems: MenuItemConstructorOptions[] = [ @@ -2346,6 +2365,24 @@ function setupIPC(): void { return getAppBuildInfo() }) + ipcMain.handle('daw-bridge:get-snapshot', () => { + return dawBridgeService?.getSnapshot() ?? { + available: false, + reason: 'The DAW bridge listener is not initialized.', + selectedSourceId: null, + sources: [], + } satisfies DawBridgeSnapshot + }) + + ipcMain.handle('daw-bridge:select-source', (_event, sourceId: unknown) => { + return dawBridgeService?.selectSource(typeof sourceId === 'string' ? sourceId : null) ?? { + available: false, + reason: 'The DAW bridge listener is not initialized.', + selectedSourceId: null, + sources: [], + } satisfies DawBridgeSnapshot + }) + ipcMain.on('audio-clips:start-drag', (event, rawPayload: unknown) => { const targetWindow = getWindowFromSender(event.sender) if (!targetWindow || !isMainRendererWindow(targetWindow)) return @@ -2778,6 +2815,19 @@ if (!hasSingleInstanceLock) { setupPermissions() void getNowPlayingManager().initialize() setupIPC() + dawBridgeService = new DawBridgeService({ + onSnapshot: (snapshot: DawBridgeSnapshot) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('daw-bridge:snapshot', snapshot) + } + }, + onAudioBatch: (batch: DawBridgeAudioBatch) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('daw-bridge:audio-batch', batch) + } + }, + }) + await dawBridgeService.start() await getWindowStateStore().initialize() desktopIntegrationPreferences = await loadDesktopIntegrationPreferences( getDesktopIntegrationPreferencesPath(), @@ -2837,6 +2887,8 @@ app.on('window-all-closed', () => { app.on('before-quit', () => { isAppQuitting = true + dawBridgeService?.stop() + dawBridgeService = null destroyAppTray() pendingTrayRendererCommands.clear() }) diff --git a/src/main/profileLibrary.ts b/src/main/profileLibrary.ts index 24b2e72..c759cb4 100644 --- a/src/main/profileLibrary.ts +++ b/src/main/profileLibrary.ts @@ -356,6 +356,7 @@ export class FileBackedProfileLibrary { && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 + && candidate.version !== 5 && candidate.version !== PROFILE_FILE_VERSION ) { throw new Error(`Unsupported profile version in ${basename(filePath)}.`) diff --git a/src/main/services/dawBridgeService.ts b/src/main/services/dawBridgeService.ts new file mode 100644 index 0000000..33838f6 --- /dev/null +++ b/src/main/services/dawBridgeService.ts @@ -0,0 +1,458 @@ +import { createServer, type Server, type Socket } from 'net' +import { + DAW_BRIDGE_AUDIO_HEADER_BYTES, + DAW_BRIDGE_FRAME_HEADER_BYTES, + DAW_BRIDGE_FRAME_MAGIC, + DAW_BRIDGE_HOST, + DAW_BRIDGE_MAX_PAYLOAD_BYTES, + DAW_BRIDGE_MESSAGE, + DAW_BRIDGE_PORT, + DAW_BRIDGE_PROTOCOL_VERSION, + type DawBridgeAudioBatch, + type DawBridgeAudioPacket, + type DawBridgeHelloPayload, + type DawBridgeSnapshot, + type DawBridgeSourceDescriptor, + type DawBridgeSubscribePayload, + type DawTransportSnapshot, +} from '../../types/dawBridge' + +const HEARTBEAT_INTERVAL_MS = 1000 +const HEARTBEAT_TIMEOUT_MS = 3000 +const AUDIO_BATCH_INTERVAL_MS = 10 +const MAX_CONNECTIONS = 64 +const MAX_PENDING_PACKETS = 128 +const SAFE_ID_PATTERN = /^[a-zA-Z0-9._:-]{1,128}$/ + +interface ClientState { + socket: Socket + receiveBuffer: Buffer + source: DawBridgeSourceDescriptor | null + lastHeartbeatAt: number +} + +interface DawBridgeServiceOptions { + host?: string + port?: number + onSnapshot?: (snapshot: DawBridgeSnapshot) => void + onAudioBatch?: (batch: DawBridgeAudioBatch) => void + heartbeatIntervalMs?: number + heartbeatTimeoutMs?: number + audioBatchIntervalMs?: number +} + +function finiteNumber(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +function optionalString(value: unknown): string | null { + if (typeof value !== 'string') return null + const trimmed = value.trim() + return trimmed ? trimmed.slice(0, 160) : null +} + +function isHelloPayload(value: unknown): value is DawBridgeHelloPayload { + if (typeof value !== 'object' || value === null) return false + const candidate = value as Partial + return typeof candidate.sourceId === 'string' + && SAFE_ID_PATTERN.test(candidate.sourceId) + && typeof candidate.instanceId === 'string' + && SAFE_ID_PATTERN.test(candidate.instanceId) +} + +function sourceLabel(payload: DawBridgeHelloPayload): string { + return optionalString(payload.customName) + ?? optionalString(payload.trackName) + ?? `Prism Bridge ${payload.sourceId.slice(0, 8)}` +} + +function isLoopbackAddress(address: string | undefined): boolean { + return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' +} + +export class DawBridgeService { + private readonly host: string + private readonly port: number + private readonly onSnapshot?: (snapshot: DawBridgeSnapshot) => void + private readonly onAudioBatch?: (batch: DawBridgeAudioBatch) => void + private readonly heartbeatIntervalMs: number + private readonly heartbeatTimeoutMs: number + private readonly audioBatchIntervalMs: number + private server: Server | null = null + private clients = new Set() + private sources = new Map() + private selectedSourceId: string | null = null + private available = false + private unavailableReason: string | null = 'The DAW bridge listener has not started.' + private heartbeatTimer: ReturnType | null = null + private audioBatchTimer: ReturnType | null = null + private pendingAudioPackets: DawBridgeAudioPacket[] = [] + + constructor(options: DawBridgeServiceOptions = {}) { + this.host = options.host ?? DAW_BRIDGE_HOST + this.port = options.port ?? DAW_BRIDGE_PORT + this.onSnapshot = options.onSnapshot + this.onAudioBatch = options.onAudioBatch + this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS + this.heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? HEARTBEAT_TIMEOUT_MS + this.audioBatchIntervalMs = options.audioBatchIntervalMs ?? AUDIO_BATCH_INTERVAL_MS + } + + async start(): Promise { + if (this.server) return this.getSnapshot() + + const server = createServer((socket) => this.acceptClient(socket)) + this.server = server + server.maxConnections = MAX_CONNECTIONS + + await new Promise((resolve) => { + const handleError = (error: Error): void => { + server.removeListener('listening', handleListening) + this.available = false + this.unavailableReason = `Could not listen on ${this.host}:${this.port}: ${error.message}` + this.server = null + try { + server.close() + } catch { + // The server may never have entered the listening state. + } + resolve() + } + const handleListening = (): void => { + server.removeListener('error', handleError) + this.available = true + this.unavailableReason = null + this.heartbeatTimer = setInterval(() => this.expireSilentClients(), this.heartbeatIntervalMs) + resolve() + } + server.once('error', handleError) + server.once('listening', handleListening) + server.listen(this.port, this.host) + }) + + this.emitSnapshot() + return this.getSnapshot() + } + + stop(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = null + } + if (this.audioBatchTimer) { + clearTimeout(this.audioBatchTimer) + this.audioBatchTimer = null + } + this.pendingAudioPackets = [] + for (const client of this.clients) { + client.socket.destroy() + } + this.clients.clear() + this.sources.clear() + this.selectedSourceId = null + this.server?.close() + this.server = null + this.available = false + this.unavailableReason = 'The DAW bridge listener is stopped.' + } + + getSnapshot(): DawBridgeSnapshot { + return { + available: this.available, + reason: this.unavailableReason, + selectedSourceId: this.selectedSourceId, + sources: [...this.sources.values()] + .flatMap((client) => client.source ? [client.source] : []) + .sort((left, right) => left.label.localeCompare(right.label)), + } + } + + getListeningPort(): number | null { + const address = this.server?.address() + return typeof address === 'object' && address ? address.port : null + } + + selectSource(sourceId: string | null): DawBridgeSnapshot { + this.selectedSourceId = typeof sourceId === 'string' && this.sources.has(sourceId) ? sourceId : null + for (const client of this.clients) { + this.sendSubscription(client) + if (client.source) { + client.source = { + ...client.source, + connectionState: client.source.id === this.selectedSourceId ? 'selected' : 'connected', + } + } + } + this.emitSnapshot() + return this.getSnapshot() + } + + private acceptClient(socket: Socket): void { + if (!isLoopbackAddress(socket.remoteAddress) || this.clients.size >= MAX_CONNECTIONS) { + socket.destroy() + return + } + + socket.setNoDelay(true) + socket.setKeepAlive(true, this.heartbeatIntervalMs) + const client: ClientState = { + socket, + receiveBuffer: Buffer.alloc(0), + source: null, + lastHeartbeatAt: Date.now(), + } + this.clients.add(client) + + socket.on('data', (chunk) => this.handleData(client, chunk)) + socket.on('error', () => socket.destroy()) + socket.on('close', () => this.removeClient(client)) + } + + private handleData(client: ClientState, chunk: Buffer): void { + client.lastHeartbeatAt = Date.now() + client.receiveBuffer = client.receiveBuffer.length === 0 + ? chunk + : Buffer.concat([client.receiveBuffer, chunk]) + + while (client.receiveBuffer.length >= DAW_BRIDGE_FRAME_HEADER_BYTES) { + const magic = client.receiveBuffer.readUInt32LE(0) + const version = client.receiveBuffer.readUInt16LE(4) + const messageType = client.receiveBuffer.readUInt16LE(6) + const payloadLength = client.receiveBuffer.readUInt32LE(8) + if ( + magic !== DAW_BRIDGE_FRAME_MAGIC + || version !== DAW_BRIDGE_PROTOCOL_VERSION + || payloadLength > DAW_BRIDGE_MAX_PAYLOAD_BYTES + ) { + client.socket.destroy() + return + } + + const frameLength = DAW_BRIDGE_FRAME_HEADER_BYTES + payloadLength + if (client.receiveBuffer.length < frameLength) return + const payload = client.receiveBuffer.subarray(DAW_BRIDGE_FRAME_HEADER_BYTES, frameLength) + client.receiveBuffer = client.receiveBuffer.subarray(frameLength) + if (!this.handleFrame(client, messageType, payload)) { + client.socket.destroy() + return + } + } + } + + private handleFrame(client: ClientState, messageType: number, payload: Buffer): boolean { + switch (messageType) { + case DAW_BRIDGE_MESSAGE.hello: + case DAW_BRIDGE_MESSAGE.sourceUpdate: + return this.handleHello(client, payload) + case DAW_BRIDGE_MESSAGE.heartbeat: + return true + case DAW_BRIDGE_MESSAGE.audio: + return this.handleAudio(client, payload) + default: + return false + } + } + + private handleHello(client: ClientState, payloadBuffer: Buffer): boolean { + let payload: unknown + try { + payload = JSON.parse(payloadBuffer.toString('utf8')) + } catch { + return false + } + if (!isHelloPayload(payload)) return false + + if (client.source) { + this.sources.delete(client.source.id) + } + + let liveId = payload.sourceId + const conflictingClient = [...this.sources.values()].find((candidate) => ( + candidate !== client && candidate.source?.persistentId === payload.sourceId + )) + if (conflictingClient?.source) { + const existingSource = conflictingClient.source + if (existingSource.id === payload.sourceId) { + this.sources.delete(existingSource.id) + const distinctExistingId = this.allocateLiveId( + payload.sourceId, + existingSource.instanceId, + conflictingClient, + ) + conflictingClient.source = { ...existingSource, id: distinctExistingId, connectionState: 'connected' } + this.sources.set(distinctExistingId, conflictingClient) + this.pendingAudioPackets = this.pendingAudioPackets.filter((packet) => packet.sourceId !== payload.sourceId) + } + if (this.selectedSourceId === payload.sourceId) { + this.selectedSourceId = null + } + this.sendSubscription(conflictingClient) + liveId = this.allocateLiveId(payload.sourceId, payload.instanceId, client) + } + + const descriptor: DawBridgeSourceDescriptor = { + id: liveId, + persistentId: payload.sourceId, + instanceId: payload.instanceId, + label: sourceLabel(payload), + customName: optionalString(payload.customName), + trackName: optionalString(payload.trackName), + hostName: optionalString(payload.hostName), + sampleRate: Math.max(1, finiteNumber(payload.sampleRate, 48000)), + channelCount: Math.max(1, Math.min(2, Math.floor(finiteNumber(payload.channelCount, 2)))), + droppedFrames: Math.max(0, Math.floor(finiteNumber(payload.droppedFrames, 0))), + connectionState: liveId === this.selectedSourceId ? 'selected' : 'connected', + } + client.source = descriptor + this.sources.set(descriptor.id, client) + this.sendSubscription(client) + this.emitSnapshot() + return true + } + + private handleAudio(client: ClientState, payload: Buffer): boolean { + const source = client.source + if (!source || source.id !== this.selectedSourceId || payload.length < DAW_BRIDGE_AUDIO_HEADER_BYTES) { + return Boolean(source) + } + + const sequence = payload.readUInt32LE(0) + const frameCount = payload.readUInt16LE(4) + const channelCount = payload.readUInt8(6) + const flags = payload.readUInt8(7) + const sampleRate = payload.readDoubleLE(8) + const expectedBytes = DAW_BRIDGE_AUDIO_HEADER_BYTES + frameCount * channelCount * Float32Array.BYTES_PER_ELEMENT + if ( + frameCount === 0 + || (channelCount !== 1 && channelCount !== 2) + || !Number.isFinite(sampleRate) + || sampleRate <= 0 + || payload.length !== expectedBytes + ) { + return false + } + + const transport: DawTransportSnapshot = { + sequence, + isPlaying: Boolean(flags & (1 << 0)), + isRecording: Boolean(flags & (1 << 1)), + isLooping: Boolean(flags & (1 << 2)), + } + if (flags & (1 << 3)) transport.timeInSamples = Number(payload.readBigInt64LE(16)) + if (flags & (1 << 4)) transport.timeInSeconds = payload.readDoubleLE(24) + if (flags & (1 << 5)) { + transport.ppqPosition = payload.readDoubleLE(32) + const lastBar = payload.readDoubleLE(48) + if (Number.isFinite(lastBar)) transport.ppqPositionOfLastBarStart = lastBar + } + if (flags & (1 << 6)) transport.bpm = payload.readDoubleLE(40) + if (flags & (1 << 7)) { + transport.timeSignature = { + numerator: payload.readInt16LE(72), + denominator: payload.readInt16LE(74), + } + } + const loopStart = payload.readDoubleLE(56) + const loopEnd = payload.readDoubleLE(64) + if (transport.isLooping && Number.isFinite(loopStart) && Number.isFinite(loopEnd)) { + transport.loopPoints = { ppqStart: loopStart, ppqEnd: loopEnd } + } + + const audioOffset = DAW_BRIDGE_AUDIO_HEADER_BYTES + const left = new Float32Array(frameCount) + const right = new Float32Array(frameCount) + for (let index = 0; index < frameCount; index += 1) { + left[index] = payload.readFloatLE(audioOffset + index * 4) + } + if (channelCount === 2) { + const rightOffset = audioOffset + frameCount * 4 + for (let index = 0; index < frameCount; index += 1) { + right[index] = payload.readFloatLE(rightOffset + index * 4) + } + } else { + right.set(left) + } + + this.pendingAudioPackets.push({ + sourceId: source.id, + sequence, + sampleRate, + channelCount, + left, + right, + transport, + }) + if (this.pendingAudioPackets.length > MAX_PENDING_PACKETS) { + this.pendingAudioPackets.splice(0, this.pendingAudioPackets.length - MAX_PENDING_PACKETS) + } + this.scheduleAudioBatch() + return true + } + + private scheduleAudioBatch(): void { + if (this.audioBatchTimer) return + this.audioBatchTimer = setTimeout(() => { + this.audioBatchTimer = null + const sourceId = this.selectedSourceId + if (!sourceId) { + this.pendingAudioPackets = [] + return + } + const packets = this.pendingAudioPackets.filter((packet) => packet.sourceId === sourceId) + this.pendingAudioPackets = [] + if (packets.length > 0) { + this.onAudioBatch?.({ sourceId, packets }) + } + }, this.audioBatchIntervalMs) + } + + private sendSubscription(client: ClientState): void { + if (!client.source || client.socket.destroyed) return + const payload: DawBridgeSubscribePayload = { + selected: client.source.id === this.selectedSourceId, + } + this.sendFrame(client.socket, DAW_BRIDGE_MESSAGE.subscribe, Buffer.from(JSON.stringify(payload), 'utf8')) + } + + private allocateLiveId(sourceId: string, instanceId: string, owner: ClientState): string { + const base = `${sourceId}:${instanceId.slice(0, 16)}` + let candidate = base + let suffix = 2 + while (this.sources.has(candidate) && this.sources.get(candidate) !== owner) { + candidate = `${base}:${suffix}` + suffix += 1 + } + return candidate + } + + private sendFrame(socket: Socket, messageType: number, payload: Buffer): void { + const header = Buffer.allocUnsafe(DAW_BRIDGE_FRAME_HEADER_BYTES) + header.writeUInt32LE(DAW_BRIDGE_FRAME_MAGIC, 0) + header.writeUInt16LE(DAW_BRIDGE_PROTOCOL_VERSION, 4) + header.writeUInt16LE(messageType, 6) + header.writeUInt32LE(payload.length, 8) + socket.write(Buffer.concat([header, payload])) + } + + private expireSilentClients(): void { + const cutoff = Date.now() - this.heartbeatTimeoutMs + for (const client of this.clients) { + if (client.lastHeartbeatAt < cutoff) { + client.socket.destroy() + } + } + } + + private removeClient(client: ClientState): void { + this.clients.delete(client) + if (client.source && this.sources.get(client.source.id) === client) { + this.sources.delete(client.source.id) + this.pendingAudioPackets = this.pendingAudioPackets.filter((packet) => packet.sourceId !== client.source!.id) + this.emitSnapshot() + } + } + + private emitSnapshot(): void { + this.onSnapshot?.(this.getSnapshot()) + } +} diff --git a/src/main/services/trayMenu.ts b/src/main/services/trayMenu.ts index 37445a5..1bc3388 100644 --- a/src/main/services/trayMenu.ts +++ b/src/main/services/trayMenu.ts @@ -68,6 +68,7 @@ export function normalizeTrayRendererState(value: unknown): TrayRendererState { const candidate = value as Partial const captureStatus = candidate.captureStatus === 'connecting' + || candidate.captureStatus === 'waiting' || candidate.captureStatus === 'capturing' || candidate.captureStatus === 'error' ? candidate.captureStatus @@ -81,18 +82,24 @@ export function normalizeTrayRendererState(value: unknown): TrayRendererState { activeSourceLabel: typeof candidate.activeSourceLabel === 'string' ? normalizeText(candidate.activeSourceLabel, 'Audio Source') : null, - captureMode: candidate.captureMode === 'device' ? 'device' : 'system', + captureMode: candidate.captureMode === 'device' || candidate.captureMode === 'daw' + ? candidate.captureMode + : 'system', selectedSystemSourceId: typeof candidate.selectedSystemSourceId === 'string' ? candidate.selectedSystemSourceId : null, selectedDeviceId: typeof candidate.selectedDeviceId === 'string' ? candidate.selectedDeviceId : null, + selectedDawSourceId: typeof candidate.selectedDawSourceId === 'string' + ? candidate.selectedDawSourceId + : null, rollingCaptureSeconds: isRollingCaptureDuration(candidate.rollingCaptureSeconds) ? candidate.rollingCaptureSeconds : null, systemSources: normalizeSources(candidate.systemSources), inputSources: normalizeSources(candidate.inputSources), + dawSources: normalizeSources(candidate.dawSources), } } @@ -102,6 +109,8 @@ export function buildTrayMenuModel(state: TrayMenuState): TrayMenuModel { ? sourceLabel ? `Capturing · ${sourceLabel}` : 'Capturing' : state.rendererState.captureStatus === 'connecting' ? 'Connecting to audio…' + : state.rendererState.captureStatus === 'waiting' + ? 'Waiting for DAW bridge…' : state.rendererState.captureStatus === 'error' ? 'Audio capture error' : 'Capture stopped' @@ -115,6 +124,7 @@ export function buildTrayMenuModel(state: TrayMenuState): TrayMenuModel { mainWindowActionLabel: state.mainWindowVisible ? 'Hide Prism' : 'Show Prism', captureActionLabel: state.rendererState.captureStatus === 'capturing' || state.rendererState.captureStatus === 'connecting' + || state.rendererState.captureStatus === 'waiting' ? 'Stop Capture' : 'Start Capture', captureActionEnabled: state.rendererReady, diff --git a/src/plugin-ui/PluginWebViewDataSource.ts b/src/plugin-ui/PluginWebViewDataSource.ts index 11f3eae..0d26995 100644 --- a/src/plugin-ui/PluginWebViewDataSource.ts +++ b/src/plugin-ui/PluginWebViewDataSource.ts @@ -20,6 +20,7 @@ export class PluginWebViewDataSource implements SpectrumAnalyzerDataSource { sampleRate: 48000, channelCount: 2, capturing: false, + suspended: false, backendKind: null, } @@ -88,6 +89,10 @@ export class PluginWebViewDataSource implements SpectrumAnalyzerDataSource { return this.sessionState.sampleRate } + getBackendKind(): null { + return null + } + isPlaying(): boolean { return this.sessionState.capturing } diff --git a/src/preload/captureSupport.ts b/src/preload/captureSupport.ts index 7c6d7e1..79fce13 100644 --- a/src/preload/captureSupport.ts +++ b/src/preload/captureSupport.ts @@ -66,5 +66,10 @@ export function getCaptureBackendSupport( available: true, reason: null, }, + dawBridge: { + kind: 'daw-bridge', + available: false, + reason: 'The DAW bridge listener is still starting.', + }, } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 23a89c6..1a062d7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -44,6 +44,10 @@ import type { import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp' import { resolveWindowCapabilities } from '../shared/windowCapabilities' import { getCaptureBackendSupport } from './captureSupport' +import type { + DawBridgeAudioBatch, + DawBridgeSnapshot, +} from '../types/dawBridge' type NativeAddonModule = VisualizerDSP & NativeCaptureAPI const windowCapabilities: WindowCapabilities = resolveWindowCapabilities({ @@ -104,7 +108,35 @@ contextBridge.exposeInMainWorld('electronAPI', { getWindowBackground: () => ipcRenderer.invoke('window:get-background') as Promise, setWindowBackground: (state: WindowBackgroundState) => ipcRenderer.invoke('window:set-background', state) as Promise, isCursorInsideWindow: () => ipcRenderer.invoke('window:is-cursor-inside') as Promise, - getCaptureBackendSupport: async () => getCaptureBackendSupport(process.platform, nativeCaptureAPI) as CaptureBackendSupport, + getCaptureBackendSupport: async () => { + const nativeSupport = getCaptureBackendSupport(process.platform, nativeCaptureAPI) + const bridgeSnapshot = await ipcRenderer.invoke('daw-bridge:get-snapshot') as DawBridgeSnapshot + return { + ...nativeSupport, + dawBridge: { + kind: 'daw-bridge', + available: bridgeSnapshot.available, + reason: bridgeSnapshot.reason, + }, + } as CaptureBackendSupport + }, + dawBridge: { + getSnapshot: () => ipcRenderer.invoke('daw-bridge:get-snapshot') as Promise, + selectSource: (sourceId: string | null) => ipcRenderer.invoke( + 'daw-bridge:select-source', + sourceId, + ) as Promise, + onSnapshot: (callback: (snapshot: DawBridgeSnapshot) => void) => { + const handler = (_event: Electron.IpcRendererEvent, snapshot: DawBridgeSnapshot): void => callback(snapshot) + ipcRenderer.on('daw-bridge:snapshot', handler) + return () => ipcRenderer.removeListener('daw-bridge:snapshot', handler) + }, + onAudioBatch: (callback: (batch: DawBridgeAudioBatch) => void) => { + const handler = (_event: Electron.IpcRendererEvent, batch: DawBridgeAudioBatch): void => callback(batch) + ipcRenderer.on('daw-bridge:audio-batch', handler) + return () => ipcRenderer.removeListener('daw-bridge:audio-batch', handler) + }, + }, audioClips: { startDrag: (payload: AudioClipDragPayload) => ipcRenderer.send('audio-clips:start-drag', payload), revealFolder: () => ipcRenderer.invoke('audio-clips:reveal-folder') as Promise, diff --git a/src/renderer/audio/AudioCapture.ts b/src/renderer/audio/AudioCapture.ts index 1ad5267..506b055 100644 --- a/src/renderer/audio/AudioCapture.ts +++ b/src/renderer/audio/AudioCapture.ts @@ -26,6 +26,11 @@ import type { NativeCaptureStartResult, NativeSystemCaptureAPI, } from '../../types/nativeCapture' +import type { + DawBridgeAudioBatch, + DawBridgeSnapshot, + DawTransportSnapshot, +} from '../../types/dawBridge' export type { CaptureMode } from '../../types/capture' @@ -35,6 +40,7 @@ interface CaptureChunk { channelCount: number capturedAt: number sequence: number + transport?: DawTransportSnapshot } interface CaptureBackendStatus { @@ -46,10 +52,12 @@ interface CaptureBackendStatus { channelCount: number activeSourceId: string | null activeSourceLabel: string | null + waiting?: boolean } interface CaptureBackendStartRequest { deviceId?: string + persistentSourceId?: string forceRestart?: boolean } @@ -140,6 +148,11 @@ export function createDefaultBackendSupport(platform = resolvePlatform()): Captu available: true, reason: null, }, + dawBridge: { + kind: 'daw-bridge', + available: false, + reason: 'The DAW bridge listener is still starting.', + }, } } @@ -152,6 +165,7 @@ export interface CaptureManagerStatus { isCapturing: boolean activeSourceId: string | null activeSourceLabel: string | null + waiting: boolean } type StatusListener = (status: CaptureManagerStatus) => void @@ -627,6 +641,158 @@ class NativeUnavailableCaptureBackend implements CaptureBackend { } } +class DawBridgeCaptureBackend implements CaptureBackend { + readonly kind = 'daw-bridge' as const + + private snapshot: DawBridgeSnapshot = { + available: false, + reason: 'The DAW bridge listener is not initialized.', + selectedSourceId: null, + sources: [], + } + private active = false + private selectedSourceId: string | null = null + private preferredPersistentId: string | null = null + private readonly chunkListeners = new Set<(chunk: CaptureChunk) => void>() + private readonly statusListeners = new Set<() => void>() + private readonly sourceListeners = new Set<() => void>() + + constructor() { + if (typeof window === 'undefined' || !window.electronAPI?.dawBridge) return + window.electronAPI.dawBridge.onSnapshot((snapshot) => { + this.snapshot = snapshot + void this.reconcileSelection() + for (const listener of this.sourceListeners) listener() + }) + window.electronAPI.dawBridge.onAudioBatch((batch) => this.handleAudioBatch(batch)) + void window.electronAPI.dawBridge.getSnapshot().then((snapshot) => { + this.snapshot = snapshot + this.emitStatus() + for (const listener of this.sourceListeners) listener() + }) + } + + async start(request?: CaptureBackendStartRequest): Promise { + this.active = true + this.preferredPersistentId = request?.persistentSourceId ?? request?.deviceId ?? this.preferredPersistentId + this.selectedSourceId = this.resolveRequestedSource(request?.deviceId ?? this.selectedSourceId) + if (typeof window === 'undefined' || !window.electronAPI?.dawBridge) { + throw new Error('The DAW bridge API is unavailable in this build.') + } + this.snapshot = await window.electronAPI.dawBridge.selectSource(this.selectedSourceId) + if (!this.snapshot.available) { + this.active = false + throw new Error(this.snapshot.reason ?? 'The DAW bridge listener is unavailable.') + } + this.emitStatus() + } + + async stop(): Promise { + this.active = false + if (typeof window !== 'undefined' && window.electronAPI?.dawBridge) { + this.snapshot = await window.electronAPI.dawBridge.selectSource(null) + } + this.emitStatus() + } + + async listSources(): Promise { + if (typeof window !== 'undefined' && window.electronAPI?.dawBridge) { + this.snapshot = await window.electronAPI.dawBridge.getSnapshot() + } + return this.snapshot.sources.map((source) => ({ + id: source.id, + persistentId: source.persistentId, + label: source.hostName ? `${source.label} — ${source.hostName}` : source.label, + kind: 'daw', + sampleRate: source.sampleRate, + channelCount: source.channelCount, + })) + } + + subscribe(listener: (chunk: CaptureChunk) => void): () => void { + this.chunkListeners.add(listener) + return () => this.chunkListeners.delete(listener) + } + + subscribeStatus(listener: () => void): () => void { + this.statusListeners.add(listener) + return () => this.statusListeners.delete(listener) + } + + subscribeSources(listener: () => void): () => void { + this.sourceListeners.add(listener) + return () => this.sourceListeners.delete(listener) + } + + setSelectedSourceId(persistentSourceId: string | null, liveSourceId: string | null = null): void { + this.preferredPersistentId = persistentSourceId + this.selectedSourceId = liveSourceId ?? this.resolveRequestedSource(persistentSourceId) + } + + getSelectedSourceId(): string | null { + return this.selectedSourceId + } + + getStatus(): CaptureBackendStatus { + const source = this.snapshot.sources.find((candidate) => candidate.id === this.selectedSourceId) ?? null + return { + kind: this.kind, + active: this.active, + available: this.snapshot.available, + reason: this.snapshot.reason, + sampleRate: source?.sampleRate ?? 48000, + channelCount: source?.channelCount ?? 2, + activeSourceId: this.active ? this.selectedSourceId : null, + activeSourceLabel: this.active ? source?.label ?? null : null, + waiting: this.active && !source, + } + } + + private handleAudioBatch(batch: DawBridgeAudioBatch): void { + if (!this.active || batch.sourceId !== this.selectedSourceId) return + for (const packet of batch.packets) { + const chunk: CaptureChunk = { + left: packet.left, + right: packet.right, + channelCount: packet.channelCount, + capturedAt: performance.now(), + sequence: packet.sequence, + transport: packet.transport, + } + for (const listener of this.chunkListeners) listener(chunk) + } + } + + private emitStatus(): void { + for (const listener of this.statusListeners) listener() + } + + private resolveRequestedSource(sourceId: string | null | undefined): string | null { + if (!sourceId) return null + const exact = this.snapshot.sources.find((source) => source.id === sourceId) + if (exact) return exact.id + const stableMatches = this.snapshot.sources.filter((source) => source.persistentId === sourceId) + return stableMatches.length === 1 ? stableMatches[0]!.id : null + } + + private async reconcileSelection(): Promise { + if (!this.active) { + this.emitStatus() + return + } + if (this.selectedSourceId && this.snapshot.sources.some((source) => source.id === this.selectedSourceId)) { + this.emitStatus() + return + } + + this.selectedSourceId = this.resolveRequestedSource(this.preferredPersistentId) + if (typeof window !== 'undefined' && window.electronAPI?.dawBridge) { + this.snapshot = await window.electronAPI.dawBridge.selectSource(this.selectedSourceId) + } + this.emitStatus() + } +} + interface CaptureStartOptions { forceDeviceRestart?: boolean } @@ -634,6 +800,7 @@ interface CaptureStartOptions { class AudioCapture { private readonly deviceInputRuntime = new DeviceInputCaptureRuntime() private readonly deviceInputBackend: CaptureBackend + private readonly dawBridgeBackend = new DawBridgeCaptureBackend() private backendSupport: CaptureBackendSupport | null = null private backendSupportPromise: Promise | null = null @@ -643,6 +810,8 @@ class AudioCapture { private selectedDeviceId: string | null = null private selectedSystemSourceId: string | null = DEFAULT_SYSTEM_SOURCE_ID + private selectedDawSourceId: string | null = null + private selectedDawConnectionId: string | null = null private captureMode: CaptureMode = 'system' private sessionId: number | null = null private inputGainDb = 0 @@ -655,6 +824,8 @@ class AudioCapture { constructor() { this.deviceInputBackend = new DeviceInputCaptureBackend(this.deviceInputRuntime) this.deviceInputBackend.subscribe((chunk) => this.handleChunk(this.deviceInputBackend.kind, chunk)) + this.dawBridgeBackend.subscribe((chunk) => this.handleChunk(this.dawBridgeBackend.kind, chunk)) + this.dawBridgeBackend.subscribeStatus(() => this.handleDawBridgeStatus()) this.nativeBackend = this.createNativeBackend(createDefaultBackendSupport().nativeBackend) this.bindNativeBackend(this.nativeBackend) @@ -701,6 +872,11 @@ class AudioCapture { await this.start(undefined, options) } + async startDawBridge(): Promise { + this.captureMode = 'daw' + await this.start() + } + async start(deviceId?: string, options: CaptureStartOptions = {}): Promise { if (deviceId) { this.selectedDeviceId = deviceId @@ -712,13 +888,18 @@ class AudioCapture { const requestedBackend = this.captureMode === 'device' ? this.deviceInputBackend - : this.nativeBackend + : this.captureMode === 'daw' + ? this.dawBridgeBackend + : this.nativeBackend const requestedDeviceId = this.captureMode === 'device' ? this.selectedDeviceId ?? undefined - : this.selectedSystemSourceId ?? DEFAULT_SYSTEM_SOURCE_ID + : this.captureMode === 'daw' + ? this.selectedDawConnectionId ?? this.selectedDawSourceId ?? undefined + : this.selectedSystemSourceId ?? DEFAULT_SYSTEM_SOURCE_ID await requestedBackend.start({ deviceId: requestedDeviceId, + persistentSourceId: this.captureMode === 'daw' ? this.selectedDawSourceId ?? undefined : undefined, forceRestart: this.captureMode === 'device' && options.forceDeviceRestart === true, }) this.activeBackend = requestedBackend @@ -734,6 +915,9 @@ class AudioCapture { backendStatus.channelCount, ) nativeVisualizerTransport.reset(audioRouter.getSessionState()) + if (requestedBackend.kind === 'daw-bridge') { + this.handleDawBridgeStatus() + } this.emitStatus() } @@ -747,6 +931,9 @@ class AudioCapture { if (mode === 'device') { return this.deviceInputBackend.listSources() } + if (mode === 'daw') { + return this.dawBridgeBackend.listSources() + } const sources = await this.nativeBackend.listSources() if (!sources.length) { @@ -780,6 +967,21 @@ class AudioCapture { this.emitStatus() } + getSelectedDawSourceId(): string | null { + return this.selectedDawSourceId + } + + setSelectedDawSourceId(id: string | null, liveConnectionId: string | null = null): void { + this.selectedDawSourceId = id + this.selectedDawConnectionId = liveConnectionId + this.dawBridgeBackend.setSelectedSourceId(id, liveConnectionId) + this.emitStatus() + } + + subscribeDawSources(listener: () => void): () => void { + return this.dawBridgeBackend.subscribeSources(listener) + } + getCaptureMode(): CaptureMode { return this.captureMode } @@ -805,6 +1007,7 @@ class AudioCapture { isCapturing, activeSourceId: isCapturing ? backendStatus?.activeSourceId ?? null : null, activeSourceLabel: isCapturing ? backendStatus?.activeSourceLabel ?? null : null, + waiting: Boolean(backendStatus?.waiting), } } @@ -918,15 +1121,30 @@ class AudioCapture { return } - if (originKind.startsWith('native-') && this.inputGainLinear !== 1) { + if ((originKind.startsWith('native-') || originKind === 'daw-bridge') && this.inputGainLinear !== 1) { applyInputGainToStereoSamples(chunk.left, chunk.right, this.inputGainLinear) } + if ( + originKind === 'daw-bridge' + && this.activeBackend + && ( + this.activeBackend.getStatus().sampleRate !== audioRouter.getSampleRate() + || chunk.channelCount !== audioRouter.getChannelCount() + ) + ) { + const status = this.activeBackend.getStatus() + this.sessionId = audioRouter.beginSession(status.sampleRate, chunk.channelCount, originKind) + this.beginRollingCaptureSession(status.sampleRate, chunk.channelCount) + nativeVisualizerTransport.reset(audioRouter.getSessionState()) + } + audioRouter.ingestChunk(chunk.left, chunk.right, { sessionId: this.sessionId, channelCount: chunk.channelCount, capturedAt: chunk.capturedAt, sequence: chunk.sequence, + transport: chunk.transport, }) let rollingAudioBuffer = this.rollingAudioBuffer @@ -955,6 +1173,32 @@ class AudioCapture { } } + private handleDawBridgeStatus(): void { + if (this.activeBackend?.kind !== 'daw-bridge' || this.sessionId === null) { + this.emitStatus() + return + } + + const status = this.activeBackend.getStatus() + if (status.waiting) { + audioRouter.suspendSession() + } else if ( + status.sampleRate !== audioRouter.getSampleRate() + || status.channelCount !== audioRouter.getChannelCount() + ) { + this.sessionId = audioRouter.beginSession( + status.sampleRate, + status.channelCount, + 'daw-bridge', + ) + this.beginRollingCaptureSession(status.sampleRate, status.channelCount) + nativeVisualizerTransport.reset(audioRouter.getSessionState()) + } else { + audioRouter.resumeSession() + } + this.emitStatus() + } + setInputGain(db: number): void { this.inputGainDb = db this.inputGainLinear = inputGainDbToLinear(this.inputGainDb) diff --git a/src/renderer/audio/AudioRouter.ts b/src/renderer/audio/AudioRouter.ts index 3ae0928..542ad10 100644 --- a/src/renderer/audio/AudioRouter.ts +++ b/src/renderer/audio/AudioRouter.ts @@ -6,6 +6,7 @@ import { AUDIO_SCOPE_KINDS, type AudioScopeKind } from '../../types/scope' import type { CaptureBackendKind } from '../../types/capture' +import type { DawTransportSnapshot } from '../../types/dawBridge' const MAX_PENDING_CHUNKS = 20 const MAX_PENDING_SPECTRUM_CHUNKS = 96 @@ -27,6 +28,7 @@ export interface AudioSessionState { sampleRate: number channelCount: number capturing: boolean + suspended: boolean backendKind: CaptureBackendKind | null } @@ -67,12 +69,14 @@ interface AudioChunkMeta { channelCount?: number capturedAt?: number sequence?: number + transport?: DawTransportSnapshot } interface MonoChunkRecord { samples: Float32Array capturedAt: number sequence: number + transport?: DawTransportSnapshot } interface StereoChunkRecord { @@ -80,6 +84,7 @@ interface StereoChunkRecord { right: Float32Array capturedAt: number sequence: number + transport?: DawTransportSnapshot } interface ScopeLatencyTracker { @@ -235,6 +240,7 @@ export class AudioRouter { private _sampleRate = 48000 private _capturing = false + private _suspended = false private _channelCount = 2 private _sessionId = 0 private _backendKind: CaptureBackendKind | null = null @@ -271,6 +277,7 @@ export class AudioRouter { sampleRate: this._sampleRate, channelCount: this._channelCount, capturing: this._capturing, + suspended: this._suspended, backendKind: this._backendKind, } } @@ -280,6 +287,7 @@ export class AudioRouter { this._sampleRate = sampleRate this._channelCount = Math.max(1, Math.floor(channelCount) || 1) this._capturing = true + this._suspended = false this._backendKind = backendKind this.reset() this.emitSessionState() @@ -289,11 +297,24 @@ export class AudioRouter { endSession(): void { this._sessionId += 1 this._capturing = false + this._suspended = false this._backendKind = null this.reset() this.emitSessionState() } + suspendSession(): void { + if (!this._capturing || this._suspended) return + this._suspended = true + this.emitSessionState() + } + + resumeSession(): void { + if (!this._capturing || !this._suspended) return + this._suspended = false + this.emitSessionState() + } + subscribeToSessionChanges(listener: (state: AudioSessionState) => void): () => void { this.sessionListeners.add(listener) listener(this.getSessionState()) @@ -344,7 +365,7 @@ export class AudioRouter { } ingestChunk(left: Float32Array, right: Float32Array, meta: AudioChunkMeta = {}): void { - if (!this._capturing) { + if (!this._capturing || this._suspended) { this.notCapturingDrops += 1 return } @@ -377,31 +398,31 @@ export class AudioRouter { const rightSamples = resolvedRight.length === len ? resolvedRight : resolvedRight.subarray(0, len) if (activeDemand.oscilloscope) { - this.rings.oscilloscope.push({ samples: leftSamples, capturedAt, sequence }) + this.rings.oscilloscope.push({ samples: leftSamples, capturedAt, sequence, transport: meta.transport }) } if (activeDemand.spectrum) { - this.rings.spectrum.push({ left: leftSamples, right: rightSamples, capturedAt, sequence }) + this.rings.spectrum.push({ left: leftSamples, right: rightSamples, capturedAt, sequence, transport: meta.transport }) } if (activeDemand.spectrogram) { - this.rings.spectrogram.push({ left: leftSamples, right: rightSamples, capturedAt, sequence }) + this.rings.spectrogram.push({ left: leftSamples, right: rightSamples, capturedAt, sequence, transport: meta.transport }) } if (activeDemand.vectorscope) { - this.rings.vectorscope.push({ left: leftSamples, right: rightSamples, capturedAt, sequence }) + this.rings.vectorscope.push({ left: leftSamples, right: rightSamples, capturedAt, sequence, transport: meta.transport }) } if (activeDemand.vumeter) { - this.rings.vumeter.push({ left: leftSamples, right: rightSamples, capturedAt, sequence }) + this.rings.vumeter.push({ left: leftSamples, right: rightSamples, capturedAt, sequence, transport: meta.transport }) } if (activeDemand.lufsmeter) { - this.rings.lufsmeter.push({ left: leftSamples, right: rightSamples, capturedAt, sequence }) + this.rings.lufsmeter.push({ left: leftSamples, right: rightSamples, capturedAt, sequence, transport: meta.transport }) } if (activeDemand.waveform) { - this.rings.waveform.push({ left: leftSamples, right: rightSamples, capturedAt, sequence }) + this.rings.waveform.push({ left: leftSamples, right: rightSamples, capturedAt, sequence, transport: meta.transport }) } } @@ -443,10 +464,10 @@ export class AudioRouter { }) } - flushPendingSpectrogramStereoSamples(): { left: Float32Array; right: Float32Array }[] { + flushPendingSpectrogramStereoSamples(): { left: Float32Array; right: Float32Array; transport?: DawTransportSnapshot }[] { const records = this.rings.spectrogram.drain() this.recordScopeDrain('spectrogram', records) - return records.map((record) => ({ left: record.left, right: record.right })) + return records.map((record) => ({ left: record.left, right: record.right, transport: record.transport })) } flushPendingVectorscopeSamples(): { left: Float32Array; right: Float32Array }[] { @@ -473,10 +494,16 @@ export class AudioRouter { return records.map((record) => record.left) } - flushPendingWaveformStereoSamples(): { left: Float32Array; right: Float32Array }[] { + flushPendingWaveformStereoSamples(): { left: Float32Array; right: Float32Array; transport?: DawTransportSnapshot }[] { const records = this.rings.waveform.drain() this.recordScopeDrain('waveform', records) - return records.map((record) => ({ left: record.left, right: record.right })) + return records.map((record) => ({ left: record.left, right: record.right, transport: record.transport })) + } + + flushPendingWaveformAnnotatedSamples(): { left: Float32Array; right: Float32Array; transport?: DawTransportSnapshot }[] { + const records = this.rings.waveform.drain() + this.recordScopeDrain('waveform', records) + return records.map((record) => ({ left: record.left, right: record.right, transport: record.transport })) } getDiagnosticsSnapshot(): AudioRouterDiagnostics { diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index 17e983a..b4ff414 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -173,11 +173,14 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const { systemSources, devices, + dawSources, selectedSystemSourceId, selectedDeviceId, + selectedDawSourceId, captureMode, isCapturing, captureStatus, + activeSourceId, captureError, captureNotice, inputGainDb, @@ -186,6 +189,7 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): clearCaptureNotice, selectSystemSource, selectDevice, + selectDawSource, startCapture, setInputGain, setRollingCaptureSeconds, @@ -238,6 +242,13 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const deviceId = value.slice('device:'.length) await selectDevice(deviceId === DEFAULT_INPUT_DEVICE_ID ? null : deviceId) await startCapture() + return + } + + if (value.startsWith('daw:')) { + const sourceId = value.slice('daw:'.length) + await selectDawSource(sourceId) + await startCapture() } } @@ -245,18 +256,32 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): ? systemSources : [{ id: '__default_system_output__', label: 'Default Output', kind: 'system', isDefault: true }] const defaultSystemSourceId = visibleSystemSources[0]?.id ?? '__default_system_output__' + const matchingDawSources = dawSources.filter((source) => ( + source.id === selectedDawSourceId || source.persistentId === selectedDawSourceId + )) + const selectedDawLiveSourceId = captureMode === 'daw' + && activeSourceId + && dawSources.some((source) => source.id === activeSourceId) + ? activeSourceId + : matchingDawSources.length === 1 + ? matchingDawSources[0]!.id + : null const selectedSourceValue = captureMode === 'system' ? `system:${selectedSystemSourceId ?? defaultSystemSourceId}` - : `device:${selectedDeviceId ?? DEFAULT_INPUT_DEVICE_ID}` + : captureMode === 'device' + ? `device:${selectedDeviceId ?? DEFAULT_INPUT_DEVICE_ID}` + : `daw:${selectedDawLiveSourceId ?? selectedDawSourceId ?? ''}` - const indicatorLabel = isCapturing - ? 'Capturing' + const indicatorLabel = captureStatus === 'waiting' + ? 'Waiting' : captureStatus === 'connecting' ? 'Connecting' : captureStatus === 'error' ? 'Capture Failed' - : 'Idle' + : isCapturing + ? 'Capturing' + : 'Idle' const trimPercent = Math.min(100, Math.max(0, ((inputGainDb + 12) / 24) * 100)) const roundedDockedRenderFps = Math.max(0, Math.round(dockedRenderFps)) @@ -421,7 +446,7 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): : nowPlayingSummary const canUseDefaultSource = captureMode === 'system' ? selectedSystemSourceId !== defaultSystemSourceId - : selectedDeviceId !== null + : captureMode === 'device' && selectedDeviceId !== null const loginItemStatusMessage = desktopIntegration.loginItemStatus === 'requires-approval' ? 'Approval required in system login settings' : desktopIntegration.loginItemStatus === 'blocked' @@ -718,6 +743,23 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): ))} + + {captureMode === 'daw' + && selectedDawSourceId + && !selectedDawLiveSourceId ? ( + + ) : null} + {dawSources.length === 0 && captureMode !== 'daw' ? ( + + ) : null} + {dawSources.map((source) => ( + + ))} +
diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index ab88e7c..4b48b91 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -264,6 +264,7 @@ export function scopeSettingsToOptions( showGrid: s.showGrid, orientation: 'horizontal', colorScheme: s.colorScheme, + timelineUnit: s.timelineUnit, } } case 'vumeter': { @@ -308,6 +309,7 @@ export function scopeSettingsToOptions( lineColor: t.line, gridMajorColor: t.guides, gridMinorColor: t.guidesSecondary, + labelColor: t.line, bandColors: { low: t.bandLow, mid: t.bandMid, @@ -316,6 +318,7 @@ export function scopeSettingsToOptions( mode: s.mode, scrollSpeed: s.scrollSpeed, multiband: s.multiband, + timelineUnit: s.timelineUnit, } } case 'nowPlaying': diff --git a/src/renderer/components/ScopePopoutBridge.tsx b/src/renderer/components/ScopePopoutBridge.tsx index 4773814..81f0f2e 100644 --- a/src/renderer/components/ScopePopoutBridge.tsx +++ b/src/renderer/components/ScopePopoutBridge.tsx @@ -45,9 +45,7 @@ function flushScopeAudioBatch(kind: AudioScopeKind, scopeSettings: ScopeSettings case 'lufsmeter': return audioRouter.flushPendingLUFSMeterSamples() case 'waveform': - return scopeSettings.waveform.mode === 'stereo' - ? audioRouter.flushPendingWaveformStereoSamples() - : audioRouter.flushPendingWaveformSamples() + return audioRouter.flushPendingWaveformAnnotatedSamples() } } @@ -57,6 +55,7 @@ function toPopoutSessionState(state: ScopePopoutSessionState): ScopePopoutSessio sampleRate: state.sampleRate, channelCount: state.channelCount, capturing: state.capturing, + suspended: state.suspended, backendKind: state.backendKind, } } diff --git a/src/renderer/components/ScopeSettingsSection.tsx b/src/renderer/components/ScopeSettingsSection.tsx index 52780ae..3129571 100644 --- a/src/renderer/components/ScopeSettingsSection.tsx +++ b/src/renderer/components/ScopeSettingsSection.tsx @@ -608,6 +608,16 @@ export default function ScopeSettingsSection({ + onUpdate('spectrogram', { timelineUnit: value as ScopeSettings['spectrogram']['timelineUnit'] })} + > + + + + + + onUpdate('waveform', { timelineUnit: value as ScopeSettings['waveform']['timelineUnit'] })} + > + + + + + { await useAudioStore.getState().startCapture() return } + if (command.type === 'select-daw-source') { + await audio.selectDawSource(command.sourceId) + await useAudioStore.getState().startCapture() + return + } if (command.type === 'set-rolling-capture') { audio.setRollingCaptureSeconds(command.durationSeconds) return @@ -66,12 +71,15 @@ export default function TrayControlBridge({ ready }: TrayControlBridgeProps): JS const hasUnsavedProfileChanges = useSettingsStore((state) => state.hasUnsavedProfileChanges) const captureStatus = useAudioStore((state) => state.captureStatus) const activeSourceLabel = useAudioStore((state) => state.activeSourceLabel) + const activeSourceId = useAudioStore((state) => state.activeSourceId) const captureMode = useAudioStore((state) => state.captureMode) const selectedSystemSourceId = useAudioStore((state) => state.selectedSystemSourceId) const selectedDeviceId = useAudioStore((state) => state.selectedDeviceId) + const selectedDawSourceId = useAudioStore((state) => state.selectedDawSourceId) const rollingCaptureSeconds = useAudioStore((state) => state.rollingCaptureSeconds) const systemSources = useAudioStore((state) => state.systemSources) const devices = useAudioStore((state) => state.devices) + const dawSources = useAudioStore((state) => state.dawSources) useEffect(() => { const unsubscribe = window.electronAPI.trayControls.onCommand((command) => { @@ -99,6 +107,16 @@ export default function TrayControlBridge({ ready }: TrayControlBridgeProps): JS const visibleSystemSources = systemSources.length > 0 ? systemSources : [{ id: DEFAULT_SYSTEM_SOURCE_ID, label: 'Default Output', isDefault: true }] + const matchingDawSources = dawSources.filter((source) => ( + source.id === selectedDawSourceId || source.persistentId === selectedDawSourceId + )) + const selectedDawLiveSourceId = captureMode === 'daw' + && activeSourceId + && dawSources.some((source) => source.id === activeSourceId) + ? activeSourceId + : matchingDawSources.length === 1 + ? matchingDawSources[0]!.id + : null const state: TrayRendererState = { profiles: Object.entries(profiles).map(([id, profile]) => ({ id, name: profile.name })), activeProfileId, @@ -108,6 +126,7 @@ export default function TrayControlBridge({ ready }: TrayControlBridgeProps): JS captureMode, selectedSystemSourceId, selectedDeviceId, + selectedDawSourceId: selectedDawLiveSourceId, rollingCaptureSeconds, systemSources: visibleSystemSources.map((source) => ({ id: source.id, @@ -123,10 +142,15 @@ export default function TrayControlBridge({ ready }: TrayControlBridgeProps): JS label: device.label || `Input ${device.deviceId.slice(0, 8)}`, })), ], + dawSources: dawSources.map((source) => ({ + id: source.id, + label: source.label, + })), } window.electronAPI.trayControls.publishState(state) }, [ activeProfileId, + activeSourceId, activeSourceLabel, captureMode, captureStatus, @@ -135,8 +159,10 @@ export default function TrayControlBridge({ ready }: TrayControlBridgeProps): JS profiles, rollingCaptureSeconds, selectedDeviceId, + selectedDawSourceId, selectedSystemSourceId, systemSources, + dawSources, ]) return null diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index e0376ad..780e714 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -44,6 +44,7 @@ import type { TrayRendererCommand, TrayRendererState, } from '../types/desktopIntegration' +import type { DawBridgeRendererAPI } from '../types/dawBridge' declare global { interface Window { @@ -81,6 +82,7 @@ declare global { setWindowBackground: (state: WindowBackgroundState) => Promise isCursorInsideWindow: () => Promise getCaptureBackendSupport: () => Promise + dawBridge: DawBridgeRendererAPI audioClips: { startDrag: (payload: AudioClipDragPayload) => void revealFolder: () => Promise diff --git a/src/renderer/popouts/ScopePopoutDataSource.ts b/src/renderer/popouts/ScopePopoutDataSource.ts index 89a506f..3598157 100644 --- a/src/renderer/popouts/ScopePopoutDataSource.ts +++ b/src/renderer/popouts/ScopePopoutDataSource.ts @@ -27,6 +27,7 @@ const INITIAL_SESSION_STATE: ScopePopoutSessionState = { sampleRate: 48000, channelCount: 2, capturing: false, + suspended: false, backendKind: null, } @@ -80,7 +81,7 @@ export class ScopePopoutDataSource implements AnyScopeDataSource { setSessionState(nextState: ScopePopoutSessionState): void { this.sessionState = nextState this.nativeVisualizerTransport.reset(nextState) - if (!nextState.capturing) { + if (!nextState.capturing || nextState.suspended) { this.monoQueue = [] this.stereoQueue = [] } @@ -95,7 +96,11 @@ export class ScopePopoutDataSource implements AnyScopeDataSource { } isPlaying(): boolean { - return this.sessionState.capturing + return this.sessionState.capturing && !this.sessionState.suspended + } + + getBackendKind(): ScopePopoutSessionState['backendKind'] { + return this.sessionState.backendKind } subscribeToSessionChanges(listener: (state: ScopePopoutSessionState) => void): () => void { @@ -149,6 +154,10 @@ export class ScopePopoutDataSource implements AnyScopeDataSource { return this.scopeKind === 'waveform' ? batch : [] } + getPendingWaveformAnnotatedSamples(): ScopePopoutStereoBatch { + return this.getPendingWaveformStereoSamples() + } + getPendingVectorscopeSamples(): ScopePopoutStereoBatch { const batch = this.stereoQueue this.stereoQueue = [] diff --git a/src/renderer/stores/audioStore.ts b/src/renderer/stores/audioStore.ts index a8ff56e..2540d43 100644 --- a/src/renderer/stores/audioStore.ts +++ b/src/renderer/stores/audioStore.ts @@ -10,6 +10,7 @@ import type { CaptureBackendSupport, CaptureMode, CaptureSourceDescriptor, + CaptureStatus, } from '../../types/capture' import { useUiStore } from './uiStore' @@ -25,6 +26,7 @@ export interface PersistedAudioState { captureMode: CaptureMode selectedSystemSourceId: string selectedDeviceId: string | null + selectedDawSourceId: string | null rollingCaptureSeconds: RollingCaptureDurationSeconds | null } @@ -40,13 +42,15 @@ interface StartCaptureOptions { interface AudioState { systemSources: CaptureSourceDescriptor[] devices: MediaDeviceInfo[] + dawSources: CaptureSourceDescriptor[] selectedSystemSourceId: string | null selectedDeviceId: string | null + selectedDawSourceId: string | null captureMode: CaptureMode activeBackendKind: CaptureBackendKind | null backendSupport: CaptureBackendSupport | null isCapturing: boolean - captureStatus: 'idle' | 'connecting' | 'capturing' | 'error' + captureStatus: CaptureStatus captureError: string | null captureNotice: string | null sampleRate: number @@ -63,9 +67,11 @@ interface AudioState { clearCaptureNotice: () => void refreshSystemSources: (options?: RefreshSourceOptions) => Promise refreshDevices: (options?: RefreshSourceOptions) => Promise + refreshDawSources: () => Promise refreshBackendSupport: (options?: RefreshSourceOptions) => Promise selectSystemSource: (sourceId: string | null) => Promise selectDevice: (deviceId: string | null) => Promise + selectDawSource: (sourceId: string) => Promise setCaptureMode: (mode: CaptureMode) => void startCapture: (options?: StartCaptureOptions) => Promise stopCapture: () => void @@ -129,6 +135,7 @@ function areCaptureSourcesEqual( return left.every((source, index) => { const candidate = right[index] return source.id === candidate.id + && source.persistentId === candidate.persistentId && source.label === candidate.label && source.kind === candidate.kind && source.isDefault === candidate.isDefault @@ -189,7 +196,7 @@ export function normalizeInputGainDb(raw: unknown): number { } function normalizeCaptureMode(raw: unknown): CaptureMode { - return raw === 'device' ? 'device' : 'system' + return raw === 'device' || raw === 'daw' ? raw : 'system' } function normalizeSystemSourceId(raw: unknown): string { @@ -220,6 +227,7 @@ export function normalizeAudioPreferences(raw: unknown): PersistedAudioState { captureMode: normalizeCaptureMode(parsed.captureMode), selectedSystemSourceId: normalizeSystemSourceId(parsed.selectedSystemSourceId), selectedDeviceId: normalizeDeviceId(parsed.selectedDeviceId), + selectedDawSourceId: normalizeDeviceId(parsed.selectedDawSourceId), rollingCaptureSeconds: normalizeRollingCaptureSeconds(parsed.rollingCaptureSeconds), } } @@ -230,12 +238,14 @@ function buildAudioPreferences( selectedSystemSourceId: string | null, selectedDeviceId: string | null, rollingCaptureSeconds: RollingCaptureDurationSeconds | null, + selectedDawSourceId: string | null, ): PersistedAudioState { return normalizeAudioPreferences({ inputGainDb, captureMode, selectedSystemSourceId, selectedDeviceId, + selectedDawSourceId, rollingCaptureSeconds, }) } @@ -271,14 +281,17 @@ const storedPreferences = loadAudioPreferences() audioCapture.setInputGain(storedPreferences.inputGainDb) audioCapture.setSelectedSystemSourceId(storedPreferences.selectedSystemSourceId) audioCapture.setSelectedDeviceId(storedPreferences.selectedDeviceId) +audioCapture.setSelectedDawSourceId(storedPreferences.selectedDawSourceId) audioCapture.setCaptureMode(storedPreferences.captureMode) audioCapture.setRollingCaptureSeconds(storedPreferences.rollingCaptureSeconds) export const useAudioStore = create((set, get) => ({ systemSources: [], devices: [], + dawSources: [], selectedSystemSourceId: audioCapture.getSelectedSystemSourceId(), selectedDeviceId: audioCapture.getSelectedDeviceId(), + selectedDawSourceId: audioCapture.getSelectedDawSourceId(), captureMode: audioCapture.getCaptureMode(), activeBackendKind: null, backendSupport: null, @@ -307,6 +320,7 @@ export const useAudioStore = create((set, get) => ({ currentState.selectedSystemSourceId, currentState.selectedDeviceId, currentState.rollingCaptureSeconds, + currentState.selectedDawSourceId, )) audioCapture.setInputGain(nextInputGainDb) set({ inputGainDb: nextInputGainDb }) @@ -323,6 +337,7 @@ export const useAudioStore = create((set, get) => ({ currentState.selectedSystemSourceId, currentState.selectedDeviceId, nextDuration, + currentState.selectedDawSourceId, )) audioCapture.setRollingCaptureSeconds(nextDuration) set({ @@ -389,6 +404,7 @@ export const useAudioStore = create((set, get) => ({ nextSelectedSystemSourceId, currentState.selectedDeviceId, currentState.rollingCaptureSeconds, + currentState.selectedDawSourceId, )) } @@ -458,6 +474,7 @@ export const useAudioStore = create((set, get) => ({ currentState.selectedSystemSourceId, nextSelectedDeviceId, currentState.rollingCaptureSeconds, + currentState.selectedDawSourceId, )) } @@ -503,6 +520,12 @@ export const useAudioStore = create((set, get) => ({ const backendSupport = await audioCapture.refreshBackendSupport() set({ backendSupport }) await get().refreshSystemSources(options) + await get().refreshDawSources() + }, + + refreshDawSources: async () => { + const dawSources = await audioCapture.listSources('daw') + set({ dawSources }) }, selectSystemSource: async (sourceId: string | null) => { @@ -516,6 +539,7 @@ export const useAudioStore = create((set, get) => ({ nextSelectedSystemSourceId, currentState.selectedDeviceId, currentState.rollingCaptureSeconds, + currentState.selectedDawSourceId, )) set({ selectedSystemSourceId: nextSelectedSystemSourceId, @@ -535,6 +559,7 @@ export const useAudioStore = create((set, get) => ({ currentState.selectedSystemSourceId, deviceId, currentState.rollingCaptureSeconds, + currentState.selectedDawSourceId, )) set({ selectedDeviceId: deviceId, @@ -544,6 +569,28 @@ export const useAudioStore = create((set, get) => ({ }) }, + selectDawSource: async (sourceId: string) => { + const currentState = get() + const source = currentState.dawSources.find((candidate) => candidate.id === sourceId) + const persistentSourceId = source?.persistentId ?? sourceId + audioCapture.setSelectedDawSourceId(persistentSourceId, sourceId) + audioCapture.setCaptureMode('daw') + persistAudioPreferences(buildAudioPreferences( + currentState.inputGainDb, + 'daw', + currentState.selectedSystemSourceId, + currentState.selectedDeviceId, + currentState.rollingCaptureSeconds, + persistentSourceId, + )) + set({ + selectedDawSourceId: persistentSourceId, + captureMode: 'daw', + captureError: null, + captureNotice: null, + }) + }, + setCaptureMode: (mode: CaptureMode) => { audioCapture.setCaptureMode(mode) const currentState = get() @@ -553,6 +600,7 @@ export const useAudioStore = create((set, get) => ({ currentState.selectedSystemSourceId, currentState.selectedDeviceId, currentState.rollingCaptureSeconds, + currentState.selectedDawSourceId, )) set({ captureMode: mode }) }, @@ -565,9 +613,10 @@ export const useAudioStore = create((set, get) => ({ if (options.skipSourceRefresh !== true) { await get().refreshBackendSupport({ rebindActiveCapture: false }) await get().refreshDevices({ rebindActiveCapture: false }) + await get().refreshDawSources() } - const { selectedDeviceId, selectedSystemSourceId, backendSupport } = get() + const { selectedDeviceId, selectedSystemSourceId, selectedDawSourceId, backendSupport } = get() const startDefaultInputFallback = async (reason: string | null): Promise => { const message = buildSystemCaptureFallbackMessage(reason) @@ -580,6 +629,7 @@ export const useAudioStore = create((set, get) => ({ currentState.selectedSystemSourceId, null, currentState.rollingCaptureSeconds, + currentState.selectedDawSourceId, )) set({ selectedDeviceId: null, @@ -601,17 +651,22 @@ export const useAudioStore = create((set, get) => ({ await startDefaultInputFallback(reason) } } - } else { + } else if (captureMode === 'device') { await audioCapture.startDevice(selectedDeviceId ?? undefined, { forceDeviceRestart: options.forceDeviceRestart === true, }) + } else { + if (!selectedDawSourceId) { + throw new Error('Select a connected Prism Bridge source first.') + } + await audioCapture.startDawBridge() } const status = audioCapture.getStatus() set((state) => ({ ...state, ...applyCaptureStatus(status), - captureStatus: 'capturing', + captureStatus: status.waiting ? 'waiting' : 'capturing', captureError: null, })) } catch (err) { @@ -748,9 +803,22 @@ audioCapture.subscribeStatus((status) => { useAudioStore.setState((state) => ({ ...state, ...applyCaptureStatus(status), + captureStatus: status.waiting + ? 'waiting' + : status.isCapturing + ? 'capturing' + : state.captureStatus === 'error' + ? 'error' + : 'idle', })) }) +audioCapture.subscribeDawSources(() => { + void useAudioStore.getState().refreshDawSources().catch((error) => { + console.error('Failed to refresh DAW bridge sources:', error) + }) +}) + audioCapture.subscribeRollingCaptureStatus((rollingCaptureStatus) => { useAudioStore.setState({ rollingCaptureStatus }) }) diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index edf5174..b3ed21f 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -1881,6 +1881,15 @@ button.toolbar__version:hover { border-color: var(--accent); } +.settings-status-pill.is-waiting .settings-status-pill__dot { + background: #f59e0b; + box-shadow: 0 0 8px rgba(245, 158, 11, 0.72); +} + +.settings-status-pill.is-waiting { + color: #fbbf24; +} + .settings-status-pill.is-capturing .settings-status-pill__dot { background: var(--success); box-shadow: 0 0 8px var(--success); diff --git a/src/renderer/visualizers/Spectrogram.ts b/src/renderer/visualizers/Spectrogram.ts index 19c8bac..72e0932 100644 --- a/src/renderer/visualizers/Spectrogram.ts +++ b/src/renderer/visualizers/Spectrogram.ts @@ -42,10 +42,12 @@ import { buildFrequencyGuides, clampFrequencyRangeToNyquist, } from '../../types/frequencyScale' +import type { DawTransportSnapshot, TimelineUnit } from '../../types/dawBridge' +import { ScrollingTimeline } from './scrollingTimeline' export interface SpectrogramDataSource extends VisualizerSessionSource { getPendingSpectrogramSamples: () => Float32Array[] - getPendingSpectrogramStereoSamples?: () => Array<{ left: Float32Array; right: Float32Array }> + getPendingSpectrogramStereoSamples?: () => Array<{ left: Float32Array; right: Float32Array; transport?: DawTransportSnapshot }> } export interface SpectrogramOptions { @@ -67,6 +69,7 @@ export interface SpectrogramOptions { showGrid?: boolean gridColor?: string labelColor?: string + timelineUnit?: TimelineUnit dataSource?: SpectrogramDataSource frameScheduler?: FrameScheduler nativeAnalyzer?: SpectrogramNativeAnalyzer | null @@ -93,6 +96,7 @@ const defaultOptions: ResolvedSpectrogramOptions = { showGrid: true, gridColor: 'rgba(255, 255, 255, 0.12)', labelColor: 'rgba(255, 255, 255, 0.4)', + timelineUnit: 'bars-beats', } const defaultSpectrogramDataSource: SpectrogramDataSource = { @@ -139,6 +143,7 @@ function resolveOptions(base: ResolvedSpectrogramOptions, overrides: Partial void) | null = null + private readonly timeline = new ScrollingTimeline() constructor(canvas: HTMLCanvasElement, options: SpectrogramOptions = {}) { this.canvas = canvas @@ -303,6 +309,7 @@ export class Spectrogram { this.nativeAnalyzer?.reset() this.lastNativeConfigKey = null this.waterfallCtx.clearRect(0, 0, this.waterfallCanvas.width, this.waterfallCanvas.height) + this.timeline.reset() this.invalidate() } @@ -503,7 +510,7 @@ export class Spectrogram { private tryDrawNativeColumns( pendingSamples: Float32Array[], - pendingStereoSamples: Array<{ left: Float32Array; right: Float32Array }>, + pendingStereoSamples: Array<{ left: Float32Array; right: Float32Array; transport?: DawTransportSnapshot }>, width: number, height: number, ): boolean { @@ -518,7 +525,11 @@ export class Spectrogram { this.ensureColumnBuffers(config.rowCount) - const results: SpectrogramNativeResult[] = [] + const results: Array<{ + result: SpectrogramNativeResult + frameCount: number + transport?: DawTransportSnapshot + }> = [] try { if (!this.configureNativeAnalyzer(config)) { return false @@ -529,9 +540,11 @@ export class Spectrogram { if (!this.isValidNativeResult(result, config.rowCount)) { return false } - if (result.columnCount > 0) { - results.push(result) - } + results.push({ + result, + frameCount: Math.min(chunk.left.length, chunk.right.length), + transport: chunk.transport, + }) } for (const chunk of pendingSamples) { @@ -539,9 +552,7 @@ export class Spectrogram { if (!this.isValidNativeResult(result, config.rowCount)) { return false } - if (result.columnCount > 0) { - results.push(result) - } + results.push({ result, frameCount: chunk.length }) } } catch (error) { console.warn('Spectrogram: native analyzer failed', error) @@ -550,8 +561,14 @@ export class Spectrogram { return false } - for (const result of results) { + for (const entry of results) { + const { result } = entry this.shiftAndPaintColumns(result.display, result.heat, result.columnCount, result.rowCount) + this.timeline.append({ + frameCount: entry.frameCount, + sampleRate: Math.max(1, this.dataSource.getSampleRate()), + transport: entry.transport, + }, result.columnCount, this.options.orientation === 'vertical' ? height : width) } return true @@ -593,6 +610,16 @@ export class Spectrogram { } this.ctx.drawImage(this.waterfallCanvas, 0, 0) this.drawFrequencyGrid(width, height) + this.timeline.draw( + this.ctx, + width, + height, + this.options.timelineUnit, + this.dataSource.getBackendKind?.() ?? null, + this.options.gridColor, + this.options.labelColor, + this.options.orientation, + ) } private drawFrequencyGrid(width: number, height: number): void { @@ -667,6 +694,7 @@ export class Spectrogram { this.waterfallCanvas.width = width this.waterfallCanvas.height = height this.waterfallCtx.imageSmoothingEnabled = false + this.timeline.resize(this.options.orientation === 'vertical' ? height : width) if (previousCtx && previousCanvas.width > 0 && previousCanvas.height > 0) { if (this.options.orientation === 'vertical') { diff --git a/src/renderer/visualizers/Waveform.ts b/src/renderer/visualizers/Waveform.ts index 3b38f3f..0254283 100644 --- a/src/renderer/visualizers/Waveform.ts +++ b/src/renderer/visualizers/Waveform.ts @@ -20,15 +20,19 @@ import { } from '../scopeMeasurement' import type { LinkedAnalysisProbe, LinkedAnalysisProjection } from '../../types/analysis' import type { NormalizedScopePoint } from '../scopeCanvasTransform' +import type { DawTransportSnapshot, TimelineUnit } from '../../types/dawBridge' +import { ScrollingTimeline } from './scrollingTimeline' export interface WaveformStereoChunk { left: Float32Array right: Float32Array + transport?: DawTransportSnapshot } export interface WaveformDataSource extends VisualizerSessionSource { getPendingWaveformSamples: () => Float32Array[] getPendingWaveformStereoSamples: () => WaveformStereoChunk[] + getPendingWaveformAnnotatedSamples?: () => WaveformStereoChunk[] } export interface WaveformOptions { @@ -36,6 +40,7 @@ export interface WaveformOptions { lineColor?: string gridMajorColor?: string gridMinorColor?: string + labelColor?: string bandColors?: { low: string mid: string @@ -44,6 +49,7 @@ export interface WaveformOptions { mode?: WaveformMode scrollSpeed?: number multiband?: boolean + timelineUnit?: TimelineUnit dataSource?: WaveformDataSource frameScheduler?: FrameScheduler nativeAnalyzer?: WaveformNativeAnalyzer | null @@ -56,6 +62,7 @@ const defaultOptions: ResolvedWaveformOptions = { lineColor: '#38bdf8', gridMajorColor: 'rgba(255, 255, 255, 0.08)', gridMinorColor: 'rgba(255, 255, 255, 0.04)', + labelColor: 'rgba(255, 255, 255, 0.4)', bandColors: { low: '#ff4444', mid: '#44dd44', @@ -64,6 +71,7 @@ const defaultOptions: ResolvedWaveformOptions = { mode: DEFAULT_WAVEFORM_MODE, scrollSpeed: DEFAULT_WAVEFORM_SCROLL_SPEED, multiband: false, + timelineUnit: 'bars-beats', } const MULTIBAND_WEIGHT_EMPHASIS = 2.6 @@ -74,6 +82,7 @@ const MULTIBAND_EDGE_ALPHA = 1.0 const defaultWaveformDataSource: WaveformDataSource = { getPendingWaveformSamples: () => audioRouter.flushPendingWaveformSamples(), getPendingWaveformStereoSamples: () => audioRouter.flushPendingWaveformStereoSamples(), + getPendingWaveformAnnotatedSamples: () => audioRouter.flushPendingWaveformAnnotatedSamples(), ...defaultVisualizerSessionSource, } @@ -106,6 +115,8 @@ export class Waveform { private rightBandHighAcc: Float32Array = new Float32Array(0) private multibandScratch: MultibandChunk = createMultibandChunk(0) private unsubscribeSessionChange: (() => void) | null = null + private readonly timeline = new ScrollingTimeline() + private timelineColumnRemainder = 0 constructor(canvas: HTMLCanvasElement, options: WaveformOptions = {}) { this.canvas = canvas @@ -159,6 +170,8 @@ export class Waveform { private resetDisplay(): void { this.waterfallCtx.clearRect(0, 0, this.waterfallCanvas.width, this.waterfallCanvas.height) this.columnAccumulatorPos = 0 + this.timelineColumnRemainder = 0 + this.timeline.reset() this.splitter.reset() this.nativeAnalyzer?.reset() this.configureNativeAnalyzer() @@ -202,6 +215,7 @@ export class Waveform { lineColor: optionUpdates.lineColor ?? this.options.lineColor, scrollSpeed: clampWaveformScrollSpeed(optionUpdates.scrollSpeed ?? this.options.scrollSpeed), multiband: optionUpdates.multiband ?? this.options.multiband, + timelineUnit: optionUpdates.timelineUnit ?? this.options.timelineUnit, } const speedChanged = nextOptions.scrollSpeed !== this.options.scrollSpeed const multibandChanged = nextOptions.multiband !== this.options.multiband @@ -509,6 +523,10 @@ export class Waveform { } private drainPendingSamples(): void { + if (this.dataSource.getPendingWaveformAnnotatedSamples) { + this.dataSource.getPendingWaveformAnnotatedSamples() + return + } if (this.options.mode === 'stereo') { this.dataSource.getPendingWaveformStereoSamples() return @@ -517,6 +535,30 @@ export class Waveform { this.dataSource.getPendingWaveformSamples() } + private recordTimeline(chunk: WaveformStereoChunk, width: number): void { + const frameCount = Math.min(chunk.left.length, chunk.right.length) + const total = this.timelineColumnRemainder + frameCount + const columnCount = Math.floor(total / Math.max(1, this.samplesPerColumn)) + this.timelineColumnRemainder = total % Math.max(1, this.samplesPerColumn) + this.timeline.append({ + frameCount, + sampleRate: Math.max(1, this.dataSource.getSampleRate()), + transport: chunk.transport, + }, columnCount, width) + } + + private drawTimeline(width: number, height: number): void { + this.timeline.draw( + this.ctx, + width, + height, + this.options.timelineUnit, + this.dataSource.getBackendKind?.() ?? null, + this.options.gridMajorColor, + this.options.labelColor, + ) + } + private processMonoChunk(chunk: Float32Array, width: number, height: number): void { if (this.useNativeAnalyzer()) { if (this.processNativeMonoChunk(chunk, width, height)) { @@ -712,6 +754,7 @@ export class Waveform { this.recomputeSamplesPerColumn() this.staticLayerKey = '' + this.timeline.resize(width) } const sampleRate = this.dataSource.getSampleRate() @@ -723,12 +766,24 @@ export class Waveform { this.drainPendingSamples() this.renderStaticLayer(width, height) this.ctx.drawImage(this.waterfallCanvas, 0, 0) + this.drawTimeline(width, height) return } - if (this.options.mode === 'stereo') { + const annotated = this.dataSource.getPendingWaveformAnnotatedSamples?.() + if (annotated) { + for (const chunk of annotated) { + this.recordTimeline(chunk, width) + if (this.options.mode === 'stereo') { + this.processStereoChunk(chunk, width, height) + } else { + this.processMonoChunk(chunk.left, width, height) + } + } + } else if (this.options.mode === 'stereo') { const pending = this.dataSource.getPendingWaveformStereoSamples() for (const chunk of pending) { + this.recordTimeline(chunk, width) this.processStereoChunk(chunk, width, height) } } else { @@ -740,6 +795,7 @@ export class Waveform { this.renderStaticLayer(width, height) this.ctx.drawImage(this.waterfallCanvas, 0, 0) + this.drawTimeline(width, height) } dispose(): void { diff --git a/src/renderer/visualizers/dataSource.ts b/src/renderer/visualizers/dataSource.ts index 8dfb308..b7fbcc5 100644 --- a/src/renderer/visualizers/dataSource.ts +++ b/src/renderer/visualizers/dataSource.ts @@ -1,14 +1,17 @@ import { audioRouter } from '../audio/AudioRouter' import type { ScopePopoutSessionState } from '../../types/popout' +import type { CaptureBackendKind } from '../../types/capture' export interface VisualizerSessionSource { getSampleRate: () => number isPlaying: () => boolean + getBackendKind?: () => CaptureBackendKind | null subscribeToSessionChanges: (listener: (state: ScopePopoutSessionState) => void) => () => void } export const defaultVisualizerSessionSource: VisualizerSessionSource = { getSampleRate: () => audioRouter.getSampleRate(), isPlaying: () => audioRouter.isCapturing(), + getBackendKind: () => audioRouter.getSessionState().backendKind, subscribeToSessionChanges: (listener) => audioRouter.subscribeToSessionChanges((state) => listener(state)), } diff --git a/src/renderer/visualizers/scrollingTimeline.ts b/src/renderer/visualizers/scrollingTimeline.ts new file mode 100644 index 0000000..a6deb0e --- /dev/null +++ b/src/renderer/visualizers/scrollingTimeline.ts @@ -0,0 +1,298 @@ +import type { CaptureBackendKind } from '../../types/capture' +import type { DawTransportSnapshot, TimelineUnit } from '../../types/dawBridge' + +export type TimelineSeamKind = 'Loop' | 'Jump' | 'Gap' + +interface TimelinePoint { + seconds?: number + ppq?: number + bpm?: number + numerator: number + denominator: number + lastBarPpq?: number + seam?: TimelineSeamKind +} + +export interface TimelineChunkAnchor { + frameCount: number + sampleRate: number + transport?: DawTransportSnapshot +} + +export interface PreviousTimelineAnchor { + sequence: number + frameCount: number + timeInSamples?: number + isPlaying: boolean + isLooping: boolean +} + +const SECOND_STEPS = [0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300] + +function formatSeconds(value: number): string { + const safe = Math.max(0, value) + const minutes = Math.floor(safe / 60) + const seconds = safe - minutes * 60 + if (minutes > 0) return `${minutes}:${seconds.toFixed(seconds < 10 ? 1 : 0).padStart(4, '0')}` + return `${seconds.toFixed(safe < 10 ? 1 : 0)}s` +} + +export function resolveTimelineSeam( + previous: PreviousTimelineAnchor | null, + current: DawTransportSnapshot, +): TimelineSeamKind | undefined { + if (!previous) return undefined + if (current.sequence !== previous.sequence + 1) return 'Gap' + if (!previous.isPlaying && current.isPlaying) return 'Jump' + if (previous.timeInSamples === undefined || current.timeInSamples === undefined) return undefined + + const expected = previous.timeInSamples + previous.frameCount + if (Math.abs(current.timeInSamples - expected) <= Math.max(2, previous.frameCount * 0.1)) return undefined + if (current.isLooping && current.timeInSamples < previous.timeInSamples) return 'Loop' + return 'Jump' +} + +function niceSecondStep(points: TimelinePoint[], width: number, dpr: number): number { + const timed = points.filter((point) => point.seconds !== undefined) + if (timed.length < 2) return 1 + const first = timed[0].seconds! + const last = timed[timed.length - 1].seconds! + const span = Math.max(0.001, Math.abs(last - first)) + const targetTicks = Math.max(1, width / Math.max(1, 80 * dpr)) + const targetStep = span / targetTicks + return SECOND_STEPS.find((step) => step >= targetStep) ?? SECOND_STEPS[SECOND_STEPS.length - 1] +} + +export class ScrollingTimeline { + private points: TimelinePoint[] = [] + private previousAnchor: PreviousTimelineAnchor | null = null + private pendingSeam: TimelineSeamKind | undefined + private stoppedPosition: TimelinePoint | null = null + + reset(): void { + this.points = [] + this.previousAnchor = null + this.pendingSeam = undefined + this.stoppedPosition = null + } + + resize(capacity: number): void { + if (capacity <= 0) { + this.points = [] + return + } + if (this.points.length > capacity) { + this.points = this.points.slice(this.points.length - capacity) + } + } + + append(anchor: TimelineChunkAnchor, columnCount: number, capacity: number): void { + const transport = anchor.transport + if (!transport) { + this.appendUnmapped(columnCount, capacity) + return + } + + const seam = resolveTimelineSeam(this.previousAnchor, transport) + if (seam) this.pendingSeam = seam + this.previousAnchor = { + sequence: transport.sequence, + frameCount: anchor.frameCount, + timeInSamples: transport.timeInSamples, + isPlaying: transport.isPlaying, + isLooping: transport.isLooping, + } + + const sampleRate = Math.max(1, anchor.sampleRate) + const numerator = Math.max(1, transport.timeSignature?.numerator ?? 4) + const denominator = Math.max(1, transport.timeSignature?.denominator ?? 4) + if (!transport.isPlaying) { + this.stoppedPosition = { + seconds: transport.timeInSeconds + ?? (transport.timeInSamples !== undefined ? transport.timeInSamples / sampleRate : undefined), + ppq: transport.ppqPosition, + bpm: transport.bpm, + numerator, + denominator, + lastBarPpq: transport.ppqPositionOfLastBarStart, + } + this.appendUnmapped(columnCount, capacity) + return + } + + this.stoppedPosition = null + for (let column = 0; column < columnCount; column += 1) { + const frameOffset = ((column + 1) / Math.max(1, columnCount)) * anchor.frameCount + const secondsOffset = frameOffset / sampleRate + const seconds = transport.timeInSeconds !== undefined + ? transport.timeInSeconds + secondsOffset + : transport.timeInSamples !== undefined + ? (transport.timeInSamples + frameOffset) / sampleRate + : undefined + const ppq = transport.ppqPosition !== undefined && transport.bpm !== undefined + ? transport.ppqPosition + secondsOffset * transport.bpm / 60 + : transport.ppqPosition + this.points.push({ + seconds, + ppq, + bpm: transport.bpm, + numerator, + denominator, + lastBarPpq: transport.ppqPositionOfLastBarStart, + ...(column === 0 && this.pendingSeam ? { seam: this.pendingSeam } : {}), + }) + if (column === 0) this.pendingSeam = undefined + } + this.resize(capacity) + } + + draw( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + unit: TimelineUnit, + backendKind: CaptureBackendKind | null, + gridColor: string, + labelColor: string, + orientation: 'horizontal' | 'vertical' = 'horizontal', + ): void { + if (unit === 'off' || backendKind !== 'daw-bridge' || this.points.length === 0) return + + const dpr = window.devicePixelRatio || 1 + const axisLength = orientation === 'horizontal' ? width : height + const start = axisLength - this.points.length + const hasPpq = this.points.some((point) => point.ppq !== undefined) + const effectiveUnit: TimelineUnit = unit === 'bars-beats' && !hasPpq ? 'seconds' : unit + const secondStep = niceSecondStep(this.points, axisLength, dpr) + let lastLabelPosition = -Infinity + + ctx.save() + ctx.font = `${10 * dpr}px monospace` + ctx.textBaseline = 'top' + ctx.lineWidth = Math.max(1, dpr) + + for (let index = 1; index < this.points.length; index += 1) { + const previous = this.points[index - 1] + const point = this.points[index] + const position = start + index + + if (point.seam) { + ctx.save() + ctx.strokeStyle = labelColor + ctx.setLineDash([4 * dpr, 4 * dpr]) + ctx.beginPath() + if (orientation === 'horizontal') { + ctx.moveTo(position, 0) + ctx.lineTo(position, height) + } else { + ctx.moveTo(0, position) + ctx.lineTo(width, position) + } + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = labelColor + ctx.fillText( + point.seam, + orientation === 'horizontal' ? position + 4 * dpr : 4 * dpr, + orientation === 'horizontal' ? 3 * dpr : position + 3 * dpr, + ) + ctx.restore() + continue + } + + let major = false + let label: string | null = null + let musicalLabel = false + if (effectiveUnit === 'bars-beats' && previous.ppq !== undefined && point.ppq !== undefined) { + const beatLength = 4 / point.denominator + const previousBeatIndex = Math.floor((previous.ppq + beatLength * 1.0e-7) / beatLength) + const beatIndex = Math.floor((point.ppq + beatLength * 1.0e-7) / beatLength) + if (previousBeatIndex === beatIndex) continue + const quartersPerBar = point.numerator * beatLength + const tickPpq = beatIndex * beatLength + const barAnchor = point.lastBarPpq + const beatsFromBarAnchor = barAnchor !== undefined + ? Math.round((tickPpq - barAnchor) / beatLength) + : beatIndex + const beatNumber = ((beatsFromBarAnchor % point.numerator) + point.numerator) % point.numerator + 1 + const barNumber = Math.floor((tickPpq + quartersPerBar * 1.0e-7) / quartersPerBar) + 1 + const ppqPerPixel = Math.abs(point.ppq - previous.ppq) + const estimatedBeatPixelSpacing = ppqPerPixel > 0 ? beatLength / ppqPerPixel : 0 + const hasRoomForBeatLabel = estimatedBeatPixelSpacing >= 34 * dpr + major = beatNumber === 1 + musicalLabel = true + // At roomy zoom levels the ruler names every beat. Dense histories keep + // every bar number instead of dropping apparently random bars. + label = major + ? hasRoomForBeatLabel ? `${barNumber}|1` : `${barNumber}` + : hasRoomForBeatLabel ? `${barNumber}|${beatNumber}` : null + } else if (previous.seconds !== undefined && point.seconds !== undefined) { + if (Math.floor(previous.seconds / secondStep) === Math.floor(point.seconds / secondStep)) continue + major = true + label = formatSeconds(Math.floor(point.seconds / secondStep) * secondStep) + } else { + continue + } + + ctx.strokeStyle = gridColor + ctx.globalAlpha = major ? 0.8 : 0.3 + ctx.beginPath() + if (orientation === 'horizontal') { + ctx.moveTo(position, 0) + ctx.lineTo(position, height) + } else { + ctx.moveTo(0, position) + ctx.lineTo(width, position) + } + ctx.stroke() + ctx.globalAlpha = 1 + + const secondsLabelHasRoom = label + ? position - lastLabelPosition >= Math.max(20 * dpr, ctx.measureText(label).width + 6 * dpr) + : false + if (label && (musicalLabel || secondsLabelHasRoom)) { + ctx.fillStyle = labelColor + ctx.fillText( + label, + orientation === 'horizontal' ? position + 3 * dpr : 3 * dpr, + orientation === 'horizontal' ? height - 15 * dpr : position + 3 * dpr, + ) + if (!musicalLabel) lastLabelPosition = position + } + } + + if (unit === 'bars-beats' && effectiveUnit === 'seconds') { + ctx.fillStyle = labelColor + ctx.fillText('Seconds (beat data unavailable)', 5 * dpr, 4 * dpr) + } + + if (this.stoppedPosition) { + const stoppedBeatLength = 4 / this.stoppedPosition.denominator + const stoppedQuartersPerBar = this.stoppedPosition.numerator * stoppedBeatLength + const stoppedBarAnchor = this.stoppedPosition.lastBarPpq + ?? Math.floor((this.stoppedPosition.ppq ?? 0) / stoppedQuartersPerBar) * stoppedQuartersPerBar + const stoppedLabel = effectiveUnit === 'bars-beats' && this.stoppedPosition.ppq !== undefined + ? `Stopped · ${Math.floor(this.stoppedPosition.ppq / stoppedQuartersPerBar) + 1}|${Math.floor((this.stoppedPosition.ppq - stoppedBarAnchor) / stoppedBeatLength) + 1}` + : this.stoppedPosition.seconds !== undefined + ? `Stopped · ${formatSeconds(this.stoppedPosition.seconds)}` + : 'Stopped' + ctx.fillStyle = labelColor + if (orientation === 'horizontal') { + ctx.textAlign = 'right' + ctx.fillText(stoppedLabel, width - 5 * dpr, 4 * dpr) + } else { + ctx.textAlign = 'left' + ctx.fillText(stoppedLabel, 5 * dpr, Math.max(4 * dpr, height - 15 * dpr)) + } + } + + ctx.restore() + } + + private appendUnmapped(columnCount: number, capacity: number): void { + for (let column = 0; column < columnCount; column += 1) { + this.points.push({ numerator: 4, denominator: 4 }) + } + this.resize(capacity) + } +} diff --git a/src/shared/profileState.ts b/src/shared/profileState.ts index 707aa3d..0bd380f 100644 --- a/src/shared/profileState.ts +++ b/src/shared/profileState.ts @@ -9,7 +9,7 @@ import { type ProfileLocalMetadata, type PrismProfileFile, type PrismProfileFileScopePopoutMap, - type PrismProfileFileV5, + type PrismProfileFileV6, type PrismProfileLocalStateV1, } from '../types/profile' import { AUDIO_SCOPE_KINDS, SCOPE_KINDS, normalizeScopeKind, type ScopeKind } from '../types/scope' @@ -38,6 +38,7 @@ import { DEFAULT_ANALYSIS_SETTINGS, normalizeAnalysisSettings, } from '../types/analysis' +import { DEFAULT_TIMELINE_UNIT, isTimelineUnit } from '../types/dawBridge' export const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter'] export const DEFAULT_SCOPE_ORDER: ScopeKind[] = [...AUDIO_SCOPE_KINDS] @@ -245,6 +246,9 @@ export function mergeScopeSettings( tiltDbPerOctave: clampSpectrogramTiltDbPerOctave( rawSpectrogram.tiltDbPerOctave ?? DEFAULT_SCOPE_SETTINGS.spectrogram.tiltDbPerOctave ), + timelineUnit: isTimelineUnit(rawSpectrogram.timelineUnit) + ? rawSpectrogram.timelineUnit + : DEFAULT_TIMELINE_UNIT, }, vumeter: { ...DEFAULT_SCOPE_SETTINGS.vumeter, @@ -271,6 +275,9 @@ export function mergeScopeSettings( multiband: typeof rawWaveform.multiband === 'boolean' ? rawWaveform.multiband : DEFAULT_SCOPE_SETTINGS.waveform.multiband, + timelineUnit: isTimelineUnit(rawWaveform.timelineUnit) + ? rawWaveform.timelineUnit + : DEFAULT_TIMELINE_UNIT, }, nowPlaying: { ...DEFAULT_SCOPE_SETTINGS.nowPlaying, ...rawNowPlaying }, } @@ -346,9 +353,9 @@ export function normalizeProfileFile( raw: unknown, fallbackId: string, fallbackName = DEFAULT_PROFILE_NAME, -) : PrismProfileFileV5 { +) : PrismProfileFileV6 { const parsed = typeof raw === 'object' && raw !== null - ? raw as Omit, 'version'> & { version?: unknown } + ? raw as Omit, 'version'> & { version?: unknown } : {} const id = typeof parsed.id === 'string' && parsed.id.trim() @@ -373,7 +380,7 @@ export function normalizeProfileFile( } } -export function profileToFileData(id: string, profile: Profile): PrismProfileFileV5 { +export function profileToFileData(id: string, profile: Profile): PrismProfileFileV6 { const normalized = normalizeProfile(profile, profile.name) return { diff --git a/src/types/capture.ts b/src/types/capture.ts index 751c233..6e432fe 100644 --- a/src/types/capture.ts +++ b/src/types/capture.ts @@ -1,4 +1,5 @@ -export type CaptureMode = 'system' | 'device' +export type CaptureMode = 'system' | 'device' | 'daw' +export type CaptureStatus = 'idle' | 'connecting' | 'waiting' | 'capturing' | 'error' export type NativeSystemCaptureBackendKind = | 'native-macos' @@ -7,6 +8,7 @@ export type NativeSystemCaptureBackendKind = export type CaptureBackendKind = | 'device-input' + | 'daw-bridge' | NativeSystemCaptureBackendKind export function resolveNativeBackendKind(platform: string): NativeSystemCaptureBackendKind { @@ -22,6 +24,7 @@ export function resolveNativeBackendKind(platform: string): NativeSystemCaptureB export interface CaptureSourceDescriptor { id: string + persistentId?: string label: string kind: CaptureMode isDefault?: boolean @@ -38,4 +41,5 @@ export interface CaptureBackendSupportEntry { export interface CaptureBackendSupport { nativeBackend: CaptureBackendSupportEntry deviceInput: CaptureBackendSupportEntry + dawBridge: CaptureBackendSupportEntry } diff --git a/src/types/dawBridge.ts b/src/types/dawBridge.ts new file mode 100644 index 0000000..1d62c0d --- /dev/null +++ b/src/types/dawBridge.ts @@ -0,0 +1,106 @@ +export const DAW_BRIDGE_HOST = '127.0.0.1' +export const DAW_BRIDGE_PORT = 51789 +export const DAW_BRIDGE_PROTOCOL_VERSION = 1 +export const DAW_BRIDGE_FRAME_MAGIC = 0x4d535250 +export const DAW_BRIDGE_FRAME_HEADER_BYTES = 12 +export const DAW_BRIDGE_AUDIO_HEADER_BYTES = 76 +export const DAW_BRIDGE_MAX_PAYLOAD_BYTES = 256 * 1024 + +export const DAW_BRIDGE_MESSAGE = { + hello: 1, + heartbeat: 2, + sourceUpdate: 3, + subscribe: 10, + audio: 20, +} as const + +export type DawBridgeConnectionState = 'connected' | 'selected' + +export interface DawBridgeSourceDescriptor { + id: string + persistentId: string + instanceId: string + label: string + customName: string | null + trackName: string | null + hostName: string | null + sampleRate: number + channelCount: number + droppedFrames: number + connectionState: DawBridgeConnectionState +} + +export interface DawBridgeSnapshot { + available: boolean + reason: string | null + selectedSourceId: string | null + sources: DawBridgeSourceDescriptor[] +} + +export interface DawTransportSnapshot { + sequence: number + timeInSamples?: number + timeInSeconds?: number + ppqPosition?: number + bpm?: number + ppqPositionOfLastBarStart?: number + timeSignature?: { + numerator: number + denominator: number + } + loopPoints?: { + ppqStart: number + ppqEnd: number + } + isPlaying: boolean + isRecording: boolean + isLooping: boolean +} + +export interface DawAnnotatedAudioChunk { + sequence: number + sampleRate: number + channelCount: number + left: Float32Array + right: Float32Array + transport: DawTransportSnapshot +} + +export interface DawBridgeAudioPacket extends DawAnnotatedAudioChunk { + sourceId: string +} + +export interface DawBridgeAudioBatch { + sourceId: string + packets: DawBridgeAudioPacket[] +} + +export interface DawBridgeHelloPayload { + sourceId: string + instanceId: string + customName?: string + trackName?: string + hostName?: string + sampleRate?: number + channelCount?: number + droppedFrames?: number +} + +export interface DawBridgeSubscribePayload { + selected: boolean +} + +export interface DawBridgeRendererAPI { + getSnapshot: () => Promise + selectSource: (sourceId: string | null) => Promise + onSnapshot: (callback: (snapshot: DawBridgeSnapshot) => void) => () => void + onAudioBatch: (callback: (batch: DawBridgeAudioBatch) => void) => () => void +} + +export type TimelineUnit = 'off' | 'bars-beats' | 'seconds' + +export function isTimelineUnit(value: unknown): value is TimelineUnit { + return value === 'off' || value === 'bars-beats' || value === 'seconds' +} + +export const DEFAULT_TIMELINE_UNIT: TimelineUnit = 'bars-beats' diff --git a/src/types/desktopIntegration.ts b/src/types/desktopIntegration.ts index 21abdef..5cf261b 100644 --- a/src/types/desktopIntegration.ts +++ b/src/types/desktopIntegration.ts @@ -36,20 +36,23 @@ export interface TrayRendererState { profiles: TrayProfileOption[] activeProfileId: string | null hasUnsavedProfileChanges: boolean - captureStatus: 'idle' | 'connecting' | 'capturing' | 'error' + captureStatus: 'idle' | 'connecting' | 'waiting' | 'capturing' | 'error' activeSourceLabel: string | null - captureMode: 'system' | 'device' + captureMode: 'system' | 'device' | 'daw' selectedSystemSourceId: string | null selectedDeviceId: string | null + selectedDawSourceId: string | null rollingCaptureSeconds: RollingCaptureDurationSeconds | null systemSources: TrayAudioSourceOption[] inputSources: TrayAudioSourceOption[] + dawSources: TrayAudioSourceOption[] } export type TrayRendererCommand = | { type: 'load-profile'; profileId: string } | { type: 'select-system-source'; sourceId: string } | { type: 'select-input-source'; deviceId: string | null } + | { type: 'select-daw-source'; sourceId: string } | { type: 'set-rolling-capture'; durationSeconds: RollingCaptureDurationSeconds | null } | { type: 'set-capture-running'; running: boolean } | { type: 'open-settings' } @@ -68,7 +71,9 @@ export const DEFAULT_TRAY_RENDERER_STATE: TrayRendererState = { captureMode: 'system', selectedSystemSourceId: null, selectedDeviceId: null, + selectedDawSourceId: null, rollingCaptureSeconds: null, systemSources: [], inputSources: [], + dawSources: [], } diff --git a/src/types/popout.ts b/src/types/popout.ts index 667f66b..05c9426 100644 --- a/src/types/popout.ts +++ b/src/types/popout.ts @@ -1,5 +1,6 @@ import type { CaptureBackendKind } from './capture' import type { ScopeKind } from './scope' +import type { DawTransportSnapshot } from './dawBridge' import type { ScopeSettings } from './settings' import type { AnalysisSettings } from './analysis' import type { @@ -40,12 +41,14 @@ export interface ScopePopoutSessionState { sampleRate: number channelCount: number capturing: boolean + suspended: boolean backendKind: CaptureBackendKind | null } export interface ScopePopoutStereoChunk { left: Float32Array right: Float32Array + transport?: DawTransportSnapshot } export type ScopePopoutMonoBatch = Float32Array[] diff --git a/src/types/profile.ts b/src/types/profile.ts index 9e13d9d..2b0c6bb 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -4,7 +4,7 @@ import type { ScopeSettings } from './settings' import type { AnalysisSettings } from './analysis' export const PROFILE_FILE_FORMAT = 'prism-profile' -export const PROFILE_FILE_VERSION = 5 +export const PROFILE_FILE_VERSION = 6 export const PROFILE_LOCAL_STATE_FORMAT = 'prism-profile-local' export const PROFILE_LOCAL_STATE_VERSION = 1 export const LEGACY_PROFILE_MIGRATION_VERSION = 1 @@ -81,7 +81,7 @@ export interface PrismProfileFileV4 { export interface PrismProfileFileV5 { format: typeof PROFILE_FILE_FORMAT - version: typeof PROFILE_FILE_VERSION + version: 5 id: string name: string themeId?: string | null @@ -93,7 +93,11 @@ export interface PrismProfileFileV5 { scopePopouts: PrismProfileFileScopePopoutMap } -export type PrismProfileFile = PrismProfileFileV1 | PrismProfileFileV2 | PrismProfileFileV3 | PrismProfileFileV4 | PrismProfileFileV5 +export interface PrismProfileFileV6 extends Omit { + version: typeof PROFILE_FILE_VERSION +} + +export type PrismProfileFile = PrismProfileFileV1 | PrismProfileFileV2 | PrismProfileFileV3 | PrismProfileFileV4 | PrismProfileFileV5 | PrismProfileFileV6 export interface ProfileLocalMetadata { windowBounds?: WindowBounds diff --git a/src/types/settings.ts b/src/types/settings.ts index 6144989..62d1894 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -21,6 +21,7 @@ import { type ScopeDisplayTransformSettings, } from './scopeTransform' import { DEFAULT_VECTORSCOPE_ZOOM_DB } from './vectorscope' +import { DEFAULT_TIMELINE_UNIT, type TimelineUnit } from './dawBridge' export interface ScopeSettings { spectrum: ScopeDisplayTransformSettings & { @@ -61,6 +62,7 @@ export interface ScopeSettings { frequencyRangeMode: FrequencyRangeMode showGrid: boolean colorScheme: 'heat' | 'mono' + timelineUnit: TimelineUnit } vumeter: { mode: VUMeterMode @@ -76,6 +78,7 @@ export interface ScopeSettings { mode: WaveformMode scrollSpeed: number multiband: boolean + timelineUnit: TimelineUnit } nowPlaying: { showCoverArt: boolean @@ -91,10 +94,10 @@ export const DEFAULT_SCOPE_SETTINGS: ScopeSettings = { spectrum: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, scaleMode: DEFAULT_FREQUENCY_SCALE_MODE, frequencyRangeMode: DEFAULT_FREQUENCY_RANGE_MODE, fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, heatmapSmoothing: 0.5, showGrid: true, smoothing: 0.9, fillGradient: true, showSideLine: false, peakInfoMode: DEFAULT_SPECTRUM_PEAK_INFO_MODE }, oscilloscope: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, pitchLock: true, underfillEnabled: false, showGrid: true, lineWidth: 2 }, vectorscope: { mode: 'lissajous', zoomDb: DEFAULT_VECTORSCOPE_ZOOM_DB, multiband: false, showGrid: true, persistence: 0.10, lineWidth: 1.5 }, - spectrogram: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, fftSize: 4096, tiltDbPerOctave: DEFAULT_SPECTROGRAM_TILT_DB_PER_OCTAVE, scrollSpeed: 2, contrast: DEFAULT_SPECTROGRAM_CONTRAST, clarityMode: 'sharper', scaleMode: 'log', frequencyRangeMode: DEFAULT_FREQUENCY_RANGE_MODE, showGrid: true, colorScheme: 'heat' }, + spectrogram: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, fftSize: 4096, tiltDbPerOctave: DEFAULT_SPECTROGRAM_TILT_DB_PER_OCTAVE, scrollSpeed: 2, contrast: DEFAULT_SPECTROGRAM_CONTRAST, clarityMode: 'sharper', scaleMode: 'log', frequencyRangeMode: DEFAULT_FREQUENCY_RANGE_MODE, showGrid: true, colorScheme: 'heat', timelineUnit: DEFAULT_TIMELINE_UNIT }, vumeter: { mode: 'bar', orientation: 'horizontal', needleChannels: 'stereo', referenceDb: DEFAULT_VU_REFERENCE_DBFS }, lufsmeter: { mode: 'bar', readout: DEFAULT_LUFS_METER_READOUT }, - waveform: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, mode: DEFAULT_WAVEFORM_MODE, scrollSpeed: DEFAULT_WAVEFORM_SCROLL_SPEED, multiband: false }, + waveform: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, mode: DEFAULT_WAVEFORM_MODE, scrollSpeed: DEFAULT_WAVEFORM_SCROLL_SPEED, multiband: false, timelineUnit: DEFAULT_TIMELINE_UNIT }, nowPlaying: { showCoverArt: true, showTitle: true, diff --git a/test/audio-router.test.ts b/test/audio-router.test.ts index b36a2b0..cd9c8ee 100644 --- a/test/audio-router.test.ts +++ b/test/audio-router.test.ts @@ -183,6 +183,26 @@ test('drops stale-session chunks before they reach scope queues', () => { assert.equal(router.getDiagnosticsSnapshot().staleSessionDrops, 1) }) +test('suspend and resume preserve queued history without creating a new session', () => { + const router = new AudioRouter() + const sessionId = router.beginSession(48000, 2, 'daw-bridge') + router.setVisualizerConsumerDemand('timeline', { waveform: true }) + router.ingestChunk(createChunk(1), createChunk(1), { sessionId, sequence: 1 }) + + router.suspendSession() + assert.equal(router.getSessionState().suspended, true) + assert.equal(router.getSessionState().sessionId, sessionId) + router.ingestChunk(createChunk(2), createChunk(2), { sessionId, sequence: 2 }) + + router.resumeSession() + assert.equal(router.getSessionState().suspended, false) + assert.equal(router.getSessionState().sessionId, sessionId) + router.ingestChunk(createChunk(3), createChunk(3), { sessionId, sequence: 3 }) + + const chunks = router.flushPendingWaveformStereoSamples() + assert.deepEqual(chunks.map((chunk) => chunk.left[0]), [1, 3]) +}) + test('publishes aggregated visualizer demand changes for downstream transports', () => { const router = new AudioRouter() const snapshots: Array> = [] diff --git a/test/audio-store.test.ts b/test/audio-store.test.ts index 079ce8a..8280349 100644 --- a/test/audio-store.test.ts +++ b/test/audio-store.test.ts @@ -30,6 +30,7 @@ function audioPreferences(overrides: Partial = {}): Persist captureMode: 'system', selectedSystemSourceId: DEFAULT_SYSTEM_SOURCE_ID, selectedDeviceId: null, + selectedDawSourceId: null, rollingCaptureSeconds: null, ...overrides, } @@ -107,6 +108,11 @@ function createBackendSupport(available: boolean, reason: string | null): Captur available: true, reason: null, }, + dawBridge: { + kind: 'daw-bridge', + available: true, + reason: null, + }, } } @@ -279,6 +285,7 @@ function installFakeDeviceWatcherEnvironment(): { function resetStores(): void { audioCapture.setSelectedSystemSourceId(DEFAULT_SYSTEM_SOURCE_ID) audioCapture.setSelectedDeviceId(null) + audioCapture.setSelectedDawSourceId(null) audioCapture.setCaptureMode('system') audioCapture.setInputGain(0) audioCapture.setRollingCaptureSeconds(null) @@ -288,6 +295,8 @@ function resetStores(): void { devices: [], selectedSystemSourceId: '__default_system_output__', selectedDeviceId: null, + selectedDawSourceId: null, + dawSources: [], captureMode: 'system', activeBackendKind: null, backendSupport: null, @@ -414,6 +423,7 @@ function installAudioCaptureHarness(options: { isCapturing, activeSourceId: isCapturing ? activeSourceId : null, activeSourceLabel: isCapturing ? activeSourceLabel : null, + waiting: false, }) audioCapture.setCaptureMode = (mode) => { captureMode = mode @@ -492,6 +502,16 @@ test('normalizeAudioPreferences preserves valid persisted selector values', () = })) }) +test('normalizeAudioPreferences preserves DAW mode and its stable bridge UUID', () => { + assert.deepEqual(normalizeAudioPreferences({ + captureMode: 'daw', + selectedDawSourceId: 'bridge-stable-uuid', + }), audioPreferences({ + captureMode: 'daw', + selectedDawSourceId: 'bridge-stable-uuid', + })) +}) + test('normalizeRollingCaptureSeconds accepts only supported durations', () => { assert.equal(normalizeRollingCaptureSeconds(5), 5) assert.equal(normalizeRollingCaptureSeconds(60), 60) @@ -633,6 +653,40 @@ test('audio store persists input source selections', async () => { } }) +test('audio store persists a Bridge stable UUID while selecting its live connection key', async () => { + resetStores() + const fakeStorage = installFakeLocalStorage() + const originalSetSelectedDawSourceId = audioCapture.setSelectedDawSourceId + const selections: Array<[string | null, string | null]> = [] + audioCapture.setSelectedDawSourceId = (stableId, liveId = null) => { + selections.push([stableId, liveId]) + } + + try { + useAudioStore.setState({ + dawSources: [{ + id: 'bridge-stable-uuid:live-key', + persistentId: 'bridge-stable-uuid', + label: 'Drums', + kind: 'daw', + }], + }) + await useAudioStore.getState().selectDawSource('bridge-stable-uuid:live-key') + + assert.equal(useAudioStore.getState().captureMode, 'daw') + assert.equal(useAudioStore.getState().selectedDawSourceId, 'bridge-stable-uuid') + assert.deepEqual(selections, [['bridge-stable-uuid', 'bridge-stable-uuid:live-key']]) + assert.equal(fakeStorage.getItem('prism:audio'), storedAudioPreferences({ + captureMode: 'daw', + selectedDawSourceId: 'bridge-stable-uuid', + })) + } finally { + audioCapture.setSelectedDawSourceId = originalSetSelectedDawSourceId + fakeStorage.restore() + resetStores() + } +}) + test('audio store auto-switches to device input when native system capture is unavailable on startup', async () => { resetStores() const fakeStorage = installFakeLocalStorage() diff --git a/test/capture-support.test.ts b/test/capture-support.test.ts index 5fecf54..e0e3d7a 100644 --- a/test/capture-support.test.ts +++ b/test/capture-support.test.ts @@ -46,6 +46,11 @@ test('getCaptureBackendSupport resolves Linux native support from the addon surf available: true, reason: null, }, + dawBridge: { + kind: 'daw-bridge', + available: false, + reason: 'The DAW bridge listener is still starting.', + }, }) }) diff --git a/test/daw-bridge-service.test.ts b/test/daw-bridge-service.test.ts new file mode 100644 index 0000000..d0eda56 --- /dev/null +++ b/test/daw-bridge-service.test.ts @@ -0,0 +1,178 @@ +import assert from 'node:assert/strict' +import { connect, type Socket } from 'node:net' +import test from 'node:test' +import { DawBridgeService } from '../src/main/services/dawBridgeService' +import { + DAW_BRIDGE_AUDIO_HEADER_BYTES, + DAW_BRIDGE_FRAME_HEADER_BYTES, + DAW_BRIDGE_FRAME_MAGIC, + DAW_BRIDGE_MESSAGE, + DAW_BRIDGE_PROTOCOL_VERSION, + type DawBridgeAudioBatch, +} from '../src/types/dawBridge' + +function frame(messageType: number, payload = Buffer.alloc(0), version = DAW_BRIDGE_PROTOCOL_VERSION): Buffer { + const header = Buffer.alloc(DAW_BRIDGE_FRAME_HEADER_BYTES) + header.writeUInt32LE(DAW_BRIDGE_FRAME_MAGIC, 0) + header.writeUInt16LE(version, 4) + header.writeUInt16LE(messageType, 6) + header.writeUInt32LE(payload.length, 8) + return Buffer.concat([header, payload]) +} + +function hello(sourceId: string, instanceId: string, trackName = 'Drums'): Buffer { + return frame(DAW_BRIDGE_MESSAGE.hello, Buffer.from(JSON.stringify({ + sourceId, + instanceId, + trackName, + hostName: 'Test DAW', + sampleRate: 48000, + channelCount: 2, + }))) +} + +function audioPacket(sequence = 7): Buffer { + const left = [0.25, -0.5] + const right = [0.75, -1] + const payload = Buffer.alloc(DAW_BRIDGE_AUDIO_HEADER_BYTES + 16) + payload.writeUInt32LE(sequence, 0) + payload.writeUInt16LE(2, 4) + payload.writeUInt8(2, 6) + payload.writeUInt8(0xff, 7) + payload.writeDoubleLE(48000, 8) + payload.writeBigInt64LE(96000n, 16) + payload.writeDoubleLE(2, 24) + payload.writeDoubleLE(8, 32) + payload.writeDoubleLE(120, 40) + payload.writeDoubleLE(8, 48) + payload.writeDoubleLE(4, 56) + payload.writeDoubleLE(12, 64) + payload.writeInt16LE(4, 72) + payload.writeInt16LE(4, 74) + left.forEach((value, index) => payload.writeFloatLE(value, DAW_BRIDGE_AUDIO_HEADER_BYTES + index * 4)) + right.forEach((value, index) => payload.writeFloatLE(value, DAW_BRIDGE_AUDIO_HEADER_BYTES + 8 + index * 4)) + return frame(DAW_BRIDGE_MESSAGE.audio, payload) +} + +async function openClient(port: number): Promise { + return new Promise((resolve, reject) => { + const socket = connect({ host: '127.0.0.1', port }, () => resolve(socket)) + socket.once('error', reject) + }) +} + +async function waitFor(predicate: () => boolean, timeoutMs = 1000, label = 'bridge state'): Promise { + const startedAt = Date.now() + while (!predicate()) { + if (Date.now() - startedAt >= timeoutMs) throw new Error(`Timed out waiting for ${label}.`) + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + +test('bridge service parses fragmented control frames and coalesced planar audio', async () => { + let deliveredBatch: DawBridgeAudioBatch | null = null + const service = new DawBridgeService({ + port: 0, + audioBatchIntervalMs: 1, + onAudioBatch: (batch) => { deliveredBatch = batch }, + }) + await service.start() + const port = service.getListeningPort() + assert.ok(port) + const socket = await openClient(port) + + try { + const helloFrame = hello('source-one', 'instance-one') + socket.write(helloFrame.subarray(0, 5)) + socket.write(helloFrame.subarray(5)) + await waitFor(() => service.getSnapshot().sources.length === 1, 1000, 'fragmented hello') + + service.selectSource('source-one') + socket.write(Buffer.concat([frame(DAW_BRIDGE_MESSAGE.heartbeat), audioPacket()])) + await waitFor(() => deliveredBatch !== null) + + const packet = deliveredBatch!.packets[0]! + assert.equal(deliveredBatch!.sourceId, 'source-one') + assert.deepEqual([...packet.left], [0.25, -0.5]) + assert.deepEqual([...packet.right], [0.75, -1]) + assert.equal(packet.transport.timeInSamples, 96000) + assert.equal(packet.transport.ppqPosition, 8) + assert.deepEqual(packet.transport.timeSignature, { numerator: 4, denominator: 4 }) + assert.deepEqual(packet.transport.loopPoints, { ppqStart: 4, ppqEnd: 12 }) + } finally { + socket.destroy() + service.stop() + } +}) + +test('duplicate persisted UUIDs get distinct live keys and clear implicit selection', async () => { + const service = new DawBridgeService({ port: 0 }) + await service.start() + const port = service.getListeningPort() + assert.ok(port) + const first = await openClient(port) + const second = await openClient(port) + + try { + first.write(hello('duplicate-source', 'instance-alpha')) + await waitFor(() => service.getSnapshot().sources.length === 1) + service.selectSource('duplicate-source') + second.write(hello('duplicate-source', 'instance-beta')) + await new Promise((resolve) => setTimeout(resolve, 30)) + assert.equal( + service.getSnapshot().sources.length, + 2, + JSON.stringify(service.getSnapshot()), + ) + + const snapshot = service.getSnapshot() + assert.equal(snapshot.selectedSourceId, null) + assert.equal(new Set(snapshot.sources.map((source) => source.id)).size, 2) + assert.ok(snapshot.sources.every((source) => source.id !== source.persistentId)) + } finally { + first.destroy() + second.destroy() + service.stop() + } +}) + +test('malformed protocol clients are rejected and silent sources expire', async () => { + const service = new DawBridgeService({ + port: 0, + heartbeatIntervalMs: 10, + heartbeatTimeoutMs: 40, + }) + await service.start() + const port = service.getListeningPort() + assert.ok(port) + const malformed = await openClient(port) + malformed.write(frame(DAW_BRIDGE_MESSAGE.hello, Buffer.from('{}'), 999)) + await new Promise((resolve) => malformed.once('close', resolve)) + assert.equal(service.getSnapshot().sources.length, 0) + + const silent = await openClient(port) + try { + silent.write(hello('silent-source', 'silent-instance')) + await waitFor(() => service.getSnapshot().sources.length === 1) + await waitFor(() => service.getSnapshot().sources.length === 0, 500) + } finally { + silent.destroy() + service.stop() + } +}) + +test('port conflicts surface an unavailable reason without changing ports', async () => { + const owner = new DawBridgeService({ port: 0 }) + await owner.start() + const port = owner.getListeningPort() + assert.ok(port) + const blocked = new DawBridgeService({ port }) + try { + const snapshot = await blocked.start() + assert.equal(snapshot.available, false) + assert.match(snapshot.reason ?? '', /Could not listen|address already in use/i) + } finally { + blocked.stop() + owner.stop() + } +}) diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts index d56831b..38895a6 100644 --- a/test/profile-library.test.ts +++ b/test/profile-library.test.ts @@ -70,6 +70,7 @@ test('profile file serialization excludes geometry and round-trips with local me assert.equal(file.format, PROFILE_FILE_FORMAT) assert.equal(file.version, PROFILE_FILE_VERSION) + assert.equal(file.version, 6) assert.equal('themeId' in file, false) assert.equal(JSON.stringify(file).includes('windowBounds'), false) assert.equal(JSON.stringify(file).includes('frameTarget'), false) @@ -85,6 +86,8 @@ test('profile file serialization excludes geometry and round-trips with local me assert.equal(file.scopeSettings.spectrogram.showGrid, true) assert.equal(file.scopeSettings.spectrogram.rotation, 90) assert.equal(file.scopeSettings.spectrogram.mirrorHorizontal, true) + assert.equal(file.scopeSettings.spectrogram.timelineUnit, 'bars-beats') + assert.equal(file.scopeSettings.waveform.timelineUnit, 'bars-beats') assert.equal('orientation' in file.scopeSettings.spectrogram, false) assert.equal(file.scopeOrder.includes('nowPlaying'), false) assert.equal(file.hiddenScopes.includes('nowPlaying'), true) @@ -402,10 +405,12 @@ test('partial files normalize, unsupported versions fail, and import does not ch assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.spectrum.showSideLine, false) assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.spectrum.heatmapSmoothing, 0.5) assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.spectrogram.colorScheme, 'heat') + assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.spectrogram.timelineUnit, 'bars-beats') assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.lufsmeter.readout, 'shortTerm') assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.waveform.mode, 'stereo') assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.waveform.scrollSpeed, 1) assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.waveform.multiband, true) + assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.waveform.timelineUnit, 'bars-beats') assert.equal(Object.hasOwn(partialSnapshot.profiles.profile_partial.scopeSettings.waveform, 'gainDb'), false) assert.equal(partialSnapshot.profiles.profile_partial.scopePopouts.spectrogram.poppedOut, true) assert.equal(partialSnapshot.profiles.profile_partial.widthWeights.spectrum, 1) diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 664ca70..6c0afef 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -101,6 +101,7 @@ import { } from '../src/renderer/visualizers/vuMeterBallistics' import { FrameScheduler } from '../src/renderer/visualizers/frameScheduler' import { VisualizerFrameLoop } from '../src/renderer/visualizers/visualizerFrameLoop' +import { resolveTimelineSeam, ScrollingTimeline } from '../src/renderer/visualizers/scrollingTimeline' import { NativeVisualizerTransport, type NativeVisualizerTransportBridge, @@ -7262,3 +7263,101 @@ test('NativePolledCaptureBackend trims stale backlog to the newest live slice wh timers.restore() } }) + +test('DAW timeline seams distinguish gaps, loops, seeks, and stopped resumption', () => { + const previous = { + sequence: 10, + frameCount: 512, + timeInSamples: 48000, + isPlaying: true, + isLooping: false, + } + const transport = { + sequence: 11, + timeInSamples: 48512, + isPlaying: true, + isRecording: false, + isLooping: false, + } + + assert.equal(resolveTimelineSeam(previous, transport), undefined) + assert.equal(resolveTimelineSeam(previous, { ...transport, sequence: 12 }), 'Gap') + assert.equal(resolveTimelineSeam(previous, { ...transport, timeInSamples: 96000 }), 'Jump') + assert.equal(resolveTimelineSeam(previous, { + ...transport, + timeInSamples: 12000, + isLooping: true, + }), 'Loop') + assert.equal(resolveTimelineSeam({ ...previous, isPlaying: false }, transport), 'Jump') +}) + +test('DAW musical ruler labels every roomy beat and every bar in dense history', () => { + const dom = installFakeCanvasDom() + try { + const roomyRecorder = createFakeCanvasRecorder() + const roomy = new ScrollingTimeline() + roomy.append({ + frameCount: 192000, + sampleRate: 48000, + transport: { + sequence: 1, + timeInSamples: 0, + timeInSeconds: 0, + ppqPosition: 0, + ppqPositionOfLastBarStart: 0, + bpm: 120, + timeSignature: { numerator: 4, denominator: 4 }, + isPlaying: true, + isRecording: false, + isLooping: false, + }, + }, 800, 800) + roomy.draw( + createFakeCanvasContext(roomyRecorder), + 800, + 180, + 'bars-beats', + 'daw-bridge', + '#333', + '#fff', + ) + assert.deepEqual( + roomyRecorder.fillTexts.slice(0, 7).map(({ text }) => text), + ['1|2', '1|3', '1|4', '2|1', '2|2', '2|3', '2|4'], + ) + + const denseRecorder = createFakeCanvasRecorder() + const dense = new ScrollingTimeline() + dense.append({ + frameCount: 768000, + sampleRate: 48000, + transport: { + sequence: 1, + timeInSamples: 0, + timeInSeconds: 0, + ppqPosition: 0, + ppqPositionOfLastBarStart: 0, + bpm: 120, + timeSignature: { numerator: 4, denominator: 4 }, + isPlaying: true, + isRecording: false, + isLooping: false, + }, + }, 160, 160) + dense.draw( + createFakeCanvasContext(denseRecorder), + 160, + 180, + 'bars-beats', + 'daw-bridge', + '#333', + '#fff', + ) + assert.deepEqual( + denseRecorder.fillTexts.map(({ text }) => text), + ['2', '3', '4', '5', '6', '7', '8', '9'], + ) + } finally { + dom.restore() + } +})