diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f3f495f..d1ce504 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,6 +57,32 @@ jobs: - name: ๐Ÿ“ฆ Install frontend dependencies run: pnpm install # change this to npm, pnpm or bun depending on which one you use. + # Build the Splicerr Bridge plugin from source so the shipped binaries + # provably match the auditable C++ in plugins/splicerr-daw-bridge. + - name: ๐ŸŽ›๏ธ Build Splicerr Bridge plugin (macOS) + if: startsWith(matrix.platform, 'macos') + run: | + cmake -B plugins/splicerr-daw-bridge/build -S plugins/splicerr-daw-bridge \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" + cmake --build plugins/splicerr-daw-bridge/build --config Release + artefacts="plugins/splicerr-daw-bridge/build/SplicerrBridge_artefacts/Release" + dest="src-tauri/resources/plugins" + rm -rf "$dest/Splicerr Bridge.component" "$dest/Splicerr Bridge.vst3" + cp -R "$artefacts/AU/Splicerr Bridge.component" "$dest/" + cp -R "$artefacts/VST3/Splicerr Bridge.vst3" "$dest/" + + - name: ๐ŸŽ›๏ธ Build Splicerr Bridge plugin (Windows) + if: matrix.platform == 'windows-latest' + shell: pwsh + run: | + cmake -B plugins/splicerr-daw-bridge/build -S plugins/splicerr-daw-bridge + cmake --build plugins/splicerr-daw-bridge/build --config Release + $artefacts = "plugins/splicerr-daw-bridge/build/SplicerrBridge_artefacts/Release" + $dest = "src-tauri/resources/plugins" + Remove-Item -Recurse -Force "$dest/Splicerr Bridge.vst3" -ErrorAction SilentlyContinue + Copy-Item -Recurse "$artefacts/VST3/Splicerr Bridge.vst3" "$dest/" + - uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -64,18 +90,51 @@ jobs: tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version. releaseName: 'Splicerr v__VERSION__' releaseBody: | - ## Splicerr v1.0.13 โ€” community fork + ## Splicerr v1.15.0 โ€” DAW Bridge + UI refresh Temporary fork of [Robert-K/splicerr](https://github.com/Robert-K/splicerr) - that restores functionality and adds bonus features. + that restores functionality and adds DAW bridge sync. - ### ๐Ÿ› Fixes - - **No more downloading samples on hover.** Samples were being prepared - (descrambled + written to disk) as soon as you hovered a row, showing a - spinner. Preparation now happens only on press, so a sample is downloaded - just when you play or drag it. + ### New + - **New "dre4moff" theme** โ€” an optional macOS-style dark glass interface + (updated sidebar, search, filters, result rows, waveform surface, floating + player). It's opt-in: the classic **Dark** and **Light** themes are the + defaults and look as they always did. Pick it under Settings โ†’ Theme. + - **Splicerr Bridge plugin** for DAW sync โ€” AU + VST3 on macOS, VST3 on Windows. + - **Bridge installer in Settings** with Install/Reinstall/Uninstall actions. + Installs into the system plug-in folders so any DAW finds it; this asks + for administrator rights (UAC on Windows, your password on macOS). + - **Clickable install path** in Settings โ€” reveals the installed plugin in + your file manager. + - DAW transport sync for play/stop, BPM, bar position, loops, and one-shot triggers. + - DAW-mode drag-and-drop for the currently selected sample stretched to the host BPM. - ### ๐Ÿงฐ Included from previous releases + ### Fixes + - DAW bridge audio rendering now runs in a **Web Worker**, so long + pitch-shifted samples no longer freeze the UI. + - Pitch shift and tempo stretch run in one combined SoundTouch pass; renders + are abortable and cancel stale jobs when you switch samples quickly. + - Lower memory pressure while browsing โ€” capped preview blobs, chunked WAV + writes, isolated AudioContexts closed after each render, and cached renders + no longer read full WAVs back into the WebView. + - Cold audio/waveform downloads prefer a direct WebView fetch (with Tauri HTTP + fallback), avoiding large transfers through IPC before rendering. + - DAW-mode play buttons start on the first click; selecting a sample re-enables + bridge playback; changing transpose re-renders the synced sample instead of + the muted local preview. + - The app preview is muted automatically while connected to the DAW. + - Bridge audio renders directly inside the DAW plugin; waveform and player + progress follow the plugin's real sample cursor. + - One-shot waveform progress resets between bar triggers. + - Waveforms/rows no longer drop out while scrolling results (duplicate + samples returned by Splice pagination are de-duplicated). + - (macOS) Closing the window hides it and reopening brings it back. + + ### ๐Ÿ™ Credits + - The dre4moff theme and DAW-mode stability work in this release come from + [@dre4moff](https://github.com/dre4moff)'s pull request โ€” thank you! + + ### Included from previous releases - **Collections** โ€” create local collections, Likes, tag filtering, export to `.zip`, and toast notifications. - **Transpose** โ€” shift samples by key or by semitones, tempo-preserving, @@ -83,10 +142,15 @@ jobs: - Drag-and-drop crash fix on macOS Sequoia and the Cloudflare 403 fix (requests routed through a real browser engine). + ### ๐Ÿ”’ Build & transparency + - The Splicerr Bridge plugin is **built from source in CI**, not shipped as a + prebuilt binary โ€” so the bundled plugin matches the public C++ in + `plugins/splicerr-daw-bridge`. + ### ๐Ÿ“ฆ Downloads - **Windows** โ€” `.exe` (installer) or `.msi` - **macOS** โ€” `.dmg` (Apple Silicon & Intel) - - **Linux** โ€” `.AppImage`, `.deb`, `.rpm` + - **Linux** โ€” `.AppImage`, `.deb`, `.rpm` (DAW bridge not included) > Once the headers fix is merged upstream, please switch back to the original repo. > All credit to the original authors โ€” please โญ the original diff --git a/.gitignore b/.gitignore index 6635cf5..c219ce3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,12 @@ node_modules /build /.svelte-kit /package +plugins/**/build + +# Bridge plugin bundles are built from source (in CI or locally), never committed. +src-tauri/resources/plugins/* +!src-tauri/resources/plugins/.gitkeep +!src-tauri/resources/plugins/README.md .env .env.* !.env.example diff --git a/RELEASE_NOTES_1.15.0.md b/RELEASE_NOTES_1.15.0.md new file mode 100644 index 0000000..f9d8932 --- /dev/null +++ b/RELEASE_NOTES_1.15.0.md @@ -0,0 +1,55 @@ +# Splicerr v1.15.0 โ€” DAW Bridge + UI refresh + +Temporary fork of [Robert-K/splicerr](https://github.com/Robert-K/splicerr) that +restores functionality and adds DAW bridge sync. + +## New + +- **New "dre4moff" theme** โ€” an optional macOS-style dark glass interface (updated + sidebar, search, filters, result rows, waveform surface, floating player). It's + opt-in: the classic **Dark** and **Light** themes are the defaults and look as they + always did. Pick it under Settings โ†’ Theme. +- **Splicerr Bridge plugin** for DAW sync โ€” AU + VST3 on macOS, VST3 on Windows. +- **Bridge installer in Settings** with Install/Reinstall/Uninstall actions. + Installs into the system plug-in folders so any DAW finds it; this asks for + administrator rights (UAC on Windows, your password on macOS). +- **Clickable install path** in Settings โ€” reveals the installed plugin in your + file manager. +- DAW transport sync for play/stop, BPM, bar position, loops, and one-shot triggers. +- DAW-mode drag-and-drop for the currently selected sample stretched to the host BPM. + +## Fixes + +- DAW bridge audio rendering now runs in a **Web Worker**, so long pitch-shifted + samples no longer freeze the UI. +- Pitch shift and tempo stretch run in one combined SoundTouch pass; renders are + abortable and cancel stale jobs when you switch samples quickly. +- Lower memory pressure while browsing โ€” capped preview blobs, chunked WAV writes, + isolated AudioContexts closed after each render, and cached renders no longer read + full WAVs back into the WebView. +- Cold audio/waveform downloads prefer a direct WebView fetch (with Tauri HTTP + fallback), avoiding large transfers through IPC before rendering. +- DAW-mode play buttons start on the first click; selecting a sample re-enables + bridge playback; changing transpose re-renders the synced sample instead of the + muted local preview. +- The app preview is muted automatically while connected to the DAW. +- Bridge audio renders directly inside the DAW plugin; waveform and player progress + follow the plugin's real sample cursor. +- One-shot waveform progress resets between bar triggers. +- Waveforms/rows no longer drop out while scrolling results (duplicate samples + returned by Splice pagination are de-duplicated). +- (macOS) Closing the window hides it and reopening brings it back. + +## Build & transparency + +- The Splicerr Bridge plugin is **built from source in CI**, not shipped as a + prebuilt binary โ€” so the bundled plugin matches the public C++ in + `plugins/splicerr-daw-bridge`. + +## Credits + +- The dre4moff theme and DAW-mode stability work in this release come from + [@dre4moff](https://github.com/dre4moff)'s pull request โ€” thank you! +- All credit to the original authors โ€” please โญ the original + [splicedd](https://github.com/ascpixi/splicedd) and + [splicerr](https://github.com/Robert-K/splicerr). diff --git a/package.json b/package.json index f16e363..5e47d4f 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "splicerr", - "version": "1.0.13", + "version": "1.15.0", "description": "", "type": "module", "scripts": { "dev": "vite dev", "build": "vite build", + "test:render-stress": "node --expose-gc scripts/stress-transpose-render.mjs", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", diff --git a/plugins/splicerr-daw-bridge/CMakeLists.txt b/plugins/splicerr-daw-bridge/CMakeLists.txt new file mode 100644 index 0000000..d11d8de --- /dev/null +++ b/plugins/splicerr-daw-bridge/CMakeLists.txt @@ -0,0 +1,53 @@ +cmake_minimum_required(VERSION 3.22) + +project(SplicerrBridge VERSION 0.1.0) + +include(FetchContent) + +FetchContent_Declare( + JUCE + GIT_REPOSITORY https://github.com/juce-framework/JUCE.git + GIT_TAG 8.0.13 + GIT_SHALLOW TRUE +) + +FetchContent_MakeAvailable(JUCE) + +juce_add_plugin(SplicerrBridge + COMPANY_NAME "Splicerr" + BUNDLE_ID "de.kosro.splicerr.bridge" + PLUGIN_MANUFACTURER_CODE Expr + PLUGIN_CODE SpBr + FORMATS AU VST3 Standalone + PRODUCT_NAME "Splicerr Bridge" + IS_SYNTH TRUE + NEEDS_MIDI_INPUT TRUE + NEEDS_MIDI_OUTPUT FALSE + IS_MIDI_EFFECT FALSE + COPY_PLUGIN_AFTER_BUILD FALSE +) + +target_sources(SplicerrBridge + PRIVATE + Source/PluginEditor.cpp + Source/PluginProcessor.cpp +) + +target_compile_features(SplicerrBridge PRIVATE cxx_std_17) + +target_compile_definitions(SplicerrBridge + PUBLIC + JUCE_WEB_BROWSER=0 + JUCE_USE_CURL=0 + JUCE_VST3_CAN_REPLACE_VST2=0 +) + +target_link_libraries(SplicerrBridge + PRIVATE + juce::juce_audio_formats + juce::juce_audio_utils + PUBLIC + juce::juce_recommended_config_flags + juce::juce_recommended_lto_flags + juce::juce_recommended_warning_flags +) diff --git a/plugins/splicerr-daw-bridge/README.md b/plugins/splicerr-daw-bridge/README.md new file mode 100644 index 0000000..1ec7b6e --- /dev/null +++ b/plugins/splicerr-daw-bridge/README.md @@ -0,0 +1,63 @@ +# Splicerr Bridge + +JUCE DAW bridge plugin for Splicerr. + +The plugin reads the host playhead and sends UDP JSON packets to the Splicerr +app on `127.0.0.1:37651`. Splicerr listens automatically while the app is open +and uses those packets to sync preview play/stop, playback BPM, and the first +beat of each 4-bar phrase. + +For DAW audio output, Splicerr renders the currently selected sample as a WAV +file and sends the file path to the plugin on the dynamic control port reported +in each sync packet. The plugin loads that WAV and renders it directly in the +DAW audio callback, locked to the host PPQ position. Loops follow the current +4-bar phrase phase; one-shot samples are detected by duration (up to 3 seconds) +and trigger once at the start of each bar, then play to completion. + +## Build + +```sh +cmake -S plugins/splicerr-daw-bridge -B plugins/splicerr-daw-bridge/build -DCMAKE_BUILD_TYPE=Release +cmake --build plugins/splicerr-daw-bridge/build --config Release +``` + +The CMake configure step downloads JUCE `8.0.13` with `FetchContent`. + +On macOS, the build creates AU, VST3, and Standalone targets. With +`COPY_PLUGIN_AFTER_BUILD` enabled, JUCE also copies plugin bundles to the +standard user plugin folders when possible. + +## Protocol + +Example packet: + +```json +{ + "source": "splicerr-bridge", + "version": 1, + "playing": true, + "bpm": 128.0, + "ppqPosition": 42.0, + "timeInSeconds": 19.25, + "packetSentAtMs": 1781808000000, + "audioPort": 49231, + "timeSignatureNumerator": 4, + "timeSignatureDenominator": 4, + "sampleRate": 48000.0 +} +``` + +Sample load packet sent by the app to `audioPort`: + +```json +{ + "source": "splicerr-app", + "version": 1, + "uuid": "sample-uuid", + "path": "/absolute/path/to/rendered.wav", + "oneShot": false, + "durationMs": 8000, + "sourceBpm": 120, + "renderedBpm": 128 +} +``` diff --git a/plugins/splicerr-daw-bridge/Source/PluginEditor.cpp b/plugins/splicerr-daw-bridge/Source/PluginEditor.cpp new file mode 100644 index 0000000..eec23b6 --- /dev/null +++ b/plugins/splicerr-daw-bridge/Source/PluginEditor.cpp @@ -0,0 +1,101 @@ +#include "PluginEditor.h" + +namespace +{ +void configureLabel(juce::Label& label, float fontSize, juce::Justification justification) +{ + label.setFont(juce::FontOptions(fontSize)); + label.setJustificationType(justification); + label.setColour(juce::Label::textColourId, juce::Colours::white); +} +} // namespace + +SplicerrBridgeAudioProcessorEditor::SplicerrBridgeAudioProcessorEditor( + SplicerrBridgeAudioProcessor& ownerProcessor) + : AudioProcessorEditor(&ownerProcessor), + audioProcessor(ownerProcessor) +{ + configureLabel(titleLabel, 22.0f, juce::Justification::centredLeft); + titleLabel.setText("Splicerr Bridge", juce::dontSendNotification); + addAndMakeVisible(titleLabel); + + configureLabel(statusLabel, 15.0f, juce::Justification::centredLeft); + addAndMakeVisible(statusLabel); + + configureLabel(tempoLabel, 15.0f, juce::Justification::centredLeft); + addAndMakeVisible(tempoLabel); + + configureLabel(audioLabel, 15.0f, juce::Justification::centredLeft); + addAndMakeVisible(audioLabel); + + configureLabel(hintLabel, 13.0f, juce::Justification::centredLeft); + hintLabel.setColour(juce::Label::textColourId, juce::Colours::lightgrey); + hintLabel.setText("Pick a sample in Splicerr. The bridge loads it and plays it on the DAW grid.", + juce::dontSendNotification); + addAndMakeVisible(hintLabel); + + setSize(420, 205); + updateLabels(); + startTimerHz(8); +} + +SplicerrBridgeAudioProcessorEditor::~SplicerrBridgeAudioProcessorEditor() +{ + stopTimer(); +} + +void SplicerrBridgeAudioProcessorEditor::paint(juce::Graphics& g) +{ + g.fillAll(juce::Colour(0xff15171c)); + + auto bounds = getLocalBounds().toFloat().reduced(16.0f); + g.setColour(juce::Colour(0xff2d333b)); + g.drawRoundedRectangle(bounds, 8.0f, 1.0f); + + g.setColour(audioProcessor.hasSentToAppRecently() + ? juce::Colour(0xff43d17a) + : juce::Colour(0xfff0b354)); + g.fillEllipse(24.0f, 69.0f, 10.0f, 10.0f); + + g.setColour(audioProcessor.hasLoadedSample() + ? juce::Colour(0xff43d17a) + : juce::Colour(0xfff0b354)); + g.fillEllipse(24.0f, 125.0f, 10.0f, 10.0f); +} + +void SplicerrBridgeAudioProcessorEditor::resized() +{ + auto bounds = getLocalBounds().reduced(24, 18); + titleLabel.setBounds(bounds.removeFromTop(34)); + bounds.removeFromTop(10); + statusLabel.setBounds(bounds.removeFromTop(28).withTrimmedLeft(18)); + tempoLabel.setBounds(bounds.removeFromTop(28)); + audioLabel.setBounds(bounds.removeFromTop(28).withTrimmedLeft(18)); + bounds.removeFromTop(8); + hintLabel.setBounds(bounds); +} + +void SplicerrBridgeAudioProcessorEditor::timerCallback() +{ + updateLabels(); + repaint(); +} + +void SplicerrBridgeAudioProcessorEditor::updateLabels() +{ + statusLabel.setText(audioProcessor.isHostPlaying() + ? "Host transport: playing" + : "Host transport: stopped", + juce::dontSendNotification); + + const auto bpm = audioProcessor.getHostBpm(); + tempoLabel.setText(bpm > 0.0 + ? "Host BPM: " + juce::String(bpm, 2) + : "Host BPM: waiting for DAW", + juce::dontSendNotification); + + audioLabel.setText(audioProcessor.hasLoadedSample() + ? "Sample: loaded in bridge" + : "Sample: waiting for Splicerr", + juce::dontSendNotification); +} diff --git a/plugins/splicerr-daw-bridge/Source/PluginEditor.h b/plugins/splicerr-daw-bridge/Source/PluginEditor.h new file mode 100644 index 0000000..bd59ae3 --- /dev/null +++ b/plugins/splicerr-daw-bridge/Source/PluginEditor.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include "PluginProcessor.h" + +class SplicerrBridgeAudioProcessorEditor final : public juce::AudioProcessorEditor, + private juce::Timer +{ +public: + explicit SplicerrBridgeAudioProcessorEditor(SplicerrBridgeAudioProcessor&); + ~SplicerrBridgeAudioProcessorEditor() override; + + void paint(juce::Graphics&) override; + void resized() override; + +private: + void timerCallback() override; + void updateLabels(); + + SplicerrBridgeAudioProcessor& audioProcessor; + juce::Label titleLabel; + juce::Label statusLabel; + juce::Label tempoLabel; + juce::Label audioLabel; + juce::Label hintLabel; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SplicerrBridgeAudioProcessorEditor) +}; diff --git a/plugins/splicerr-daw-bridge/Source/PluginProcessor.cpp b/plugins/splicerr-daw-bridge/Source/PluginProcessor.cpp new file mode 100644 index 0000000..ff71b5b --- /dev/null +++ b/plugins/splicerr-daw-bridge/Source/PluginProcessor.cpp @@ -0,0 +1,708 @@ +#include "PluginProcessor.h" +#include "PluginEditor.h" + +#include +#include +#include +#include + +namespace +{ +constexpr int audioPacketHeaderBytes = 16; + +double optionalOrZero(juce::Optional value) +{ + return value.hasValue() ? *value : 0.0; +} + +juce::String jsonBool(bool value) +{ + return value ? "true" : "false"; +} + +uint16_t readU16LE(const char* data) +{ + return static_cast(static_cast(data[0])) + | static_cast(static_cast(data[1]) << 8); +} + +float readF32LE(const char* data) +{ + uint32_t value = static_cast(static_cast(data[0])) + | (static_cast(static_cast(data[1])) << 8) + | (static_cast(static_cast(data[2])) << 16) + | (static_cast(static_cast(data[3])) << 24); + + float sample = 0.0f; + std::memcpy(&sample, &value, sizeof(sample)); + return sample; +} + +double positiveModulo(double value, double divisor) +{ + if (divisor <= 0.0) + return 0.0; + + const auto result = std::fmod(value, divisor); + return result < 0.0 ? result + divisor : result; +} + +template +FloatType readBufferAt(const juce::AudioBuffer& buffer, + int channel, + double position) +{ + const auto numFrames = buffer.getNumSamples(); + const auto numChannels = buffer.getNumChannels(); + + if (numFrames <= 0 || numChannels <= 0) + return static_cast(0); + + const auto sourceChannel = juce::jlimit(0, numChannels - 1, channel); + const auto boundedPosition = positiveModulo(position, static_cast(numFrames)); + const auto index = juce::jlimit(0, numFrames - 1, static_cast(std::floor(boundedPosition))); + const auto nextIndex = juce::jmin(index + 1, numFrames - 1); + const auto fraction = static_cast(boundedPosition - std::floor(boundedPosition)); + const auto* data = buffer.getReadPointer(sourceChannel); + + return static_cast(data[index] + (data[nextIndex] - data[index]) * fraction); +} +} // namespace + +SplicerrBridgeAudioProcessor::SplicerrBridgeAudioProcessor() + : AudioProcessor(BusesProperties() + .withInput("Input", juce::AudioChannelSet::stereo(), false) + .withOutput("Output", juce::AudioChannelSet::stereo(), true)), + socket(false), + audioSocket(false) +{ + formatManager.registerBasicFormats(); + startAudioReceiver(); + startTimerHz(timerHz); +} + +SplicerrBridgeAudioProcessor::~SplicerrBridgeAudioProcessor() +{ + stopTimer(); + stopAudioReceiver(); +} + +void SplicerrBridgeAudioProcessor::prepareToPlay(double sampleRate, int) +{ + currentSampleRate.store(sampleRate); + sendSnapshot(true); +} + +void SplicerrBridgeAudioProcessor::releaseResources() {} + +bool SplicerrBridgeAudioProcessor::isBusesLayoutSupported(const BusesLayout& layouts) const +{ + const auto& mainIn = layouts.getMainInputChannelSet(); + const auto& mainOut = layouts.getMainOutputChannelSet(); + const auto outputSupported = + mainOut == juce::AudioChannelSet::mono() + || mainOut == juce::AudioChannelSet::stereo(); + const auto inputSupported = + mainIn.isDisabled() + || mainIn == juce::AudioChannelSet::mono() + || mainIn == juce::AudioChannelSet::stereo(); + + return outputSupported && inputSupported; +} + +template +void SplicerrBridgeAudioProcessor::processTransport(juce::AudioBuffer& buffer) +{ + juce::ignoreUnused(buffer); + + if (auto* playHead = getPlayHead()) + { + if (auto position = playHead->getPosition()) + { + hostPlaying.store(position->getIsPlaying()); + hostBpm.store(optionalOrZero(position->getBpm())); + hostPpqPosition.store(optionalOrZero(position->getPpqPosition())); + hostTimeInSeconds.store(optionalOrZero(position->getTimeInSeconds())); + hostLooping.store(position->getIsLooping()); + + if (auto signature = position->getTimeSignature()) + { + hostTimeSignatureNumerator.store(signature->numerator); + hostTimeSignatureDenominator.store(signature->denominator); + } + + if (auto loop = position->getLoopPoints()) + { + hostLoopStartPpq.store(loop->ppqStart); + hostLoopEndPpq.store(loop->ppqEnd); + } + } + } +} + +template +void passAudioThrough(juce::AudioProcessor& processor, juce::AudioBuffer& buffer) +{ + const auto totalInputChannels = processor.getTotalNumInputChannels(); + const auto totalOutputChannels = processor.getTotalNumOutputChannels(); + + if (totalInputChannels == 0) + { + buffer.clear(); + return; + } + + for (auto channel = totalInputChannels; channel < totalOutputChannels; ++channel) + buffer.clear(channel, 0, buffer.getNumSamples()); +} + +void SplicerrBridgeAudioProcessor::startAudioReceiver() +{ + if (audioReceiverRunning.load()) + return; + + if (! audioSocket.bindToPort(0, "127.0.0.1")) + return; + + audioUdpPort.store(audioSocket.getBoundPort()); + audioReceiverRunning.store(true); + audioReceiverThread = std::thread([this] + { + std::vector packet(65536); + + while (audioReceiverRunning.load()) + { + if (audioSocket.waitUntilReady(true, 100) <= 0) + continue; + + const auto bytesRead = audioSocket.read(packet.data(), static_cast(packet.size()), false); + if (bytesRead < 4) + continue; + + if (std::memcmp(packet.data(), "SPSM", 4) == 0) + { + handleSamplePacket(packet.data() + 4, bytesRead - 4); + continue; + } + + if (std::memcmp(packet.data(), "SPCT", 4) == 0) + { + handleControlPacket(packet.data() + 4, bytesRead - 4); + continue; + } + + if (bytesRead < audioPacketHeaderBytes) + continue; + + if (std::memcmp(packet.data(), "SPAU", 4) != 0) + continue; + + const auto channels = static_cast(packet[12]); + const auto frames = static_cast(readU16LE(packet.data() + 14)); + if (channels == 0 || channels > 2 || frames <= 0) + continue; + + const auto expectedBytes = audioPacketHeaderBytes + frames * channels * static_cast(sizeof(float)); + if (bytesRead < expectedBytes) + continue; + + std::vector stereo(static_cast(frames * streamedAudioChannels), 0.0f); + const auto* sampleData = packet.data() + audioPacketHeaderBytes; + for (auto frame = 0; frame < frames; ++frame) + { + const auto left = readF32LE(sampleData + (frame * channels) * 4); + const auto right = channels > 1 + ? readF32LE(sampleData + (frame * channels + 1) * 4) + : left; + stereo[static_cast(frame * 2)] = left; + stereo[static_cast(frame * 2 + 1)] = right; + } + + pushAudioSamples(stereo.data(), frames); + lastAudioPacketMs.store(static_cast(juce::Time::getMillisecondCounter())); + } + }); +} + +void SplicerrBridgeAudioProcessor::stopAudioReceiver() +{ + audioReceiverRunning.store(false); + audioSocket.shutdown(); + + if (audioReceiverThread.joinable()) + audioReceiverThread.join(); +} + +void SplicerrBridgeAudioProcessor::pushAudioSamples(const float* samples, int frames) +{ + std::lock_guard lock(audioMutex); + + const auto maxSamples = maxQueuedAudioFrames * streamedAudioChannels; + while (static_cast(audioQueue.size()) + frames * streamedAudioChannels > maxSamples) + { + for (auto i = 0; i < streamedAudioChannels && ! audioQueue.empty(); ++i) + audioQueue.pop_front(); + } + + for (auto i = 0; i < frames * streamedAudioChannels; ++i) + audioQueue.push_back(samples[i]); +} + +void SplicerrBridgeAudioProcessor::handleSamplePacket(const char* data, int bytes) +{ + const auto payload = juce::String::fromUTF8(data, bytes); + const auto parsed = juce::JSON::parse(payload); + + if (! parsed.isObject()) + return; + + const auto* object = parsed.getDynamicObject(); + if (object == nullptr) + return; + + if (object->getProperty("source").toString() != "splicerr-app") + return; + + const auto path = object->getProperty("path").toString(); + const auto uuid = object->getProperty("uuid").toString(); + const auto oneShot = static_cast(object->getProperty("oneShot")); + const auto durationMs = static_cast(object->getProperty("durationMs")); + + loadSampleFromPath(path, uuid, oneShot, durationMs); +} + +void SplicerrBridgeAudioProcessor::handleControlPacket(const char* data, int bytes) +{ + const auto payload = juce::String::fromUTF8(data, bytes); + const auto parsed = juce::JSON::parse(payload); + + if (! parsed.isObject()) + return; + + const auto* object = parsed.getDynamicObject(); + if (object == nullptr) + return; + + if (object->getProperty("source").toString() != "splicerr-app") + return; + + if (object->hasProperty("playbackEnabled")) + appPlaybackEnabled.store(static_cast(object->getProperty("playbackEnabled"))); +} + +void SplicerrBridgeAudioProcessor::loadSampleFromPath(const juce::String& path, + const juce::String& uuid, + bool oneShot, + double durationMs) +{ + const juce::File file(path); + std::unique_ptr reader(formatManager.createReaderFor(file)); + + if (reader == nullptr || reader->lengthInSamples <= 0) + { + std::lock_guard lock(sampleMutex); + loadedSample.reset(); + sampleLoaded.store(false); + loadedSampleDurationSeconds.store(0.0); + samplePositionSeconds.store(0.0); + sampleActive.store(false); + sampleRevision.fetch_add(1); + return; + } + + const auto channels = juce::jlimit(1, 2, static_cast(reader->numChannels)); + const auto frames = static_cast(juce::jmin( + reader->lengthInSamples, + static_cast(std::numeric_limits::max()))); + auto sample = std::make_shared(); + sample->buffer.setSize(channels, frames); + sample->sampleRate = reader->sampleRate; + sample->oneShot = oneShot; + sample->uuid = uuid; + sample->durationMs = durationMs; + + reader->read(&sample->buffer, 0, frames, 0, true, true); + + { + std::lock_guard lock(sampleMutex); + loadedSample = sample; + } + + sampleLoaded.store(true); + loadedSampleDurationSeconds.store(static_cast(frames) / sample->sampleRate); + samplePositionSeconds.store(0.0); + sampleActive.store(false); + lastSampleLoadMs.store(static_cast(juce::Time::getMillisecondCounter())); + sampleRevision.fetch_add(1); + sendSnapshot(true); +} + +template +void SplicerrBridgeAudioProcessor::mixStreamedAudio(juce::AudioBuffer& buffer) +{ + const auto outputChannels = getTotalNumOutputChannels(); + if (outputChannels <= 0) + return; + + const auto frames = buffer.getNumSamples(); + std::vector local(static_cast(frames * streamedAudioChannels), 0.0f); + auto framesRead = 0; + + { + std::lock_guard lock(audioMutex); + framesRead = std::min(frames, static_cast(audioQueue.size()) / streamedAudioChannels); + + for (auto i = 0; i < framesRead * streamedAudioChannels; ++i) + { + local[static_cast(i)] = audioQueue.front(); + audioQueue.pop_front(); + } + } + + for (auto frame = 0; frame < framesRead; ++frame) + { + const auto left = static_cast(local[static_cast(frame * 2)]); + const auto right = static_cast(local[static_cast(frame * 2 + 1)]); + + buffer.addSample(0, frame, left); + if (outputChannels > 1) + buffer.addSample(1, frame, right); + } +} + +template +void SplicerrBridgeAudioProcessor::renderLoadedSample(juce::AudioBuffer& buffer) +{ + std::shared_ptr sample; + { + std::lock_guard lock(sampleMutex); + sample = loadedSample; + } + + if (sample == nullptr || sample->buffer.getNumSamples() <= 0) + return; + + const auto outputChannels = getTotalNumOutputChannels(); + const auto outputFrames = buffer.getNumSamples(); + const auto hostRate = currentSampleRate.load(); + const auto bpm = hostBpm.load(); + const auto ppqStart = hostPpqPosition.load(); + const auto numerator = hostTimeSignatureNumerator.load(); + const auto denominator = hostTimeSignatureDenominator.load(); + + if (outputChannels <= 0 || outputFrames <= 0 || hostRate <= 0.0 || bpm <= 0.0) + return; + + const auto revision = sampleRevision.load(); + if (renderedSampleRevision != revision) + { + renderedSampleRevision = revision; + lastOneShotTriggerBar = std::numeric_limits::min(); + oneShotActive = false; + oneShotReadPosition = 0.0; + loopReadPosition = 0.0; + samplePositionSeconds.store(0.0); + sampleActive.store(false); + lastRenderedPpq = std::numeric_limits::quiet_NaN(); + lastRenderedHostTime = std::numeric_limits::quiet_NaN(); + lastOneShotTriggerPpq = -std::numeric_limits::infinity(); + } + + if (! hostPlaying.load() || ! appPlaybackEnabled.load()) + { + oneShotActive = false; + oneShotReadPosition = 0.0; + lastOneShotTriggerBar = std::numeric_limits::min(); + loopReadPosition = 0.0; + samplePositionSeconds.store(0.0); + sampleActive.store(false); + lastRenderedPpq = std::numeric_limits::quiet_NaN(); + lastRenderedHostTime = std::numeric_limits::quiet_NaN(); + lastOneShotTriggerPpq = -std::numeric_limits::infinity(); + return; + } + + const auto beatsPerBar = juce::jmax(1.0, static_cast(numerator) * (4.0 / static_cast(denominator))); + const auto beatStep = (bpm / 60.0) / hostRate; + const auto sourceStep = sample->sampleRate / hostRate; + const auto hostTimeStart = hostTimeInSeconds.load(); + const auto loopActive = hostLooping.load(); + const auto loopStart = hostLoopStartPpq.load(); + const auto loopEnd = hostLoopEndPpq.load(); + const auto ppqMovedBack = + std::isfinite(lastRenderedPpq) && ppqStart < lastRenderedPpq - beatStep * 2.0; + const auto crossedHostLoopBoundary = + loopActive + && loopEnd > loopStart + && std::isfinite(lastRenderedPpq) + && lastRenderedPpq >= loopEnd - beatStep * 2.0 + && ppqStart <= loopStart + beatStep * 4.0; + const auto hostTimeMovedBack = + std::isfinite(lastRenderedHostTime) + && hostTimeStart > 0.0 + && hostTimeStart < lastRenderedHostTime - (2.0 / hostRate); + const auto hostTimeUnavailable = hostTimeStart <= 0.0 || ! std::isfinite(hostTimeStart); + const auto transportWrapped = + crossedHostLoopBoundary + || (ppqMovedBack && (hostTimeMovedBack || hostTimeUnavailable)); + + if (transportWrapped) + { + lastOneShotTriggerBar = std::numeric_limits::min(); + lastOneShotTriggerPpq = -std::numeric_limits::infinity(); + oneShotActive = false; + oneShotReadPosition = 0.0; + loopReadPosition = 0.0; + samplePositionSeconds.store(0.0); + sampleActive.store(false); + lastRenderedHostTime = std::numeric_limits::quiet_NaN(); + } + + if (sample->oneShot) + { + const auto triggerWindowBeats = juce::jmax(beatStep * 2.0, 0.0001); + const auto minRetriggerDistanceBeats = beatsPerBar * 0.5; + + for (auto frame = 0; frame < outputFrames; ++frame) + { + const auto ppq = ppqStart + static_cast(frame) * beatStep; + const auto barIndex = static_cast(std::floor(ppq / beatsPerBar)); + const auto positionInBar = positiveModulo(ppq, beatsPerBar); + const auto farEnoughFromPreviousTrigger = + ppq < lastOneShotTriggerPpq + || ppq - lastOneShotTriggerPpq >= minRetriggerDistanceBeats; + + if (! oneShotActive + && barIndex != lastOneShotTriggerBar + && positionInBar <= triggerWindowBeats + && farEnoughFromPreviousTrigger) + { + oneShotActive = true; + oneShotReadPosition = 0.0; + samplePositionSeconds.store(0.0); + sampleActive.store(true); + lastOneShotTriggerBar = barIndex; + lastOneShotTriggerPpq = ppq; + } + + if (! oneShotActive) + continue; + + if (oneShotReadPosition >= sample->buffer.getNumSamples()) + { + oneShotActive = false; + oneShotReadPosition = 0.0; + samplePositionSeconds.store(0.0); + sampleActive.store(false); + continue; + } + + const auto left = readBufferAt(sample->buffer, 0, oneShotReadPosition); + const auto right = readBufferAt(sample->buffer, + sample->buffer.getNumChannels() > 1 ? 1 : 0, + oneShotReadPosition); + + buffer.addSample(0, frame, left); + if (outputChannels > 1) + buffer.addSample(1, frame, right); + + oneShotReadPosition += sourceStep; + } + + if (oneShotActive) + samplePositionSeconds.store(oneShotReadPosition / sample->sampleRate); + sampleActive.store(oneShotActive); + + lastRenderedPpq = ppqStart + static_cast(outputFrames) * beatStep; + lastRenderedHostTime = hostTimeStart + static_cast(outputFrames) / hostRate; + return; + } + + for (auto frame = 0; frame < outputFrames; ++frame) + { + if (! std::isfinite(lastRenderedPpq) && frame == 0) + { + const auto projectSeconds = ppqStart * (60.0 / bpm); + const auto sampleSeconds = sample->buffer.getNumSamples() / sample->sampleRate; + loopReadPosition = positiveModulo(projectSeconds, sampleSeconds) * sample->sampleRate; + samplePositionSeconds.store(loopReadPosition / sample->sampleRate); + } + + const auto left = readBufferAt(sample->buffer, 0, loopReadPosition); + const auto right = readBufferAt(sample->buffer, + sample->buffer.getNumChannels() > 1 ? 1 : 0, + loopReadPosition); + + buffer.addSample(0, frame, left); + if (outputChannels > 1) + buffer.addSample(1, frame, right); + + loopReadPosition = positiveModulo( + loopReadPosition + sourceStep, + static_cast(sample->buffer.getNumSamples())); + } + + samplePositionSeconds.store(loopReadPosition / sample->sampleRate); + sampleActive.store(true); + lastRenderedPpq = ppqStart + static_cast(outputFrames) * beatStep; + lastRenderedHostTime = hostTimeStart + static_cast(outputFrames) / hostRate; +} + +void SplicerrBridgeAudioProcessor::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer&) +{ + juce::ScopedNoDenormals noDenormals; + processTransport(buffer); + passAudioThrough(*this, buffer); + renderLoadedSample(buffer); +} + +void SplicerrBridgeAudioProcessor::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer&) +{ + juce::ScopedNoDenormals noDenormals; + processTransport(buffer); + passAudioThrough(*this, buffer); + renderLoadedSample(buffer); +} + +juce::AudioProcessorEditor* SplicerrBridgeAudioProcessor::createEditor() +{ + return new SplicerrBridgeAudioProcessorEditor(*this); +} + +bool SplicerrBridgeAudioProcessor::hasEditor() const +{ + return true; +} + +const juce::String SplicerrBridgeAudioProcessor::getName() const +{ + return JucePlugin_Name; +} + +bool SplicerrBridgeAudioProcessor::acceptsMidi() const +{ + return true; +} + +bool SplicerrBridgeAudioProcessor::producesMidi() const +{ + return false; +} + +bool SplicerrBridgeAudioProcessor::isMidiEffect() const +{ + return false; +} + +double SplicerrBridgeAudioProcessor::getTailLengthSeconds() const +{ + return 0.0; +} + +int SplicerrBridgeAudioProcessor::getNumPrograms() +{ + return 1; +} + +int SplicerrBridgeAudioProcessor::getCurrentProgram() +{ + return 0; +} + +void SplicerrBridgeAudioProcessor::setCurrentProgram(int) {} + +const juce::String SplicerrBridgeAudioProcessor::getProgramName(int) +{ + return {}; +} + +void SplicerrBridgeAudioProcessor::changeProgramName(int, const juce::String&) {} + +void SplicerrBridgeAudioProcessor::getStateInformation(juce::MemoryBlock&) {} + +void SplicerrBridgeAudioProcessor::setStateInformation(const void*, int) {} + +bool SplicerrBridgeAudioProcessor::hasSentToAppRecently() const noexcept +{ + const auto now = juce::Time::getMillisecondCounter(); + return now - static_cast(lastPacketMs.load()) < 1500; +} + +bool SplicerrBridgeAudioProcessor::hasReceivedAudioRecently() const noexcept +{ + const auto now = juce::Time::getMillisecondCounter(); + return now - static_cast(lastAudioPacketMs.load()) < 1500; +} + +bool SplicerrBridgeAudioProcessor::hasLoadedSampleRecently() const noexcept +{ + const auto now = juce::Time::getMillisecondCounter(); + return sampleLoaded.load() + && now - static_cast(lastSampleLoadMs.load()) < 3000; +} + +void SplicerrBridgeAudioProcessor::timerCallback() +{ + sendSnapshot(false); +} + +void SplicerrBridgeAudioProcessor::sendSnapshot(bool force) +{ + const auto playing = hostPlaying.load(); + const auto bpm = hostBpm.load(); + const auto ppq = hostPpqPosition.load(); + const auto numerator = hostTimeSignatureNumerator.load(); + const auto denominator = hostTimeSignatureDenominator.load(); + const auto samplePosition = samplePositionSeconds.load(); + const auto active = sampleActive.load(); + const auto now = juce::Time::getMillisecondCounter(); + const auto wallClockMs = juce::Time::getCurrentTime().toMilliseconds(); + + const auto transportChanged = playing != lastSentPlaying + || std::abs(bpm - lastSentBpm) > 0.001 + || std::abs(ppq - lastSentPpqPosition) > 0.01 + || std::abs(samplePosition - lastSentSamplePositionSeconds) > 0.01 + || active != lastSentSampleActive + || numerator != lastSentTimeSignatureNumerator + || denominator != lastSentTimeSignatureDenominator; + const auto needsKeepAlive = now - static_cast(lastPacketMs.load()) >= keepAliveMs; + + if (! force && ! transportChanged && ! needsKeepAlive) + return; + + const juce::String payload = "{" + "\"source\":\"splicerr-bridge\"," + "\"version\":1," + "\"playing\":" + jsonBool(playing) + "," + "\"bpm\":" + juce::String(bpm, 6) + "," + "\"ppqPosition\":" + juce::String(ppq, 6) + "," + "\"timeInSeconds\":" + juce::String(hostTimeInSeconds.load(), 6) + "," + "\"packetSentAtMs\":" + juce::String(wallClockMs) + "," + "\"audioPort\":" + juce::String(audioUdpPort.load()) + "," + "\"timeSignatureNumerator\":" + juce::String(numerator) + "," + "\"timeSignatureDenominator\":" + juce::String(denominator) + "," + "\"looping\":" + jsonBool(hostLooping.load()) + "," + "\"loopStartPpq\":" + juce::String(hostLoopStartPpq.load(), 6) + "," + "\"loopEndPpq\":" + juce::String(hostLoopEndPpq.load(), 6) + "," + "\"loadedSampleDurationSeconds\":" + juce::String(loadedSampleDurationSeconds.load(), 6) + "," + "\"samplePositionSeconds\":" + juce::String(samplePosition, 6) + "," + "\"sampleActive\":" + jsonBool(active) + "," + "\"sampleRate\":" + juce::String(currentSampleRate.load(), 1) + + "}"; + + socket.write("127.0.0.1", udpPort, payload.toRawUTF8(), static_cast(payload.getNumBytesAsUTF8())); + + lastSentPlaying = playing; + lastSentBpm = bpm; + lastSentPpqPosition = ppq; + lastSentSamplePositionSeconds = samplePosition; + lastSentSampleActive = active; + lastSentTimeSignatureNumerator = numerator; + lastSentTimeSignatureDenominator = denominator; + lastPacketMs.store(static_cast(now)); +} + +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new SplicerrBridgeAudioProcessor(); +} diff --git a/plugins/splicerr-daw-bridge/Source/PluginProcessor.h b/plugins/splicerr-daw-bridge/Source/PluginProcessor.h new file mode 100644 index 0000000..f002c2e --- /dev/null +++ b/plugins/splicerr-daw-bridge/Source/PluginProcessor.h @@ -0,0 +1,135 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class SplicerrBridgeAudioProcessor final : public juce::AudioProcessor, + private juce::Timer +{ +public: + SplicerrBridgeAudioProcessor(); + ~SplicerrBridgeAudioProcessor() override; + + void prepareToPlay(double sampleRate, int samplesPerBlock) override; + void releaseResources() override; + + bool isBusesLayoutSupported(const BusesLayout& layouts) const override; + void processBlock(juce::AudioBuffer&, juce::MidiBuffer&) override; + void processBlock(juce::AudioBuffer&, juce::MidiBuffer&) override; + + juce::AudioProcessorEditor* createEditor() override; + bool hasEditor() const override; + + const juce::String getName() const override; + bool acceptsMidi() const override; + bool producesMidi() const override; + bool isMidiEffect() const override; + double getTailLengthSeconds() const override; + + int getNumPrograms() override; + int getCurrentProgram() override; + void setCurrentProgram(int index) override; + const juce::String getProgramName(int index) override; + void changeProgramName(int index, const juce::String& newName) override; + + void getStateInformation(juce::MemoryBlock& destData) override; + void setStateInformation(const void* data, int sizeInBytes) override; + + bool isHostPlaying() const noexcept { return hostPlaying.load(); } + double getHostBpm() const noexcept { return hostBpm.load(); } + bool hasSentToAppRecently() const noexcept; + bool hasReceivedAudioRecently() const noexcept; + bool hasLoadedSample() const noexcept { return sampleLoaded.load(); } + bool hasLoadedSampleRecently() const noexcept; + +private: + template + void processTransport(juce::AudioBuffer& buffer); + + void timerCallback() override; + void sendSnapshot(bool force); + void startAudioReceiver(); + void stopAudioReceiver(); + void pushAudioSamples(const float* samples, int frames); + void handleSamplePacket(const char* data, int bytes); + void handleControlPacket(const char* data, int bytes); + void loadSampleFromPath(const juce::String& path, + const juce::String& uuid, + bool oneShot, + double durationMs); + + template + void mixStreamedAudio(juce::AudioBuffer& buffer); + + template + void renderLoadedSample(juce::AudioBuffer& buffer); + + struct LoadedSample + { + juce::AudioBuffer buffer; + double sampleRate = 44100.0; + bool oneShot = false; + juce::String uuid; + double durationMs = 0.0; + }; + + static constexpr int udpPort = 37651; + static constexpr int timerHz = 60; + static constexpr int keepAliveMs = 250; + static constexpr int streamedAudioChannels = 2; + static constexpr int maxQueuedAudioFrames = 48000 * 2; + + juce::DatagramSocket socket; + juce::DatagramSocket audioSocket; + juce::AudioFormatManager formatManager; + std::thread audioReceiverThread; + std::atomic audioReceiverRunning { false }; + std::mutex audioMutex; + std::deque audioQueue; + std::mutex sampleMutex; + std::shared_ptr loadedSample; + + std::atomic hostPlaying { false }; + std::atomic sampleLoaded { false }; + std::atomic audioUdpPort { 0 }; + std::atomic hostLooping { false }; + std::atomic hostBpm { 0.0 }; + std::atomic hostPpqPosition { 0.0 }; + std::atomic hostTimeInSeconds { 0.0 }; + std::atomic hostLoopStartPpq { 0.0 }; + std::atomic hostLoopEndPpq { 0.0 }; + std::atomic loadedSampleDurationSeconds { 0.0 }; + std::atomic samplePositionSeconds { 0.0 }; + std::atomic sampleActive { false }; + std::atomic hostTimeSignatureNumerator { 4 }; + std::atomic hostTimeSignatureDenominator { 4 }; + std::atomic currentSampleRate { 0.0 }; + std::atomic lastPacketMs { 0 }; + std::atomic lastAudioPacketMs { 0 }; + std::atomic lastSampleLoadMs { 0 }; + std::atomic sampleRevision { 0 }; + + bool lastSentPlaying = false; + double lastSentBpm = -1.0; + double lastSentPpqPosition = -1.0; + double lastSentSamplePositionSeconds = -1.0; + bool lastSentSampleActive = false; + int lastSentTimeSignatureNumerator = 4; + int lastSentTimeSignatureDenominator = 4; + int64_t renderedSampleRevision = -1; + int64_t lastOneShotTriggerBar = std::numeric_limits::min(); + bool oneShotActive = false; + double oneShotReadPosition = 0.0; + double loopReadPosition = 0.0; + double lastRenderedPpq = std::numeric_limits::quiet_NaN(); + double lastRenderedHostTime = std::numeric_limits::quiet_NaN(); + double lastOneShotTriggerPpq = -std::numeric_limits::infinity(); + std::atomic appPlaybackEnabled { true }; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SplicerrBridgeAudioProcessor) +}; diff --git a/scripts/stress-transpose-render.mjs b/scripts/stress-transpose-render.mjs new file mode 100644 index 0000000..fd6d7a4 --- /dev/null +++ b/scripts/stress-transpose-render.mjs @@ -0,0 +1,173 @@ +import { performance } from "node:perf_hooks" +import { SoundTouch, SimpleFilter, WebAudioBufferSource } from "soundtouchjs" + +class TestAudioBuffer { + constructor({ length, numberOfChannels, sampleRate }) { + this.length = length + this.numberOfChannels = numberOfChannels + this.sampleRate = sampleRate + this.duration = length / sampleRate + this.channels = Array.from( + { length: numberOfChannels }, + () => new Float32Array(length) + ) + } + + getChannelData(channel) { + return this.channels[channel] + } + + copyToChannel(data, channel) { + this.channels[channel].set(data.subarray(0, this.length)) + } +} + +globalThis.AudioBuffer = TestAudioBuffer + +const SAMPLE_RATE = 44_100 +const CHANNELS = 2 +const BLOCK = 4096 + +function makeSyntheticSample(seconds, seed) { + const length = Math.round(seconds * SAMPLE_RATE) + const buffer = new AudioBuffer({ + length, + numberOfChannels: CHANNELS, + sampleRate: SAMPLE_RATE, + }) + + for (let channel = 0; channel < CHANNELS; channel++) { + const data = buffer.getChannelData(channel) + const base = 80 + seed * 23 + channel * 11 + for (let i = 0; i < length; i++) { + const t = i / SAMPLE_RATE + const envelope = Math.min(1, t / 0.02) * Math.max(0, 1 - t / seconds) + data[i] = + envelope * + 0.35 * + (Math.sin(2 * Math.PI * base * t) + + 0.4 * Math.sin(2 * Math.PI * (base * 2.01) * t)) + } + } + + return buffer +} + +function transformAudioBuffer(buffer, semitones, tempoRatio) { + const shouldPitch = !!semitones + const shouldTempo = + Number.isFinite(tempoRatio) && Math.abs(tempoRatio - 1) >= 0.001 + if (!shouldPitch && !shouldTempo) return buffer + + const channelCount = buffer.numberOfChannels + const sampleRate = buffer.sampleRate + const originalLength = buffer.length + const targetLength = shouldTempo + ? Math.max(1, Math.round(originalLength / tempoRatio)) + : originalLength + const padFrames = Math.ceil(sampleRate * 0.5) + const padded = new AudioBuffer({ + length: originalLength + padFrames, + numberOfChannels: channelCount, + sampleRate, + }) + + for (let channel = 0; channel < channelCount; channel++) { + padded.copyToChannel(buffer.getChannelData(channel), channel) + } + + const soundtouch = new SoundTouch() + if (shouldPitch) soundtouch.pitchSemitones = semitones + if (shouldTempo) soundtouch.tempo = tempoRatio + + const source = new WebAudioBufferSource(padded) + const filter = new SimpleFilter(source, soundtouch) + const interleaved = new Float32Array(BLOCK * 2) + const output = new AudioBuffer({ + length: targetLength, + numberOfChannels: channelCount, + sampleRate, + }) + const left = output.getChannelData(0) + const right = channelCount > 1 ? output.getChannelData(1) : null + let writeIndex = 0 + let extracted = 0 + + while ( + writeIndex < targetLength && + (extracted = filter.extract(interleaved, BLOCK)) > 0 + ) { + const writable = Math.min(extracted, targetLength - writeIndex) + for (let i = 0; i < writable; i++) { + left[writeIndex + i] = interleaved[i * 2] + if (right) right[writeIndex + i] = interleaved[i * 2 + 1] + } + writeIndex += writable + } + + return output +} + +function memoryMb() { + if (globalThis.gc) globalThis.gc() + return process.memoryUsage().heapUsed / 1024 / 1024 +} + +const cases = [ + { seconds: 8, semitones: 5, tempoRatio: 1.18 }, + { seconds: 12, semitones: -4, tempoRatio: 0.86 }, + { seconds: 16, semitones: 7, tempoRatio: 1.33 }, + { seconds: 20, semitones: -2, tempoRatio: 0.74 }, + { seconds: 10, semitones: 3, tempoRatio: 1.08 }, + { seconds: 18, semitones: -5, tempoRatio: 0.92 }, +] + +const before = memoryMb() +const started = performance.now() +let maxCaseMs = 0 +let renderedSeconds = 0 + +for (const [index, testCase] of cases.entries()) { + const input = makeSyntheticSample(testCase.seconds, index + 1) + const caseStart = performance.now() + const stretched = transformAudioBuffer( + input, + testCase.semitones, + testCase.tempoRatio + ) + const elapsed = performance.now() - caseStart + + maxCaseMs = Math.max(maxCaseMs, elapsed) + renderedSeconds += stretched.duration + console.log( + `case ${index + 1}: input=${testCase.seconds}s pitch=${testCase.semitones} tempo=${testCase.tempoRatio} output=${stretched.duration.toFixed(2)}s elapsed=${elapsed.toFixed(0)}ms` + ) +} + +const totalMs = performance.now() - started +const after = memoryMb() +const memoryDelta = after - before + +console.log( + JSON.stringify( + { + cases: cases.length, + renderedSeconds: Number(renderedSeconds.toFixed(2)), + totalMs: Math.round(totalMs), + maxCaseMs: Math.round(maxCaseMs), + heapBeforeMb: Number(before.toFixed(1)), + heapAfterMb: Number(after.toFixed(1)), + heapDeltaMb: Number(memoryDelta.toFixed(1)), + }, + null, + 2 + ) +) + +if (memoryDelta > 80) { + throw new Error(`Heap grew too much: ${memoryDelta.toFixed(1)} MB`) +} + +if (maxCaseMs > 10_000) { + throw new Error(`Single render too slow: ${maxCaseMs.toFixed(0)} ms`) +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6079886..b1f8344 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3929,9 +3929,11 @@ checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] name = "splicerr" -version = "1.0.0" +version = "1.15.0" dependencies = [ + "base64 0.22.1", "serde", + "serde_json", "tauri", "tauri-build", "tauri-plugin-dialog", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c1e7876..1f8c6e2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "splicerr" -version = "1.0.0" +version = "1.15.0" description = "Yet another Splice frontend, inspired by ascpixi" authors = ["kosro"] edition = "2021" @@ -21,6 +21,8 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = ["devtools"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } +serde_json = "1" +base64 = "0.22" tokio = { version = "1", features = ["sync", "time"] } tauri-plugin-http = "2" tauri-plugin-drag = "2.1" @@ -29,4 +31,3 @@ tauri-plugin-dialog = "2" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-window-state = "2" - diff --git a/src-tauri/resources/plugins/.gitkeep b/src-tauri/resources/plugins/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src-tauri/resources/plugins/README.md b/src-tauri/resources/plugins/README.md new file mode 100644 index 0000000..9ec7aa2 --- /dev/null +++ b/src-tauri/resources/plugins/README.md @@ -0,0 +1,24 @@ +# Bridge plugin bundles + +This directory holds the **Splicerr Bridge** audio plugin bundles that Tauri +ships as app resources. They are intentionally **not committed** โ€” they are +built from source in [`plugins/splicerr-daw-bridge`](../../../plugins/splicerr-daw-bridge) +so that the shipped binaries provably match the auditable C++ source. + +Expected contents after a build: + +| Platform | Bundle(s) | +| --- | --- | +| macOS | `Splicerr Bridge.component` (AU) + `Splicerr Bridge.vst3` | +| Windows | `Splicerr Bridge.vst3` | + +## Build locally + +```sh +cmake -B plugins/splicerr-daw-bridge/build -S plugins/splicerr-daw-bridge -DCMAKE_BUILD_TYPE=Release +cmake --build plugins/splicerr-daw-bridge/build --config Release +``` + +Then copy the produced bundles from +`plugins/splicerr-daw-bridge/build/SplicerrBridge_artefacts/Release/{AU,VST3}/` +into this folder. CI does this automatically during release builds. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1447add..5449481 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,9 +1,15 @@ use std::collections::HashMap; +use std::net::UdpSocket; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use std::path::{Path, PathBuf}; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use std::process::Command; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Mutex; use std::time::Duration; -use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; +use base64::Engine; +use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent}; use tokio::sync::{oneshot, Notify}; /// The Splice GraphQL endpoint. The hidden bridge webview is parked on this exact @@ -63,6 +69,20 @@ const BRIDGE_INIT_JS: &str = r#" })(); "#; +/// Local UDP port used by the DAW bridge plugin. The plugin sends small JSON +/// packets here with host transport and tempo data. +const DAW_SYNC_PORT: u16 = 37651; +#[cfg(any(target_os = "macos", target_os = "windows"))] +const BRIDGE_VST3_RESOURCE: &str = "resources/plugins/Splicerr Bridge.vst3"; +#[cfg(target_os = "windows")] +const BRIDGE_VST3_BUNDLE: &str = "Splicerr Bridge.vst3"; +#[cfg(target_os = "macos")] +const BRIDGE_COMPONENT_RESOURCE: &str = "resources/plugins/Splicerr Bridge.component"; +#[cfg(target_os = "macos")] +const SYSTEM_COMPONENTS_DIR: &str = "/Library/Audio/Plug-Ins/Components"; +#[cfg(target_os = "macos")] +const SYSTEM_VST3_DIR: &str = "/Library/Audio/Plug-Ins/VST3"; + /// Shared state correlating in-flight requests with the bridge webview's replies. #[derive(Default)] struct BridgeState { @@ -72,12 +92,50 @@ struct BridgeState { ready_notify: Notify, } +struct DawAudioState { + socket: UdpSocket, + counter: AtomicU64, +} + +impl DawAudioState { + fn new() -> Result { + let socket = UdpSocket::bind(("127.0.0.1", 0)) + .map_err(|e| format!("Failed to bind DAW audio UDP sender: {e}"))?; + + Ok(Self { + socket, + counter: AtomicU64::new(0), + }) + } +} + #[derive(Clone, serde::Serialize)] struct SpliceRequest { id: u64, body: String, } +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct DawSamplePacket { + source: &'static str, + version: u8, + uuid: String, + path: String, + one_shot: bool, + duration_ms: f64, + source_bpm: Option, + rendered_bpm: Option, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct DawControlPacket { + source: &'static str, + version: u8, + playback_enabled: bool, +} + /// Sends a GraphQL request to Splice by relaying it through the hidden bridge /// webview (a real browser engine). Keeps the same name/signature the frontend /// already invokes, so callers are unchanged. @@ -145,6 +203,452 @@ fn splice_bridge_ready(app: tauri::AppHandle) { state.ready_notify.notify_waiters(); } +#[tauri::command] +fn daw_audio_chunk( + app: tauri::AppHandle, + audio_port: u16, + channels: u8, + frames: u16, + samples_base64: String, +) -> Result<(), String> { + if audio_port == 0 { + return Err("DAW audio port is not ready".into()); + } + + if channels == 0 || channels > 2 { + return Err("DAW audio packets support one or two channels".into()); + } + + let sample_bytes = base64::engine::general_purpose::STANDARD + .decode(samples_base64) + .map_err(|e| format!("Invalid DAW audio base64 packet: {e}"))?; + + let expected = channels as usize * frames as usize * std::mem::size_of::(); + if sample_bytes.len() != expected { + return Err(format!( + "Invalid DAW audio packet: expected {expected} bytes, got {}", + sample_bytes.len() + )); + } + + let state = app.state::(); + let sequence = state.counter.fetch_add(1, Ordering::Relaxed); + let mut packet = Vec::with_capacity(16 + sample_bytes.len()); + + packet.extend_from_slice(b"SPAU"); + packet.extend_from_slice(&sequence.to_le_bytes()); + packet.push(channels); + packet.push(0); + packet.extend_from_slice(&frames.to_le_bytes()); + packet.extend_from_slice(&sample_bytes); + + state + .socket + .send_to(&packet, ("127.0.0.1", audio_port)) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +fn daw_load_sample( + app: tauri::AppHandle, + audio_port: u16, + uuid: String, + path: String, + one_shot: bool, + duration_ms: f64, + source_bpm: Option, + rendered_bpm: Option, +) -> Result<(), String> { + if audio_port == 0 { + return Err("DAW audio/control port is not ready".into()); + } + + let packet = DawSamplePacket { + source: "splicerr-app", + version: 1, + uuid, + path, + one_shot, + duration_ms, + source_bpm, + rendered_bpm, + }; + let payload = + serde_json::to_vec(&packet).map_err(|e| format!("Invalid DAW sample packet: {e}"))?; + let state = app.state::(); + let mut datagram = Vec::with_capacity(4 + payload.len()); + + datagram.extend_from_slice(b"SPSM"); + datagram.extend_from_slice(&payload); + + state + .socket + .send_to(&datagram, ("127.0.0.1", audio_port)) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +fn daw_set_playback_enabled( + app: tauri::AppHandle, + audio_port: u16, + playback_enabled: bool, +) -> Result<(), String> { + if audio_port == 0 { + return Err("DAW audio/control port is not ready".into()); + } + + let packet = DawControlPacket { + source: "splicerr-app", + version: 1, + playback_enabled, + }; + let payload = + serde_json::to_vec(&packet).map_err(|e| format!("Invalid DAW control packet: {e}"))?; + let state = app.state::(); + let mut datagram = Vec::with_capacity(4 + payload.len()); + + datagram.extend_from_slice(b"SPCT"); + datagram.extend_from_slice(&payload); + + state + .socket + .send_to(&datagram, ("127.0.0.1", audio_port)) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[cfg(target_os = "macos")] +fn shell_quote(path: &Path) -> String { + format!("'{}'", path.to_string_lossy().replace('\'', "'\\''")) +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn bridge_resource_path(app: &tauri::AppHandle, resource: &str) -> Result { + let resource_dir = app + .path() + .resource_dir() + .map_err(|e| format!("Failed to locate app resources: {e}"))?; + let path = resource_dir.join(resource); + + if !path.exists() { + return Err(format!("Bridge resource not found: {}", path.display())); + } + + Ok(path) +} + +/// System-wide VST3 directory on Windows (`%CommonProgramFiles%\VST3`, i.e. +/// `C:\Program Files\Common Files\VST3`). Every Windows DAW (Ableton Live, FL +/// Studio, Cubase, Reaper) scans this location by default, so installing here +/// makes the bridge appear without any per-host folder configuration. Writing +/// here requires administrator rights, so installs go through a UAC prompt โ€” +/// mirroring the macOS install into the system Audio Plug-Ins folders. +#[cfg(target_os = "windows")] +fn windows_vst3_dir() -> Result { + let common_program_files = std::env::var_os("CommonProgramFiles") + .ok_or_else(|| "CommonProgramFiles is not set".to_string())?; + Ok(PathBuf::from(common_program_files).join("VST3")) +} + +/// Quotes a path as a PowerShell single-quoted string literal (doubling any +/// embedded single quote). Used to safely embed app-controlled paths into the +/// elevated installer script. +#[cfg(target_os = "windows")] +fn ps_single_quote(path: &Path) -> String { + format!("'{}'", path.to_string_lossy().replace('\'', "''")) +} + +/// Runs `script` in an elevated PowerShell (UAC prompt) and waits for it. +/// +/// The script body is passed via `-EncodedCommand` (UTF-16LE base64) rather +/// than a temp `.ps1` file. A temp script would live in the user-writable +/// `%TEMP%` yet execute elevated, making it a local privilege-escalation +/// hijack target (an attacker racing the UAC prompt could overwrite it). +/// Base64 is a single quote-free token, so it also needs no command-line +/// escaping. +#[cfg(target_os = "windows")] +fn run_elevated_powershell(script: &str) -> Result<(), String> { + use std::os::windows::process::CommandExt; + // Suppresses the console window of the (non-elevated) launcher process. The + // elevated child is hidden via `-WindowStyle Hidden` below. The UAC consent + // dialog still appears โ€” that prompt is the security boundary and must not + // be suppressed. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + let utf16: Vec = script + .encode_utf16() + .flat_map(|unit| unit.to_le_bytes()) + .collect(); + let encoded = base64::engine::general_purpose::STANDARD.encode(utf16); + + let launcher = format!( + "$ErrorActionPreference='Stop'; \ + $p = Start-Process -FilePath 'powershell' \ + -ArgumentList @('-NoProfile','-WindowStyle','Hidden','-EncodedCommand','{encoded}') \ + -Verb RunAs -WindowStyle Hidden -Wait -PassThru; exit $p.ExitCode" + ); + + let status = Command::new("powershell") + .args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + &launcher, + ]) + .creation_flags(CREATE_NO_WINDOW) + .status() + .map_err(|e| format!("Failed to launch elevated installer: {e}"))?; + + if !status.success() { + return Err( + "Bridge operation failed or was not approved (administrator rights are required)." + .into(), + ); + } + + Ok(()) +} + +#[cfg(target_os = "macos")] +fn plugin_install_script(source: &Path, destination_dir: &str) -> String { + let destination = Path::new(destination_dir).join( + source + .file_name() + .expect("bridge plugin resource must have a file name"), + ); + let quoted_source = shell_quote(source); + let quoted_destination = shell_quote(&destination); + let quoted_destination_dir = shell_quote(Path::new(destination_dir)); + + [ + format!("mkdir -p {quoted_destination_dir}"), + format!("rm -rf {quoted_destination}"), + format!("ditto {quoted_source} {quoted_destination}"), + format!("xattr -cr {quoted_destination}"), + format!("xattr -r -d com.apple.quarantine {quoted_destination} 2>/dev/null || true"), + format!("codesign --force --deep --sign - {quoted_destination}"), + ] + .join(" && ") +} + +#[cfg(target_os = "macos")] +fn plugin_remove_script(destination_dir: &str, bundle_name: &str) -> String { + let destination = Path::new(destination_dir).join(bundle_name); + format!("rm -rf {}", shell_quote(&destination)) +} + +#[cfg(target_os = "macos")] +fn bridge_plugins_installed_paths() -> bool { + Path::new(SYSTEM_COMPONENTS_DIR) + .join("Splicerr Bridge.component") + .exists() + && Path::new(SYSTEM_VST3_DIR) + .join("Splicerr Bridge.vst3") + .exists() +} + +#[tauri::command] +fn bridge_plugins_installed() -> Result { + #[cfg(target_os = "macos")] + { + Ok(bridge_plugins_installed_paths()) + } + + #[cfg(target_os = "windows")] + { + Ok(windows_vst3_dir()?.join(BRIDGE_VST3_BUNDLE).exists()) + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + Ok(false) + } +} + +/// Path to the installed bridge `.vst3` bundle in the system plug-in folder, +/// for revealing it in the OS file manager. `None` on unsupported platforms. +#[tauri::command] +fn bridge_install_path() -> Result, String> { + #[cfg(target_os = "macos")] + { + Ok(Some( + Path::new(SYSTEM_VST3_DIR) + .join("Splicerr Bridge.vst3") + .to_string_lossy() + .into_owned(), + )) + } + + #[cfg(target_os = "windows")] + { + Ok(Some( + windows_vst3_dir()? + .join(BRIDGE_VST3_BUNDLE) + .to_string_lossy() + .into_owned(), + )) + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + Ok(None) + } +} + +#[tauri::command] +fn install_bridge_plugins(app: tauri::AppHandle) -> Result { + #[cfg(target_os = "windows")] + { + let vst3 = bridge_resource_path(&app, BRIDGE_VST3_RESOURCE)?; + let destination_dir = windows_vst3_dir()?; + let destination = destination_dir.join(BRIDGE_VST3_BUNDLE); + + let script = format!( + "$ErrorActionPreference = 'Stop'\n\ + $dest = {dest}\n\ + if (Test-Path -LiteralPath $dest) {{ Remove-Item -LiteralPath $dest -Recurse -Force }}\n\ + New-Item -ItemType Directory -Force -Path {dest_dir} | Out-Null\n\ + Copy-Item -LiteralPath {src} -Destination {dest_dir} -Recurse -Force\n", + dest = ps_single_quote(&destination), + dest_dir = ps_single_quote(&destination_dir), + src = ps_single_quote(&vst3), + ); + run_elevated_powershell(&script)?; + + return Ok("Installed Splicerr Bridge.".into()); + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + let _ = app; + return Err( + "Bridge plugin installation is only supported on macOS and Windows".into(), + ); + } + + #[cfg(target_os = "macos")] + { + let component = bridge_resource_path(&app, BRIDGE_COMPONENT_RESOURCE)?; + let vst3 = bridge_resource_path(&app, BRIDGE_VST3_RESOURCE)?; + let script = [ + plugin_install_script(&component, SYSTEM_COMPONENTS_DIR), + plugin_install_script(&vst3, SYSTEM_VST3_DIR), + ] + .join(" && "); + let osascript = format!( + "do shell script {:?} with administrator privileges", + script + ); + let output = Command::new("osascript") + .arg("-e") + .arg(osascript) + .output() + .map_err(|e| format!("Failed to launch installer: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(format!( + "Bridge installation failed: {}{}", + stderr, + stdout + )); + } + + Ok("Installed Splicerr Bridge.".into()) + } +} + +#[tauri::command] +fn uninstall_bridge_plugins() -> Result { + #[cfg(target_os = "windows")] + { + let destination = windows_vst3_dir()?.join(BRIDGE_VST3_BUNDLE); + let script = format!( + "$ErrorActionPreference = 'Stop'\n\ + $dest = {dest}\n\ + if (Test-Path -LiteralPath $dest) {{ Remove-Item -LiteralPath $dest -Recurse -Force }}\n", + dest = ps_single_quote(&destination), + ); + run_elevated_powershell(&script)?; + return Ok("Removed Splicerr Bridge from the system VST3 folder".into()); + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + return Err( + "Bridge plugin uninstallation is only supported on macOS and Windows".into(), + ); + } + + #[cfg(target_os = "macos")] + { + let script = [ + plugin_remove_script(SYSTEM_COMPONENTS_DIR, "Splicerr Bridge.component"), + plugin_remove_script(SYSTEM_VST3_DIR, "Splicerr Bridge.vst3"), + ] + .join(" && "); + let osascript = format!( + "do shell script {:?} with administrator privileges", + script + ); + let output = Command::new("osascript") + .arg("-e") + .arg(osascript) + .output() + .map_err(|e| format!("Failed to launch uninstaller: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(format!( + "Bridge uninstallation failed: {}{}", + stderr, + stdout + )); + } + + Ok("Removed Splicerr Bridge from the system Audio Plug-Ins folders".into()) + } +} + +fn start_daw_sync_listener(app: tauri::AppHandle) -> Result<(), String> { + let socket = UdpSocket::bind(("127.0.0.1", DAW_SYNC_PORT)) + .map_err(|e| format!("Failed to bind DAW sync UDP port {DAW_SYNC_PORT}: {e}"))?; + + std::thread::Builder::new() + .name("splicerr-daw-sync".into()) + .spawn(move || { + let mut buf = [0_u8; 2048]; + loop { + match socket.recv_from(&mut buf) { + Ok((len, _addr)) => { + if let Ok(text) = std::str::from_utf8(&buf[..len]) { + let _ = app.emit_to("main", "daw-sync", text.to_string()); + } + } + Err(err) => { + eprintln!("DAW sync UDP listener stopped: {err}"); + break; + } + } + } + }) + .map_err(|e| e.to_string())?; + + Ok(()) +} + +fn show_main_window(app: &tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -161,13 +665,31 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_drag::init()) .manage(BridgeState::default()) + .manage(DawAudioState::new().expect("failed to initialize DAW audio sender")) .invoke_handler(tauri::generate_handler![ splice_graphql, splice_response, splice_bridge_ready, + daw_audio_chunk, + daw_load_sample, + daw_set_playback_enabled, + bridge_plugins_installed, + bridge_install_path, + install_bridge_plugins, + uninstall_bridge_plugins, open_devtools ]) + .on_window_event(|window, event| { + if window.label() == "main" { + if let WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + let _ = window.hide(); + } + } + }) .setup(|app| { + start_daw_sync_listener(app.handle().clone())?; + // Hidden webview parked on the Splice GraphQL host. Navigating here // (a 400 JSON page) makes Cloudflare hand out a `__cf_bm` cookie for // the host without an interactive challenge, after which same-origin @@ -184,6 +706,12 @@ pub fn run() { .build()?; Ok(()) }) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(|app, event| { + #[cfg(target_os = "macos")] + if let tauri::RunEvent::Reopen { .. } = event { + show_main_window(app); + } + }); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f202434..33aab7e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -21,7 +21,8 @@ ], "security": { "csp": { - "connect-src": "'self' ipc: blob: http://ipc.localhost http://tauri.localhost https://*.splice.com/** https://spliceproduction.s3.*.amazonaws.com/** https://*.amazonaws.com/spliceblob.splice.com/** https://splice-res.cloudinary.com/**" + "connect-src": "'self' ipc: blob: http://ipc.localhost http://tauri.localhost https://*.splice.com/** https://spliceproduction.s3.*.amazonaws.com/** https://*.amazonaws.com/spliceblob.splice.com/** https://splice-res.cloudinary.com/**", + "worker-src": "'self' blob: http://tauri.localhost" } } }, diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json new file mode 100644 index 0000000..2c1014d --- /dev/null +++ b/src-tauri/tauri.macos.conf.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "resources": [ + "resources/plugins/Splicerr Bridge.component", + "resources/plugins/Splicerr Bridge.vst3" + ] + } +} diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json new file mode 100644 index 0000000..5ef66bf --- /dev/null +++ b/src-tauri/tauri.windows.conf.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "resources": [ + "resources/plugins/Splicerr Bridge.vst3" + ] + } +} diff --git a/src/app.css b/src/app.css index 5dec35e..f46c0ab 100644 --- a/src/app.css +++ b/src/app.css @@ -65,6 +65,40 @@ --sidebar-border: 240 3.7% 15.9%; --sidebar-ring: 217.2 91.2% 59.8%; } + + /* + * dre4moff โ€” opt-in dark "skin" toggled via data-skin on + * (see updateTheme in config.svelte.ts). It always rides on top of dark + * mode, so the palette below overrides the plain .dark values. + */ + .dark[data-skin="dre4moff"] { + --background: 210 10% 6%; + --foreground: 210 28% 96%; + --muted: 215 20% 18%; + --muted-foreground: 215 13% 70%; + --popover: 216 24% 9%; + --popover-foreground: 210 28% 96%; + --card: 216 24% 9%; + --card-foreground: 210 28% 96%; + --border: 210 24% 100%; + --input: 210 24% 100%; + --primary: 210 28% 96%; + --primary-foreground: 210 10% 6%; + --secondary: 214 22% 16%; + --secondary-foreground: 210 28% 96%; + --accent: 210 22% 18%; + --accent-foreground: 210 28% 96%; + --ring: 210 28% 84%; + --radius: 0.75rem; + --sidebar-background: 210 12% 10%; + --sidebar-foreground: 210 28% 92%; + --sidebar-primary: 210 28% 96%; + --sidebar-primary-foreground: 210 10% 6%; + --sidebar-accent: 210 28% 100%; + --sidebar-accent-foreground: 210 28% 96%; + --sidebar-border: 210 24% 100%; + --sidebar-ring: 210 28% 84%; + } } @layer base { @@ -72,7 +106,14 @@ @apply border-border select-none; } body { - @apply bg-background text-foreground overflow-hidden h-screen; + @apply bg-background text-foreground overflow-hidden h-screen antialiased; + } + /* Ambient glow only under the dre4moff skin. */ + :root[data-skin="dre4moff"] body { + background: + radial-gradient(circle at 10% 8%, rgba(52, 151, 141, 0.2), transparent 30rem), + radial-gradient(circle at 88% 0%, rgba(194, 157, 84, 0.09), transparent 28rem), + linear-gradient(135deg, #060708 0%, #050606 48%, #090b0b 100%); } body.waiting, body.waiting a:hover, @@ -87,3 +128,122 @@ src: url("/InterVariable.woff2") format("woff2"); } } + +@layer components { + .macos-shell { + height: 100%; + min-height: 0; + } + + /* + * The .glass-* / surface classes are used directly in component markup. + * Their default rules below are flat and palette-driven so Dark and Light + * look like ordinary controls; the dre4moff overrides further down add + * the translucent blur. This keeps a single set of markup for all themes. + */ + .glass-panel { + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + box-shadow: + 0 1px 3px 0 rgb(0 0 0 / 0.1), + 0 1px 2px -1px rgb(0 0 0 / 0.1); + } + + .glass-control { + background: transparent; + border: 1px solid hsl(var(--input)); + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + } + + .glass-row { + background: transparent; + border: 1px solid transparent; + transition: + background-color 180ms ease, + border-color 180ms ease; + } + + .glass-row:hover { + background: hsl(var(--accent)); + } + + .glass-row-selected { + background: hsl(var(--accent)); + } + + .glass-pill { + background: hsl(var(--secondary)); + border: 1px solid hsl(var(--border)); + } + + .glass-pill-active { + background: hsl(var(--accent)); + color: hsl(var(--accent-foreground)); + } + + /* No surface box in Dark/Light (matches the original look); dre4moff + adds the translucent panel below. */ + .waveform-surface { + background: transparent; + } + + /* ---- dre4moff skin overrides ---- */ + :root[data-skin="dre4moff"] .macos-shell { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 18rem), + transparent; + } + + :root[data-skin="dre4moff"] .glass-panel { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.035)); + border: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 18px 60px rgba(0, 0, 0, 0.32); + backdrop-filter: blur(18px) saturate(135%); + } + + :root[data-skin="dre4moff"] .glass-control { + background: rgba(255, 255, 255, 0.075); + border: 1px solid rgba(255, 255, 255, 0.11); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 8px 28px rgba(0, 0, 0, 0.18); + backdrop-filter: blur(14px) saturate(130%); + } + + :root[data-skin="dre4moff"] .glass-row { + background: rgba(255, 255, 255, 0.035); + } + + :root[data-skin="dre4moff"] .glass-row:hover { + background: rgba(255, 255, 255, 0.065); + border-color: rgba(255, 255, 255, 0.1); + } + + :root[data-skin="dre4moff"] .glass-row-selected { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.14); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); + } + + :root[data-skin="dre4moff"] .glass-pill { + background: rgba(255, 255, 255, 0.055); + border: 1px solid rgba(255, 255, 255, 0.09); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055); + } + + :root[data-skin="dre4moff"] .glass-pill-active { + background: rgba(255, 255, 255, 0.16); + border-color: rgba(255, 255, 255, 0.18); + color: hsl(var(--foreground)); + } + + :root[data-skin="dre4moff"] .waveform-surface { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.025)), + rgba(0, 0, 0, 0.08); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045); + } +} diff --git a/src/lib/components/asset-category-select.svelte b/src/lib/components/asset-category-select.svelte index cd15530..2792448 100644 --- a/src/lib/components/asset-category-select.svelte +++ b/src/lib/components/asset-category-select.svelte @@ -38,7 +38,7 @@ onselect() }} > - {triggerContent} + {triggerContent} 0 ? progressCurrentTime / progressDuration : 0 + ) + const playbackPaused = $derived( + dawSync.connected ? !dawSync.playbackEnabled : globalAudio.paused + ) + + function handleProgressInput(event: Event) { + if (dawSync.connected) return + globalAudio.currentTime = Number((event.currentTarget as HTMLInputElement).value) + } -
+
globalAudio.ref.play()} + value={progressCurrentTime} + oninput={handleProgressInput} + onclick={() => { + if (!dawSync.connected) globalAudio.ref.play() + }} /> -
-
+
+
@@ -94,7 +149,7 @@ {#if globalAudio.currentAsset}
-
+
{#if globalAudio.currentAsset.asset_category_slug in assetIcons} {@const Icon = assetIcons[ @@ -107,12 +162,12 @@
{currentName} @@ -122,7 +177,7 @@
{#each globalAudio.currentAsset.tags as tag} {@const active = dataStore.tags.includes( @@ -135,7 +190,7 @@ { if (!active) { @@ -152,11 +207,64 @@
{/if}
+ + + + + {dawStatusLabel} + + + {dawStatusTitle} + + + + {#if globalAudio.currentAsset && dawSync.connected && dawSync.bpm} + + + + + Drag current sample stretched to {Math.round( + dawSync.bpm + )} BPM + + + + {/if}
-
+
- + Collections
-
+
@@ -122,7 +122,7 @@ 0 ? "flex" : "hidden" )} > diff --git a/src/lib/components/settings-dialog.svelte b/src/lib/components/settings-dialog.svelte index 1d64c5c..3077d20 100644 --- a/src/lib/components/settings-dialog.svelte +++ b/src/lib/components/settings-dialog.svelte @@ -20,9 +20,61 @@ import ThemeSelect from "./theme-select.svelte" import Switch from "$lib/components/ui/switch/switch.svelte" import Terminal from "lucide-svelte/icons/terminal" + import Download from "lucide-svelte/icons/download" + import LoaderCircle from "lucide-svelte/icons/loader-circle" + import Trash2 from "lucide-svelte/icons/trash-2" import { invoke } from "@tauri-apps/api/core" + import { revealItemInDir } from "@tauri-apps/plugin-opener" let flashbangAudio = $state(null!) + let installingBridge = $state(false) + let uninstallingBridge = $state(false) + let bridgeInstalled = $state(null) + let bridgeInstallStatus = $state(null) + let bridgeInstallPath = $state(null) + + async function refreshBridgeInstallState() { + try { + bridgeInstalled = await invoke("bridge_plugins_installed") + bridgeInstallPath = await invoke("bridge_install_path") + } catch (err) { + bridgeInstallStatus = String(err) + } + } + + async function installBridge() { + installingBridge = true + bridgeInstallStatus = null + + try { + bridgeInstallStatus = await invoke("install_bridge_plugins") + await refreshBridgeInstallState() + } catch (err) { + bridgeInstallStatus = String(err) + } finally { + installingBridge = false + } + } + + async function uninstallBridge() { + uninstallingBridge = true + bridgeInstallStatus = null + + try { + bridgeInstallStatus = await invoke("uninstall_bridge_plugins") + await refreshBridgeInstallState() + } catch (err) { + bridgeInstallStatus = String(err) + } finally { + uninstallingBridge = false + } + } + + $effect(() => { + if (settingsDialog.open) { + void refreshBridgeInstallState() + } + }) @@ -156,6 +208,61 @@
+
+ +

+ Install the bundled bridge plugin so your DAW can sync with + Splicerr. On macOS this installs AU and VST3 into the system + Audio Plug-Ins folders; on Windows it installs the VST3 into + the system VST3 folder. You'll be asked for administrator + rights. +

+
+ + {#if bridgeInstalled} + + {/if} +
+ {#if bridgeInstallStatus} +

+ {bridgeInstallStatus} +

+ {/if} + {#if bridgeInstalled && bridgeInstallPath} + + {/if} +

diff --git a/src/lib/components/sort-select.svelte b/src/lib/components/sort-select.svelte index 6cc7059..76ede3a 100644 --- a/src/lib/components/sort-select.svelte +++ b/src/lib/components/sort-select.svelte @@ -72,7 +72,7 @@ onselect()}> - +

{triggerLabel} {#if showOrder} diff --git a/src/lib/components/tag-badge.svelte b/src/lib/components/tag-badge.svelte index d1abff8..40a7a05 100644 --- a/src/lib/components/tag-badge.svelte +++ b/src/lib/components/tag-badge.svelte @@ -27,10 +27,11 @@ diff --git a/src/lib/shared/audio-render-worker-client.ts b/src/lib/shared/audio-render-worker-client.ts new file mode 100644 index 0000000..f71b3ad --- /dev/null +++ b/src/lib/shared/audio-render-worker-client.ts @@ -0,0 +1,174 @@ +type RenderAudioOptions = { + semitones: number + tempoRatio: number + trimStartSeconds: number + maxDurationSeconds: number | null + signal?: AbortSignal +} + +type WorkerRenderAudioOptions = Omit + +type RenderRequest = WorkerRenderAudioOptions & { + id: number + channels: Float32Array[] + sampleRate: number +} + +type RenderResponse = + | { + id: number + ok: true + wavData: Uint8Array + durationSeconds: number + } + | { + id: number + ok: false + error: string + } + +let worker: Worker | null = null +let nextId = 1 +const pending = new Map< + number, + { + resolve: (value: { wavData: Uint8Array; durationSeconds: number }) => void + reject: (reason?: unknown) => void + } +>() + +function abortError() { + return new DOMException("Audio render cancelled", "AbortError") +} + +function throwIfAborted(signal?: AbortSignal) { + if (signal?.aborted) { + throw abortError() + } +} + +function nextFrame() { + return new Promise((resolve) => { + window.requestAnimationFrame(() => resolve()) + }) +} + +async function copyAudioBufferChannels( + buffer: AudioBuffer, + signal?: AbortSignal +) { + const channels = Array.from( + { length: buffer.numberOfChannels }, + () => new Float32Array(buffer.length) + ) + const chunkFrames = 262_144 + + for (let channel = 0; channel < buffer.numberOfChannels; channel++) { + const source = buffer.getChannelData(channel) + const destination = channels[channel] + + for (let offset = 0; offset < buffer.length; offset += chunkFrames) { + throwIfAborted(signal) + const end = Math.min(buffer.length, offset + chunkFrames) + destination.set(source.subarray(offset, end), offset) + await nextFrame() + } + } + + throwIfAborted(signal) + return channels +} + +function getWorker() { + if (worker) return worker + + worker = new Worker(new URL("./audio-render-worker.ts", import.meta.url), { + type: "module", + }) + + worker.onmessage = (event: MessageEvent) => { + const response = event.data + const request = pending.get(response.id) + if (!request) return + + pending.delete(response.id) + if (response.ok) { + request.resolve({ + wavData: response.wavData, + durationSeconds: response.durationSeconds, + }) + } else { + request.reject(new Error(response.error)) + } + } + + worker.onerror = (event) => { + for (const request of pending.values()) { + request.reject( + new Error(event.message || "Audio render worker failed") + ) + } + pending.clear() + worker?.terminate() + worker = null + } + + return worker +} + +export function cancelAudioRenderWorker() { + if (!worker) return + + for (const request of pending.values()) { + request.reject(abortError()) + } + pending.clear() + worker.terminate() + worker = null +} + +export async function renderAudioBufferToWav( + buffer: AudioBuffer, + options: RenderAudioOptions +) { + throwIfAborted(options.signal) + const id = nextId++ + const channels = await copyAudioBufferChannels(buffer, options.signal) + const transfer = channels.map((channel) => channel.buffer) + const { signal: _signal, ...workerOptions } = options + + return new Promise<{ wavData: Uint8Array; durationSeconds: number }>( + (resolve, reject) => { + if (options.signal?.aborted) { + reject(abortError()) + return + } + + pending.set(id, { + resolve: (value) => { + if (options.signal?.aborted) { + reject(abortError()) + } else { + resolve(value) + } + }, + reject: (reason) => { + reject(reason) + }, + }) + const request: RenderRequest = { + id, + channels, + sampleRate: buffer.sampleRate, + ...workerOptions, + } + + try { + getWorker().postMessage(request, { transfer }) + } catch (err) { + pending.delete(id) + reject(err) + } + } + ) +} diff --git a/src/lib/shared/audio-render-worker.ts b/src/lib/shared/audio-render-worker.ts new file mode 100644 index 0000000..c499277 --- /dev/null +++ b/src/lib/shared/audio-render-worker.ts @@ -0,0 +1,238 @@ +import { SimpleFilter, SoundTouch, WebAudioBufferSource } from "soundtouchjs" + +type RenderRequest = { + id: number + channels: Float32Array[] + sampleRate: number + semitones: number + tempoRatio: number + trimStartSeconds: number + maxDurationSeconds: number | null +} + +type RenderResponse = + | { + id: number + ok: true + wavData: Uint8Array + durationSeconds: number + } + | { + id: number + ok: false + error: string + } + +const BLOCK = 4096 + +class WorkerAudioBuffer { + readonly length: number + readonly numberOfChannels: number + readonly sampleRate: number + readonly duration: number + private readonly channels: Float32Array[] + + constructor({ + length, + numberOfChannels, + sampleRate, + channels, + }: { + length: number + numberOfChannels: number + sampleRate: number + channels?: Float32Array[] + }) { + this.length = length + this.numberOfChannels = numberOfChannels + this.sampleRate = sampleRate + this.duration = length / sampleRate + this.channels = + channels ?? + Array.from( + { length: numberOfChannels }, + () => new Float32Array(length) + ) + } + + getChannelData(channel: number) { + return this.channels[channel] + } + + copyToChannel(data: Float32Array, channel: number) { + this.channels[channel].set(data.subarray(0, this.length)) + } + + copyFromChannel(destination: Float32Array, channel: number) { + destination.set( + this.channels[channel].subarray(0, destination.length) + ) + } +} + +function fromChannels(channels: Float32Array[], sampleRate: number) { + const length = Math.min(...channels.map((channel) => channel.length)) + return new WorkerAudioBuffer({ + length, + numberOfChannels: channels.length, + sampleRate, + channels: channels.map((channel) => channel.subarray(0, length)), + }) +} + +function transformAudioBuffer( + buffer: WorkerAudioBuffer, + semitones: number, + tempoRatio: number +): WorkerAudioBuffer { + const shouldPitch = !!semitones + const shouldTempo = + Number.isFinite(tempoRatio) && Math.abs(tempoRatio - 1) >= 0.001 + if (!shouldPitch && !shouldTempo) return buffer + + const channelCount = buffer.numberOfChannels + const sampleRate = buffer.sampleRate + const originalLength = buffer.length + const targetLength = shouldTempo + ? Math.max(1, Math.round(originalLength / tempoRatio)) + : originalLength + const padFrames = Math.ceil(sampleRate * 0.5) + const padded = new WorkerAudioBuffer({ + length: originalLength + padFrames, + numberOfChannels: channelCount, + sampleRate, + }) + + for (let channel = 0; channel < channelCount; channel++) { + padded.copyToChannel(buffer.getChannelData(channel), channel) + } + + const soundtouch = new SoundTouch() + if (shouldPitch) soundtouch.pitchSemitones = semitones + if (shouldTempo) soundtouch.tempo = tempoRatio + + const source = new WebAudioBufferSource(padded) + const filter = new SimpleFilter(source, soundtouch) + const interleaved = new Float32Array(BLOCK * 2) + const output = new WorkerAudioBuffer({ + length: targetLength, + numberOfChannels: channelCount, + sampleRate, + }) + const left = output.getChannelData(0) + const right = channelCount > 1 ? output.getChannelData(1) : null + let writeIndex = 0 + let extracted = 0 + + while ( + writeIndex < targetLength && + (extracted = filter.extract(interleaved, BLOCK)) > 0 + ) { + const writable = Math.min(extracted, targetLength - writeIndex) + for (let i = 0; i < writable; i++) { + left[writeIndex + i] = interleaved[i * 2] + if (right) right[writeIndex + i] = interleaved[i * 2 + 1] + } + writeIndex += writable + } + + return output +} + +function writeString(view: DataView, offset: number, value: string) { + for (let i = 0; i < value.length; i++) { + view.setUint8(offset + i, value.charCodeAt(i)) + } +} + +function encodeWav(channels: Float32Array[], sampleRate: number) { + const channelCount = channels.length + const frameCount = Math.min(...channels.map((channel) => channel.length)) + const bytesPerSample = 2 + const dataBytes = frameCount * channelCount * bytesPerSample + const buffer = new ArrayBuffer(44 + dataBytes) + const view = new DataView(buffer) + + writeString(view, 0, "RIFF") + view.setUint32(4, 36 + dataBytes, true) + writeString(view, 8, "WAVE") + writeString(view, 12, "fmt ") + view.setUint32(16, 16, true) + view.setUint16(20, 1, true) + view.setUint16(22, channelCount, true) + view.setUint32(24, sampleRate, true) + view.setUint32(28, sampleRate * channelCount * bytesPerSample, true) + view.setUint16(32, channelCount * bytesPerSample, true) + view.setUint16(34, 16, true) + writeString(view, 36, "data") + view.setUint32(40, dataBytes, true) + + let offset = 44 + for (let frame = 0; frame < frameCount; frame++) { + for (let channel = 0; channel < channelCount; channel++) { + const sample = Math.max(-1, Math.min(1, channels[channel][frame])) + view.setInt16( + offset, + sample < 0 ? sample * 0x8000 : sample * 0x7fff, + true + ) + offset += bytesPerSample + } + } + + return new Uint8Array(buffer) +} + +function renderToWav(request: RenderRequest) { + let rendered = fromChannels(request.channels, request.sampleRate) + rendered = transformAudioBuffer( + rendered, + request.semitones, + request.tempoRatio + ) + + const trimStart = Math.min( + rendered.length, + Math.max(0, Math.floor(request.trimStartSeconds * rendered.sampleRate)) + ) + const maxFrames = + request.maxDurationSeconds === null + ? rendered.length - trimStart + : Math.max( + 0, + Math.floor(request.maxDurationSeconds * rendered.sampleRate) + ) + const end = Math.min(rendered.length, trimStart + maxFrames) + const channels = Array.from( + { length: rendered.numberOfChannels }, + (_, channel) => rendered.getChannelData(channel).subarray(trimStart, end) + ) + const wavData = encodeWav(channels, rendered.sampleRate) + + return { + wavData, + durationSeconds: (end - trimStart) / rendered.sampleRate, + } +} + +self.onmessage = (event: MessageEvent) => { + const request = event.data + + try { + const { wavData, durationSeconds } = renderToWav(request) + const response: RenderResponse = { + id: request.id, + ok: true, + wavData, + durationSeconds, + } + self.postMessage(response, { transfer: [wavData.buffer] }) + } catch (err) { + const response: RenderResponse = { + id: request.id, + ok: false, + error: err instanceof Error ? err.message : String(err), + } + self.postMessage(response) + } +} diff --git a/src/lib/shared/config.svelte.ts b/src/lib/shared/config.svelte.ts index f3f27a2..24cc6ac 100644 --- a/src/lib/shared/config.svelte.ts +++ b/src/lib/shared/config.svelte.ts @@ -12,7 +12,7 @@ import { resetMode, setMode } from "mode-watcher" const CONFIG_FILE_NAME = "config.json" -export type UITheme = "system" | "light" | "dark" +export type UITheme = "system" | "light" | "dark" | "dre4moff" export type TransposeMode = "key" | "pitch" export type NoteSpelling = "flat" | "sharp" @@ -89,6 +89,11 @@ export async function loadConfig() { console.log("๐Ÿ“‚ Config loaded") } + // Re-apply the saved theme so the dre4moff skin (a custom data-skin + // attribute mode-watcher doesn't track) is restored on startup, not just + // the light/dark mode. + updateTheme() + await validateSamplesDir() } @@ -111,10 +116,24 @@ export async function saveConfig() { } export function updateTheme() { + // The dre4moff skin is an opt-in dark variant: it rides on top of dark + // mode and is enabled via a data-skin attribute that app.css keys off. Plain + // Dark/Light/System clear the attribute so they render the original palette. + if (typeof document !== "undefined") { + if (config.ui_theme === "dre4moff") { + document.documentElement.setAttribute("data-skin", "dre4moff") + } else { + document.documentElement.removeAttribute("data-skin") + } + } + switch (config.ui_theme) { case "system": resetMode() break + case "dre4moff": + setMode("dark") + break default: setMode(config.ui_theme) break diff --git a/src/lib/shared/daw-sync.svelte.ts b/src/lib/shared/daw-sync.svelte.ts new file mode 100644 index 0000000..15addde --- /dev/null +++ b/src/lib/shared/daw-sync.svelte.ts @@ -0,0 +1,782 @@ +import { listen, type UnlistenFn } from "@tauri-apps/api/event" +import { invoke } from "@tauri-apps/api/core" +import type { SampleAsset } from "$lib/splice/types" +import { globalAudio } from "$lib/shared/audio.svelte" +import { saveDawStretchedSampleInfo } from "$lib/shared/files.svelte" +import { semitonesFor } from "$lib/shared/transpose.svelte" +import { cancelAudioRenderWorker } from "$lib/shared/audio-render-worker-client" + +const MIN_PLAYBACK_RATE = 0.25 +const MAX_PLAYBACK_RATE = 4 +const PHRASE_LENGTH_BARS = 4 +const MAX_PACKET_AGE_MS = 250 +const SAMPLE_OUTPUT_LATENCY_COMPENSATION_SECONDS = 0.045 +const PHASE_CORRECTION_THRESHOLD_SECONDS = 0.04 +const ONE_SHOT_MAX_DURATION_MS = 3000 +const BAR_START_TRIGGER_WINDOW_BEATS = 0.08 + +type DawSyncPacket = { + source?: string + version?: number + playing?: boolean + bpm?: number + ppqPosition?: number + timeInSeconds?: number + packetSentAtMs?: number + audioPort?: number + timeSignatureNumerator?: number + timeSignatureDenominator?: number + looping?: boolean + loopStartPpq?: number + loopEndPpq?: number + loadedSampleDurationSeconds?: number + samplePositionSeconds?: number + sampleActive?: boolean +} + +export const dawSync = $state({ + connected: false, + playing: false, + bpm: null as number | null, + ppqPosition: 0, + barNumber: 1, + barIndex: 0, + beatInBar: 1, + beatsPerBar: 4, + phraseNumber: 1, + phrasePositionBeats: 0, + packetAgeMs: 0, + audioPort: 0, + nextPhraseBarNumber: 1, + timeSignatureNumerator: 4, + timeSignatureDenominator: 4, + looping: false, + loopStartPpq: 0, + loopEndPpq: 0, + waitingForBar: false, + nextBarAt: null as number | null, + lastPacketAt: 0, + lastError: null as string | null, + playbackEnabled: true, + visualCurrentTime: 0, + visualDuration: 0, + loadedSampleDurationSeconds: 0, + samplePositionSeconds: null as number | null, + sampleActive: null as boolean | null, +}) + +let unlisten: UnlistenFn | null = null +let statusTimer: number | null = null +let scheduledStart: number | null = null +let syncedAssetUuid: string | null = null +let pendingAssetUuid: string | null = null +let lastPpqPosition: number | null = null +let lastOneShotBarIndex: number | null = null +let loadedPluginSampleKey: string | null = null +let loadingPluginSampleKey: string | null = null +let sampleLoadGeneration = 0 +let retryPluginSampleAfterRender = false +let sampleLoadAbortController: AbortController | null = null +let scheduledPluginSampleKey: string | null = null +let scheduledPluginSampleTimer: number | null = null +let deferredTransposeReloadTimer: number | null = null +let deferredTransposeAssetUuid: string | null = null +let mutedAppForDaw = false +let volumeBeforeDawMute = 0.8 +let bridgePlaybackEnabled = true +let visualTimer = 0 + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function positiveModulo(value: number, divisor: number) { + return ((value % divisor) + divisor) % divisor +} + +function validTimeSignaturePart(value: unknown, fallback: number) { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback + return Math.max(1, Math.round(value)) +} + +function packetAgeMs(packet: DawSyncPacket, receivedAt: number) { + if ( + typeof packet.packetSentAtMs !== "number" || + !Number.isFinite(packet.packetSentAtMs) + ) { + return 0 + } + + return clamp(receivedAt - packet.packetSentAtMs, 0, MAX_PACKET_AGE_MS) +} + +function extrapolatePpqPosition( + ppqPosition: number, + bpm: number | null, + ageMs: number +) { + if (!bpm || bpm <= 0) return ppqPosition + return ppqPosition + (ageMs / 1000) * (bpm / 60) +} + +function setPreservesPitch(audio: HTMLAudioElement) { + const media = audio as HTMLAudioElement & { + preservesPitch?: boolean + mozPreservesPitch?: boolean + webkitPreservesPitch?: boolean + } + + media.preservesPitch = true + media.mozPreservesPitch = true + media.webkitPreservesPitch = true +} + +function applyTempoSync() { + if (!globalAudio.ref) return + + const sampleBpm = globalAudio.currentAsset?.bpm + if (!dawSync.bpm || !sampleBpm || sampleBpm <= 0) { + globalAudio.ref.playbackRate = 1 + return + } + + setPreservesPitch(globalAudio.ref) + globalAudio.ref.playbackRate = clamp( + dawSync.bpm / sampleBpm, + MIN_PLAYBACK_RATE, + MAX_PLAYBACK_RATE + ) +} + +function applyAppDawMute(connected: boolean) { + if (connected && !mutedAppForDaw) { + volumeBeforeDawMute = globalAudio.volume + globalAudio.volume = 0 + mutedAppForDaw = true + return + } + + if (!connected && mutedAppForDaw) { + globalAudio.volume = volumeBeforeDawMute + mutedAppForDaw = false + } +} + +function clearScheduledStart() { + if (scheduledStart !== null) { + window.clearTimeout(scheduledStart) + scheduledStart = null + } + pendingAssetUuid = null + dawSync.waitingForBar = false + dawSync.nextBarAt = null +} + +function updateBarPosition() { + dawSync.beatsPerBar = + dawSync.timeSignatureNumerator * (4 / dawSync.timeSignatureDenominator) + + const beatsPerBar = Math.max(1, dawSync.beatsPerBar) + const phraseBeats = Math.max(1, beatsPerBar * PHRASE_LENGTH_BARS) + const barIndex = Math.floor(dawSync.ppqPosition / beatsPerBar) + const positionInBar = positiveModulo(dawSync.ppqPosition, beatsPerBar) + const positionInPhrase = positiveModulo(dawSync.ppqPosition, phraseBeats) + + dawSync.barNumber = Math.max(1, barIndex + 1) + dawSync.barIndex = barIndex + dawSync.beatInBar = positionInBar + 1 + dawSync.phraseNumber = Math.floor(barIndex / PHRASE_LENGTH_BARS) + 1 + dawSync.phrasePositionBeats = positionInPhrase + dawSync.nextPhraseBarNumber = + (Math.floor(barIndex / PHRASE_LENGTH_BARS) + 1) * + PHRASE_LENGTH_BARS + + 1 +} + +function isOneShotAsset(asset: SampleAsset | null = globalAudio.currentAsset) { + if (!asset) return false + return asset.duration <= ONE_SHOT_MAX_DURATION_MS +} + +function pluginSampleKey(asset: SampleAsset, dawBpm: number) { + return `${asset.uuid}:full:${Math.round(dawBpm * 100)}:${semitonesFor(asset)}:${asset.duration}` +} + +function renderedDurationSeconds() { + const asset = globalAudio.currentAsset + if (dawSync.loadedSampleDurationSeconds > 0) { + return dawSync.loadedSampleDurationSeconds + } + + if (dawSync.connected && asset && !isOneShotAsset(asset)) { + return 0 + } + + const duration = Number.isFinite(globalAudio.duration) + ? globalAudio.duration + : 0 + + if (!asset || duration <= 0) return 0 + if (isOneShotAsset(asset)) return duration + + const sourceBpm = asset.bpm + if (!sourceBpm || sourceBpm <= 0 || !dawSync.bpm || dawSync.bpm <= 0) { + return duration + } + + return duration / clamp(dawSync.bpm / sourceBpm, MIN_PLAYBACK_RATE, MAX_PLAYBACK_RATE) +} + +function updateDawVisualPosition() { + const duration = renderedDurationSeconds() + dawSync.visualDuration = duration + + if ( + !dawSync.connected || + !dawSync.playing || + !bridgePlaybackEnabled || + !dawSync.bpm || + duration <= 0 + ) { + dawSync.visualCurrentTime = 0 + return + } + + const ageMs = Date.now() - dawSync.lastPacketAt + + if (dawSync.samplePositionSeconds !== null) { + if (isOneShotAsset() && dawSync.sampleActive === false) { + dawSync.visualCurrentTime = 0 + return + } + + const interpolatedPosition = + dawSync.samplePositionSeconds + Math.max(0, ageMs) / 1000 + + dawSync.visualCurrentTime = isOneShotAsset() + ? clamp(interpolatedPosition, 0, duration) + : positiveModulo(interpolatedPosition, duration) + return + } + + const visualPpq = + dawSync.ppqPosition + + (Math.max(0, ageMs) / 1000) * (dawSync.bpm / 60) + + if (isOneShotAsset()) { + const positionInBar = positiveModulo( + visualPpq, + Math.max(1, dawSync.beatsPerBar) + ) + const secondsInBar = positionInBar * (60 / dawSync.bpm) + dawSync.visualCurrentTime = + secondsInBar <= duration ? secondsInBar : duration + return + } + + const ppqReference = + dawSync.looping && dawSync.loopEndPpq > dawSync.loopStartPpq + ? dawSync.loopStartPpq + : 0 + const projectSeconds = + Math.max(0, visualPpq - ppqReference) * (60 / dawSync.bpm) + dawSync.visualCurrentTime = positiveModulo(projectSeconds, duration) +} + +function startVisualClock() { + if (visualTimer) return + + const tick = () => { + updateDawVisualPosition() + visualTimer = window.requestAnimationFrame(tick) + } + visualTimer = window.requestAnimationFrame(tick) +} + +function stopVisualClock() { + if (!visualTimer) return + window.cancelAnimationFrame(visualTimer) + visualTimer = 0 +} + +function clearScheduledPluginSample() { + if (scheduledPluginSampleTimer !== null) { + window.clearTimeout(scheduledPluginSampleTimer) + scheduledPluginSampleTimer = null + } + scheduledPluginSampleKey = null +} + +function clearDeferredTransposeReload() { + if (deferredTransposeReloadTimer !== null) { + window.clearTimeout(deferredTransposeReloadTimer) + deferredTransposeReloadTimer = null + } + deferredTransposeAssetUuid = null +} + +async function syncPluginSample(immediate = false) { + const asset = globalAudio.currentAsset + const dawBpm = dawSync.bpm + const audioPort = dawSync.audioPort + + if (!asset || !dawSync.connected || !dawBpm || !audioPort) return + + const key = pluginSampleKey(asset, dawBpm) + if ( + deferredTransposeAssetUuid !== null && + deferredTransposeAssetUuid !== asset.uuid + ) { + clearDeferredTransposeReload() + } + if (loadedPluginSampleKey === key || loadingPluginSampleKey === key) return + + if (!immediate && loadingPluginSampleKey === null) { + if (scheduledPluginSampleKey === key) return + clearScheduledPluginSample() + scheduledPluginSampleKey = key + scheduledPluginSampleTimer = window.setTimeout(() => { + scheduledPluginSampleTimer = null + scheduledPluginSampleKey = null + void syncPluginSample(true) + }, 140) + return + } + + if (loadingPluginSampleKey !== null) { + if (retryPluginSampleAfterRender) return + retryPluginSampleAfterRender = true + sampleLoadGeneration++ + sampleLoadAbortController?.abort() + return + } + + const generation = ++sampleLoadGeneration + clearScheduledPluginSample() + const abortController = new AbortController() + sampleLoadAbortController = abortController + loadingPluginSampleKey = key + dawSync.loadedSampleDurationSeconds = 0 + dawSync.samplePositionSeconds = null + dawSync.sampleActive = null + dawSync.visualCurrentTime = 0 + dawSync.visualDuration = 0 + + try { + const shouldCancel = () => + generation !== sampleLoadGeneration || + abortController.signal.aborted || + globalAudio.currentAsset?.uuid !== asset.uuid || + !dawSync.connected || + dawSync.audioPort !== audioPort + + const renderedSample = await saveDawStretchedSampleInfo(asset, dawBpm, { + shouldCancel, + signal: abortController.signal, + }) + if ( + generation !== sampleLoadGeneration || + globalAudio.currentAsset?.uuid !== asset.uuid || + !dawSync.connected || + dawSync.audioPort !== audioPort + ) { + return + } + + await invoke("daw_load_sample", { + audioPort, + uuid: asset.uuid, + path: renderedSample.path, + oneShot: isOneShotAsset(asset), + durationMs: asset.duration, + sourceBpm: asset.bpm ?? null, + renderedBpm: dawBpm, + }) + await sendBridgePlaybackEnabled(bridgePlaybackEnabled) + loadedPluginSampleKey = key + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return + dawSync.lastError = String(err) + loadedPluginSampleKey = null + } finally { + if (loadingPluginSampleKey === key) { + loadingPluginSampleKey = null + } + if (sampleLoadAbortController === abortController) { + sampleLoadAbortController = null + } + if (retryPluginSampleAfterRender) { + retryPluginSampleAfterRender = false + window.queueMicrotask(() => void syncPluginSample()) + } + } +} + +async function sendBridgePlaybackEnabled(enabled: boolean) { + if (!dawSync.connected || !dawSync.audioPort) return + + try { + await invoke("daw_set_playback_enabled", { + audioPort: dawSync.audioPort, + playbackEnabled: enabled, + }) + } catch (err) { + dawSync.lastError = String(err) + } +} + +export function toggleDawSyncedPlayback() { + if (!dawSync.connected) { + globalAudio.togglePlay() + return + } + + setDawSyncedPlaybackEnabled(!bridgePlaybackEnabled) +} + +export function setDawSyncedPlaybackEnabled(enabled: boolean) { + if (!dawSync.connected) { + return + } + + bridgePlaybackEnabled = enabled + dawSync.playbackEnabled = bridgePlaybackEnabled + void sendBridgePlaybackEnabled(bridgePlaybackEnabled) + + if (globalAudio.ref) { + if (bridgePlaybackEnabled) { + if (dawSync.playing && globalAudio.currentAsset) { + syncedAssetUuid = null + applyTransportSync() + } + } else { + globalAudio.ref.pause() + globalAudio.ref.currentTime = 0 + syncedAssetUuid = null + lastOneShotBarIndex = null + } + } +} + +export function notifyDawTransposeChanged() { + if (!dawSync.connected || !globalAudio.currentAsset || !dawSync.bpm) return + + const asset = globalAudio.currentAsset + const key = pluginSampleKey(asset, dawSync.bpm) + + sampleLoadGeneration++ + sampleLoadAbortController?.abort() + retryPluginSampleAfterRender = false + clearScheduledPluginSample() + clearDeferredTransposeReload() + + // Avoid immediately re-rendering the currently playing bridge sample while + // the user is choosing a new key/sample. If they stay on this sample, render + // the new key after a short idle window; selecting another sample cancels it. + loadedPluginSampleKey = key + loadingPluginSampleKey = null + deferredTransposeAssetUuid = asset.uuid + deferredTransposeReloadTimer = window.setTimeout(() => { + if ( + dawSync.connected && + globalAudio.currentAsset?.uuid === asset.uuid + ) { + loadedPluginSampleKey = null + void syncPluginSample(true) + } + clearDeferredTransposeReload() + }, 1200) +} + +function desiredMediaOffsetSeconds(compensateOutputLatency: boolean = true) { + const sampleBpm = globalAudio.currentAsset?.bpm + if (!sampleBpm || sampleBpm <= 0 || isOneShotAsset()) return 0 + + const latencyBeats = compensateOutputLatency + ? SAMPLE_OUTPUT_LATENCY_COMPENSATION_SECONDS * (sampleBpm / 60) + : 0 + const rawOffset = + (dawSync.phrasePositionBeats + latencyBeats) * (60 / sampleBpm) + const duration = + Number.isFinite(globalAudio.duration) && globalAudio.duration > 0 + ? globalAudio.duration + : 0 + + if (!duration) return rawOffset + return positiveModulo(rawOffset, duration) +} + +function seekToSyncedPhase() { + if (!globalAudio.ref) return + const target = desiredMediaOffsetSeconds() + try { + globalAudio.ref.currentTime = target + } catch (err) { + dawSync.lastError = String(err) + } +} + +function startSyncedPlayback(assetUuid: string) { + if (!globalAudio.ref || !globalAudio.currentAsset) return + if (globalAudio.currentAsset.uuid !== assetUuid || !dawSync.playing) return + + clearScheduledStart() + applyTempoSync() + seekToSyncedPhase() + syncedAssetUuid = assetUuid + if (isOneShotAsset()) { + lastOneShotBarIndex = dawSync.barIndex + } + globalAudio.ref.play().catch((err) => { + dawSync.lastError = String(err) + syncedAssetUuid = null + }) +} + +function correctPhaseDrift() { + if (!globalAudio.ref || globalAudio.paused || isOneShotAsset()) return + + const target = desiredMediaOffsetSeconds() + const duration = + Number.isFinite(globalAudio.duration) && globalAudio.duration > 0 + ? globalAudio.duration + : 0 + const actual = globalAudio.ref.currentTime + const directDiff = Math.abs(actual - target) + const wrappedDiff = duration + ? Math.min(directDiff, duration - directDiff) + : directDiff + + if (wrappedDiff > PHASE_CORRECTION_THRESHOLD_SECONDS) { + seekToSyncedPhase() + } +} + +function applyTransportSync() { + if (!globalAudio.ref || !globalAudio.currentAsset) return + + if (!bridgePlaybackEnabled) { + if (!globalAudio.paused || globalAudio.currentTime !== 0) { + globalAudio.ref.pause() + globalAudio.ref.currentTime = 0 + } + return + } + + if (dawSync.playing) { + applyTempoSync() + const oneShot = isOneShotAsset() + const positionInBar = positiveModulo( + dawSync.ppqPosition, + Math.max(1, dawSync.beatsPerBar) + ) + const nearBarStart = positionInBar <= BAR_START_TRIGGER_WINDOW_BEATS + const oneShotStillPlaying = + oneShot && + !globalAudio.paused && + globalAudio.currentTime > 0 && + (!Number.isFinite(globalAudio.duration) || + globalAudio.currentTime < globalAudio.duration - 0.02) + const oneShotNeedsRetrigger = + oneShot && + !oneShotStillPlaying && + nearBarStart && + lastOneShotBarIndex !== dawSync.barIndex + + if ( + oneShot && + syncedAssetUuid !== globalAudio.currentAsset.uuid && + !nearBarStart + ) { + globalAudio.ref.pause() + globalAudio.ref.currentTime = 0 + return + } + + if ( + syncedAssetUuid !== globalAudio.currentAsset.uuid || + oneShotNeedsRetrigger + ) { + globalAudio.ref.pause() + globalAudio.ref.currentTime = 0 + startSyncedPlayback(globalAudio.currentAsset.uuid) + } else if (!oneShot) { + correctPhaseDrift() + } + return + } + + clearScheduledStart() + syncedAssetUuid = null + lastOneShotBarIndex = null + if (!globalAudio.paused || globalAudio.currentTime !== 0) { + globalAudio.ref.pause() + globalAudio.ref.currentTime = 0 + } +} + +function parsePacket(payload: unknown): DawSyncPacket | null { + try { + const packet = + typeof payload === "string" ? JSON.parse(payload) : payload + if (!packet || typeof packet !== "object") return null + return packet as DawSyncPacket + } catch (err) { + dawSync.lastError = String(err) + return null + } +} + +function handlePacket(payload: unknown) { + const receivedAt = Date.now() + const packet = parsePacket(payload) + if (!packet || packet.source !== "splicerr-bridge") return + + const playing = + typeof packet.playing === "boolean" ? packet.playing : dawSync.playing + const bpm = + typeof packet.bpm === "number" && Number.isFinite(packet.bpm) + ? packet.bpm + : dawSync.bpm + const rawPpqPosition = + typeof packet.ppqPosition === "number" && + Number.isFinite(packet.ppqPosition) + ? packet.ppqPosition + : dawSync.ppqPosition + const ageMs = packetAgeMs(packet, receivedAt) + const ppqPosition = extrapolatePpqPosition(rawPpqPosition, bpm, ageMs) + const ppqDelta = lastPpqPosition === null ? 0 : ppqPosition - lastPpqPosition + const transportJumped = + playing && + lastPpqPosition !== null && + (ppqDelta < -0.25 || ppqDelta > Math.max(4, dawSync.beatsPerBar * 2)) + + const previousAudioPort = dawSync.audioPort + dawSync.connected = true + applyAppDawMute(true) + dawSync.playing = playing + dawSync.bpm = bpm + dawSync.ppqPosition = ppqPosition + dawSync.packetAgeMs = ageMs + dawSync.audioPort = + typeof packet.audioPort === "number" && packet.audioPort > 0 + ? Math.round(packet.audioPort) + : dawSync.audioPort + if (dawSync.audioPort !== previousAudioPort) { + void sendBridgePlaybackEnabled(bridgePlaybackEnabled) + } + dawSync.timeSignatureNumerator = validTimeSignaturePart( + packet.timeSignatureNumerator, + dawSync.timeSignatureNumerator + ) + dawSync.timeSignatureDenominator = validTimeSignaturePart( + packet.timeSignatureDenominator, + dawSync.timeSignatureDenominator + ) + dawSync.looping = + typeof packet.looping === "boolean" ? packet.looping : dawSync.looping + dawSync.loopStartPpq = + typeof packet.loopStartPpq === "number" && + Number.isFinite(packet.loopStartPpq) + ? packet.loopStartPpq + : dawSync.loopStartPpq + dawSync.loopEndPpq = + typeof packet.loopEndPpq === "number" && Number.isFinite(packet.loopEndPpq) + ? packet.loopEndPpq + : dawSync.loopEndPpq + dawSync.loadedSampleDurationSeconds = + typeof packet.loadedSampleDurationSeconds === "number" && + Number.isFinite(packet.loadedSampleDurationSeconds) && + packet.loadedSampleDurationSeconds > 0 + ? packet.loadedSampleDurationSeconds + : dawSync.loadedSampleDurationSeconds + dawSync.samplePositionSeconds = + typeof packet.samplePositionSeconds === "number" && + Number.isFinite(packet.samplePositionSeconds) && + packet.samplePositionSeconds >= 0 + ? packet.samplePositionSeconds + : dawSync.samplePositionSeconds + dawSync.sampleActive = + typeof packet.sampleActive === "boolean" + ? packet.sampleActive + : dawSync.sampleActive + dawSync.lastPacketAt = receivedAt + dawSync.lastError = null + + updateBarPosition() + if (transportJumped) { + syncedAssetUuid = null + lastOneShotBarIndex = null + } + void syncPluginSample() + applyTempoSync() + applyTransportSync() + lastPpqPosition = ppqPosition +} + +function resetPluginSampleState() { + loadedPluginSampleKey = null + loadingPluginSampleKey = null + retryPluginSampleAfterRender = false + clearScheduledPluginSample() + clearDeferredTransposeReload() + sampleLoadGeneration++ + sampleLoadAbortController?.abort() + sampleLoadAbortController = null + cancelAudioRenderWorker() + dawSync.loadedSampleDurationSeconds = 0 + dawSync.samplePositionSeconds = null + dawSync.sampleActive = null +} + +function updateConnectionStatus() { + const connected = Date.now() - dawSync.lastPacketAt < 1500 + dawSync.connected = connected + if (!connected) { + applyAppDawMute(false) + dawSync.playing = false + dawSync.audioPort = 0 + resetPluginSampleState() + clearScheduledStart() + syncedAssetUuid = null + lastOneShotBarIndex = null + lastPpqPosition = null + dawSync.visualCurrentTime = 0 + dawSync.visualDuration = 0 + } +} + +export async function startDawSync() { + if (unlisten) return + startVisualClock() + unlisten = await listen("daw-sync", (event) => { + handlePacket(event.payload) + }) + statusTimer = window.setInterval(() => { + updateConnectionStatus() + void syncPluginSample() + }, 500) +} + +export function stopDawSync() { + unlisten?.() + unlisten = null + if (statusTimer !== null) { + window.clearInterval(statusTimer) + statusTimer = null + } + clearScheduledStart() + syncedAssetUuid = null + lastOneShotBarIndex = null + lastPpqPosition = null + resetPluginSampleState() + stopVisualClock() + dawSync.connected = false + applyAppDawMute(false) + dawSync.audioPort = 0 + dawSync.visualCurrentTime = 0 + dawSync.visualDuration = 0 + if (globalAudio.ref) { + globalAudio.ref.playbackRate = 1 + } +} diff --git a/src/lib/shared/drag.svelte.ts b/src/lib/shared/drag.svelte.ts index 6b40470..24e09cb 100644 --- a/src/lib/shared/drag.svelte.ts +++ b/src/lib/shared/drag.svelte.ts @@ -1,7 +1,7 @@ import { startDrag } from "@crabnebula/tauri-plugin-drag" import { join, appCacheDir } from "@tauri-apps/api/path" import { exists, create, mkdir, readFile } from "@tauri-apps/plugin-fs" -import { saveSample, savePackImage } from "./files.svelte" +import { saveDawStretchedSample, saveSample, savePackImage } from "./files.svelte" import { semitonesFor } from "./transpose.svelte" import { loading } from "./loading.svelte" import type { SampleAsset, PackAsset } from "$lib/splice/types" @@ -103,6 +103,17 @@ const dragCache = new Map() const inFlight = new Map>() const cacheKey = (s: SampleAsset) => `${s.uuid}:${semitonesFor(s)}` +const dawCacheKey = (s: SampleAsset, bpm: number) => + `${cacheKey(s)}:daw-full:${Math.round(bpm)}` + +async function dragIconForSample(sampleAsset: SampleAsset) { + const pack = sampleAsset.parents.items[0] as PackAsset + const packImagePath = await savePackImage(sampleAsset) + if (packImagePath && (await exists(packImagePath))) { + return await createDragIcon(packImagePath, pack.uuid) + } + return await createInvisibleIcon() +} /** * Prepares a sample for dragging: descrambles + writes the WAV and builds the @@ -122,15 +133,7 @@ export function prefetchSampleDrag(sampleAsset: SampleAsset): Promise { + const key = dawCacheKey(sampleAsset, dawBpm) + const cached = dragCache.get(key) + if (cached) return Promise.resolve(cached) + const existing = inFlight.get(key) + if (existing) return existing + + const p = (async () => { + loading.samples.add(sampleAsset.uuid) + loading.samplesCount++ + try { + const path = await saveDawStretchedSample(sampleAsset, dawBpm) + const iconPath = await dragIconForSample(sampleAsset) + const data = { path, iconPath } + dragCache.set(key, data) + return data + } catch (e) { + console.error("โš ๏ธ Failed preparing DAW-stretched sample for drag", e) + return null + } finally { + loading.samples.delete(sampleAsset.uuid) + loading.samplesCount-- + inFlight.delete(key) + } + })() + inFlight.set(key, p) + return p +} + /** * dragstart handler. MUST stay synchronous: on macOS the native drag session * snapshots `NSApp.currentEvent`, so any `await` before `startDrag` leaves it @@ -165,3 +200,22 @@ export function handleSampleDrag(event: DragEvent, sampleAsset: SampleAsset) { prefetchSampleDrag(sampleAsset) } } + +export function handleDawSampleDrag( + event: DragEvent, + sampleAsset: SampleAsset, + dawBpm: number +) { + event.preventDefault() + const data = dragCache.get(dawCacheKey(sampleAsset, dawBpm)) + if (data) { + startDrag({ item: [data.path], icon: data.iconPath }) + } else { + console.log( + "๐Ÿซณ Preparing DAW-stretched sample", + sampleAsset.name, + "โ€” drag again once ready" + ) + prefetchDawSampleDrag(sampleAsset, dawBpm) + } +} diff --git a/src/lib/shared/files.svelte.ts b/src/lib/shared/files.svelte.ts index 33fd792..851b8f2 100644 --- a/src/lib/shared/files.svelte.ts +++ b/src/lib/shared/files.svelte.ts @@ -1,17 +1,31 @@ import type { SampleAsset } from "$lib/splice/types" import { join, sep } from "@tauri-apps/api/path" import { exists, create, mkdir } from "@tauri-apps/plugin-fs" -import { getDescrambledSampleURL } from "./store.svelte" +import { + getDescrambledSampleBytes, + getDescrambledSampleURL, +} from "./store.svelte" import { config, isSamplesDirValid } from "$lib/shared/config.svelte" import { - pitchShiftAudioBuffer, semitonesFor, transposeSuffix, } from "$lib/shared/transpose.svelte" -import { encode } from "node-wav" -import { Buffer } from "buffer" +import { + decodeAudioFromArrayBuffer, + decodeAudioFromURL, +} from "$lib/shared/wav" +import { renderAudioBufferToWav } from "$lib/shared/audio-render-worker-client" + +export type DawStretchedSample = { + path: string + durationSeconds: number +} -globalThis.Buffer = Buffer // node-wav needs Buffer which is not defined when using Vite +type EncodeOptions = { + shouldCancel?: () => boolean + releaseSourceAfterRender?: boolean + signal?: AbortSignal +} const sanitizePath = (path: string) => path.replace(/[^a-zA-Z0-9#_\-\.\/]/g, "_") @@ -42,6 +56,28 @@ async function ensureFileDirectoryExists(filePath: string) { } } +function nextFrame() { + return new Promise((resolve) => { + window.requestAnimationFrame(() => resolve()) + }) +} + +async function writeFileChunked(filePath: string, data: Uint8Array) { + await ensureFileDirectoryExists(filePath) + + const file = await create(filePath) + const chunkSize = 1024 * 1024 + + try { + for (let offset = 0; offset < data.byteLength; offset += chunkSize) { + await file.write(data.subarray(offset, offset + chunkSize)) + await nextFrame() + } + } finally { + await file.close() + } +} + export async function absoluteSamplePath(sampleAsset: SampleAsset, suffix = "") { if (!config.samples_dir) { throw new Error("โŒ Samples Directory not set") @@ -54,6 +90,77 @@ export async function absoluteSamplePath(sampleAsset: SampleAsset, suffix = "") return await join(config.samples_dir, sampleAssetPath(sampleAsset, suffix)) } +function estimatedRenderedDurationSeconds( + sampleAsset: SampleAsset, + tempoRatio: number +) { + const metadataSeconds = sampleAsset.duration / 1000 + if (!Number.isFinite(metadataSeconds) || metadataSeconds <= 0) return 0 + if (!Number.isFinite(tempoRatio) || tempoRatio <= 0) return metadataSeconds + return metadataSeconds / tempoRatio +} + +async function encodeSampleWavWithDuration( + sampleAsset: SampleAsset, + semitones = semitonesFor(sampleAsset), + tempoRatio = 1, + trimToMetadataDuration = true, + options: EncodeOptions = {} +): Promise<{ wavData: Uint8Array; durationSeconds: number }> { + const throwIfCancelled = () => { + if (options.shouldCancel?.() || options.signal?.aborted) { + throw new DOMException("Sample render cancelled", "AbortError") + } + } + + throwIfCancelled() + const decoded = options.releaseSourceAfterRender + ? await (async () => { + const bytes = await getDescrambledSampleBytes( + sampleAsset, + options.signal + ) + const arrayBuffer = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength + ) + return await decodeAudioFromArrayBuffer(arrayBuffer, { + isolatedContext: true, + signal: options.signal, + }) + })() + : await decodeAudioFromURL(await getDescrambledSampleURL(sampleAsset), { + signal: options.signal, + }) + + throwIfCancelled() + const metadataDuration = (sampleAsset.duration / 1000) / tempoRatio + const rendered = await renderAudioBufferToWav(decoded, { + semitones, + tempoRatio, + trimStartSeconds: config.cut_mp3_delay ? 0.012 : 0, + maxDurationSeconds: trimToMetadataDuration ? metadataDuration : null, + signal: options.signal, + }) + throwIfCancelled() + + console.info( + "๐ŸŽš๏ธ Encoded sample duration", + { + name: sampleAsset.name, + metadataSeconds: sampleAsset.duration / 1000, + decodedSeconds: decoded.duration, + renderedSeconds: rendered.durationSeconds, + trimToMetadataDuration, + } + ) + + return { + wavData: rendered.wavData, + durationSeconds: rendered.durationSeconds, + } +} + /** * Descrambles a sample and returns its decoded, trimmed WAV bytes (16-bit), * pitch-shifted by `semitones` (defaults to the current transpose setting). @@ -61,42 +168,20 @@ export async function absoluteSamplePath(sampleAsset: SampleAsset, suffix = "") */ export async function encodeSampleWav( sampleAsset: SampleAsset, - semitones = semitonesFor(sampleAsset) + semitones = semitonesFor(sampleAsset), + tempoRatio = 1, + trimToMetadataDuration = true, + options: EncodeOptions = {} ): Promise { - const blobURL = await getDescrambledSampleURL(sampleAsset) - - const response = await fetch(blobURL) - - const blob = await response.blob() - - const buffer = await blob.arrayBuffer() - - const decoded = await new AudioContext().decodeAudioData(buffer) - // Apply tempo-preserving pitch shift before trimming/encoding (no-op when 0) - const samples = pitchShiftAudioBuffer(decoded, semitones) - const channels: Float32Array[] = [] - - for (let i = 0; i < samples.numberOfChannels; i++) { - const channel = samples.getChannelData(i) - - // Calculate 12ms in samples based on the actual sample rate - const trimSamples = config.cut_mp3_delay ? Math.floor(samples.sampleRate * 0.012) : 0 - - const start = trimSamples - const end = (sampleAsset.duration / 1000) * samples.sampleRate + start - - // Make sure we don't try to slice beyond the available data - const safeEnd = Math.min(end, channel.length) - - channels.push(channel.subarray(start, safeEnd)) - } - - const wavData = encode(channels as any, { - bitDepth: 16, - sampleRate: samples.sampleRate, - }) - - return new Uint8Array(wavData) + return ( + await encodeSampleWavWithDuration( + sampleAsset, + semitones, + tempoRatio, + trimToMetadataDuration, + options + ) + ).wavData } export async function saveSample(sampleAsset: SampleAsset) { @@ -119,17 +204,77 @@ export async function saveSample(sampleAsset: SampleAsset) { console.log("๐Ÿ† Sample converted! Saving at", absolutePath) - await ensureFileDirectoryExists(absolutePath) - - const file = await create(absolutePath) - await file.write(wavData) - await file.close() + await writeFileChunked(absolutePath, wavData) console.log("๐ŸŽ‰ Success!") return absolutePath } +export async function saveDawStretchedSampleInfo( + sampleAsset: SampleAsset, + dawBpm: number, + options: EncodeOptions = {} +): Promise { + const semitones = semitonesFor(sampleAsset) + const sourceBpm = sampleAsset.bpm + const oneShot = sampleAsset.duration <= 3000 + const tempoRatio = + !oneShot && sourceBpm && sourceBpm > 0 && dawBpm > 0 + ? dawBpm / sourceBpm + : 1 + const suffix = `${transposeSuffix(semitones)}_daw_full_${Math.round(dawBpm)}bpm` + const absolutePath = await absoluteSamplePath(sampleAsset, suffix) + + if (!absolutePath) { + throw new Error("โŒ Invalid path") + } + + if (await exists(absolutePath)) { + console.log("๐Ÿ—ƒ๏ธ DAW-stretched sample already exists at", absolutePath) + if (options.shouldCancel?.()) { + throw new DOMException("Sample render cancelled", "AbortError") + } + return { + path: absolutePath, + durationSeconds: estimatedRenderedDurationSeconds( + sampleAsset, + tempoRatio + ), + } + } + + const { wavData, durationSeconds } = await encodeSampleWavWithDuration( + sampleAsset, + semitones, + tempoRatio, + false, + { + ...options, + releaseSourceAfterRender: true, + } + ) + + if (options.shouldCancel?.()) { + throw new DOMException("Sample render cancelled", "AbortError") + } + + console.log("๐Ÿ† DAW-stretched sample converted! Saving at", absolutePath) + + await writeFileChunked(absolutePath, wavData) + + console.log("๐ŸŽ‰ DAW-stretched sample saved!") + + return { path: absolutePath, durationSeconds } +} + +export async function saveDawStretchedSample( + sampleAsset: SampleAsset, + dawBpm: number +) { + return (await saveDawStretchedSampleInfo(sampleAsset, dawBpm)).path +} + export async function absolutePackImagePath(sampleAsset: SampleAsset) { if (!config.samples_dir) { throw new Error("โŒ Samples Directory not set") @@ -184,4 +329,3 @@ export async function savePackImage(sampleAsset: SampleAsset) { throw e } } - diff --git a/src/lib/shared/store.svelte.ts b/src/lib/shared/store.svelte.ts index 58f7a14..cace404 100644 --- a/src/lib/shared/store.svelte.ts +++ b/src/lib/shared/store.svelte.ts @@ -13,12 +13,15 @@ import type { } from "$lib/splice/types" import { globalAudio } from "./audio.svelte" import { loading } from "./loading.svelte" -import { fetch } from "@tauri-apps/plugin-http" -import { pitchShiftAudioBuffer, semitonesFor } from "./transpose.svelte" -import { audioBufferToWav, decodeAudioFromURL } from "./wav" +import { fetch as tauriFetch } from "@tauri-apps/plugin-http" +import { semitonesFor } from "./transpose.svelte" +import { decodeAudioFromURL } from "./wav" +import { renderAudioBufferToWav } from "$lib/shared/audio-render-worker-client" export const DEFAULT_SORT = "relevance" export const PER_PAGE = 50 +const MAX_DESCRAMBLED_BLOBS = 4 +const MAX_TRANSPOSED_BLOBS = 2 export const randomSeed = () => Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString() @@ -86,6 +89,47 @@ export const storeCallbacks = $state({ let currentQueryIdentity: string = "" +async function fetchBinaryResponse(url: string, signal?: AbortSignal) { + try { + return await window.fetch(url, { signal }) + } catch (err) { + if (signal?.aborted) throw err + console.info("๐ŸŒ Browser fetch failed, falling back to Tauri HTTP", err) + return await tauriFetch(url, { signal }) + } +} + +function touchMapEntry(map: Map, key: string) { + const value = map.get(key) + if (value === undefined) return value + + map.delete(key) + map.set(key, value) + return value +} + +function trimDescrambledCache() { + for (const [uuid, blobURL] of dataStore.descrambledSamples) { + if (dataStore.descrambledSamples.size <= MAX_DESCRAMBLED_BLOBS) break + if (uuid === globalAudio.currentAsset?.uuid) continue + + dataStore.descrambledSamples.delete(uuid) + window.URL.revokeObjectURL(blobURL) + console.info("๐Ÿงน Trimmed descrambled sample blob") + } +} + +function trimTransposedCache() { + for (const [key, blobURL] of dataStore.transposedSamples) { + if (dataStore.transposedSamples.size <= MAX_TRANSPOSED_BLOBS) break + if (key.startsWith(`${globalAudio.currentAsset?.uuid}:`)) continue + + dataStore.transposedSamples.delete(key) + window.URL.revokeObjectURL(blobURL) + console.info("๐Ÿงน Trimmed transposed sample blob") + } +} + export const fetchAssets = () => { const identityBeforeFetch = JSON.stringify(queryIdentity) if (identityBeforeFetch != currentQueryIdentity) { @@ -149,7 +193,10 @@ export const fetchAssets = () => { } export async function getDescrambledSampleURL(sampleAsset: SampleAsset) { - const existingBlobURL = dataStore.descrambledSamples.get(sampleAsset.uuid) + const existingBlobURL = touchMapEntry( + dataStore.descrambledSamples, + sampleAsset.uuid + ) if (existingBlobURL) { console.info("โœ”๏ธ Reusing descrambled sample blob") return existingBlobURL @@ -163,13 +210,13 @@ export async function getDescrambledSampleURL(sampleAsset: SampleAsset) { // bails out early. This bites samples in a collection whose cached CDN url has // expired and couldn't be refreshed (see refreshCollectionUrls). try { - let response = await fetch(sampleAsset.files[0].url) + let response = await fetchBinaryResponse(sampleAsset.files[0].url) if (!response.ok) { // The presigned url likely expired mid-session. Re-resolve it from the // parent pack and retry once before giving up. const refreshed = await refreshSampleUrl(sampleAsset) if (refreshed) { - response = await fetch(sampleAsset.files[0].url) + response = await fetchBinaryResponse(sampleAsset.files[0].url) } } if (!response.ok) { @@ -189,6 +236,7 @@ export async function getDescrambledSampleURL(sampleAsset: SampleAsset) { const blobURL = window.URL.createObjectURL(blob) dataStore.descrambledSamples.set(sampleAsset.uuid, blobURL) + trimDescrambledCache() console.info("๐Ÿ”— Created descrambled sample blob") @@ -199,6 +247,48 @@ export async function getDescrambledSampleURL(sampleAsset: SampleAsset) { } } +export async function getDescrambledSampleBytes( + sampleAsset: SampleAsset, + signal?: AbortSignal +) { + const throwIfAborted = () => { + if (signal?.aborted) { + throw new DOMException("Sample fetch cancelled", "AbortError") + } + } + + loading.samples.add(sampleAsset.uuid) + loading.samplesCount++ + + try { + throwIfAborted() + let response = await fetchBinaryResponse(sampleAsset.files[0].url, signal) + if (!response.ok) { + const refreshed = await refreshSampleUrl(sampleAsset) + if (refreshed) { + throwIfAborted() + response = await fetchBinaryResponse( + sampleAsset.files[0].url, + signal + ) + } + } + if (!response.ok) { + throw new Error( + `Sample fetch failed (${response.status} ${response.statusText}) โ€” the CDN url is likely expired` + ) + } + + throwIfAborted() + const data = new Uint8Array(await response.arrayBuffer()) + throwIfAborted() + return descrambleSample(data) + } finally { + loading.samples.delete(sampleAsset.uuid) + loading.samplesCount-- + } +} + export function freeDescrambledSample(uuid: string) { // Free any pitch-shifted variants of this sample first for (const key of [...dataStore.transposedSamples.keys()]) { @@ -227,7 +317,7 @@ export async function getTransposedSampleURL( semitones: number ) { const cacheKey = `${sampleAsset.uuid}:${semitones}` - const existing = dataStore.transposedSamples.get(cacheKey) + const existing = touchMapEntry(dataStore.transposedSamples, cacheKey) if (existing) { console.info("โœ”๏ธ Reusing transposed sample blob") return existing @@ -239,12 +329,17 @@ export async function getTransposedSampleURL( try { const descrambledURL = await getDescrambledSampleURL(sampleAsset) const buffer = await decodeAudioFromURL(descrambledURL) - const shifted = pitchShiftAudioBuffer(buffer, semitones) - const wav = audioBufferToWav(shifted) + const { wavData } = await renderAudioBufferToWav(buffer, { + semitones, + tempoRatio: 1, + trimStartSeconds: 0, + maxDurationSeconds: null, + }) const blobURL = window.URL.createObjectURL( - new Blob([wav], { type: "audio/wav" }) + new Blob([wavData], { type: "audio/wav" }) ) dataStore.transposedSamples.set(cacheKey, blobURL) + trimTransposedCache() console.info(`๐ŸŽš๏ธ Created transposed sample blob (${semitones} st)`) return blobURL } finally { diff --git a/src/lib/shared/transpose.svelte.ts b/src/lib/shared/transpose.svelte.ts index 2326fc7..b351504 100644 --- a/src/lib/shared/transpose.svelte.ts +++ b/src/lib/shared/transpose.svelte.ts @@ -133,28 +133,87 @@ export function pitchShiftAudioBuffer( const BLOCK = 4096 const interleaved = new Float32Array(BLOCK * 2) - const left: number[] = [] - const right: number[] = [] + const output = new AudioBuffer({ + length: originalLength, + numberOfChannels: channelCount, + sampleRate, + }) + const left = output.getChannelData(0) + const right = channelCount > 1 ? output.getChannelData(1) : null + let writeIndex = 0 let extracted: number // SoundTouch always works in stereo internally; mono input is duplicated. - while ((extracted = filter.extract(interleaved, BLOCK)) > 0) { - for (let i = 0; i < extracted; i++) { - left.push(interleaved[i * 2]) - right.push(interleaved[i * 2 + 1]) + while ( + writeIndex < originalLength && + (extracted = filter.extract(interleaved, BLOCK)) > 0 + ) { + const writable = Math.min(extracted, originalLength - writeIndex) + for (let i = 0; i < writable; i++) { + left[writeIndex + i] = interleaved[i * 2] + if (right) right[writeIndex + i] = interleaved[i * 2 + 1] } + writeIndex += writable + } + + return output +} + +/** + * Tempo-stretches an AudioBuffer while preserving pitch. `tempoRatio` > 1 makes + * the output shorter/faster; < 1 makes it longer/slower. + */ +export function tempoStretchAudioBuffer( + buffer: AudioBuffer, + tempoRatio: number +): AudioBuffer { + if (!Number.isFinite(tempoRatio) || Math.abs(tempoRatio - 1) < 0.001) { + return buffer } - // Trim/pad to the original length so loops stay seamless and exports keep timing. + const channelCount = buffer.numberOfChannels + const sampleRate = buffer.sampleRate + const originalLength = buffer.length + const targetLength = Math.max(1, Math.round(originalLength / tempoRatio)) + + const padFrames = Math.ceil(sampleRate * 0.5) + const padded = new AudioBuffer({ + length: originalLength + padFrames, + numberOfChannels: channelCount, + sampleRate, + }) + for (let c = 0; c < channelCount; c++) { + padded.copyToChannel(buffer.getChannelData(c), c) + } + + const soundtouch = new SoundTouch() + soundtouch.tempo = tempoRatio + + const source = new WebAudioBufferSource(padded) + const filter = new SimpleFilter(source, soundtouch) + + const BLOCK = 4096 + const interleaved = new Float32Array(BLOCK * 2) const output = new AudioBuffer({ - length: originalLength, + length: targetLength, numberOfChannels: channelCount, sampleRate, }) - const take = Math.min(left.length, originalLength) - output.copyToChannel(Float32Array.from(left.slice(0, take)), 0) - if (channelCount > 1) { - output.copyToChannel(Float32Array.from(right.slice(0, take)), 1) + const left = output.getChannelData(0) + const right = channelCount > 1 ? output.getChannelData(1) : null + let writeIndex = 0 + + let extracted: number + while ( + writeIndex < targetLength && + (extracted = filter.extract(interleaved, BLOCK)) > 0 + ) { + const writable = Math.min(extracted, targetLength - writeIndex) + for (let i = 0; i < writable; i++) { + left[writeIndex + i] = interleaved[i * 2] + if (right) right[writeIndex + i] = interleaved[i * 2 + 1] + } + writeIndex += writable } return output diff --git a/src/lib/shared/wav.ts b/src/lib/shared/wav.ts index edb5153..b4310a0 100644 --- a/src/lib/shared/wav.ts +++ b/src/lib/shared/wav.ts @@ -6,11 +6,46 @@ globalThis.Buffer = Buffer // node-wav needs Buffer which is not defined when us let sharedContext: AudioContext | null = null const audioContext = () => (sharedContext ??= new AudioContext()) +type DecodeOptions = { + isolatedContext?: boolean + signal?: AbortSignal +} + +function throwIfAborted(signal?: AbortSignal) { + if (signal?.aborted) { + throw new DOMException("Audio decode cancelled", "AbortError") + } +} + /** Decode an (already descrambled) audio blob URL into an AudioBuffer. */ -export async function decodeAudioFromURL(url: string): Promise { +export async function decodeAudioFromURL( + url: string, + options: DecodeOptions = {} +): Promise { + throwIfAborted(options.signal) const response = await fetch(url) + throwIfAborted(options.signal) const arrayBuffer = await response.arrayBuffer() - return await audioContext().decodeAudioData(arrayBuffer) + return await decodeAudioFromArrayBuffer(arrayBuffer, options) +} + +/** Decode audio bytes. Use an isolated context for one-off heavy renders. */ +export async function decodeAudioFromArrayBuffer( + arrayBuffer: ArrayBuffer, + options: DecodeOptions = {} +): Promise { + throwIfAborted(options.signal) + const context = options.isolatedContext ? new AudioContext() : audioContext() + + try { + const decoded = await context.decodeAudioData(arrayBuffer) + throwIfAborted(options.signal) + return decoded + } finally { + if (options.isolatedContext) { + void context.close().catch(() => undefined) + } + } } /** Encode an AudioBuffer to 16-bit PCM WAV bytes. */ diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index f35691e..4542182 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -9,6 +9,7 @@ settingsDialog, } from "$lib/shared/config.svelte" import { loadCollections } from "$lib/shared/collections.svelte" + import { startDawSync, stopDawSync } from "$lib/shared/daw-sync.svelte" import Toaster from "$lib/components/toaster.svelte" import { onMount } from "svelte" @@ -27,6 +28,8 @@ } }) loadCollections() + startDawSync() + return () => stopDawSync() }) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index ad3eebb..e7f9182 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -38,6 +38,7 @@ findCollection, removeSample, } from "$lib/shared/collections.svelte" + import { dawSync } from "$lib/shared/daw-sync.svelte" // Single source of truth for the active view. The header, list, empty state // and row actions all key off this one derived, so they can never disagree @@ -58,12 +59,28 @@ // The samples shown in the main list: search results in browse mode, // the collection's stored samples (optionally tag-filtered) in collection mode. const shownSamples = $derived.by(() => { - if (view.kind !== "collection") return dataStore.sampleAssets + // Splice pagination can return the same sample on more than one page. + // The results list is keyed by uuid, and a duplicate key throws in + // Svelte 5 (each_key_duplicate), which breaks rendering of that part of + // the list โ€” rows and waveforms vanish. Dedupe by uuid so keys are + // always unique. + const dedupeByUuid = (samples: T[]) => { + const seen = new Set() + return samples.filter((sample) => { + if (seen.has(sample.uuid)) return false + seen.add(sample.uuid) + return true + }) + } + if (view.kind !== "collection") + return dedupeByUuid(dataStore.sampleAssets) const samples = collectionSamples(view.collection.uuid) - if (viewStore.tagFilter.length == 0) return samples - return samples.filter((sample) => - viewStore.tagFilter.every((tagUuid) => - sample.tags.some((tag) => tag.uuid == tagUuid) + if (viewStore.tagFilter.length == 0) return dedupeByUuid(samples) + return dedupeByUuid( + samples.filter((sample) => + viewStore.tagFilter.every((tagUuid) => + sample.tags.some((tag) => tag.uuid == tagUuid) + ) ) ) }) @@ -163,13 +180,22 @@ fetchAssets() } + const selectOrPlaySample = (sampleAsset: (typeof shownSamples)[number]) => { + if (dawSync.connected) { + globalAudio.selectSampleAsset(sampleAsset, false) + return + } + + globalAudio.playSampleAsset(sampleAsset) + } + const gotoPrev = () => { const currentIndex = shownSamples.findIndex( (asset) => asset.uuid === globalAudio.currentAsset?.uuid ) if (currentIndex > 0) { const sampleAsset = shownSamples[currentIndex - 1] - globalAudio.playSampleAsset(sampleAsset) + selectOrPlaySample(sampleAsset) const entryEl = document.getElementById( `sample-list-entry-${sampleAsset.uuid}` ) @@ -184,7 +210,7 @@ ) if (currentIndex !== -1 && currentIndex + 1 < shownSamples.length) { const sampleAsset = shownSamples[currentIndex + 1] - globalAudio.playSampleAsset(sampleAsset) + selectOrPlaySample(sampleAsset) const entryEl = document.getElementById( `sample-list-entry-${sampleAsset.uuid}` ) @@ -220,17 +246,17 @@ }) -
-
+
+
-
-
-
+
+
+
{#if view.kind === "browse"}
@@ -313,7 +339,7 @@ tagsContainerRef.offsetHeight + "px" }) }} - class="shrink-0 h-6 px-5 text-muted-foreground" + class="shrink-0 h-8 px-5 rounded-full text-muted-foreground" >
-
-
+
+
{dataStore.total_records.toLocaleString()} results