prism bridge daw server

This commit is contained in:
Boof2015
2026-08-22 16:53:53 -04:00
parent 8bf2550829
commit b5bb8503e3
53 changed files with 3136 additions and 69 deletions
+11 -3
View File
@@ -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
+13 -5
View File
@@ -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
+12
View File
@@ -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.
+65
View File
@@ -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).
+1
View File
@@ -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",
+64 -1
View File
@@ -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 - "$<TARGET_BUNDLE_DIR:PrismBridge_AU>"
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)
+17 -4
View File
@@ -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
+77
View File
@@ -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));
}
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <juce_audio_processors/juce_audio_processors.h>
#include <juce_gui_basics/juce_gui_basics.h>
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)
};
+586
View File
@@ -0,0 +1,586 @@
#include "BridgeProcessor.h"
#include "BridgeEditor.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <limits>
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 <typename Integer>
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<double>::quiet_NaN();
if (const auto value = position->getTimeSignature())
{
result.numerator = static_cast<int16_t>(value->numerator);
result.denominator = static_cast<int16_t>(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<double>::quiet_NaN();
result.loopEndPpq = std::numeric_limits<double>::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<double>(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<float>& 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<uint64_t>(frameCount), std::memory_order_relaxed);
return;
}
auto& packet = packets[static_cast<size_t>(start1)];
std::memcpy(packet.left.data(), left, static_cast<size_t>(frameCount) * sizeof(float));
std::memcpy(packet.right.data(), right, static_cast<size_t>(frameCount) * sizeof(float));
packet.transport = transport;
packet.sequence = nextSequence.fetch_add(1, std::memory_order_relaxed) + 1;
packet.frameCount = static_cast<uint16_t>(frameCount);
packet.channelCount = static_cast<uint8_t>(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<size_t>(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<int64_t>(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<int64_t>(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<int>(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<juce::StreamingSocket>();
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<double>(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<size_t>(json.getNumBytesAsUTF8()));
}
bool PrismBridgeProcessor::sendHeartbeat()
{
return sendFrame(messageHeartbeat, nullptr, 0);
}
bool PrismBridgeProcessor::sendAudioPacket(const AudioPacket& packet)
{
std::array<uint8_t, audioHeaderBytes + packetFrames * 2 * sizeof(float)> 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<size_t>(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<uint8_t, frameHeaderBytes> header {};
writeLittleEndian(header.data() + 0, frameMagic);
writeLittleEndian(header.data() + 4, protocolVersion);
writeLittleEndian(header.data() + 6, messageType);
writeLittleEndian(header.data() + 8, static_cast<uint32_t>(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<const uint8_t*>(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<int>(byteCount - written));
if (result <= 0) return false;
written += static_cast<size_t>(result);
}
return written == byteCount;
}
bool PrismBridgeProcessor::readControlMessages()
{
if (!socket || !socket->isConnected()) return false;
while (socket->waitUntilReady(true, 0) > 0)
{
std::array<uint8_t, 4096> buffer {};
const int bytesRead = socket->read(buffer.data(), static_cast<int>(buffer.size()), false);
if (bytesRead <= 0) return false;
controlBuffer.append(buffer.data(), static_cast<size_t>(bytesRead));
if (controlBuffer.getSize() > maxPayloadBytes + frameHeaderBytes) return false;
parseControlBuffer();
}
return true;
}
void PrismBridgeProcessor::parseControlBuffer()
{
while (controlBuffer.getSize() >= frameHeaderBytes)
{
const auto* data = static_cast<const uint8_t*>(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<const char*>(data + frameHeaderBytes),
static_cast<int>(payloadSize));
const auto parsed = juce::JSON::parse(json);
if (const auto* object = parsed.getDynamicObject())
selected.store(static_cast<bool>(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<size_t>(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<const char*>(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();
}
+147
View File
@@ -0,0 +1,147 @@
#pragma once
#include <juce_audio_processors/juce_audio_processors.h>
#include <juce_core/juce_core.h>
#include <array>
#include <atomic>
#include <memory>
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<float>&, 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<float, packetFrames> left {};
std::array<float, packetFrames> 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<AudioPacket, packetCapacity + 1> packets;
std::atomic<double> currentSampleRate { 48000.0 };
std::atomic<uint32_t> nextSequence { 0 };
std::atomic<uint64_t> droppedFrames { 0 };
std::atomic<bool> connected { false };
std::atomic<bool> selected { false };
std::atomic<bool> 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<juce::StreamingSocket> socket;
juce::MemoryBlock controlBuffer;
uint64_t reportedDroppedFrames = 0;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PrismBridgeProcessor)
};
+131
View File
@@ -0,0 +1,131 @@
#include "BridgeProcessor.h"
#include <cmath>
#include <cstring>
#include <iostream>
namespace
{
int failures = 0;
void expect(bool condition, const char* message)
{
if (condition) return;
std::cerr << "FAIL: " << message << '\n';
++failures;
}
void fillTestSignal(juce::AudioBuffer<float>& 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<float>(sample + channel * 7) * 0.013f));
}
void expectBitIdentical(const juce::AudioBuffer<float>& actual,
const juce::AudioBuffer<float>& 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<size_t>(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<float> stereo(2, 1025);
fillTestSignal(stereo);
juce::AudioBuffer<float> 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<float> mono(1, PrismBridgeProcessor::packetFrames);
fillTestSignal(mono);
juce::AudioBuffer<float> 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<uint8_t, 128> 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<int>(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;
}
@@ -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"
@@ -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
@@ -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"
+2 -1
View File
@@ -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
+31
View File
@@ -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)
+1
View File
@@ -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',
+52
View File
@@ -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<typeof setInterval> | 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<typeof buildTrayMenuModel>): 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<typeof buildTrayMenuModel>): 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()
})
+1
View File
@@ -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)}.`)
+458
View File
@@ -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<DawBridgeHelloPayload>
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<ClientState>()
private sources = new Map<string, ClientState>()
private selectedSourceId: string | null = null
private available = false
private unavailableReason: string | null = 'The DAW bridge listener has not started.'
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
private audioBatchTimer: ReturnType<typeof setTimeout> | 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<DawBridgeSnapshot> {
if (this.server) return this.getSnapshot()
const server = createServer((socket) => this.acceptClient(socket))
this.server = server
server.maxConnections = MAX_CONNECTIONS
await new Promise<void>((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())
}
}
+11 -1
View File
@@ -68,6 +68,7 @@ export function normalizeTrayRendererState(value: unknown): TrayRendererState {
const candidate = value as Partial<TrayRendererState>
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,
+5
View File
@@ -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
}
+5
View File
@@ -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.',
},
}
}
+33 -1
View File
@@ -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<WindowBackgroundSnapshot>,
setWindowBackground: (state: WindowBackgroundState) => ipcRenderer.invoke('window:set-background', state) as Promise<WindowBackgroundSnapshot>,
isCursorInsideWindow: () => ipcRenderer.invoke('window:is-cursor-inside') as Promise<boolean>,
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<DawBridgeSnapshot>,
selectSource: (sourceId: string | null) => ipcRenderer.invoke(
'daw-bridge:select-source',
sourceId,
) as Promise<DawBridgeSnapshot>,
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<void>,
+245 -1
View File
@@ -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<void> {
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<void> {
this.active = false
if (typeof window !== 'undefined' && window.electronAPI?.dawBridge) {
this.snapshot = await window.electronAPI.dawBridge.selectSource(null)
}
this.emitStatus()
}
async listSources(): Promise<CaptureSourceDescriptor[]> {
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<void> {
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<CaptureBackendSupport> | 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<void> {
this.captureMode = 'daw'
await this.start()
}
async start(deviceId?: string, options: CaptureStartOptions = {}): Promise<void> {
if (deviceId) {
this.selectedDeviceId = deviceId
@@ -712,13 +888,18 @@ class AudioCapture {
const requestedBackend = this.captureMode === 'device'
? this.deviceInputBackend
: this.captureMode === 'daw'
? this.dawBridgeBackend
: this.nativeBackend
const requestedDeviceId = this.captureMode === 'device'
? this.selectedDeviceId ?? undefined
: 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)
+39 -12
View File
@@ -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 {
+46 -4
View File
@@ -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,17 +256,31 @@ 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'
: isCapturing
? 'Capturing'
: 'Idle'
const trimPercent = Math.min(100, Math.max(0, ((inputGainDb + 12) / 24) * 100))
@@ -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):
</option>
))}
</optgroup>
<optgroup label="DAW Bridges">
{captureMode === 'daw'
&& selectedDawSourceId
&& !selectedDawLiveSourceId ? (
<option value={`daw:${selectedDawSourceId}`}>
Waiting for selected bridge…
</option>
) : null}
{dawSources.length === 0 && captureMode !== 'daw' ? (
<option value="daw:" disabled>No bridges connected</option>
) : null}
{dawSources.map((source) => (
<option key={source.id} value={`daw:${source.id}`}>
{source.label}
</option>
))}
</optgroup>
</ThemedSelect>
<div className={`settings-status-pill is-${captureStatus}`.trim()}>
+3
View File
@@ -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':
@@ -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,
}
}
@@ -608,6 +608,16 @@ export default function ScopeSettingsSection({
<option value="mono">Solid</option>
</SelectControl>
<SelectControl
label="Timeline"
value={current.timelineUnit}
onChange={(value) => onUpdate('spectrogram', { timelineUnit: value as ScopeSettings['spectrogram']['timelineUnit'] })}
>
<option value="off">Off</option>
<option value="bars-beats">Bars + Beats</option>
<option value="seconds">Seconds</option>
</SelectControl>
<ToggleGroup label="Overlay">
<ToggleChip
label="Frequency Grid"
@@ -743,6 +753,16 @@ export default function ScopeSettingsSection({
/>
</ToggleGroup>
<SelectControl
label="Timeline"
value={current.timelineUnit}
onChange={(value) => onUpdate('waveform', { timelineUnit: value as ScopeSettings['waveform']['timelineUnit'] })}
>
<option value="off">Off</option>
<option value="bars-beats">Bars + Beats</option>
<option value="seconds">Seconds</option>
</SelectControl>
<RangeControl
label="Speed"
value={current.scrollSpeed}
@@ -47,6 +47,11 @@ async function handleTrayCommand(command: TrayRendererCommand): Promise<void> {
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
+2
View File
@@ -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<WindowBackgroundSnapshot>
isCursorInsideWindow: () => Promise<boolean>
getCaptureBackendSupport: () => Promise<CaptureBackendSupport>
dawBridge: DawBridgeRendererAPI
audioClips: {
startDrag: (payload: AudioClipDragPayload) => void
revealFolder: () => Promise<void>
+11 -2
View File
@@ -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 = []
+73 -5
View File
@@ -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<void>
refreshDevices: (options?: RefreshSourceOptions) => Promise<void>
refreshDawSources: () => Promise<void>
refreshBackendSupport: (options?: RefreshSourceOptions) => Promise<void>
selectSystemSource: (sourceId: string | null) => Promise<void>
selectDevice: (deviceId: string | null) => Promise<void>
selectDawSource: (sourceId: string) => Promise<void>
setCaptureMode: (mode: CaptureMode) => void
startCapture: (options?: StartCaptureOptions) => Promise<void>
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<AudioState>((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<AudioState>((set, get) => ({
currentState.selectedSystemSourceId,
currentState.selectedDeviceId,
currentState.rollingCaptureSeconds,
currentState.selectedDawSourceId,
))
audioCapture.setInputGain(nextInputGainDb)
set({ inputGainDb: nextInputGainDb })
@@ -323,6 +337,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
currentState.selectedSystemSourceId,
currentState.selectedDeviceId,
nextDuration,
currentState.selectedDawSourceId,
))
audioCapture.setRollingCaptureSeconds(nextDuration)
set({
@@ -389,6 +404,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
nextSelectedSystemSourceId,
currentState.selectedDeviceId,
currentState.rollingCaptureSeconds,
currentState.selectedDawSourceId,
))
}
@@ -458,6 +474,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
currentState.selectedSystemSourceId,
nextSelectedDeviceId,
currentState.rollingCaptureSeconds,
currentState.selectedDawSourceId,
))
}
@@ -503,6 +520,12 @@ export const useAudioStore = create<AudioState>((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<AudioState>((set, get) => ({
nextSelectedSystemSourceId,
currentState.selectedDeviceId,
currentState.rollingCaptureSeconds,
currentState.selectedDawSourceId,
))
set({
selectedSystemSourceId: nextSelectedSystemSourceId,
@@ -535,6 +559,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
currentState.selectedSystemSourceId,
deviceId,
currentState.rollingCaptureSeconds,
currentState.selectedDawSourceId,
))
set({
selectedDeviceId: deviceId,
@@ -544,6 +569,28 @@ export const useAudioStore = create<AudioState>((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<AudioState>((set, get) => ({
currentState.selectedSystemSourceId,
currentState.selectedDeviceId,
currentState.rollingCaptureSeconds,
currentState.selectedDawSourceId,
))
set({ captureMode: mode })
},
@@ -565,9 +613,10 @@ export const useAudioStore = create<AudioState>((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<void> => {
const message = buildSystemCaptureFallbackMessage(reason)
@@ -580,6 +629,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
currentState.selectedSystemSourceId,
null,
currentState.rollingCaptureSeconds,
currentState.selectedDawSourceId,
))
set({
selectedDeviceId: null,
@@ -601,17 +651,22 @@ export const useAudioStore = create<AudioState>((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 })
})
+9
View File
@@ -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);
+38 -10
View File
@@ -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<Spe
showGrid: overrides.showGrid ?? base.showGrid,
gridColor: overrides.gridColor ?? base.gridColor,
labelColor: overrides.labelColor ?? base.labelColor,
timelineUnit: overrides.timelineUnit ?? base.timelineUnit,
}
}
@@ -259,6 +264,7 @@ export class Spectrogram {
private lastNativeConfigKey: string | null = null
private unsubscribeSessionChange: (() => 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') {
+56
View File
@@ -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
}
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 {
+3
View File
@@ -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)),
}
@@ -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)
}
}
+11 -4
View File
@@ -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<Partial<PrismProfileFileV5>, 'version'> & { version?: unknown }
? raw as Omit<Partial<PrismProfileFileV6>, '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 {
+5 -1
View File
@@ -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
}
+106
View File
@@ -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<DawBridgeSnapshot>
selectSource: (sourceId: string | null) => Promise<DawBridgeSnapshot>
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'
+7 -2
View File
@@ -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: [],
}
+3
View File
@@ -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[]
+7 -3
View File
@@ -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<PrismProfileFileV5, 'version'> {
version: typeof PROFILE_FILE_VERSION
}
export type PrismProfileFile = PrismProfileFileV1 | PrismProfileFileV2 | PrismProfileFileV3 | PrismProfileFileV4 | PrismProfileFileV5 | PrismProfileFileV6
export interface ProfileLocalMetadata {
windowBounds?: WindowBounds
+5 -2
View File
@@ -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,
+20
View File
@@ -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<ReturnType<AudioRouter['getActiveVisualizerDemand']>> = []
+54
View File
@@ -30,6 +30,7 @@ function audioPreferences(overrides: Partial<PersistedAudioState> = {}): 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()
+5
View File
@@ -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.',
},
})
})
+178
View File
@@ -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<Socket> {
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<void> {
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()
}
})
+5
View File
@@ -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)
+99
View File
@@ -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()
}
})