diff --git a/examples/audiograph/CMakeLists.txt b/examples/audiograph/CMakeLists.txt index dc8a0dde..170097ea 100644 --- a/examples/audiograph/CMakeLists.txt +++ b/examples/audiograph/CMakeLists.txt @@ -46,7 +46,8 @@ yup_standalone_app ( yup::yup_audio_gui yup::yup_audio_processors yup::yup_audio_graph - yup::yup_audio_plugin_host) + yup::yup_audio_plugin_host + dr_libs) file (GLOB_RECURSE sources "${CMAKE_CURRENT_LIST_DIR}/source/*.cpp" diff --git a/examples/audiograph/source/AudioGraphApp.cpp b/examples/audiograph/source/AudioGraphApp.cpp index 35f31717..a3ab4d9f 100644 --- a/examples/audiograph/source/AudioGraphApp.cpp +++ b/examples/audiograph/source/AudioGraphApp.cpp @@ -358,8 +358,6 @@ void AudioGraphApp::loadGraphFromMemory (const yup::MemoryBlock& mb, const yup:: return; } - model->setNodePosition (yup::AudioGraphModel::getGraphInputNodeID(), 40.0f, 200.0f); - model->setNodePosition (yup::AudioGraphModel::getGraphOutputNodeID(), 760.0f, 200.0f); graph->commitChanges(); editorPanel->clearUndoHistory(); currentFilePath = file; diff --git a/examples/audiograph/source/nodes/AnalyzerNodes.h b/examples/audiograph/source/nodes/AnalyzerNodes.h new file mode 100644 index 00000000..f583d6fc --- /dev/null +++ b/examples/audiograph/source/nodes/AnalyzerNodes.h @@ -0,0 +1,658 @@ +/* + ============================================================================== + + 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 + +#include +#include +#include +#include + +#include "NodeViewHelpers.h" + +//============================================================================== +namespace AnalyzerNodeHelpers +{ + +inline void pushMonoSamples (const yup::AudioBuffer& audioBuffer, + yup::SpectrumAnalyzerState& analyzerState, + std::atomic& peakLevel) noexcept +{ + const auto numChannels = audioBuffer.getNumChannels(); + const auto numSamples = audioBuffer.getNumSamples(); + + if (numChannels <= 0 || numSamples <= 0) + { + peakLevel.store (0.0f, std::memory_order_relaxed); + return; + } + + float blockPeak = 0.0f; + + for (int sample = 0; sample < numSamples; ++sample) + { + float monoSample = 0.0f; + + for (int channel = 0; channel < numChannels; ++channel) + monoSample += audioBuffer.getSample (channel, sample); + + monoSample /= static_cast (numChannels); + blockPeak = yup::jmax (blockPeak, std::abs (monoSample)); + analyzerState.pushSample (monoSample); + } + + peakLevel.store (blockPeak, std::memory_order_relaxed); +} + +inline yup::String formatPeakLevel (float peakLevel) +{ + if (peakLevel <= 0.000001f) + return "-inf dB"; + + return yup::String (20.0f * std::log10 (peakLevel), 1) + " dB"; +} + +inline yup::DataTree createStatelessNodeState (const yup::Identifier& type) +{ + yup::DataTree state (type); + auto transaction = state.beginTransaction(); + transaction.setProperty ("version", 1); + return state; +} + +inline yup::Result loadStatelessNodeState (const yup::DataTree& state, const yup::Identifier& expectedType) +{ + if (! state.isValid() || state.getType() != expectedType) + return yup::Result::fail ("Invalid node state"); + + if (static_cast (state.getProperty ("version", 0)) != 1) + return yup::Result::fail ("Unsupported node state version"); + + return yup::Result::ok(); +} + +inline yup::Rectangle getAnalyzerBounds (const yup::Component& component, int preferredWidth) +{ + const auto scale = component.getLocalBounds().getWidth() / static_cast (preferredWidth); + auto body = component.getLocalBounds().to().reduced (14.0f * scale, 0.0f); + + return { body.getX() + 8.0f * scale, + 45.0f * scale, + body.getWidth() - 16.0f * scale, + 72.0f * scale }; +} + +inline int getDisplayPointCount (float displayWidth, + int sourcePointCount, + int minimumPointCount, + int maximumPointCount, + float pixelsPerPoint = 1.0f) noexcept +{ + if (displayWidth <= 1.0f || sourcePointCount <= 0) + return 0; + + const auto maxPointCount = yup::jmin (maximumPointCount, sourcePointCount); + const auto minPointCount = yup::jmin (minimumPointCount, maxPointCount); + const auto widthPointCount = static_cast (std::ceil (displayWidth / yup::jmax (1.0f, pixelsPerPoint))); + + return yup::jlimit (minPointCount, maxPointCount, widthPointCount); +} + +inline void drawAnalyzerBackground (yup::Graphics& g, yup::Rectangle bounds, yup::Color accentColor) +{ + g.setFillColor (yup::Color (0xff0f1318)); + g.fillRect (bounds); + + g.setStrokeWidth (1.0f); + g.setStrokeColor (accentColor.withAlpha (0.13f)); + + const auto horizontalLines = bounds.getHeight() >= 36.0f ? 3 : 1; + const auto verticalLines = bounds.getWidth() >= 90.0f ? 5 : (bounds.getWidth() >= 45.0f ? 2 : 0); + + for (int i = 1; i <= horizontalLines; ++i) + { + const auto y = bounds.getY() + bounds.getHeight() * static_cast (i) / static_cast (horizontalLines + 1); + g.strokeLine (bounds.getX(), y, bounds.getRight(), y); + } + + for (int i = 1; i <= verticalLines; ++i) + { + const auto x = bounds.getX() + bounds.getWidth() * static_cast (i) / static_cast (verticalLines + 1); + g.strokeLine (x, bounds.getY(), x, bounds.getBottom()); + } +} + +} // namespace AnalyzerNodeHelpers + +//============================================================================== +class OscilloscopeProcessor final : public yup::AudioProcessor +{ +public: + OscilloscopeProcessor() + : AudioProcessor ("Oscilloscope", + yup::AudioBusLayout ({ yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Input, 2) }, + { yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Output, 2) })) + , analyzerState (512) + { + analyzerState.setOverlapFactor (0.0f); + } + + void prepareToPlay (float, int) override + { + analyzerState.reset(); + peakLevel.store (0.0f, std::memory_order_relaxed); + } + + void releaseResources() override {} + + void processBlock (yup::AudioProcessContext& context) override + { + AnalyzerNodeHelpers::pushMonoSamples (context.audio, analyzerState, peakLevel); + } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + yup::String getPresetName (int) const override { return {}; } + + void setPresetName (int, yup::StringRef) override {} + + bool supportsDataTreeState() const noexcept override { return true; } + + yup::Result loadStateFromDataTree (const yup::DataTree& state) override + { + return AnalyzerNodeHelpers::loadStatelessNodeState (state, stateType); + } + + yup::Result saveStateIntoDataTree (yup::DataTree& state) override + { + state = AnalyzerNodeHelpers::createStatelessNodeState (stateType); + return yup::Result::ok(); + } + + bool hasEditor() const override { return false; } + + yup::SpectrumAnalyzerState& getAnalyzerState() noexcept { return analyzerState; } + + float getPeakLevel() const noexcept { return peakLevel.load (std::memory_order_relaxed); } + +private: + static constexpr const char* stateType = "OscilloscopeState"; + + yup::SpectrumAnalyzerState analyzerState; + std::atomic peakLevel { 0.0f }; +}; + +//============================================================================== +class OscilloscopeDisplayComponent final + : public yup::Component + , public yup::Timer +{ +public: + OscilloscopeDisplayComponent (OscilloscopeProcessor& processorIn, yup::Color accentColorIn) + : processor (processorIn) + , accentColor (accentColorIn) + , scopeData (processorIn.getAnalyzerState().getFftSize(), 0.0f) + { + setOpaque (false); + startTimerHz (30); + } + + ~OscilloscopeDisplayComponent() override + { + stopTimer(); + } + + void paint (yup::Graphics& g) override + { + const auto bounds = getLocalBounds().to(); + + AnalyzerNodeHelpers::drawAnalyzerBackground (g, bounds, accentColor); + + const auto centerY = bounds.getCenterY(); + g.setStrokeColor (accentColor.withAlpha (0.28f)); + g.strokeLine (bounds.getX(), centerY, bounds.getRight(), centerY); + + if (scopeData.size() < 2) + return; + + const auto displayPointCount = AnalyzerNodeHelpers::getDisplayPointCount (bounds.getWidth(), + static_cast (scopeData.size()), + 8, + static_cast (scopeData.size())); + + if (displayPointCount < 2) + return; + + if (displayPointCount >= static_cast (scopeData.size())) + { + yup::Path waveform; + const auto maxIndex = static_cast (scopeData.size() - 1); + + for (size_t i = 0; i < scopeData.size(); ++i) + { + const auto sample = yup::jlimit (-1.0f, 1.0f, scopeData[i]); + const auto x = bounds.getX() + bounds.getWidth() * static_cast (i) / maxIndex; + const auto y = centerY - sample * bounds.getHeight() * 0.46f; + + if (i == 0) + waveform.startNewSubPath (x, y); + else + waveform.lineTo (x, y); + } + + g.setStrokeJoin (yup::StrokeJoin::Round); + g.setStrokeColor (accentColor.withAlpha (0.95f)); + g.setStrokeWidth (1.5f); + g.strokePath (waveform); + return; + } + + yup::Path envelope; + const auto sourcePointCount = static_cast (scopeData.size()); + const auto maxDisplayIndex = static_cast (displayPointCount - 1); + + for (int point = 0; point < displayPointCount; ++point) + { + const auto startIndex = point * sourcePointCount / displayPointCount; + const auto endIndex = yup::jmax (startIndex + 1, (point + 1) * sourcePointCount / displayPointCount); + float minimumSample = 1.0f; + float maximumSample = -1.0f; + + for (int sourceIndex = startIndex; sourceIndex < endIndex; ++sourceIndex) + { + const auto sample = yup::jlimit (-1.0f, 1.0f, scopeData[static_cast (sourceIndex)]); + minimumSample = yup::jmin (minimumSample, sample); + maximumSample = yup::jmax (maximumSample, sample); + } + + const auto x = bounds.getX() + bounds.getWidth() * static_cast (point) / maxDisplayIndex; + const auto minimumY = centerY - maximumSample * bounds.getHeight() * 0.46f; + const auto maximumY = centerY - minimumSample * bounds.getHeight() * 0.46f; + + envelope.startNewSubPath (x, minimumY); + envelope.lineTo (x, maximumY); + } + + g.setStrokeJoin (yup::StrokeJoin::Round); + g.setStrokeColor (accentColor.withAlpha (0.95f)); + g.setStrokeWidth (1.5f); + g.strokePath (envelope); + } + +private: + void timerCallback() override + { + auto& analyzerState = processor.getAnalyzerState(); + bool hasNewData = false; + + for (int i = 0; i < 4 && analyzerState.isFFTDataReady(); ++i) + hasNewData = analyzerState.getFFTData (scopeData.data()) || hasNewData; + + if (hasNewData) + repaint(); + } + + OscilloscopeProcessor& processor; + yup::Color accentColor; + std::vector scopeData; +}; + +//============================================================================== +class OscilloscopeNodeView final : public yup::AudioGraphNodeView +{ +public: + OscilloscopeNodeView (yup::AudioGraphNodeID nodeID, OscilloscopeProcessor& processorIn) + : AudioGraphNodeView (nodeID) + , processor (processorIn) + , display (processorIn, getNodeColor()) + { + setColor (Style::parameterBackgroundColorId, yup::Color (0x00000000)); + setColor (Style::parameterValueBackgroundColorId, yup::Color (0x00000000)); + addAndMakeVisible (display); + } + + yup::String getNodeTitle() const override { return "SCOPE"; } + + int getNumInputPorts() const override { return 1; } + + int getNumOutputPorts() const override { return 1; } + + int getPreferredWidth() const override { return 260; } + + yup::Color getNodeColor() const override { return yup::Color (0xff22c55e); } + + yup::String getNodeSubtitle() const override { return AnalyzerNodeHelpers::formatPeakLevel (processor.getPeakLevel()); } + + PortInfo getInputPortInfo (int) const override { return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; } + + PortInfo getOutputPortInfo (int) const override { return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; } + + int getNumParameterRows() const override { return 3; } + + ParameterInfo getParameterInfo (int) const override + { + return { {}, {}, getNodeColor(), -1.0f, PortKind::parameter }; + } + + void resized() override + { + display.setBounds (AnalyzerNodeHelpers::getAnalyzerBounds (*this, getPreferredWidth())); + } + +private: + OscilloscopeProcessor& processor; + OscilloscopeDisplayComponent display; +}; + +//============================================================================== +class SpectrumAnalyzerProcessor final : public yup::AudioProcessor +{ +public: + SpectrumAnalyzerProcessor() + : AudioProcessor ("Spectrum Analyzer", + yup::AudioBusLayout ({ yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Input, 2) }, + { yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Output, 2) })) + , analyzerState (1024) + { + analyzerState.setOverlapFactor (0.5f); + } + + void prepareToPlay (float, int) override + { + analyzerState.reset(); + peakLevel.store (0.0f, std::memory_order_relaxed); + } + + void releaseResources() override {} + + void processBlock (yup::AudioProcessContext& context) override + { + AnalyzerNodeHelpers::pushMonoSamples (context.audio, analyzerState, peakLevel); + } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + yup::String getPresetName (int) const override { return {}; } + + void setPresetName (int, yup::StringRef) override {} + + bool supportsDataTreeState() const noexcept override { return true; } + + yup::Result loadStateFromDataTree (const yup::DataTree& state) override + { + return AnalyzerNodeHelpers::loadStatelessNodeState (state, stateType); + } + + yup::Result saveStateIntoDataTree (yup::DataTree& state) override + { + state = AnalyzerNodeHelpers::createStatelessNodeState (stateType); + return yup::Result::ok(); + } + + bool hasEditor() const override { return false; } + + yup::SpectrumAnalyzerState& getAnalyzerState() noexcept { return analyzerState; } + + float getPeakLevel() const noexcept { return peakLevel.load (std::memory_order_relaxed); } + +private: + static constexpr const char* stateType = "SpectrumAnalyzerState"; + + yup::SpectrumAnalyzerState analyzerState; + std::atomic peakLevel { 0.0f }; +}; + +//============================================================================== +class SpectrumAnalyzerDisplayComponent final + : public yup::Component + , public yup::Timer +{ +public: + SpectrumAnalyzerDisplayComponent (SpectrumAnalyzerProcessor& processorIn, yup::Color accentColorIn) + : processor (processorIn) + , accentColor (accentColorIn) + , fftSize (processorIn.getAnalyzerState().getFftSize()) + , fftProcessor (fftSize) + , fftInput (static_cast (fftSize), 0.0f) + , fftOutput (static_cast (fftSize * 2), 0.0f) + , displayLevels (maxDisplayPoints, 0.0f) + , binRanges (maxDisplayPoints) + { + generateWindow(); + setOpaque (false); + startTimerHz (30); + } + + ~SpectrumAnalyzerDisplayComponent() override + { + stopTimer(); + } + + void paint (yup::Graphics& g) override + { + const auto bounds = getLocalBounds().to(); + + AnalyzerNodeHelpers::drawAnalyzerBackground (g, bounds, accentColor); + + const auto displayPointCount = getTargetDisplayPointCount(); + if (displayPointCount < 2) + return; + + const auto baseline = bounds.getBottom(); + const auto maxDisplayIndex = static_cast (displayPointCount - 1); + yup::Path fillPath; + yup::Path linePath; + + fillPath.startNewSubPath (bounds.getX(), baseline); + + for (int i = 0; i < displayPointCount; ++i) + { + const auto level = yup::jlimit (0.0f, 1.0f, displayLevels[static_cast (i)]); + const auto x = bounds.getX() + bounds.getWidth() * static_cast (i) / maxDisplayIndex; + const auto y = baseline - bounds.getHeight() * level; + + fillPath.lineTo (x, y); + + if (i == 0) + linePath.startNewSubPath (x, y); + else + linePath.lineTo (x, y); + } + + fillPath.lineTo (bounds.getRight(), baseline); + fillPath.closeSubPath(); + + g.setFillColor (accentColor.withAlpha (0.24f)); + g.fillPath (fillPath); + + g.setStrokeJoin (yup::StrokeJoin::Round); + g.setStrokeColor (accentColor.withAlpha (0.95f)); + g.setStrokeWidth (1.5f); + g.strokePath (linePath); + } + +private: + void timerCallback() override + { + auto& analyzerState = processor.getAnalyzerState(); + const auto displayPointCount = getTargetDisplayPointCount(); + bool hasNewData = false; + + for (int i = 0; i < 4 && displayPointCount >= 2 && analyzerState.isFFTDataReady(); ++i) + { + if (analyzerState.getFFTData (fftInput.data())) + { + processFftFrame (displayPointCount); + hasNewData = true; + } + } + + if (hasNewData) + repaint(); + } + + void generateWindow() + { + window.resize (static_cast (fftSize), 0.0f); + + for (int i = 0; i < fftSize; ++i) + { + const auto phase = yup::MathConstants::twoPi * static_cast (i) / static_cast (fftSize - 1); + window[static_cast (i)] = 0.5f - 0.5f * std::cos (phase); + } + } + + int getTargetDisplayPointCount() const noexcept + { + return AnalyzerNodeHelpers::getDisplayPointCount (getLocalBounds().getWidth(), + maxDisplayPoints, + minDisplayPoints, + maxDisplayPoints, + 2.0f); + } + + void updateBinRanges (int displayPointCount) + { + if (displayPointCount == cachedDisplayPointCount) + return; + + const auto maxBin = fftSize / 2; + + for (int displayBin = 0; displayBin < displayPointCount; ++displayBin) + { + const auto startNormalised = static_cast (displayBin) / static_cast (displayPointCount); + const auto endNormalised = static_cast (displayBin + 1) / static_cast (displayPointCount); + const auto startBin = yup::jlimit (1, maxBin, static_cast (std::pow (startNormalised, 1.8f) * static_cast (maxBin - 1)) + 1); + const auto endBin = yup::jlimit (startBin, maxBin, static_cast (std::pow (endNormalised, 1.8f) * static_cast (maxBin - 1)) + 1); + + binRanges[static_cast (displayBin)] = { startBin, endBin }; + } + + cachedDisplayPointCount = displayPointCount; + } + + void processFftFrame (int displayPointCount) + { + updateBinRanges (displayPointCount); + + for (int i = 0; i < fftSize; ++i) + fftInput[static_cast (i)] *= window[static_cast (i)]; + + fftProcessor.performRealFFTForward (fftInput.data(), fftOutput.data()); + + for (int displayBin = 0; displayBin < displayPointCount; ++displayBin) + { + const auto binRange = binRanges[static_cast (displayBin)]; + float magnitude = 0.0f; + + for (int bin = binRange.startBin; bin <= binRange.endBin; ++bin) + { + const auto real = fftOutput[static_cast (bin * 2)]; + const auto imag = fftOutput[static_cast (bin * 2 + 1)]; + magnitude = yup::jmax (magnitude, std::sqrt (real * real + imag * imag)); + } + + const auto normalizedMagnitude = yup::jmax (0.000001f, magnitude * 2.0f / static_cast (fftSize)); + const auto decibels = 20.0f * std::log10 (normalizedMagnitude); + const auto targetLevel = yup::jlimit (0.0f, 1.0f, (decibels + 72.0f) / 72.0f); + auto& displayLevel = displayLevels[static_cast (displayBin)]; + + displayLevel = targetLevel > displayLevel + ? targetLevel + : displayLevel * 0.78f + targetLevel * 0.22f; + } + } + + struct BinRange + { + int startBin = 1; + int endBin = 1; + }; + + static constexpr int minDisplayPoints = 8; + static constexpr int maxDisplayPoints = 160; + + SpectrumAnalyzerProcessor& processor; + yup::Color accentColor; + int fftSize = 0; + yup::FFTProcessor fftProcessor; + std::vector fftInput; + std::vector fftOutput; + std::vector window; + std::vector displayLevels; + std::vector binRanges; + int cachedDisplayPointCount = 0; +}; + +//============================================================================== +class SpectrumAnalyzerNodeView final : public yup::AudioGraphNodeView +{ +public: + SpectrumAnalyzerNodeView (yup::AudioGraphNodeID nodeID, SpectrumAnalyzerProcessor& processorIn) + : AudioGraphNodeView (nodeID) + , processor (processorIn) + , display (processorIn, getNodeColor()) + { + setColor (Style::parameterBackgroundColorId, yup::Color (0x00000000)); + setColor (Style::parameterValueBackgroundColorId, yup::Color (0x00000000)); + addAndMakeVisible (display); + } + + yup::String getNodeTitle() const override { return "SPECTRUM"; } + + int getNumInputPorts() const override { return 1; } + + int getNumOutputPorts() const override { return 1; } + + int getPreferredWidth() const override { return 260; } + + yup::Color getNodeColor() const override { return yup::Color (0xfff59e0b); } + + yup::String getNodeSubtitle() const override { return AnalyzerNodeHelpers::formatPeakLevel (processor.getPeakLevel()); } + + PortInfo getInputPortInfo (int) const override { return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; } + + PortInfo getOutputPortInfo (int) const override { return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; } + + int getNumParameterRows() const override { return 3; } + + ParameterInfo getParameterInfo (int) const override + { + return { {}, {}, getNodeColor(), -1.0f, PortKind::parameter }; + } + + void resized() override + { + display.setBounds (AnalyzerNodeHelpers::getAnalyzerBounds (*this, getPreferredWidth())); + } + +private: + SpectrumAnalyzerProcessor& processor; + SpectrumAnalyzerDisplayComponent display; +}; diff --git a/examples/audiograph/source/nodes/DistortionNodes.h b/examples/audiograph/source/nodes/DistortionNodes.h index 42e21f8f..0b16c14b 100644 --- a/examples/audiograph/source/nodes/DistortionNodes.h +++ b/examples/audiograph/source/nodes/DistortionNodes.h @@ -22,7 +22,6 @@ #pragma once #include -#include #include #include @@ -38,6 +37,12 @@ class TanhDistortionProcessor final : public yup::AudioProcessor yup::AudioBusLayout ({ yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Input, 2) }, { yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Output, 2) })) { + drive = NodeViewHelpers::createParameter ("drive", "Drive", 1.0f, 24.0f, 4.0f, 0.1f); + oversamplingIndex = NodeViewHelpers::createParameter ("oversampling", "Oversampling", 0.0f, 3.0f, 2.0f, 1.0f); + + addParameter (drive); + addParameter (oversamplingIndex); + updateLatency(); } @@ -51,6 +56,9 @@ class TanhDistortionProcessor final : public yup::AudioProcessor oversampler8x.prepare (sampleRate, maximumOversampledChannels, blockSize); oversamplersPrepared = true; + smoothedDrive.reset (newSampleRate, 0.02); + smoothedDrive.setCurrentAndTargetValue (getDrive()); + updateLatency(); } @@ -73,36 +81,45 @@ class TanhDistortionProcessor final : public yup::AudioProcessor if (numChannels <= 0 || numSamples <= 0) return; - const auto currentDrive = drive.load (std::memory_order_relaxed); - const auto currentOversamplingIndex = oversamplingIndex.load (std::memory_order_relaxed); + smoothedDrive.setTargetValue (getDrive()); + const auto currentOversamplingIndex = getOversamplingIndex(); if (! oversamplersPrepared || currentOversamplingIndex == 0) { - processChannelsInPlace (audioBuffer, 0, numChannels, currentDrive); + for (int sample = 0; sample < numSamples; ++sample) + { + const float drive = smoothedDrive.getNextValue(); + for (int channel = 0; channel < numChannels; ++channel) + { + auto* channelData = audioBuffer.getWritePointer (channel); + channelData[sample] = processSample (channelData[sample], drive); + } + } return; } + const float effectiveDrive = smoothedDrive.skip (numSamples); const int oversampledChannels = yup::jmin (numChannels, maximumOversampledChannels); switch (currentOversamplingIndex) { case 1: - processOversampled (oversampler2x, audioBuffer, oversampledChannels, numSamples, currentDrive); + processOversampled (oversampler2x, audioBuffer, oversampledChannels, numSamples, effectiveDrive); break; case 2: - processOversampled (oversampler4x, audioBuffer, oversampledChannels, numSamples, currentDrive); + processOversampled (oversampler4x, audioBuffer, oversampledChannels, numSamples, effectiveDrive); break; case 3: - processOversampled (oversampler8x, audioBuffer, oversampledChannels, numSamples, currentDrive); + processOversampled (oversampler8x, audioBuffer, oversampledChannels, numSamples, effectiveDrive); break; default: break; } - processChannelsInPlace (audioBuffer, oversampledChannels, numChannels, currentDrive); + processChannelsInPlace (audioBuffer, oversampledChannels, numChannels, effectiveDrive); } int getCurrentPreset() const noexcept override { return 0; } @@ -115,56 +132,43 @@ class TanhDistortionProcessor final : public yup::AudioProcessor void setPresetName (int, yup::StringRef) override {} - yup::Result loadStateFromMemory (const yup::MemoryBlock& data) override - { - if (data.isEmpty()) - return yup::Result::ok(); - - yup::MemoryInputStream stream (data, false); - - const int version = stream.readInt(); - if (version != 1) - return yup::Result::fail ("Unsupported tanh distortion node state version"); + bool supportsDataTreeState() const noexcept override { return true; } - setDrive (stream.readFloat()); - setOversamplingIndex (stream.readInt()); + yup::Result loadStateFromDataTree (const yup::DataTree& state) override + { + if (const auto result = NodeViewHelpers::loadParameterState (state, stateType, getParameters()); result.failed()) + return result; + updateLatency(); return yup::Result::ok(); } - yup::Result saveStateIntoMemory (yup::MemoryBlock& data) override + yup::Result saveStateIntoDataTree (yup::DataTree& state) override { - yup::MemoryOutputStream stream (data, false); - stream.writeInt (1); - stream.writeFloat (getDrive()); - stream.writeInt (getOversamplingIndex()); - stream.flush(); - + state = NodeViewHelpers::createParameterState (stateType, getParameters()); return yup::Result::ok(); } bool hasEditor() const override { return false; } - yup::AudioProcessorEditor* createEditor() override { return nullptr; } - float getDrive() const noexcept { - return drive.load (std::memory_order_relaxed); + return drive->getValue(); } int getOversamplingIndex() const noexcept { - return oversamplingIndex.load (std::memory_order_relaxed); + return yup::roundToInt (oversamplingIndex->getValue()); } void setDrive (float newDrive) noexcept { - drive.store (yup::jlimit (1.0f, 24.0f, newDrive), std::memory_order_relaxed); + drive->setValue (newDrive); } void setOversamplingIndex (int newOversamplingIndex) noexcept { - oversamplingIndex.store (yup::jlimit (0, 3, newOversamplingIndex), std::memory_order_relaxed); + oversamplingIndex->setValue (static_cast (newOversamplingIndex)); updateLatency(); } @@ -184,6 +188,7 @@ class TanhDistortionProcessor final : public yup::AudioProcessor } private: + static constexpr const char* stateType = "TanhDistortionState"; static constexpr int maximumOversampledChannels = 2; static float processSample (float input, float currentDrive) noexcept @@ -249,8 +254,9 @@ class TanhDistortionProcessor final : public yup::AudioProcessor setLatencySamples (latencySamples); } - std::atomic drive { 4.0f }; - std::atomic oversamplingIndex { 2 }; + yup::AudioParameter::Ptr drive; + yup::AudioParameter::Ptr oversamplingIndex; + yup::SmoothedValue smoothedDrive; yup::Oversampler2xFloat oversampler2x; yup::Oversampler4xFloat oversampler4x; yup::Oversampler8xFloat oversampler8x; @@ -266,12 +272,22 @@ class BlunterSoftClipperProcessor final : public yup::AudioProcessor yup::AudioBusLayout ({ yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Input, 2) }, { yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Output, 2) })) { + drive = NodeViewHelpers::createParameter ("drive", "Drive", 0.1f, 24.0f, 1.0f, 0.1f); + output = NodeViewHelpers::createParameter ("output", "Output", 0.0f, 1.5f, 0.5f, 0.01f); + + addParameter (drive); + addParameter (output); } void prepareToPlay (float newSampleRate, int maxBlockSize) override { for (auto& clipper : clippers) clipper.prepare (newSampleRate, maxBlockSize); + + smoothedDrive.reset (newSampleRate, 0.02); + smoothedDrive.setCurrentAndTargetValue (getDrive()); + smoothedOutput.reset (newSampleRate, 0.02); + smoothedOutput.setCurrentAndTargetValue (getOutput()); } void releaseResources() override {} @@ -286,19 +302,25 @@ class BlunterSoftClipperProcessor final : public yup::AudioProcessor { auto& audioBuffer = context.audio; - const auto currentDrive = drive.load (std::memory_order_relaxed); - const auto currentOutput = output.load (std::memory_order_relaxed); - const int clipperChannels = static_cast (clippers.size()); + const int numChannels = audioBuffer.getNumChannels(); + const int numSamples = audioBuffer.getNumSamples(); + const int clipperChans = static_cast (clippers.size()); - for (auto& clipper : clippers) - clipper.setParameters (currentDrive, currentOutput); + smoothedDrive.setTargetValue (getDrive()); + smoothedOutput.setTargetValue (getOutput()); - for (int channel = 0; channel < audioBuffer.getNumChannels(); ++channel) + for (int sample = 0; sample < numSamples; ++sample) { - auto* channelData = audioBuffer.getWritePointer (channel); - auto& clipper = clippers[static_cast (yup::jmin (channel, clipperChannels - 1))]; + const float drive = smoothedDrive.getNextValue(); + const float out = smoothedOutput.getNextValue(); - clipper.processInPlace (channelData, audioBuffer.getNumSamples()); + for (int channel = 0; channel < numChannels; ++channel) + { + auto& clipper = clippers[static_cast (yup::jmin (channel, clipperChans - 1))]; + clipper.setParameters (drive, out); + auto* channelData = audioBuffer.getWritePointer (channel); + channelData[sample] = clipper.processSample (channelData[sample]); + } } } @@ -312,8 +334,24 @@ class BlunterSoftClipperProcessor final : public yup::AudioProcessor void setPresetName (int, yup::StringRef) override {} + bool supportsDataTreeState() const noexcept override { return true; } + + yup::Result loadStateFromDataTree (const yup::DataTree& state) override + { + return NodeViewHelpers::loadParameterState (state, stateType, getParameters()); + } + + yup::Result saveStateIntoDataTree (yup::DataTree& state) override + { + state = NodeViewHelpers::createParameterState (stateType, getParameters()); + return yup::Result::ok(); + } + yup::Result loadStateFromMemory (const yup::MemoryBlock& data) override { + if (const auto result = AudioProcessor::loadStateFromMemory (data); result.wasOk()) + return result; + if (data.isEmpty()) return yup::Result::ok(); @@ -331,13 +369,7 @@ class BlunterSoftClipperProcessor final : public yup::AudioProcessor yup::Result saveStateIntoMemory (yup::MemoryBlock& data) override { - yup::MemoryOutputStream stream (data, false); - stream.writeInt (1); - stream.writeFloat (getDrive()); - stream.writeFloat (getOutput()); - stream.flush(); - - return yup::Result::ok(); + return AudioProcessor::saveStateIntoMemory (data); } bool hasEditor() const override { return false; } @@ -346,27 +378,31 @@ class BlunterSoftClipperProcessor final : public yup::AudioProcessor float getDrive() const noexcept { - return drive.load (std::memory_order_relaxed); + return drive->getValue(); } float getOutput() const noexcept { - return output.load (std::memory_order_relaxed); + return output->getValue(); } void setDrive (float newDrive) noexcept { - drive.store (yup::jlimit (0.1f, 24.0f, newDrive), std::memory_order_relaxed); + drive->setValue (newDrive); } void setOutput (float newOutput) noexcept { - output.store (yup::jlimit (0.0f, 1.5f, newOutput), std::memory_order_relaxed); + output->setValue (newOutput); } private: - std::atomic drive { 1.0f }; - std::atomic output { 0.5f }; + static constexpr const char* stateType = "BlunterSoftClipperState"; + + yup::AudioParameter::Ptr drive; + yup::AudioParameter::Ptr output; + yup::SmoothedValue smoothedDrive; + yup::SmoothedValue smoothedOutput; std::array clippers; }; @@ -539,3 +575,230 @@ class BlunterSoftClipperNodeView final : public yup::AudioGraphNodeView yup::Slider driveSlider; yup::Slider outputSlider; }; + +//============================================================================== +class AaIirHardClipperProcessor final : public yup::AudioProcessor +{ +public: + AaIirHardClipperProcessor() + : AudioProcessor ("AA-IIR Hard Clipper", + yup::AudioBusLayout ({ yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Input, 2) }, + { yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Output, 2) })) + { + drive = NodeViewHelpers::createParameter ("drive", "Drive", 0.1f, 24.0f, 1.0f, 0.1f); + output = NodeViewHelpers::createParameter ("output", "Output", 0.0f, 1.5f, 1.0f, 0.01f); + + addParameter (drive); + addParameter (output); + } + + void prepareToPlay (float newSampleRate, int maxBlockSize) override + { + for (auto& clipper : clippers) + clipper.prepare (static_cast (newSampleRate), maxBlockSize); + + smoothedDrive.reset (newSampleRate, 0.02); + smoothedDrive.setCurrentAndTargetValue (getDrive()); + smoothedOutput.reset (newSampleRate, 0.02); + smoothedOutput.setCurrentAndTargetValue (getOutput()); + } + + void releaseResources() override {} + + void flush() override + { + for (auto& clipper : clippers) + clipper.reset(); + } + + void processBlock (yup::AudioProcessContext& context) override + { + auto& audioBuffer = context.audio; + + const int numChannels = audioBuffer.getNumChannels(); + const int numSamples = audioBuffer.getNumSamples(); + + smoothedDrive.setTargetValue (getDrive()); + smoothedOutput.setTargetValue (getOutput()); + + for (int sample = 0; sample < numSamples; ++sample) + { + const float drive = smoothedDrive.getNextValue(); + const float out = smoothedOutput.getNextValue(); + + for (int channel = 0; channel < numChannels; ++channel) + { + auto* channelData = audioBuffer.getWritePointer (channel); + auto& clipper = clippers[static_cast (yup::jmin (channel, clipperChannels - 1))]; + channelData[sample] = clipper.processSample (channelData[sample] * drive) * out; + } + } + } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + yup::String getPresetName (int) const override { return {}; } + + void setPresetName (int, yup::StringRef) override {} + + bool supportsDataTreeState() const noexcept override { return true; } + + yup::Result loadStateFromDataTree (const yup::DataTree& state) override + { + return NodeViewHelpers::loadParameterState (state, stateType, getParameters()); + } + + yup::Result saveStateIntoDataTree (yup::DataTree& state) override + { + state = NodeViewHelpers::createParameterState (stateType, getParameters()); + return yup::Result::ok(); + } + + yup::Result loadStateFromMemory (const yup::MemoryBlock& data) override + { + if (const auto result = AudioProcessor::loadStateFromMemory (data); result.wasOk()) + return result; + + if (data.isEmpty()) + return yup::Result::ok(); + + yup::MemoryInputStream stream (data, false); + + const int version = stream.readInt(); + if (version != 1) + return yup::Result::fail ("Unsupported AA-IIR hard clipper node state version"); + + setDrive (stream.readFloat()); + setOutput (stream.readFloat()); + + return yup::Result::ok(); + } + + yup::Result saveStateIntoMemory (yup::MemoryBlock& data) override + { + return AudioProcessor::saveStateIntoMemory (data); + } + + bool hasEditor() const override { return false; } + + yup::AudioProcessorEditor* createEditor() override { return nullptr; } + + float getDrive() const noexcept + { + return drive->getValue(); + } + + float getOutput() const noexcept + { + return output->getValue(); + } + + void setDrive (float newDrive) noexcept + { + drive->setValue (newDrive); + } + + void setOutput (float newOutput) noexcept + { + output->setValue (newOutput); + } + +private: + static constexpr const char* stateType = "AaIirHardClipperState"; + static constexpr int clipperChannels = 2; + + yup::AudioParameter::Ptr drive; + yup::AudioParameter::Ptr output; + yup::SmoothedValue smoothedDrive; + yup::SmoothedValue smoothedOutput; + std::array clippers; +}; + +//============================================================================== +class AaIirHardClipperNodeView final : public yup::AudioGraphNodeView +{ +public: + AaIirHardClipperNodeView (yup::AudioGraphNodeID nodeID, AaIirHardClipperProcessor& processorIn) + : AudioGraphNodeView (nodeID) + , processor (processorIn) + , driveSlider (yup::Slider::LinearBarHorizontal) + , outputSlider (yup::Slider::LinearBarHorizontal) + { + NodeViewHelpers::configureParameterSlider (driveSlider, getPortKindColor (PortKind::parameter)); + driveSlider.setRange (0.1, 24.0, 0.1); + driveSlider.setSkewFactorFromMidpoint (2.0); + driveSlider.setValue (processor.getDrive(), yup::dontSendNotification); + driveSlider.onValueChanged = [this] (double value) + { + processor.setDrive (static_cast (value)); + repaint(); + }; + addAndMakeVisible (driveSlider); + + NodeViewHelpers::configureParameterSlider (outputSlider, getPortKindColor (PortKind::parameter)); + outputSlider.setRange (0.0, 1.5, 0.01); + outputSlider.setValue (processor.getOutput(), yup::dontSendNotification); + outputSlider.onValueChanged = [this] (double value) + { + processor.setOutput (static_cast (value)); + repaint(); + }; + addAndMakeVisible (outputSlider); + } + + yup::String getNodeTitle() const override { return "AA-IIR"; } + + int getNumInputPorts() const override { return 1; } + + int getNumOutputPorts() const override { return 1; } + + int getPreferredWidth() const override { return 240; } + + yup::Color getNodeColor() const override { return yup::Color (0xff8b5cf6); } + + yup::String getNodeSubtitle() const override + { + return yup::String ("x ") + yup::String (processor.getDrive(), 1) + " -> " + yup::String (processor.getOutput(), 2); + } + + PortInfo getInputPortInfo (int) const override { return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; } + + PortInfo getOutputPortInfo (int) const override { return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; } + + int getNumParameterRows() const override { return 2; } + + ParameterInfo getParameterInfo (int parameterIndex) const override + { + switch (parameterIndex) + { + case 0: + return { "Drive", yup::String (processor.getDrive(), 1), getPortKindColor (PortKind::parameter), normalizedDrive (processor.getDrive()), PortKind::parameter }; + + case 1: + return { "Output", yup::String (processor.getOutput(), 2), getPortKindColor (PortKind::parameter), processor.getOutput() / 1.5f, PortKind::parameter }; + + default: + return {}; + } + } + + void resized() override + { + driveSlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth(), 0)); + outputSlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth(), 1)); + } + +private: + static float normalizedDrive (float value) noexcept + { + return yup::jlimit (0.0f, 1.0f, (value - 0.1f) / 23.9f); + } + + AaIirHardClipperProcessor& processor; + yup::Slider driveSlider; + yup::Slider outputSlider; +}; diff --git a/examples/audiograph/source/nodes/FractionalDelayNode.h b/examples/audiograph/source/nodes/FractionalDelayNode.h new file mode 100644 index 00000000..0739a6f0 --- /dev/null +++ b/examples/audiograph/source/nodes/FractionalDelayNode.h @@ -0,0 +1,369 @@ +/* + ============================================================================== + + 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 + +#include +#include +#include + +#include + +#include "NodeViewHelpers.h" + +//============================================================================== +class FractionalDelayProcessor final : public yup::AudioProcessor +{ +public: + FractionalDelayProcessor() + : AudioProcessor ("Fractional Delay", + yup::AudioBusLayout ({ yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Input, 2) }, + { yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Output, 2) })) + { + delayLeftMilliseconds = NodeViewHelpers::createParameter ("delayLeftMilliseconds", "L Time", 1.0f, maximumDelayMilliseconds, defaultDelayLeftMilliseconds, 1.0f); + delayRightMilliseconds = NodeViewHelpers::createParameter ("delayRightMilliseconds", "R Time", 1.0f, maximumDelayMilliseconds, defaultDelayRightMilliseconds, 1.0f); + feedback = NodeViewHelpers::createParameter ("feedback", "Feedback", 0.0f, maximumFeedback, 0.35f, 0.01f); + dryWet = NodeViewHelpers::createParameter ("dryWet", "Dry/Wet", 0.0f, 1.0f, 0.5f, 0.01f); + + addParameter (delayLeftMilliseconds); + addParameter (delayRightMilliseconds); + addParameter (feedback); + addParameter (dryWet); + } + + void prepareToPlay (float newSampleRate, int) override + { + sampleRate.store (yup::jmax (1.0f, newSampleRate), std::memory_order_relaxed); + + const int maxDelaySamples = yup::roundToInt (delayMillisecondsToSamples (maximumDelayMilliseconds)); + for (auto& delayLine : delayLines) + { + delayLine.setMaxDelaySamples (maxDelaySamples); + delayLine.reset(); + } + + feedbackState = {}; + } + + void releaseResources() override {} + + void flush() override + { + for (auto& delayLine : delayLines) + delayLine.reset(); + + feedbackState = {}; + } + + void processBlock (yup::AudioProcessContext& context) override + { + if (delayLines[0].getBufferSize() == 0 || delayLines[1].getBufferSize() == 0) + return; + + const auto currentDelayLeftSamples = delayMillisecondsToSamples (getDelayLeftMilliseconds()); + const auto currentDelayRightSamples = delayMillisecondsToSamples (getDelayRightMilliseconds()); + + delayLines[0].setDelaySamples (currentDelayLeftSamples); + delayLines[1].setDelaySamples (currentDelayRightSamples); + + auto& audioBuffer = context.audio; + + const auto currentFeedback = getFeedback(); + const auto currentDryWet = getDryWet(); + const auto dryGain = 1.0f - currentDryWet; + + const int channels = yup::jmin (audioBuffer.getNumChannels(), static_cast (delayLines.size())); + + for (int channel = 0; channel < channels; ++channel) + { + auto* channelData = audioBuffer.getWritePointer (channel); + auto& delayLine = delayLines[static_cast (channel)]; + auto& channelFeedback = feedbackState[static_cast (channel)]; + + for (int sample = 0; sample < audioBuffer.getNumSamples(); ++sample) + { + const auto input = channelData[sample]; + const auto delayed = delayLine.processSample (input + channelFeedback * currentFeedback); + + channelFeedback = delayed; + channelData[sample] = dryGain * input + currentDryWet * delayed; + } + } + } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + yup::String getPresetName (int) const override { return {}; } + + void setPresetName (int, yup::StringRef) override {} + + bool supportsDataTreeState() const noexcept override { return true; } + + yup::Result loadStateFromDataTree (const yup::DataTree& state) override + { + return NodeViewHelpers::loadParameterState (state, stateType, getParameters()); + } + + yup::Result saveStateIntoDataTree (yup::DataTree& state) override + { + state = NodeViewHelpers::createParameterState (stateType, getParameters()); + return yup::Result::ok(); + } + + yup::Result loadStateFromMemory (const yup::MemoryBlock& data) override + { + if (const auto result = AudioProcessor::loadStateFromMemory (data); result.wasOk()) + return result; + + if (data.isEmpty()) + return yup::Result::ok(); + + yup::MemoryInputStream stream (data, false); + + const int version = stream.readInt(); + if (version != 1) + return yup::Result::fail ("Unsupported fractional delay node state version"); + + setDelayLeftMilliseconds (stream.readFloat()); + setDelayRightMilliseconds (stream.readFloat()); + setFeedback (stream.readFloat()); + setDryWet (stream.readFloat()); + + return yup::Result::ok(); + } + + yup::Result saveStateIntoMemory (yup::MemoryBlock& data) override + { + return AudioProcessor::saveStateIntoMemory (data); + } + + bool hasEditor() const override { return false; } + + yup::AudioProcessorEditor* createEditor() override { return nullptr; } + + float getDelayLeftMilliseconds() const noexcept + { + return delayLeftMilliseconds->getValue(); + } + + float getDelayRightMilliseconds() const noexcept + { + return delayRightMilliseconds->getValue(); + } + + float getFeedback() const noexcept + { + return feedback->getValue(); + } + + float getDryWet() const noexcept + { + return dryWet->getValue(); + } + + void setDelayLeftMilliseconds (float newDelayMilliseconds) noexcept + { + delayLeftMilliseconds->setValue (limitDelayMilliseconds (newDelayMilliseconds)); + } + + void setDelayRightMilliseconds (float newDelayMilliseconds) noexcept + { + delayRightMilliseconds->setValue (limitDelayMilliseconds (newDelayMilliseconds)); + } + + void setFeedback (float newFeedback) noexcept + { + feedback->setValue (newFeedback); + } + + void setDryWet (float newDryWet) noexcept + { + dryWet->setValue (newDryWet); + } + +private: + static constexpr const char* stateType = "FractionalDelayState"; + + static constexpr float defaultDelayLeftMilliseconds = 250.0f; + static constexpr float defaultDelayRightMilliseconds = 375.0f; + static constexpr float maximumDelayMilliseconds = 2000.0f; + static constexpr float maximumFeedback = 0.95f; + + static float limitDelayMilliseconds (float milliseconds) noexcept + { + return yup::jlimit (1.0f, maximumDelayMilliseconds, milliseconds); + } + + double delayMillisecondsToSamples (float milliseconds) const noexcept + { + const auto sr = sampleRate.load (std::memory_order_relaxed); + return (static_cast (milliseconds) * static_cast (sr)) / 1000.0; + } + + std::atomic sampleRate { 44100.0f }; + yup::AudioParameter::Ptr delayLeftMilliseconds; + yup::AudioParameter::Ptr delayRightMilliseconds; + yup::AudioParameter::Ptr feedback; + yup::AudioParameter::Ptr dryWet; + std::array delayLines; + std::array feedbackState {}; +}; + +//============================================================================== +class FractionalDelayNodeView final : public yup::AudioGraphNodeView +{ +public: + FractionalDelayNodeView (yup::AudioGraphNodeID nodeID, FractionalDelayProcessor& processorIn) + : AudioGraphNodeView (nodeID) + , processor (processorIn) + , delayLeftSlider (yup::Slider::LinearBarHorizontal) + , delayRightSlider (yup::Slider::LinearBarHorizontal) + , feedbackSlider (yup::Slider::LinearBarHorizontal) + , dryWetSlider (yup::Slider::LinearBarHorizontal) + { + configureDelaySlider (delayLeftSlider, processor.getDelayLeftMilliseconds(), [this] (double value) + { + processor.setDelayLeftMilliseconds (static_cast (value)); + repaint(); + }); + + configureDelaySlider (delayRightSlider, processor.getDelayRightMilliseconds(), [this] (double value) + { + processor.setDelayRightMilliseconds (static_cast (value)); + repaint(); + }); + + configureUnitSlider (feedbackSlider, processor.getFeedback(), maximumFeedback, [this] (double value) + { + processor.setFeedback (static_cast (value)); + repaint(); + }); + + configureUnitSlider (dryWetSlider, processor.getDryWet(), 1.0, [this] (double value) + { + processor.setDryWet (static_cast (value)); + repaint(); + }); + + addAndMakeVisible (delayLeftSlider); + addAndMakeVisible (delayRightSlider); + addAndMakeVisible (feedbackSlider); + addAndMakeVisible (dryWetSlider); + } + + yup::String getNodeTitle() const override { return "FAD"; } + + int getNumInputPorts() const override { return 1; } + + int getNumOutputPorts() const override { return 1; } + + int getPreferredWidth() const override { return 260; } + + yup::Color getNodeColor() const override { return yup::Color (0xfff97316); } + + yup::String getNodeSubtitle() const override + { + return yup::String ("L ") + yup::String (processor.getDelayLeftMilliseconds(), 0) + + " / R " + yup::String (processor.getDelayRightMilliseconds(), 0) + " ms"; + } + + PortInfo getInputPortInfo (int) const override { return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; } + + PortInfo getOutputPortInfo (int) const override { return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; } + + int getNumParameterRows() const override { return 4; } + + ParameterInfo getParameterInfo (int parameterIndex) const override + { + switch (parameterIndex) + { + case 0: + return { "L Time", millisecondsString (processor.getDelayLeftMilliseconds()), getPortKindColor (PortKind::parameter), delayNormalized (processor.getDelayLeftMilliseconds()), PortKind::parameter }; + + case 1: + return { "R Time", millisecondsString (processor.getDelayRightMilliseconds()), getPortKindColor (PortKind::parameter), delayNormalized (processor.getDelayRightMilliseconds()), PortKind::parameter }; + + case 2: + return { "Feedback", percentageString (processor.getFeedback()), getPortKindColor (PortKind::parameter), processor.getFeedback() / maximumFeedback, PortKind::parameter }; + + case 3: + return { "Dry/Wet", percentageString (processor.getDryWet()), getPortKindColor (PortKind::parameter), processor.getDryWet(), PortKind::parameter }; + + default: + return {}; + } + } + + void resized() override + { + delayLeftSlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth(), 0)); + delayRightSlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth(), 1)); + feedbackSlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth(), 2)); + dryWetSlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth(), 3)); + } + +private: + static constexpr float maximumFeedback = 0.95f; + + template + void configureDelaySlider (yup::Slider& slider, float value, Callback&& callback) + { + NodeViewHelpers::configureParameterSlider (slider, getPortKindColor (PortKind::parameter)); + slider.setRange (1.0, 2000.0, 1.0); + slider.setSkewFactorFromMidpoint (250.0); + slider.setValue (value, yup::dontSendNotification); + slider.onValueChanged = std::forward (callback); + } + + template + void configureUnitSlider (yup::Slider& slider, float value, double maximum, Callback&& callback) + { + NodeViewHelpers::configureParameterSlider (slider, getPortKindColor (PortKind::parameter)); + slider.setRange (0.0, maximum, 0.01); + slider.setValue (value, yup::dontSendNotification); + slider.onValueChanged = std::forward (callback); + } + + static float delayNormalized (float milliseconds) noexcept + { + return yup::jlimit (0.0f, 1.0f, (milliseconds - 1.0f) / 1999.0f); + } + + static yup::String millisecondsString (float milliseconds) + { + return yup::String (milliseconds, 0) + " ms"; + } + + static yup::String percentageString (float value) + { + return yup::String (value * 100.0f, 0) + "%"; + } + + FractionalDelayProcessor& processor; + yup::Slider delayLeftSlider; + yup::Slider delayRightSlider; + yup::Slider feedbackSlider; + yup::Slider dryWetSlider; +}; diff --git a/examples/audiograph/source/nodes/GainNode.h b/examples/audiograph/source/nodes/GainNode.h index e6298227..8e833e59 100644 --- a/examples/audiograph/source/nodes/GainNode.h +++ b/examples/audiograph/source/nodes/GainNode.h @@ -21,8 +21,6 @@ #pragma once -#include - #include "NodeViewHelpers.h" //============================================================================== @@ -34,15 +32,22 @@ class GainProcessor final : public yup::AudioProcessor yup::AudioBusLayout ({ yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Input, 2) }, { yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Output, 2) })) { + gain = NodeViewHelpers::createParameter ("gain", "Gain", 0.0f, 1.5f, 0.75f); + addParameter (gain); } - void prepareToPlay (float, int) override {} + void prepareToPlay (float newSampleRate, int) override + { + smoothedGain.reset (newSampleRate, 0.02); + smoothedGain.setCurrentAndTargetValue (getGain()); + } void releaseResources() override {} void processBlock (yup::AudioProcessContext& context) override { - context.audio.applyGain (gain.load (std::memory_order_relaxed)); + smoothedGain.setTargetValue (getGain()); + smoothedGain.applyGain (context.audio, context.audio.getNumSamples()); } int getCurrentPreset() const noexcept override { return 0; } @@ -55,23 +60,33 @@ class GainProcessor final : public yup::AudioProcessor void setPresetName (int, yup::StringRef) override {} - yup::Result loadStateFromMemory (const yup::MemoryBlock&) override { return yup::Result::ok(); } + bool supportsDataTreeState() const noexcept override { return true; } + + yup::Result loadStateFromDataTree (const yup::DataTree& state) override + { + return NodeViewHelpers::loadParameterState (state, stateType, getParameters()); + } - yup::Result saveStateIntoMemory (yup::MemoryBlock&) override { return yup::Result::ok(); } + yup::Result saveStateIntoDataTree (yup::DataTree& state) override + { + state = NodeViewHelpers::createParameterState (stateType, getParameters()); + return yup::Result::ok(); + } bool hasEditor() const override { return false; } - yup::AudioProcessorEditor* createEditor() override { return nullptr; } - - float getGain() const noexcept { return gain.load (std::memory_order_relaxed); } + float getGain() const noexcept { return gain->getValue(); } void setGain (float newGain) noexcept { - gain.store (yup::jlimit (0.0f, 1.5f, newGain), std::memory_order_relaxed); + gain->setValue (newGain); } private: - std::atomic gain { 0.75f }; + static constexpr const char* stateType = "GainState"; + + yup::AudioParameter::Ptr gain; + yup::SmoothedValue smoothedGain; }; //============================================================================== @@ -119,7 +134,7 @@ class GainNodeView final : public yup::AudioGraphNodeView void resized() override { - gainSlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth())); + gainSlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth(), 0)); } private: diff --git a/examples/audiograph/source/nodes/LatencyNode.h b/examples/audiograph/source/nodes/LatencyNode.h index 4b9b58b8..3a71a51a 100644 --- a/examples/audiograph/source/nodes/LatencyNode.h +++ b/examples/audiograph/source/nodes/LatencyNode.h @@ -34,6 +34,9 @@ class LatencyProcessor final : public yup::AudioProcessor yup::AudioBusLayout ({ yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Input, 2) }, { yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Output, 2) })) { + delayMilliseconds = NodeViewHelpers::createParameter ("delayMilliseconds", "Delay", 0.0f, maximumDelayMilliseconds, defaultDelayMilliseconds, 1.0f); + addParameter (delayMilliseconds); + setDelayMilliseconds (defaultDelayMilliseconds); reportUpdatedLatency(); } @@ -98,40 +101,29 @@ class LatencyProcessor final : public yup::AudioProcessor void setPresetName (int, yup::StringRef) override {} - yup::Result loadStateFromMemory (const yup::MemoryBlock& data) override - { - if (data.isEmpty()) - return yup::Result::ok(); - - yup::MemoryInputStream stream (data, false); + bool supportsDataTreeState() const noexcept override { return true; } - const int version = stream.readInt(); - if (version != 1) - return yup::Result::fail ("Unsupported latency node state version"); + yup::Result loadStateFromDataTree (const yup::DataTree& state) override + { + if (const auto result = NodeViewHelpers::loadParameterState (state, stateType, getParameters()); result.failed()) + return result; - setDelayMilliseconds (stream.readFloat()); + setDelayMilliseconds (getDelayMilliseconds()); reportUpdatedLatency(); - return yup::Result::ok(); } - yup::Result saveStateIntoMemory (yup::MemoryBlock& data) override + yup::Result saveStateIntoDataTree (yup::DataTree& state) override { - yup::MemoryOutputStream stream (data, false); - stream.writeInt (1); - stream.writeFloat (delayMilliseconds.load (std::memory_order_relaxed)); - stream.flush(); - + state = NodeViewHelpers::createParameterState (stateType, getParameters()); return yup::Result::ok(); } bool hasEditor() const override { return false; } - yup::AudioProcessorEditor* createEditor() override { return nullptr; } - float getDelayMilliseconds() const noexcept { - return delayMilliseconds.load (std::memory_order_relaxed); + return delayMilliseconds->getValue(); } int getDelaySamples() const noexcept @@ -141,9 +133,9 @@ class LatencyProcessor final : public yup::AudioProcessor void setDelayMilliseconds (float newDelayMilliseconds) { - delayMilliseconds.store (yup::jlimit (0.0f, maximumDelayMilliseconds, newDelayMilliseconds), std::memory_order_relaxed); + delayMilliseconds->setValue (newDelayMilliseconds); - const auto newDelaySamples = delayMillisecondsToSamples (delayMilliseconds.load (std::memory_order_relaxed)); + const auto newDelaySamples = delayMillisecondsToSamples (getDelayMilliseconds()); delaySamples.store (newDelaySamples, std::memory_order_relaxed); } @@ -153,6 +145,8 @@ class LatencyProcessor final : public yup::AudioProcessor } private: + static constexpr const char* stateType = "LatencyState"; + static constexpr float defaultDelayMilliseconds = 100.0f; static constexpr float maximumDelayMilliseconds = 1000.0f; @@ -163,7 +157,7 @@ class LatencyProcessor final : public yup::AudioProcessor } std::atomic sampleRate { 44100.0f }; - std::atomic delayMilliseconds { defaultDelayMilliseconds }; + yup::AudioParameter::Ptr delayMilliseconds; std::atomic delaySamples { 0 }; int writePosition = 0; yup::AudioBuffer history; @@ -223,7 +217,7 @@ class LatencyNodeView final : public yup::AudioGraphNodeView void resized() override { - delaySlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth())); + delaySlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth(), 0)); } private: diff --git a/examples/audiograph/source/nodes/LowPassFilterNode.h b/examples/audiograph/source/nodes/LowPassFilterNode.h deleted file mode 100644 index 14e0d933..00000000 --- a/examples/audiograph/source/nodes/LowPassFilterNode.h +++ /dev/null @@ -1,152 +0,0 @@ -/* - ============================================================================== - - 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 - -#include -#include - -#include "NodeViewHelpers.h" - -//============================================================================== -class LowPassFilterProcessor final : public yup::AudioProcessor -{ -public: - LowPassFilterProcessor() - : AudioProcessor ("Low-pass", - yup::AudioBusLayout ({ yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Input, 2) }, - { yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Output, 2) })) - { - } - - void prepareToPlay (float newSampleRate, int) override - { - sampleRate = newSampleRate; - state[0] = 0.0f; - state[1] = 0.0f; - } - - void releaseResources() override {} - - void processBlock (yup::AudioProcessContext& context) override - { - auto& audioBuffer = context.audio; - const auto currentCutoff = static_cast (cutoff.load (std::memory_order_relaxed)); - const auto alpha = static_cast (1.0 - std::exp (-yup::MathConstants::twoPi * currentCutoff / static_cast (sampleRate))); - - for (int channel = 0; channel < audioBuffer.getNumChannels(); ++channel) - { - auto y = state[static_cast (yup::jmin (channel, 1))]; - - for (int sample = 0; sample < audioBuffer.getNumSamples(); ++sample) - { - y += alpha * (audioBuffer.getSample (channel, sample) - y); - audioBuffer.setSample (channel, sample, y); - } - - state[static_cast (yup::jmin (channel, 1))] = y; - } - } - - int getCurrentPreset() const noexcept override { return 0; } - - void setCurrentPreset (int) noexcept override {} - - int getNumPresets() const override { return 0; } - - yup::String getPresetName (int) const override { return {}; } - - void setPresetName (int, yup::StringRef) override {} - - yup::Result loadStateFromMemory (const yup::MemoryBlock&) override { return yup::Result::ok(); } - - yup::Result saveStateIntoMemory (yup::MemoryBlock&) override { return yup::Result::ok(); } - - bool hasEditor() const override { return false; } - - yup::AudioProcessorEditor* createEditor() override { return nullptr; } - - double getCutoff() const noexcept { return static_cast (cutoff.load (std::memory_order_relaxed)); } - - void setCutoff (double newCutoff) noexcept - { - cutoff.store (static_cast (yup::jlimit (80.0, 12000.0, newCutoff)), std::memory_order_relaxed); - } - -private: - float sampleRate = 44100.0f; - std::atomic cutoff { 800.0f }; - float state[2] {}; -}; - -//============================================================================== -class LowPassFilterNodeView final : public yup::AudioGraphNodeView -{ -public: - LowPassFilterNodeView (yup::AudioGraphNodeID nodeID, LowPassFilterProcessor& processorIn) - : AudioGraphNodeView (nodeID) - , processor (processorIn) - , cutoffSlider (yup::Slider::LinearBarHorizontal) - { - NodeViewHelpers::configureParameterSlider (cutoffSlider, getPortKindColor (PortKind::parameter)); - cutoffSlider.setRange (80.0, 12000.0); - cutoffSlider.setSkewFactorFromMidpoint (1000.0); - cutoffSlider.setValue (processor.getCutoff(), yup::dontSendNotification); - cutoffSlider.onValueChanged = [this] (double value) - { - processor.setCutoff (value); - repaint(); - }; - addAndMakeVisible (cutoffSlider); - } - - yup::String getNodeTitle() const override { return "LPF"; } - - int getNumInputPorts() const override { return 1; } - - int getNumOutputPorts() const override { return 1; } - - int getPreferredWidth() const override { return 220; } - - yup::Color getNodeColor() const override { return yup::Color (0xff7c3aed); } - - yup::String getNodeSubtitle() const override { return yup::String (processor.getCutoff(), 0) + " Hz"; } - - PortInfo getInputPortInfo (int) const override { return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; } - - PortInfo getOutputPortInfo (int) const override { return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; } - - int getNumParameterRows() const override { return 1; } - - ParameterInfo getParameterInfo (int) const override - { - return { "Cutoff", yup::String (processor.getCutoff(), 0), getPortKindColor (PortKind::parameter), -1.0f, PortKind::parameter }; - } - - void resized() override - { - cutoffSlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth())); - } - -private: - LowPassFilterProcessor& processor; - yup::Slider cutoffSlider; -}; diff --git a/examples/audiograph/source/nodes/NodeRegistry.h b/examples/audiograph/source/nodes/NodeRegistry.h index 9428e39b..143983da 100644 --- a/examples/audiograph/source/nodes/NodeRegistry.h +++ b/examples/audiograph/source/nodes/NodeRegistry.h @@ -26,12 +26,15 @@ #include #include +#include "AnalyzerNodes.h" #include "DistortionNodes.h" +#include "FractionalDelayNode.h" #include "GainNode.h" #include "LatencyNode.h" -#include "LowPassFilterNode.h" +#include "StateVariableFilterNode.h" #include "OscillatorNode.h" #include "PluginNodeView.h" +#include "RecorderNode.h" #include "SamplePlayerNode.h" #include "SubgraphNode.h" @@ -57,8 +60,17 @@ class NodeRegistry /** Stable factory key for the built-in latency node. */ static constexpr const char* latencyIdentifier = "internal.latency"; - /** Stable factory key for the built-in low-pass filter node. */ - static constexpr const char* lpfIdentifier = "internal.lpf"; + /** Stable factory key for the built-in fractional delay node. */ + static constexpr const char* fractionalDelayIdentifier = "internal.fractionalDelay"; + + /** Stable factory key for the built-in state variable filter node. */ + static constexpr const char* svfIdentifier = "internal.svf"; + + /** Stable factory key for the built-in oscilloscope node. */ + static constexpr const char* oscilloscopeIdentifier = "internal.oscilloscope"; + + /** Stable factory key for the built-in spectrum analyzer node. */ + static constexpr const char* spectrumAnalyzerIdentifier = "internal.spectrumAnalyzer"; /** Stable factory key for the built-in tanh distortion node. */ static constexpr const char* tanhDistortionIdentifier = "internal.tanhDistortion"; @@ -66,9 +78,15 @@ class NodeRegistry /** Stable factory key for the built-in Blunter soft clipper node. */ static constexpr const char* blunterSoftClipperIdentifier = "internal.blunterSoftClipper"; + /** Stable factory key for the built-in AA-IIR antialiased hard clipper node. */ + static constexpr const char* aaIirHardClipperIdentifier = "internal.aaIirHardClipper"; + /** Stable factory key for the built-in looping sample player node. */ static constexpr const char* samplePlayerIdentifier = "internal.samplePlayer"; + /** Stable factory key for the built-in recorder node. */ + static constexpr const char* recorderIdentifier = "internal.recorder"; + /** Stable factory key for the built-in recursive subgraph node. */ static constexpr const char* subgraphIdentifier = "internal.subgraph"; @@ -152,18 +170,63 @@ class NodeRegistry } }; - entries[lpfIdentifier] = { + entries[fractionalDelayIdentifier] = { + [] (const yup::AudioGraphNodeProperties&) -> yup::ResultValue> + { + return yup::makeResultValueOk (std::make_unique()); + }, + [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc, yup::AudioGraphProcessor*) -> std::unique_ptr + { + auto* delay = dynamic_cast (proc); + if (delay == nullptr) + return nullptr; + + return std::make_unique (nodeID, *delay); + } + }; + + entries[svfIdentifier] = { + [] (const yup::AudioGraphNodeProperties&) -> yup::ResultValue> + { + return yup::makeResultValueOk (std::make_unique()); + }, + [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc, yup::AudioGraphProcessor*) -> std::unique_ptr + { + auto* svf = dynamic_cast (proc); + if (svf == nullptr) + return nullptr; + + return std::make_unique (nodeID, *svf); + } + }; + + entries[oscilloscopeIdentifier] = { + [] (const yup::AudioGraphNodeProperties&) -> yup::ResultValue> + { + return yup::makeResultValueOk (std::make_unique()); + }, + [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc, yup::AudioGraphProcessor*) -> std::unique_ptr + { + auto* oscilloscope = dynamic_cast (proc); + if (oscilloscope == nullptr) + return nullptr; + + return std::make_unique (nodeID, *oscilloscope); + } + }; + + entries[spectrumAnalyzerIdentifier] = { [] (const yup::AudioGraphNodeProperties&) -> yup::ResultValue> { - return yup::makeResultValueOk (std::make_unique()); + return yup::makeResultValueOk (std::make_unique()); }, [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc, yup::AudioGraphProcessor*) -> std::unique_ptr { - auto* lpf = dynamic_cast (proc); - if (lpf == nullptr) + auto* spectrumAnalyzer = dynamic_cast (proc); + if (spectrumAnalyzer == nullptr) return nullptr; - return std::make_unique (nodeID, *lpf); + return std::make_unique (nodeID, *spectrumAnalyzer); } }; @@ -197,6 +260,21 @@ class NodeRegistry } }; + entries[aaIirHardClipperIdentifier] = { + [] (const yup::AudioGraphNodeProperties&) -> yup::ResultValue> + { + return yup::makeResultValueOk (std::make_unique()); + }, + [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc, yup::AudioGraphProcessor*) -> std::unique_ptr + { + auto* clipper = dynamic_cast (proc); + if (clipper == nullptr) + return nullptr; + + return std::make_unique (nodeID, *clipper); + } + }; + entries[samplePlayerIdentifier] = { [] (const yup::AudioGraphNodeProperties&) -> yup::ResultValue> { @@ -212,6 +290,21 @@ class NodeRegistry } }; + entries[recorderIdentifier] = { + [] (const yup::AudioGraphNodeProperties&) -> yup::ResultValue> + { + return yup::makeResultValueOk (std::make_unique()); + }, + [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc, yup::AudioGraphProcessor*) -> std::unique_ptr + { + auto* recorder = dynamic_cast (proc); + if (recorder == nullptr) + return nullptr; + + return std::make_unique (nodeID, *recorder); + } + }; + entries[subgraphIdentifier] = { [this] (const yup::AudioGraphNodeProperties& props) -> yup::ResultValue> { @@ -426,14 +519,19 @@ class NodeRegistry std::vector getInternalNodeIdentifiers() const { return { - oscillatorIdentifier, - gainIdentifier, - lpfIdentifier, - tanhDistortionIdentifier, + aaIirHardClipperIdentifier, blunterSoftClipperIdentifier, + fractionalDelayIdentifier, + gainIdentifier, latencyIdentifier, + oscillatorIdentifier, + oscilloscopeIdentifier, + recorderIdentifier, samplePlayerIdentifier, - subgraphIdentifier + spectrumAnalyzerIdentifier, + subgraphIdentifier, + svfIdentifier, + tanhDistortionIdentifier, }; } @@ -456,8 +554,17 @@ class NodeRegistry if (id == latencyIdentifier) return "Latency"; - if (id == lpfIdentifier) - return "Low Pass Filter"; + if (id == fractionalDelayIdentifier) + return "Fractional Delay"; + + if (id == svfIdentifier) + return "State Variable Filter"; + + if (id == oscilloscopeIdentifier) + return "Oscilloscope"; + + if (id == spectrumAnalyzerIdentifier) + return "Spectrum Analyzer"; if (id == tanhDistortionIdentifier) return "Tanh Distortion"; @@ -465,8 +572,15 @@ class NodeRegistry if (id == blunterSoftClipperIdentifier) return "Blunter Soft Clip"; + if (id == aaIirHardClipperIdentifier) + return "AA-IIR Hard Clip"; + if (id == samplePlayerIdentifier) return "Sample Player"; + + if (id == recorderIdentifier) + return "Recorder"; + if (id == subgraphIdentifier) return "Subgraph"; diff --git a/examples/audiograph/source/nodes/NodeViewHelpers.h b/examples/audiograph/source/nodes/NodeViewHelpers.h index 86b98de0..ba9d8ff0 100644 --- a/examples/audiograph/source/nodes/NodeViewHelpers.h +++ b/examples/audiograph/source/nodes/NodeViewHelpers.h @@ -45,9 +45,59 @@ inline yup::Rectangle getInlineSliderBounds (const yup::Component& compon 20.0f * scale }; } -inline yup::Rectangle getInlineSliderBounds (const yup::Component& component, int preferredWidth) +inline yup::AudioParameter::Ptr createParameter (yup::StringRef id, + yup::StringRef name, + float minValue, + float maxValue, + float defaultValue, + float interval = 0.0f) { - return getInlineSliderBounds (component, preferredWidth, 0); + auto builder = yup::AudioParameterBuilder() + .withID (id) + .withName (name) + .withRange (yup::NormalisableRange (minValue, maxValue, interval)) + .withDefault (defaultValue); + + if (interval > 0.0f) + builder.withStepped (true); + + return builder.build(); +} + +inline yup::DataTree createParameterState (const yup::Identifier& type, yup::Span parameters) +{ + yup::DataTree state (type); + auto transaction = state.beginTransaction(); + transaction.setProperty ("version", 1); + + for (const auto& parameter : parameters) + if (parameter != nullptr) + transaction.setProperty (parameter->getID(), parameter->getValue()); + + return state; +} + +inline yup::Result loadParameterState (const yup::DataTree& state, + const yup::Identifier& expectedType, + yup::Span parameters) +{ + if (! state.isValid() || state.getType() != expectedType) + return yup::Result::fail ("Invalid node state"); + + if (static_cast (state.getProperty ("version", 0)) != 1) + return yup::Result::fail ("Unsupported node state version"); + + for (const auto& parameter : parameters) + { + if (parameter == nullptr) + continue; + + const yup::Identifier propertyName (parameter->getID()); + if (state.hasProperty (propertyName)) + parameter->setValue (static_cast (static_cast (state.getProperty (propertyName, parameter->getValue())))); + } + + return yup::Result::ok(); } } // namespace NodeViewHelpers diff --git a/examples/audiograph/source/nodes/OscillatorNode.h b/examples/audiograph/source/nodes/OscillatorNode.h index d48c2b2f..3a3d2af2 100644 --- a/examples/audiograph/source/nodes/OscillatorNode.h +++ b/examples/audiograph/source/nodes/OscillatorNode.h @@ -21,7 +21,6 @@ #pragma once -#include #include #include "NodeViewHelpers.h" @@ -35,12 +34,19 @@ class OscillatorProcessor final : public yup::AudioProcessor yup::AudioBusLayout ({}, { yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Output, 2) })) { + frequency = NodeViewHelpers::createParameter ("frequency", "Frequency", 40.0f, 22050.0f, 440.0f); + sweepEnabled = NodeViewHelpers::createParameter ("sweepEnabled", "Sweep", 0.0f, 1.0f, 0.0f, 1.0f); + + addParameter (frequency); + addParameter (sweepEnabled); } void prepareToPlay (float newSampleRate, int) override { sampleRate = newSampleRate; phase = 0.0; + sweepPositionSeconds = 0.0; + wasSweepActive = false; } void releaseResources() override {} @@ -48,11 +54,18 @@ class OscillatorProcessor final : public yup::AudioProcessor void processBlock (yup::AudioProcessContext& context) override { auto& audioBuffer = context.audio; - const auto currentFrequency = static_cast (frequency.load (std::memory_order_relaxed)); - const auto increment = yup::MathConstants::twoPi * currentFrequency / static_cast (sampleRate); + const auto baseFrequency = getFrequency(); + const auto nyquist = static_cast (sampleRate) * 0.5; + const auto sweepActive = isSweepEnabled() && nyquist > sweepStartFrequency; + + if (sweepActive && ! wasSweepActive) + sweepPositionSeconds = 0.0; for (int sample = 0; sample < audioBuffer.getNumSamples(); ++sample) { + const auto currentFrequency = sweepActive ? getSweepFrequency (nyquist) + : baseFrequency; + const auto increment = yup::MathConstants::twoPi * currentFrequency / static_cast (sampleRate); const auto value = static_cast (std::sin (phase) * 0.22); for (int channel = 0; channel < audioBuffer.getNumChannels(); ++channel) @@ -61,7 +74,20 @@ class OscillatorProcessor final : public yup::AudioProcessor phase += increment; if (phase >= yup::MathConstants::twoPi) phase -= yup::MathConstants::twoPi; + + if (sweepActive) + { + sweepPositionSeconds += 1.0 / static_cast (sampleRate); + while (sweepPositionSeconds >= sweepDurationSeconds) + sweepPositionSeconds -= sweepDurationSeconds; + } + else + { + sweepPositionSeconds = 0.0; + } } + + wasSweepActive = sweepActive; } int getCurrentPreset() const noexcept override { return 0; } @@ -74,25 +100,57 @@ class OscillatorProcessor final : public yup::AudioProcessor void setPresetName (int, yup::StringRef) override {} - yup::Result loadStateFromMemory (const yup::MemoryBlock&) override { return yup::Result::ok(); } + bool supportsDataTreeState() const noexcept override { return true; } - yup::Result saveStateIntoMemory (yup::MemoryBlock&) override { return yup::Result::ok(); } + yup::Result loadStateFromDataTree (const yup::DataTree& state) override + { + return NodeViewHelpers::loadParameterState (state, stateType, getParameters()); + } - bool hasEditor() const override { return false; } + yup::Result saveStateIntoDataTree (yup::DataTree& state) override + { + state = NodeViewHelpers::createParameterState (stateType, getParameters()); + return yup::Result::ok(); + } - yup::AudioProcessorEditor* createEditor() override { return nullptr; } + bool hasEditor() const override { return false; } - double getFrequency() const noexcept { return static_cast (frequency.load (std::memory_order_relaxed)); } + double getFrequency() const noexcept { return static_cast (frequency->getValue()); } void setFrequency (double newFrequency) noexcept { - frequency.store (static_cast (yup::jlimit (40.0, 2000.0, newFrequency)), std::memory_order_relaxed); + frequency->setValue (static_cast (newFrequency)); + } + + bool isSweepEnabled() const noexcept { return sweepEnabled->getValue() >= 0.5f; } + + void setSweepEnabled (bool shouldBeEnabled) noexcept + { + sweepEnabled->setValue (shouldBeEnabled ? 1.0f : 0.0f); } private: + static constexpr const char* stateType = "OscillatorState"; + static constexpr double sweepStartFrequency = 20.0; + static constexpr double sweepDurationSeconds = 10.0; + + double getSweepProgress() const noexcept + { + return sweepPositionSeconds / sweepDurationSeconds; + } + + double getSweepFrequency (double nyquist) const noexcept + { + const auto progress = yup::jlimit (0.0, 1.0, getSweepProgress()); + return sweepStartFrequency * std::pow (nyquist / sweepStartFrequency, progress); + } + double sampleRate = 44100.0; double phase = 0.0; - std::atomic frequency { 440.0f }; + double sweepPositionSeconds = 0.0; + bool wasSweepActive = false; + yup::AudioParameter::Ptr frequency; + yup::AudioParameter::Ptr sweepEnabled; }; //============================================================================== @@ -114,6 +172,15 @@ class OscillatorNodeView final : public yup::AudioGraphNodeView repaint(); }; addAndMakeVisible (frequencySlider); + + sweepButton.setButtonText ("Sweep"); + sweepButton.setToggleState (processor.isSweepEnabled(), yup::dontSendNotification); + sweepButton.onClick = [this] + { + processor.setSweepEnabled (sweepButton.getToggleState()); + repaint(); + }; + addAndMakeVisible (sweepButton); } yup::String getNodeTitle() const override { return "OSC"; } @@ -126,23 +193,39 @@ class OscillatorNodeView final : public yup::AudioGraphNodeView yup::Color getNodeColor() const override { return yup::Color (0xff2563eb); } - yup::String getNodeSubtitle() const override { return yup::String (processor.getFrequency(), 0) + " Hz"; } + yup::String getNodeSubtitle() const override + { + if (! processor.isSweepEnabled()) + return yup::String (processor.getFrequency(), 0) + " Hz"; + + return "20 Hz-Nyq / 10 s"; + } PortInfo getOutputPortInfo (int) const override { return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; } - int getNumParameterRows() const override { return 1; } + int getNumParameterRows() const override { return 2; } - ParameterInfo getParameterInfo (int) const override + ParameterInfo getParameterInfo (int parameterIndex) const override { - return { "Frequency", yup::String (processor.getFrequency(), 0), getPortKindColor (PortKind::parameter), -1.0f, PortKind::parameter }; + if (parameterIndex == 0) + return { "Frequency", yup::String (processor.getFrequency(), 0), getPortKindColor (PortKind::parameter), -1.0f, PortKind::parameter }; + + return { "Sweep", processor.isSweepEnabled() ? "20 Hz-Nyq" : "off", getPortKindColor (PortKind::parameter), -1.0f, PortKind::parameter }; } void resized() override { - frequencySlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth())); + frequencySlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth(), 0)); + + const auto scale = getLocalBounds().getWidth() / static_cast (getPreferredWidth()); + sweepButton.setBounds (62.0f * scale, + 74.0f * scale, + 72.0f * scale, + 20.0f * scale); } private: OscillatorProcessor& processor; yup::Slider frequencySlider; + yup::ToggleButton sweepButton; }; diff --git a/examples/audiograph/source/nodes/RecorderNode.h b/examples/audiograph/source/nodes/RecorderNode.h new file mode 100644 index 00000000..a98fe330 --- /dev/null +++ b/examples/audiograph/source/nodes/RecorderNode.h @@ -0,0 +1,687 @@ +/* + ============================================================================== + + 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 + +#include +#include +#include +#include +#include +#include + +#include "NodeViewHelpers.h" + +//============================================================================== +/** Shared recording state accessed by both the background drain thread and the UI thread. */ +struct RecordingData +{ + static constexpr int peakWindowSize = 512; + + std::mutex mutex; + + std::vector samples[2]; + std::vector> peaks[2]; + float peakMin[2] = {}; + float peakMax[2] = {}; + int peakAccumCount = 0; + double sampleRate = 44100.0; + + std::atomic totalFrames { 0 }; + + void reset() + { + std::lock_guard lock (mutex); + + for (int ch = 0; ch < 2; ++ch) + { + samples[ch].clear(); + peaks[ch].clear(); + peakMin[ch] = 0.0f; + peakMax[ch] = 0.0f; + } + + peakAccumCount = 0; + totalFrames.store (0, std::memory_order_relaxed); + } +}; + +//============================================================================== +class RecorderProcessor final : public yup::AudioProcessor +{ +public: + /** Ring buffer capacity: 65536 stereo frames (~1.37 s at 48 kHz). */ + static constexpr int fifoCapacity = 131072; + static constexpr int fadeSamples = 512; + static constexpr float fadeStep = 1.0f / static_cast (fadeSamples); + + RecorderProcessor() + : AudioProcessor ("Recorder", + yup::AudioBusLayout ( + { yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Input, 2) }, + {})) + , fifo (fifoCapacity) + { + fifoBuffer.resize (fifoCapacity); + } + + void prepareToPlay (float sampleRate, int) override + { + currentSampleRate.store (sampleRate > 0.0f ? sampleRate : 44100.0f, std::memory_order_relaxed); + } + + void releaseResources() override {} + + void processBlock (yup::AudioProcessContext& context) override + { + auto& audioBuffer = context.audio; + const int numFrames = audioBuffer.getNumSamples(); + if (numFrames == 0) + return; + + const int cmd = recordCommand.exchange (0, std::memory_order_acq_rel); + if (cmd == 1 && currentState == State::Idle) + { + currentState = State::FadingIn; + fadeGain = 0.0f; + } + else if (cmd == 2 && currentState != State::Idle && currentState != State::FadingOut) + { + currentState = State::FadingOut; + } + + if (currentState == State::Idle) + return; + + const int evenAvailable = yup::jmin (numFrames * 2, fifo.getFreeSpace()) & ~1; + if (evenAvailable < 2) + return; + + auto scope = fifo.write (evenAvailable); + int frameIdx = 0; + + auto writeFrame = [&] (int bufIdx) + { + float gain = 0.0f; + + if (currentState == State::FadingIn) + { + gain = fadeGain; + fadeGain += fadeStep; + if (fadeGain >= 1.0f) + { + fadeGain = 1.0f; + currentState = State::Recording; + } + } + else if (currentState == State::Recording) + { + gain = 1.0f; + } + else if (currentState == State::FadingOut) + { + gain = fadeGain; + fadeGain -= fadeStep; + if (fadeGain <= 0.0f) + { + fadeGain = 0.0f; + currentState = State::Idle; + } + } + + const int nc = audioBuffer.getNumChannels(); + const float l = nc > 0 ? audioBuffer.getSample (0, frameIdx) * gain : 0.0f; + const float r = nc > 1 ? audioBuffer.getSample (1, frameIdx) * gain : l; + fifoBuffer[bufIdx] = l; + fifoBuffer[bufIdx + 1] = r; + ++frameIdx; + }; + + for (int i = 0; i < scope.blockSize1; i += 2) + writeFrame (scope.startIndex1 + i); + + for (int i = 0; i < scope.blockSize2; i += 2) + writeFrame (scope.startIndex2 + i); + } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + yup::String getPresetName (int) const override { return {}; } + + void setPresetName (int, yup::StringRef) override {} + + bool supportsDataTreeState() const noexcept override { return true; } + + yup::Result loadStateFromDataTree (const yup::DataTree& state) override + { + if (! state.isValid() || state.getType().toString() != stateType) + return yup::Result::fail ("Invalid node state"); + + if (static_cast (state.getProperty ("version", 0)) != 1) + return yup::Result::fail ("Unsupported node state version"); + + return yup::Result::ok(); + } + + yup::Result saveStateIntoDataTree (yup::DataTree& state) override + { + state = yup::DataTree (stateType); + auto transaction = state.beginTransaction(); + transaction.setProperty ("version", 1); + return yup::Result::ok(); + } + + bool hasEditor() const override { return false; } + + void startRecording() noexcept { recordCommand.store (1, std::memory_order_release); } + + void stopRecording() noexcept { recordCommand.store (2, std::memory_order_release); } + + float getSampleRate() const noexcept { return currentSampleRate.load (std::memory_order_relaxed); } + + /** Drains available frames from the ring buffer into the provided per-channel vectors. + Safe to call from a single background thread concurrently with processBlock. */ + int drain (std::vector& leftOut, std::vector& rightOut) + { + const int ready = fifo.getNumReady() & ~1; + if (ready <= 0) + return 0; + + const auto scope = fifo.read (ready); + int framesRead = 0; + + for (int i = 0; i < scope.blockSize1; i += 2) + { + leftOut.push_back (fifoBuffer[scope.startIndex1 + i]); + rightOut.push_back (fifoBuffer[scope.startIndex1 + i + 1]); + ++framesRead; + } + + for (int i = 0; i < scope.blockSize2; i += 2) + { + leftOut.push_back (fifoBuffer[scope.startIndex2 + i]); + rightOut.push_back (fifoBuffer[scope.startIndex2 + i + 1]); + ++framesRead; + } + + return framesRead; + } + +private: + static constexpr const char* stateType = "RecorderState"; + + enum class State + { + Idle, + FadingIn, + Recording, + FadingOut + }; + + State currentState = State::Idle; + float fadeGain = 0.0f; + + std::atomic recordCommand { 0 }; + std::atomic currentSampleRate { 44100.0f }; + + yup::AbstractFifo fifo; + std::vector fifoBuffer; +}; + +//============================================================================== +/** Background thread that drains the processor ring buffer and appends to RecordingData. */ +class RecordingEngine +{ +public: + RecordingEngine (RecorderProcessor& processorIn, RecordingData& dataIn, yup::AsyncUpdater& updaterIn) + : processor (processorIn) + , data (dataIn) + , updater (updaterIn) + { + } + + ~RecordingEngine() { stop(); } + + void start() + { + shouldStop.store (false, std::memory_order_release); + thread = std::thread ([this] + { + run(); + }); + } + + void stop() + { + shouldStop.store (true, std::memory_order_release); + cv.notify_all(); + + if (thread.joinable()) + thread.join(); + } + + void wakeUp() { cv.notify_all(); } + +private: + void run() + { + while (! shouldStop.load (std::memory_order_acquire)) + { + drainOnce(); + + std::unique_lock lock (cvMutex); + cv.wait_for (lock, std::chrono::milliseconds (5), [this] + { + return shouldStop.load (std::memory_order_acquire); + }); + } + + drainOnce(); // final drain after stop signal + } + + void drainOnce() + { + tempLeft.clear(); + tempRight.clear(); + + const int framesRead = processor.drain (tempLeft, tempRight); + if (framesRead == 0) + return; + + { + std::lock_guard lock (data.mutex); + + data.samples[0].insert (data.samples[0].end(), tempLeft.begin(), tempLeft.end()); + data.samples[1].insert (data.samples[1].end(), tempRight.begin(), tempRight.end()); + + for (int i = 0; i < framesRead; ++i) + { + const float sl = tempLeft[static_cast (i)]; + const float sr = tempRight[static_cast (i)]; + + data.peakMin[0] = yup::jmin (data.peakMin[0], sl); + data.peakMax[0] = yup::jmax (data.peakMax[0], sl); + data.peakMin[1] = yup::jmin (data.peakMin[1], sr); + data.peakMax[1] = yup::jmax (data.peakMax[1], sr); + + if (++data.peakAccumCount >= RecordingData::peakWindowSize) + { + for (int ch = 0; ch < 2; ++ch) + { + data.peaks[ch].emplace_back (data.peakMin[ch], data.peakMax[ch]); + data.peakMin[ch] = 0.0f; + data.peakMax[ch] = 0.0f; + } + + data.peakAccumCount = 0; + } + } + + data.totalFrames.store (static_cast (data.samples[0].size()), std::memory_order_relaxed); + } + + updater.triggerAsyncUpdate(); + } + + RecorderProcessor& processor; + RecordingData& data; + yup::AsyncUpdater& updater; + + std::thread thread; + std::atomic shouldStop { true }; + std::mutex cvMutex; + std::condition_variable cv; + + std::vector tempLeft, tempRight; +}; + +//============================================================================== +/** Draws an accumulating stereo waveform from a RecordingData peak array. */ +class RecordingWaveformComponent final : public yup::Component +{ +public: + RecordingWaveformComponent (RecordingData& dataIn, yup::Color accentColor) + : data (dataIn) + , color (accentColor) + { + setOpaque (false); + } + + void paint (yup::Graphics& g) override + { + const auto bounds = getLocalBounds().to(); + + std::vector> snapPeaks[2]; + { + std::lock_guard lock (data.mutex); + + for (int ch = 0; ch < 2; ++ch) + snapPeaks[ch] = data.peaks[ch]; + } + + if (snapPeaks[0].empty()) + { + g.setFillColor (color.withAlpha (0.35f)); + g.fillFittedText ("No recording", + yup::ApplicationTheme::getGlobalTheme()->getDefaultFont().withHeight (9.0f), + bounds.reduced (6.0f), + yup::Justification::center); + return; + } + + const auto laneBounds = bounds.reduced (5.0f, 3.0f); + const float laneHeight = laneBounds.getHeight() / 2.0f; + + for (int ch = 0; ch < 2; ++ch) + { + const auto lane = laneBounds + .withY (laneBounds.getY() + static_cast (ch) * laneHeight) + .withHeight (laneHeight - 2.0f); + + g.setFillColor (color.withAlpha (0.12f)); + g.fillRect ({ lane.getX(), lane.getCenterY(), lane.getWidth(), 1.0f }); + + const auto& peaks = snapPeaks[ch]; + if (peaks.empty()) + continue; + + const float xScale = lane.getWidth() / static_cast (peaks.size()); + const float halfH = lane.getHeight() * 0.5f; + const float centerY = lane.getCenterY(); + + g.setFillColor (color.withAlpha (0.8f)); + + for (size_t i = 0; i < peaks.size(); ++i) + { + const float x = lane.getX() + static_cast (i) * xScale; + const float yTop = centerY - peaks[i].second * halfH; + const float yBot = centerY - peaks[i].first * halfH; + g.fillRect ({ x, yTop, yup::jmax (1.0f, xScale), yup::jmax (1.0f, yBot - yTop) }); + } + } + } + +private: + RecordingData& data; + yup::Color color; +}; + +//============================================================================== +class RecorderNodeView final + : public yup::AudioGraphNodeView + , public yup::AsyncUpdater +{ +public: + RecorderNodeView (yup::AudioGraphNodeID nodeID, RecorderProcessor& processorIn) + : AudioGraphNodeView (nodeID) + , processor (processorIn) + , engine (processorIn, data, *this) + , waveform (data, getNodeColor()) + { + setColor (Style::parameterBackgroundColorId, yup::Color (0x00000000)); + setColor (Style::parameterValueBackgroundColorId, yup::Color (0x00000000)); + + recordButton.setButtonText ("● REC"); + recordButton.onClick = [this] + { + onRecordButtonClicked(); + }; + addAndMakeVisible (recordButton); + + saveButton.setButtonText ("Save WAV"); + saveButton.setEnabled (false); + saveButton.onClick = [this] + { + onSaveButtonClicked(); + }; + addAndMakeVisible (saveButton); + + addAndMakeVisible (waveform); + engine.start(); + } + + ~RecorderNodeView() override + { + processor.stopRecording(); + engine.stop(); + + if (saveThread.joinable()) + saveThread.join(); + + cancelPendingUpdate(); + } + + yup::String getNodeTitle() const override { return "REC"; } + + yup::String getNodeSubtitle() const override + { + switch (static_cast (saveState.load (std::memory_order_acquire))) + { + case SaveState::Saving: + return "saving..."; + case SaveState::Saved: + return "saved"; + case SaveState::Error: + return "save failed"; + default: + break; + } + + const int frames = data.totalFrames.load (std::memory_order_relaxed); + if (frames == 0) + return "recorder"; + + const double sr = data.sampleRate > 0.0 ? data.sampleRate : 44100.0; + const int totalSecs = static_cast (static_cast (frames) / sr); + const int mins = totalSecs / 60; + const int secs = totalSecs % 60; + const auto timeStr = yup::String (mins) + ":" + (secs < 10 ? "0" : "") + yup::String (secs); + + return isCurrentlyRecording ? timeStr : timeStr + " (stopped)"; + } + + int getNumInputPorts() const override { return 1; } + + int getNumOutputPorts() const override { return 0; } + + int getPreferredWidth() const override { return 260; } + + yup::Color getNodeColor() const override { return yup::Color (0xffe11d48); } + + PortInfo getInputPortInfo (int) const override + { + return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; + } + + PortInfo getOutputPortInfo (int) const override + { + return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; + } + + int getNumParameterRows() const override { return 3; } + + ParameterInfo getParameterInfo (int) const override + { + return { {}, {}, getNodeColor(), -1.0f, PortKind::parameter }; + } + + void resized() override + { + const auto scale = getLocalBounds().getWidth() / static_cast (getPreferredWidth()); + const auto body = getLocalBounds().to().reduced (14.0f * scale, 0.0f); + + const float innerX = body.getX() + 8.0f * scale; + const float innerW = body.getWidth() - 16.0f * scale; + const float buttonY = 43.0f * scale; + const float buttonH = 20.0f * scale; + const float halfW = (innerW - 4.0f * scale) * 0.5f; + + recordButton.setBounds (innerX, + buttonY, + halfW, + buttonH); + + saveButton.setBounds (innerX + halfW + 4.0f * scale, + buttonY, + halfW, + buttonH); + + waveform.setBounds (innerX, + 67.0f * scale, + innerW, + 52.0f * scale); + } + + void handleAsyncUpdate() override + { + waveform.repaint(); + updateButtonsFromSaveState(); + repaint(); // refreshes subtitle + } + +private: + void onRecordButtonClicked() + { + if (isCurrentlyRecording) + { + processor.stopRecording(); + engine.wakeUp(); + isCurrentlyRecording = false; + recordButton.setButtonText ("● REC"); + saveButton.setEnabled (data.totalFrames.load() > 0); + } + else + { + data.sampleRate = processor.getSampleRate(); + data.reset(); + saveState.store ((int) SaveState::None, std::memory_order_relaxed); + processor.startRecording(); + isCurrentlyRecording = true; + recordButton.setButtonText ("■ STOP"); + saveButton.setEnabled (false); + } + + repaint(); + } + + void onSaveButtonClicked() + { + if (saveState.load (std::memory_order_relaxed) == (int) SaveState::Saving) + return; + + saveState.store ((int) SaveState::Saving, std::memory_order_relaxed); + saveButton.setButtonText ("Saving..."); + saveButton.setEnabled (false); + + std::vector leftData, rightData; + double sr = 44100.0; + { + std::lock_guard lock (data.mutex); + leftData = data.samples[0]; + rightData = data.samples[1]; + sr = data.sampleRate; + } + + if (saveThread.joinable()) + saveThread.join(); + + saveThread = std::thread ([this, leftData = std::move (leftData), rightData = std::move (rightData), sr] + { + const auto result = writeToDisk (leftData, rightData, sr); + saveState.store ((int) (result.wasOk() ? SaveState::Saved : SaveState::Error), + std::memory_order_release); + triggerAsyncUpdate(); + }); + } + + static yup::Result writeToDisk (const std::vector& left, + const std::vector& right, + double sampleRate) + { + if (left.empty()) + return yup::Result::fail ("Nothing to save"); + + const auto now = yup::Time::getCurrentTime(); + const auto filename = yup::String ("recording_") + now.formatted ("%Y%m%d_%H%M%S") + ".wav"; + const auto file = yup::File::getSpecialLocation (yup::File::userDesktopDirectory) + .getChildFile (filename); + + yup::AudioFormatManager formatManager; + formatManager.registerDefaultFormats(); + + auto writer = formatManager.createWriterFor (file, + static_cast (sampleRate), + 2, + 24); + if (writer == nullptr) + return yup::Result::fail ("Could not create WAV writer: " + file.getFullPathName()); + + const float* channels[2] = { left.data(), right.data() }; + if (! writer->write (channels, static_cast (left.size()))) + return yup::Result::fail ("Failed writing WAV data"); + + return yup::Result::ok(); + } + + void updateButtonsFromSaveState() + { + switch (static_cast (saveState.load (std::memory_order_acquire))) + { + case SaveState::Saved: + saveButton.setButtonText ("Saved"); + saveButton.setEnabled (false); + break; + + case SaveState::Error: + saveButton.setButtonText ("Save WAV"); + saveButton.setEnabled (data.totalFrames.load() > 0 && ! isCurrentlyRecording); + break; + + default: + break; + } + } + + enum class SaveState + { + None, + Saving, + Saved, + Error + }; + + RecorderProcessor& processor; + RecordingData data; + RecordingEngine engine; + RecordingWaveformComponent waveform; + + yup::TextButton recordButton, saveButton; + std::thread saveThread; + + std::atomic saveState { (int) SaveState::None }; + bool isCurrentlyRecording = false; + + YUP_DECLARE_WEAK_REFERENCEABLE (RecorderNodeView) +}; diff --git a/examples/audiograph/source/nodes/SamplePlayerNode.h b/examples/audiograph/source/nodes/SamplePlayerNode.h index 0ee28fa4..afc25079 100644 --- a/examples/audiograph/source/nodes/SamplePlayerNode.h +++ b/examples/audiograph/source/nodes/SamplePlayerNode.h @@ -106,14 +106,17 @@ class SamplePlayerProcessor final : public yup::AudioProcessor void setPresetName (int, yup::StringRef) override {} - yup::Result loadStateFromMemory (const yup::MemoryBlock& data) override + bool supportsDataTreeState() const noexcept override { return true; } + + yup::Result loadStateFromDataTree (const yup::DataTree& state) override { - if (data.isEmpty()) - return yup::Result::ok(); + if (! state.isValid() || state.getType() != stateType) + return yup::Result::fail ("Invalid sample player state"); - yup::MemoryInputStream stream (data, false); - const auto path = stream.readEntireStreamAsString(); + if (static_cast (state.getProperty ("version", 0)) != 1) + return yup::Result::fail ("Unsupported sample player state version"); + const auto path = state.getProperty ("path", {}).toString(); if (path.isEmpty()) return yup::Result::ok(); @@ -121,18 +124,17 @@ class SamplePlayerProcessor final : public yup::AudioProcessor return result.wasOk() ? result : yup::Result::ok(); } - yup::Result saveStateIntoMemory (yup::MemoryBlock& data) override + yup::Result saveStateIntoDataTree (yup::DataTree& state) override { - yup::MemoryOutputStream stream (data, false); - stream.writeText (getSampleFile().getFullPathName(), false, false, nullptr); - stream.flush(); + state = yup::DataTree (stateType); + auto transaction = state.beginTransaction(); + transaction.setProperty ("version", 1); + transaction.setProperty ("path", getSampleFile().getFullPathName()); return yup::Result::ok(); } bool hasEditor() const override { return false; } - yup::AudioProcessorEditor* createEditor() override { return nullptr; } - yup::Result loadSampleFile (const yup::File& file) { if (! file.existsAsFile()) @@ -185,9 +187,10 @@ class SamplePlayerProcessor final : public yup::AudioProcessor } private: + const yup::Identifier stateType = "SamplePlayerState"; + std::atomic currentSample { nullptr }; std::shared_ptr currentSampleForUi; - // Keeps previously loaded buffers alive because the audio thread reads currentSample as a raw pointer. std::vector> retainedSamples; std::atomic playbackPosition { 0.0 }; std::atomic playbackSampleRate { 44100.0f }; diff --git a/examples/audiograph/source/nodes/StateVariableFilterNode.h b/examples/audiograph/source/nodes/StateVariableFilterNode.h new file mode 100644 index 00000000..682eca38 --- /dev/null +++ b/examples/audiograph/source/nodes/StateVariableFilterNode.h @@ -0,0 +1,251 @@ +/* + ============================================================================== + + 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 + +#include "NodeViewHelpers.h" + +//============================================================================== +class StateVariableFilterProcessor final : public yup::AudioProcessor +{ +public: + StateVariableFilterProcessor() + : AudioProcessor ("State Variable Filter", + yup::AudioBusLayout ({ yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Input, 2) }, + { yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Output, 2) })) + { + mode = NodeViewHelpers::createParameter ("mode", "Mode", 0.0f, 3.0f, 0.0f, 1.0f); + cutoff = NodeViewHelpers::createParameter ("cutoff", "Cutoff", 20.0f, 20000.0f, 1000.0f); + resonance = NodeViewHelpers::createParameter ("resonance", "Resonance", 0.5f, 20.0f, 0.707f); + addParameter (mode); + addParameter (cutoff); + addParameter (resonance); + } + + void prepareToPlay (float newSampleRate, int blockSize) override + { + sampleRate = newSampleRate; + + for (auto& f : filters) + f.prepare (newSampleRate, blockSize); + + smoothedCutoff.reset (newSampleRate, 0.02); + smoothedCutoff.setCurrentAndTargetValue (static_cast (getCutoff())); + + smoothedResonance.reset (newSampleRate, 0.02); + smoothedResonance.setCurrentAndTargetValue (static_cast (getResonance())); + } + + void releaseResources() override {} + + void processBlock (yup::AudioProcessContext& context) override + { + auto& audioBuffer = context.audio; + const int numSamples = audioBuffer.getNumSamples(); + const int numChannels = audioBuffer.getNumChannels(); + + smoothedCutoff.setTargetValue (static_cast (getCutoff())); + smoothedResonance.setTargetValue (static_cast (getResonance())); + + const auto filterMode = modeIndexToFilterMode (static_cast (getMode())); + + for (int sample = 0; sample < numSamples; ++sample) + { + const float freq = smoothedCutoff.getNextValue(); + const float q = smoothedResonance.getNextValue(); + + for (int channel = 0; channel < numChannels; ++channel) + { + const auto idx = static_cast (yup::jmin (channel, 1)); + filters[idx].setParameters (filterMode, freq, q, sampleRate); + audioBuffer.setSample (channel, sample, filters[idx].processSample (audioBuffer.getSample (channel, sample))); + } + } + } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + yup::String getPresetName (int) const override { return {}; } + + void setPresetName (int, yup::StringRef) override {} + + bool supportsDataTreeState() const noexcept override { return true; } + + yup::Result loadStateFromDataTree (const yup::DataTree& state) override + { + return NodeViewHelpers::loadParameterState (state, stateType, getParameters()); + } + + yup::Result saveStateIntoDataTree (yup::DataTree& state) override + { + state = NodeViewHelpers::createParameterState (stateType, getParameters()); + return yup::Result::ok(); + } + + bool hasEditor() const override { return false; } + + float getMode() const noexcept { return mode->getValue(); } + + double getCutoff() const noexcept { return static_cast (cutoff->getValue()); } + + double getResonance() const noexcept { return static_cast (resonance->getValue()); } + + static yup::String modeIndexToString (int index) + { + switch (index) + { + case 1: + return "HP"; + case 2: + return "BP"; + case 3: + return "Notch"; + default: + return "LP"; + } + } + +private: + static constexpr const char* stateType = "StateVariableFilterState"; + + static yup::FilterModeType modeIndexToFilterMode (int index) noexcept + { + switch (index) + { + case 1: + return yup::FilterMode::highpass; + case 2: + return yup::FilterMode::bandpass; + case 3: + return yup::FilterMode::bandstop; + default: + return yup::FilterMode::lowpass; + } + } + + float sampleRate = 44100.0f; + yup::AudioParameter::Ptr mode; + yup::AudioParameter::Ptr cutoff; + yup::AudioParameter::Ptr resonance; + yup::SmoothedValue smoothedCutoff; + yup::SmoothedValue smoothedResonance; + yup::StateVariableFilter filters[2]; +}; + +//============================================================================== +class StateVariableFilterNodeView final : public yup::AudioGraphNodeView +{ +public: + StateVariableFilterNodeView (yup::AudioGraphNodeID nodeID, StateVariableFilterProcessor& processorIn) + : AudioGraphNodeView (nodeID) + , processor (processorIn) + , modeSlider (yup::Slider::LinearBarHorizontal) + , cutoffSlider (yup::Slider::LinearBarHorizontal) + , resonanceSlider (yup::Slider::LinearBarHorizontal) + { + const auto accent = getPortKindColor (PortKind::parameter); + + NodeViewHelpers::configureParameterSlider (modeSlider, accent); + modeSlider.setRange (0.0, 3.0, 1.0); + modeSlider.setValue (static_cast (processor.getMode()), yup::dontSendNotification); + modeSlider.onValueChanged = [this] (double value) + { + processor.getParameters()[0]->setValue (static_cast (value)); + repaint(); + }; + addAndMakeVisible (modeSlider); + + NodeViewHelpers::configureParameterSlider (cutoffSlider, accent); + cutoffSlider.setRange (20.0, 20000.0); + cutoffSlider.setSkewFactorFromMidpoint (1000.0); + cutoffSlider.setValue (processor.getCutoff(), yup::dontSendNotification); + cutoffSlider.onValueChanged = [this] (double value) + { + processor.getParameters()[1]->setValue (static_cast (value)); + repaint(); + }; + addAndMakeVisible (cutoffSlider); + + NodeViewHelpers::configureParameterSlider (resonanceSlider, accent); + resonanceSlider.setRange (0.5, 20.0); + resonanceSlider.setValue (processor.getResonance(), yup::dontSendNotification); + resonanceSlider.onValueChanged = [this] (double value) + { + processor.getParameters()[2]->setValue (static_cast (value)); + repaint(); + }; + addAndMakeVisible (resonanceSlider); + } + + yup::String getNodeTitle() const override { return "SVF"; } + + int getNumInputPorts() const override { return 1; } + + int getNumOutputPorts() const override { return 1; } + + int getPreferredWidth() const override { return 220; } + + yup::Color getNodeColor() const override { return yup::Color (0xff7c3aed); } + + yup::String getNodeSubtitle() const override + { + const auto modeStr = StateVariableFilterProcessor::modeIndexToString (static_cast (processor.getMode())); + return modeStr + " | " + yup::String (processor.getCutoff(), 0) + " Hz"; + } + + PortInfo getInputPortInfo (int) const override { return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; } + + PortInfo getOutputPortInfo (int) const override { return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; } + + int getNumParameterRows() const override { return 3; } + + ParameterInfo getParameterInfo (int rowIndex) const override + { + switch (rowIndex) + { + case 0: + return { "Mode", StateVariableFilterProcessor::modeIndexToString (static_cast (processor.getMode())), getPortKindColor (PortKind::parameter), -1.0f, PortKind::parameter }; + case 1: + return { "Cutoff", yup::String (processor.getCutoff(), 0) + " Hz", getPortKindColor (PortKind::parameter), -1.0f, PortKind::parameter }; + case 2: + return { "Q", yup::String (processor.getResonance(), 3), getPortKindColor (PortKind::parameter), -1.0f, PortKind::parameter }; + default: + return {}; + } + } + + void resized() override + { + modeSlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth(), 0)); + cutoffSlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth(), 1)); + resonanceSlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth(), 2)); + } + +private: + StateVariableFilterProcessor& processor; + yup::Slider modeSlider; + yup::Slider cutoffSlider; + yup::Slider resonanceSlider; +}; diff --git a/examples/audiograph/source/nodes/SubgraphNode.h b/examples/audiograph/source/nodes/SubgraphNode.h index 9c86ce67..147ff9fc 100644 --- a/examples/audiograph/source/nodes/SubgraphNode.h +++ b/examples/audiograph/source/nodes/SubgraphNode.h @@ -169,41 +169,33 @@ class SubgraphProcessor final : public yup::AudioProcessor void setPresetName (int, yup::StringRef) override {} - yup::Result loadStateFromMemory (const yup::MemoryBlock& data) override - { - if (data.getSize() == 0) - return yup::Result::ok(); - - yup::MemoryInputStream stream (data, false); - auto xml = yup::parseXML (stream.readEntireStreamAsString()); + bool supportsDataTreeState() const noexcept override { return true; } - if (xml == nullptr || ! xml->hasTagName ("SubgraphState")) + yup::Result loadStateFromDataTree (const yup::DataTree& state) override + { + if (! state.isValid() || state.getType() != stateType) return yup::Result::fail ("Invalid subgraph state"); - if (xml->getIntAttribute ("version", 0) != 1) + if (static_cast (state.getProperty ("version", 0)) != 1) return yup::Result::fail ("Unsupported subgraph state version"); - auto* graphState = xml->getChildByName ("graphState"); - if (graphState == nullptr || graphState->getStringAttribute ("encoding") != "base64") + const auto graphXml = state.getProperty ("graphXml", {}).toString(); + if (graphXml.isEmpty()) return yup::Result::fail ("Subgraph state is missing graph data"); yup::MemoryBlock graphBlock; yup::MemoryOutputStream output (graphBlock, false); - const auto base64Text = graphState->getAllSubText().removeCharacters (" \t\r\n"); - - if (! yup::Base64::convertFromBase64 (output, base64Text)) - return yup::Result::fail ("Subgraph state has invalid graph data"); - + output.writeText (graphXml, false, false, nullptr); output.flush(); SubgraphConfig savedConfig; - savedConfig.audioChannels = yup::jlimit (1, 2, xml->getIntAttribute ("audioChannels", config.audioChannels)); - savedConfig.midiEnabled = xml->getBoolAttribute ("midiEnabled", config.midiEnabled); + savedConfig.audioChannels = yup::jlimit (1, 2, static_cast (state.getProperty ("audioChannels", config.audioChannels))); + savedConfig.midiEnabled = static_cast (state.getProperty ("midiEnabled", config.midiEnabled)); return loadPrunedGraphState (graphBlock, savedConfig); } - yup::Result saveStateIntoMemory (yup::MemoryBlock& data) override + yup::Result saveStateIntoDataTree (yup::DataTree& state) override { yup::MemoryBlock graphBlock; const auto result = graph->saveStateIntoMemory (graphBlock); @@ -211,25 +203,21 @@ class SubgraphProcessor final : public yup::AudioProcessor if (result.failed()) return result; - yup::XmlElement xml ("SubgraphState"); - xml.setAttribute ("version", 1); - xml.setAttribute ("audioChannels", yup::jlimit (1, 2, config.audioChannels)); - xml.setAttribute ("midiEnabled", config.midiEnabled ? 1 : 0); - - auto* graphState = new yup::XmlElement ("graphState"); - graphState->setAttribute ("encoding", "base64"); - graphState->addTextElement (yup::Base64::toBase64 (graphBlock.getData(), graphBlock.getSize())); - xml.addChildElement (graphState); + yup::MemoryInputStream input (graphBlock, false); - yup::MemoryOutputStream stream (data, false); - xml.writeTo (stream); - stream.flush(); + state = yup::DataTree (stateType); + auto transaction = state.beginTransaction(); + transaction.setProperty ("version", 1); + transaction.setProperty ("audioChannels", yup::jlimit (1, 2, config.audioChannels)); + transaction.setProperty ("midiEnabled", config.midiEnabled); + transaction.setProperty ("graphXml", input.readEntireStreamAsString()); return yup::Result::ok(); } bool hasEditor() const override { return false; } - yup::AudioProcessorEditor* createEditor() override { return nullptr; } +private: + const yup::Identifier stateType = "SubgraphState"; static yup::AudioBusLayout createBusLayout (const SubgraphConfig& config) { @@ -242,14 +230,13 @@ class SubgraphProcessor final : public yup::AudioProcessor if (config.midiEnabled) { - inputs.emplace_back ("MIDI In", yup::AudioBus::Type::MIDI, yup::AudioBus::Direction::Input, 0); - outputs.emplace_back ("MIDI Out", yup::AudioBus::Type::MIDI, yup::AudioBus::Direction::Output, 0); + inputs.emplace_back ("MIDI In", yup::AudioBus::Type::Midi, yup::AudioBus::Direction::Input, 0); + outputs.emplace_back ("MIDI Out", yup::AudioBus::Type::Midi, yup::AudioBus::Direction::Output, 0); } return yup::AudioBusLayout (std::move (inputs), std::move (outputs)); } -private: yup::Result loadPrunedGraphState (const yup::MemoryBlock& graphBlock, const SubgraphConfig& savedConfig) { yup::MemoryInputStream stream (graphBlock, false); diff --git a/examples/graphics/source/examples/FilterDemo.h b/examples/graphics/source/examples/FilterDemo.h index d806b463..371fdb40 100644 --- a/examples/graphics/source/examples/FilterDemo.h +++ b/examples/graphics/source/examples/FilterDemo.h @@ -1544,6 +1544,14 @@ class FilterDemo responseTypeCombo->addItem ("Allpass", 9); } + void addSVFResponseTypes() + { + responseTypeCombo->addItem ("Lowpass", 1); + responseTypeCombo->addItem ("Highpass", 2); + responseTypeCombo->addItem ("Bandpass", 3); + responseTypeCombo->addItem ("Notch", 5); + } + void addFIRResponseTypes() { responseTypeCombo->addItem ("Lowpass", 1); @@ -1592,6 +1600,10 @@ class FilterDemo switch (filterType) { + case 3: + addSVFResponseTypes(); + break; + case 6: addFIRResponseTypes(); break; diff --git a/modules/yup_audio_basics/sources/yup_BufferingAudioSource.cpp b/modules/yup_audio_basics/sources/yup_BufferingAudioSource.cpp index 7b2a312d..cb770148 100644 --- a/modules/yup_audio_basics/sources/yup_BufferingAudioSource.cpp +++ b/modules/yup_audio_basics/sources/yup_BufferingAudioSource.cpp @@ -84,8 +84,7 @@ void BufferingAudioSource::prepareToPlay (int samplesPerBlockExpected, double ne const ScopedLock sl (bufferRangeLock); - bufferValidStart = 0; - bufferValidEnd = 0; + invalidateBufferRange(); backgroundThread.addTimeSliceClient (this); @@ -222,8 +221,7 @@ void BufferingAudioSource::setLooping (bool shouldLoop) if (wasSourceLooping != isSourceLooping) { wasSourceLooping = isSourceLooping; - bufferValidStart = 0; - bufferValidEnd = 0; + invalidateBufferRange(); } } @@ -260,9 +258,17 @@ std::tuple> BufferingAudioSource::getValidBufferRangeAndAdvanc pos, Range { (int) (jlimit (bufferValidStart, bufferValidEnd, pos) - pos), (int) (jlimit (bufferValidStart, bufferValidEnd, pos + numSamples) - pos) }); } +void BufferingAudioSource::invalidateBufferRange() +{ + bufferValidStart = 0; + bufferValidEnd = 0; + ++bufferRangeGeneration; +} + bool BufferingAudioSource::readNextBufferChunk() { int64 newBVS, newBVE, sectionToReadStart, sectionToReadEnd; + uint64 readGeneration; { const ScopedLock sl (bufferRangeLock); @@ -270,8 +276,7 @@ bool BufferingAudioSource::readNextBufferChunk() if (wasSourceLooping != isLooping()) { wasSourceLooping = isLooping(); - bufferValidStart = 0; - bufferValidEnd = 0; + invalidateBufferRange(); } newBVS = jmax ((int64) 0, nextPlayPos.load()); @@ -288,8 +293,7 @@ bool BufferingAudioSource::readNextBufferChunk() sectionToReadStart = newBVS; sectionToReadEnd = newBVE; - bufferValidStart = 0; - bufferValidEnd = 0; + invalidateBufferRange(); } else if (std::abs ((int) (newBVS - bufferValidStart)) > 512 || std::abs ((int) (newBVE - bufferValidEnd)) > 512) @@ -302,6 +306,8 @@ bool BufferingAudioSource::readNextBufferChunk() bufferValidStart = newBVS; bufferValidEnd = jmin (bufferValidEnd, newBVE); } + + readGeneration = bufferRangeGeneration; } if (sectionToReadStart == sectionToReadEnd) @@ -334,6 +340,9 @@ bool BufferingAudioSource::readNextBufferChunk() { const ScopedLock sl2 (bufferRangeLock); + if (readGeneration != bufferRangeGeneration) + return true; + bufferValidStart = newBVS; bufferValidEnd = newBVE; } diff --git a/modules/yup_audio_basics/sources/yup_BufferingAudioSource.h b/modules/yup_audio_basics/sources/yup_BufferingAudioSource.h index 773fef2c..ad36c2e0 100644 --- a/modules/yup_audio_basics/sources/yup_BufferingAudioSource.h +++ b/modules/yup_audio_basics/sources/yup_BufferingAudioSource.h @@ -121,6 +121,7 @@ class YUP_API BufferingAudioSource : public PositionableAudioSource //============================================================================== Range getValidBufferRange (int numSamples) const; std::tuple> getValidBufferRangeAndAdvance (int numSamples); + void invalidateBufferRange(); bool readNextBufferChunk(); void readBufferSection (int64 start, int length, int bufferOffset); int useTimeSlice() override; @@ -133,6 +134,7 @@ class YUP_API BufferingAudioSource : public PositionableAudioSource CriticalSection callbackLock, bufferRangeLock; WaitableEvent bufferReadyEvent; int64 bufferValidStart = 0, bufferValidEnd = 0; + uint64 bufferRangeGeneration = 0; std::atomic nextPlayPos { 0 }; double sampleRate = 0; bool wasSourceLooping = false, isPrepared = false; diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp b/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp index 19fe85f3..896a517c 100644 --- a/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp +++ b/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp @@ -130,6 +130,35 @@ Result modelReadBase64Element (const XmlElement& parent, const char* tagName, Me return Result::ok(); } +void modelWriteDataTreeElement (XmlElement& parent, const char* tagName, const DataTree& state) +{ + auto xml = state.createXml(); + if (xml == nullptr) + return; + + auto* element = new XmlElement (tagName); + element->addChildElement (xml.release()); + parent.addChildElement (element); +} + +Result modelReadDataTreeElement (const XmlElement& parent, const char* tagName, DataTree& state) +{ + auto* element = parent.getChildByName (tagName); + + if (element == nullptr) + return Result::fail (String ("Audio graph state is missing ") + tagName); + + auto* stateXml = element->getFirstChildElement(); + if (stateXml == nullptr) + return Result::fail (String ("Audio graph state has invalid ") + tagName + " data"); + + state = DataTree::fromXml (*stateXml); + if (! state.isValid()) + return Result::fail (String ("Audio graph state has invalid ") + tagName + " DataTree"); + + return Result::ok(); +} + void modelWriteCreationDataElement (XmlElement& parent, const MemoryBlock& block) { if (block.getSize() == 0) @@ -545,13 +574,30 @@ ResultValue> AudioGraphModel::createXml() const if (node.processor == nullptr) return makeResultValueFail ("Audio graph contains an empty node"); - MemoryBlock nodeState; - const auto result = node.processor->saveStateIntoMemory (nodeState); + SavedNodeState savedNode; + savedNode.id = node.id; + savedNode.properties = node.properties; - if (! result) - return makeResultValueFail ("Audio graph node state save failed: " + result.getErrorMessage()); + if (node.processor->supportsDataTreeState()) + { + savedNode.hasStateTree = true; + const auto result = node.processor->saveStateIntoDataTree (savedNode.stateTree); + + if (! result) + return makeResultValueFail ("Audio graph node DataTree state save failed: " + result.getErrorMessage()); + + if (! savedNode.stateTree.isValid()) + return makeResultValueFail ("Audio graph node DataTree state save failed: invalid state"); + } + else + { + const auto result = node.processor->saveStateIntoMemory (savedNode.state); + + if (! result) + return makeResultValueFail ("Audio graph node state save failed: " + result.getErrorMessage()); + } - savedNodes.push_back ({ node.id, node.properties, std::move (nodeState) }); + savedNodes.push_back (std::move (savedNode)); } auto root = std::make_unique (audioGraphModelStateTag); @@ -568,7 +614,11 @@ ResultValue> AudioGraphModel::createXml() const nodeElement->setAttribute ("id", String (static_cast (node.id.getRawID()))); modelWriteNodeProperties (*nodeElement, node.properties); - modelWriteBase64Element (*nodeElement, "state", node.state); + + if (node.hasStateTree) + modelWriteDataTreeElement (*nodeElement, "stateTree", node.stateTree); + else + modelWriteBase64Element (*nodeElement, "state", node.state); } auto* boundaryNodesElement = new XmlElement ("boundaryNodes"); @@ -632,8 +682,17 @@ Result AudioGraphModel::restoreFromXml (const XmlElement& xml) if (const auto result = modelReadNodeProperties (*nodeElement, savedNode.properties); result.failed()) return result; - if (const auto result = modelReadBase64Element (*nodeElement, "state", savedNode.state); result.failed()) + if (nodeElement->getChildByName ("stateTree") != nullptr) + { + savedNode.hasStateTree = true; + + if (const auto result = modelReadDataTreeElement (*nodeElement, "stateTree", savedNode.stateTree); result.failed()) + return result; + } + else if (const auto result = modelReadBase64Element (*nodeElement, "state", savedNode.state); result.failed()) + { return result; + } savedNodes.push_back (std::move (savedNode)); } @@ -770,7 +829,11 @@ Result AudioGraphModel::restoreModel (uint64_t savedNextNodeID, if (processor == nullptr) return Result::fail ("Audio graph node factory returned an empty processor"); - const auto result = processor->loadStateFromMemory (savedNode.state); + const auto result = savedNode.hasStateTree + ? (processor->supportsDataTreeState() + ? processor->loadStateFromDataTree (savedNode.stateTree) + : Result::fail ("Audio graph node does not support DataTree state")) + : processor->loadStateFromMemory (savedNode.state); if (! result) return Result::fail ("Audio graph node state load failed: " + result.getErrorMessage()); diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphModel.h b/modules/yup_audio_graph/graph/yup_AudioGraphModel.h index 8df3da37..d6047669 100644 --- a/modules/yup_audio_graph/graph/yup_AudioGraphModel.h +++ b/modules/yup_audio_graph/graph/yup_AudioGraphModel.h @@ -192,7 +192,9 @@ class YUP_API AudioGraphModel final { AudioGraphNodeID id; AudioGraphNodeProperties properties; + DataTree stateTree; MemoryBlock state; + bool hasStateTree = false; }; Result restoreModel (uint64_t savedNextNodeID, diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp index aaafbb3c..b69e3bab 100644 --- a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp +++ b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp @@ -1317,9 +1317,9 @@ void AudioGraphProcessor::Pimpl::WorkerThread::run() AudioBusLayout AudioGraphProcessor::createDefaultBusLayout() { return AudioBusLayout ({ AudioBus ("Input", AudioBus::Type::Audio, AudioBus::Direction::Input, 2), - AudioBus ("MIDI Input", AudioBus::Type::MIDI, AudioBus::Direction::Input, 1) }, + AudioBus ("MIDI Input", AudioBus::Type::Midi, AudioBus::Direction::Input, 1) }, { AudioBus ("Output", AudioBus::Type::Audio, AudioBus::Direction::Output, 2), - AudioBus ("MIDI Output", AudioBus::Type::MIDI, AudioBus::Direction::Output, 1) }); + AudioBus ("MIDI Output", AudioBus::Type::Midi, AudioBus::Direction::Output, 1) }); } AudioGraphProcessor::AudioGraphProcessor (std::shared_ptr model, diff --git a/modules/yup_audio_plugin_client/clap/yup_audio_plugin_client_CLAP.cpp b/modules/yup_audio_plugin_client/clap/yup_audio_plugin_client_CLAP.cpp index ab704169..bcbaee03 100644 --- a/modules/yup_audio_plugin_client/clap/yup_audio_plugin_client_CLAP.cpp +++ b/modules/yup_audio_plugin_client/clap/yup_audio_plugin_client_CLAP.cpp @@ -1023,7 +1023,7 @@ bool AudioPluginProcessorCLAP::initialise() uint32_t count = 0; for (const auto& bus : busses) - if (bus.getType() == AudioBus::Type::MIDI) + if (bus.getType() == AudioBus::Type::Midi) ++count; // Fallback: synths with no declared MIDI input bus always get one @@ -1043,7 +1043,7 @@ bool AudioPluginProcessorCLAP::initialise() uint32_t midiIndex = 0; for (const auto& bus : busses) { - if (bus.getType() != AudioBus::Type::MIDI) + if (bus.getType() != AudioBus::Type::Midi) continue; if (midiIndex == index) diff --git a/modules/yup_audio_plugin_client/vst3/yup_audio_plugin_client_VST3.cpp b/modules/yup_audio_plugin_client/vst3/yup_audio_plugin_client_VST3.cpp index 40179718..95b9a126 100644 --- a/modules/yup_audio_plugin_client/vst3/yup_audio_plugin_client_VST3.cpp +++ b/modules/yup_audio_plugin_client/vst3/yup_audio_plugin_client_VST3.cpp @@ -1162,7 +1162,7 @@ class AudioPluginProcessorVST3 : public Vst::AudioEffect if (inputBus.getType() == AudioBus::Type::Audio) addAudioInput (toTChar (nameUTF16), speakerArrForChannels (inputBus.getNumChannels())); - else if (inputBus.getType() == AudioBus::Type::MIDI) + else if (inputBus.getType() == AudioBus::Type::Midi) addEventInput (toTChar (nameUTF16)); } @@ -1172,7 +1172,7 @@ class AudioPluginProcessorVST3 : public Vst::AudioEffect if (outputBus.getType() == AudioBus::Type::Audio) addAudioOutput (toTChar (nameUTF16), speakerArrForChannels (outputBus.getNumChannels())); - else if (outputBus.getType() == AudioBus::Type::MIDI) + else if (outputBus.getType() == AudioBus::Type::Midi) addEventOutput (toTChar (nameUTF16)); } diff --git a/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_AUv2.mm b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_AUv2.mm index 8afa3d4a..c032bf29 100644 --- a/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_AUv2.mm +++ b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_AUv2.mm @@ -792,10 +792,10 @@ static AudioBusLayout makeBusLayout(const AudioPluginDescription& desc, OSType c outputs.emplace_back("Output", AudioBus::Type::Audio, AudioBus::Direction::Output, outputChannels); if (desc.numMidiInputPorts > 0) - inputs.emplace_back("MIDI Input", AudioBus::Type::MIDI, AudioBus::Direction::Input, 1); + inputs.emplace_back("MIDI Input", AudioBus::Type::Midi, AudioBus::Direction::Input, 1); if (desc.numMidiOutputPorts > 0) - outputs.emplace_back("MIDI Output", AudioBus::Type::MIDI, AudioBus::Direction::Output, 1); + outputs.emplace_back("MIDI Output", AudioBus::Type::Midi, AudioBus::Direction::Output, 1); return AudioBusLayout(std::move(inputs), std::move(outputs)); } diff --git a/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_CLAP.cpp b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_CLAP.cpp index 784107a6..0d9459ea 100644 --- a/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_CLAP.cpp +++ b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_CLAP.cpp @@ -1050,7 +1050,7 @@ class CLAPInstance : public AudioPluginInstance { clap_note_port_info_t info {}; if (notePortsExt->get (plugin, i, true, &info)) - inputs.emplace_back (String (info.name), AudioBus::Type::MIDI, AudioBus::Direction::Input, 1); + inputs.emplace_back (String (info.name), AudioBus::Type::Midi, AudioBus::Direction::Input, 1); } const uint32_t numOutputs = notePortsExt->count (plugin, false); @@ -1058,7 +1058,7 @@ class CLAPInstance : public AudioPluginInstance { clap_note_port_info_t info {}; if (notePortsExt->get (plugin, i, false, &info)) - outputs.emplace_back (String (info.name), AudioBus::Type::MIDI, AudioBus::Direction::Output, 1); + outputs.emplace_back (String (info.name), AudioBus::Type::Midi, AudioBus::Direction::Output, 1); } } diff --git a/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_VST3.cpp b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_VST3.cpp index 349517c7..bca4f511 100644 --- a/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_VST3.cpp +++ b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_VST3.cpp @@ -1396,7 +1396,7 @@ class VST3Instance : public AudioPluginInstance { Vst::BusInfo info; component->getBusInfo (Vst::kEvent, Vst::kInput, i, info); - inputs.emplace_back (toString (info.name), AudioBus::Type::MIDI, AudioBus::Direction::Input, 1); + inputs.emplace_back (toString (info.name), AudioBus::Type::Midi, AudioBus::Direction::Input, 1); } const int numMidiOutputs = component->getBusCount (Vst::kEvent, Vst::kOutput); @@ -1404,7 +1404,7 @@ class VST3Instance : public AudioPluginInstance { Vst::BusInfo info; component->getBusInfo (Vst::kEvent, Vst::kOutput, i, info); - outputs.emplace_back (toString (info.name), AudioBus::Type::MIDI, AudioBus::Direction::Output, 1); + outputs.emplace_back (toString (info.name), AudioBus::Type::Midi, AudioBus::Direction::Output, 1); } return AudioBusLayout (std::move (inputs), std::move (outputs)); diff --git a/modules/yup_audio_processors/processors/yup_AudioBus.h b/modules/yup_audio_processors/processors/yup_AudioBus.h index e3d8b81d..76dfd05d 100644 --- a/modules/yup_audio_processors/processors/yup_AudioBus.h +++ b/modules/yup_audio_processors/processors/yup_AudioBus.h @@ -36,7 +36,7 @@ class AudioBus enum Type { Audio, - MIDI + Midi }; /** The direction of the bus. */ diff --git a/modules/yup_audio_processors/processors/yup_AudioProcessor.cpp b/modules/yup_audio_processors/processors/yup_AudioProcessor.cpp index b510e37c..c9a3498c 100644 --- a/modules/yup_audio_processors/processors/yup_AudioProcessor.cpp +++ b/modules/yup_audio_processors/processors/yup_AudioProcessor.cpp @@ -101,6 +101,58 @@ void AudioProcessor::updateHostDisplay (ChangeDetails details) //============================================================================== +Result AudioProcessor::loadStateFromDataTree (const DataTree& state) +{ + ignoreUnused (state); + return Result::fail ("Processor does not support DataTree state"); +} + +Result AudioProcessor::saveStateIntoDataTree (DataTree& state) +{ + ignoreUnused (state); + return Result::fail ("Processor does not support DataTree state"); +} + +Result AudioProcessor::loadStateFromMemory (const MemoryBlock& memoryBlock) +{ + if (! supportsDataTreeState()) + return Result::fail ("Processor does not support binary state"); + + MemoryInputStream stream (memoryBlock, false); + auto xml = parseXML (stream.readEntireStreamAsString()); + + if (xml == nullptr) + return Result::fail ("Processor state is not valid XML"); + + auto state = DataTree::fromXml (*xml); + if (! state.isValid()) + return Result::fail ("Processor state is not a valid DataTree"); + + return loadStateFromDataTree (state); +} + +Result AudioProcessor::saveStateIntoMemory (MemoryBlock& memoryBlock) +{ + if (! supportsDataTreeState()) + return Result::fail ("Processor does not support binary state"); + + DataTree state; + if (const auto result = saveStateIntoDataTree (state); result.failed()) + return result; + + auto xml = state.createXml(); + if (xml == nullptr) + return Result::fail ("Processor DataTree state is invalid"); + + MemoryOutputStream stream (memoryBlock, false); + xml->writeTo (stream); + stream.flush(); + + return Result::ok(); +} + +//============================================================================== + int AudioProcessor::getNumAudioOutputs() const { int count = 0; @@ -125,6 +177,26 @@ int AudioProcessor::getNumAudioInputs() const //============================================================================== +bool AudioProcessor::acceptsMidi() const noexcept +{ + for (const auto& bus : busLayout.getInputBuses()) + if (bus.getType() == AudioBus::Type::Midi) + return true; + + return false; +} + +bool AudioProcessor::producesMidi() const noexcept +{ + for (const auto& bus : busLayout.getOutputBuses()) + if (bus.getType() == AudioBus::Type::Midi) + return true; + + return false; +} + +//============================================================================== + void AudioProcessor::suspendProcessing (bool shouldSuspend) { auto lock = CriticalSection::ScopedLockType (processLock); @@ -164,6 +236,14 @@ void AudioProcessor::setProcessingPrecision (ProcessingPrecision precision) //============================================================================== +AudioProcessorEditor* AudioProcessor::createEditor() +{ + jassert (hasEditor()); + return nullptr; +} + +//============================================================================== + void AudioProcessor::setPlaybackConfiguration (float sampleRate, int samplesPerBlock) { releaseResources(); diff --git a/modules/yup_audio_processors/processors/yup_AudioProcessor.h b/modules/yup_audio_processors/processors/yup_AudioProcessor.h index edd06476..e4c05b4c 100644 --- a/modules/yup_audio_processors/processors/yup_AudioProcessor.h +++ b/modules/yup_audio_processors/processors/yup_AudioProcessor.h @@ -28,11 +28,16 @@ class AudioProcessorEditor; /** Base class for all audio processors. + The AudioProcessor class is the base class for all audio processing modules in the framework. + It provides a common interface for processing audio and MIDI data, managing parameters, and + communicating with hosts. + @see AudioProcessorEditor */ class YUP_API AudioProcessor { public: + //============================================================================== /** Details about a processor-level change notification. */ struct ChangeDetails { @@ -92,6 +97,7 @@ class YUP_API AudioProcessor bool nonParameterStateChanged = false; }; + //============================================================================== /** Receives processor-level change notifications. */ class Listener { @@ -111,7 +117,6 @@ class YUP_API AudioProcessor }; //============================================================================== - /** Constructs an AudioProcessor. */ AudioProcessor (StringRef name, AudioBusLayout busLayout); @@ -119,12 +124,10 @@ class YUP_API AudioProcessor virtual ~AudioProcessor(); //============================================================================== - /** Returns the name of the processor. */ String getName() const { return processorName; } //============================================================================== - /** Returns the parameters. */ Span getParameters() const { return parameters; } @@ -140,6 +143,7 @@ class YUP_API AudioProcessor /** Adds a parameter. */ void addParameter (AudioParameter::Ptr parameter); + //============================================================================== /** Adds a processor-level change listener. */ void addListener (Listener* listener); @@ -150,7 +154,6 @@ class YUP_API AudioProcessor void updateHostDisplay (ChangeDetails details); //============================================================================== - /** Returns the bus layout. */ const AudioBusLayout& getBusLayout() const noexcept { return busLayout; } @@ -161,7 +164,13 @@ class YUP_API AudioProcessor int getNumAudioInputs() const; //============================================================================== + /** Returns true if the processor accepts MIDI input. */ + virtual bool acceptsMidi() const noexcept; + + /** Returns true if the processor produces MIDI output. */ + virtual bool producesMidi() const noexcept; + //============================================================================== /** Prepares the processor for playback. getSampleRate() and getSamplesPerBlock() are guaranteed to return the correct @@ -218,7 +227,6 @@ class YUP_API AudioProcessor virtual void flush() {} //============================================================================== - /** Returns true if this processor implements the double-precision processBlock(). */ virtual bool supportsDoublePrecisionProcessing() const { return false; } @@ -232,23 +240,27 @@ class YUP_API AudioProcessor bool isUsingDoublePrecision() const noexcept { return processingPrecision == ProcessingPrecision::doublePrecision; } //============================================================================== - + /** Returns the critical section used to protect the audio processing code. */ CriticalSection& getProcessLock() { return processLock; } + /** Returns true if the processor is currently suspended. */ bool isSuspended() const; + /** Suspends or resumes the processor. */ virtual void suspendProcessing (bool shouldSuspend); //============================================================================== - + /** Returns the current sample rate. */ float getSampleRate() const { return sampleRate; } + /** Returns the current block size in samples. */ int getSamplesPerBlock() const { return samplesPerBlock; } //============================================================================== - + /** Returns the number of tail samples. */ virtual int getTailSamples() { return 0; } + /** Returns the latency in samples. */ virtual int getLatencySamples() { return latencySamples.load(); } /** Sets the processor latency in samples and notifies listeners when it changes. */ @@ -259,7 +271,6 @@ class YUP_API AudioProcessor virtual int getNumVoices() const { return 0; } //============================================================================== - /** Returns true when the processor is running in offline (non-realtime) mode. */ bool isOfflineProcessing() const noexcept { return offlineProcessing.load(); } @@ -267,60 +278,98 @@ class YUP_API AudioProcessor void setOfflineProcessing (bool offline) { offlineProcessing.store (offline); } //============================================================================== - - /** - Returns the current preset index. - */ + /** Returns the current preset index. */ virtual int getCurrentPreset() const noexcept = 0; - /** - Sets the current preset index. + /** Sets the current preset index. + + @param index The index of the preset to select. */ virtual void setCurrentPreset (int index) noexcept = 0; - /** - Returns the number of available user presets. - */ + /** Returns the number of available user presets. */ virtual int getNumPresets() const = 0; - /** - Returns the name of a preset by index. + /** Returns the name of a preset by index. + + @param index The index of the preset. + + @return The name of the preset. */ virtual String getPresetName (int index) const = 0; - /** - Returns the name of a preset by index. + /** Sets the name of a preset by index. + + @param index The index of the preset. + @param newName The new name for the preset. */ virtual void setPresetName (int index, StringRef newName) = 0; //============================================================================== + /** Returns true when this processor supports structured DataTree state. - /** - Loads a preset from a memory block. + Processors that return true can use the default binary state transport, + which serializes the DataTree state as XML into a MemoryBlock. + */ + virtual bool supportsDataTreeState() const noexcept { return false; } + + /** Loads a preset from a structured DataTree. + + The default implementation returns a failure. Override this together with + saveStateIntoDataTree() and supportsDataTreeState() for processors that + want XML-readable state while still using MemoryBlock transport in plugin + wrappers. + + @param state The structured state to load. + + @return The result of the operation. + */ + virtual Result loadStateFromDataTree (const DataTree& state); + + /** Saves the current state into a structured DataTree. + + The implementation should assign a valid DataTree to @p state. + + @param state The structured state destination. + + @return The result of the operation. + */ + virtual Result saveStateIntoDataTree (DataTree& state); + + /** Loads a preset from a memory block. + + The default implementation is available to processors that return true + from supportsDataTreeState(): it parses XML from the memory block and + forwards the resulting DataTree to loadStateFromDataTree(). Processors + that need opaque binary state should override this method. @param memoryBlock The memory block to load the state from. + @return The result of the operation. */ - virtual Result loadStateFromMemory (const MemoryBlock& memoryBlock) = 0; + virtual Result loadStateFromMemory (const MemoryBlock& memoryBlock); - /** - Saves the current state as a memory block. + /** Saves the current state as a memory block. + + The default implementation is available to processors that return true + from supportsDataTreeState(): it calls saveStateIntoDataTree() and writes + the resulting XML into the memory block. Processors that need opaque + binary state should override this method. @param memoryBlock The memory block to save the state to. + @return The result of the operation. */ - virtual Result saveStateIntoMemory (MemoryBlock& memoryBlock) = 0; + virtual Result saveStateIntoMemory (MemoryBlock& memoryBlock); //============================================================================== - /** Returns true if the processor has an editor. */ virtual bool hasEditor() const = 0; /** Creates an editor for the processor. */ - virtual AudioProcessorEditor* createEditor() { return nullptr; } + virtual AudioProcessorEditor* createEditor(); //============================================================================== - /** @internal Used by plugin wrappers. */ void setPlaybackConfiguration (float sampleRate, int samplesPerBlock); diff --git a/modules/yup_audio_processors/yup_audio_processors.h b/modules/yup_audio_processors/yup_audio_processors.h index 214195dc..7782f20d 100644 --- a/modules/yup_audio_processors/yup_audio_processors.h +++ b/modules/yup_audio_processors/yup_audio_processors.h @@ -32,7 +32,7 @@ website: https://github.com/kunitoki/yup license: ISC - dependencies: yup_audio_basics + dependencies: yup_audio_basics yup_data_model END_YUP_MODULE_DECLARATION @@ -45,6 +45,7 @@ #include #include +#include #if YUP_MODULE_AVAILABLE_yup_gui #include diff --git a/modules/yup_dsp/delays/yup_FractionallyAddressedDelay.h b/modules/yup_dsp/delays/yup_FractionallyAddressedDelay.h new file mode 100644 index 00000000..12e6ee42 --- /dev/null +++ b/modules/yup_dsp/delays/yup_FractionallyAddressedDelay.h @@ -0,0 +1,233 @@ +/* + ============================================================================== + + 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 +{ + +//============================================================================== +/** + Fractionally-Addressed Delay (FAD) line. + + Implements the delay-line architecture proposed by Davide Rocchesso (1999), + in which a single fractional pointer is used for both reading and writing. + This gives two practical advantages over the classic FIR (two-pointer) delay: + + 1. **Lower frequency-dependent attenuation.** The mean attenuation at 2/3 + Nyquist is ~0.5 dB vs. ~1.2 dB for the linearly-interpolated FIR line. + + 2. **Tension-model modulation.** Varying the delay length changes the + effective propagation speed rather than the physical length, so the pitch + shift follows exp(−k) rather than (1+k). This avoids the Doppler artifact + of FIR delay lines, making the FAD well-suited for smooth chorus, flanger, + and waveguide pitch-bending effects. + + ### Usage + + @code + yup::FractionallyAddressedDelay fad; + fad.setMaxDelaySamples (4096); // allocates the buffer + fad.setDelaySamples (2048.0f); // sets the delay (may be fractional) + + for (int i = 0; i < blockSize; ++i) + output[i] = fad.processSample (input[i]); + @endcode + + ### Algorithm + + Given buffer size `B` and increment `I = B / delaySamples`: + + @code + fph = floor(phase) + output = lerp(buf[fph], buf[(fph+1) & mask], frac(phase)) // linear read + numWrites = (fph - phaseOldInt + B) & mask + for i in 0..numWrites-1: + buf[(phaseOldInt+1+i) & mask] = lerp(prevInput, input, (i+1)/numWrites) + phaseOldInt = fph + phase += I; if phase >= B then phase -= B + @endcode + + Read uses linear interpolation between adjacent cells. Write uses linear + interpolation between the previous and current input to fill the gap when + `I > 1`. When `I < 1` no write is performed and the pointer re-reads existing + buffer content, naturally implementing delays longer than the buffer size. + + @note `setMaxDelaySamples()` must be called before any processing. + + @tparam SampleType Type of audio samples (float or double). + @tparam CoeffType Type used for internal computation (defaults to double). +*/ +template +class FractionallyAddressedDelay +{ +public: + //============================================================================== + /** Default constructor. Call setMaxDelaySamples() before processing. */ + FractionallyAddressedDelay() noexcept = default; + + //============================================================================== + /** Allocates the internal buffer to hold at least @p maxDelaySamples cells. + + The actual buffer size is rounded up to the next power of two for + efficient modular addressing. Resets all internal state. + + @param maxDelaySamples Maximum number of delay samples required (>= 1). + */ + void setMaxDelaySamples (int maxDelaySamples) + { + jassert (maxDelaySamples >= 1); + + bufferSize = nextPowerOfTwo (jmax (2, maxDelaySamples)); + bufferMask = bufferSize - 1; + delayBuffer.assign (static_cast (bufferSize), SampleType {}); + + reset(); + } + + /** Returns the allocated buffer size (a power of two >= the requested size). */ + int getBufferSize() const noexcept { return bufferSize; } + + //============================================================================== + /** Sets the current delay length in samples (may be fractional). + + Internally computes the phase increment `I = bufferSize / delaySamples`. + A larger delay produces a smaller increment and a slower pointer advance. + + @param newDelaySamples Desired delay in samples, clamped to a minimum + of 1 to prevent a zero or negative increment. + */ + void setDelaySamples (CoeffType newDelaySamples) noexcept + { + const CoeffType clamped = jmax (CoeffType (1), newDelaySamples); + increment = static_cast (bufferSize) / clamped; + } + + /** Returns the current delay in samples derived from the stored increment. */ + CoeffType getDelaySamples() const noexcept + { + if (bufferSize == 0 || increment <= CoeffType (0)) + return CoeffType (0); + + return static_cast (bufferSize) / increment; + } + + //============================================================================== + /** No-op: this processor requires no sample-rate-dependent setup. */ + void prepare (double /*sampleRate*/, int /*maximumBlockSize*/) noexcept {} + + /** Clears the delay buffer and resets the phase pointers. */ + void reset() noexcept + { + std::fill (delayBuffer.begin(), delayBuffer.end(), SampleType {}); + phase = CoeffType (0); + phaseOldInt = bufferSize > 0 ? bufferSize - 1 : 0; + prevInput = SampleType {}; + } + + //============================================================================== + /** Processes a single sample through the FAD line. + + @param inputSample The input sample to write into the delay. + @returns The linearly-interpolated delayed output sample. + */ + forcedinline SampleType processSample (SampleType inputSample) noexcept + { + const int fph = static_cast (phase) & bufferMask; + const CoeffType frac = phase - std::floor (phase); + + // Linear read: samples following the phase pointer + const CoeffType y0 = static_cast (delayBuffer[static_cast (fph)]); + const CoeffType y1 = static_cast (delayBuffer[static_cast ((fph + 1) & bufferMask)]); + const SampleType output = static_cast (y0 + frac * (y1 - y0)); + + // Fill buffer cells between the previous and current phase floor + const int numWrites = (fph - phaseOldInt + bufferSize) & bufferMask; + + if (numWrites > 0) + { + const CoeffType prevIn = static_cast (prevInput); + const CoeffType currIn = static_cast (inputSample); + const CoeffType invWrites = CoeffType (1) / static_cast (numWrites); + + for (int i = 0; i < numWrites; ++i) + { + const CoeffType writeFrac = static_cast (i + 1) * invWrites; + const int writeIdx = (phaseOldInt + 1 + i) & bufferMask; + delayBuffer[static_cast (writeIdx)] = + static_cast (prevIn + writeFrac * (currIn - prevIn)); + } + } + + phaseOldInt = fph; + prevInput = inputSample; + + phase += increment; + + if (phase >= static_cast (bufferSize)) + phase -= static_cast (bufferSize); + + return output; + } + + /** Processes a block of samples. + + @param inputBuffer Pointer to input samples. + @param outputBuffer Pointer to the output buffer (must not alias inputBuffer). + @param numSamples Number of samples to process. + */ + void processBlock (const SampleType* inputBuffer, SampleType* outputBuffer, int numSamples) noexcept + { + for (int i = 0; i < numSamples; ++i) + outputBuffer[i] = processSample (inputBuffer[i]); + } + + /** Processes a block of samples in-place. + + @param buffer Pointer to the buffer to process in-place. + @param numSamples Number of samples to process. + */ + void processInPlace (SampleType* buffer, int numSamples) noexcept + { + for (int i = 0; i < numSamples; ++i) + buffer[i] = processSample (buffer[i]); + } + +private: + //============================================================================== + std::vector delayBuffer; + int bufferSize = 0; + int bufferMask = 0; + CoeffType phase = CoeffType (0); + int phaseOldInt = 0; + SampleType prevInput = SampleType {}; + CoeffType increment = CoeffType (1); + + //============================================================================== + YUP_LEAK_DETECTOR (FractionallyAddressedDelay) +}; + +//============================================================================== +/** Type aliases for convenience. */ +using FractionallyAddressedDelayFloat = FractionallyAddressedDelay; +using FractionallyAddressedDelayDouble = FractionallyAddressedDelay; + +} // namespace yup diff --git a/modules/yup_dsp/dynamics/yup_HardClipper.h b/modules/yup_dsp/dynamics/yup_HardClipper.h new file mode 100644 index 00000000..c7a81d0e --- /dev/null +++ b/modules/yup_dsp/dynamics/yup_HardClipper.h @@ -0,0 +1,85 @@ +/* + ============================================================================== + + 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 +{ + +//============================================================================== +/** + Nonlinear traits for a hard clipper: f(x) = clamp(x, -1, 1). +*/ +struct HardClipperTraits +{ + /** Applies hard clipping: returns x clamped to [-1, 1]. */ + template + static T f (T x) noexcept + { + return std::clamp (x, T (-1), T (1)); + } + + /** Adds u-space breakpoints at the clipping thresholds ξ = ±1. + Called by AaIirAntialiaser to split the integration interval at the + exact points where f changes from constant (−1 or +1) to linear (ξ). + pts must have capacity for at least 2 additional entries beyond nPts. */ + template + static void fillBreakpoints (T x0, T delta, T* pts, int& nPts) noexcept + { + const T uMinus = (T (-1) - x0) / delta; // u where ξ = −1 + const T uPlus = (T (+1) - x0) / delta; // u where ξ = +1 + if (uMinus > T (0) && uMinus < T (1)) + pts[nPts++] = uMinus; + if (uPlus > T (0) && uPlus < T (1)) + pts[nPts++] = uPlus; + } +}; + +//============================================================================== +/** + Nonlinear traits for a hyperbolic-tangent soft clipper. + + Smooth everywhere — no fillBreakpoints needed. A single affine segment + over [x_n, x_{n+1}] gives an accurate approximation because tanh has no + derivative discontinuities. +*/ +struct TanhClipperTraits +{ + /** Applies tanh saturation: output ∈ (−1, 1) for any finite input. */ + template + static T f (T x) noexcept + { + return std::tanh (x); + } +}; + +//============================================================================== +/** Convenience alias: AA-IIR antialiaser with hard-clipper nonlinearity. */ +template +using HardClipper = AaIirAntialiaser; + +/** Hard-clipper AA-IIR antialiaser with float precision. */ +using HardClipperFloat = HardClipper; + +/** Hard-clipper AA-IIR antialiaser with double precision. */ +using HardClipperDouble = HardClipper; + +} // namespace yup diff --git a/modules/yup_dsp/nonlinear/yup_AaIirAntialiaser.h b/modules/yup_dsp/nonlinear/yup_AaIirAntialiaser.h new file mode 100644 index 00000000..744533a8 --- /dev/null +++ b/modules/yup_dsp/nonlinear/yup_AaIirAntialiaser.h @@ -0,0 +1,584 @@ +/* + ============================================================================== + + 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 +{ + +//============================================================================== +/** + IIR antiderivative antialiaser for static nonlinear functions. + + Implements the AA-IIR method from the paper "Arbitrary-Order IIR Antiderivative + Antialiasing" (La Pastina, D'Angelo, Gabrielli, DAFx 2021). The method discretizes + a nonlinear static function using a fictitious continuous-time domain with an IIR + antialiasing filter, yielding substantially better aliasing reduction than simple + oversampling or rectangular-kernel AA-FIR at comparable cost. + + The IIR filter is specified via its Laplace-domain partial fraction decomposition + into complex conjugate pole pairs and real poles. Each pole contributes one + recursive state variable updated per sample according to closed-form expressions + derived from the chosen NonlinearTraits type. + + The algorithm introduces one sample of inherent latency. + + @tparam SampleType Audio sample type (float or double). + @tparam NonlinearTraits Policy class providing the nonlinear function f(x) and + its closed-form integral computations. + @tparam CoeffType Internal arithmetic precision (defaults to double). +*/ +template +class AaIirAntialiaser +{ +public: + //============================================================================== + /** + Laplace-domain pole configuration for the AA-IIR filter kernel. + + Poles must all lie in the left half-plane (poleReal < 0, Re(β) < 0) + to ensure filter stability. Complex poles must be specified as + conjugate pairs; the conjugate is added automatically. + */ + struct PoleConfig + { + /** A complex conjugate pole pair with its partial-fraction residue. */ + struct ComplexPair + { + CoeffType poleReal; ///< Real part of pole (must be < 0) + CoeffType poleImag; ///< Imaginary part of pole (positive half) + CoeffType residueReal; ///< Re(B), residue from partial fractions + CoeffType residueImag; ///< Im(B), residue from partial fractions + }; + + /** A simple real pole with its partial-fraction residue. */ + struct RealPole + { + CoeffType pole; ///< Pole location (must be < 0) + CoeffType residue; ///< Residue A from partial fractions + }; + + std::vector complexPairs; + std::vector realPoles; + + /** Direct (Dirac delta) term A0; contributes A0 * f(x) to the output. + Zero for proper low-pass filters. */ + CoeffType constantTerm = CoeffType (0); + }; + + //============================================================================== + /** Constructs the processor with the given pole configuration. + + Call prepare() before processing audio. + + @param config Pole configuration. Defaults to a 2nd-order Butterworth + lowpass at 0.45*Fs (the AA-IIR-1 preset from the paper). + */ + explicit AaIirAntialiaser (PoleConfig config = makeChebyshevTypeIIOrder10()) noexcept + { + configure (std::move (config)); + } + + //============================================================================== + /** Reconfigures the processor with new pole specifications. + + Must be followed by a call to prepare() before processing audio. + + @param config New pole configuration. + */ + void configure (PoleConfig config) + { + complexPoles.clear(); + for (const auto& pair : config.complexPairs) + { + ComplexPoleState s; + s.poleReal = pair.poleReal; + s.poleImag = pair.poleImag; + s.residueReal = pair.residueReal; + s.residueImag = pair.residueImag; + complexPoles.push_back (s); + } + + realPoles.clear(); + for (const auto& rp : config.realPoles) + { + RealPoleState s; + s.pole = rp.pole; + s.residue = rp.residue; + realPoles.push_back (s); + } + + constantTerm = config.constantTerm; + storedConfig = std::move (config); + } + + //============================================================================== + /** Prepares the processor for playback. + + Precomputes per-pole exponentials e^beta (sample-rate independent for + poles given in normalized rad/sample) and clears all internal state. + + @param sampleRate Sample rate (unused for normalized poles but + accepted for API consistency). + @param maximumBlockSize Maximum expected block size (unused). + */ + void prepare (double /*sampleRate*/, int /*maximumBlockSize*/) noexcept + { + for (auto& p : complexPoles) + { + const CoeffType mag = std::exp (p.poleReal); + p.expReal = mag * std::cos (p.poleImag); + p.expImag = mag * std::sin (p.poleImag); + } + + for (auto& p : realPoles) + p.expAlpha = std::exp (p.pole); + + reset(); + } + + /** Clears all internal filter state. + + Does not affect pole configuration or precomputed exponentials. + */ + void reset() noexcept + { + prevX = CoeffType (0); + + for (auto& p : complexPoles) + { + p.stateY = CoeffType (0); + p.stateZ = CoeffType (0); + } + + for (auto& p : realPoles) + p.state = CoeffType (0); + } + + //============================================================================== + /** Processes a single input sample through the AA-IIR nonlinearity. + + Implements the per-sample update from the paper (Eq. 20-21 for complex + poles, Eq. 9 for real poles). The output at step n uses the previous + input x_{n-1} and the current input x_n to compute the antialiased + nonlinear output y_n. + + @param inputSample The current input sample. + @returns The antialiased nonlinear output. + */ + SampleType processSample (SampleType inputSample) noexcept + { + const CoeffType x0 = prevX; + const CoeffType x1 = static_cast (inputSample); + prevX = x1; + + CoeffType output = constantTerm != CoeffType (0) + ? constantTerm * NonlinearTraits::f (x0) + : CoeffType (0); + + for (auto& p : complexPoles) + { + const auto [IR, II] = computeComplexIntegral (x0, x1, p.poleReal, p.poleImag); + + const CoeffType newY = p.expReal * p.stateY - p.expImag * p.stateZ + + CoeffType (2) * (p.residueReal * IR - p.residueImag * II); + const CoeffType newZ = p.expImag * p.stateY + p.expReal * p.stateZ + + CoeffType (2) * (p.residueImag * IR + p.residueReal * II); + + p.stateY = newY; + p.stateZ = newZ; + output += newY; + } + + for (auto& p : realPoles) + { + const CoeffType I = computeRealIntegral (x0, x1, p.pole); + const CoeffType newY = p.expAlpha * p.state + p.residue * I; + p.state = newY; + output += newY; + } + + return static_cast (output); + } + + /** Processes a block of samples. + + @param inputBuffer Pointer to the input samples. + @param outputBuffer Pointer to the output buffer (may alias inputBuffer). + @param numSamples Number of samples to process. + */ + void processBlock (const SampleType* inputBuffer, SampleType* outputBuffer, int numSamples) noexcept + { + for (int i = 0; i < numSamples; ++i) + outputBuffer[i] = processSample (inputBuffer[i]); + } + + /** Processes a block of samples in-place. + + @param buffer Pointer to the buffer to process. + @param numSamples Number of samples to process. + */ + void processInPlace (SampleType* buffer, int numSamples) noexcept + { + processBlock (buffer, buffer, numSamples); + } + + //============================================================================== + /** Creates a pole configuration for a 2nd-order Butterworth lowpass antialiasing filter. + + This is the AA-IIR-1 configuration from the paper, with cutoff at 0.45*Fs. + The poles are at beta = omega_c * (-1 + j) / sqrt(2) in rad/sample (Fs = 1 + normalisation), with residue B = -j * omega_c / sqrt(2). + + @param cutoffNormalized Filter cutoff as a fraction of the sample rate + (0 < cutoffNormalized < 0.5). Default: 0.45. + */ + static PoleConfig makeButterworthOrder2 (CoeffType cutoffNormalized = CoeffType (0.45)) + { + // 2nd-order Butterworth lowpass: H(s) = wc^2 / (s^2 + sqrt(2)*wc*s + wc^2) + // Poles: beta = wc * (-1 +/- j) / sqrt(2) + // Residue: B = wc^2 / (beta - conj(beta)) = -j * wc / sqrt(2) + const CoeffType wc = CoeffType (2) * CoeffType (MathConstants::pi) * cutoffNormalized; + const CoeffType sq = std::sqrt (CoeffType (2)); + + typename PoleConfig::ComplexPair pair; + pair.poleReal = -wc / sq; + pair.poleImag = +wc / sq; + pair.residueReal = CoeffType (0); + pair.residueImag = -wc / sq; + + PoleConfig config; + config.complexPairs.push_back (pair); + return config; + } + + /** Creates a pole configuration for a 10th-order Chebyshev Type II lowpass antialiasing filter. + + This is the AA-IIR-2 configuration from the paper, with stopband edge at 0.61*Fs + and 60 dB stopband attenuation by default. + + Chebyshev Type II has a flat passband and equiripple stopband. The filter is + biproper (numerator degree = denominator degree = 10), so the partial-fraction + decomposition includes a non-zero constant term A₀ in addition to the five + complex conjugate pole pairs. + + Design steps (all computed in double precision): + 1. ε = 1 / sqrt(10^(As/10) − 1) from the stopband attenuation As. + 2. β = asinh(1/ε) / N (Chebyshev I parameter). + 3. Chebyshev I prototype poles (upper half-plane, unit cutoff): + p̄ₖ = −sinh(β)·sin(θₖ) + j·cosh(β)·cos(θₖ), θₖ = π(2k−1)/(2N). + 4. Chebyshev II poles (upper half-plane, scaled to stopband ωs): + βₖ = ωs / conj(p̄ₖ). + 5. Transmission zeros (imaginary axis): zₖ = ωs / cos(θₖ). + 6. DC-gain constant: K = ∏|βₖ|² / ∏zₖ² so that H(0) = 1. + 7. Residues via the partial-fraction formula. + 8. Constant term A₀ = K (biproper limit H(∞) = K). + + @param stopbandNormalized Stopband edge as a fraction of sample rate (default 0.61). + @param stopbandAttenuationdB Stopband attenuation in dB (default 60). + */ + static PoleConfig makeChebyshevTypeIIOrder10 (CoeffType stopbandNormalized = CoeffType (0.61), + CoeffType stopbandAttenuationdB = CoeffType (60)) + { + using C = std::complex; + constexpr int N = 10; + constexpr int Nh = N / 2; // number of conjugate pairs + + const double ws = 2.0 * MathConstants::pi * static_cast (stopbandNormalized); + const double As = static_cast (stopbandAttenuationdB); + + // Chebyshev I parameter + const double eps = 1.0 / std::sqrt (std::pow (10.0, As / 10.0) - 1.0); + const double beta = std::asinh (1.0 / eps) / N; + + std::array poles; + std::array zeros; + + for (int k = 0; k < Nh; ++k) + { + const double theta = MathConstants::pi * (2 * (k + 1) - 1) / (2 * N); + + // Chebyshev I upper half-plane prototype pole (unit cutoff) + const C p1 (-std::sinh (beta) * std::sin (theta), + std::cosh (beta) * std::cos (theta)); + + // Chebyshev II upper half-plane pole: ωs / conj(p1) + poles[k] = C (ws) / std::conj (p1); + + // Transmission zero frequency (positive imaginary axis): ωs / cos(θ) + zeros[k] = ws / std::cos (theta); + } + + // DC-gain normalisation: K = ∏|βₖ|² / ∏zₖ² → H(0) = 1. + double K = 1.0; + for (int k = 0; k < Nh; ++k) + { + K *= std::norm (poles[k]); // |βₖ|² + K /= zeros[k] * zeros[k]; // zₖ² + } + + // Residue at each upper half-plane pole βₖ: + // Bₖ = K · N(βₖ) / [(βₖ − β̄ₖ) · ∏_{j≠k}(βₖ−βⱼ)(βₖ−β̄ⱼ)] + // where N(s) = ∏ⱼ(s² + zⱼ²) is the numerator polynomial. + std::array residues; + for (int k = 0; k < Nh; ++k) + { + const C s = poles[k]; + + // Numerator N(s) = K · ∏ⱼ(s² + zⱼ²) + C num (K); + for (int j = 0; j < Nh; ++j) + num *= s * s + C (zeros[j] * zeros[j]); + + // Denominator derivative at s: (s − s̄) · ∏_{j≠k}(s−βⱼ)(s−β̄ⱼ) + C den = s - std::conj (s); // = 2j · Im(βₖ) + for (int j = 0; j < Nh; ++j) + if (j != k) + den *= (s - poles[j]) * (s - std::conj (poles[j])); + + residues[k] = num / den; + } + + PoleConfig config; + for (int k = 0; k < Nh; ++k) + { + typename PoleConfig::ComplexPair pair; + pair.poleReal = static_cast (poles[k].real()); + pair.poleImag = static_cast (poles[k].imag()); // > 0 by construction + pair.residueReal = static_cast (residues[k].real()); + pair.residueImag = static_cast (residues[k].imag()); + config.complexPairs.push_back (pair); + } + + // A₀ = K: biproper limit H(∞) = K (numerator and denominator share degree N). + config.constantTerm = static_cast (K); + return config; + } + +private: + //============================================================================== + // Detection helper: true when NonlinearTraits provides fillBreakpoints. + // Traits that satisfy this get piecewise-accurate integration (needed for + // hard clipping). Traits that do not (smooth functions like tanh) use a + // single affine segment over [x0, x1], which is exact for smooth nonlinearities. + template + struct BreakpointHelper : std::false_type + { + static void fill (T, T, T*, int&) noexcept {} + }; + + template + struct BreakpointHelper (T {}, T {}, (T*) nullptr, std::declval()))>> + : std::true_type + { + static void fill (T x0, T delta, T* pts, int& nPts) noexcept + { + Tr::template fillBreakpoints (x0, delta, pts, nPts); + } + }; + + //============================================================================== + /** Computes the mean definite integral used by the AA-IIR method for a complex pole. + + The paper uses the mean integral (eq. 3): ⁻∫_a^b = (1/(b-a)) * ∫_a^b. + Returns the real and imaginary parts of: + + I_mean = (1/delta) * integral_{x0}^{x1} f(xi) * exp(beta*(1-(xi-x0)/delta)) dxi + + where delta = x1-x0, beta = poleReal + j*poleImag, and f = NonlinearTraits::f. + + Using u = (xi-x0)/delta ∈ [0,1], the mean integral per affine segment is: + + I_seg = (1/beta) * (exp_a*(fA + slopeU/beta) - exp_b*(fB + slopeU/beta)) + + where slopeU = (fB-fA)/(ub-ua) is the slope of f in u-space (independent of delta). + This formula has the correct finite limit as delta → 0: f(x0) * (exp(beta)-1)/beta. + */ + template + static std::pair computeComplexIntegral (T x0, T x1, T poleReal, T poleImag) noexcept + { + // 1/beta = conj(beta) / |beta|^2 + const T betaMagSq = poleReal * poleReal + poleImag * poleImag; + const T ibR = poleReal / betaMagSq; // Re(1/beta) + const T ibI = -poleImag / betaMagSq; // Im(1/beta) + + // exp(beta) at u=0 + const T emag = std::exp (poleReal); + const T e1R = emag * std::cos (poleImag); // Re(exp(beta)) + const T e1I = emag * std::sin (poleImag); // Im(exp(beta)) + + const T delta = x1 - x0; + + // Build segment breakpoints in u-space via the traits (if provided). + // Traits that implement fillBreakpoints (e.g. HardClipperTraits) add + // their characteristic breakpoints here; smooth nonlinearities don't and + // the integral collapses to a single affine segment over [x0, x1]. + T pts[4] = { T (0), T (1), T (0), T (0) }; + int nPts = 2; + + if (std::abs (delta) >= T (1e-7)) + { + BreakpointHelper::fill (x0, delta, pts, nPts); + + for (int i = 1; i < nPts; ++i) + { + const T key = pts[i]; + int j = i - 1; + while (j >= 0 && pts[j] > key) + { + pts[j + 1] = pts[j]; + --j; + } + pts[j + 1] = key; + } + } + + T resultR = T (0); + T resultI = T (0); + + for (int i = 0; i < nPts - 1; ++i) + { + const T ua = pts[i]; + const T ub = pts[i + 1]; + const T xiA = x0 + ua * delta; + const T xiB = x0 + ub * delta; + const T fA = NonlinearTraits::f (xiA); + const T fB = NonlinearTraits::f (xiB); + + // Slope in u-space: slopeU = (fB-fA)/(ub-ua) — independent of delta. + // ub > ua always, so no division by zero here. + const T slopeU = (fB - fA) / (ub - ua); + + // slopeU / beta (complex) + const T sR = slopeU * ibR; + const T sI = slopeU * ibI; + + // exp(beta*(1-ua)) and exp(beta*(1-ub)) + const T magA = std::exp (poleReal * (T (1) - ua)); + const T eaR = magA * std::cos (poleImag * (T (1) - ua)); + const T eaI = magA * std::sin (poleImag * (T (1) - ua)); + + const T magB = std::exp (poleReal * (T (1) - ub)); + const T ebR = magB * std::cos (poleImag * (T (1) - ub)); + const T ebI = magB * std::sin (poleImag * (T (1) - ub)); + + // e_a*(fA + slopeU/beta) - e_b*(fB + slopeU/beta) + const T tAR = fA + sR; + const T tBR = fB + sR; + const T diffPR = eaR * tAR - eaI * sI - ebR * tBR + ebI * sI; + const T diffPI = eaR * sI + eaI * tAR - ebR * sI - ebI * tBR; + + // I_seg = (1/beta) * diff + resultR += ibR * diffPR - ibI * diffPI; + resultI += ibR * diffPI + ibI * diffPR; + } + + return { resultR, resultI }; + } + + /** Computes the mean definite integral for a real pole (real arithmetic version). + + See computeComplexIntegral for derivation. Returns: + + I_mean = (1/delta) * integral_{x0}^{x1} f(xi) * exp(pole*(1-(xi-x0)/delta)) dxi + */ + template + static T computeRealIntegral (T x0, T x1, T pole) noexcept + { + const T invPole = T (1) / pole; + const T delta = x1 - x0; + + T pts[4] = { T (0), T (1), T (0), T (0) }; + int nPts = 2; + + if (std::abs (delta) >= T (1e-7)) + { + BreakpointHelper::fill (x0, delta, pts, nPts); + + for (int i = 1; i < nPts; ++i) + { + const T key = pts[i]; + int j = i - 1; + while (j >= 0 && pts[j] > key) + { + pts[j + 1] = pts[j]; + --j; + } + pts[j + 1] = key; + } + } + + T result = T (0); + + for (int i = 0; i < nPts - 1; ++i) + { + const T ua = pts[i]; + const T ub = pts[i + 1]; + const T xiA = x0 + ua * delta; + const T xiB = x0 + ub * delta; + const T fA = NonlinearTraits::f (xiA); + const T fB = NonlinearTraits::f (xiB); + const T slopeU = (fB - fA) / (ub - ua); + const T sOverP = slopeU * invPole; + + const T ea = std::exp (pole * (T (1) - ua)); + const T eb = std::exp (pole * (T (1) - ub)); + + // I_seg = (1/pole) * (ea*(fA + slopeU/pole) - eb*(fB + slopeU/pole)) + result += invPole * (ea * (fA + sOverP) - eb * (fB + sOverP)); + } + + return result; + } + + //============================================================================== + struct ComplexPoleState + { + CoeffType poleReal = CoeffType (0); + CoeffType poleImag = CoeffType (0); + CoeffType residueReal = CoeffType (0); + CoeffType residueImag = CoeffType (0); + CoeffType expReal = CoeffType (0); // Re(e^beta), computed in prepare() + CoeffType expImag = CoeffType (0); // Im(e^beta), computed in prepare() + CoeffType stateY = CoeffType (0); // Re(ŷ_n) + CoeffType stateZ = CoeffType (0); // Im(ŷ_n) + }; + + struct RealPoleState + { + CoeffType pole = CoeffType (0); + CoeffType residue = CoeffType (0); + CoeffType expAlpha = CoeffType (0); // e^pole, computed in prepare() + CoeffType state = CoeffType (0); // y_n + }; + + //============================================================================== + std::vector complexPoles; + std::vector realPoles; + CoeffType constantTerm = CoeffType (0); + CoeffType prevX = CoeffType (0); + PoleConfig storedConfig; + + //============================================================================== + YUP_LEAK_DETECTOR (AaIirAntialiaser) +}; + +} // namespace yup diff --git a/modules/yup_dsp/resampling/yup_Oversampler.h b/modules/yup_dsp/resampling/yup_Oversampler.h index 2656c299..4bbb600e 100644 --- a/modules/yup_dsp/resampling/yup_Oversampler.h +++ b/modules/yup_dsp/resampling/yup_Oversampler.h @@ -41,7 +41,7 @@ namespace yup os.upsample (inputPtrs, numChannels, numSamples); os.processOversampledBlock ([&] (auto& buf) { - applyDistortion (buf); // buf is std::vector>& + applyDistortion (buf); // buf is AudioBuffer& }); os.downsample (outputPtrs, numChannels, numSamples); @endcode @@ -71,7 +71,7 @@ class Oversampler /** Prepares the oversampler for processing. - Configures the internal windowed sinc table and allocates per-channel + Configures the internal windowed sinc tables and allocates per-channel history and staging buffers. Must be called before upsample() or downsample(). @param sampleRate Input sample rate in Hz. @@ -82,8 +82,14 @@ class Oversampler { jassert (sampleRate > 0.0 && maxChannels > 0 && maxBlockSize > 0); - sincTable.configure (static_cast (sampleRate)); - sincTable.applyKaiserWindow (CoeffType (5)); + interpolationTable.configure (static_cast (sampleRate)); + interpolationTable.applyKaiserWindow (CoeffType (5)); + + decimationTable.configureWithCutoff (static_cast (sampleRate) * antiAliasCutoffRatio, + static_cast (sampleRate)); + decimationTable.applyKaiserWindow (CoeffType (5)); + + normalizeFilterGains(); const int maxInterpolated = maxBlockSize * OversampleFactor; @@ -98,7 +104,7 @@ class Oversampler xDecim.setSize (maxChannels, maxInterpolated + SincRadius * OversampleFactor); xDecim.clear(); - oversampledBuffer.setSize (maxChannels, maxInterpolated); + oversampledBuffer.setSize (maxChannels, maxInterpolated, false, false, true); oversampledBuffer.clear(); currentOversampledSize = 0; @@ -148,6 +154,8 @@ class Oversampler */ void upsample (const SampleType* const* input, int numChannels, int numSamples) noexcept { + ScopedNoDenormals noDenormals; + jassert (numChannels > 0 && numSamples > 0); jassert (numChannels <= xInterp.getNumChannels()); jassert (numSamples + SincRadius <= xInterp.getNumSamples()); @@ -165,6 +173,7 @@ class Oversampler currentOversampledSize = numSamples * OversampleFactor; currentNumChannels = numChannels; + oversampledBuffer.setSize (numChannels, currentOversampledSize, false, false, true); for (int ch = 0; ch < numChannels; ++ch) { @@ -185,12 +194,12 @@ class Oversampler CoeffType acc = CoeffType (0); for (int n = -SincRadius; n <= 0; ++n) - acc += sincTable (n, delta) * static_cast (xBuf[static_cast (index - n)]); + acc += interpolationTable (n, delta) * static_cast (xBuf[static_cast (index - n)]); for (int n = 1; n <= SincRadius; ++n) - acc += sincTable (n, delta) * static_cast (beginBuf[SincRadius - n]); + acc += interpolationTable (n, delta) * static_cast (beginBuf[SincRadius - n]); - *outBuf++ = static_cast (acc); + *outBuf++ = static_cast (acc * interpolationGains[static_cast (delta)]); } else { @@ -214,15 +223,20 @@ class Oversampler processed (e.g. via processOversampledBlock()). @param output Array of write pointers, one per channel. - @param numChannels Number of channels to write (must be <= maxChannels from prepare()). + @param numChannels Number of channels to write (must match the numChannels + passed to the preceding upsample() call). @param numSamples Number of output samples per channel (must match the numSamples passed to the preceding upsample() call). */ void downsample (SampleType* const* output, int numChannels, int numSamples) noexcept { + ScopedNoDenormals noDenormals; + jassert (numChannels > 0 && numSamples > 0); jassert (numChannels <= xDecim.getNumChannels()); + jassert (numChannels == currentNumChannels); jassert (currentOversampledSize > 0); + jassert (numSamples * OversampleFactor == currentOversampledSize); const int interpolatedSize = currentOversampledSize; @@ -255,35 +269,47 @@ class Oversampler CoeffType acc = CoeffType (0); for (int n = 1; n <= SincRadius * OversampleFactor; ++n) - acc += sincTable[n] * static_cast (beginBuf[SincRadius * OversampleFactor - n]); + acc += decimationTable[n] * static_cast (beginBuf[SincRadius * OversampleFactor - n]); for (int n = 0; n >= -(SincRadius * OversampleFactor); --n) - acc += sincTable[n] * static_cast (xBuf[static_cast (index - n)]); + acc += decimationTable[n] * static_cast (xBuf[static_cast (index - n)]); for (int i = 0; i < OversampleFactor; ++i) beginBuf.push (xBuf[static_cast (index + i)]); - outputData[k] = static_cast (acc / static_cast (OversampleFactor)); + outputData[k] = static_cast (acc * decimationGain); } for (int i = 0; i < SincRadius * OversampleFactor; ++i) dEndBuf.push (xBuf[static_cast (interpolatedSize + i)]); } + + currentOversampledSize = 0; + currentNumChannels = 0; } //============================================================================== /** Invokes a callback with the internal oversampled multi-channel buffer. - The callback receives a reference to the internal `AudioBuffer`, where each inner - vector has getOversampledNumSamples() elements. Use this to apply processing at the elevated - sample rate. + The callback receives a reference to the internal `AudioBuffer`. + The buffer has the same channel count as the most recent upsample() call, + and getOversampledNumSamples() samples per channel. Use this to apply + processing at the elevated sample rate. If there is no pending + oversampled block, the callback receives an empty buffer. @param callback Callable with signature `void(AudioBuffer&)`. */ template void processOversampledBlock (Callable&& callback) { + if (currentOversampledSize == 0 || currentNumChannels == 0) + { + AudioBuffer emptyBuffer; + callback (emptyBuffer); + return; + } + callback (oversampledBuffer); } @@ -325,9 +351,9 @@ class Oversampler /** Returns the number of samples currently in each oversampled channel. - Equal to the numSamples argument of the most recent upsample() call - multiplied by OversampleFactor. Returns 0 before the first upsample() - call or after reset(). + Equal to the numSamples argument of the most recent pending upsample() + call multiplied by OversampleFactor. Returns 0 before the first + upsample() call, after downsample(), or after reset(). */ forcedinline int getOversampledNumSamples() const noexcept { @@ -346,7 +372,35 @@ class Oversampler private: //============================================================================== - SincTable sincTable; + void normalizeFilterGains() noexcept + { + for (int delta = 0; delta < OversampleFactor; ++delta) + { + CoeffType sum = CoeffType (0); + + for (int n = -SincRadius; n <= SincRadius; ++n) + sum += interpolationTable (n, delta); + + jassert (sum != CoeffType (0)); + interpolationGains[static_cast (delta)] = CoeffType (1) / sum; + } + + CoeffType decimationSum = decimationTable[0]; + + for (int n = 1; n <= SincRadius * OversampleFactor; ++n) + decimationSum += CoeffType (2) * decimationTable[n]; + + jassert (decimationSum != CoeffType (0)); + decimationGain = CoeffType (1) / decimationSum; + } + + // Leave transition width before the original Nyquist frequency for decimation. + static constexpr CoeffType antiAliasCutoffRatio = CoeffType (0.45); + + SincTable interpolationTable; + SincTable decimationTable; + std::array (OversampleFactor)> interpolationGains {}; + CoeffType decimationGain = CoeffType (1); std::vector> interpolBeginBufs; std::vector> interpolEndBufs; diff --git a/modules/yup_dsp/yup_dsp.h b/modules/yup_dsp/yup_dsp.h index 7c524301..0fe54d38 100644 --- a/modules/yup_dsp/yup_dsp.h +++ b/modules/yup_dsp/yup_dsp.h @@ -153,6 +153,9 @@ #include "base/yup_AnalogPoles.h" #include "base/yup_StateVariableCoefficients.h" +// Nonlinear processors +#include "nonlinear/yup_AaIirAntialiaser.h" + // Metering and level measurement (after Biquad definition) #include "metering/yup_LevelProcessor.h" #include "metering/yup_LoudnessFilter.h" @@ -175,8 +178,12 @@ #include "filters/yup_CombFilter.h" // Dynamics processors -#include "dynamics/yup_SoftClipper.h" #include "dynamics/yup_BlunterClipper.h" +#include "dynamics/yup_HardClipper.h" +#include "dynamics/yup_SoftClipper.h" + +// Delay lines +#include "delays/yup_FractionallyAddressedDelay.h" // Convolution processors #include "convolution/yup_PartitionedConvolver.h" diff --git a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp index a3361838..2e2dad86 100644 --- a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp +++ b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp @@ -1069,18 +1069,35 @@ void paintAudioGraphComponent (Graphics& g, const ApplicationTheme&, const Audio g.setFillColor (backgroundColor); g.fillAll(); - const auto spacing = 24.0f * zoom; - if (spacing >= 6.0f && ! gridColor.isTransparent()) + const auto baseSpacing = 24.0f * zoom; + if (baseSpacing >= 1.0f && ! gridColor.isTransparent()) { + constexpr float minGridSpacing = 18.0f; + constexpr int maxGridDots = 6000; + + auto gridStep = jmax (1, static_cast (std::ceil (minGridSpacing / baseSpacing))); + auto spacing = baseSpacing * static_cast (gridStep); + auto estimatedDots = static_cast (std::ceil (static_cast (graph.getWidth()) / spacing)) + * static_cast (std::ceil (static_cast (graph.getHeight()) / spacing)); + + while (estimatedDots > maxGridDots) + { + ++gridStep; + spacing = baseSpacing * static_cast (gridStep); + estimatedDots = static_cast (std::ceil (static_cast (graph.getWidth()) / spacing)) + * static_cast (std::ceil (static_cast (graph.getHeight()) / spacing)); + } + const auto startX = std::fmod (canvasOffset.getX(), spacing); const auto startY = std::fmod (canvasOffset.getY(), spacing); + const auto dotRadius = spacing >= 28.0f ? 1.0f : 0.75f; g.setFillColor (gridColor); for (auto x = startX; x < graph.getWidth(); x += spacing) { for (auto y = startY; y < graph.getHeight(); y += spacing) - g.fillEllipse (x - 1.0f, y - 1.0f, 2.0f, 2.0f); + g.fillEllipse (x - dotRadius, y - dotRadius, dotRadius * 2.0f, dotRadius * 2.0f); } } diff --git a/tests/yup_audio_basics/yup_BufferingAudioSource.cpp b/tests/yup_audio_basics/yup_BufferingAudioSource.cpp index aa8e7151..44840107 100644 --- a/tests/yup_audio_basics/yup_BufferingAudioSource.cpp +++ b/tests/yup_audio_basics/yup_BufferingAudioSource.cpp @@ -56,8 +56,15 @@ class MockPositionableAudioSource : public PositionableAudioSource void getNextAudioBlock (const AudioSourceChannelInfo& info) override { + getNextAudioBlockCallCount.fetch_add (1); getNextAudioBlockCalled.store (true); + fillNextAudioBlock (info); + } + +protected: + void fillNextAudioBlock (const AudioSourceChannelInfo& info) + { const auto startPosition = currentPosition.fetch_add (info.numSamples); // Fill with a pattern based on current position @@ -71,6 +78,7 @@ class MockPositionableAudioSource : public PositionableAudioSource } } +public: void setNextReadPosition (int64 newPosition) override { setNextReadPositionCalled.store (true); @@ -101,6 +109,7 @@ class MockPositionableAudioSource : public PositionableAudioSource std::atomic releaseResourcesCalled { false }; std::atomic getNextAudioBlockCalled { false }; std::atomic setNextReadPositionCalled { false }; + std::atomic getNextAudioBlockCallCount { 0 }; std::atomic lastSamplesPerBlock { 0 }; std::atomic lastSampleRate { 0.0 }; std::atomic totalLength; @@ -108,6 +117,35 @@ class MockPositionableAudioSource : public PositionableAudioSource std::atomic looping; }; +class BlockingReadMockPositionableAudioSource : public MockPositionableAudioSource +{ +public: + explicit BlockingReadMockPositionableAudioSource (int callIndexToBlock) + : callIndexToBlock (callIndexToBlock) + { + } + + void getNextAudioBlock (const AudioSourceChannelInfo& info) override + { + const auto callIndex = getNextAudioBlockCallCount.fetch_add (1) + 1; + getNextAudioBlockCalled.store (true); + + if (callIndex == callIndexToBlock) + { + blockedReadStarted.signal(); + allowBlockedReadToFinish.wait (1000); + } + + fillNextAudioBlock (info); + } + + WaitableEvent blockedReadStarted; + WaitableEvent allowBlockedReadToFinish; + +private: + const int callIndexToBlock; +}; + bool waitForFlag (const std::atomic& flag, int timeoutMs = 1000) { for (int elapsedMs = 0; elapsedMs < timeoutMs; elapsedMs += 5) @@ -120,6 +158,19 @@ bool waitForFlag (const std::atomic& flag, int timeoutMs = 1000) return flag.load(); } + +bool waitForValueAtLeast (const std::atomic& value, int minimumValue, int timeoutMs = 1000) +{ + for (int elapsedMs = 0; elapsedMs < timeoutMs; elapsedMs += 5) + { + if (value.load() >= minimumValue) + return true; + + Thread::sleep (5); + } + + return value.load() >= minimumValue; +} } // namespace //============================================================================== @@ -617,6 +668,28 @@ TEST_F (BufferingAudioSourceTests, ReadNextBufferChunkLoopingChange) EXPECT_TRUE (waitForFlag (mockSource->getNextAudioBlockCalled)); } +TEST_F (BufferingAudioSourceTests, LoopingChangeDuringReadDiscardsStaleBufferRange) +{ + auto* source = new BlockingReadMockPositionableAudioSource (4); + auto blockingBuffering = std::make_unique (source, *thread, true, 8192, 2, false); + + blockingBuffering->prepareToPlay (512, 44100.0); + + const auto blockedReadStarted = source->blockedReadStarted.wait (1000); + EXPECT_TRUE (blockedReadStarted); + + if (! blockedReadStarted) + { + source->allowBlockedReadToFinish.signal(); + return; + } + + blockingBuffering->setLooping (true); + source->allowBlockedReadToFinish.signal(); + + EXPECT_TRUE (waitForValueAtLeast (source->getNextAudioBlockCallCount, 5)); +} + //============================================================================== TEST_F (BufferingAudioSourceTests, UseTimeSlice) { diff --git a/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp b/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp index 2a042744..ba730d10 100644 --- a/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp +++ b/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp @@ -46,8 +46,8 @@ AudioBusLayout monoLayout() AudioBusLayout midiLayout() { - return AudioBusLayout ({ AudioBus ("MIDI In", AudioBus::Type::MIDI, AudioBus::Direction::Input, 0) }, - { AudioBus ("MIDI Out", AudioBus::Type::MIDI, AudioBus::Direction::Output, 0) }); + return AudioBusLayout ({ AudioBus ("MIDI In", AudioBus::Type::Midi, AudioBus::Direction::Input, 0) }, + { AudioBus ("MIDI Out", AudioBus::Type::Midi, AudioBus::Direction::Output, 0) }); } class TestProcessor : public AudioProcessor @@ -433,6 +433,88 @@ AudioGraphModel::NodeFactory statefulGainFactory() }; } +class TreeStateProcessor final : public AudioProcessor +{ +public: + static const yup::Identifier stateType; + + explicit TreeStateProcessor (float initialGain = 1.0f) + : AudioProcessor ("Tree State", stereoLayout()) + , gain (initialGain) + { + } + + void prepareToPlay (float, int) override {} + + void releaseResources() override {} + + void processBlock (AudioProcessContext& context) override + { + auto& audioBuffer = context.audio; + for (int channel = 0; channel < audioBuffer.getNumChannels(); ++channel) + audioBuffer.applyGain (channel, 0, audioBuffer.getNumSamples(), gain); + } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + String getPresetName (int) const override { return {}; } + + void setPresetName (int, StringRef) override {} + + bool supportsDataTreeState() const noexcept override { return true; } + + Result loadStateFromDataTree (const DataTree& state) override + { + if (! state.isValid() || state.getType() != stateType) + return Result::fail ("Invalid tree processor state"); + + gain = static_cast (static_cast (state.getProperty ("gain", 1.0))); + return Result::ok(); + } + + Result saveStateIntoDataTree (DataTree& state) override + { + state = DataTree (stateType); + auto transaction = state.beginTransaction(); + transaction.setProperty ("gain", gain); + return Result::ok(); + } + + bool hasEditor() const override { return false; } + + void setGain (float newGain) noexcept { gain = newGain; } + + float getGain() const noexcept { return gain; } + +private: + float gain = 1.0f; +}; + +const yup::Identifier TreeStateProcessor::stateType = "TreeStateProcessorState"; + +AudioGraphNodeProperties treeStateProperties() +{ + AudioGraphNodeProperties properties; + properties.identifier = "treeState"; + properties.name = "Tree State"; + return properties; +} + +AudioGraphModel::NodeFactory treeStateFactory() +{ + return [] (const AudioGraphNodeProperties& properties) -> ResultValue> + { + if (properties.identifier != "treeState") + return makeResultValueFail ("Unknown node type"); + + return makeResultValueOk (std::make_unique()); + }; +} + class StatefulExternalProcessor : public AudioProcessor { public: @@ -701,9 +783,9 @@ class DenormalCheckProcessor : public AudioProcessor AudioBusLayout mixedLayout() { return AudioBusLayout ({ AudioBus ("Audio In", AudioBus::Type::Audio, AudioBus::Direction::Input, 2), - AudioBus ("MIDI In", AudioBus::Type::MIDI, AudioBus::Direction::Input, 0) }, + AudioBus ("MIDI In", AudioBus::Type::Midi, AudioBus::Direction::Input, 0) }, { AudioBus ("Audio Out", AudioBus::Type::Audio, AudioBus::Direction::Output, 2), - AudioBus ("MIDI Out", AudioBus::Type::MIDI, AudioBus::Direction::Output, 0) }); + AudioBus ("MIDI Out", AudioBus::Type::Midi, AudioBus::Direction::Output, 0) }); } class MixedProcessor : public AudioProcessor @@ -977,8 +1059,8 @@ TEST (AudioGraphProcessorTests, MixesFanIn) TEST (AudioGraphProcessorTests, PreservesMidiTimestamps) { - AudioBusLayout midiLayout ({ AudioBus ("MIDI In", AudioBus::Type::MIDI, AudioBus::Direction::Input, 0) }, - { AudioBus ("MIDI Out", AudioBus::Type::MIDI, AudioBus::Direction::Output, 0) }); + AudioBusLayout midiLayout ({ AudioBus ("MIDI In", AudioBus::Type::Midi, AudioBus::Direction::Input, 0) }, + { AudioBus ("MIDI Out", AudioBus::Type::Midi, AudioBus::Direction::Output, 0) }); auto model = std::make_shared(); AudioGraphProcessor graph (model, midiLayout); @@ -1338,6 +1420,116 @@ TEST (AudioGraphProcessorTests, SaveAndLoadRestoresConnectionsAndNodeState) EXPECT_FLOAT_EQ (0.25f, audio.getReadPointer (1)[0]); } +TEST (AudioGraphProcessorTests, DataTreeProcessorStateSavesAsReadableStateTree) +{ + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + + auto processor = std::make_unique (0.25f); + auto* processorPtr = processor.get(); + const auto node = model->addNode (std::move (processor), treeStateProperties()); + + ASSERT_TRUE (graph.commitChanges().wasOk()); + + MemoryBlock savedState; + ASSERT_TRUE (graph.saveStateIntoMemory (savedState).wasOk()); + + auto xml = parseMemoryBlockAsXml (savedState); + ASSERT_NE (nullptr, xml.get()); + + auto* savedNodeElement = xml->getChildByName ("nodes")->getChildByName ("node"); + ASSERT_NE (nullptr, savedNodeElement); + EXPECT_EQ (nullptr, savedNodeElement->getChildByName ("state")); + + auto* stateTreeElement = savedNodeElement->getChildByName ("stateTree"); + ASSERT_NE (nullptr, stateTreeElement); + + auto* stateElement = stateTreeElement->getChildByName (TreeStateProcessor::stateType); + ASSERT_NE (nullptr, stateElement); + EXPECT_DOUBLE_EQ (0.25, stateElement->getDoubleAttribute ("gain")); + + processorPtr->setGain (0.75f); + model->setNodeFactory (treeStateFactory()); + ASSERT_TRUE (graph.loadStateFromMemory (savedState).wasOk()); + + auto* restored = dynamic_cast (model->getNodeProcessor (node)); + ASSERT_NE (nullptr, restored); + EXPECT_FLOAT_EQ (0.25f, restored->getGain()); +} + +TEST (AudioGraphProcessorTests, DataTreeProcessorCanLoadLegacyXmlBackedBinaryState) +{ + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + + const auto node = model->addNode (std::make_unique (0.5f), treeStateProperties()); + ASSERT_TRUE (graph.commitChanges().wasOk()); + + MemoryBlock savedState; + ASSERT_TRUE (graph.saveStateIntoMemory (savedState).wasOk()); + + auto xml = parseMemoryBlockAsXml (savedState); + ASSERT_NE (nullptr, xml.get()); + + auto* savedNodeElement = xml->getChildByName ("nodes")->getChildByName ("node"); + ASSERT_NE (nullptr, savedNodeElement); + + auto* stateTreeElement = savedNodeElement->getChildByName ("stateTree"); + ASSERT_NE (nullptr, stateTreeElement); + auto* stateElement = stateTreeElement->getFirstChildElement(); + ASSERT_NE (nullptr, stateElement); + + MemoryBlock xmlBackedState; + MemoryOutputStream stateStream (xmlBackedState, false); + stateElement->writeTo (stateStream); + stateStream.flush(); + + savedNodeElement->removeChildElement (stateTreeElement, true); + + auto* legacyStateElement = new XmlElement ("state"); + legacyStateElement->setAttribute ("encoding", "base64"); + legacyStateElement->addTextElement (Base64::toBase64 (xmlBackedState.getData(), xmlBackedState.getSize())); + savedNodeElement->addChildElement (legacyStateElement); + + auto destinationModel = std::make_shared(); + AudioGraphProcessor destination (destinationModel); + destinationModel->setNodeFactory (treeStateFactory()); + + ASSERT_TRUE (destination.loadStateFromMemory (memoryBlockFromXml (*xml)).wasOk()); + + auto* restored = dynamic_cast (destinationModel->getNodeProcessor (node)); + ASSERT_NE (nullptr, restored); + EXPECT_FLOAT_EQ (0.5f, restored->getGain()); +} + +TEST (AudioGraphProcessorTests, DataTreeProcessorStateTreeLoadFailureIsNotBinaryFallback) +{ + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + + model->addNode (std::make_unique (0.5f), treeStateProperties()); + ASSERT_TRUE (graph.commitChanges().wasOk()); + + MemoryBlock savedState; + ASSERT_TRUE (graph.saveStateIntoMemory (savedState).wasOk()); + + auto xml = parseMemoryBlockAsXml (savedState); + ASSERT_NE (nullptr, xml.get()); + + auto* stateElement = xml->getChildByName ("nodes") + ->getChildByName ("node") + ->getChildByName ("stateTree") + ->getFirstChildElement(); + ASSERT_NE (nullptr, stateElement); + stateElement->setTagName ("WrongState"); + + auto destinationModel = std::make_shared(); + AudioGraphProcessor destination (destinationModel); + destinationModel->setNodeFactory (treeStateFactory()); + + EXPECT_TRUE (destination.loadStateFromMemory (memoryBlockFromXml (*xml)).failed()); +} + TEST (AudioGraphProcessorTests, LoadStateRecreatesProcessorNodesWithFactory) { auto sourceModel = std::make_shared(); diff --git a/tests/yup_audio_plugin_host/yup_AudioPluginInstance.cpp b/tests/yup_audio_plugin_host/yup_AudioPluginInstance.cpp index d230070f..ed2e65b2 100644 --- a/tests/yup_audio_plugin_host/yup_AudioPluginInstance.cpp +++ b/tests/yup_audio_plugin_host/yup_AudioPluginInstance.cpp @@ -83,8 +83,8 @@ class CountOnlyPluginInstance : public AudioPluginInstance public: CountOnlyPluginInstance() : AudioPluginInstance (makeDescription(), - AudioBusLayout ({ AudioBus ("MIDI Input", AudioBus::Type::MIDI, AudioBus::Direction::Input, 1) }, - { AudioBus ("MIDI Output", AudioBus::Type::MIDI, AudioBus::Direction::Output, 1) })) + AudioBusLayout ({ AudioBus ("MIDI Input", AudioBus::Type::Midi, AudioBus::Direction::Input, 1) }, + { AudioBus ("MIDI Output", AudioBus::Type::Midi, AudioBus::Direction::Output, 1) })) { } diff --git a/tests/yup_audio_processors/yup_AudioParameter.cpp b/tests/yup_audio_processors/yup_AudioParameter.cpp index fa53e973..6e14302d 100644 --- a/tests/yup_audio_processors/yup_AudioParameter.cpp +++ b/tests/yup_audio_processors/yup_AudioParameter.cpp @@ -121,6 +121,62 @@ class ProcessorListener final : public AudioProcessor::Listener AudioProcessor::ChangeDetails lastDetails; }; +class DefaultStateAudioProcessor : public AudioProcessor +{ +public: + DefaultStateAudioProcessor() + : AudioProcessor ("Default State", AudioBusLayout ({}, {})) + { + } + + void prepareToPlay (float, int) override {} + + void releaseResources() override {} + + void processBlock (AudioProcessContext& context) override + { + ignoreUnused (context); + } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + String getPresetName (int) const override { return {}; } + + void setPresetName (int, StringRef) override {} + + bool hasEditor() const override { return false; } +}; + +class DataTreeStateAudioProcessor final : public DefaultStateAudioProcessor +{ +public: + bool supportsDataTreeState() const noexcept override { return true; } + + Result loadStateFromDataTree (const DataTree& state) override + { + if (! state.isValid() || state.getType() != stateType) + return Result::fail ("Invalid state"); + + value = static_cast (static_cast (state.getProperty ("value", 0.0))); + return Result::ok(); + } + + Result saveStateIntoDataTree (DataTree& state) override + { + state = DataTree (stateType); + auto transaction = state.beginTransaction(); + transaction.setProperty ("value", value); + return Result::ok(); + } + + const yup::Identifier stateType = "DataTreeStateAudioProcessorState"; + float value = 0.0f; +}; + AudioParameter::Ptr makeParameter (StringRef id, StringRef name) { return AudioParameterBuilder() @@ -131,6 +187,15 @@ AudioParameter::Ptr makeParameter (StringRef id, StringRef name) .build(); } +MemoryBlock memoryBlockFromString (const String& text) +{ + MemoryBlock result; + MemoryOutputStream stream (result, false); + stream << text; + stream.flush(); + return result; +} + } // namespace TEST (AudioParameterTests, UsesIndexAsHostIDByDefault) @@ -150,6 +215,44 @@ TEST (AudioParameterTests, UsesIndexAsHostIDByDefault) EXPECT_EQ (second.get(), processor.getParameterByHostID (1u).get()); } +TEST (AudioProcessorStateTests, DefaultBinaryStateFailsWithoutDataTreeSupport) +{ + DefaultStateAudioProcessor processor; + MemoryBlock state; + + EXPECT_FALSE (processor.supportsDataTreeState()); + EXPECT_TRUE (processor.saveStateIntoMemory (state).failed()); + EXPECT_TRUE (processor.loadStateFromMemory (state).failed()); +} + +TEST (AudioProcessorStateTests, DataTreeStateRoundTripsThroughBinaryXml) +{ + DataTreeStateAudioProcessor source; + source.value = 12.5f; + + MemoryBlock state; + ASSERT_TRUE (source.saveStateIntoMemory (state).wasOk()); + EXPECT_FALSE (state.isEmpty()); + + MemoryInputStream stream (state, false); + auto xml = parseXML (stream.readEntireStreamAsString()); + ASSERT_NE (nullptr, xml.get()); + EXPECT_TRUE (xml->hasTagName (source.stateType)); + EXPECT_DOUBLE_EQ (12.5, xml->getDoubleAttribute ("value")); + + DataTreeStateAudioProcessor destination; + ASSERT_TRUE (destination.loadStateFromMemory (state).wasOk()); + EXPECT_FLOAT_EQ (12.5f, destination.value); +} + +TEST (AudioProcessorStateTests, DataTreeBinaryStateRejectsInvalidXml) +{ + DataTreeStateAudioProcessor processor; + + EXPECT_TRUE (processor.loadStateFromMemory (memoryBlockFromString ("not xml")).failed()); + EXPECT_TRUE (processor.loadStateFromMemory (memoryBlockFromString ("")).failed()); +} + TEST (AudioParameterTests, UsesExplicitStableHostIDWhenProvided) { TestAudioProcessor processor; @@ -429,9 +532,9 @@ TEST (AudioProcessorTests, CountsOnlyAudioBuses) { TestAudioProcessor processor (AudioBusLayout ( { AudioBus ("Audio In", AudioBus::Type::Audio, AudioBus::Direction::Input, 2), - AudioBus ("MIDI In", AudioBus::Type::MIDI, AudioBus::Direction::Input, 0) }, + AudioBus ("MIDI In", AudioBus::Type::Midi, AudioBus::Direction::Input, 0) }, { AudioBus ("Audio Out", AudioBus::Type::Audio, AudioBus::Direction::Output, 2), - AudioBus ("MIDI Out", AudioBus::Type::MIDI, AudioBus::Direction::Output, 0) })); + AudioBus ("MIDI Out", AudioBus::Type::Midi, AudioBus::Direction::Output, 0) })); EXPECT_EQ (1, processor.getNumAudioInputs()); EXPECT_EQ (1, processor.getNumAudioOutputs()); diff --git a/tests/yup_dsp.cpp b/tests/yup_dsp.cpp index eb9308e0..8a061332 100644 --- a/tests/yup_dsp.cpp +++ b/tests/yup_dsp.cpp @@ -29,6 +29,7 @@ #include "yup_dsp/yup_FFTProcessor.cpp" #include "yup_dsp/yup_FilterDesigner.cpp" #include "yup_dsp/yup_FirstOrderFilter.cpp" +#include "yup_dsp/yup_FractionallyAddressedDelay.cpp" #include "yup_dsp/yup_KMeterState.cpp" #include "yup_dsp/yup_LevelProcessor.cpp" #include "yup_dsp/yup_LinkwitzRileyFilter.cpp" @@ -41,6 +42,7 @@ #include "yup_dsp/yup_SincTable.cpp" #include "yup_dsp/yup_SoftClipper.cpp" #include "yup_dsp/yup_BlunterClipper.cpp" +#include "yup_dsp/yup_AaIirAntialiaser.cpp" #include "yup_dsp/yup_SpectrumAnalyzerState.cpp" #include "yup_dsp/yup_StateVariableFilter.cpp" #include "yup_dsp/yup_WindowFunctions.cpp" diff --git a/tests/yup_dsp/yup_AaIirAntialiaser.cpp b/tests/yup_dsp/yup_AaIirAntialiaser.cpp new file mode 100644 index 00000000..0375fba1 --- /dev/null +++ b/tests/yup_dsp/yup_AaIirAntialiaser.cpp @@ -0,0 +1,438 @@ +/* + ============================================================================== + + 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. + + ============================================================================== +*/ + +#include "yup_core/yup_core.h" +#include "yup_dsp/yup_dsp.h" + +#include + +#include +#include +#include + +template +class AaIirAntialiaserTests : public ::testing::Test +{ +public: + using Clipper = yup::HardClipper; + using Config = typename Clipper::PoleConfig; + + static constexpr double kSampleRate = 44100.0; + static constexpr int kBlockSize = 512; + static constexpr FloatType kTolerance = FloatType (1e-6); + + Clipper clipper; + + void SetUp() override + { + clipper = Clipper {}; + clipper.prepare (kSampleRate, kBlockSize); + } + + //============================================================================== + void testHardClipperTraitsIdentity() + { + EXPECT_NEAR (yup::HardClipperTraits::f (FloatType (0.5)), FloatType (0.5), kTolerance); + EXPECT_NEAR (yup::HardClipperTraits::f (FloatType (-0.3)), FloatType (-0.3), kTolerance); + EXPECT_NEAR (yup::HardClipperTraits::f (FloatType (0.0)), FloatType (0.0), kTolerance); + EXPECT_NEAR (yup::HardClipperTraits::f (FloatType (1.0)), FloatType (1.0), kTolerance); + EXPECT_NEAR (yup::HardClipperTraits::f (FloatType (-1.0)), FloatType (-1.0), kTolerance); + } + + void testHardClipperTraitsSaturation() + { + EXPECT_NEAR (yup::HardClipperTraits::f (FloatType (2.0)), FloatType (1.0), kTolerance); + EXPECT_NEAR (yup::HardClipperTraits::f (FloatType (-1.5)), FloatType (-1.0), kTolerance); + EXPECT_NEAR (yup::HardClipperTraits::f (FloatType (10.0)), FloatType (1.0), kTolerance); + } + + void testDefaultButterworthConfig() + { + const auto config = Clipper::makeButterworthOrder2(); + + EXPECT_EQ (config.complexPairs.size(), std::size_t (1)); + EXPECT_EQ (config.realPoles.size(), std::size_t (0)); + + // Poles at wc * (-1 +/- j) / sqrt(2), wc = 2*pi*0.45 + const auto wc = FloatType (2) * yup::MathConstants::pi * FloatType (0.45); + const auto sq = std::sqrt (FloatType (2)); + + const auto& pair = config.complexPairs[0]; + EXPECT_NEAR (pair.poleReal, -wc / sq, FloatType (1e-5)); + EXPECT_NEAR (pair.poleImag, +wc / sq, FloatType (1e-5)); + EXPECT_NEAR (pair.residueReal, FloatType (0), FloatType (1e-5)); + EXPECT_NEAR (pair.residueImag, -wc / sq, FloatType (1e-5)); + } + + void testResetClearsState() + { + // Push a large signal through to build up significant IIR state. + for (int i = 0; i < 200; ++i) + clipper.processSample (FloatType (5.0) * std::sin (FloatType (i) * FloatType (0.1))); + + clipper.reset(); + + // After reset the state is zero, so the first output must equal what a + // fresh instance produces for the same input — verify by comparing to + // an independent freshly-prepared clipper. + Clipper fresh; + fresh.prepare (kSampleRate, kBlockSize); + + const FloatType testInput = FloatType (0.1); + const FloatType afterReset = clipper.processSample (testInput); + const FloatType fromFresh = fresh.processSample (testInput); + + EXPECT_NEAR (afterReset, fromFresh, kTolerance); + } + + void testLowAmplitudeIsApproximatelyLinear() + { + // For |x| << 1, f(x) = x so the antialiased output should closely track the input. + // The IIR filter introduces phase shift but not amplitude error at DC. + // Feed a DC value and wait for the IIR to settle, then check convergence. + const FloatType dc = FloatType (0.1); + + for (int i = 0; i < 2000; ++i) + clipper.processSample (dc); + + // After settling the output should be close to dc (the filter has unity DC gain + // for a proper LP filter, and the hard clipper is linear for |x| < 1). + const FloatType out = clipper.processSample (dc); + EXPECT_NEAR (out, dc, FloatType (0.02)); + } + + void testOutputIsBoundedForLargeInput() + { + // The antialiased output should remain bounded even for very large inputs. + bool bounded = true; + for (int i = 0; i < 500; ++i) + { + const FloatType x = FloatType (10) * std::sin (FloatType (i) * FloatType (0.3)); + const FloatType out = clipper.processSample (x); + if (std::abs (out) > FloatType (2)) + bounded = false; + } + EXPECT_TRUE (bounded); + } + + void testProcessBlockMatchesProcessSample() + { + // Both paths must produce bit-identical results. + constexpr int N = 64; + FloatType input[N], outBlock[N], outSample[N]; + + for (int i = 0; i < N; ++i) + input[i] = FloatType (0.3) * std::sin (FloatType (i) * FloatType (0.2)); + + clipper.reset(); + clipper.processBlock (input, outBlock, N); + + clipper.reset(); + for (int i = 0; i < N; ++i) + outSample[i] = clipper.processSample (input[i]); + + for (int i = 0; i < N; ++i) + EXPECT_EQ (outBlock[i], outSample[i]); + } + + void testProcessInPlaceMatchesProcessBlock() + { + constexpr int N = 32; + FloatType input[N], outBlock[N], outInPlace[N]; + + for (int i = 0; i < N; ++i) + input[i] = FloatType (0.5) * std::sin (FloatType (i) * FloatType (0.15)); + + clipper.reset(); + clipper.processBlock (input, outBlock, N); + + clipper.reset(); + std::copy (input, input + N, outInPlace); + clipper.processInPlace (outInPlace, N); + + for (int i = 0; i < N; ++i) + EXPECT_EQ (outInPlace[i], outBlock[i]); + } + + void testChebyshevTypeIIOrder10Config() + { + const auto config = Clipper::makeChebyshevTypeIIOrder10(); + + // 10th-order → 5 complex conjugate pairs, no real poles. + EXPECT_EQ (config.complexPairs.size(), std::size_t (5)); + EXPECT_EQ (config.realPoles.size(), std::size_t (0)); + + // All poles must be stable (Re < 0) and upper-half-plane (Im > 0). + for (const auto& pair : config.complexPairs) + { + EXPECT_LT (pair.poleReal, FloatType (0)); + EXPECT_GT (pair.poleImag, FloatType (0)); + } + + // Constant term must be positive (it equals the biproper gain K). + EXPECT_GT (config.constantTerm, FloatType (0)); + } + + void testChebyshevTypeIIOrder10ConvergesToDC() + { + // Feed a low-amplitude DC signal through the Chebyshev II config and verify + // that the output converges to the input (unity DC gain, linear region). + Clipper cheb (Clipper::makeChebyshevTypeIIOrder10()); + cheb.prepare (kSampleRate, kBlockSize); + + const FloatType dc = FloatType (0.1); + + for (int i = 0; i < 5000; ++i) + cheb.processSample (dc); + + const FloatType out = cheb.processSample (dc); + EXPECT_NEAR (out, dc, FloatType (0.05)); + } + + void testChebyshevTypeIIOrder10BoundedOutput() + { + // With large input the output must remain finite and bounded. + Clipper cheb (Clipper::makeChebyshevTypeIIOrder10()); + cheb.prepare (kSampleRate, kBlockSize); + + bool bounded = true; + for (int i = 0; i < 500; ++i) + { + const FloatType x = FloatType (10) * std::sin (FloatType (i) * FloatType (0.3)); + const FloatType out = cheb.processSample (x); + if (! std::isfinite (static_cast (out)) || std::abs (out) > FloatType (3)) + bounded = false; + } + EXPECT_TRUE (bounded); + } + + void testCustomPoleConfigWorks() + { + // A first-order real-pole configuration (simple RC lowpass). + // pole = -pi (cutoff at 0.5*Fs), residue = pi (unity DC gain: A/(-alpha) = 1). + Config config; + typename Config::RealPole rp; + rp.pole = FloatType (-yup::MathConstants::pi); + rp.residue = FloatType (yup::MathConstants::pi); + config.realPoles.push_back (rp); + + Clipper custom (config); + custom.prepare (kSampleRate, kBlockSize); + + // Should not crash and should produce bounded output. + for (int i = 0; i < 100; ++i) + { + const FloatType out = custom.processSample (FloatType (0.5) * std::sin (FloatType (i) * FloatType (0.1))); + EXPECT_TRUE (std::isfinite (static_cast (out))); + } + } +}; + +using TestTypes = ::testing::Types; +TYPED_TEST_SUITE (AaIirAntialiaserTests, TestTypes); + +TYPED_TEST (AaIirAntialiaserTests, HardClipperTraitsIdentity) { this->testHardClipperTraitsIdentity(); } + +TYPED_TEST (AaIirAntialiaserTests, HardClipperTraitsSaturation) { this->testHardClipperTraitsSaturation(); } + +TYPED_TEST (AaIirAntialiaserTests, DefaultButterworthConfig) { this->testDefaultButterworthConfig(); } + +TYPED_TEST (AaIirAntialiaserTests, ResetClearsState) { this->testResetClearsState(); } + +TYPED_TEST (AaIirAntialiaserTests, LowAmplitudeIsApproximatelyLinear) { this->testLowAmplitudeIsApproximatelyLinear(); } + +TYPED_TEST (AaIirAntialiaserTests, OutputIsBoundedForLargeInput) { this->testOutputIsBoundedForLargeInput(); } + +TYPED_TEST (AaIirAntialiaserTests, ProcessBlockMatchesProcessSample) { this->testProcessBlockMatchesProcessSample(); } + +TYPED_TEST (AaIirAntialiaserTests, ProcessInPlaceMatchesProcessBlock) { this->testProcessInPlaceMatchesProcessBlock(); } + +TYPED_TEST (AaIirAntialiaserTests, ChebyshevTypeIIOrder10Config) { this->testChebyshevTypeIIOrder10Config(); } + +TYPED_TEST (AaIirAntialiaserTests, ChebyshevTypeIIOrder10ConvergesToDC) { this->testChebyshevTypeIIOrder10ConvergesToDC(); } + +TYPED_TEST (AaIirAntialiaserTests, ChebyshevTypeIIOrder10BoundedOutput) { this->testChebyshevTypeIIOrder10BoundedOutput(); } + +TYPED_TEST (AaIirAntialiaserTests, CustomPoleConfigWorks) { this->testCustomPoleConfigWorks(); } + +//============================================================================== +// Mathematical correctness: verify the mean-integral recursion against the +// closed-form from Appendix B of the paper (f(x)=x, first-order real-pole LP). +// These tests are not templated — they require double precision. + +namespace +{ + +// Identity nonlinearity — no breakpoints, no clipping. Used to isolate +// the integral computation from the nonlinear function's piecewise structure. +struct LinearTraits +{ + template + static T f (T x) noexcept + { + return x; + } +}; + +// Compute Goertzel power estimate at a single frequency for a block of samples. +// Equivalent to |DFT[k]|^2 / N^2 but at one bin, in O(N) time. +double goertzelPower (const std::vector& signal, double freq, double sampleRate) +{ + const int N = static_cast (signal.size()); + const double omega = 2.0 * yup::MathConstants::pi * freq / sampleRate; + const double coeff = 2.0 * std::cos (omega); + double s0 = 0.0, s1 = 0.0, s2 = 0.0; + + for (double x : signal) + { + s0 = x + coeff * s1 - s2; + s2 = s1; + s1 = s0; + } + + return (s1 * s1 + s2 * s2 - coeff * s1 * s2) / (static_cast (N) * N); +} + +} // namespace + +// --- Appendix B ground-truth test ------------------------------------------- + +TEST (AaIirCorrectnessTests, LinearFunctionMatchesAppendixBFormula) +{ + // Appendix B derives the exact recursion for f(x)=x with H(s)=-α/(s-α): + // + // y_{n+1} = e^α · y_n + // − ((e^α − α − 1) / α) · x_{n+1} + // − ((α − 1)·e^α + 1) / α · x_n + // + // Verify that our AA-IIR implementation reproduces this formula exactly. + + using LinearClipper = yup::AaIirAntialiaser; + + // First-order LP: H(s) = -α/(s-α), pole α=-1, residue A=-α=1, H(0)=1. + LinearClipper::PoleConfig config; + { + typename LinearClipper::PoleConfig::RealPole rp; + rp.pole = -1.0; + rp.residue = 1.0; // -alpha = 1.0 + config.realPoles.push_back (rp); + } + + LinearClipper aaIir (config); + aaIir.prepare (44100.0, 512); + + const double alpha = -1.0; + const double eA = std::exp (alpha); // e^{-1} + const double k1 = (eA - alpha - 1.0) / alpha; // coeff for x_{n+1} + const double k2 = ((alpha - 1.0) * eA + 1.0) / alpha; // coeff for x_n + + const double inputs[] = { 0.3, -0.5, 0.7, 0.1, -0.4, 0.2, 0.6, -0.8, 0.0, 0.9 }; + + double yRef = 0.0; + double xPrev = 0.0; + + for (double xCurr : inputs) + { + const double yComputed = aaIir.processSample (xCurr); + yRef = eA * yRef - k1 * xCurr - k2 * xPrev; + EXPECT_NEAR (yComputed, yRef, 1e-10); + xPrev = xCurr; + } +} + +// --- Custom nonlinearity test ------------------------------------------------ + +TEST (AaIirCorrectnessTests, TanhTraitsRequiresNoBreakpoints) +{ + // TanhClipperTraits is smooth — it must work without fillBreakpoints. + // Verify it compiles, produces bounded output, and converges to tanh(dc) at DC. + using TanhClipper = yup::AaIirAntialiaser; + + TanhClipper clipper (TanhClipper::makeChebyshevTypeIIOrder10()); + clipper.prepare (44100.0, 512); + + const double dc = 2.0; // well into saturation: tanh(2) ≈ 0.964 + + for (int i = 0; i < 5000; ++i) + clipper.processSample (dc); + + const double out = clipper.processSample (dc); + EXPECT_NEAR (out, std::tanh (dc), 0.05); +} + +// --- SNR quality comparison -------------------------------------------------- + +TEST (AaIirQualityTests, ButterworthReducesAliasingVsTrivialClipper) +{ + // Feed a 1 kHz sine at gain 10 (heavy clipping). The trivial clipper generates + // odd harmonics that fold above Nyquist and alias back into the audible band. + // For example, the 23 kHz harmonic aliases to 44100-23000 = 21100 Hz — a + // frequency that is NOT an odd harmonic of 1 kHz. + // + // Measure the power at 21100 Hz (a known aliasing target). + // AA-IIR-1 should have significantly less alias power there. + + constexpr double Fs = 44100.0; + constexpr double f0 = 1000.0; + constexpr double gain = 10.0; + constexpr int N = 8192; + constexpr int warmup = 2000; + constexpr double aliasHz = 21100.0; // 44100 - 23000 (alias of 23rd harmonic) + + std::vector input (N + warmup); + for (int i = 0; i < N + warmup; ++i) + input[i] = gain * std::sin (2.0 * yup::MathConstants::pi * f0 * i / Fs); + + // Trivial hard clipper + std::vector trivialOut (N); + for (int i = 0; i < N + warmup; ++i) + { + const double y = std::clamp (input[i], -1.0, 1.0); + if (i >= warmup) + trivialOut[i - warmup] = y; + } + + // AA-IIR-1 (Butterworth order 2) + yup::HardClipperDouble aaIir1 (yup::HardClipperDouble::makeButterworthOrder2()); + aaIir1.prepare (Fs, N); + std::vector aaIir1Out (N); + for (int i = 0; i < warmup; ++i) + aaIir1.processSample (input[i]); + for (int i = 0; i < N; ++i) + aaIir1Out[i] = aaIir1.processSample (input[i + warmup]); + + // AA-IIR-2 (Chebyshev II order 10) + yup::HardClipperDouble aaIir2 (yup::HardClipperDouble::makeChebyshevTypeIIOrder10()); + aaIir2.prepare (Fs, N); + std::vector aaIir2Out (N); + for (int i = 0; i < warmup; ++i) + aaIir2.processSample (input[i]); + for (int i = 0; i < N; ++i) + aaIir2Out[i] = aaIir2.processSample (input[i + warmup]); + + const double trivialAlias = goertzelPower (trivialOut, aliasHz, Fs); + const double aaIir1Alias = goertzelPower (aaIir1Out, aliasHz, Fs); + const double aaIir2Alias = goertzelPower (aaIir2Out, aliasHz, Fs); + + // Both AA-IIR methods must have less aliasing power than trivial clipping. + EXPECT_LT (aaIir1Alias, trivialAlias) << "AA-IIR-1 should reduce aliasing at " << aliasHz << " Hz"; + EXPECT_LT (aaIir2Alias, aaIir1Alias) << "AA-IIR-2 should reduce aliasing more than AA-IIR-1"; +} diff --git a/tests/yup_dsp/yup_FractionallyAddressedDelay.cpp b/tests/yup_dsp/yup_FractionallyAddressedDelay.cpp new file mode 100644 index 00000000..1088b3bb --- /dev/null +++ b/tests/yup_dsp/yup_FractionallyAddressedDelay.cpp @@ -0,0 +1,266 @@ +/* + ============================================================================== + + 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. + + ============================================================================== +*/ + +#include "yup_core/yup_core.h" +#include "yup_dsp/yup_dsp.h" + +#include + +template +class FractionallyAddressedDelayTests : public ::testing::Test +{ +public: + using FAD = yup::FractionallyAddressedDelay; +}; + +using TestTypes = ::testing::Types; +TYPED_TEST_SUITE (FractionallyAddressedDelayTests, TestTypes); + +// --- Construction and parameter accessors --- + +TYPED_TEST (FractionallyAddressedDelayTests, DefaultConstruction) +{ + using FAD = yup::FractionallyAddressedDelay; + FAD fad; + EXPECT_EQ (fad.getBufferSize(), 0); + EXPECT_NEAR (fad.getDelaySamples(), TypeParam (0), TypeParam (1e-6)); +} + +TYPED_TEST (FractionallyAddressedDelayTests, SetMaxDelaySamplesAllocatesPowerOfTwo) +{ + using FAD = yup::FractionallyAddressedDelay; + FAD fad; + fad.setMaxDelaySamples (100); + EXPECT_GE (fad.getBufferSize(), 100); + const int sz = fad.getBufferSize(); + EXPECT_EQ (sz & (sz - 1), 0); +} + +TYPED_TEST (FractionallyAddressedDelayTests, SetDelaySamplesRoundtrip) +{ + using FAD = yup::FractionallyAddressedDelay; + FAD fad; + fad.setMaxDelaySamples (128); + fad.setDelaySamples (TypeParam (64)); + EXPECT_NEAR (fad.getDelaySamples(), TypeParam (64), TypeParam (0.5)); +} + +TYPED_TEST (FractionallyAddressedDelayTests, SetDelaySamplesClampsToMinimum) +{ + using FAD = yup::FractionallyAddressedDelay; + FAD fad; + fad.setMaxDelaySamples (128); + fad.setDelaySamples (TypeParam (0)); + EXPECT_GT (fad.getDelaySamples(), TypeParam (0)); +} + +// --- Silence on empty buffer --- + +TYPED_TEST (FractionallyAddressedDelayTests, OutputsSilenceBeforeAnyInput) +{ + using FAD = yup::FractionallyAddressedDelay; + FAD fad; + fad.setMaxDelaySamples (64); + fad.setDelaySamples (TypeParam (16)); + for (int i = 0; i < 10; ++i) + EXPECT_NEAR (fad.processSample (TypeParam (0)), TypeParam (0), TypeParam (1e-6)); +} + +// --- Integer delay accuracy --- + +TYPED_TEST (FractionallyAddressedDelayTests, IntegerDelayProducesImpulseAtCorrectOffset) +{ + using FAD = yup::FractionallyAddressedDelay; + FAD fad; + constexpr int bufSize = 16; + fad.setMaxDelaySamples (bufSize); + fad.setDelaySamples (TypeParam (bufSize)); + + std::vector input (bufSize * 2, TypeParam (0)); + input[0] = TypeParam (1); + + std::vector output (bufSize * 2); + for (int i = 0; i < bufSize * 2; ++i) + output[i] = fad.processSample (input[i]); + + EXPECT_NEAR (output[bufSize], TypeParam (1), TypeParam (0.05)); + + for (int i = 0; i < bufSize; ++i) + EXPECT_NEAR (output[i], TypeParam (0), TypeParam (0.05)); +} + +// --- Half-speed delay (increment = 0.5) --- + +TYPED_TEST (FractionallyAddressedDelayTests, HalfSpeedDelayOutputsSlowedSignal) +{ + using FAD = yup::FractionallyAddressedDelay; + FAD fad; + constexpr int bufSize = 32; + fad.setMaxDelaySamples (bufSize); + fad.setDelaySamples (TypeParam (bufSize * 2)); + + const int totalSamples = bufSize * 4; + std::vector output (totalSamples); + for (int i = 0; i < totalSamples; ++i) + output[i] = fad.processSample (TypeParam (1)); + + for (int i = 0; i < totalSamples; ++i) + { + EXPECT_TRUE (std::isfinite (output[i])); + EXPECT_LE (std::abs (output[i]), TypeParam (1) + TypeParam (1e-4)); + } +} + +// --- DC pass-through (steady state) --- + +TYPED_TEST (FractionallyAddressedDelayTests, DCInputReachesSteadyState) +{ + using FAD = yup::FractionallyAddressedDelay; + FAD fad; + constexpr int bufSize = 64; + fad.setMaxDelaySamples (bufSize); + fad.setDelaySamples (TypeParam (bufSize / 2)); + + for (int i = 0; i < bufSize * 3; ++i) + fad.processSample (TypeParam (1)); + + for (int i = 0; i < 16; ++i) + EXPECT_NEAR (fad.processSample (TypeParam (1)), TypeParam (1), TypeParam (0.02)); +} + +// --- Reset clears state --- + +TYPED_TEST (FractionallyAddressedDelayTests, ResetClearsBufferAndPhase) +{ + using FAD = yup::FractionallyAddressedDelay; + FAD fad; + fad.setMaxDelaySamples (64); + fad.setDelaySamples (TypeParam (32)); + + for (int i = 0; i < 64; ++i) + fad.processSample (TypeParam (1)); + + fad.reset(); + + for (int i = 0; i < 10; ++i) + EXPECT_NEAR (fad.processSample (TypeParam (0)), TypeParam (0), TypeParam (1e-6)); +} + +// --- prepare() is a no-op but must be callable --- + +TYPED_TEST (FractionallyAddressedDelayTests, PrepareDoesNotThrow) +{ + using FAD = yup::FractionallyAddressedDelay; + FAD fad; + fad.setMaxDelaySamples (64); + fad.setDelaySamples (TypeParam (32)); + EXPECT_NO_THROW (fad.prepare (44100.0, 512)); +} + +// --- processBlock matches processSample --- + +TYPED_TEST (FractionallyAddressedDelayTests, ProcessBlockMatchesProcessSample) +{ + using FAD = yup::FractionallyAddressedDelay; + constexpr int N = 128; + std::vector input (N); + for (int i = 0; i < N; ++i) + input[i] = static_cast (i % 8) / TypeParam (8); + + FAD fadA, fadB; + fadA.setMaxDelaySamples (64); + fadA.setDelaySamples (TypeParam (32)); + fadB.setMaxDelaySamples (64); + fadB.setDelaySamples (TypeParam (32)); + + std::vector outA (N), outB (N); + + for (int i = 0; i < N; ++i) + outA[i] = fadA.processSample (input[i]); + + fadB.processBlock (input.data(), outB.data(), N); + + for (int i = 0; i < N; ++i) + EXPECT_NEAR (outA[i], outB[i], TypeParam (1e-6)); +} + +// --- processInPlace matches processBlock --- + +TYPED_TEST (FractionallyAddressedDelayTests, ProcessInPlaceMatchesProcessBlock) +{ + using FAD = yup::FractionallyAddressedDelay; + constexpr int N = 64; + std::vector input (N); + for (int i = 0; i < N; ++i) + input[i] = static_cast (i % 4) / TypeParam (4); + + FAD fadA, fadB; + fadA.setMaxDelaySamples (64); + fadA.setDelaySamples (TypeParam (32)); + fadB.setMaxDelaySamples (64); + fadB.setDelaySamples (TypeParam (32)); + + std::vector outBlock (N); + fadA.processBlock (input.data(), outBlock.data(), N); + + std::vector inPlace = input; + fadB.processInPlace (inPlace.data(), N); + + for (int i = 0; i < N; ++i) + EXPECT_NEAR (outBlock[i], inPlace[i], TypeParam (1e-6)); +} + +// --- Output is always finite --- + +TYPED_TEST (FractionallyAddressedDelayTests, OutputIsAlwaysFiniteForBoundedInput) +{ + using FAD = yup::FractionallyAddressedDelay; + FAD fad; + fad.setMaxDelaySamples (128); + fad.setDelaySamples (TypeParam (75.7)); + + for (int i = 0; i < 512; ++i) + { + const TypeParam x = (i % 2 == 0) ? TypeParam (0.9) : TypeParam (-0.9); + EXPECT_TRUE (std::isfinite (fad.processSample (x))); + } +} + +// --- Modulation: changing delay mid-stream stays finite --- + +TYPED_TEST (FractionallyAddressedDelayTests, DelayModulationStaysFinite) +{ + using FAD = yup::FractionallyAddressedDelay; + FAD fad; + fad.setMaxDelaySamples (256); + fad.setDelaySamples (TypeParam (128)); + + bool anyFiniteViolation = false; + for (int i = 0; i < 1024; ++i) + { + const TypeParam delayMod = TypeParam (128) + TypeParam (64) * std::sin (TypeParam (i) * TypeParam (0.01)); + fad.setDelaySamples (delayMod); + const TypeParam out = fad.processSample (TypeParam (0.5)); + if (! std::isfinite (out)) + anyFiniteViolation = true; + } + EXPECT_FALSE (anyFiniteViolation); +} diff --git a/tests/yup_dsp/yup_Oversampler.cpp b/tests/yup_dsp/yup_Oversampler.cpp index 53699a77..591c849c 100644 --- a/tests/yup_dsp/yup_Oversampler.cpp +++ b/tests/yup_dsp/yup_Oversampler.cpp @@ -128,9 +128,10 @@ TEST_F (OversamplerTest, UpsampleDCSignalHasCorrectMagnitude) TEST_F (OversamplerTest, ProcessOversampledBlockCallbackReceivesCorrectSize) { - std::vector ch0 (blockSize, 0.0f), ch1 (blockSize, 0.0f); - const float* inputPtrs[] = { ch0.data(), ch1.data() }; - os2x.upsample (inputPtrs, 2, blockSize); + constexpr int shortBlockSize = 64; + std::vector ch0 (shortBlockSize, 0.0f); + const float* inputPtrs[] = { ch0.data() }; + os2x.upsample (inputPtrs, 1, shortBlockSize); int callbackChannels = 0; int callbackSamples = 0; @@ -140,8 +141,56 @@ TEST_F (OversamplerTest, ProcessOversampledBlockCallbackReceivesCorrectSize) callbackSamples = buf.getNumSamples(); }); - EXPECT_EQ (callbackChannels, maxChannels); - EXPECT_EQ (callbackSamples, blockSize * 2); + EXPECT_EQ (callbackChannels, 1); + EXPECT_EQ (callbackSamples, shortBlockSize * 2); +} + +TEST_F (OversamplerTest, ProcessOversampledBlockReceivesEmptyBufferWithoutPendingBlock) +{ + int callbackChannels = -1; + int callbackSamples = -1; + + os2x.processOversampledBlock ([&] (auto& buf) + { + callbackChannels = buf.getNumChannels(); + callbackSamples = buf.getNumSamples(); + }); + + EXPECT_EQ (callbackChannels, 0); + EXPECT_EQ (callbackSamples, 0); +} + +TEST_F (OversamplerTest, DownsampleConsumesPendingOversampledBlock) +{ + std::vector ch0 (blockSize, 0.0f); + std::vector output (blockSize, 0.0f); + const float* inputPtrs[] = { ch0.data() }; + float* outputPtrs[] = { output.data() }; + + os2x.upsample (inputPtrs, 1, blockSize); + ASSERT_EQ (os2x.getOversampledNumSamples(), blockSize * 2); + + os2x.downsample (outputPtrs, 1, blockSize); + + EXPECT_EQ (os2x.getOversampledNumSamples(), 0); + EXPECT_EQ (os2x.getOversampledChannelData (0), nullptr); +} + +TEST_F (OversamplerTest, UpsampleThenDownsamplePreservesDCMagnitude) +{ + constexpr float dcValue = 0.5f; + std::vector input (blockSize, dcValue); + std::vector output (blockSize, 0.0f); + const float* inputPtrs[] = { input.data() }; + float* outputPtrs[] = { output.data() }; + + for (int b = 0; b < 10; ++b) + { + os2x.upsample (inputPtrs, 1, blockSize); + os2x.downsample (outputPtrs, 1, blockSize); + } + + EXPECT_NEAR (calculateRMS (output.data(), blockSize), dcValue, 0.02f); } TEST_F (OversamplerTest, UpsampleThenDownsamplePreservesLowFrequencySine)