mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-28 16:50:57 +02:00
prism bridge daw server
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)}.`)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+2
@@ -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>
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -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: [],
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']>> = []
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user