#include "dspcontext.h" #include "audioclipbackend.h" #include "buffer.h" #include "remotebuffer.h" #include "rendercontext.h" #include "timing/piecewise_linear.h" #include "util.h" #include #include #include #include #include #include #include #include #include #include // Web audio glue static uint8_t audioThreadStack[3670016]; EMSCRIPTEN_WEBAUDIO_T DSPContext::audioContext = -1; bool DSPContext::asyncInitialized = false; int DSPContext::audioContextSampleRate = 0; void DSPContext::AudioThreadInitialized(EMSCRIPTEN_WEBAUDIO_T audioContext, bool success, void *userData) { if (!success) { auto continuation = static_cast(userData); continuation->reject(emscripten::val("Failed to initialize audio context")); delete continuation; return; } asyncInitialized = true; CreateProcessorAsync(static_cast(userData)); } void DSPContext::CreateProcessorAsync(ContinueDSPContextCreate *continuation) { WebAudioWorkletProcessorCreateOptions opts = { .name = continuation->name.c_str(), }; emscripten_create_wasm_audio_worklet_processor_async( audioContext, &opts, &AudioWorkletProcessorCreated, continuation); } void DSPContext::AudioWorkletProcessorCreated( EMSCRIPTEN_WEBAUDIO_T audioContext, bool success, void *userData) { if (!success) { auto continuation = static_cast(userData); continuation->reject(emscripten::val("Failed to create processor")); delete continuation; return; } auto continuation = static_cast(userData); int outputChannelCounts[1] = {continuation->channelCount}; EmscriptenAudioWorkletNodeCreateOptions options = {.numberOfInputs = 0, .numberOfOutputs = 1, .outputChannelCounts = outputChannelCounts}; DSPContextRoot *root = nullptr; try { root = new DSPContextRoot{ .instance = std::make_shared( continuation->vuAlpha, continuation->channelCount, audioContextSampleRate, false, true, continuation->analyticsObserver), }; } catch (const std::exception &e) { continuation->reject(emscripten::val(e.what())); delete continuation; return; } // Create node EMSCRIPTEN_AUDIO_WORKLET_NODE_T wasmAudioWorklet = emscripten_create_wasm_audio_worklet_node(audioContext, continuation->name.c_str(), &options, &RunWorklet, root); // Connect it to audio context destination EM_ASM({emscriptenGetAudioObject($0).connect( emscriptenGetAudioObject($1).destination)}, wasmAudioWorklet, audioContext); EM_ASM({ console.log(emscriptenGetAudioObject($0)); }, audioContext); if (emscripten_audio_context_state(audioContext) != AUDIO_CONTEXT_STATE_RUNNING) { emscripten_resume_audio_context_sync(audioContext); } continuation->resolve(root->instance); delete continuation; } bool DSPContext::RunWorklet(int numInputs, const AudioSampleFrame *inputs, int numOutputs, AudioSampleFrame *outputs, int numParams, const AudioParamFrame *params, void *userData) { auto *root = static_cast(userData); return root->instance->run(numInputs, inputs, numOutputs, outputs); } DSPContextPromise DSPContext::create(const std::string &name, float vuAlpha, const std::shared_ptr &analyticsObserver, std::optional channelCount) { int channelCountVal = channelCount.value_or(2); // only mono or stereo are supported for now assert(channelCountVal == 1 || channelCountVal == 2); auto promObj = make_blank_promise(); auto prom = promObj["promise"].as(); auto continuation = new ContinueDSPContextCreate{ .name = name, .vuAlpha = vuAlpha, .channelCount = channelCountVal, .analyticsObserver = analyticsObserver, .resolve = promObj["capturedResolve"], .reject = promObj["capturedReject"], }; if (audioContext == -1) { EmscriptenWebAudioCreateAttributes attrs = {.latencyHint = nullptr}; audioContext = emscripten_create_audio_context(&attrs); audioContextSampleRate = EM_ASM_INT( { var audioContext = emscriptenGetAudioObject($0); return audioContext.sampleRate; }, audioContext); emscripten_start_wasm_audio_worklet_thread_async( audioContext, audioThreadStack, sizeof(audioThreadStack), &AudioThreadInitialized, continuation); } else { assert(asyncInitialized); CreateProcessorAsync(continuation); } return prom; } DSPContextPromise DSPContext::createOffline(const std::string &name, float vuAlpha, int channelCount, int sampleRate, bool enableLimiter) { auto result = std::make_shared(vuAlpha, channelCount, sampleRate, true, enableLimiter, nullptr); return emscripten::val::global("Promise").call("resolve", result); } // End web audio glue static std::shared_ptr make_project_limiter(int channelCount, int sampleRate) { return std::make_shared(channelCount, sampleRate, 0.0279f, 0.0276f, 0.026f, 0.f, false, 0.8f); } DSPContext::DSPContext( float vuAlpha, int channelCount, int sampleRate, bool isOffline, bool enableLimiter, const std::shared_ptr &analyticsObserver) : sync_newPlayingTimeline(nullptr), rt_playingTimeline(nullptr), timelineDestroyRequests([] { return true; // XXX }), vuAlpha(vuAlpha), channelCount(channelCount), sampleRate(sampleRate), isOffline(isOffline), meter(std::make_shared(channelCount, sampleRate, 10, 10, vuAlpha)), analyticsObserver(analyticsObserver), mipMapWorker(isOffline ? nullptr : std::make_shared()), limiter(enableLimiter ? make_project_limiter(channelCount, sampleRate) : nullptr), timelineSharedState(std::make_shared()) {} DSPContext::~DSPContext() { if (rt_playingTimeline) { delete rt_playingTimeline; } if (sync_newPlayingTimeline) { delete sync_newPlayingTimeline; } } // Helper function to assign clips to conflict-free groups std::vector>> DSPContext::assignClipsToGroups( const std::vector> &allClips) { struct ClipInterval { std::shared_ptr clip; double startBeats; double endBeats; int groupIndex; ClipInterval(std::shared_ptr c) : clip(std::move(c)), groupIndex(-1) { startBeats = clip->getTimelineStartBeats(); endBeats = clip->getTimelineEndBeats(); } }; // Create interval list std::vector intervals; intervals.reserve(allClips.size()); for (auto clip : allClips) { intervals.emplace_back(clip); } // Sort intervals by start time (O(N log N)) std::sort(intervals.begin(), intervals.end(), [](const ClipInterval &a, const ClipInterval &b) { return a.startBeats < b.startBeats; }); // Greedy assignment using priority queue for group end times // Each entry: (end_time, group_index) std::priority_queue, std::vector>, std::greater>> groupEndTimes; int nextGroupIndex = 0; // Process each interval in start time order for (auto &interval : intervals) { int assignedGroup = -1; // Find earliest ending group that can accommodate this interval if (!groupEndTimes.empty() && groupEndTimes.top().first <= interval.startBeats) { // Reuse existing group assignedGroup = groupEndTimes.top().second; groupEndTimes.pop(); } else { // Create new group assignedGroup = nextGroupIndex++; } // Assign interval to group and update group end time interval.groupIndex = assignedGroup; groupEndTimes.emplace(interval.endBeats, assignedGroup); } // Convert to output format: group clips by assigned group index std::vector>> groups(nextGroupIndex); for (const auto &interval : intervals) { groups[interval.groupIndex].push_back(interval.clip); } return groups; } std::shared_ptr DSPContext::createTimeline( const std::shared_ptr &timing, const std::vector> &tracks) { // Collect all clips from all tracks std::vector> allClips; for (const auto &track : tracks) { for (int i = 0; i < track->getClipCount(); ++i) { allClips.push_back(track->getClip(i)); } } // Assign clips to conflict-free groups auto clipGroups = assignClipsToGroups(allClips); struct TimestretchReaderCompatKey { int underlyingSampleRate; int channelCount; TimestretchReaderCompatKey(int underlyingSampleRate, int channelCount) : underlyingSampleRate(underlyingSampleRate), channelCount(channelCount) {} bool operator<(const TimestretchReaderCompatKey &other) const { if (underlyingSampleRate != other.underlyingSampleRate) { return underlyingSampleRate < other.underlyingSampleRate; } return channelCount < other.channelCount; } }; struct BufferAssignment { std::shared_ptr buffer; int usedByGroup; BufferAssignment() : buffer(nullptr), usedByGroup(-1) {} }; struct TimestretchReaderAssignment { std::shared_ptr reader; int usedByGroup; TimestretchReaderAssignment() : reader(nullptr), usedByGroup(-1) {} }; std::unordered_map, BufferAssignment> bufferAssignments; std::map timestretchReaderAssignments; // Process each group for (int groupIdx = 0; groupIdx < (int)clipGroups.size(); ++groupIdx) { const auto &clipsInGroup = clipGroups[groupIdx]; // Resamplers by channel count std::map> resamplerAssignments; // Process clips in this group for (auto &clip : clipsInGroup) { const auto originalReadable = clip->getOriginalUnderlyingReadable(); if (originalReadable == nullptr) { continue; } int underlyingSampleRate = originalReadable->getSampleRate(); // Resampler auto resampler = resamplerAssignments[originalReadable->getChannelCount()]; if (resampler == nullptr) { resampler = std::make_shared( originalReadable->getChannelCount(), sampleRate, sampleRate); resamplerAssignments[originalReadable->getChannelCount()] = resampler; } // TimestretchReader auto &tsa = timestretchReaderAssignments[TimestretchReaderCompatKey( underlyingSampleRate, originalReadable->getChannelCount())]; if (tsa.reader == nullptr || tsa.usedByGroup != groupIdx) { tsa.reader = std::make_shared( originalReadable->getChannelCount(), originalReadable->getSampleRate(), sampleRate, true); tsa.usedByGroup = groupIdx; } // Readable auto &ba = bufferAssignments[originalReadable]; if (ba.buffer == nullptr || ba.usedByGroup != groupIdx) { ba.buffer = originalReadable->clone(); ba.usedByGroup = groupIdx; } clip->setBackend( std::make_shared(tsa.reader, ba.buffer, resampler)); } } return std::make_shared( sampleRate, timing, tracks, analyticsObserver, limiter, isOffline ? nullptr : meter, timelineSharedState); } std::shared_ptr DSPContext::createTrack(float gain, float pan, bool muted, const std::shared_ptr &meter, const std::shared_ptr &filterChain, const std::vector> &clips) { return std::make_shared(sampleRate, vuAlpha, gain, pan, muted, meter, filterChain, clips); } std::shared_ptr DSPContext::createAudioClip( std::shared_ptr underlyingReadable, std::shared_ptr> warpMap, float gain, double timelineStartBeats, double timelineEndBeats, double loopStartBeats, double loopEndBeats, double readStartBeats, double fadeInBeats, double fadeInExponent, double fadeOutBeats, double fadeOutExponent, float transposition, double warpedContentBps, bool warpEnabled, const std::string &arrangementId) { return std::make_shared( underlyingReadable, warpMap, sampleRate, gain, timelineStartBeats, timelineEndBeats, loopStartBeats, loopEndBeats, readStartBeats, fadeInBeats, fadeInExponent, fadeOutBeats, fadeOutExponent, transposition, warpedContentBps, warpEnabled, Uuid(arrangementId)); } std::shared_ptr DSPContext::createMeter() { return std::make_shared(channelCount, sampleRate, 10, 10, vuAlpha); } std::shared_ptr DSPContext::createFilterChain(int filterCount) { return std::make_shared(sampleRate, channelCount, filterCount); } void DSPContext::swapLiveTimeline( const std::shared_ptr &newTimeline) { flushDestroyRequests(); // Send new timeline, possibly getting back a pending timeline const auto tdr = new TimelineDestroyRequest(newTimeline); const auto tdrPtrVal = reinterpret_cast(tdr); while (true) { const auto pendingTdrPtrVal = emscripten_atomic_load_u32(&sync_newPlayingTimeline); if (pendingTdrPtrVal != 0) { auto pendingTdr = reinterpret_cast(pendingTdrPtrVal); if (pendingTdr->timeline == newTimeline) { // nothing to do delete tdr; break; } } const auto replacedVal = emscripten_atomic_cas_u32( &sync_newPlayingTimeline, pendingTdrPtrVal, tdrPtrVal); if (replacedVal == pendingTdrPtrVal) { // replaced successfully if (replacedVal != 0) { const auto replacedTdr = reinterpret_cast(replacedVal); // must delete the one we replaced delete replacedTdr; } break; } } } void DSPContext::flushDestroyRequests() { auto requests = timelineDestroyRequests.removeAll(); while (requests != nullptr) { auto reqCopy = requests; requests = requests->next; delete reqCopy; } } void DSPContext::resolvePendingTimelineSwap() { // Check for a pending timeline swap const auto pendingTdrPtrVal = emscripten_atomic_load_u32(&sync_newPlayingTimeline); if (pendingTdrPtrVal != 0 && pendingTdrPtrVal == emscripten_atomic_cas_u32(&sync_newPlayingTimeline, pendingTdrPtrVal, 0)) { // successfully swapped const auto pendingTdr = reinterpret_cast(pendingTdrPtrVal); if (rt_playingTimeline != nullptr) { timelineDestroyRequests.add(rt_playingTimeline); } rt_playingTimeline = pendingTdr; } } bool DSPContext::run(int numInputs, const AudioSampleFrame *inputs, int numOutputs, AudioSampleFrame *outputs) { assert(!isOffline); resolvePendingTimelineSwap(); assert(numOutputs == 1); auto &output = outputs[0]; auto outAsBuffer = BufferF32::fromVLA(output.numberOfChannels, output.samplesPerChannel, output.data); if (rt_playingTimeline != nullptr) { const auto &timeline = rt_playingTimeline->timeline; if (timeline != nullptr) { timeline->read(outAsBuffer); } return true; } outAsBuffer.fill(0.0); return true; } std::shared_ptr DSPContext::createBouncer(double start, double end, int bufferSize) { assert(isOffline); resolvePendingTimelineSwap(); if (rt_playingTimeline == nullptr) { return nullptr; } const auto &timeline = rt_playingTimeline->timeline; if (timeline == nullptr) { return nullptr; } return std::make_shared(timeline, start, end, channelCount, bufferSize); } std::shared_ptr DSPContext::createSumOnlyBouncer(int bufferSize) { assert(isOffline); return std::make_shared(bufferSize, channelCount, sampleRate); } void DSPContext::setLimiterParams(float lookaheadSeconds, float attackSeconds, float releaseSeconds, float preGainDb, bool bypass, float stereoLink) { assert(limiter != nullptr); limiter->setParameters(lookaheadSeconds, attackSeconds, releaseSeconds, preGainDb, bypass, stereoLink); } Bouncer::Bouncer(const std::shared_ptr &timeline, double start, double end, int channelCount, int bufferSize) : timeline(timeline), loopGuard(timeline), end(end) { timeline->setPlaying(true); timeline->setPosition(start); outputBuffer = std::make_shared(channelCount, bufferSize); } std::shared_ptr Bouncer::bounceNext() { if (timeline->getPosition() >= end) { return nullptr; } outputBuffer->fill(0.0); timeline->read(*outputBuffer); return outputBuffer; } SumOnlyBouncer::SumOnlyBouncer(int bufferSize, int channelCount, int sampleRate) : mixdownBuffer(std::make_shared(channelCount, bufferSize)), outputBuffer(std::make_shared(channelCount, bufferSize)), limiter(make_project_limiter(channelCount, sampleRate)), framesProduced(0), tailFramesProduced(0) { mixdownBuffer->fill(0.0); } void SumOnlyBouncer::sumIntoMixdownBuffer( const std::shared_ptr &buffer) { assert(buffer->getChannelCount() == mixdownBuffer->getChannelCount()); assert(buffer->getFrameCount() == mixdownBuffer->getFrameCount()); mixdownBuffer->sumWith(*buffer); } std::shared_ptr SumOnlyBouncer::bounceNext(bool inputFinished) { const auto delay = limiter ? limiter->getDelayFrames() : 0; if (limiter) { limiter->process(*mixdownBuffer); } framesProduced += mixdownBuffer->getFrameCount(); std::shared_ptr returnBuffer; if (tailFramesProduced > 0) { assert(inputFinished); } if (inputFinished) { const auto framesLeft = std::max(delay - (int)tailFramesProduced, 0); const auto framesToProduce = std::min(framesLeft, (int)mixdownBuffer->getFrameCount()); tailFramesProduced += framesToProduce; returnBuffer = std::make_shared(outputBuffer->slice(0, framesToProduce)); returnBuffer->set(0, mixdownBuffer->slice(0, framesToProduce)); } else { const auto framesToOutput = std::min(std::max((int)framesProduced - delay, 0), (int)mixdownBuffer->getFrameCount()); returnBuffer = std::make_shared(outputBuffer->slice(0, framesToOutput)); if (framesToOutput > 0) { returnBuffer->set(0, mixdownBuffer->slice(mixdownBuffer->getFrameCount() - framesToOutput)); } } mixdownBuffer->fill(0.0); return returnBuffer; } // this is a little roundabout EM_JS(emscripten::EM_VAL, get_audio_context_as_val, (int context_id), { return Emval.toHandle(emscriptenGetAudioObject(context_id)); }); AudioContextValType DSPContext::getAudioContext() { assert(!isOffline); return emscripten::val::take_ownership(get_audio_context_as_val(audioContext)) .as(); } EMSCRIPTEN_BINDINGS(dspcontext) { using namespace emscripten; register_type("Promise"); register_type("AudioContext"); register_optional(); class_("Bouncer") .smart_ptr>("Bouncer") .function("bounceNext", &Bouncer::bounceNext); class_("SumOnlyBouncer") .smart_ptr>("SumOnlyBouncer") .function("sumIntoMixdownBuffer", &SumOnlyBouncer::sumIntoMixdownBuffer) .function("bounceNext", &SumOnlyBouncer::bounceNext); class_("DSPContext") .smart_ptr>("DSPContext") .class_function("create(name, vuAlpha, analyticsObserver, channelCount)", &DSPContext::create) .class_function("createOffline(name, vuAlpha, channelCount, sampleRate, " "enableLimiter)", &DSPContext::createOffline) .function("createTimeline(timing, tracks)", &DSPContext::createTimeline, nonnull()) .function("createTrack(gain, pan, muted, meter, filterChain, clips)", &DSPContext::createTrack, nonnull()) .function( "createAudioClip(underlyingReadable, warpMap, gain, " "timelineStartBeats, timelineEndBeats, loopStartBeats, loopEndBeats, " "readStartBeats, fadeInBeats, fadeInExponent, fadeOutBeats, " "fadeOutExponent, transposition, warpedContentBps, warpEnabled, " "arrangementId)", &DSPContext::createAudioClip, nonnull()) .function("createMeter", &DSPContext::createMeter, nonnull()) .function("createFilterChain(filterCount)", &DSPContext::createFilterChain, nonnull()) .function("swapLiveTimeline(newLiveTimeline)", &DSPContext::swapLiveTimeline) .function("createBouncer(start, end, bufferSize)", &DSPContext::createBouncer, nonnull()) .function("createSumOnlyBouncer(bufferSize)", &DSPContext::createSumOnlyBouncer, nonnull()) .function("getAudioContext", &DSPContext::getAudioContext) .function("setLimiterParams(lookaheadSeconds, attackSeconds, " "releaseSeconds, preGainDb, bypass, stereoLink)", &DSPContext::setLimiterParams) .property("meter", &DSPContext::meter) .property("analyticsObserver", &DSPContext::analyticsObserver) .property("mipMapWorker", &DSPContext::mipMapWorker); } TEST_CASE("dspcontext scheduling tests", "[dspcontext]") { auto dsp = std::make_shared(1.0, 2, 44100, true, true, nullptr); // Helper function to create test AudioClip with specific timeline start/end // beats auto createTestClip = [](double startBeats, double endBeats) -> std::shared_ptr { auto buf = std::make_shared(2, 1024); buf->noise(); auto rab = std::make_shared(44100, buf); auto warpMap = std::make_shared>( std::vector::WarpMarker>{}); return std::make_shared(rab, warpMap, 44100, 1.0f, startBeats, endBeats, 0.0, endBeats, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0f, 120.0, false, Uuid()); }; SECTION("Empty input") { std::vector> emptyClips; auto result = dsp->assignClipsToGroups(emptyClips); REQUIRE(result.empty()); } SECTION("Single clip") { std::vector> clips; clips.push_back(createTestClip(0.0, 4.0)); auto result = dsp->assignClipsToGroups(clips); REQUIRE(result.size() == 1); REQUIRE(result[0].size() == 1); REQUIRE(result[0][0] == clips[0]); } SECTION("Non-overlapping clips in order") { std::vector> clips; clips.push_back(createTestClip(0.0, 2.0)); // [0-2] clips.push_back(createTestClip(3.0, 5.0)); // [3-5] clips.push_back(createTestClip(6.0, 8.0)); // [6-8] auto result = dsp->assignClipsToGroups(clips); REQUIRE(result.size() == 1); // All should be in same group REQUIRE(result[0].size() == 3); // Check that all clips are present std::set> resultSet(result[0].begin(), result[0].end()); std::set> inputSet(clips.begin(), clips.end()); REQUIRE(resultSet == inputSet); } SECTION("Non-overlapping clips out of order") { std::vector> clips; clips.push_back(createTestClip(6.0, 8.0)); // [6-8] clips.push_back(createTestClip(0.0, 2.0)); // [0-2] clips.push_back(createTestClip(3.0, 5.0)); // [3-5] auto result = dsp->assignClipsToGroups(clips); REQUIRE(result.size() == 1); // All should be in same group REQUIRE(result[0].size() == 3); // Check that all clips are present std::set> resultSet(result[0].begin(), result[0].end()); std::set> inputSet(clips.begin(), clips.end()); REQUIRE(resultSet == inputSet); } SECTION("Overlapping clips - simple case") { std::vector> clips; auto clip1 = createTestClip(0.0, 3.0); // [0-3] auto clip2 = createTestClip(2.0, 5.0); // [2-5] - overlaps with clip1 clips.push_back(clip1); clips.push_back(clip2); auto result = dsp->assignClipsToGroups(clips); REQUIRE(result.size() == 2); // Should be in separate groups // Each group should have one clip REQUIRE(result[0].size() == 1); REQUIRE(result[1].size() == 1); // Check that both clips are present in different groups std::set> allResultClips; for (const auto &group : result) { for (const auto &clip : group) { allResultClips.insert(clip); } } REQUIRE(allResultClips.size() == 2); REQUIRE(allResultClips.count(clip1) == 1); REQUIRE(allResultClips.count(clip2) == 1); } SECTION("Complex overlapping pattern") { std::vector> clips; auto clip1 = createTestClip(0.0, 2.0); // [0-2] auto clip2 = createTestClip(1.0, 3.0); // [1-3] - overlaps with clip1 auto clip3 = createTestClip(4.0, 6.0); // [4-6] - no overlap auto clip4 = createTestClip(5.0, 7.0); // [5-7] - overlaps with clip3 auto clip5 = createTestClip(8.0, 10.0); // [8-10] - no overlap with any clips.push_back(clip1); clips.push_back(clip2); clips.push_back(clip3); clips.push_back(clip4); clips.push_back(clip5); auto result = dsp->assignClipsToGroups(clips); // Expected groups: // Group 0: clip1, clip3 (or clip4), clip5 // Group 1: clip2, clip4 (or clip3) // The algorithm should produce at most 2 groups for this pattern REQUIRE(result.size() <= 2); // Check that all clips are assigned std::set> allResultClips; for (const auto &group : result) { for (const auto &clip : group) { allResultClips.insert(clip); } } REQUIRE(allResultClips.size() == 5); // Verify no conflicts within groups for (const auto &group : result) { for (size_t i = 0; i < group.size(); i++) { for (size_t j = i + 1; j < group.size(); j++) { double start1 = group[i]->getTimelineStartBeats(); double end1 = group[i]->getTimelineEndBeats(); double start2 = group[j]->getTimelineStartBeats(); double end2 = group[j]->getTimelineEndBeats(); // Clips should not overlap: end1 <= start2 OR end2 <= start1 REQUIRE((end1 <= start2 || end2 <= start1)); } } } } SECTION("Adjacent clips (touching boundaries)") { std::vector> clips; clips.push_back(createTestClip(0.0, 2.0)); // [0-2] clips.push_back( createTestClip(2.0, 4.0)); // [2-4] - starts exactly where first ends clips.push_back( createTestClip(4.0, 6.0)); // [4-6] - starts exactly where second ends auto result = dsp->assignClipsToGroups(clips); REQUIRE(result.size() == 1); // Should all be in same group (no overlap) REQUIRE(result[0].size() == 3); } SECTION("Same start and end times") { std::vector> clips; clips.push_back(createTestClip(1.0, 3.0)); // [1-3] clips.push_back(createTestClip(1.0, 3.0)); // [1-3] - identical clips.push_back(createTestClip(1.0, 2.0)); // [1-2] - overlaps both auto result = dsp->assignClipsToGroups(clips); REQUIRE(result.size() == 3); // All must be in separate groups due to overlaps // Each group should have exactly one clip for (const auto &group : result) { REQUIRE(group.size() == 1); } } SECTION("Zero-length clips") { std::vector> clips; clips.push_back(createTestClip(1.0, 1.0)); // [1-1] - zero length clips.push_back(createTestClip(2.0, 2.0)); // [2-2] - zero length clips.push_back(createTestClip(1.0, 3.0)); // [1-3] - overlaps first auto result = dsp->assignClipsToGroups(clips); REQUIRE(result.size() == 2); // First and third should be in different groups // Verify no conflicts within groups for (const auto &group : result) { for (size_t i = 0; i < group.size(); i++) { for (size_t j = i + 1; j < group.size(); j++) { double start1 = group[i]->getTimelineStartBeats(); double end1 = group[i]->getTimelineEndBeats(); double start2 = group[j]->getTimelineStartBeats(); double end2 = group[j]->getTimelineEndBeats(); // Clips should not overlap: end1 <= start2 OR end2 <= start1 REQUIRE((end1 <= start2 || end2 <= start1)); } } } } } TEST_CASE("resource assignment tests", "[dspcontext]") { auto dsp = std::make_shared(1.0f, 2, 44100, true, true, nullptr); // Create some shared audio buffers with different properties auto buffer44k_2ch = std::make_shared(2, 1024); buffer44k_2ch->noise(); auto readable44k_2ch = std::make_shared(44100, buffer44k_2ch); auto buffer48k_2ch = std::make_shared(2, 1024); buffer48k_2ch->noise(); auto readable48k_2ch = std::make_shared(48000, buffer48k_2ch); auto buffer44k_1ch = std::make_shared(1, 1024); buffer44k_1ch->noise(); auto readable44k_1ch = std::make_shared(44100, buffer44k_1ch); // Helper function to create AudioClip instances auto createClipWithReadable = [](std::shared_ptr readable, double startBeats, double endBeats) -> std::shared_ptr { auto warpMap = std::make_shared>( std::vector::WarpMarker>{}); return std::make_shared( readable, warpMap, 44100, 1.0f, startBeats, endBeats, 0.0, endBeats, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0f, 120.0, false, Uuid()); }; SECTION("Resource assignment with shared and unique readables") { std::vector> clips; // Create clips that share the same underlying readable clips.push_back(createClipWithReadable(readable44k_2ch, 0.0, 2.0)); // Group 0: 44.1kHz 2ch clips.push_back(createClipWithReadable(readable44k_2ch->clone(), 4.0, 6.0)); // Group 0: shared readable // Create clips with different sample rates and channel counts clips.push_back(createClipWithReadable(readable48k_2ch, 1.0, 3.0)); // Group 1: 48kHz 2ch clips.push_back(createClipWithReadable(readable44k_1ch, 2.5, 4.5)); // Group 1: 44.1kHz 1ch // Create another clip sharing the first readable (different group due to // overlap) clips.push_back( createClipWithReadable(readable44k_2ch->clone(), 1.5, 3.5)); // Group 2 // Create tracks with the clips auto meter = dsp->createMeter(); auto filterChain = dsp->createFilterChain(1); std::vector> tracks; // Split clips across multiple tracks to test track-level organization tracks.push_back(dsp->createTrack(1.0f, 0.0f, false, meter, filterChain, {clips[0], clips[1]})); tracks.push_back(dsp->createTrack(1.0f, 0.0f, false, meter, filterChain, {clips[2], clips[3], clips[4]})); // Create timing auto timing = std::make_shared( 2.0, std::vector{}); // Call createTimeline - this should assign backends to all clips auto timeline = dsp->createTimeline(timing, tracks); REQUIRE(timeline != nullptr); // Verify that all clips have backends assigned for (auto &clip : clips) { // Check that each clip now has a backend // We can't directly access the backend from the public interface, // so we'll check by attempting to call readSegment which requires a // backend // Create a test render context auto testTiming = std::make_shared( 2.0, std::vector{}); RenderContext renderContext( time_units::Beats(0.0), // position 2.0, // bps true, // isContinuous *testTiming, // globalBeatsToSeconds nullptr, // analyticsObserver time_units::BeatsDelta(512.0 / 44100.0 * 2.0) // duration ); // Try to read from the clip - this should not crash if backend is // properly assigned BufferF32 testOutput( clip->getOriginalUnderlyingReadable()->getChannelCount(), 512); clip->readSegment(&renderContext, testOutput); // If we get here without crashing, the backend was properly assigned } // Test that clips with the same underlying readable properties can share // resources This is implementation-specific and may vary, but we can at // least ensure no exceptions were thrown during timeline creation REQUIRE(true); // Timeline creation succeeded without exceptions // Additional verification could include checking that: // - Clips with same sample rate/channel count share timestretch readers // (within groups) // - Clips with same channel count share resamplers (within groups) // - Clips sharing the same original readable get cloned readables when in // different groups However, these details are internal to the // implementation } SECTION("Resource assignment with overlapping clips") { std::vector> clips; // Create overlapping clips that should be in different groups clips.push_back( createClipWithReadable(readable44k_2ch, 0.0, 4.0)); // Group 0 clips.push_back(createClipWithReadable(readable44k_2ch->clone(), 2.0, 6.0)); // Group 1 (overlaps) clips.push_back(createClipWithReadable(readable48k_2ch, 1.0, 3.0)); // Group 2 (overlaps both) auto meter = dsp->createMeter(); auto filterChain = dsp->createFilterChain(1); std::vector> tracks; tracks.push_back( dsp->createTrack(1.0f, 0.0f, false, meter, nullptr, clips)); auto timing = std::make_shared( 2.0, std::vector{}); // This should succeed and properly assign resources to overlapping clips auto timeline = dsp->createTimeline(timing, tracks); REQUIRE(timeline != nullptr); // Verify all clips can be read from auto testTiming2 = std::make_shared( 2.0, std::vector{}); RenderContext renderContext( time_units::Beats(0.0), // position 2.0, // bps true, // isContinuous *testTiming2, // globalBeatsToSeconds nullptr, // analyticsObserver time_units::BeatsDelta(512.0 / 44100.0 * 2.0) // duration ); for (auto &clip : clips) { BufferF32 testOutput( clip->getOriginalUnderlyingReadable()->getChannelCount(), 512); clip->readSegment(&renderContext, testOutput); // Successful read indicates proper backend assignment } } SECTION("Resource assignment with mixed sample rates and channel counts") { std::vector> clips; // Create non-overlapping clips with different audio properties clips.push_back( createClipWithReadable(readable44k_1ch, 0.0, 2.0)); // 44.1kHz 1ch clips.push_back( createClipWithReadable(readable44k_2ch, 2.0, 4.0)); // 44.1kHz 2ch clips.push_back( createClipWithReadable(readable48k_2ch, 4.0, 6.0)); // 48kHz 2ch // Should all be assignable to the same group since they don't overlap auto meter = dsp->createMeter(); std::vector> tracks; tracks.push_back( dsp->createTrack(1.0f, 0.0f, false, meter, nullptr, clips)); auto timing = std::make_shared( 2.0, std::vector{}); auto timeline = dsp->createTimeline(timing, tracks); REQUIRE(timeline != nullptr); // All clips should be readable auto testTiming3 = std::make_shared( 2.0, std::vector{}); RenderContext renderContext( time_units::Beats(0.0), // position 2.0, // bps true, // isContinuous *testTiming3, // globalBeatsToSeconds nullptr, // analyticsObserver time_units::BeatsDelta(512.0 / 44100.0 * 2.0) // duration ); for (auto &clip : clips) { BufferF32 testOutput( clip->getOriginalUnderlyingReadable()->getChannelCount(), 512); clip->readSegment(&renderContext, testOutput); // Successful read indicates proper backend assignment } } } TEST_CASE("sum only bouncer tests", "[dspcontext]") { auto dsp = std::make_shared(1.0f, 2, 44100, true, true, nullptr); const auto limiterDelay = make_project_limiter(2, 44100)->getDelayFrames(); // check limiter is working { auto bigBuffer = std::make_shared(2, 44100); for (int channel = 0; channel < 2; channel++) { auto channelData = bigBuffer->getChannelData(channel); for (int i = 0; i < 44100; i++) { channelData[i] = std::sin(i * 2.0f * M_PI / 44100.0f * 440.0f) * 10.0f; } } const auto bufferSize = 1024; auto sumOnlyBouncer = dsp->createSumOnlyBouncer(bufferSize); auto workBuffer = std::make_shared(2, bufferSize); int readPos = 0; int checkedFrames = 0; while (readPos + bufferSize < bigBuffer->getFrameCount()) { auto thisChunk = bigBuffer->slice(readPos, readPos + bufferSize); readPos += bufferSize; sumOnlyBouncer->sumIntoMixdownBuffer( std::make_shared(thisChunk)); auto result = sumOnlyBouncer->bounceNext(false); REQUIRE(result != nullptr); REQUIRE(result->getChannelCount() == 2); checkedFrames += result->getFrameCount(); if (result->getFrameCount() == 0) { continue; } bool allUnderLimit = true; for (int channel = 0; channel < 2 && allUnderLimit; channel++) { auto channelData = result->getChannelData(channel); for (int i = 0; i < bufferSize; i++) { if (std::abs(channelData[i]) > 1.0f) { allUnderLimit = false; break; } } } REQUIRE(allUnderLimit); } REQUIRE(checkedFrames > 10000); } for (auto bufferSize : {123, 456, 17, 1024}) { auto sumOnlyBouncer = dsp->createSumOnlyBouncer(bufferSize); int overallFrames = 2 * limiterDelay + bufferSize - (2 * limiterDelay) % bufferSize; BufferF32 overallInput(2, overallFrames), overallOutput(2, overallFrames); for (int channel = 0; channel < 2; channel++) { auto channelData = overallInput.getChannelData(channel); for (int i = 0; i < overallFrames; i++) { channelData[i] = std::sin(i * 2.0f * M_PI / 44100.0f * 440.0f) * 0.7f; } } overallOutput.fill(0.0f); int inputPosition = 0; int outputPosition = 0; REQUIRE(sumOnlyBouncer != nullptr); for (int i = 0; i < limiterDelay / bufferSize; i++) { sumOnlyBouncer->sumIntoMixdownBuffer(std::make_shared( overallInput.slice(inputPosition, inputPosition + bufferSize))); inputPosition += bufferSize; auto result = sumOnlyBouncer->bounceNext(false); REQUIRE(result != nullptr); REQUIRE(result->getFrameCount() == 0); REQUIRE(result->getChannelCount() == 2); } sumOnlyBouncer->sumIntoMixdownBuffer(std::make_shared( overallInput.slice(inputPosition, inputPosition + bufferSize))); inputPosition += bufferSize; auto result = sumOnlyBouncer->bounceNext(false); REQUIRE(result != nullptr); REQUIRE(result->getFrameCount() == bufferSize - limiterDelay % bufferSize); overallOutput .slice(outputPosition, outputPosition + result->getFrameCount()) .set(0, *result); outputPosition += result->getFrameCount(); while (inputPosition < overallFrames) { sumOnlyBouncer->sumIntoMixdownBuffer(std::make_shared( overallInput.slice(inputPosition, inputPosition + bufferSize))); inputPosition += bufferSize; auto result = sumOnlyBouncer->bounceNext(false); REQUIRE(result != nullptr); REQUIRE(result->getFrameCount() == bufferSize); overallOutput .slice(outputPosition, outputPosition + result->getFrameCount()) .set(0, *result); outputPosition += result->getFrameCount(); } for (;;) { auto result = sumOnlyBouncer->bounceNext(true); REQUIRE(result != nullptr); if (result->getFrameCount() == 0) { break; } overallOutput .slice(outputPosition, outputPosition + result->getFrameCount()) .set(0, *result); outputPosition += result->getFrameCount(); } REQUIRE(outputPosition == overallFrames); for (int channel = 0; channel < 2; channel++) { auto inChannelData = overallInput.getChannelData(channel); auto outChannelData = overallOutput.getChannelData(channel); for (int i = 0; i < overallFrames; i++) { // TODO the limiter shouldn't be changing the signal *this* much... REQUIRE(std::abs(inChannelData[i] - outChannelData[i]) < 0.1f); } } } } TEST_CASE("offline context creation tests", "[dspcontext]") { SECTION("Mono offline context creation") { auto monoContext = std::make_shared(1.0f, 1, 44100, true, false, nullptr); REQUIRE(monoContext != nullptr); } SECTION("Stereo offline context creation") { auto stereoContext = std::make_shared(1.0f, 2, 48000, true, true, nullptr); REQUIRE(stereoContext != nullptr); } } TEST_CASE("bouncer tests", "[dspcontext]") { for (auto channelCount : {1, 2}) { DYNAMIC_SECTION("Bounce a single buffer, channel count: " << channelCount) { auto dsp = std::make_shared(1.0f, channelCount, 44100, true, false, nullptr); auto buffer = std::make_shared(channelCount, 4096); for (int channel = 0; channel < channelCount; channel++) { auto channelData = buffer->getChannelData(channel); for (int i = 0; i < 4096; i++) { channelData[i] = std::sin(i * 2.0f * M_PI / 44100.0f * 440.0f) * 0.5f; } } auto readable = std::make_shared(44100, buffer); auto warpMap = std::make_shared>( std::vector::WarpMarker>{}); auto clip = std::make_shared( readable, warpMap, 44100, 1.0f, 0.0, 4.0, 0.0, 4.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0f, 120.0, false, Uuid()); auto meter = dsp->createMeter(); std::vector> clips = {clip}; auto track = dsp->createTrack(1.0f, 0.0f, false, meter, nullptr, clips); auto timing = std::make_shared( 2.0, // bps (beats per second) std::vector{}); std::vector> tracks = {track}; auto timeline = dsp->createTimeline(timing, tracks); dsp->swapLiveTimeline(timeline); auto bouncer = dsp->createBouncer(0.0, 4.0, 1024); REQUIRE(bouncer != nullptr); int totalFramesBounced = 0; int iterationCount = 0; while (true) { auto result = bouncer->bounceNext(); if (result == nullptr) { break; } REQUIRE(result->getChannelCount() == channelCount); REQUIRE(result->getFrameCount() <= 1024); bool hasNonZeroData = false; for (int channel = 0; channel < channelCount; channel++) { auto channelData = result->getChannelData(channel); for (int i = 0; i < result->getFrameCount(); i++) { if (std::abs(channelData[i]) > 0.001f) { hasNonZeroData = true; break; } } if (hasNonZeroData) break; } totalFramesBounced += result->getFrameCount(); iterationCount++; } REQUIRE(totalFramesBounced > 80000); REQUIRE(totalFramesBounced < 100000); REQUIRE(iterationCount > 0); } } }