diff --git a/examples/graphics/source/examples/AudioFileDemo.h b/examples/graphics/source/examples/AudioFileDemo.h index 51f3c65a9..97ae96721 100644 --- a/examples/graphics/source/examples/AudioFileDemo.h +++ b/examples/graphics/source/examples/AudioFileDemo.h @@ -221,9 +221,12 @@ class MeteringAudioSource : public yup::PositionableAudioSource class TimeStretchAudioSource : public yup::PositionableAudioSource { public: - TimeStretchAudioSource (yup::PositionableAudioSource* sourceToWrap, int numChannelsToUse) + TimeStretchAudioSource (yup::PositionableAudioSource* sourceToWrap, + int numChannelsToUse, + yup::TimeStretchProcessor::Backend backendToUse) : source (sourceToWrap) , numChannels (numChannelsToUse) + , preferredBackend (backendToUse) { } @@ -243,7 +246,7 @@ class TimeStretchAudioSource : public yup::PositionableAudioSource spec.maximumBlockSize = maxInputBlockSize; spec.numChannels = numChannels; - const auto result = timeStretchProcessor.prepare (spec); + const auto result = timeStretchProcessor.prepare (spec, preferredBackend); timeStretchAvailable = result.wasOk(); if (timeStretchAvailable) @@ -420,9 +423,11 @@ class TimeStretchAudioSource : public yup::PositionableAudioSource muteHead = static_cast (clampedBegin - beginFrame); muteTail = static_cast ((beginFrame + numFrames) - clampedEnd); const int validFrames = static_cast (clampedEnd - clampedBegin); + const int framesToRead = yup::jmin (validFrames, tempBuffer.getNumSamples()); + muteTail = yup::jlimit (0, numFrames - muteHead, muteTail + validFrames - framesToRead); source->setNextReadPosition (clampedBegin); - yup::AudioSourceChannelInfo info (&tempBuffer, 0, validFrames); + yup::AudioSourceChannelInfo info (&tempBuffer, 0, framesToRead); source->getNextAudioBlock (info); for (int ch = 0; ch < numChannels; ++ch) @@ -431,11 +436,11 @@ class TimeStretchAudioSource : public yup::PositionableAudioSource std::fill (destChannels[ch], destChannels[ch] + muteHead, 0.0f); std::copy (tempBuffer.getReadPointer (ch), - tempBuffer.getReadPointer (ch) + validFrames, + tempBuffer.getReadPointer (ch) + framesToRead, destChannels[ch] + muteHead); if (muteTail > 0) - std::fill (destChannels[ch] + muteHead + validFrames, + std::fill (destChannels[ch] + numFrames - muteTail, destChannels[ch] + numFrames, 0.0f); } @@ -468,6 +473,7 @@ class TimeStretchAudioSource : public yup::PositionableAudioSource double sampleRate = 0.0; yup::int64 outputPosition = 0; bool timeStretchAvailable = false; + yup::TimeStretchProcessor::Backend preferredBackend = yup::TimeStretchProcessor::Backend::automatic; std::atomic timeRatio { 1.0 }; std::atomic pitchRatio { 1.0 }; @@ -593,7 +599,8 @@ class AudioFileDemo : public yup::Component header.removeFromTop (4); auto stretchRow = header.removeFromTop (buttonHeight); const int sliderLabelWidth = 70; - const int sliderWidth = 180; + const int sliderWidth = 165; + const int backendWidth = 150; timeLabel.setBounds (stretchRow.removeFromLeft (sliderLabelWidth)); stretchRow.removeFromLeft (buttonMargin); @@ -602,6 +609,10 @@ class AudioFileDemo : public yup::Component pitchLabel.setBounds (stretchRow.removeFromLeft (sliderLabelWidth)); stretchRow.removeFromLeft (buttonMargin); pitchShiftSlider.setBounds (stretchRow.removeFromLeft (sliderWidth)); + stretchRow.removeFromLeft (buttonMargin * 2); + backendLabel.setBounds (stretchRow.removeFromLeft (sliderLabelWidth)); + stretchRow.removeFromLeft (buttonMargin); + backendComboBox.setBounds (stretchRow.removeFromLeft (backendWidth)); bounds.removeFromTop (6); infoLabel.setBounds (bounds.removeFromTop (22)); @@ -850,10 +861,31 @@ class AudioFileDemo : public yup::Component timeStretchSource->setPitchRatio (pitchShiftRatio); }; + addAndMakeVisible (backendLabel); + backendLabel.setText ("Backend", yup::NotificationType::dontSendNotification); + backendLabel.setColor (yup::Label::Style::textFillColorId, yup::Colors::white); + + addAndMakeVisible (backendComboBox); + backendComboBox.addItem ("Auto", backendAutomaticId); + backendComboBox.addItem ("Time Domain", backendTimeDomainId); + + if (yup::TimeStretchProcessor::isBackendAvailable (yup::TimeStretchProcessor::Backend::bungee)) + backendComboBox.addItem ("Bungee", backendBungeeId); + + backendComboBox.setSelectedId (getBackendId (selectedTimeStretchBackend), + yup::NotificationType::dontSendNotification); + backendComboBox.onSelectedItemChanged = [this] + { + const auto backend = getBackendForId (backendComboBox.getSelectedId()); + setTimeStretchBackend (backend); + }; + timeStretchSlider.setEnabled (timeStretchSupported); pitchShiftSlider.setEnabled (timeStretchSupported); + backendComboBox.setEnabled (timeStretchSupported); timeLabel.setEnabled (timeStretchSupported); pitchLabel.setEnabled (timeStretchSupported); + backendLabel.setEnabled (timeStretchSupported); addAndMakeVisible (waveformDisplay); waveformDisplay.setSelectable (true); @@ -877,6 +909,40 @@ class AudioFileDemo : public yup::Component infoLabel.setText (currentFileName + " | " + positionText + " | Stopped", yup::NotificationType::dontSendNotification); } + void setTimeStretchBackend (yup::TimeStretchProcessor::Backend backend) + { + if (selectedTimeStretchBackend == backend) + return; + + selectedTimeStretchBackend = backend; + + if (hasLoadedAudio && memorySource != nullptr) + rebuildTimeStretchSource(); + + updateStatus ("Time stretch backend: " + getBackendName (selectedTimeStretchBackend)); + updatePlaybackStatus(); + } + + void rebuildTimeStretchSource() + { + const bool wasPlaying = transportSource.isPlaying(); + const double previousPosition = transportSource.getCurrentPosition(); + + transportSource.stop(); + transportSource.setSource (nullptr); + + timeStretchSource = std::make_unique (memorySource.get(), + audioBuffer.getNumChannels(), + selectedTimeStretchBackend); + timeStretchSource->setTimeRatio (timeStretchRatio); + timeStretchSource->setPitchRatio (pitchShiftRatio); + transportSource.setSource (timeStretchSource.get(), 0, nullptr, loadedSampleRate, audioBuffer.getNumChannels()); + transportSource.setPosition (previousPosition); + + if (wasPlaying) + transportSource.start(); + } + void togglePlayback() { if (! hasLoadedAudio) @@ -939,7 +1005,9 @@ class AudioFileDemo : public yup::Component transportSource.stop(); transportSource.setSource (nullptr); memorySource = std::make_unique (audioBuffer, false, loopEnabled); - timeStretchSource = std::make_unique (memorySource.get(), numChannels); + timeStretchSource = std::make_unique (memorySource.get(), + numChannels, + selectedTimeStretchBackend); timeStretchSource->setTimeRatio (timeStretchRatio); timeStretchSource->setPitchRatio (pitchShiftRatio); transportSource.setSource (timeStretchSource.get(), 0, nullptr, loadedSampleRate, numChannels); @@ -1022,6 +1090,56 @@ class AudioFileDemo : public yup::Component return "*.wav;*.aiff;*.aif;*.flac;*.mp3;*.opus;*.m4a;*.wma;*.ogg"; } + static int getBackendId (yup::TimeStretchProcessor::Backend backend) + { + switch (backend) + { + case yup::TimeStretchProcessor::Backend::automatic: + return backendAutomaticId; + + case yup::TimeStretchProcessor::Backend::timeDomain: + return backendTimeDomainId; + + case yup::TimeStretchProcessor::Backend::bungee: + return backendBungeeId; + } + + return backendAutomaticId; + } + + static yup::TimeStretchProcessor::Backend getBackendForId (int backendId) + { + switch (backendId) + { + case backendTimeDomainId: + return yup::TimeStretchProcessor::Backend::timeDomain; + + case backendBungeeId: + return yup::TimeStretchProcessor::Backend::bungee; + + case backendAutomaticId: + default: + return yup::TimeStretchProcessor::Backend::automatic; + } + } + + static yup::String getBackendName (yup::TimeStretchProcessor::Backend backend) + { + switch (backend) + { + case yup::TimeStretchProcessor::Backend::automatic: + return "Auto"; + + case yup::TimeStretchProcessor::Backend::timeDomain: + return "Time Domain"; + + case yup::TimeStretchProcessor::Backend::bungee: + return "Bungee"; + } + + return "Auto"; + } + yup::AudioFormatManager formatManager; yup::AudioDeviceManager deviceManager; yup::AudioSourcePlayer sourcePlayer; @@ -1050,8 +1168,10 @@ class AudioFileDemo : public yup::Component yup::Label timeLabel; yup::Label pitchLabel; + yup::Label backendLabel; yup::Slider timeStretchSlider { yup::Slider::LinearHorizontal, "Time Stretch" }; yup::Slider pitchShiftSlider { yup::Slider::LinearHorizontal, "Pitch Shift" }; + yup::ComboBox backendComboBox { "TimeStretchBackend" }; yup::Label infoLabel; yup::Label statusLabel; @@ -1068,7 +1188,12 @@ class AudioFileDemo : public yup::Component double audioLengthSeconds = 0.0; double timeStretchRatio = 1.0; double pitchShiftRatio = 1.0; + yup::TimeStretchProcessor::Backend selectedTimeStretchBackend = yup::TimeStretchProcessor::Backend::automatic; bool hasLoadedAudio = false; bool loopEnabled = false; bool timeStretchSupported = false; + + static constexpr int backendAutomaticId = 1; + static constexpr int backendTimeDomainId = 2; + static constexpr int backendBungeeId = 3; }; diff --git a/modules/yup_dsp/stretching/yup_TimeStretchBungeeBackend.h b/modules/yup_dsp/stretching/yup_TimeStretchBungeeBackend.h new file mode 100644 index 000000000..a9ed3b9f8 --- /dev/null +++ b/modules/yup_dsp/stretching/yup_TimeStretchBungeeBackend.h @@ -0,0 +1,243 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +namespace yup +{ + +//============================================================================== +#if YUP_ENABLE_BUNGEE + +class BungeeTimeStretchBackend : public TimeStretchProcessor::Engine +{ +public: + Result prepare (const TimeStretchProcessor::ProcessSpec& specToUse) override + { + sampleRates.input = static_cast (std::round (specToUse.inputSampleRate)); + sampleRates.output = static_cast (std::round (specToUse.outputSampleRate)); + channelCount = specToUse.numChannels; + maximumBlockSize = specToUse.maximumBlockSize; + + stretcher = std::make_unique> (sampleRates, channelCount, 0); + const int maxFrames = stretcher->maxInputFrameCount(); + maxInputFrameCount = maxFrames; + + // Allocate contiguous buffer with strided layout + // Layout: [ch0_frame0...ch0_frameN][ch1_frame0...ch1_frameN]... + inputChunkBuffer.resize (static_cast (channelCount * maxFrames)); + channelPtrs.resize (static_cast (channelCount)); + + resetState (0); + return Result::ok(); + } + + void reset() override + { + if (stretcher == nullptr || channelCount <= 0 || maximumBlockSize <= 0) + return; + + resetState (pendingInputPosition); + } + + void setInputPosition (int64 newInputPosition) override + { + pendingInputPosition = newInputPosition; + seekPending = true; + } + + void setParameters (const TimeStretchProcessor::Parameters& newParameters) override + { + parameters = newParameters; + } + + void setInputProvider (TimeStretchProcessor::InputProvider provider) override + { + inputProvider = std::move (provider); + } + + int getMaxInputFrameCount() const override + { + return maxInputFrameCount; + } + + int process (const float* const* inputChannels, + int inputFrameCount, + float* const* outputChannels, + int outputFrameCount) override + { + (void) inputChannels; + (void) inputFrameCount; + + if (stretcher == nullptr || outputChannels == nullptr || outputFrameCount <= 0) + return 0; + + if (inputProvider == nullptr) + return 0; + + if (seekPending) + { + resetState (pendingInputPosition); + seekPending = false; + } + + if (! requestInitialized) + initializeRequest(); + + request.speed = parameters.timeRatio > 0.0 ? 1.0 / parameters.timeRatio : 1.0; + request.pitch = parameters.pitchRatio; + + framesNeeded += static_cast (outputFrameCount); + int frameCounter = 0; + const int totalNeededFrames = static_cast (std::round (framesNeeded)); + + while (frameCounter < totalNeededFrames) + { + const bool hasOutput = outputChunk.request[0] != nullptr + && ! std::isnan (outputChunk.request[0]->position) + && outputChunk.frameCount > 0 + && outputChunkConsumed < outputChunk.frameCount; + + if (! hasOutput) + { + const auto inputChunk = stretcher->specifyGrain (request, 0.0); + const int frameCount = inputChunk.end - inputChunk.begin; + if (frameCount <= 0) + break; + + // Track current position from the grain center + currentInputPosition = static_cast (request.position); + + for (int ch = 0; ch < channelCount; ++ch) + channelPtrs[static_cast (ch)] = inputChunkBuffer.data() + ch * maxInputFrameCount; + + int muteHead = 0; + int muteTail = 0; + inputProvider (inputChunk.begin, + frameCount, + channelPtrs.data(), + maxInputFrameCount, + muteHead, + muteTail); + + stretcher->analyseGrain (inputChunkBuffer.data(), + maxInputFrameCount, + muteHead, + muteTail); + stretcher->synthesiseGrain (outputChunk); + outputChunkConsumed = 0; + stretcher->next (request); + request.reset = false; + continue; + } + + const int need = totalNeededFrames - frameCounter; + const int available = outputChunk.frameCount - outputChunkConsumed; + const int numFrames = std::min (need, available); + + for (int channel = 0; channel < channelCount; ++channel) + { + std::copy (outputChunk.data + outputChunkConsumed + channel * outputChunk.channelStride, + outputChunk.data + outputChunkConsumed + channel * outputChunk.channelStride + numFrames, + outputChannels[channel] + frameCounter); + } + + frameCounter += numFrames; + outputChunkConsumed += numFrames; + } + + framesNeeded -= frameCounter; + return frameCounter; + } + + String getBackendName() const override + { + return "Bungee"; + } + + double getLatencyInFrames() const override + { + if (outputChunk.request[0] == nullptr || outputChunk.frameCount <= 0) + return 0.0; + + double outPosition = outputChunk.request[0]->position; + if (outputChunk.request[1] != nullptr) + { + const double span = outputChunk.request[1]->position - outputChunk.request[0]->position; + outPosition += outputChunkConsumed * span / static_cast (outputChunk.frameCount); + } + + return static_cast (currentInputPosition) - outPosition; + } + +private: + void resetState (int64 inputPosition) + { + request.position = static_cast (inputPosition); + request.speed = 1.0; + request.pitch = parameters.pitchRatio; + request.reset = true; + request.resampleMode = resampleMode_autoOut; + stretcher->preroll (request); + + outputChunk = {}; + outputChunkConsumed = 0; + framesNeeded = 0.0; + requestInitialized = true; + currentInputPosition = inputPosition; + } + + void initializeRequest() + { + request.position = static_cast (pendingInputPosition); + request.speed = 1.0; + request.pitch = parameters.pitchRatio; + request.reset = true; + request.resampleMode = resampleMode_autoOut; + stretcher->preroll (request); + requestInitialized = true; + } + + Bungee::SampleRates sampleRates {}; + int channelCount = 0; + int maximumBlockSize = 0; + int maxInputFrameCount = 0; + TimeStretchProcessor::Parameters parameters; + + std::unique_ptr> stretcher; + TimeStretchProcessor::InputProvider inputProvider; + + Bungee::Request request {}; + Bungee::OutputChunk outputChunk {}; + int outputChunkConsumed = 0; + double framesNeeded = 0.0; + bool requestInitialized = false; + bool seekPending = false; + int64 pendingInputPosition = 0; + int64 currentInputPosition = 0; + + std::vector inputChunkBuffer; + std::vector channelPtrs; +}; + +#endif + +} // namespace yup diff --git a/modules/yup_dsp/stretching/yup_TimeStretchProcessor.cpp b/modules/yup_dsp/stretching/yup_TimeStretchProcessor.cpp index 46767963f..4ce4de1c6 100644 --- a/modules/yup_dsp/stretching/yup_TimeStretchProcessor.cpp +++ b/modules/yup_dsp/stretching/yup_TimeStretchProcessor.cpp @@ -22,248 +22,15 @@ namespace yup { -//============================================================================== -class TimeStretchProcessor::Engine -{ -public: - virtual ~Engine() = default; - - virtual Result prepare (const TimeStretchProcessor::ProcessSpec& spec) = 0; - virtual void reset() = 0; - virtual void setInputPosition (int64 newInputPosition) = 0; - virtual void setParameters (const TimeStretchProcessor::Parameters& parameters) = 0; - virtual void setInputProvider (TimeStretchProcessor::InputProvider provider) = 0; - virtual int getMaxInputFrameCount() const = 0; - virtual int process (const float* const* inputChannels, - int inputFrameCount, - float* const* outputChannels, - int outputFrameCount) = 0; - virtual String getBackendName() const = 0; - virtual double getLatencyInFrames() const = 0; -}; - -//============================================================================== -#if YUP_ENABLE_BUNGEE - -class BungeeEngine : public TimeStretchProcessor::Engine -{ -public: - Result prepare (const TimeStretchProcessor::ProcessSpec& specToUse) override - { - sampleRates.input = static_cast (std::round (specToUse.inputSampleRate)); - sampleRates.output = static_cast (std::round (specToUse.outputSampleRate)); - channelCount = specToUse.numChannels; - maximumBlockSize = specToUse.maximumBlockSize; - - stretcher = std::make_unique> (sampleRates, channelCount, 0); - const int maxFrames = stretcher->maxInputFrameCount(); - maxInputFrameCount = maxFrames; - - // Allocate contiguous buffer with strided layout - // Layout: [ch0_frame0...ch0_frameN][ch1_frame0...ch1_frameN]... - inputChunkBuffer.resize (static_cast (channelCount * maxFrames)); - - resetState (0); - return Result::ok(); - } - - void reset() override - { - if (stretcher == nullptr || channelCount <= 0 || maximumBlockSize <= 0) - return; - - resetState (pendingInputPosition); - } - - void setInputPosition (int64 newInputPosition) override - { - pendingInputPosition = newInputPosition; - seekPending = true; - } - - void setParameters (const TimeStretchProcessor::Parameters& newParameters) override - { - parameters = newParameters; - } - - void setInputProvider (TimeStretchProcessor::InputProvider provider) override - { - inputProvider = std::move (provider); - } - - int getMaxInputFrameCount() const override - { - return maxInputFrameCount; - } - - int process (const float* const* inputChannels, - int inputFrameCount, - float* const* outputChannels, - int outputFrameCount) override - { - (void) inputChannels; - (void) inputFrameCount; - - if (stretcher == nullptr || outputChannels == nullptr || outputFrameCount <= 0) - return 0; - - if (inputProvider == nullptr) - return 0; - - if (seekPending) - { - resetState (pendingInputPosition); - seekPending = false; - } - - if (! requestInitialized) - initializeRequest(); - - request.speed = parameters.timeRatio > 0.0 ? 1.0 / parameters.timeRatio : 1.0; - request.pitch = parameters.pitchRatio; - - framesNeeded += static_cast (outputFrameCount); - int frameCounter = 0; - const int totalNeededFrames = static_cast (std::round (framesNeeded)); - - while (frameCounter < totalNeededFrames) - { - const bool hasOutput = outputChunk.request[0] != nullptr - && ! std::isnan (outputChunk.request[0]->position) - && outputChunk.frameCount > 0 - && outputChunkConsumed < outputChunk.frameCount; - - if (! hasOutput) - { - const auto inputChunk = stretcher->specifyGrain (request, 0.0); - const int frameCount = inputChunk.end - inputChunk.begin; - if (frameCount <= 0) - break; - - // Track current position from the grain center - currentInputPosition = static_cast (request.position); - - // Create channel pointers into strided buffer - std::vector channelPtrs (static_cast (channelCount)); - for (int ch = 0; ch < channelCount; ++ch) - channelPtrs[static_cast (ch)] = inputChunkBuffer.data() + ch * maxInputFrameCount; - - int muteHead = 0; - int muteTail = 0; - inputProvider (inputChunk.begin, - frameCount, - channelPtrs.data(), - maxInputFrameCount, - muteHead, - muteTail); - - stretcher->analyseGrain (inputChunkBuffer.data(), - maxInputFrameCount, - muteHead, - muteTail); - stretcher->synthesiseGrain (outputChunk); - outputChunkConsumed = 0; - stretcher->next (request); - request.reset = false; - continue; - } - - const int need = totalNeededFrames - frameCounter; - const int available = outputChunk.frameCount - outputChunkConsumed; - const int numFrames = std::min (need, available); - - for (int channel = 0; channel < channelCount; ++channel) - { - std::copy (outputChunk.data + outputChunkConsumed + channel * outputChunk.channelStride, - outputChunk.data + outputChunkConsumed + channel * outputChunk.channelStride + numFrames, - outputChannels[channel] + frameCounter); - } - - frameCounter += numFrames; - outputChunkConsumed += numFrames; - } - - framesNeeded -= frameCounter; - return frameCounter; - } - - String getBackendName() const override - { - return "Bungee"; - } - - double getLatencyInFrames() const override - { - if (outputChunk.request[0] == nullptr || outputChunk.frameCount <= 0) - return 0.0; - - double outPosition = outputChunk.request[0]->position; - if (outputChunk.request[1] != nullptr) - { - const double span = outputChunk.request[1]->position - outputChunk.request[0]->position; - outPosition += outputChunkConsumed * span / static_cast (outputChunk.frameCount); - } - - return static_cast (currentInputPosition) - outPosition; - } - -private: - void resetState (int64 inputPosition) - { - request.position = static_cast (inputPosition); - request.speed = 1.0; - request.pitch = parameters.pitchRatio; - request.reset = true; - request.resampleMode = resampleMode_autoOut; - stretcher->preroll (request); - - outputChunk = {}; - outputChunkConsumed = 0; - framesNeeded = 0.0; - requestInitialized = true; - currentInputPosition = inputPosition; - } - - void initializeRequest() - { - request.position = static_cast (pendingInputPosition); - request.speed = 1.0; - request.pitch = parameters.pitchRatio; - request.reset = true; - request.resampleMode = resampleMode_autoOut; - stretcher->preroll (request); - requestInitialized = true; - } - - Bungee::SampleRates sampleRates {}; - int channelCount = 0; - int maximumBlockSize = 0; - int maxInputFrameCount = 0; - TimeStretchProcessor::Parameters parameters; - - std::unique_ptr> stretcher; - TimeStretchProcessor::InputProvider inputProvider; - - Bungee::Request request {}; - Bungee::OutputChunk outputChunk {}; - int outputChunkConsumed = 0; - double framesNeeded = 0.0; - bool requestInitialized = false; - bool seekPending = false; - int64 pendingInputPosition = 0; - int64 currentInputPosition = 0; - - std::vector inputChunkBuffer; -}; - -#endif - //============================================================================== static std::unique_ptr createEngineForBackend (TimeStretchProcessor::Backend backend) { + if (backend == TimeStretchProcessor::Backend::timeDomain) + return std::make_unique(); + #if YUP_ENABLE_BUNGEE if (backend == TimeStretchProcessor::Backend::bungee) - return std::make_unique(); + return std::make_unique(); #else (void) backend; #endif @@ -332,6 +99,9 @@ bool TimeStretchProcessor::isBackendAvailable (Backend backendToCheck) noexcept if (backendToCheck == Backend::automatic) return ! getAvailableBackends().empty(); + if (backendToCheck == Backend::timeDomain) + return true; + #if YUP_ENABLE_BUNGEE if (backendToCheck == Backend::bungee) return true; @@ -344,6 +114,8 @@ std::vector TimeStretchProcessor::getAvailableBac { std::vector backends; + backends.push_back (Backend::timeDomain); + #if YUP_ENABLE_BUNGEE backends.push_back (Backend::bungee); #endif @@ -482,7 +254,7 @@ TimeStretchProcessor::Backend TimeStretchProcessor::resolveBackend (Backend pref #if YUP_ENABLE_BUNGEE return Backend::bungee; #else - return Backend::automatic; + return Backend::timeDomain; #endif } diff --git a/modules/yup_dsp/stretching/yup_TimeStretchProcessor.h b/modules/yup_dsp/stretching/yup_TimeStretchProcessor.h index 89c5cae05..7226ec35a 100644 --- a/modules/yup_dsp/stretching/yup_TimeStretchProcessor.h +++ b/modules/yup_dsp/stretching/yup_TimeStretchProcessor.h @@ -70,8 +70,9 @@ class TimeStretchProcessor /** Backends supported by the time-stretch processor. */ enum class Backend { - automatic, /**< Automatically select the best available backend. */ - bungee /**< Use the Bungee backend if available. */ + automatic, /**< Automatically select the best available backend. */ + timeDomain, /**< Use the built-in time-domain backend for tempo changes and resampling-based pitch shifting. */ + bungee /**< Use the Bungee backend if available. */ }; //============================================================================== @@ -141,7 +142,31 @@ class TimeStretchProcessor bool isPrepared() const noexcept { return prepared; } //============================================================================== - /** Set a custom input provider for granular backends. */ + /** Set a custom input provider for granular backends. + + The provider is a callback function that fills the given destination buffers + with input audio for a specified frame range. This is used by granular + backends to fetch input data on demand. + + The provider should write numFrames of audio starting from beginFrame into + destChannels, which is an array of pointers to the destination buffers for + each channel. channelStride indicates the byte offset between consecutive + channels in destChannels. + + The provider can also set muteHead and muteTail to indicate if the beginning + or end of the provided block should be muted (e.g., for handling edge cases + at the start or end of the input). + + If no provider is set, the processor will use a default internal buffer for input. + + @code + processor.setInputProvider ([](int64 beginFrame, int numFrames, float* const* destChannels, int channelStride, int& muteHead, int& muteTail) + { + // Fill destChannels with input audio for the specified frame range. + // Set muteHead and muteTail as needed. + }); + @endcode + */ void setInputProvider (InputProvider provider); //============================================================================== diff --git a/modules/yup_dsp/stretching/yup_TimeStretchProcessorEngine.h b/modules/yup_dsp/stretching/yup_TimeStretchProcessorEngine.h new file mode 100644 index 000000000..f834c1093 --- /dev/null +++ b/modules/yup_dsp/stretching/yup_TimeStretchProcessorEngine.h @@ -0,0 +1,79 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +namespace yup +{ + +//============================================================================== +/** Engine interface for the TimeStretchProcessor. + + This abstract class defines the interface that all time-stretching backends + must implement to be used within the TimeStretchProcessor. It provides methods + for preparing the engine with processing specifications, resetting state, + setting input position and parameters, providing input audio data, and + processing audio blocks. + + Each backend implementation will + inherit from this interface and provide concrete implementations of these + methods according to their specific algorithms and requirements. + + @see TimeStretchProcessor +*/ +class TimeStretchProcessor::Engine +{ +public: + /** Destructor. */ + virtual ~Engine() = default; + + /** Prepares the engine with the given processing specifications. */ + virtual Result prepare (const TimeStretchProcessor::ProcessSpec& spec) = 0; + + /** Resets the engine to its initial state. */ + virtual void reset() = 0; + + /** Sets the input position for the engine. */ + virtual void setInputPosition (int64 newInputPosition) = 0; + + /** Sets the parameters for the engine. */ + virtual void setParameters (const TimeStretchProcessor::Parameters& parameters) = 0; + + /** Sets the input provider for the engine. */ + virtual void setInputProvider (TimeStretchProcessor::InputProvider provider) = 0; + + /** Returns the maximum number of input frames the engine can process at once. */ + virtual int getMaxInputFrameCount() const = 0; + + /** Processes the input audio and produces the output audio. */ + virtual int process (const float* const* inputChannels, + int inputFrameCount, + float* const* outputChannels, + int outputFrameCount) = 0; + + /** Returns the name of the backend. */ + virtual String getBackendName() const = 0; + + /** Returns the latency of the engine in frames. */ + virtual double getLatencyInFrames() const = 0; +}; + +} // namespace yup diff --git a/modules/yup_dsp/stretching/yup_TimeStretchTimeDomainBackend.h b/modules/yup_dsp/stretching/yup_TimeStretchTimeDomainBackend.h new file mode 100644 index 000000000..1cea7f3df --- /dev/null +++ b/modules/yup_dsp/stretching/yup_TimeStretchTimeDomainBackend.h @@ -0,0 +1,682 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +namespace yup +{ + +//============================================================================== +static constexpr int defaultSequenceLengthMs = 82; +static constexpr int defaultSeekWindowLengthMs = 14; +static constexpr int defaultOverlapLengthMs = 12; +static constexpr float maximumPreallocatedTempo = 4.0f; +static constexpr double minimumCorrelationValue = std::numeric_limits::lowest(); + +static constexpr int quickScanOffsets[4][24] = { + { 124, 186, 248, 310, 372, 434, 496, 558, 620, 682, 744, 806, 868, 930, 992, 1054, 1116, 1178, 1240, 1302, 1364, 1426, 1488, 0 }, + { -100, -75, -50, -25, 25, 50, 75, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + { -20, -15, -10, -5, 5, 10, 15, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + { -4, -3, -2, -1, 1, 2, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } +}; + +class MultiChannelSampleFifo +{ +public: + void prepare (int numChannels, int capacityPerChannel) + { + channels.resize (static_cast (numChannels)); + for (auto& channel : channels) + { + channel.assign (static_cast (capacityPerChannel), 0.0f); + } + + readPosition = 0; + numSamples = 0; + } + + void clear() + { + readPosition = 0; + numSamples = 0; + } + + int getNumChannels() const noexcept + { + return static_cast (channels.size()); + } + + int getNumSamples() const noexcept + { + return numSamples; + } + + const float* getReadPointer (int channel, int offset = 0) const noexcept + { + return channels[static_cast (channel)].data() + readPosition + offset; + } + + float* getReadPointer (int channel, int offset = 0) noexcept + { + return channels[static_cast (channel)].data() + readPosition + offset; + } + + float* getWritePointer (int channel, int samplesToWrite) + { + ensureWritable (samplesToWrite); + return channels[static_cast (channel)].data() + readPosition + numSamples; + } + + void ensureWritable (int samplesToWrite) + { + const auto requiredSize = static_cast (readPosition + numSamples + samplesToWrite); + if (channels.empty() || requiredSize <= channels.front().size()) + return; + + const auto newSize = std::max (requiredSize, channels.front().size() * 2); + for (auto& channel : channels) + channel.resize (newSize, 0.0f); + } + + void advanceWritePosition (int samplesWritten) noexcept + { + numSamples += samplesWritten; + } + + void advanceReadPosition (int samplesRead) + { + samplesRead = jlimit (0, numSamples, samplesRead); + readPosition += samplesRead; + numSamples -= samplesRead; + + if (numSamples == 0) + { + clear(); + return; + } + + if (readPosition > 4096 && readPosition > static_cast (channels.front().size() / 2)) + compact(); + } + +private: + void compact() + { + for (auto& channel : channels) + { + std::move (channel.begin() + readPosition, + channel.begin() + readPosition + numSamples, + channel.begin()); + } + + readPosition = 0; + } + + std::vector> channels; + int readPosition = 0; + int numSamples = 0; +}; + +class TimeDomainTimeStretchBackend : public TimeStretchProcessor::Engine +{ +public: + Result prepare (const TimeStretchProcessor::ProcessSpec& specToUse) override + { + spec = specToUse; + channelCount = spec.numChannels; + inputStartPosition = 0; + pendingInputPosition = 0; + + setTimeConstants (static_cast (std::round (spec.inputSampleRate)), + defaultSequenceLengthMs, + defaultSeekWindowLengthMs, + defaultOverlapLengthMs); + + midBuffers.assign (static_cast (channelCount), std::vector (static_cast (overlapLength))); + referenceMidBuffers.assign (static_cast (channelCount), std::vector (static_cast (overlapLength))); + providerPointers.resize (static_cast (channelCount)); + + maximumSamplesPerRequest = calculateSamplesPerRequest (maximumPreallocatedTempo); + const int fifoCapacity = maximumSamplesPerRequest + (spec.maximumBlockSize * 4) + (sequenceWindowLength * 4); + inputBuffer.prepare (channelCount, fifoCapacity); + outputBuffer.prepare (channelCount, fifoCapacity); + preparePitchShifter (fifoCapacity); + + reset(); + return Result::ok(); + } + + void reset() override + { + inputBuffer.clear(); + outputBuffer.clear(); + pitchOutputBuffer.clear(); + pitchResampler.reset(); + + for (auto& channel : midBuffers) + std::fill (channel.begin(), channel.end(), 0.0f); + + midBufferDirty = false; + skipFraction = 0.0f; + inputStartPosition = pendingInputPosition; + } + + void setInputPosition (int64 newInputPosition) override + { + pendingInputPosition = newInputPosition; + reset(); + } + + void setParameters (const TimeStretchProcessor::Parameters& newParameters) override + { + const auto previousPitchRatio = getPitchRatio(); + + parameters = newParameters; + updateTempo(); + + if (std::abs (previousPitchRatio - getPitchRatio()) > 0.000001) + resetPitchShifter(); + } + + void setInputProvider (TimeStretchProcessor::InputProvider provider) override + { + inputProvider = std::move (provider); + } + + int getMaxInputFrameCount() const override + { + return jmax (maximumSamplesPerRequest, jmax (samplesPerRequest, spec.maximumBlockSize + overlapLength)); + } + + int process (const float* const* inputChannels, + int inputFrameCount, + float* const* outputChannels, + int outputFrameCount) override + { + if (outputChannels == nullptr || outputFrameCount <= 0) + return 0; + + if (inputProvider == nullptr && inputChannels != nullptr && inputFrameCount > 0) + appendDirectInput (inputChannels, inputFrameCount); + + if (isPitchShiftEnabled()) + return processPitchShifted (outputChannels, outputFrameCount); + + return renderTimeDomainOutput (outputChannels, outputFrameCount); + } + + String getBackendName() const override + { + return "Time Domain"; + } + + double getLatencyInFrames() const override + { + return jmax (0.0, static_cast (inputBuffer.getNumSamples() - outputBuffer.getNumSamples())); + } + +private: + int renderTimeDomainOutput (float* const* outputChannels, int outputFrameCount) + { + const int framesToCalculate = outputFrameCount + overlapLength; + while (outputBuffer.getNumSamples() < framesToCalculate) + { + if (isUnityTempo()) + processUnity (framesToCalculate - outputBuffer.getNumSamples()); + else + processStretchedSequence(); + } + + for (int channel = 0; channel < channelCount; ++channel) + std::copy (outputBuffer.getReadPointer (channel), + outputBuffer.getReadPointer (channel) + outputFrameCount, + outputChannels[channel]); + + outputBuffer.advanceReadPosition (outputFrameCount); + return outputFrameCount; + } + + void setTimeConstants (int sampleRate, int sequenceMs, int seekWindowMs, int overlapMs) + { + seekLength = jmax (1, (sampleRate * seekWindowMs) / 1000); + sequenceWindowLength = jmax (32, (sampleRate * sequenceMs) / 1000); + overlapLength = jmax (16, (sampleRate * overlapMs) / 1000); + + if (sequenceWindowLength <= overlapLength * 2) + sequenceWindowLength = overlapLength * 2 + 1; + + updateTempo(); + } + + void updateTempo() + { + const auto timeRatio = getEffectiveTimeRatio(); + const auto requestedTempo = static_cast (1.0 / timeRatio); + jassert (requestedTempo <= maximumPreallocatedTempo); + tempo = jmin (requestedTempo, maximumPreallocatedTempo); + nominalSkip = tempo * static_cast (sequenceWindowLength - overlapLength); + skipFraction = 0.0f; + + samplesPerRequest = calculateSamplesPerRequest (tempo); + } + + int calculateSamplesPerRequest (float tempoToUse) const + { + const auto skip = tempoToUse * static_cast (sequenceWindowLength - overlapLength); + const int integerSkip = static_cast (skip + 0.5f); + return jmax (integerSkip + overlapLength, sequenceWindowLength) + seekLength; + } + + bool isUnityTempo() const noexcept + { + return std::abs (static_cast (tempo) - 1.0) <= 0.000001; + } + + double getTimeRatio() const noexcept + { + return parameters.timeRatio > 0.0 ? parameters.timeRatio : 1.0; + } + + double getPitchRatio() const noexcept + { + return parameters.pitchRatio > 0.0 ? parameters.pitchRatio : 1.0; + } + + double getEffectiveTimeRatio() const noexcept + { + return getTimeRatio() * getPitchRatio(); + } + + bool isPitchShiftEnabled() const noexcept + { + return std::abs (getPitchRatio() - 1.0) > 0.000001; + } + + void preparePitchShifter (int fifoCapacity) + { + pitchOutputBuffer.prepare (channelCount, fifoCapacity); + + pitchStretchedBuffers.resize (static_cast (channelCount)); + pitchResampledBuffers.resize (static_cast (channelCount)); + pitchStretchedReadPointers.resize (static_cast (channelCount)); + pitchStretchedWritePointers.resize (static_cast (channelCount)); + pitchResampledWritePointers.resize (static_cast (channelCount)); + + ensurePitchProcessingCapacity (jmax (1, spec.maximumBlockSize * 4)); + resetPitchShifter(); + } + + void resetPitchShifter() + { + if (channelCount <= 0 || spec.outputSampleRate <= 0.0) + return; + + pitchOutputBuffer.clear(); + pitchResampler.prepare (spec.outputSampleRate * getPitchRatio(), + spec.outputSampleRate, + channelCount, + pitchResamplerInputCapacity); + pitchResampler.reset(); + } + + int processPitchShifted (float* const* outputChannels, int outputFrameCount) + { + while (pitchOutputBuffer.getNumSamples() < outputFrameCount) + { + const int missingFrames = outputFrameCount - pitchOutputBuffer.getNumSamples(); + const int stretchedFramesNeeded = calculatePitchInputFrameCount (missingFrames); + + ensurePitchProcessingCapacity (stretchedFramesNeeded); + preparePitchPointers(); + + const int stretchedFrames = renderTimeDomainOutput (pitchStretchedWritePointers.data(), stretchedFramesNeeded); + if (stretchedFrames <= 0) + break; + + const int resampledFrames = pitchResampler.resample (pitchStretchedReadPointers.data(), + pitchResampledWritePointers.data(), + channelCount, + stretchedFrames); + if (resampledFrames <= 0) + continue; + + appendPitchOutput (resampledFrames); + } + + const int framesToCopy = jmin (outputFrameCount, pitchOutputBuffer.getNumSamples()); + for (int channel = 0; channel < channelCount; ++channel) + std::copy (pitchOutputBuffer.getReadPointer (channel), + pitchOutputBuffer.getReadPointer (channel) + framesToCopy, + outputChannels[channel]); + + pitchOutputBuffer.advanceReadPosition (framesToCopy); + return framesToCopy; + } + + int calculatePitchInputFrameCount (int outputFramesNeeded) const + { + return jmax (1, static_cast (std::ceil (static_cast (outputFramesNeeded) * getPitchRatio())) + 2); + } + + int calculatePitchOutputCapacity (int inputFrameCount) const + { + return jmax (1, static_cast (std::ceil (static_cast (inputFrameCount) / getPitchRatio())) + 2); + } + + void ensurePitchProcessingCapacity (int inputFrameCount) + { + if (inputFrameCount > pitchResamplerInputCapacity) + { + pitchResamplerInputCapacity = inputFrameCount; + resetPitchShifter(); + } + + const int outputFrameCapacity = calculatePitchOutputCapacity (inputFrameCount); + for (int channel = 0; channel < channelCount; ++channel) + { + pitchStretchedBuffers[static_cast (channel)].resize (static_cast (inputFrameCount)); + pitchResampledBuffers[static_cast (channel)].resize (static_cast (outputFrameCapacity)); + } + } + + void preparePitchPointers() + { + for (int channel = 0; channel < channelCount; ++channel) + { + auto& stretchedBuffer = pitchStretchedBuffers[static_cast (channel)]; + auto& resampledBuffer = pitchResampledBuffers[static_cast (channel)]; + + pitchStretchedReadPointers[static_cast (channel)] = stretchedBuffer.data(); + pitchStretchedWritePointers[static_cast (channel)] = stretchedBuffer.data(); + pitchResampledWritePointers[static_cast (channel)] = resampledBuffer.data(); + } + } + + void appendPitchOutput (int frameCount) + { + for (int channel = 0; channel < channelCount; ++channel) + { + auto* dest = pitchOutputBuffer.getWritePointer (channel, frameCount); + const auto& source = pitchResampledBuffers[static_cast (channel)]; + std::copy (source.begin(), source.begin() + frameCount, dest); + } + + pitchOutputBuffer.advanceWritePosition (frameCount); + } + + void appendDirectInput (const float* const* inputChannels, int inputFrameCount) + { + for (int channel = 0; channel < channelCount; ++channel) + { + auto* dest = inputBuffer.getWritePointer (channel, inputFrameCount); + if (inputChannels[channel] != nullptr) + std::copy (inputChannels[channel], inputChannels[channel] + inputFrameCount, dest); + else + std::fill (dest, dest + inputFrameCount, 0.0f); + } + + inputBuffer.advanceWritePosition (inputFrameCount); + } + + void appendInputFromProvider (int numFrames) + { + if (numFrames <= 0) + return; + + inputBuffer.ensureWritable (numFrames); + + for (int channel = 0; channel < channelCount; ++channel) + { + providerPointers[static_cast (channel)] = inputBuffer.getWritePointer (channel, numFrames); + std::fill (providerPointers[static_cast (channel)], + providerPointers[static_cast (channel)] + numFrames, + 0.0f); + } + + int muteHead = 0; + int muteTail = 0; + + if (inputProvider != nullptr) + { + inputProvider (inputStartPosition + inputBuffer.getNumSamples(), + numFrames, + providerPointers.data(), + numFrames, + muteHead, + muteTail); + } + + muteHead = jlimit (0, numFrames, muteHead); + muteTail = jlimit (0, numFrames - muteHead, muteTail); + + for (int channel = 0; channel < channelCount; ++channel) + { + auto* dest = providerPointers[static_cast (channel)]; + if (muteHead > 0) + std::fill (dest, dest + muteHead, 0.0f); + + if (muteTail > 0) + std::fill (dest + numFrames - muteTail, dest + numFrames, 0.0f); + } + + inputBuffer.advanceWritePosition (numFrames); + } + + void ensureInputFrames (int minimumFrames) + { + if (inputBuffer.getNumSamples() >= minimumFrames) + return; + + const int framesToRead = minimumFrames - inputBuffer.getNumSamples(); + if (inputProvider != nullptr) + { + appendInputFromProvider (framesToRead); + return; + } + + for (int channel = 0; channel < channelCount; ++channel) + { + auto* dest = inputBuffer.getWritePointer (channel, framesToRead); + std::fill (dest, dest + framesToRead, 0.0f); + } + + inputBuffer.advanceWritePosition (framesToRead); + } + + void processUnity (int framesNeeded) + { + if (midBufferDirty) + { + ensureInputFrames (overlapLength); + writeOverlap (0); + inputBuffer.advanceReadPosition (overlapLength); + inputStartPosition += overlapLength; + midBufferDirty = false; + return; + } + + const int framesToCopy = jmax (1, framesNeeded); + ensureInputFrames (framesToCopy); + + for (int channel = 0; channel < channelCount; ++channel) + { + std::copy (inputBuffer.getReadPointer (channel), + inputBuffer.getReadPointer (channel) + framesToCopy, + outputBuffer.getWritePointer (channel, framesToCopy)); + } + + inputBuffer.advanceReadPosition (framesToCopy); + outputBuffer.advanceWritePosition (framesToCopy); + inputStartPosition += framesToCopy; + } + + void processStretchedSequence() + { + ensureInputFrames (samplesPerRequest); + + const int offset = midBufferDirty ? seekBestOverlapPosition() : 0; + writeOverlap (offset); + + const int nonOverlappedSamples = sequenceWindowLength - 2 * overlapLength; + if (nonOverlappedSamples > 0) + { + for (int channel = 0; channel < channelCount; ++channel) + { + std::copy (inputBuffer.getReadPointer (channel, offset + overlapLength), + inputBuffer.getReadPointer (channel, offset + overlapLength + nonOverlappedSamples), + outputBuffer.getWritePointer (channel, nonOverlappedSamples)); + } + + outputBuffer.advanceWritePosition (nonOverlappedSamples); + } + + for (int channel = 0; channel < channelCount; ++channel) + { + std::copy (inputBuffer.getReadPointer (channel, offset + sequenceWindowLength - overlapLength), + inputBuffer.getReadPointer (channel, offset + sequenceWindowLength), + midBuffers[static_cast (channel)].begin()); + } + + midBufferDirty = true; + + skipFraction += nominalSkip; + const int framesToSkip = static_cast (skipFraction); + skipFraction -= static_cast (framesToSkip); + + inputBuffer.advanceReadPosition (framesToSkip); + inputStartPosition += framesToSkip; + } + + void writeOverlap (int inputOffset) + { + const float scale = 1.0f / static_cast (overlapLength); + + for (int channel = 0; channel < channelCount; ++channel) + { + const auto* input = inputBuffer.getReadPointer (channel, inputOffset); + const auto& mid = midBuffers[static_cast (channel)]; + auto* output = outputBuffer.getWritePointer (channel, overlapLength); + + for (int i = 0; i < overlapLength; ++i) + { + output[i] = (input[i] * static_cast (i) + + mid[static_cast (i)] * static_cast (overlapLength - i)) + * scale; + } + } + + outputBuffer.advanceWritePosition (overlapLength); + } + + int seekBestOverlapPosition() + { + for (int channel = 0; channel < channelCount; ++channel) + { + const auto& mid = midBuffers[static_cast (channel)]; + auto& referenceMid = referenceMidBuffers[static_cast (channel)]; + + for (int i = 0; i < overlapLength; ++i) + { + const float slope = static_cast (i * (overlapLength - i)); + referenceMid[static_cast (i)] = mid[static_cast (i)] * slope; + } + } + + double bestCorrelation = minimumCorrelationValue; + int bestOffset = 0; + int correlationOffset = 0; + + for (const auto& scanOffsets : quickScanOffsets) + { + for (int j = 0; scanOffsets[j] != 0; ++j) + { + const int offset = correlationOffset + scanOffsets[j]; + if (offset >= seekLength) + break; + + if (offset < 0) + continue; + + const double correlation = calculateCrossCorrelation (offset); + if (correlation > bestCorrelation) + { + bestCorrelation = correlation; + bestOffset = offset; + } + } + + correlationOffset = bestOffset; + } + + return bestOffset; + } + + double calculateCrossCorrelation (int inputOffset) const + { + double correlation = 0.0; + + for (int channel = 0; channel < channelCount; ++channel) + { + const auto* input = inputBuffer.getReadPointer (channel, inputOffset); + const auto& referenceMid = referenceMidBuffers[static_cast (channel)]; + + for (int i = 0; i < overlapLength; ++i) + correlation += static_cast (input[i]) * static_cast (referenceMid[static_cast (i)]); + } + + return jmax (correlation, minimumCorrelationValue); + } + + TimeStretchProcessor::ProcessSpec spec; + TimeStretchProcessor::Parameters parameters; + TimeStretchProcessor::InputProvider inputProvider; + + int channelCount = 0; + int samplesPerRequest = 0; + int maximumSamplesPerRequest = 0; + int overlapLength = 0; + int seekLength = 0; + int sequenceWindowLength = 0; + + float tempo = 1.0f; + float nominalSkip = 0.0f; + float skipFraction = 0.0f; + bool midBufferDirty = false; + + int64 inputStartPosition = 0; + int64 pendingInputPosition = 0; + + MultiChannelSampleFifo inputBuffer; + MultiChannelSampleFifo outputBuffer; + MultiChannelSampleFifo pitchOutputBuffer; + std::vector> midBuffers; + std::vector> referenceMidBuffers; + std::vector> pitchStretchedBuffers; + std::vector> pitchResampledBuffers; + std::vector pitchStretchedReadPointers; + std::vector pitchStretchedWritePointers; + std::vector pitchResampledWritePointers; + std::vector providerPointers; + ResamplerFloat pitchResampler; + int pitchResamplerInputCapacity = 1; +}; + +} // namespace yup diff --git a/modules/yup_dsp/windowing/yup_WindowFunctions.h b/modules/yup_dsp/windowing/yup_WindowFunctions.h index e9d823991..0d267a74f 100644 --- a/modules/yup_dsp/windowing/yup_WindowFunctions.h +++ b/modules/yup_dsp/windowing/yup_WindowFunctions.h @@ -94,7 +94,8 @@ class WindowFunctions @param n The sample index (0 to N-1) @param N The window length @param parameter Optional parameter for parameterizable windows (Kaiser beta, Gaussian sigma, etc.) - @returns The window value at sample n + + @return The window value at sample n */ static FloatType getValue (WindowType type, int n, int N, FloatType parameter = FloatType (8)) noexcept { @@ -232,22 +233,50 @@ class WindowFunctions //============================================================================== /** Method-based API for backwards compatibility and direct access */ + /** Evaluates a rectangular window function at sample n. + + @param n The sample index (0 to N-1) + @param N The window length + + @return The window value at sample n + */ static FloatType rectangular (int n, int N) noexcept { ignoreUnused (n, N); return FloatType (1); } + /** Evaluates a Hann window function at sample n. + + @param n The sample index (0 to N-1) + @param N The window length + + @return The window value at sample n + */ static FloatType hann (int n, int N) noexcept { return FloatType (0.5) * (FloatType (1) - std::cos (MathConstants::twoPi * n / (N - 1))); } + /** Evaluates a Hamming window function at sample n. + + @param n The sample index (0 to N-1) + @param N The window length + + @return The window value at sample n + */ static FloatType hamming (int n, int N) noexcept { return FloatType (0.54) - FloatType (0.46) * std::cos (MathConstants::twoPi * n / (N - 1)); } + /** Evaluates a Blackman window function at sample n. + + @param n The sample index (0 to N-1) + @param N The window length + + @return The window value at sample n + */ static FloatType blackman (int n, int N) noexcept { const auto a0 = FloatType (0.42); @@ -258,6 +287,13 @@ class WindowFunctions return a0 - a1 * std::cos (factor) + a2 * std::cos (FloatType (2) * factor); } + /** Evaluates a Blackman-Harris window function at sample n. + + @param n The sample index (0 to N-1) + @param N The window length + + @return The window value at sample n + */ static FloatType blackmanHarris (int n, int N) noexcept { const auto a0 = FloatType (0.35875); @@ -269,6 +305,14 @@ class WindowFunctions return a0 - a1 * std::cos (factor) + a2 * std::cos (FloatType (2) * factor) - a3 * std::cos (FloatType (3) * factor); } + /** Evaluates a Kaiser window function at sample n. + + @param n The sample index (0 to N-1) + @param N The window length + @param beta The shape parameter (higher values produce a more tapered window) + + @return The window value at sample n + */ static FloatType kaiser (int n, int N, FloatType beta) noexcept { const auto arg = FloatType (2) * n / (N - 1) - FloatType (1); @@ -277,12 +321,28 @@ class WindowFunctions return modifiedBesselI0 (x) / modifiedBesselI0 (beta); } + /** Evaluates a Gaussian window function at sample n. + + @param n The sample index (0 to N-1) + @param N The window length + @param sigma The standard deviation (controls the width of the Gaussian) + + @return The window value at sample n + */ static FloatType gaussian (int n, int N, FloatType sigma = FloatType (0.4)) noexcept { const auto arg = (n - (N - 1) / FloatType (2)) / (sigma * (N - 1) / FloatType (2)); return std::exp (FloatType (-0.5) * arg * arg); } + /** Evaluates a Tukey window function at sample n. + + @param n The sample index (0 to N-1) + @param N The window length + @param alpha The taper ratio (0 to 1) + + @return The window value at sample n + */ static FloatType tukey (int n, int N, FloatType alpha = FloatType (0.5)) noexcept { const auto halfAlphaN = alpha * (N - 1) / FloatType (2); @@ -305,7 +365,7 @@ class WindowFunctions @param phi Normalised phase in [0, 1] @param alpha Taper ratio in (0, 1]: 1.0 produces a full Hann shape, values near 0 approach a rectangular window - @returns Window amplitude in [0, 1] + @return Window amplitude in [0, 1] */ static FloatType tukeyFromPhase (FloatType phi, FloatType alpha = FloatType (0.5)) noexcept { @@ -323,17 +383,38 @@ class WindowFunctions return FloatType (1); } + /** Evaluates a Bartlett window function at sample n. + + @param n The sample index (0 to N-1) + @param N The window length + + @return The window value at sample n + */ static FloatType bartlett (int n, int N) noexcept { return FloatType (1) - FloatType (2) * std::abs (n - (N - 1) / FloatType (2)) / (N - 1); } + /** Evaluates a Welch window function at sample n. + + @param n The sample index (0 to N-1) + @param N The window length + + @return The window value at sample n + */ static FloatType welch (int n, int N) noexcept { const auto arg = (n - (N - 1) / FloatType (2)) / ((N - 1) / FloatType (2)); return FloatType (1) - arg * arg; } + /** Evaluates a Flattop window function at sample n. + + @param n The sample index (0 to N-1) + @param N The window length + + @return The window value at sample n + */ static FloatType flattop (int n, int N) noexcept { const auto a0 = FloatType (0.21557895); @@ -347,11 +428,25 @@ class WindowFunctions - a3 * std::cos (FloatType (3) * factor) + a4 * std::cos (FloatType (4) * factor); } + /** Evaluates a Cosine window function at sample n. + + @param n The sample index (0 to N-1) + @param N The window length + + @return The window value at sample n + */ static FloatType cosine (int n, int N) noexcept { return std::sin (MathConstants::pi * n / (N - 1)); } + /** Evaluates a Lanczos window function at sample n. + + @param n The sample index (0 to N-1) + @param N The window length + + @return The window value at sample n + */ static FloatType lanczos (int n, int N) noexcept { const auto x = FloatType (2) * n / (N - 1) - FloatType (1); @@ -362,6 +457,13 @@ class WindowFunctions return std::sin (px) / px; } + /** Evaluates a Nuttall window function at sample n. + + @param n The sample index (0 to N-1) + @param N The window length + + @return The window value at sample n + */ static FloatType nuttall (int n, int N) noexcept { const auto a0 = FloatType (0.355768); @@ -373,6 +475,13 @@ class WindowFunctions return a0 - a1 * std::cos (factor) + a2 * std::cos (FloatType (2) * factor) - a3 * std::cos (FloatType (3) * factor); } + /** Evaluates a Blackman-Nuttall window function at sample n. + + @param n The sample index (0 to N-1) + @param N The window length + + @return The window value at sample n + */ static FloatType blackmanNuttall (int n, int N) noexcept { const auto a0 = FloatType (0.3635819); @@ -393,7 +502,8 @@ class WindowFunctions @param n Sample index (0 to N-1) @param N Window length @param r Controlling parameter (default 1.0). Higher values give better side-lobe roll-off. - Common values: 0.0005, 1.18, 1.618, 30, 75 + Common values: 0.0005, 1.18, 1.618, 30, 75 + @return Window value at sample n @note Reference: "FIR Filter Design Using An Adjustable Novel Window and Its Applications" diff --git a/modules/yup_dsp/yup_dsp.cpp b/modules/yup_dsp/yup_dsp.cpp index 2019dd5d6..7a3968d74 100644 --- a/modules/yup_dsp/yup_dsp.cpp +++ b/modules/yup_dsp/yup_dsp.cpp @@ -86,6 +86,9 @@ #include "metering/yup_KMeterState.cpp" #include "designers/yup_FilterDesigner.cpp" #include "convolution/yup_PartitionedConvolver.cpp" +#include "stretching/yup_TimeStretchProcessorEngine.h" +#include "stretching/yup_TimeStretchTimeDomainBackend.h" +#include "stretching/yup_TimeStretchBungeeBackend.h" #include "stretching/yup_TimeStretchProcessor.cpp" #include "utilities/yup_DspMath.cpp" diff --git a/tests/yup_dsp.cpp b/tests/yup_dsp.cpp index 8fdfc9627..4bcd9b402 100644 --- a/tests/yup_dsp.cpp +++ b/tests/yup_dsp.cpp @@ -42,6 +42,4 @@ #include "yup_dsp/yup_StateVariableFilter.cpp" #include "yup_dsp/yup_WindowFunctions.cpp" -#if YUP_MODULE_AVAILABLE_bungee_library #include "yup_dsp/yup_TimeStretchProcessor.cpp" -#endif diff --git a/tests/yup_dsp/yup_TimeStretchProcessor.cpp b/tests/yup_dsp/yup_TimeStretchProcessor.cpp index 45063f7c9..4264bc09e 100644 --- a/tests/yup_dsp/yup_TimeStretchProcessor.cpp +++ b/tests/yup_dsp/yup_TimeStretchProcessor.cpp @@ -122,6 +122,18 @@ TEST_F (TimeStretchProcessorTests, PrepareWithAutomaticBackend) EXPECT_TRUE (processor.isPrepared()); } +TEST_F (TimeStretchProcessorTests, PrepareWithTimeDomainBackend) +{ + TimeStretchProcessor processor; + auto result = processor.prepare (spec, TimeStretchProcessor::Backend::timeDomain); + + ASSERT_TRUE (result.wasOk()); + EXPECT_TRUE (processor.isPrepared()); + EXPECT_EQ (processor.getBackend(), TimeStretchProcessor::Backend::timeDomain); + EXPECT_EQ (processor.getBackendName(), "Time Domain"); +} + +#if YUP_ENABLE_BUNGEE TEST_F (TimeStretchProcessorTests, PrepareWithBungeeBackend) { TimeStretchProcessor processor; @@ -132,6 +144,7 @@ TEST_F (TimeStretchProcessorTests, PrepareWithBungeeBackend) EXPECT_EQ (processor.getBackend(), TimeStretchProcessor::Backend::bungee); EXPECT_EQ (processor.getBackendName(), "Bungee"); } +#endif TEST_F (TimeStretchProcessorTests, PrepareWithInvalidSampleRate) { @@ -192,7 +205,10 @@ TEST_F (TimeStretchProcessorTests, PrepareWithDifferentOutputSampleRate) TEST_F (TimeStretchProcessorTests, BackendAvailability) { EXPECT_TRUE (TimeStretchProcessor::isBackendAvailable (TimeStretchProcessor::Backend::automatic)); + EXPECT_TRUE (TimeStretchProcessor::isBackendAvailable (TimeStretchProcessor::Backend::timeDomain)); +#if YUP_ENABLE_BUNGEE EXPECT_TRUE (TimeStretchProcessor::isBackendAvailable (TimeStretchProcessor::Backend::bungee)); +#endif } TEST_F (TimeStretchProcessorTests, GetAvailableBackends) @@ -200,9 +216,13 @@ TEST_F (TimeStretchProcessorTests, GetAvailableBackends) auto backends = TimeStretchProcessor::getAvailableBackends(); EXPECT_FALSE (backends.empty()); + EXPECT_TRUE (std::find (backends.begin(), backends.end(), TimeStretchProcessor::Backend::timeDomain) != backends.end()); +#if YUP_ENABLE_BUNGEE EXPECT_TRUE (std::find (backends.begin(), backends.end(), TimeStretchProcessor::Backend::bungee) != backends.end()); +#endif } +#if YUP_ENABLE_BUNGEE TEST_F (TimeStretchProcessorTests, SetBackendBeforePrepare) { TimeStretchProcessor processor; @@ -211,7 +231,9 @@ TEST_F (TimeStretchProcessorTests, SetBackendBeforePrepare) ASSERT_TRUE (result.wasOk()); EXPECT_EQ (processor.getBackend(), TimeStretchProcessor::Backend::bungee); } +#endif +#if YUP_ENABLE_BUNGEE TEST_F (TimeStretchProcessorTests, SetBackendAfterPrepare) { TimeStretchProcessor processor; @@ -222,7 +244,21 @@ TEST_F (TimeStretchProcessorTests, SetBackendAfterPrepare) ASSERT_TRUE (result.wasOk()); EXPECT_EQ (processor.getBackend(), TimeStretchProcessor::Backend::bungee); } +#endif + +TEST_F (TimeStretchProcessorTests, SetTimeDomainBackendAfterPrepare) +{ + TimeStretchProcessor processor; + ASSERT_TRUE (processor.prepare (spec).wasOk()); + auto result = processor.setBackend (TimeStretchProcessor::Backend::timeDomain); + + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (processor.getBackend(), TimeStretchProcessor::Backend::timeDomain); + EXPECT_EQ (processor.getBackendName(), "Time Domain"); +} + +#if YUP_ENABLE_BUNGEE TEST_F (TimeStretchProcessorTests, SetSameBackend) { TimeStretchProcessor processor; @@ -232,6 +268,7 @@ TEST_F (TimeStretchProcessorTests, SetSameBackend) ASSERT_TRUE (result.wasOk()); } +#endif //============================================================================== TEST_F (TimeStretchProcessorTests, SetTimeRatio) @@ -389,6 +426,20 @@ TEST_F (TimeStretchProcessorTests, GetMaxInputFrameCount) EXPECT_GT (processor.getMaxInputFrameCount(), 0); } +TEST_F (TimeStretchProcessorTests, TimeDomainMaxInputFrameCountCoversPitchChanges) +{ + TimeStretchProcessor processor; + ASSERT_TRUE (processor.prepare (spec, TimeStretchProcessor::Backend::timeDomain).wasOk()); + + const int preparedMaxInputFrames = processor.getMaxInputFrameCount(); + EXPECT_GT (preparedMaxInputFrames, 0); + + processor.setTimeRatio (0.5); + processor.setPitchRatio (0.5); + + EXPECT_LE (processor.getMaxInputFrameCount(), preparedMaxInputFrames); +} + TEST_F (TimeStretchProcessorTests, GetLatencyInFrames) { TimeStretchProcessor processor; @@ -526,6 +577,228 @@ TEST_F (TimeStretchProcessorTests, ProcessWithValidInput) EXPECT_TRUE (hasNonZeroSamples); } +TEST_F (TimeStretchProcessorTests, TimeDomainBackendProcessesInput) +{ + TimeStretchProcessor processor; + ASSERT_TRUE (processor.prepare (spec, TimeStretchProcessor::Backend::timeDomain).wasOk()); + + processor.setTimeRatio (1.5); + const int outputFrames = processor.getExpectedOutputFrameCount (inputBuffer.getNumSamples()); + + auto result = processor.process (inputBuffer.getArrayOfReadPointers(), + inputBuffer.getNumSamples(), + outputBuffer.getArrayOfWritePointers(), + outputFrames); + + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (result.getValue(), outputFrames); + + bool hasNonZeroSamples = false; + for (int ch = 0; ch < numChannels; ++ch) + { + const auto* channelData = outputBuffer.getReadPointer (ch); + for (int i = 0; i < result.getValue(); ++i) + { + if (std::abs (channelData[i]) > 0.0001f) + { + hasNonZeroSamples = true; + break; + } + } + } + + EXPECT_TRUE (hasNonZeroSamples); +} + +TEST_F (TimeStretchProcessorTests, TimeDomainBackendSupportsPitchShift) +{ + TimeStretchProcessor processor; + ASSERT_TRUE (processor.prepare (spec, TimeStretchProcessor::Backend::timeDomain).wasOk()); + + processor.setPitchRatio (2.0); + const int outputFrames = processor.getExpectedOutputFrameCount (inputBuffer.getNumSamples()); + + auto result = processor.process (inputBuffer.getArrayOfReadPointers(), + inputBuffer.getNumSamples(), + outputBuffer.getArrayOfWritePointers(), + outputFrames); + + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (result.getValue(), outputFrames); + + bool hasNonZeroSamples = false; + for (int ch = 0; ch < numChannels; ++ch) + { + const auto* channelData = outputBuffer.getReadPointer (ch); + for (int i = 0; i < result.getValue(); ++i) + { + if (std::abs (channelData[i]) > 0.0001f) + { + hasNonZeroSamples = true; + break; + } + } + } + + EXPECT_TRUE (hasNonZeroSamples); +} + +TEST_F (TimeStretchProcessorTests, TimeDomainProviderMuteRegionsAreApplied) +{ + TimeStretchProcessor processor; + ASSERT_TRUE (processor.prepare (spec, TimeStretchProcessor::Backend::timeDomain).wasOk()); + + int providerCallCount = 0; + processor.setInputProvider ([&providerCallCount, numChannels = this->numChannels] (int64 beginFrame, + int numFrames, + float* const* destChannels, + int channelStride, + int& muteHead, + int& muteTail) + { + (void) beginFrame; + (void) channelStride; + + ++providerCallCount; + muteHead = 3; + muteTail = jmax (0, numFrames - 16); + + for (int ch = 0; ch < numChannels; ++ch) + std::fill (destChannels[ch], destChannels[ch] + numFrames, static_cast (ch + 1)); + }); + + AudioBuffer providerOutput (numChannels, 32); + auto result = processor.process (nullptr, + 0, + providerOutput.getArrayOfWritePointers(), + providerOutput.getNumSamples()); + + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (result.getValue(), providerOutput.getNumSamples()); + EXPECT_GT (providerCallCount, 0); + + for (int ch = 0; ch < numChannels; ++ch) + { + const auto expectedValue = static_cast (ch + 1); + const auto* samples = providerOutput.getReadPointer (ch); + + for (int i = 0; i < 3; ++i) + EXPECT_FLOAT_EQ (samples[i], 0.0f); + + for (int i = 3; i < 16; ++i) + EXPECT_FLOAT_EQ (samples[i], expectedValue); + + for (int i = 16; i < providerOutput.getNumSamples(); ++i) + EXPECT_FLOAT_EQ (samples[i], 0.0f); + } +} + +TEST_F (TimeStretchProcessorTests, TimeDomainUnityTempoCopiesProviderInput) +{ + TimeStretchProcessor processor; + ASSERT_TRUE (processor.prepare (spec, TimeStretchProcessor::Backend::timeDomain).wasOk()); + + int lastRequestedFrameCount = 0; + processor.setInputProvider ([&lastRequestedFrameCount, numChannels = this->numChannels] (int64 beginFrame, + int numFrames, + float* const* destChannels, + int channelStride, + int& muteHead, + int& muteTail) + { + (void) channelStride; + + lastRequestedFrameCount = numFrames; + muteHead = 0; + muteTail = 0; + + for (int ch = 0; ch < numChannels; ++ch) + { + for (int i = 0; i < numFrames; ++i) + destChannels[ch][i] = static_cast ((beginFrame + i) * (ch + 1)); + } + }); + + AudioBuffer providerOutput (numChannels, 64); + auto result = processor.process (nullptr, + 0, + providerOutput.getArrayOfWritePointers(), + providerOutput.getNumSamples()); + + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (result.getValue(), providerOutput.getNumSamples()); + EXPECT_GE (lastRequestedFrameCount, providerOutput.getNumSamples()); + + for (int ch = 0; ch < numChannels; ++ch) + { + const auto* samples = providerOutput.getReadPointer (ch); + for (int i = 0; i < providerOutput.getNumSamples(); ++i) + EXPECT_FLOAT_EQ (samples[i], static_cast (i * (ch + 1))); + } +} + +TEST_F (TimeStretchProcessorTests, TimeDomainStretchUsesOverlapSearchWithProviderInput) +{ + TimeStretchProcessor processor; + ASSERT_TRUE (processor.prepare (spec, TimeStretchProcessor::Backend::timeDomain).wasOk()); + + int providerCallCount = 0; + int64 lastBeginFrame = 0; + processor.setInputProvider ([&providerCallCount, &lastBeginFrame, numChannels = this->numChannels, sampleRate = this->sampleRate] (int64 beginFrame, + int numFrames, + float* const* destChannels, + int channelStride, + int& muteHead, + int& muteTail) + { + (void) channelStride; + + ++providerCallCount; + lastBeginFrame = beginFrame; + muteHead = 0; + muteTail = 0; + + for (int ch = 0; ch < numChannels; ++ch) + { + for (int i = 0; i < numFrames; ++i) + { + const double phase = 2.0 * MathConstants::pi * 220.0 + * static_cast (beginFrame + i) / sampleRate; + destChannels[ch][i] = static_cast (0.5 * std::sin (phase)); + } + } + }); + + processor.setTimeRatio (1.5); + + AudioBuffer stretchedOutput (numChannels, 4096); + auto result = processor.process (nullptr, + 0, + stretchedOutput.getArrayOfWritePointers(), + stretchedOutput.getNumSamples()); + + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (result.getValue(), stretchedOutput.getNumSamples()); + EXPECT_GT (providerCallCount, 1); + EXPECT_GT (lastBeginFrame, 0); + + bool hasNonZeroSamples = false; + for (int ch = 0; ch < numChannels; ++ch) + { + const auto* samples = stretchedOutput.getReadPointer (ch); + for (int i = 0; i < result.getValue(); ++i) + { + if (std::abs (samples[i]) > 0.0001f) + { + hasNonZeroSamples = true; + break; + } + } + } + + EXPECT_TRUE (hasNonZeroSamples); +} + TEST_F (TimeStretchProcessorTests, ProcessWithNegativeInputFrameCount) { TimeStretchProcessor processor;