diff --git a/.gitignore b/.gitignore index d38a0f660..8e7b07be9 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ out/* # Ides/Agents __pycache__/ .juggler/ +.reasonix/ .vscode/ .idea/ .vs/ @@ -51,4 +52,3 @@ __pycache__/ .pytest_cache/ .cache/ docs/superpowers/ -reasonix.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index ad30c5d2d..456a5b9eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - SDL3 windowing: mouse move/drag was broken on touch platforms (iOS, Android). Motion was synthesized only by polling `SDL_GetGlobalMouseState`, which has no backend implementation there and falls back to window-relative coordinates, so subtracting the window position shifted every move. Touch platforms now consume the touch-synthesized `SDL_EVENT_MOUSE_MOTION` events directly; desktop keeps the global-cursor poll (needed for embedded plugin editors). - SDL3 windowing: mouse drag events were lost inside embedded plugin editors (notably on macOS, where the host owns the native application so SDL never receives Cocoa mouse focus and suppresses drag motion). Dragging is now synthesized by polling the global cursor while a button is held, on the message thread, for all platforms. - UBSAN and ASAN fixes throughout the codebase +- AUv3 plugin host bypass is now connected to the processor: the wrapper-owned bypass parameter is created and drives `processBlockBypassed`, and host bypass state is persisted/restored inside the `YUPProcessorState` blob (legacy raw processor state still loads) +- Added bypass parameter handling tests for the AU, CLAP, and VST3 plugin client wrappers (routing to `processBlockBypassed`, bypass state round-trip, and text/value conversion) --- @@ -229,6 +231,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Standalone plugin support with improved audio parameters ([#46](https://github.com/kunitoki/yup/pull/46)) - CLAP/VST3/AU validators and code signing (`YUP_ENABLE_VST3_VALIDATOR`, etc.) ([#106](https://github.com/kunitoki/yup/pull/106)) - pluginval integration for automated VST3 validation ([#67](https://github.com/kunitoki/yup/pull/67)) +- Sidechain and multi-bus audio input support across VST3, CLAP, AUv3, AUv2, AAX, and LV2: `AudioBus` gains a `Role` (`Main`/`Auxiliary`) and `isDefaultActive`, `AudioProcessContext` exposes per-bus `inputs`/`outputs` views (`AudioBusBufferView`) with `getMainInput()`/`getAuxiliaryInput()`/`getMainOutput()` accessors, and secondary input buses are forwarded to the processor instead of being discarded #### Audio Formats (`yup_audio_formats`) - New `yup_audio_formats` module: `AudioFormat`, `AudioFormatManager`, `AudioFormatReader`, `AudioFormatWriter`, WAV codec ([#51](https://github.com/kunitoki/yup/pull/51)) diff --git a/cmake/platforms/mac/AudioUnitV3ContainerInfo.plist.in b/cmake/platforms/mac/AudioUnitV3ContainerInfo.plist.in index d1830b676..4b814c925 100644 --- a/cmake/platforms/mac/AudioUnitV3ContainerInfo.plist.in +++ b/cmake/platforms/mac/AudioUnitV3ContainerInfo.plist.in @@ -7,7 +7,7 @@ CFBundleExecutable ${MACOSX_BUNDLE_EXECUTABLE_NAME} CFBundleIdentifier - @auv3_container_bundle_identifier@ + ${MACOSX_BUNDLE_GUI_IDENTIFIER} CFBundleInfoDictionaryVersion 6.0 CFBundleName diff --git a/cmake/plugins/yup_plugin_auv3.cmake b/cmake/plugins/yup_plugin_auv3.cmake index e00c8f075..f8a5cedc6 100644 --- a/cmake/plugins/yup_plugin_auv3.cmake +++ b/cmake/plugins/yup_plugin_auv3.cmake @@ -178,6 +178,7 @@ function (yup_plugin_auv3) MACOSX_BUNDLE_INFO_PLIST "${auv3_container_plist_output}" MACOSX_BUNDLE_BUNDLE_NAME "${YUP_ARG_PLUGIN_NAME}" MACOSX_BUNDLE_GUI_IDENTIFIER "${auv3_container_bundle_identifier}" + XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${auv3_container_bundle_identifier}" FOLDER "${target_ide_group}" XCODE_GENERATE_SCHEME ON) diff --git a/examples/plugin/data/RobotoFlex-VariableFont.ttf b/examples/plugin/data/RobotoFlex-VariableFont.ttf deleted file mode 100644 index 2e5c2a26a..000000000 Binary files a/examples/plugin/data/RobotoFlex-VariableFont.ttf and /dev/null differ diff --git a/modules/yup_audio_devices/yup_audio_devices.h b/modules/yup_audio_devices/yup_audio_devices.h index 5324bacf3..a93d6cb91 100644 --- a/modules/yup_audio_devices/yup_audio_devices.h +++ b/modules/yup_audio_devices/yup_audio_devices.h @@ -177,7 +177,7 @@ Enable logging of audio device events on macOS, such as device changes and errors. */ #ifndef YUP_ENABLE_CORE_AUDIO_LOGGING -#define YUP_ENABLE_CORE_AUDIO_LOGGING 1 +#define YUP_ENABLE_CORE_AUDIO_LOGGING 0 #endif //============================================================================== diff --git a/modules/yup_audio_plugin_client/aax/yup_audio_plugin_client_AAX.cpp b/modules/yup_audio_plugin_client/aax/yup_audio_plugin_client_AAX.cpp index 2634a4acd..ac50ebea5 100644 --- a/modules/yup_audio_plugin_client/aax/yup_audio_plugin_client_AAX.cpp +++ b/modules/yup_audio_plugin_client/aax/yup_audio_plugin_client_AAX.cpp @@ -77,7 +77,7 @@ YupPlugin_AAX_PlugInID_Native and YupPlugin_AAX_PlugInID_AudioSuite to be define #endif #endif -extern "C" yup::AudioProcessor* createPluginProcessor(); +extern "C" yup::AudioProcessor* YUP_AUDIO_PLUGIN_CREATE_FUNCTION(); namespace yup { @@ -278,7 +278,7 @@ class YupAAX_Processor YupAAX_Processor() { - processor.reset (createPluginProcessor()); + processor.reset (::YUP_AUDIO_PLUGIN_CREATE_FUNCTION()); processor->addListener (this); AAX_CEffectParameters::GetNumberOfChunks (&yupChunkIndex); @@ -613,6 +613,63 @@ class YupAAX_Processor // Audio processing //========================================================================== + // Builds one AudioBusBufferView per audio input bus from the flat array of + // host-provided input channel buffers. Channels are consumed in bus + // declaration order; channels beyond the host-provided count (e.g. an + // unconnected sidechain) are exposed as null channel pointers so + // processors see deterministic silence. + void buildInputBusViews (float* const* inputChannelData, int numIn) + { + inputBusViews.clear(); + + int chOffset = 0; + + for (const auto& bus : processor->getBusLayout().getInputBuses()) + { + if (bus.getType() != AudioBus::Type::Audio) + continue; + + const int numCh = bus.getNumChannels(); + auto* chPtrs = inputChannelPtrStorage.data() + chOffset; + + for (int ch = 0; ch < numCh; ++ch) + chPtrs[ch] = (inputChannelData != nullptr && chOffset + ch < numIn) + ? inputChannelData[chOffset + ch] + : nullptr; + + inputBusViews.emplace_back (chPtrs, numCh, bus.getRole()); + + chOffset += numCh; + } + } + + // Builds one AudioBusBufferView per audio output bus from the flat array of + // host-provided output channel buffers. + void buildOutputBusViews (float* const* outputChannelData, int numOut) + { + outputBusViews.clear(); + + int chOffset = 0; + + for (const auto& bus : processor->getBusLayout().getOutputBuses()) + { + if (bus.getType() != AudioBus::Type::Audio) + continue; + + const int numCh = bus.getNumChannels(); + auto* chPtrs = outputChannelPtrStorage.data() + chOffset; + + for (int ch = 0; ch < numCh; ++ch) + chPtrs[ch] = (outputChannelData != nullptr && chOffset + ch < numOut) + ? outputChannelData[chOffset + ch] + : nullptr; + + outputBusViews.emplace_back (chPtrs, numCh, bus.getRole()); + + chOffset += numCh; + } + } + void process (float* const* inputChannelData, float* const* outputChannelData, int bufferSize, @@ -636,12 +693,21 @@ class YupAAX_Processor const auto numIn = layout.getNumAudioInputChannels(); const auto numOut = layout.getNumAudioOutputChannels(); + // Only Main-role input channels are copied into the outputs; auxiliary + // (sidechain) channels must never leak into the main signal path. This + // assumes the main input buses are declared before auxiliary buses, which + // is the YUP layout convention (mirrored by VST3's kMain-before-kAux rule). + int numMainIn = 0; + for (const auto& bus : layout.getInputBuses()) + if (bus.getType() == AudioBus::Type::Audio && bus.getRole() == AudioBus::Role::Main) + numMainIn += bus.getNumChannels(); + for (int ch = 0; ch < numOut; ++ch) { if (outputChannelData[ch] == nullptr) continue; - if (ch < numIn && inputChannelData[ch] != nullptr) + if (inputChannelData != nullptr && ch < numMainIn && inputChannelData[ch] != nullptr) { if (outputChannelData[ch] != inputChannelData[ch]) std::memcpy (outputChannelData[ch], inputChannelData[ch], static_cast (bufferSize) * sizeof (float)); @@ -654,10 +720,18 @@ class YupAAX_Processor AudioBuffer audioBuffer (outputChannelData, numOut, bufferSize); + buildInputBusViews (inputChannelData, numIn); + buildOutputBusViews (outputChannelData, numOut); + paramChangeBuffer.clear(); AudioProcessContext context { - audioBuffer, midiBuffer, paramChangeBuffer, nullptr + audioBuffer, + midiBuffer, + paramChangeBuffer, + nullptr, + { inputBusViews.data(), inputBusViews.size() }, + { outputBusViews.data(), outputBusViews.size() } }; processAudioBlock (*processor, context, currentlyBypassed); @@ -712,6 +786,12 @@ class YupAAX_Processor paramChangeBuffer.reserve (getDefaultParameterChangeCapacity (*processor)); + // Pre-allocate per-bus view storage so the algorithm callback never allocates + inputBusViews.reserve (static_cast (processor->getNumAudioInputs())); + outputBusViews.reserve (static_cast (processor->getNumAudioOutputs())); + inputChannelPtrStorage.resize (static_cast (processor->getBusLayout().getNumAudioInputChannels())); + outputChannelPtrStorage.resize (static_cast (processor->getBusLayout().getNumAudioOutputChannels())); + if (auto* ctrl = Controller()) ctrl->SetSignalLatency (processor->getLatencySamples()); @@ -971,6 +1051,13 @@ class YupAAX_Processor MidiBuffer midiBuffer; ParameterChangeBuffer paramChangeBuffer; + // Per-bus view state, pre-allocated in preparePlugin so the real-time + // algorithm callback only clears and refills the vectors without allocating + std::vector> inputBusViews; + std::vector> outputBusViews; + std::vector inputChannelPtrStorage; + std::vector outputChannelPtrStorage; + std::unordered_map paramMap; std::vector aaxMeters; @@ -1153,7 +1240,7 @@ void AAX_CALLBACK yupAAXAlgorithmCallback (void* const instancesBegin[], const v static void getPlugInDescription (AAX_IEffectDescriptor& descriptor) { - std::unique_ptr plugin (createPluginProcessor()); + std::unique_ptr plugin (::YUP_AUDIO_PLUGIN_CREATE_FUNCTION()); descriptor.AddName (YupPlugin_Name); descriptor.AddName (YupPlugin_Description); diff --git a/modules/yup_audio_plugin_client/au/yup_audio_plugin_client_AU.mm b/modules/yup_audio_plugin_client/au/yup_audio_plugin_client_AU.mm index 5a314d242..ca0e51f46 100644 --- a/modules/yup_audio_plugin_client/au/yup_audio_plugin_client_AU.mm +++ b/modules/yup_audio_plugin_client/au/yup_audio_plugin_client_AU.mm @@ -21,6 +21,7 @@ #include "../yup_audio_plugin_client.h" +#include "../common/yup_AudioPluginAUHelpers.h" #include "../common/yup_AudioPluginUtilities.h" #if ! defined(YUP_AUDIO_PLUGIN_ENABLE_AU) @@ -50,7 +51,7 @@ //============================================================================== -extern "C" yup::AudioProcessor* createPluginProcessor(); +extern "C" yup::AudioProcessor* YUP_AUDIO_PLUGIN_CREATE_FUNCTION(); @class AudioPluginEditorViewAU; @@ -62,11 +63,6 @@ static String describeScopeAndElement (AudioUnitScope scope, AudioUnitElement el return "scope=" + String (static_cast (scope)) + ", element=" + String (static_cast (element)); } -static String describePointer (const void* value) -{ - return "0x" + String::toHexString (static_cast (reinterpret_cast (value))); -} - static String describeStatus (OSStatus status) { return String (static_cast (status)); @@ -74,85 +70,6 @@ static String describeStatus (OSStatus status) //============================================================================== -namespace -{ - -//============================================================================== - -static CFStringRef getProcessorStateKey() -{ - return CFSTR ("YUPProcessorState"); -} - -//============================================================================== - -struct AUScopedYupInitialiser -{ - AUScopedYupInitialiser() - { - if (numAUScopedInitInstances.fetch_add (1) == 0) - { - YUP_MODULE_DBG (PLUGIN_CLIENT_AU, "initialising YUP GUI"); - initialiseYup_GUI(); - } - } - - ~AUScopedYupInitialiser() - { - if (numAUScopedInitInstances.fetch_sub (1) == 1) - { - YUP_MODULE_DBG (PLUGIN_CLIENT_AU, "shutting down YUP GUI"); - shutdownYup_GUI(); - } - } - -private: - static std::atomic_int numAUScopedInitInstances; -}; - -std::atomic_int AUScopedYupInitialiser::numAUScopedInitInstances = 0; - -struct AUScopedYupWindowingInitialiser -{ - AUScopedYupWindowingInitialiser() - { - if (numAUScopedInitInstances.fetch_add (1) == 0) - { - YUP_MODULE_DBG (PLUGIN_CLIENT_AU, "initialising YUP windowing for editor"); - initialiseYup_Windowing(); - } - } - - ~AUScopedYupWindowingInitialiser() - { - if (numAUScopedInitInstances.fetch_sub (1) == 1) - { - YUP_MODULE_DBG (PLUGIN_CLIENT_AU, "shutting down YUP windowing for editor"); - shutdownYup_Windowing(); - } - } - -private: - static std::atomic_int numAUScopedInitInstances; -}; - -std::atomic_int AUScopedYupWindowingInitialiser::numAUScopedInitInstances = 0; - -//============================================================================== - -static OSType osTypeFromString (const char* s) -{ - if (s == nullptr || std::strlen (s) < 4) - return 0; - - return static_cast ( - (static_cast (static_cast (s[0])) << 24) | (static_cast (static_cast (s[1])) << 16) | (static_cast (static_cast (s[2])) << 8) | static_cast (static_cast (s[3]))); -} - -} // namespace - -//============================================================================== - #if YupPlugin_IsSynth using AudioPluginAUBase = ausdk::MusicDeviceBase; #else @@ -208,7 +125,7 @@ bool canControlTransport() override #endif componentInstance (component) { - processor.reset (::createPluginProcessor()); + processor.reset (::YUP_AUDIO_PLUGIN_CREATE_FUNCTION()); YUP_MODULE_DBG (PLUGIN_CLIENT_AU, "created processor instance: wrapper=" << yup::describePointer (this) << ", component=" << yup::describePointer (componentInstance) << ", processor=" << yup::describePointer (processor.get())); @@ -251,6 +168,13 @@ OSStatus Initialize() override return kAudioUnitErr_FailedInitialization; } +#if ! YupPlugin_IsSynth + // Expose one input element per audio input bus so hosts can route + // sidechain signals to auxiliary input elements (element 0 is always + // the main input). Processors with a single input bus keep one element. + SetNumberOfElements (kAudioUnitScope_Input, static_cast (processor->getNumAudioInputs())); +#endif + processor->setOfflineProcessing (renderingOffline); processor->setPlaybackConfiguration (static_cast (getCurrentSampleRate()), static_cast (GetMaxFramesPerSlice())); @@ -263,6 +187,12 @@ OSStatus Initialize() override emptyParamChangeBuffer.reserve (getDefaultParameterChangeCapacity (*processor)); audioChannels.reserve (static_cast (getTotalAudioOutputChannels (*processor))); + // Pre-allocate per-bus view storage so the render callback never allocates + renderViews.prepare (*processor, + getTotalAudioInputChannels (*processor), + getTotalAudioOutputChannels (*processor)); + auxiliaryInputValid.resize (static_cast (processor->getNumAudioInputs()), 0); + YUP_MODULE_DBG (PLUGIN_CLIENT_AU, "Initialize completed: sampleRate=" << String (getCurrentSampleRate()) << ", maxFramesPerSlice=" << String (static_cast (GetMaxFramesPerSlice()))); return noErr; @@ -316,15 +246,12 @@ OSStatus GetParameterInfo (AudioUnitScope inScope, const auto& param = parameters[parameterIndex]; - outParameterInfo.flags = kAudioUnitParameterFlag_IsReadable | kAudioUnitParameterFlag_HasCFNameString; - - if (! param->isReadOnly()) - outParameterInfo.flags |= kAudioUnitParameterFlag_IsWritable; + outParameterInfo.flags = makeAUParameterFlags (*param) | kAudioUnitParameterFlag_HasCFNameString; outParameterInfo.cfNameString = param->getName().toCFString(); param->getName().copyToUTF8 (outParameterInfo.name, sizeof (outParameterInfo.name)); - outParameterInfo.unit = kAudioUnitParameterUnit_Generic; + outParameterInfo.unit = makeAUUnit (*param); outParameterInfo.minValue = param->getMinimumValue(); outParameterInfo.maxValue = param->getMaximumValue(); outParameterInfo.defaultValue = param->getDefaultValue(); @@ -618,6 +545,116 @@ OSStatus HandleSysEx (const UInt8* inData, UInt32 inLength) override } #else + // Effect: pull auxiliary (sidechain) input elements before the base class + // renders element 0, so their buffers are populated when ProcessBufferLists + // builds the per-bus input views. + OSStatus Render (AudioUnitRenderActionFlags& ioActionFlags, + const AudioTimeStamp& inTimeStamp, + UInt32 inNumberFrames) override + { + if (processor != nullptr) + pullAuxiliaryInputElements (ioActionFlags, inTimeStamp, inNumberFrames); + + return AudioPluginAUBase::Render (ioActionFlags, inTimeStamp, inNumberFrames); + } + + //============================================================================== + + void pullAuxiliaryInputElements (AudioUnitRenderActionFlags& ioActionFlags, + const AudioTimeStamp& inTimeStamp, + UInt32 inNumberFrames) + { + const auto numInputs = static_cast (processor->getNumAudioInputs()); + const auto numElements = Inputs().GetNumberOfElements(); + + for (UInt32 element = 1; element < numInputs && element < numElements; ++element) + { + auto& input = Input (element); + + // Only connected elements produce audio; disconnected sidechain + // elements stay silent and are exposed as null-buffer views below + const auto pullResult = input.IsActive() + ? input.PullInput (ioActionFlags, inTimeStamp, element, inNumberFrames) + : kAudioUnitErr_NoConnection; + + auxiliaryInputValid[static_cast (element)] = (pullResult == noErr); + } + } + + //============================================================================== + + // Builds one AudioBusBufferView per audio input bus. Element 0 (the main + // input) is mapped from the passed buffer list; elements 1..n (auxiliary / + // sidechain inputs) are mapped from their own pulled buffer lists. Views are + // real-time safe: they reuse pre-allocated channel-pointer storage. + void buildInputBusViews (const AudioBufferList& mainInBuffer) + { + renderViews.inputBusViews.clear(); + + int chOffset = 0; + int audioIdx = 0; + + for (const auto& bus : processor->getBusLayout().getInputBuses()) + { + if (bus.getType() != AudioBus::Type::Audio) + continue; + + const int numCh = bus.getNumChannels(); + auto* chPtrs = renderViews.inputChannelPtrStorage.data() + chOffset; + + if (audioIdx == 0) + { + const UInt32 copyCount = std::min (mainInBuffer.mNumberBuffers, static_cast (numCh)); + for (UInt32 ch = 0; ch < copyCount; ++ch) + chPtrs[ch] = static_cast (mainInBuffer.mBuffers[ch].mData); + } + else if (auxiliaryInputValid[static_cast (audioIdx)]) + { + const auto& busBufferList = Input (static_cast (audioIdx)).GetBufferList(); + const UInt32 copyCount = std::min (busBufferList.mNumberBuffers, static_cast (numCh)); + for (UInt32 ch = 0; ch < copyCount; ++ch) + chPtrs[ch] = static_cast (busBufferList.mBuffers[ch].mData); + } + + renderViews.inputBusViews.emplace_back (chPtrs, numCh, bus.getRole()); + + chOffset += numCh; + ++audioIdx; + } + } + + // Builds one AudioBusBufferView per audio output bus from the output buffer + // list, consuming channels sequentially across buses. + void buildOutputBusViews (const AudioBufferList& outBuffer) + { + renderViews.outputBusViews.clear(); + + int chOffset = 0; + + for (const auto& bus : processor->getBusLayout().getOutputBuses()) + { + if (bus.getType() != AudioBus::Type::Audio) + continue; + + const int numCh = bus.getNumChannels(); + auto* chPtrs = renderViews.outputChannelPtrStorage.data() + chOffset; + + std::fill (chPtrs, chPtrs + numCh, nullptr); + + const auto available = outBuffer.mNumberBuffers > static_cast (chOffset) + ? static_cast (numCh) + : 0u; + const UInt32 copyCount = std::min (available, outBuffer.mNumberBuffers - static_cast (chOffset)); + + for (UInt32 ch = 0; ch < copyCount; ++ch) + chPtrs[ch] = static_cast (outBuffer.mBuffers[static_cast (chOffset) + ch].mData); + + renderViews.outputBusViews.emplace_back (chPtrs, numCh, bus.getRole()); + + chOffset += numCh; + } + } + // Effect: copy input to output and call processBlock OSStatus ProcessBufferLists (AudioUnitRenderActionFlags& ioActionFlags, const AudioBufferList& inBuffer, @@ -646,6 +683,9 @@ AudioSampleBuffer audioBuffer (audioChannels.data(), 0, static_cast (inFramesToProcess)); + buildInputBusViews (inBuffer); + buildOutputBusViews (outBuffer); + AudioPluginPlayHeadAU playHead (*this, nullptr); std::unique_lock parameterLock (parameterChangeMutex, std::try_to_lock); auto& processParamChangeBuffer = parameterLock.owns_lock() ? paramChangeBuffer : emptyParamChangeBuffer; @@ -653,7 +693,9 @@ AudioSampleBuffer audioBuffer (audioChannels.data(), AudioProcessContext context { audioBuffer, midiBuffer, processParamChangeBuffer, - &playHead }; + &playHead, + { renderViews.inputBusViews.data(), renderViews.inputBusViews.size() }, + { renderViews.outputBusViews.data(), renderViews.outputBusViews.size() } }; processAudioBlock (*processor, context, isBypassed); midiBuffer.clear(); processParamChangeBuffer.clear(); @@ -703,7 +745,7 @@ OSStatus SaveState (CFPropertyListRef* outData) override auto* stateDictionary = const_cast (static_cast (*outData)); CFDictionarySetValue (stateDictionary, - getProcessorStateKey(), + getAUProcessorStateKey(), (__bridge CFDataRef) nsData); } @@ -728,7 +770,7 @@ OSStatus RestoreState (CFPropertyListRef inData) override if (CFGetTypeID (inData) == CFDictionaryGetTypeID()) { processorState = static_cast (CFDictionaryGetValue (static_cast (inData), - getProcessorStateKey())); + getAUProcessorStateKey())); if (processorState != nullptr && CFGetTypeID (processorState) != CFDataGetTypeID()) return kAudioUnitErr_InvalidPropertyValue; @@ -1084,6 +1126,11 @@ Float64 getCurrentSampleRate() std::vector listenedParameters; std::vector audioChannels; std::vector editorViews; + + // Per-bus view state, pre-allocated in Initialize so the render callback + // only clears and refills the vectors without allocating + AudioPluginAURenderViews renderViews; + std::vector auxiliaryInputValid; // per-element pull success (effect path) AudioUnit componentInstance = nullptr; bool renderingOffline = false; bool isBypassed = false; diff --git a/modules/yup_audio_plugin_client/auv3/yup_audio_plugin_client_AUv3.mm b/modules/yup_audio_plugin_client/auv3/yup_audio_plugin_client_AUv3.mm index d251acd3d..d8d9a43bb 100644 --- a/modules/yup_audio_plugin_client/auv3/yup_audio_plugin_client_AUv3.mm +++ b/modules/yup_audio_plugin_client/auv3/yup_audio_plugin_client_AUv3.mm @@ -21,6 +21,7 @@ #include "../yup_audio_plugin_client.h" +#include "../common/yup_AudioPluginAUHelpers.h" #include "../common/yup_AudioPluginUtilities.h" #if ! defined(YUP_AUDIO_PLUGIN_ENABLE_AUv3) @@ -50,55 +51,51 @@ //============================================================================== -extern "C" yup::AudioProcessor* createPluginProcessor(); +NS_ASSUME_NONNULL_BEGIN + +extern "C" yup::AudioProcessor* YUP_AUDIO_PLUGIN_CREATE_FUNCTION(); namespace yup { //============================================================================== -static String describePointer (const void* value) +/** Returns value-strings for enumerated/stepped parameters so the host + can display discrete choices instead of a raw numeric value. +*/ +static NSArray* _Nullable makeAUValueStrings (const AudioParameter& param) { - return "0x" + String::toHexString (static_cast (reinterpret_cast (value))); -} + if (! param.isEnum() && ! param.isStepped()) + return nil; -//============================================================================== + const int numSteps = param.getNumSteps(); + if (numSteps <= 0) + return nil; -struct AUScopedYupInitialiser -{ - AUScopedYupInitialiser() - { - if (numAUScopedInitInstances.fetch_add (1) == 0) - { - YUP_MODULE_DBG (PLUGIN_CLIENT_AUV3, "initialising YUP GUI"); - initialiseYup_GUI(); - } - } + const int numValues = numSteps + 1; // a range with N steps has N+1 discrete values + auto* strings = [[NSMutableArray alloc] initWithCapacity:static_cast (numValues)]; + + const float stepSize = (param.getMaximumValue() - param.getMinimumValue()) / static_cast (numSteps); - ~AUScopedYupInitialiser() + for (int i = 0; i < numValues; ++i) { - if (numAUScopedInitInstances.fetch_sub (1) == 1) - { - YUP_MODULE_DBG (PLUGIN_CLIENT_AUV3, "shutting down YUP GUI"); - shutdownYup_GUI(); - } + const auto stepValue = param.getMinimumValue() + static_cast (i) * stepSize; + [strings addObject:yupStringToNS (param.convertToString (stepValue))]; } -private: - static std::atomic_int numAUScopedInitInstances; -}; - -std::atomic_int AUScopedYupInitialiser::numAUScopedInitInstances = 0; - -//============================================================================== - -static float getMaximumParameterValue (const AudioParameter& p) -{ - return p.getMaximumValue(); + return strings; } //============================================================================== +/** Magic/version used to wrap the YUP processor state blob with wrapper-owned + bypass state so host bypass survives preset/session restore, matching the + approach used by the VST3, CLAP, LV2, and AAX wrappers. Legacy raw processor + state (without this magic) is still loaded via readWrapperBypassState's fallback. +*/ +constexpr int auv3WrapperStateMagic = 0x33564159; // "YAV3" +constexpr int auv3WrapperStateVersion = 1; + } // namespace yup //============================================================================== @@ -126,7 +123,7 @@ static float getMaximumParameterValue (const AudioParameter& p) NSError**) : au (audioUnit) { - processor.reset (::createPluginProcessor()); + processor.reset (::YUP_AUDIO_PLUGIN_CREATE_FUNCTION()); init(); } @@ -155,6 +152,8 @@ static float getMaximumParameterValue (const AudioParameter& p) void init() { + jassert (au != nil); // The AUAudioUnit must be set before initialization + if (processor == nullptr) return; @@ -174,23 +173,17 @@ void init() if (bus.getType() == AudioBus::Type::Audio) totalOutChannels += bus.getNumChannels(); - // Build channel capabilities + // Build channel capabilities (one entry per audio bus) { channelCapabilities.reset ([[NSMutableArray alloc] init]); - int maxInputCh = 0; - int maxOutputCh = 0; - for (const auto& bus : busLayout.getInputBuses()) if (bus.getType() == AudioBus::Type::Audio) - maxInputCh = std::max (maxInputCh, bus.getNumChannels()); + [channelCapabilities.get() addObject:[NSNumber numberWithInteger:bus.getNumChannels()]]; for (const auto& bus : busLayout.getOutputBuses()) if (bus.getType() == AudioBus::Type::Audio) - maxOutputCh = std::max (maxOutputCh, bus.getNumChannels()); - - [channelCapabilities.get() addObject:[NSNumber numberWithInteger:maxInputCh]]; - [channelCapabilities.get() addObject:[NSNumber numberWithInteger:maxOutputCh]]; + [channelCapabilities.get() addObject:[NSNumber numberWithInteger:bus.getNumChannels()]]; } internalRenderBlock = CreateObjCBlock (this, &AudioPluginProcessorAUv3::renderCallback); @@ -231,6 +224,19 @@ void addParameters() addressForIndex[i] = address; } + // Wrapper-owned bypass parameter backing the host's AUv3 bypass property. + // It is deliberately not added to the processor or the AU parameter tree: + // the host drives it through setShouldBypassEffect:, the render callback + // reads it directly, and it is persisted inside the YUPProcessorState blob. + auto bypassMetadata = AudioParameter::Metadata {}; + bypassMetadata.name = "Bypass"; + bypassMetadata.hostParameterID = getBypassHostParameterID (*processor); + bypassMetadata.valueRange = { 0.0f, 1.0f, 1.0f }; + bypassMetadata.defaultValue = 0.0f; + bypassMetadata.setStepped (true); + + bypassHostParam = std::make_unique ("bypass", bypassMetadata); + installParameterTree (createTopLevelNodes()); } @@ -300,10 +306,12 @@ void installParameterTree (NSMutableArray* topLevelNodes) address:address min:minVal max:maxVal - unit:kAudioUnitParameterUnit_Generic - unitName:nil - flags:0 - valueStrings:nil + unit:makeAUUnit (*param) + unitName:(param->getUnit() == AudioParameter::ParameterUnit::Custom + ? yupStringToNS (param->getUnitName()) + : nil) + flags:makeAUv3ParameterFlags (*param) + valueStrings:makeAUValueStrings (*param) dependentParameters:nil]; if (auParam != nullptr) @@ -325,11 +333,9 @@ void valueChangedFromHost (AUParameter* param, AUValue value) if (yupParam == nullptr) return; - const auto normalisedValue = static_cast (value) / getMaximumParameterValue (*yupParam); - - if (! approximatelyEqual (normalisedValue, yupParam->getNormalizedValue())) + if (! approximatelyEqual (static_cast (value), yupParam->getValue())) { - yupParam->setNormalizedValue (normalisedValue); + yupParam->setValue (static_cast (value)); inParameterChangedCallback = true; yupParam->beginChangeGesture(); @@ -346,7 +352,7 @@ AUValue getValueForHost (AUParameter* param) const if (yupParam == nullptr) return 0; - return static_cast (yupParam->getNormalizedValue() * getMaximumParameterValue (*yupParam)); + return static_cast (yupParam->getValue()); } NSString* stringFromValue (AUParameter* param, const AUValue* value) const @@ -358,8 +364,7 @@ AUValue getValueForHost (AUParameter* param) const if (yupParam == nullptr) return @""; - const auto normalised = static_cast (*value) / getMaximumParameterValue (*yupParam); - return yupStringToNS (yupParam->convertToString (normalised)); + return yupStringToNS (yupParam->convertToString (static_cast (*value))); } AUValue valueFromString (AUParameter* param, NSString* str) const @@ -371,11 +376,10 @@ AUValue valueFromString (AUParameter* param, NSString* str) const if (yupParam == nullptr) return 0; - const auto normalised = yupParam->convertFromString (String::fromCFString ((__bridge CFStringRef) str)); - return static_cast (normalised * getMaximumParameterValue (*yupParam)); + return static_cast (yupParam->convertFromString (String::fromCFString ((__bridge CFStringRef) str))); } - AudioParameter* getParamForAUAddress (AUParameterAddress address) const + AudioParameter* _Nullable getParamForAUAddress (AUParameterAddress address) const { for (size_t i = 0; i < addressForIndex.size(); ++i) { @@ -390,7 +394,7 @@ AUValue valueFromString (AUParameter* param, NSString* str) const return nullptr; } - AudioParameter* getParamForIndex (int index) const + AudioParameter* _Nullable getParamForIndex (int index) const { const auto parameters = processor->getParameters(); if (isPositiveAndBelow (index, static_cast (parameters.size()))) @@ -429,7 +433,7 @@ void addPresets() return factoryPresets.get(); } - AUAudioUnitPreset* getCurrentPreset() const + AUAudioUnitPreset* _Nullable getCurrentPreset() const { if (processor == nullptr) return nil; @@ -466,10 +470,18 @@ void setCurrentPreset (AUAudioUnitPreset* preset) if (processor != nullptr) processor->saveStateIntoMemory (state); - if (state.getSize() > 0) + // Wrap processor state together with the wrapper-owned bypass state so + // host bypass survives preset/session restore (see auv3WrapperStateMagic). + const auto wrapperState = writeWrapperBypassState (auv3WrapperStateMagic, + auv3WrapperStateVersion, + getShouldBypassEffect(), + state, + state.getSize() > 0); + + if (wrapperState.getSize() > 0) { - [retval setObject:[[NSData alloc] initWithBytes:state.getData() length:state.getSize()] - forKey:@"YUPProcessorState"]; + [retval setObject:[[NSData alloc] initWithBytes:wrapperState.getData() length:wrapperState.getSize()] + forKey:(__bridge NSString*) getAUProcessorStateKey()]; } return retval; @@ -480,7 +492,7 @@ void setFullState (NSDictionary* state) if (state == nil || processor == nullptr) return; - id obj = [state objectForKey:@"YUPProcessorState"]; + id obj = [state objectForKey:(__bridge NSString*) getAUProcessorStateKey()]; if (obj == nil || ! [obj isKindOfClass:[NSData class]]) return; @@ -494,7 +506,16 @@ void setFullState (NSDictionary* state) } MemoryBlock stateBlock ([data bytes], static_cast (numBytes)); - processor->loadStateFromMemory (stateBlock); + const auto wrapperState = readWrapperBypassState (stateBlock, auv3WrapperStateMagic, auv3WrapperStateVersion); + + // Restore wrapper-owned bypass state when present, otherwise fall back to + // treating the whole blob as legacy raw processor state. + if (wrapperState.hasWrapperState) + setShouldBypassEffect (wrapperState.isBypassed); + + const bool shouldLoadProcessorState = ! wrapperState.hasWrapperState || wrapperState.hasProcessorState; + if (shouldLoadProcessorState && wrapperState.processorState.getSize() > 0) + processor->loadStateFromMemory (wrapperState.processorState); { ObjCMsgSendSuper (au, @selector (didChangeValueForKey:), @"allParameterValues"); @@ -506,6 +527,8 @@ void setFullState (NSDictionary* state) void addAudioUnitBusses (bool isInput) { + jassert (au != nil); // The AUAudioUnit must be set before creating bus arrays + auto* array = [[NSMutableArray alloc] init]; const auto& busLayout = processor->getBusLayout(); @@ -539,7 +562,10 @@ void addAudioUnitBusses (bool isInput) auto* auBus = [[AUAudioUnitBus alloc] initWithFormat:format error:&error]; if (auBus != nil) + { + auBus.name = yupStringToNS (bus.getName()); [array addObject:auBus]; + } } if (isInput) @@ -562,6 +588,24 @@ bool shouldChangeToFormat (AVAudioFormat* format, AUAudioUnitBus* auBus) if (allocated) return false; + // Accept Float32 always, Float64 if the processor supports it. + // Other sample formats (Int16, Int32, etc.) are rejected — AUv3 hosts + // predominantly use floating-point. + const auto commonFormat = [format commonFormat]; + if (commonFormat != AVAudioPCMFormatFloat32) + { + if (commonFormat != AVAudioPCMFormatFloat64 + || processor == nullptr + || ! processor->supportsDoublePrecisionProcessing()) + { + return false; + } + } + + // Accept both interleaved and non-interleaved. The render callback + // handles deinterleave / interleave internally via AudioData converters, + // so there is no need to reject interleaved formats here. + const auto isInput = ([auBus busType] == AUAudioUnitBusTypeInput); const auto busIdx = static_cast ([auBus index]); const auto newNumChannels = static_cast ([format channelCount]); @@ -576,7 +620,7 @@ bool shouldChangeToFormat (AVAudioFormat* format, AUAudioUnitBus* auBus) if (bus.getType() != AudioBus::Type::Audio) return false; - return newNumChannels > 0 && newNumChannels <= bus.getNumChannels(); + return newNumChannels > 0 && newNumChannels == bus.getNumChannels(); } //============================================================================== @@ -584,6 +628,8 @@ bool shouldChangeToFormat (AVAudioFormat* format, AUAudioUnitBus* auBus) bool allocateRenderResourcesAndReturnError (NSError** outError) { + jassert (au != nil); // The AUAudioUnit must be set before allocating render resources + allocated = false; if (processor == nullptr) @@ -595,20 +641,94 @@ bool allocateRenderResourcesAndReturnError (NSError** outError) const AUAudioFrameCount maxFrames = [au maximumFramesToRender]; auto sampleRate = 44100.0; + bool sampleRateSet = false; + size_t maxInterleavedBytes = 0; + + // Validate bus formats and compute required scratch space. + // Float32 and Float64 are accepted (Float64 gated by shouldChangeToFormat). + // Both interleaved and non-interleaved are accepted — conversion happens in the + // render callback via AudioData converters. for (auto* busses : { inputBusses.get(), outputBusses.get() }) { - if ([busses count] > 0) + for (NSUInteger i = 0; i < [busses count]; ++i) { - sampleRate = [[[busses objectAtIndexedSubscript:0] format] sampleRate]; - break; + auto* auBus = [busses objectAtIndexedSubscript:i]; + auto* fmt = [auBus format]; + + // Only float formats are supported (shouldChangeToFormat already gates this) + const auto commonFormat = [fmt commonFormat]; + if (commonFormat != AVAudioPCMFormatFloat32 && commonFormat != AVAudioPCMFormatFloat64) + { + if (outError != nullptr) + *outError = [NSError errorWithDomain:NSOSStatusErrorDomain + code:kAudioUnitErr_FormatNotSupported + userInfo:@{ + NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Unsupported sample format for bus %@", + [auBus name]] + }]; + return false; + } + + // Track the largest interleaved buffer needed for format conversion + const auto numCh = [fmt channelCount]; + const auto bytesPerSample = (commonFormat == AVAudioPCMFormatFloat64) ? sizeof (double) : sizeof (float); + const auto interleavedBytes = static_cast (maxFrames) * static_cast (numCh) * bytesPerSample; + maxInterleavedBytes = jmax (maxInterleavedBytes, interleavedBytes); + + // Validate consistent sample rate across all buses + const auto busSampleRate = [fmt sampleRate]; + if (! sampleRateSet) + { + sampleRate = busSampleRate; + sampleRateSet = true; + } + else if (! approximatelyEqual (sampleRate, busSampleRate)) + { + if (outError != nullptr) + *outError = [NSError errorWithDomain:NSOSStatusErrorDomain + code:kAudioUnitErr_FormatNotSupported + userInfo:@{ + NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Inconsistent sample rate for bus %@", + [auBus name]] + }]; + return false; + } } } + // Pre-allocate interleaved scratch buffer for format conversion (real-time safe) + if (maxInterleavedBytes > interleavedScratchSize) + { + interleavedScratchData.allocate (maxInterleavedBytes, false); + interleavedScratchSize = maxInterleavedBytes; + } + + allocatedMaximumFrames = maxFrames; + processor->setPlaybackConfiguration (sampleRate, static_cast (maxFrames)); midiMessages.ensureSize (2048); midiMessages.clear(); + // Reserve parameter change buffer capacity for sample-accurate automation. + // Capacity = number of parameters * maxFrames per block so that per-sample + // ramps never exceed the pre-allocated storage. + { + const auto numParams = static_cast (processor->getParameters().size()); + paramChangeBuffer.reserve (numParams * static_cast (maxFrames)); + } + + // Pre-allocate scratch buffers (real-time safe — no allocation in render callback) + scratchBuffer.setSize (totalInChannels, static_cast (maxFrames)); + scratchBuffer.clear(); + scratchOutputBuffer.setSize (totalOutChannels, static_cast (maxFrames)); + scratchOutputBuffer.clear(); + + // Pre-allocate per-bus view storage + renderViews.prepare (*processor, totalInChannels, totalOutChannels); + hostMusicalContextCallback = [au musicalContextBlock]; hostTransportStateCallback = [au transportStateBlock]; @@ -628,6 +748,7 @@ bool allocateRenderResourcesAndReturnError (NSError** outError) void deallocateRenderResources() { allocated = false; + allocatedMaximumFrames = 0; midiOutputEventBlock = nullptr; hostMusicalContextCallback = nullptr; hostTransportStateCallback = nullptr; @@ -659,6 +780,12 @@ AUAudioUnitStatus renderCallback (AudioUnitRenderActionFlags* actionFlags, if (processor == nullptr) return kAudioUnitErr_NoConnection; + // Reject frame counts exceeding the pre-allocated buffer capacity. + // The host should never request more than maximumFramesToRender, but if it + // does, proceeding would cause a buffer overrun in the scratch buffers. + if (frameCount > allocatedMaximumFrames) + return kAudioUnitErr_TooManyFramesToProcess; + const int numFrames = static_cast (frameCount); if (! approximatelyEqual (lastTimeStamp.mSampleTime, timestamp->mSampleTime)) @@ -666,17 +793,18 @@ AUAudioUnitStatus renderCallback (AudioUnitRenderActionFlags* actionFlags, midiMessages.clear(); // Process events (MIDI and parameters) - processEvents (realtimeEventListHead, static_cast (timestamp->mSampleTime)); + processEvents (realtimeEventListHead, static_cast (timestamp->mSampleTime), frameCount); lastTimeStamp = *timestamp; - // Prepare audio buffer - scratchBuffer.setSize (std::max (totalInChannels, totalOutChannels), numFrames); + // Clear scratch buffers (already sized in allocateRenderResources) scratchBuffer.clear(); + scratchOutputBuffer.clear(); const auto& busLayout = processor->getBusLayout(); - // Pull inputs + // Build per-bus input views and pull inputs + renderViews.inputBusViews.clear(); { int chIdx = 0; const auto& inputBuses = busLayout.getInputBuses(); @@ -688,25 +816,119 @@ AUAudioUnitStatus renderCallback (AudioUnitRenderActionFlags* actionFlags, continue; const int numCh = bus.getNumChannels(); - AudioBufferList* pullData = nullptr; if (pullInputBlock != nullptr) { - AudioBufferList localBuffer = {}; - localBuffer.mNumberBuffers = static_cast (numCh); + // Look up the AU bus format to determine pull-buffer layout + const auto auBusIdx = static_cast (renderViews.inputBusViews.size()); + auto* auBus = [inputBusses.get() objectAtIndexedSubscript:static_cast (auBusIdx)]; + auto* busFmt = [auBus format]; + const bool busIsInterleaved = [busFmt isInterleaved]; + const bool busIsFloat64 = ([busFmt commonFormat] == AVAudioPCMFormatFloat64); + + if (! busIsInterleaved && ! busIsFloat64) + { + // Planar Float32 — direct pull into scratch (fast path) + const auto bufferListSize = offsetof (AudioBufferList, mBuffers) + static_cast (numCh) * sizeof (::AudioBuffer); + auto* pullBuffer = static_cast (alloca (bufferListSize)); + pullBuffer->mNumberBuffers = static_cast (numCh); - float* channelPtrs[16] = {}; - for (int ch = 0; ch < numCh && ch < 16; ++ch) + for (int ch = 0; ch < numCh; ++ch) + { + pullBuffer->mBuffers[ch].mNumberChannels = 1; + pullBuffer->mBuffers[ch].mData = scratchBuffer.getWritePointer (chIdx + ch); + pullBuffer->mBuffers[ch].mDataByteSize = static_cast (numFrames * sizeof (float)); + } + + if (pullInputBlock (actionFlags, timestamp, frameCount, auBusIdx, pullBuffer) != noErr) + { + for (int ch = 0; ch < numCh; ++ch) + scratchBuffer.clear (chIdx + ch, 0, numFrames); + } + } + else if (busIsInterleaved) { - localBuffer.mBuffers[ch].mNumberChannels = 1; - localBuffer.mBuffers[ch].mData = scratchBuffer.getWritePointer (chIdx + ch); - localBuffer.mBuffers[ch].mDataByteSize = static_cast (numFrames * sizeof (float)); + // Interleaved pull — pull into a single interleaved buffer, then deinterleave + const auto bytesPerSample = busIsFloat64 ? sizeof (double) : sizeof (float); + const auto interleavedBytes = static_cast (numFrames) * static_cast (numCh) * bytesPerSample; + + const auto bufferListSize = offsetof (AudioBufferList, mBuffers) + sizeof (::AudioBuffer); + auto* pullBuffer = static_cast (alloca (bufferListSize)); + pullBuffer->mNumberBuffers = 1; + pullBuffer->mBuffers[0].mNumberChannels = static_cast (numCh); + pullBuffer->mBuffers[0].mData = interleavedScratchData.getData(); + pullBuffer->mBuffers[0].mDataByteSize = static_cast (interleavedBytes); + + const auto pullStatus = pullInputBlock (actionFlags, timestamp, frameCount, auBusIdx, pullBuffer); + + // Build destination channel pointers for deinterleave. + // Stack allocation is real-time safe and avoids const-correctness + // issues with renderViews.inputChannelPtrStorage (which stores const float* + // for the later AudioBusBufferView build). + auto* chanPtrs = static_cast ( + alloca (static_cast (numCh) * sizeof (float*))); + for (int ch = 0; ch < numCh; ++ch) + chanPtrs[ch] = scratchBuffer.getWritePointer (chIdx + ch); + + if (pullStatus == noErr) + { + if (busIsFloat64) + { + using SrcFmt = AudioData::Format; + using DstFmt = AudioData::Format; + AudioData::deinterleaveSamples ( + AudioData::InterleavedSource { reinterpret_cast (interleavedScratchData.getData()), numCh }, + AudioData::NonInterleavedDest { chanPtrs, numCh }, + numFrames); + } + else + { + using SrcFmt = AudioData::Format; + using DstFmt = AudioData::Format; + AudioData::deinterleaveSamples ( + AudioData::InterleavedSource { reinterpret_cast (interleavedScratchData.getData()), numCh }, + AudioData::NonInterleavedDest { chanPtrs, numCh }, + numFrames); + } + } + else + { + for (int ch = 0; ch < numCh; ++ch) + scratchBuffer.clear (chIdx + ch, 0, numFrames); + } } - - if (pullInputBlock (actionFlags, timestamp, frameCount, busIdx, &localBuffer) != noErr) + else { + // Planar Float64 — pull into planar double, then convert double → float + const auto bufferListSize = offsetof (AudioBufferList, mBuffers) + static_cast (numCh) * sizeof (::AudioBuffer); + auto* pullBuffer = static_cast (alloca (bufferListSize)); + pullBuffer->mNumberBuffers = static_cast (numCh); + + auto* doubleScratch = reinterpret_cast (interleavedScratchData.getData()); for (int ch = 0; ch < numCh; ++ch) - scratchBuffer.clear (chIdx + ch, 0, numFrames); + { + pullBuffer->mBuffers[ch].mNumberChannels = 1; + pullBuffer->mBuffers[ch].mData = doubleScratch + static_cast (ch) * static_cast (numFrames); + pullBuffer->mBuffers[ch].mDataByteSize = static_cast (numFrames * sizeof (double)); + } + + const auto pullStatus = pullInputBlock (actionFlags, timestamp, frameCount, auBusIdx, pullBuffer); + + if (pullStatus == noErr) + { + for (int ch = 0; ch < numCh; ++ch) + { + const auto* src = doubleScratch + static_cast (ch) * static_cast (numFrames); + auto* dst = scratchBuffer.getWritePointer (chIdx + ch); + for (int s = 0; s < numFrames; ++s) + dst[s] = static_cast (src[s]); + } + } + else + { + for (int ch = 0; ch < numCh; ++ch) + scratchBuffer.clear (chIdx + ch, 0, numFrames); + } } } else @@ -715,6 +937,58 @@ AUAudioUnitStatus renderCallback (AudioUnitRenderActionFlags* actionFlags, scratchBuffer.clear (chIdx + ch, 0, numFrames); } + // Build AudioBusBufferView for this input bus (real-time safe — uses pre-allocated storage) + auto* chPtrs = renderViews.inputChannelPtrStorage.data() + chIdx; + for (int ch = 0; ch < numCh; ++ch) + chPtrs[ch] = scratchBuffer.getReadPointer (chIdx + ch); + + renderViews.inputBusViews.emplace_back (chPtrs, numCh, bus.getRole()); + + // Copy main inputs to the corresponding output area + if (bus.getRole() == AudioBus::Role::Main) + { + // Find matching output bus for this main input + int outputChIdx = 0; + for (const auto& outBus : busLayout.getOutputBuses()) + { + if (outBus.getType() != AudioBus::Type::Audio) + continue; + if (outBus.getRole() == AudioBus::Role::Main + && outputChIdx == chIdx) // match by channel offset + { + for (int ch = 0; ch < std::min (numCh, outBus.getNumChannels()); ++ch) + scratchOutputBuffer.copyFrom (outputChIdx + ch, 0, scratchBuffer, chIdx + ch, 0, numFrames); + break; + } + outputChIdx += outBus.getNumChannels(); + } + } + + chIdx += numCh; + } + } + + // Build per-bus output views + renderViews.outputBusViews.clear(); + { + int chIdx = 0; + const auto& outputBuses = busLayout.getOutputBuses(); + + for (int busIdx = 0; busIdx < static_cast (outputBuses.size()); ++busIdx) + { + const auto& bus = outputBuses[busIdx]; + if (bus.getType() != AudioBus::Type::Audio) + continue; + + const int numCh = bus.getNumChannels(); + + // Build AudioBusBufferView for this output bus (real-time safe — uses pre-allocated storage) + auto* chPtrs = renderViews.outputChannelPtrStorage.data() + chIdx; + for (int ch = 0; ch < numCh; ++ch) + chPtrs[ch] = scratchOutputBuffer.getWritePointer (chIdx + ch); + + renderViews.outputBusViews.emplace_back (chPtrs, numCh, bus.getRole()); + chIdx += numCh; } } @@ -723,10 +997,14 @@ AUAudioUnitStatus renderCallback (AudioUnitRenderActionFlags* actionFlags, { AudioPluginPlayHeadAU playHead (*this, timestamp); - AudioProcessContext context { scratchBuffer, - midiMessages, - emptyParamChangeBuffer, - &playHead }; + AudioProcessContext context { + scratchOutputBuffer, + midiMessages, + paramChangeBuffer, + &playHead, + { renderViews.inputBusViews.data(), renderViews.inputBusViews.size() }, + { renderViews.outputBusViews.data(), renderViews.outputBusViews.size() } + }; if (bypassHostParam != nullptr) processAudioBlock (*processor, context, bypassHostParam->getNormalizedValue() > 0.5f); @@ -742,6 +1020,7 @@ AUAudioUnitStatus renderCallback (AudioUnitRenderActionFlags* actionFlags, { const auto& outputBuses = processor->getBusLayout().getOutputBuses(); int chIdx = 0; + int auOutBusIdx = 0; for (int busIdx = 0; busIdx < static_cast (outputBuses.size()); ++busIdx) { @@ -751,17 +1030,94 @@ AUAudioUnitStatus renderCallback (AudioUnitRenderActionFlags* actionFlags, const int numCh = bus.getNumChannels(); - if (busIdx == static_cast (outputBusNumber)) + if (auOutBusIdx == static_cast (outputBusNumber)) { - for (int ch = 0; ch < numCh && ch < static_cast (outputData->mNumberBuffers); ++ch) + // Look up the AU bus format to determine output layout + auto* auBus = [outputBusses.get() objectAtIndexedSubscript:static_cast (auOutBusIdx)]; + auto* busFmt = [auBus format]; + const bool busIsInterleaved = [busFmt isInterleaved]; + const bool busIsFloat64 = ([busFmt commonFormat] == AVAudioPCMFormatFloat64); + + if (! busIsInterleaved && ! busIsFloat64) + { + // Planar Float32 — direct copy (fast path) + for (int ch = 0; ch < numCh && ch < static_cast (outputData->mNumberBuffers); ++ch) + { + const auto* src = scratchOutputBuffer.getReadPointer (chIdx + ch); + auto* buf = &outputData->mBuffers[ch]; + auto* dst = static_cast (buf->mData); + + if (dst == nullptr || src == nullptr) + continue; + + if (buf->mDataByteSize < static_cast (numFrames * static_cast (sizeof (float)))) + continue; + + std::copy (src, src + numFrames, dst); + } + } + else if (busIsInterleaved) + { + // Interleaved output — interleave from planar scratch. + // Validate the host buffer can hold the interleaved data. + const auto bytesPerSample = busIsFloat64 ? sizeof (double) : sizeof (float); + const auto requiredBytes = static_cast (numFrames * numCh * static_cast (bytesPerSample)); + + if (outputData->mNumberBuffers >= 1 + && outputData->mBuffers[0].mData != nullptr + && outputData->mBuffers[0].mDataByteSize >= requiredBytes) + { + // Build source channel pointers from scratch buffer (stack allocation, real-time safe). + // NonInterleavedSource expects const float* const* — we allocate as + // const float** and reinterpret_cast to add the inner const qualifier. + auto* storage = static_cast ( + alloca (static_cast (numCh) * sizeof (const float*))); + for (int ch = 0; ch < numCh; ++ch) + storage[ch] = scratchOutputBuffer.getReadPointer (chIdx + ch); + auto* chanPtrs = reinterpret_cast (storage); + + if (busIsFloat64) + { + using SrcFmt = AudioData::Format; + using DstFmt = AudioData::Format; + AudioData::interleaveSamples ( + AudioData::NonInterleavedSource { chanPtrs, numCh }, + AudioData::InterleavedDest { reinterpret_cast (outputData->mBuffers[0].mData), numCh }, + numFrames); + } + else + { + using SrcFmt = AudioData::Format; + using DstFmt = AudioData::Format; + AudioData::interleaveSamples ( + AudioData::NonInterleavedSource { chanPtrs, numCh }, + AudioData::InterleavedDest { static_cast (outputData->mBuffers[0].mData), numCh }, + numFrames); + } + } + } + else { - const auto* src = scratchBuffer.getReadPointer (chIdx + ch); - auto* dst = static_cast (outputData->mBuffers[ch].mData); + // Planar Float64 — convert float → double per channel + for (int ch = 0; ch < numCh && ch < static_cast (outputData->mNumberBuffers); ++ch) + { + const auto* src = scratchOutputBuffer.getReadPointer (chIdx + ch); + auto* buf = &outputData->mBuffers[ch]; + auto* dst = static_cast (buf->mData); + + if (dst == nullptr || src == nullptr) + continue; - std::copy (src, src + numFrames, dst); + if (buf->mDataByteSize < static_cast (numFrames * static_cast (sizeof (double)))) + continue; + + for (int s = 0; s < numFrames; ++s) + dst[s] = static_cast (src[s]); + } } } + ++auOutBusIdx; chIdx += numCh; } } @@ -769,8 +1125,10 @@ AUAudioUnitStatus renderCallback (AudioUnitRenderActionFlags* actionFlags, return noErr; } - void processEvents (const AURenderEvent* realtimeEventListHead, AUEventSampleTime startTime) + void processEvents (const AURenderEvent* realtimeEventListHead, AUEventSampleTime startTime, AUAudioFrameCount frameCount) { + paramChangeBuffer.clear(); + for (const AURenderEvent* event = realtimeEventListHead; event != nullptr; event = event->head.next) { switch (event->head.eventType) @@ -786,14 +1144,48 @@ void processEvents (const AURenderEvent* realtimeEventListHead, AUEventSampleTim break; case AURenderEventParameter: + { + const AUParameterEvent& paramEvent = event->parameter; + + if (auto* p = getParamForAUAddress (paramEvent.parameterAddress)) + { + const int offset = static_cast (paramEvent.eventSampleTime - startTime); + const auto normalised = p->convertToNormalizedValue (static_cast (paramEvent.value)); + p->setValue (static_cast (paramEvent.value)); + + if (isPositiveAndBelow (offset, static_cast (frameCount))) + paramChangeBuffer.addChange (p->getIndexInContainer(), normalised, offset); + + inParameterChangedCallback = true; + } + } + break; + case AURenderEventParameterRamp: { const AUParameterEvent& paramEvent = event->parameter; if (auto* p = getParamForAUAddress (paramEvent.parameterAddress)) { - auto normalisedValue = static_cast (paramEvent.value) / getMaximumParameterValue (*p); - p->setNormalizedValue (normalisedValue); + const int startOffset = static_cast (paramEvent.eventSampleTime - startTime); + const int rampFrames = static_cast (paramEvent.rampDurationSampleFrames); + const int rampEndOffset = startOffset + rampFrames; + const auto startValue = p->getValue(); + const auto endValue = static_cast (paramEvent.value); + const auto startNormalised = p->convertToNormalizedValue (startValue); + const auto endNormalised = p->convertToNormalizedValue (endValue); + + // Add ramp start event at the ramp's start offset + if (isPositiveAndBelow (startOffset, static_cast (frameCount))) + paramChangeBuffer.addChange (p->getIndexInContainer(), startNormalised, startOffset); + + // Add ramp end event. If the ramp extends past this block, clamp it to the last frame. + const int endOffsetClamped = jmin (rampEndOffset, static_cast (frameCount) - 1); + if (endOffsetClamped > startOffset) + paramChangeBuffer.addChange (p->getIndexInContainer(), endNormalised, endOffsetClamped); + + // Always set the final value so the next block picks up the correct value + p->setValue (endValue); inParameterChangedCallback = true; } @@ -804,6 +1196,9 @@ void processEvents (const AURenderEvent* realtimeEventListHead, AUEventSampleTim break; } } + + // Sort changes by sample offset for binary-search lookups during processing + paramChangeBuffer.sort(); } void sendMidi (int64_t baseTimeStamp, AUAudioFrameCount frameCount) @@ -1060,7 +1455,7 @@ void sendParameterEvent (int idx, float newValue, AUParameterAutomationEventType if (yupParam == nullptr) return; - const auto value = newValue * getMaximumParameterValue (*yupParam); + const auto value = yupParam->convertToDenormalizedValue (newValue); if (@available (macOS 10.12, *)) { @@ -1157,7 +1552,7 @@ void unregisterAllParameterListeners() NSUniquePtr> channelCapabilities; NSUniquePtr paramTree; - AUParameterObserverToken* editorObserverToken = nullptr; + AUParameterObserverToken _Nullable * _Nullable editorObserverToken = nullptr; mutable std::mutex factoryPresetsMutex; NSUniquePtr> factoryPresets; @@ -1166,8 +1561,17 @@ void unregisterAllParameterListeners() ObjCBlock renderContextObserver; MidiBuffer midiMessages; - ParameterChangeBuffer emptyParamChangeBuffer; + ParameterChangeBuffer paramChangeBuffer; AudioBuffer scratchBuffer; + AudioBuffer scratchOutputBuffer; + + // Per-bus view state, pre-allocated in allocateRenderResources so the + // render callback only clears and refills the vectors without allocating + AudioPluginAURenderViews renderViews; + + // Interleaved scratch buffer for format conversion (deinterleave/interleave) + HeapBlock interleavedScratchData; + size_t interleavedScratchSize = 0; AUMIDIOutputEventBlock midiOutputEventBlock = nullptr; @@ -1176,13 +1580,14 @@ void unregisterAllParameterListeners() AudioTimeStamp lastTimeStamp; - AudioParameter* bypassHostParam = nullptr; + std::unique_ptr bypassHostParam; double viewConfigWidth = 0; double viewConfigHeight = 0; ThreadLocalValue inParameterChangedCallback; bool allocated = false; + AUAudioFrameCount allocatedMaximumFrames = 0; YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AudioPluginProcessorAUv3) }; @@ -1369,7 +1774,7 @@ void unregisterAllParameterListeners() return getIvar (self, "cppObject"); } - static void setThis (id self, AudioPluginProcessorAUv3* cpp) + static void setThis (id self, AudioPluginProcessorAUv3* _Nullable cpp) { setIvar (self, "cppObject", cpp); } @@ -1386,55 +1791,30 @@ explicit AudioPluginViewControllerv3 (AUViewController* vc) : myself (vc) { initialiseYup_GUI(); - processor.reset (::createPluginProcessor()); } ~AudioPluginViewControllerv3() override { if (processor != nullptr) { - yup::endActiveParameterGestures (processor.get()); + yup::endActiveParameterGestures (processor); processor->removeListener (this); + } - if (editor != nullptr) - { - delete editor; - editor = nullptr; - } + if (editor != nullptr) + { + delete editor; + editor = nullptr; } } void loadView() { - if (processor == nullptr) - return; - - if (processor->hasEditor()) - { - editor = processor->createEditor(); - - if (editor != nullptr) - { - preferredSize = { editor->getWidth(), editor->getHeight() }; - - NSView* view = [[NSView alloc] initWithFrame:NSMakeRect (0, 0, preferredSize.getWidth(), preferredSize.getHeight())]; - [myself setView:view]; - - editor->setVisible (true); - - auto options = ComponentNative::Options() - .withFlags (ComponentNative::defaultFlags & ~ComponentNative::decoratedWindow); - - editor->addToDesktop (options, (__bridge void*) view); - } - } + createEditorIfNeeded(); } void viewDidLayoutSubviews() { - if (processor == nullptr) - return; - if ([myself view] != nil) { if (editor != nullptr) @@ -1463,33 +1843,57 @@ void audioProcessorChanged (AudioProcessorBase*, const AudioProcessorBase::Chang AUAudioUnit* createAudioUnit (const AudioComponentDescription& desc, NSError** error) { - if (processor == nullptr) - return nil; - - auto* cpp = new AudioPluginProcessorAUv3 (nil, desc, 0, error); - + // Let the ObjC init create the C++ wrapper — it correctly passes self (the + // real AUAudioUnit) to the AudioPluginProcessorAUv3 constructor, so au is + // valid when init() runs. static AUAudioUnitSubclass auClass; auto* au = auClass.createInstance(); au = ObjCMsgSendSuper (au, @selector (initWithComponentDescription:options:error:), desc, 0, error); - if (au == nil) + if (au != nil) { - delete cpp; - return nil; - } + auto* cpp = AUAudioUnitSubclass::_this (au); + processor = cpp != nullptr ? cpp->getProcessor() : nullptr; - AUAudioUnitSubclass::setThis (au, cpp); - cpp->init(); + createEditorIfNeeded(); + } return au; } - AudioProcessor* getProcessor() const { return processor.get(); } + AudioProcessor* getProcessor() const { return processor; } private: + void createEditorIfNeeded() + { + // Already created, or no processor available yet + if (editor != nullptr || processor == nullptr) + return; + + if (processor->hasEditor()) + { + editor = processor->createEditor(); + + if (editor != nullptr) + { + preferredSize = { editor->getWidth(), editor->getHeight() }; + + NSView* view = [[NSView alloc] initWithFrame:NSMakeRect (0, 0, preferredSize.getWidth(), preferredSize.getHeight())]; + [myself setView:view]; + + editor->setVisible (true); + + auto options = ComponentNative::Options() + .withFlags (ComponentNative::defaultFlags & ~ComponentNative::decoratedWindow); + + editor->addToDesktop (options, (__bridge void*) view); + } + } + } + AUViewController* myself = nil; - std::unique_ptr processor; + AudioProcessor* processor = nullptr; AudioProcessorEditor* editor = nullptr; Rectangle preferredSize { 1.0f, 1.0f }; }; @@ -1519,8 +1923,8 @@ - (void) loadView cpp->loadView(); } -- (AUAudioUnit*) createAudioUnitWithComponentDescription:(AudioComponentDescription)desc - error:(NSError**)error +- (AUAudioUnit* _Nullable) createAudioUnitWithComponentDescription:(AudioComponentDescription)desc + error:(NSError* _Nullable* _Nullable)error { return cpp->createAudioUnit (desc, error); } @@ -1542,4 +1946,6 @@ - (void) viewDidLayout @end +NS_ASSUME_NONNULL_END + #endif // YUP_MAC 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 74bc8d3fd..080394416 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 @@ -34,7 +34,7 @@ #include -extern "C" yup::AudioProcessor* createPluginProcessor(); +extern "C" yup::AudioProcessor* YUP_AUDIO_PLUGIN_CREATE_FUNCTION(); namespace yup { @@ -518,6 +518,7 @@ class AudioPluginProcessorCLAP final const void* getExtension (std::string_view id); const clap_plugin_t* getPlugin() const; + AudioProcessor* getProcessor() const noexcept; void editorResized(); ScopedValueSetter scopedHostEditorResizing(); @@ -576,8 +577,22 @@ class AudioPluginProcessorCLAP final std::vector listenedParameters; std::vector outputChannelsFloat; std::vector outputChannelsDouble; + + std::vector> inputBusViewsFloat; + std::vector> outputBusViewsFloat; + std::vector> inputBusViewsDouble; + std::vector> outputBusViewsDouble; + bool isBypassed = false; std::atomic isActive { false }; + + //============================================================================== + /** Returns true when the CLAP audio bus role is Main. */ + bool isCLAPAudioBusMain (uint32_t clapAudioBusIndex, bool isInput) const noexcept + { + return audioProcessor->getBusLayout().getAudioBusRole (static_cast (clapAudioBusIndex), isInput) == AudioBus::Role::Main; + } + std::atomic isInsideProcessBlock { false }; std::atomic callLatencyChangeOnNextActivate { false }; std::atomic tailChangedPending { false }; @@ -733,9 +748,13 @@ AudioPluginProcessorCLAP::AudioPluginProcessorCLAP (const clap_host_t* host) if (useDoublePrecision) { - // Copy input audio into output buffers for effect processors (double) + // Copy main input audio into matching main output buffers for effects. + // Auxiliary (sidechain) inputs are NOT copied to outputs. for (uint32_t busIdx = 0; busIdx < std::min (process->audio_inputs_count, process->audio_outputs_count); ++busIdx) { + if (! wrapper->isCLAPAudioBusMain (busIdx, true)) + continue; + const auto& inBus = process->audio_inputs[busIdx]; const auto& outBus = process->audio_outputs[busIdx]; const uint32_t chCount = std::min (inBus.channel_count, outBus.channel_count); @@ -759,7 +778,37 @@ AudioPluginProcessorCLAP::AudioPluginProcessorCLAP (const clap_host_t* host) 0, static_cast (process->frames_count)); - AudioProcessContext context { audioBuffer, midiBuffer, wrapper->paramChangeBuffer, playHeadPtr }; + // Build per-bus input views (all input buses) + wrapper->inputBusViewsDouble.clear(); + for (uint32_t busIdx = 0; busIdx < process->audio_inputs_count; ++busIdx) + { + const auto& inBus = process->audio_inputs[busIdx]; + const bool isSilent = inBus.constant_mask != 0; // CLAP silence flag + wrapper->inputBusViewsDouble.emplace_back ( + isSilent ? nullptr : reinterpret_cast (inBus.data64), + static_cast (inBus.channel_count), + audioProcessor.getBusLayout().getAudioBusRole (static_cast (busIdx), true)); + } + + // Build per-bus output views (all output buses) + wrapper->outputBusViewsDouble.clear(); + for (uint32_t busIdx = 0; busIdx < process->audio_outputs_count; ++busIdx) + { + const auto& outBus = process->audio_outputs[busIdx]; + wrapper->outputBusViewsDouble.emplace_back ( + reinterpret_cast (outBus.data64), + static_cast (outBus.channel_count), + audioProcessor.getBusLayout().getAudioBusRole (static_cast (busIdx), false)); + } + + AudioProcessContext context { + audioBuffer, + midiBuffer, + wrapper->paramChangeBuffer, + playHeadPtr, + { wrapper->inputBusViewsDouble.data(), wrapper->inputBusViewsDouble.size() }, + { wrapper->outputBusViewsDouble.data(), wrapper->outputBusViewsDouble.size() } + }; wrapper->isInsideProcessBlock.store (true); processAudioBlock (audioProcessor, context, bypassed); @@ -767,9 +816,13 @@ AudioPluginProcessorCLAP::AudioPluginProcessorCLAP (const clap_host_t* host) } else { - // Copy input audio into output buffers for effect processors (float) + // Copy main input audio into matching main output buffers for effects. + // Auxiliary (sidechain) inputs are NOT copied to outputs. for (uint32_t busIdx = 0; busIdx < std::min (process->audio_inputs_count, process->audio_outputs_count); ++busIdx) { + if (! wrapper->isCLAPAudioBusMain (busIdx, true)) + continue; + const auto& inBus = process->audio_inputs[busIdx]; const auto& outBus = process->audio_outputs[busIdx]; const uint32_t chCount = std::min (inBus.channel_count, outBus.channel_count); @@ -793,7 +846,37 @@ AudioPluginProcessorCLAP::AudioPluginProcessorCLAP (const clap_host_t* host) 0, static_cast (process->frames_count)); - AudioProcessContext context { audioBuffer, midiBuffer, wrapper->paramChangeBuffer, playHeadPtr }; + // Build per-bus input views (all input buses) + wrapper->inputBusViewsFloat.clear(); + for (uint32_t busIdx = 0; busIdx < process->audio_inputs_count; ++busIdx) + { + const auto& inBus = process->audio_inputs[busIdx]; + const bool isSilent = inBus.constant_mask != 0; // CLAP silence flag + wrapper->inputBusViewsFloat.emplace_back ( + isSilent ? nullptr : reinterpret_cast (inBus.data32), + static_cast (inBus.channel_count), + audioProcessor.getBusLayout().getAudioBusRole (static_cast (busIdx), true)); + } + + // Build per-bus output views (all output buses) + wrapper->outputBusViewsFloat.clear(); + for (uint32_t busIdx = 0; busIdx < process->audio_outputs_count; ++busIdx) + { + const auto& outBus = process->audio_outputs[busIdx]; + wrapper->outputBusViewsFloat.emplace_back ( + reinterpret_cast (outBus.data32), + static_cast (outBus.channel_count), + audioProcessor.getBusLayout().getAudioBusRole (static_cast (busIdx), false)); + } + + AudioProcessContext context { + audioBuffer, + midiBuffer, + wrapper->paramChangeBuffer, + playHeadPtr, + { wrapper->inputBusViewsFloat.data(), wrapper->inputBusViewsFloat.size() }, + { wrapper->outputBusViewsFloat.data(), wrapper->outputBusViewsFloat.size() } + }; wrapper->isInsideProcessBlock.store (true); processAudioBlock (audioProcessor, context, bypassed); @@ -866,7 +949,7 @@ bool AudioPluginProcessorCLAP::initialise() { jassert (audioProcessor == nullptr); - audioProcessor.reset (::createPluginProcessor()); + audioProcessor.reset (::YUP_AUDIO_PLUGIN_CREATE_FUNCTION()); if (audioProcessor == nullptr) return false; @@ -1126,7 +1209,9 @@ bool AudioPluginProcessorCLAP::initialise() info->id = index; info->channel_count = audioBus->getNumChannels(); - uint32_t flags = (index == 0) ? CLAP_AUDIO_PORT_IS_MAIN : 0; + uint32_t flags = audioBus->getRole() == AudioBus::Role::Main + ? CLAP_AUDIO_PORT_IS_MAIN + : 0; if (audioProcessor->supportsDoublePrecisionProcessing()) flags |= CLAP_AUDIO_PORT_SUPPORTS_64BITS | CLAP_AUDIO_PORT_PREFERS_64BITS | CLAP_AUDIO_PORT_REQUIRES_COMMON_SAMPLE_SIZE; info->flags = flags; @@ -1551,6 +1636,17 @@ bool AudioPluginProcessorCLAP::activate (float sampleRate, int samplesPerBlock) outputChannelsFloat.reserve (static_cast (totalOutputChannels)); outputChannelsDouble.reserve (static_cast (totalOutputChannels)); + // Pre-allocate per-bus view storage + { + const auto numAudioInputs = static_cast (audioProcessor->getNumAudioInputs()); + const auto numAudioOutputs = static_cast (audioProcessor->getNumAudioOutputs()); + + inputBusViewsFloat.reserve (numAudioInputs); + outputBusViewsFloat.reserve (numAudioOutputs); + inputBusViewsDouble.reserve (numAudioInputs); + outputBusViewsDouble.reserve (numAudioOutputs); + } + isActive.store (true); return true; @@ -1637,6 +1733,13 @@ const clap_plugin_t* AudioPluginProcessorCLAP::getPlugin() const //============================================================================== +AudioProcessor* AudioPluginProcessorCLAP::getProcessor() const noexcept +{ + return audioProcessor.get(); +} + +//============================================================================== + void AudioPluginProcessorCLAP::addParameterListeners() { removeParameterListeners(); diff --git a/modules/yup_audio_plugin_client/common/yup_AudioPluginAUHelpers.h b/modules/yup_audio_plugin_client/common/yup_AudioPluginAUHelpers.h new file mode 100644 index 000000000..d147e9f06 --- /dev/null +++ b/modules/yup_audio_plugin_client/common/yup_AudioPluginAUHelpers.h @@ -0,0 +1,152 @@ +/* + ============================================================================== + + 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 + +#if YUP_MAC + +#import + +#include + +#include +#include + +//============================================================================== +namespace yup +{ + +/** Returns a hex string describing a pointer, for debug logging. */ +inline String describePointer (const void* value) +{ + return "0x" + String::toHexString (static_cast (reinterpret_cast (value))); +} + +/** Returns the dictionary key used to store the YUP processor state + alongside the host's own AU state (shared by the AUv2 and AUv3 clients). */ +inline CFStringRef getAUProcessorStateKey() +{ + return CFSTR ("YUPProcessorState"); +} + +/** Maps a ParameterUnit to the corresponding AudioUnitParameterUnit constant. */ +inline AudioUnitParameterUnit makeAUUnit (const AudioParameter& param) +{ + switch (param.getUnit()) + { + case AudioParameter::ParameterUnit::Generic: + return kAudioUnitParameterUnit_Generic; + case AudioParameter::ParameterUnit::Percent: + return kAudioUnitParameterUnit_Percent; + case AudioParameter::ParameterUnit::Decibels: + return kAudioUnitParameterUnit_Decibels; + case AudioParameter::ParameterUnit::Hertz: + return kAudioUnitParameterUnit_Hertz; + case AudioParameter::ParameterUnit::Milliseconds: + return kAudioUnitParameterUnit_Milliseconds; + case AudioParameter::ParameterUnit::Seconds: + return kAudioUnitParameterUnit_Seconds; + case AudioParameter::ParameterUnit::Degrees: + return kAudioUnitParameterUnit_Degrees; + case AudioParameter::ParameterUnit::Cents: + return kAudioUnitParameterUnit_Cents; + case AudioParameter::ParameterUnit::Semitones: + return kAudioUnitParameterUnit_RelativeSemiTones; + case AudioParameter::ParameterUnit::Octaves: + return kAudioUnitParameterUnit_Octaves; + case AudioParameter::ParameterUnit::BPM: + return kAudioUnitParameterUnit_BPM; + case AudioParameter::ParameterUnit::Beats: + return kAudioUnitParameterUnit_Beats; + case AudioParameter::ParameterUnit::Ratio: + return kAudioUnitParameterUnit_Ratio; + case AudioParameter::ParameterUnit::LinearGain: + return kAudioUnitParameterUnit_LinearGain; + case AudioParameter::ParameterUnit::Pan: + return kAudioUnitParameterUnit_Pan; + case AudioParameter::ParameterUnit::MIDINoteNumber: + return kAudioUnitParameterUnit_MIDINoteNumber; + case AudioParameter::ParameterUnit::Custom: + return kAudioUnitParameterUnit_CustomUnit; + } + + return kAudioUnitParameterUnit_Generic; +} + +/** Returns the base AudioUnitParameterOptions shared by the AUv2 and AUv3 + clients: readable, plus writable when the parameter is not read-only. + + AUv2 callers must OR in kAudioUnitParameterFlag_HasCFNameString. +*/ +inline AudioUnitParameterOptions makeAUParameterFlags (const AudioParameter& param) +{ + AudioUnitParameterOptions flags = kAudioUnitParameterFlag_IsReadable; + + if (! param.isReadOnly()) + flags |= kAudioUnitParameterFlag_IsWritable; + + return flags; +} + +/** Returns the AudioUnitParameterOptions used by the AUv3 client. + + Extends makeAUParameterFlags with value-string and ramp capabilities. + This must not be used by the AUv2 client, which implements no string + properties and cannot schedule parameters. +*/ +inline AudioUnitParameterOptions makeAUv3ParameterFlags (const AudioParameter& param) +{ + AudioUnitParameterOptions flags = makeAUParameterFlags (param) + | kAudioUnitParameterFlag_ValuesHaveStrings; + + if (! param.isStepped()) + flags |= kAudioUnitParameterFlag_CanRamp; + + return flags; +} + +//============================================================================== + +/** Per-bus render view storage shared by the AUv2 and AUv3 clients. + + Pre-allocated once at initialization so the render callback only clears + and refills the vectors without allocating. +*/ +struct AudioPluginAURenderViews +{ + std::vector> inputBusViews; + std::vector> outputBusViews; + std::vector inputChannelPtrStorage; + std::vector outputChannelPtrStorage; + + /** Reserves and sizes the storage for the processor's bus layout. */ + void prepare (const AudioProcessor& processor, int totalInputChannels, int totalOutputChannels) + { + inputBusViews.reserve (static_cast (processor.getNumAudioInputs())); + outputBusViews.reserve (static_cast (processor.getNumAudioOutputs())); + inputChannelPtrStorage.resize (static_cast (totalInputChannels)); + outputChannelPtrStorage.resize (static_cast (totalOutputChannels)); + } +}; + +} // namespace yup + +#endif // YUP_MAC diff --git a/modules/yup_audio_plugin_client/common/yup_AudioPluginUtilities.h b/modules/yup_audio_plugin_client/common/yup_AudioPluginUtilities.h index 7c453f935..f1133a0f8 100644 --- a/modules/yup_audio_plugin_client/common/yup_AudioPluginUtilities.h +++ b/modules/yup_audio_plugin_client/common/yup_AudioPluginUtilities.h @@ -66,6 +66,18 @@ inline int getTotalAudioOutputChannels (const AudioProcessor& processor) return count; } +/** Returns the total number of input audio channels across all input audio buses. */ +inline int getTotalAudioInputChannels (const AudioProcessor& processor) +{ + int count = 0; + + for (const auto& bus : processor.getBusLayout().getInputBuses()) + if (bus.getType() == AudioBus::Type::Audio) + count += bus.getNumChannels(); + + return count; +} + /** Returns the default automation-event capacity used by plugin wrappers. */ inline int getDefaultParameterChangeCapacity (const AudioProcessor& processor) { diff --git a/modules/yup_audio_plugin_client/lv2/yup_audio_plugin_client_LV2.cpp b/modules/yup_audio_plugin_client/lv2/yup_audio_plugin_client_LV2.cpp index f4b99c98e..a6c048af7 100644 --- a/modules/yup_audio_plugin_client/lv2/yup_audio_plugin_client_LV2.cpp +++ b/modules/yup_audio_plugin_client/lv2/yup_audio_plugin_client_LV2.cpp @@ -50,7 +50,7 @@ //============================================================================== -extern "C" yup::AudioProcessor* createPluginProcessor(); +extern "C" yup::AudioProcessor* YUP_AUDIO_PLUGIN_CREATE_FUNCTION(); namespace yup { @@ -205,7 +205,7 @@ class AudioPluginProcessorLV2 : private AudioProcessorBase::Listener processFeatures (features); - processor.reset (createPluginProcessor()); + processor.reset (::YUP_AUDIO_PLUGIN_CREATE_FUNCTION()); jassert (processor != nullptr); if (processor == nullptr) @@ -214,6 +214,12 @@ class AudioPluginProcessorLV2 : private AudioProcessorBase::Listener numInputChannels = processor->getBusLayout().getNumAudioInputChannels(); numOutputChannels = processor->getBusLayout().getNumAudioOutputChannels(); + // Pre-allocate per-bus view storage so run() never allocates + inputBusViews.reserve (static_cast (processor->getNumAudioInputs())); + outputBusViews.reserve (static_cast (processor->getNumAudioOutputs())); + inputChannelPtrStorage.resize (static_cast (numInputChannels)); + outputChannelPtrStorage.resize (static_cast (numOutputChannels)); + // Build parameter Urid maps const auto params = processor->getParameters(); lastSentValues.resize (params.size(), -1.0f); @@ -315,6 +321,9 @@ class AudioPluginProcessorLV2 : private AudioProcessorBase::Listener audioBuffer.copyFrom (ch, 0, src, numSamplesInt); } + buildInputBusViews(); + buildOutputBusViews(); + // Apply offline processing state if (freeWheelingPort != nullptr) processor->setOfflineProcessing (*freeWheelingPort > 0.5f); @@ -323,7 +332,12 @@ class AudioPluginProcessorLV2 : private AudioProcessorBase::Listener isBypassed = ! isEnabled; // Build process context and process - AudioProcessContext context { audioBuffer, midiEvents, parameterChanges, &playHead }; + AudioProcessContext context { audioBuffer, + midiEvents, + parameterChanges, + &playHead, + { inputBusViews.data(), inputBusViews.size() }, + { outputBusViews.data(), outputBusViews.size() } }; { const ScopedLock lock (processor->getProcessLock()); @@ -362,6 +376,66 @@ class AudioPluginProcessorLV2 : private AudioProcessorBase::Listener *latencyPort = static_cast (processor->getLatencySamples()); } + // Builds one AudioBusBufferView per audio input bus from the host input + // port buffers. Channels are consumed in bus declaration order; ports not + // connected by the host (null) become null channel pointers so processors + // see deterministic silence. + void buildInputBusViews() + { + inputBusViews.clear(); + + PortIndices indices { numInputChannels, numOutputChannels }; + int chOffset = 0; + + for (const auto& bus : processor->getBusLayout().getInputBuses()) + { + if (bus.getType() != AudioBus::Type::Audio) + continue; + + const int numCh = bus.getNumChannels(); + auto* chPtrs = inputChannelPtrStorage.data() + chOffset; + + for (int ch = 0; ch < numCh; ++ch) + { + const auto port = indices.getAudioInputPort (chOffset + ch); + const auto* src = (chOffset + ch < numInputChannels) ? audioPorts[static_cast (port)] : nullptr; + chPtrs[ch] = src; + } + + inputBusViews.emplace_back (chPtrs, numCh, bus.getRole()); + + chOffset += numCh; + } + } + + // Builds one AudioBusBufferView per audio output bus pointing into the + // in-place processing buffer, which is copied to the output ports after + // processing (the same memory the flat AudioProcessContext::audio exposes). + void buildOutputBusViews() + { + outputBusViews.clear(); + + int chOffset = 0; + + for (const auto& bus : processor->getBusLayout().getOutputBuses()) + { + if (bus.getType() != AudioBus::Type::Audio) + continue; + + const int numCh = bus.getNumChannels(); + auto* chPtrs = outputChannelPtrStorage.data() + chOffset; + + for (int ch = 0; ch < numCh; ++ch) + chPtrs[ch] = (chOffset + ch < numOutputChannels) + ? audioBuffer.getWritePointer (chOffset + ch) + : nullptr; + + outputBusViews.emplace_back (chPtrs, numCh, bus.getRole()); + + chOffset += numCh; + } + } + void deactivate() { } @@ -639,6 +713,12 @@ class AudioPluginProcessorLV2 : private AudioProcessorBase::Listener const float* freeWheelingPort = nullptr; const float* enabledPort = nullptr; + // Per-bus view state, pre-allocated at construction so run() never allocates + std::vector> inputBusViews; + std::vector> outputBusViews; + std::vector inputChannelPtrStorage; + std::vector outputChannelPtrStorage; + LV2_Worker_Schedule* workerSchedule = nullptr; std::map paramUridToIndex; diff --git a/modules/yup_audio_plugin_client/standalone/yup_audio_plugin_client_Standalone.cpp b/modules/yup_audio_plugin_client/standalone/yup_audio_plugin_client_Standalone.cpp index ff20dd281..a2000a480 100644 --- a/modules/yup_audio_plugin_client/standalone/yup_audio_plugin_client_Standalone.cpp +++ b/modules/yup_audio_plugin_client/standalone/yup_audio_plugin_client_Standalone.cpp @@ -29,7 +29,7 @@ #error "YUP_AUDIO_PLUGIN_ENABLE_STANDALONE must be defined" #endif -extern "C" yup::AudioProcessor* createPluginProcessor(); +extern "C" yup::AudioProcessor* YUP_AUDIO_PLUGIN_CREATE_FUNCTION(); namespace yup { @@ -73,7 +73,7 @@ class AudioProcessorApplication { public: AudioProcessorApplication() - : processor (::createPluginProcessor()) + : processor (::YUP_AUDIO_PLUGIN_CREATE_FUNCTION()) { } diff --git a/modules/yup_audio_plugin_client/tools/yup_lv2_ttl_generator.cpp b/modules/yup_audio_plugin_client/tools/yup_lv2_ttl_generator.cpp index 872c1a655..823ac1c84 100644 --- a/modules/yup_audio_plugin_client/tools/yup_lv2_ttl_generator.cpp +++ b/modules/yup_audio_plugin_client/tools/yup_lv2_ttl_generator.cpp @@ -36,7 +36,7 @@ //============================================================================== -extern "C" yup::AudioProcessor* createPluginProcessor(); +extern "C" yup::AudioProcessor* YUP_AUDIO_PLUGIN_CREATE_FUNCTION(); //============================================================================== @@ -331,7 +331,7 @@ int main (int argc, char** argv) return 1; } - std::unique_ptr processor (createPluginProcessor()); + std::unique_ptr processor (::YUP_AUDIO_PLUGIN_CREATE_FUNCTION()); if (processor == nullptr) { std::cerr << "lv2_ttl_generator: createPluginProcessor() returned nullptr\n"; 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 c66558627..f69b39128 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 @@ -53,7 +53,7 @@ //============================================================================== -extern "C" yup::AudioProcessor* createPluginProcessor(); +extern "C" yup::AudioProcessor* YUP_AUDIO_PLUGIN_CREATE_FUNCTION(); namespace yup { @@ -992,9 +992,11 @@ class AudioPluginControllerVST3 ? Vst::ParameterInfo::kCanAutomate : 0; + const auto unitShortName = String (AudioParameter::getParameterUnitShortName (parameter->getUnit(), parameter->getUnitName())); + parameters.addParameter ( - reinterpret_cast (parameter->getName().toUTF16().getAddress()), - nullptr, // units + toTChar (parameter->getName().toUTF16()), + unitShortName.isEmpty() ? nullptr : toTChar (unitShortName.toUTF16()), parameter->getNumSteps(), // step count parameter->getNormalizedValue(), // normalized value flags, // flags @@ -1132,7 +1134,7 @@ class AudioPluginProcessorVST3 : public Vst::AudioEffect AudioPluginProcessorVST3() { - processor.reset (::createPluginProcessor()); + processor.reset (::YUP_AUDIO_PLUGIN_CREATE_FUNCTION()); setControllerClass (YupPlugin_Controller_UID); } @@ -1145,6 +1147,10 @@ class AudioPluginProcessorVST3 : public Vst::AudioEffect //============================================================================== + AudioProcessor* getProcessor() const noexcept { return processor.get(); } + + //============================================================================== + static FUnknown* createInstance ([[maybe_unused]] void* context) { return (Vst::IAudioProcessor*) new AudioPluginProcessorVST3(); @@ -1163,9 +1169,20 @@ class AudioPluginProcessorVST3 : public Vst::AudioEffect const auto nameUTF16 = inputBus.getName().toUTF16(); if (inputBus.getType() == AudioBus::Type::Audio) - addAudioInput (toTChar (nameUTF16), speakerArrForChannels (inputBus.getNumChannels())); + { + const auto busType = inputBus.getRole() == AudioBus::Role::Auxiliary + ? Steinberg::Vst::kAux + : Steinberg::Vst::kMain; + const auto busFlags = inputBus.isDefaultActive() + ? Steinberg::Vst::BusInfo::kDefaultActive + : 0; + + addAudioInput (toTChar (nameUTF16), speakerArrForChannels (inputBus.getNumChannels()), busType, busFlags); + } else if (inputBus.getType() == AudioBus::Type::Midi) + { addEventInput (toTChar (nameUTF16)); + } } for (const auto& outputBus : processor->getBusLayout().getOutputBuses()) @@ -1173,9 +1190,20 @@ class AudioPluginProcessorVST3 : public Vst::AudioEffect const auto nameUTF16 = outputBus.getName().toUTF16(); if (outputBus.getType() == AudioBus::Type::Audio) - addAudioOutput (toTChar (nameUTF16), speakerArrForChannels (outputBus.getNumChannels())); + { + const auto busType = outputBus.getRole() == AudioBus::Role::Auxiliary + ? Steinberg::Vst::kAux + : Steinberg::Vst::kMain; + const auto busFlags = outputBus.isDefaultActive() + ? Steinberg::Vst::BusInfo::kDefaultActive + : 0; + + addAudioOutput (toTChar (nameUTF16), speakerArrForChannels (outputBus.getNumChannels()), busType, busFlags); + } else if (outputBus.getType() == AudioBus::Type::Midi) + { addEventOutput (toTChar (nameUTF16)); + } } // Fallback: synths without an explicit MIDI input bus always get one @@ -1404,6 +1432,17 @@ class AudioPluginProcessorVST3 : public Vst::AudioEffect outputChannelsFloat.reserve (static_cast (totalOutputChannels)); outputChannelsDouble.reserve (static_cast (totalOutputChannels)); + // Pre-allocate per-bus view storage + { + const auto numAudioInputs = static_cast (processor->getNumAudioInputs()); + const auto numAudioOutputs = static_cast (processor->getNumAudioOutputs()); + + inputBusViewsFloat.reserve (numAudioInputs); + outputBusViewsFloat.reserve (numAudioOutputs); + inputBusViewsDouble.reserve (numAudioInputs); + outputBusViewsDouble.reserve (numAudioOutputs); + } + return kResultOk; } @@ -1535,7 +1574,8 @@ class AudioPluginProcessorVST3 : public Vst::AudioEffect const bool useDoublePrecision = processSetup.symbolicSampleSize == Vst::kSample64 && processor->supportsDoublePrecisionProcessing(); - // Copy input audio into output buffers for effects + // Copy main input audio into matching main output buffers for effects. + // Auxiliary (sidechain) inputs are NOT copied to outputs. if (data.inputs != nullptr) { for (int32 busIdx = 0; busIdx < std::min (data.numInputs, data.numOutputs); ++busIdx) @@ -1543,6 +1583,10 @@ class AudioPluginProcessorVST3 : public Vst::AudioEffect auto& inBus = data.inputs[busIdx]; auto& outBus = data.outputs[busIdx]; + // Only copy when the bus role is Main (skip Auxiliary/sidechain) + if (! isVST3AudioBusMain (busIdx, true)) + continue; + for (int32 ch = 0; ch < std::min (inBus.numChannels, outBus.numChannels); ++ch) { if (useDoublePrecision) @@ -1568,6 +1612,7 @@ class AudioPluginProcessorVST3 : public Vst::AudioEffect if (useDoublePrecision) { + // Build output channel pointers (flat buffer, backward compat) outputChannelsDouble.clear(); for (int32 busIdx = 0; busIdx < data.numOutputs; ++busIdx) for (int32 ch = 0; ch < data.outputs[busIdx].numChannels; ++ch) @@ -1578,12 +1623,30 @@ class AudioPluginProcessorVST3 : public Vst::AudioEffect 0, data.numSamples); - AudioProcessContext doubleCtx { audioBuffer, midiBuffer, paramChangeBuffer, playHeadPtr }; + // Build per-bus input views (all input buses) + inputBusViewsDouble.clear(); + for (int32 busIdx = 0; busIdx < data.numInputs; ++busIdx) + inputBusViewsDouble.push_back (buildVST3InputBusView (data, busIdx)); + + // Build per-bus output views (all output buses) + outputBusViewsDouble.clear(); + for (int32 busIdx = 0; busIdx < data.numOutputs; ++busIdx) + outputBusViewsDouble.push_back (buildVST3OutputBusView (data, busIdx)); + + AudioProcessContext doubleCtx { + audioBuffer, + midiBuffer, + paramChangeBuffer, + playHeadPtr, + { inputBusViewsDouble.data(), static_cast (inputBusViewsDouble.size()) }, + { outputBusViewsDouble.data(), static_cast (outputBusViewsDouble.size()) } + }; processAudioBlock (*processor, doubleCtx, bypassed); } else { + // Build output channel pointers (flat buffer, backward compat) outputChannelsFloat.clear(); for (int32 busIdx = 0; busIdx < data.numOutputs; ++busIdx) for (int32 ch = 0; ch < data.outputs[busIdx].numChannels; ++ch) @@ -1594,7 +1657,24 @@ class AudioPluginProcessorVST3 : public Vst::AudioEffect 0, data.numSamples); - AudioProcessContext context { audioBuffer, midiBuffer, paramChangeBuffer, playHeadPtr }; + // Build per-bus input views (all input buses) + inputBusViewsFloat.clear(); + for (int32 busIdx = 0; busIdx < data.numInputs; ++busIdx) + inputBusViewsFloat.push_back (buildVST3InputBusView (data, busIdx)); + + // Build per-bus output views (all output buses) + outputBusViewsFloat.clear(); + for (int32 busIdx = 0; busIdx < data.numOutputs; ++busIdx) + outputBusViewsFloat.push_back (buildVST3OutputBusView (data, busIdx)); + + AudioProcessContext context { + audioBuffer, + midiBuffer, + paramChangeBuffer, + playHeadPtr, + { inputBusViewsFloat.data(), static_cast (inputBusViewsFloat.size()) }, + { outputBusViewsFloat.data(), static_cast (outputBusViewsFloat.size()) } + }; processAudioBlock (*processor, context, bypassed); } @@ -1618,7 +1698,70 @@ class AudioPluginProcessorVST3 : public Vst::AudioEffect ParameterChangeBuffer paramChangeBuffer; std::vector outputChannelsFloat; std::vector outputChannelsDouble; + + std::vector> inputBusViewsFloat; + std::vector> outputBusViewsFloat; + std::vector> inputBusViewsDouble; + std::vector> outputBusViewsDouble; + bool isBypassed = false; + + //============================================================================== + /** Looks up the role of a VST3 audio bus by its audio-bus index. + Returns true when the bus role is Main. */ + bool isVST3AudioBusMain (int32 vst3AudioBusIndex, bool isInput) const noexcept + { + return processor->getBusLayout().getAudioBusRole (vst3AudioBusIndex, isInput) == AudioBus::Role::Main; + } + + /** Builds an AudioBusBufferView for a VST3 input bus. */ + template + AudioBusBufferView buildVST3InputBusView (const Vst::ProcessData& data, int32 busIdx) const + { + const auto& inBus = data.inputs[busIdx]; + + if constexpr (std::is_same_v) + { + return { reinterpret_cast (inBus.channelBuffers64), + static_cast (inBus.numChannels), + processor->getBusLayout().getAudioBusRole (busIdx, true) }; + } + else + { + // Silence flag: when the host reports silence, provide null pointers so + // the processor can detect inactive buses via getReadPointer returning null. + const bool isSilent = (inBus.silenceFlags != 0); + if (isSilent) + { + // All channels are silent — return a view with null data pointers + return { nullptr, static_cast (inBus.numChannels), processor->getBusLayout().getAudioBusRole (busIdx, true) }; + } + + return { reinterpret_cast (inBus.channelBuffers32), + static_cast (inBus.numChannels), + processor->getBusLayout().getAudioBusRole (busIdx, true) }; + } + } + + /** Builds an AudioBusBufferView for a VST3 output bus. */ + template + AudioBusBufferView buildVST3OutputBusView (const Vst::ProcessData& data, int32 busIdx) const + { + const auto& outBus = data.outputs[busIdx]; + + if constexpr (std::is_same_v) + { + return { reinterpret_cast (outBus.channelBuffers64), + static_cast (outBus.numChannels), + processor->getBusLayout().getAudioBusRole (busIdx, false) }; + } + else + { + return { reinterpret_cast (outBus.channelBuffers32), + static_cast (outBus.numChannels), + processor->getBusLayout().getAudioBusRole (busIdx, false) }; + } + } }; #ifdef YupPlugin_VST3_Categories diff --git a/modules/yup_audio_plugin_client/yup_audio_plugin_client.h b/modules/yup_audio_plugin_client/yup_audio_plugin_client.h index 46299b5d5..16513903e 100644 --- a/modules/yup_audio_plugin_client/yup_audio_plugin_client.h +++ b/modules/yup_audio_plugin_client/yup_audio_plugin_client.h @@ -93,4 +93,22 @@ //============================================================================== +/** Config: YUP_AUDIO_PLUGIN_CREATE_FUNCTION + + The name of the extern "C" function that plugin wrappers call to obtain + an AudioProcessor instance. The user must define a function with this name, + returning a heap-allocated AudioProcessor. Defaults to createPluginProcessor. + + Override before including the wrapper source to change the entry-point name. +*/ +#ifndef YUP_AUDIO_PLUGIN_CREATE_FUNCTION +#define YUP_AUDIO_PLUGIN_CREATE_FUNCTION createPluginProcessor +#endif + +//============================================================================== + #include + +//============================================================================== + +#include "common/yup_AudioPluginUtilities.h" diff --git a/modules/yup_audio_processors/processors/yup_AudioBus.h b/modules/yup_audio_processors/processors/yup_AudioBus.h index 76dfd05d7..dc92c6e49 100644 --- a/modules/yup_audio_processors/processors/yup_AudioBus.h +++ b/modules/yup_audio_processors/processors/yup_AudioBus.h @@ -46,22 +46,37 @@ class AudioBus Output }; + /** The role of the bus within a multi-bus layout. */ + enum class Role + { + /** Primary signal path (e.g. main input, main output). */ + Main, + /** Auxiliary signal path (e.g. sidechain input, reference input). */ + Auxiliary + }; + /** Constructs an AudioBus. - @param name A user-friendly name for the bus. - @param type Signal type. - @param direction Input or output. - @param channels Number of channels (e.g., stereo = 2). + @param name A user-friendly name for the bus. + @param type Signal type. + @param direction Input or output. + @param channels Number of channels (e.g., stereo = 2). + @param role The role of the bus in the layout. + @param isDefaultActive Whether the bus is active by default. */ AudioBus (StringRef name, Type type, Direction direction, - int channels) + int channels, + Role role = Role::Main, + bool isDefaultActive = true) : name (name) , type (type) , direction (direction) , numChannels (channels) + , role (role) + , defaultActive (isDefaultActive) { } @@ -77,6 +92,12 @@ class AudioBus /** Returns the number of channels on the bus. */ int getNumChannels() const noexcept { return numChannels; } + /** Returns the role of the bus within the layout. */ + Role getRole() const noexcept { return role; } + + /** Returns true if the bus is active by default. */ + bool isDefaultActive() const noexcept { return defaultActive; } + /** Returns true if the bus is mono. */ bool isMono() const noexcept { return numChannels == 1; } @@ -88,6 +109,8 @@ class AudioBus Type type = Type::Audio; Direction direction = Direction::Output; int numChannels = 0; + Role role = Role::Main; + bool defaultActive = true; }; } // namespace yup diff --git a/modules/yup_audio_processors/processors/yup_AudioBusBufferView.h b/modules/yup_audio_processors/processors/yup_AudioBusBufferView.h new file mode 100644 index 000000000..9d9a329cd --- /dev/null +++ b/modules/yup_audio_processors/processors/yup_AudioBusBufferView.h @@ -0,0 +1,100 @@ +/* + ============================================================================== + + 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. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== + +/** + A lightweight non-owning view over the channels of a single audio bus. + + AudioBusBufferView wraps a pointer array and channel count to provide + per-bus access within AudioProcessContext, enabling processors to read + individual input buses (main, sidechain) and write individual output + buses independently of the flat channel buffer. + + The const-qualified variant (@c AudioBusBufferView) + provides read-only access for input buses; the mutable variant + (@c AudioBusBufferView) provides read-write access for + output buses. + + @see AudioProcessContext, AudioBus, AudioBusLayout +*/ +template +struct AudioBusBufferView +{ + /** The underlying sample type (float or double). */ + using Type = SampleType; + + /** Default-constructs an empty view with no channels. */ + AudioBusBufferView() = default; + + /** + Constructs a view over @p numCh channels pointed to by @p channelPtrs. + + @param channelPtrs Array of @p numCh sample pointers, one per channel. + Pass nullptr to create a view without channel data + (e.g. for an inactive or silent bus); all channel + accessors will then return nullptr. + @param numCh Number of channels in this bus. + @param busRole The role of this bus in the layout (Main or Auxiliary). + */ + AudioBusBufferView (SampleType* const* channelPtrs, int numCh, AudioBus::Role busRole = AudioBus::Role::Main) noexcept + : channels (channelPtrs) + , numChannels (numCh) + , role (busRole) + { + } + + /** Returns the number of channels in this bus. */ + int getNumChannels() const noexcept { return numChannels; } + + /** Returns the role of this bus within the layout. */ + AudioBus::Role getRole() const noexcept { return role; } + + /** Returns a pointer to the channel data array. + The array has getNumChannels() elements. */ + SampleType* const* getChannels() const noexcept { return channels; } + + /** Returns a read-only pointer to the sample data for channel @p index. + Returns nullptr if @p index is out of range, or when the view has no + channel data (e.g. an inactive or silent bus). */ + const SampleType* getReadPointer (int index) const noexcept + { + return (channels != nullptr && isPositiveAndBelow (index, numChannels)) ? channels[index] : nullptr; + } + + /** Returns a mutable pointer to the sample data for channel @p index. + Returns nullptr if @p index is out of range, or when the view has no + channel data (e.g. an inactive or silent bus). */ + SampleType* getWritePointer (int index) const noexcept + { + return (channels != nullptr && isPositiveAndBelow (index, numChannels)) ? const_cast (channels[index]) : nullptr; + } + +private: + SampleType* const* channels = nullptr; + int numChannels = 0; + AudioBus::Role role = AudioBus::Role::Main; +}; + +} // namespace yup diff --git a/modules/yup_audio_processors/processors/yup_AudioBusLayout.h b/modules/yup_audio_processors/processors/yup_AudioBusLayout.h index 5c285b4fa..8118b2331 100644 --- a/modules/yup_audio_processors/processors/yup_AudioBusLayout.h +++ b/modules/yup_audio_processors/processors/yup_AudioBusLayout.h @@ -82,6 +82,33 @@ class AudioBusLayout return numChannels; } + /** Returns the role of the audio bus at the given audio-only index. + + Audio-bus indexes count only Audio buses (MIDI buses are skipped), which + matches the audio-bus indexing convention used by plugin hosts (e.g. the + VST3 audio-bus index, the CLAP audio-port index, or the AU audio-element + index). Returns Role::Main when @p audioBusIndex is out of range, so + wrapper code can safely fall back to the primary signal path. + */ + AudioBus::Role getAudioBusRole (int audioBusIndex, bool isInput) const noexcept + { + const auto& buses = isInput ? inputBuses : outputBuses; + int audioIdx = 0; + + for (const auto& bus : buses) + { + if (bus.getType() != AudioBus::Type::Audio) + continue; + + if (audioIdx == audioBusIndex) + return bus.getRole(); + + ++audioIdx; + } + + return AudioBus::Role::Main; + } + private: std::vector inputBuses; std::vector outputBuses; diff --git a/modules/yup_audio_processors/processors/yup_AudioParameter.h b/modules/yup_audio_processors/processors/yup_AudioParameter.h index e1689fa17..fdd352b9d 100644 --- a/modules/yup_audio_processors/processors/yup_AudioParameter.h +++ b/modules/yup_audio_processors/processors/yup_AudioParameter.h @@ -48,6 +48,93 @@ class AudioParameter : public ReferenceCountedObject /** A function that converts a string to a real value. */ using StringToValue = std::function; + //============================================================================== + + /** + The measurement unit of a parameter value. + + Describes what the real (denormalised) parameter value represents so that + plugin hosts can display appropriate unit labels and format values + correctly. + + The subset of unit types here maps to every plugin format that supports + measurement units, with `Custom` reserved for user-supplied strings. + */ + enum class ParameterUnit : uint8 + { + Generic, /**< No specific unit. */ + Percent, /**< 0–100%. */ + Decibels, /**< dB. */ + Hertz, /**< Hz. */ + Milliseconds, /**< ms. */ + Seconds, /**< s. */ + Degrees, /**< Angular degrees. */ + Cents, /**< Musical cents (1/100 of a semitone). */ + Semitones, /**< Musical semitones. */ + Octaves, /**< Musical octaves. */ + BPM, /**< Beats per minute. */ + Beats, /**< Beats. */ + Ratio, /**< Dimensionless ratio. */ + LinearGain, /**< Linear gain factor. */ + Pan, /**< Stereo panning. */ + MIDINoteNumber, /**< MIDI note number (0–127). */ + Custom /**< User-defined unit; use unitName for the label. */ + }; + + //============================================================================== + + /** Returns a short display label for a ParameterUnit. + + Suitable for plugin format fields that need a compact unit string + such as VST3's ParameterInfo::units. + + For ParameterUnit::Custom, returns the provided unitName if non-empty, + otherwise an empty string. + */ + static const char* getParameterUnitShortName (ParameterUnit unit, + const String& unitName = {}) + { + switch (unit) + { + case ParameterUnit::Generic: + return ""; + case ParameterUnit::Percent: + return "%"; + case ParameterUnit::Decibels: + return "dB"; + case ParameterUnit::Hertz: + return "Hz"; + case ParameterUnit::Milliseconds: + return "ms"; + case ParameterUnit::Seconds: + return "s"; + case ParameterUnit::Degrees: + return "deg"; + case ParameterUnit::Cents: + return "ct"; + case ParameterUnit::Semitones: + return "st"; + case ParameterUnit::Octaves: + return "oct"; + case ParameterUnit::BPM: + return "bpm"; + case ParameterUnit::Beats: + return "beats"; + case ParameterUnit::Ratio: + return ""; + case ParameterUnit::LinearGain: + return ""; + case ParameterUnit::Pan: + return ""; + case ParameterUnit::MIDINoteNumber: + return ""; + case ParameterUnit::Custom: + return unitName.isNotEmpty() ? unitName.toRawUTF8() : ""; + } + + return ""; + } + /** Sentinel used when a parameter does not provide an explicit host-facing ID. */ static constexpr uint32 invalidHostParameterID = 0xffffffffu; @@ -148,6 +235,14 @@ class AudioParameter : public ReferenceCountedObject /** Optional host-facing module path, using "/" as a separator. */ String modulePath; + /** The measurement unit of the parameter value. + Use `Custom` together with `unitName` for user-defined labels. */ + ParameterUnit unit = ParameterUnit::Generic; + + /** A custom unit label, used only when `unit` is set to `Custom`. + Examples: "dBFS", "LUFS", "ms/cm". */ + String unitName; + private: uint8 flags = automatableFlag; }; @@ -256,6 +351,12 @@ class AudioParameter : public ReferenceCountedObject /** Returns the module path of this parameter. */ String getModulePath() const { return metadata.modulePath; } + /** Returns the measurement unit of this parameter. */ + ParameterUnit getUnit() const noexcept { return metadata.unit; } + + /** Returns the custom unit label string (only meaningful when getUnit() == Custom). */ + String getUnitName() const { return metadata.unitName; } + //============================================================================== /** Begins a change gesture for this parameter. diff --git a/modules/yup_audio_processors/processors/yup_AudioParameterBuilder.cpp b/modules/yup_audio_processors/processors/yup_AudioParameterBuilder.cpp index efe50662e..5c857aa82 100644 --- a/modules/yup_audio_processors/processors/yup_AudioParameterBuilder.cpp +++ b/modules/yup_audio_processors/processors/yup_AudioParameterBuilder.cpp @@ -160,6 +160,14 @@ AudioParameterBuilder& AudioParameterBuilder::withModulePath (const String& modu return *this; } +AudioParameterBuilder& AudioParameterBuilder::withUnit (AudioParameter::ParameterUnit parameterUnit, + const String& parameterUnitName) +{ + metadata.unit = parameterUnit; + metadata.unitName = parameterUnitName; + return *this; +} + //============================================================================== AudioParameter::Ptr AudioParameterBuilder::build() const diff --git a/modules/yup_audio_processors/processors/yup_AudioParameterBuilder.h b/modules/yup_audio_processors/processors/yup_AudioParameterBuilder.h index f5d313a29..e965405ad 100644 --- a/modules/yup_audio_processors/processors/yup_AudioParameterBuilder.h +++ b/modules/yup_audio_processors/processors/yup_AudioParameterBuilder.h @@ -119,6 +119,20 @@ class AudioParameterBuilder /** Sets the optional host-facing module path, using "/" as a separator. */ AudioParameterBuilder& withModulePath (const String& modulePath); + /** + Sets the measurement unit for this parameter. + + Plugins should set this so hosts can display appropriate unit labels. + For custom units not in the ParameterUnit enum, pass + ParameterUnit::Custom together with a unit name string. + + @param parameterUnit The measurement unit. + @param parameterUnitName Optional custom unit name (only used when + parameterUnit is ParameterUnit::Custom). + */ + AudioParameterBuilder& withUnit (AudioParameter::ParameterUnit parameterUnit, + const String& parameterUnitName = {}); + /** Finalizes the builder and returns a fully constructed AudioProcessorParameter instance. diff --git a/modules/yup_audio_processors/processors/yup_AudioProcessContext.h b/modules/yup_audio_processors/processors/yup_AudioProcessContext.h index a8c8c1c9b..6e11b3b88 100644 --- a/modules/yup_audio_processors/processors/yup_AudioProcessContext.h +++ b/modules/yup_audio_processors/processors/yup_AudioProcessContext.h @@ -28,7 +28,8 @@ namespace yup All inputs available to an AudioProcessor for a single processing block. AudioProcessContext is passed to AudioProcessor::processBlock() and bundles: - - the audio I/O buffer (in-place processing model, single or double precision), + - the audio I/O buffer (flat in-place processing model, single or double precision), + - per-bus input and output views (for multi-bus and sidechain processing), - sample-accurate MIDI events, - sample-accurate parameter automation events, - host play-head information, when available. @@ -51,7 +52,9 @@ namespace yup template struct AudioProcessContext { - /** Audio I/O buffer. Process in-place: read and write the same channels. */ + /** Audio I/O buffer. Process in-place: read and write the same channels. + This is the flat concatenation of all output bus channels for backward + compatibility. For per-bus access, use @c inputs and @c outputs. */ AudioBuffer& audio; /** MIDI events for this block, sorted by samplePosition in [0, blockSize). */ @@ -62,6 +65,81 @@ struct AudioProcessContext /** Optional play-head for this block. A null pointer means position information is unavailable. */ AudioPlayHead* playHead = nullptr; + + /** Per-bus read-only views of the input audio buses. + Indexed by audio-bus index (excluding MIDI buses). + Empty when there are no audio inputs. */ + Span> inputs; + + /** Per-bus read-write views of the output audio buses. + Indexed by audio-bus index (excluding MIDI buses). + Empty when there are no audio outputs. */ + Span> outputs; + + //============================================================================== + /** @name Convenience accessors for common bus layouts. + These helpers return the first bus matching the requested role and direction. + They return an empty view when no matching bus exists, so callers should + check @c getNumChannels() before dereferencing. + */ + ///@{ + + /** Returns a view of the first main input bus, or an empty view. */ + const AudioBusBufferView& getMainInput() const noexcept + { + return getInputByRole (AudioBus::Role::Main); + } + + /** Returns a view of the first main output bus, or an empty view. */ + AudioBusBufferView& getMainOutput() noexcept + { + return getOutputByRole (AudioBus::Role::Main); + } + + /** Returns a view of the @p index -th auxiliary input bus, or an empty view. */ + const AudioBusBufferView& getAuxiliaryInput (int index) const noexcept + { + return getInputByRole (AudioBus::Role::Auxiliary, index); + } + + ///@} + +private: + // Shared fallback views returned when no bus matches the requested role. + // They are mutable statics so getOutputByRole can bind them, but callers + // must treat them as read-only (they always report zero channels). + inline static AudioBusBufferView emptyInputView; + inline static AudioBusBufferView emptyOutputView; + + const AudioBusBufferView& getInputByRole (AudioBus::Role role, int skip = 0) const noexcept + { + skip = jmax (0, skip); + for (const auto& input : inputs) + { + if (input.getRole() == role) + { + if (skip == 0) + return input; + --skip; + } + } + return emptyInputView; + } + + AudioBusBufferView& getOutputByRole (AudioBus::Role role, int skip = 0) noexcept + { + skip = jmax (0, skip); + for (auto& output : outputs) + { + if (output.getRole() == role) + { + if (skip == 0) + return output; + --skip; + } + } + return emptyOutputView; + } }; } // namespace yup diff --git a/modules/yup_audio_processors/yup_audio_processors.h b/modules/yup_audio_processors/yup_audio_processors.h index fdf7846c2..34e18548d 100644 --- a/modules/yup_audio_processors/yup_audio_processors.h +++ b/modules/yup_audio_processors/yup_audio_processors.h @@ -55,6 +55,7 @@ //============================================================================== #include "processors/yup_AudioBus.h" +#include "processors/yup_AudioBusBufferView.h" #include "processors/yup_AudioBusLayout.h" #include "processors/yup_AudioParameter.h" #include "processors/yup_AudioParameterBuilder.h" diff --git a/modules/yup_gui/application/yup_Application.cpp b/modules/yup_gui/application/yup_Application.cpp index e6c45faed..4b3c1a5f9 100644 --- a/modules/yup_gui/application/yup_Application.cpp +++ b/modules/yup_gui/application/yup_Application.cpp @@ -26,8 +26,6 @@ namespace yup YUPApplication::YUPApplication() { - initialiseYup_Windowing(); - #if YUP_MAC NSMenu* menuBar = [[NSMenu alloc] init]; NSMenuItem* menuBarItem = [[NSMenuItem alloc] init]; @@ -44,10 +42,7 @@ YUPApplication::YUPApplication() #endif } -YUPApplication::~YUPApplication() -{ - shutdownYup_Windowing(); -} +YUPApplication::~YUPApplication() = default; bool YUPApplication::moreThanOneInstanceAllowed() { diff --git a/modules/yup_gui/application/yup_Application.h b/modules/yup_gui/application/yup_Application.h index 3c702b2d2..a413905fa 100644 --- a/modules/yup_gui/application/yup_Application.h +++ b/modules/yup_gui/application/yup_Application.h @@ -22,6 +22,42 @@ namespace yup { +//============================================================================== + +/** Initialises YUP's Windowing classes. + + @see shutdownYup_Windowing() +*/ +YUP_API void YUP_CALLTYPE initialiseYup_Windowing(); + +/** Clears up any static data being used by YUP's Windowing classes. + + @see initialiseYup_Windowing() +*/ +YUP_API void YUP_CALLTYPE shutdownYup_Windowing(); + +//============================================================================== +/** A utility object that helps you initialise and shutdown YUP Windowing correctly + using an RAII pattern. + + @note initialiseYup_GUI() or ScopedYupInitialiser_GUI must be called before this. +*/ +class YUP_API ScopedYupInitialiser_Windowing final +{ +public: + /** The constructor simply calls initialiseYup_Windowing(). */ + ScopedYupInitialiser_Windowing(); + + /** The destructor simply calls shutdownYup_Windowing(). */ + ~ScopedYupInitialiser_Windowing(); + + YUP_DECLARE_NON_COPYABLE (ScopedYupInitialiser_Windowing) + YUP_DECLARE_NON_MOVEABLE (ScopedYupInitialiser_Windowing) + +private: + static std::atomic_int numScopedInitInstances; +}; + //============================================================================== /** Main application class for the YUPApplication, extending YUP application functionality. @@ -96,43 +132,9 @@ class YUP_API YUPApplication : public YUPApplicationBase int lineNumber) override; private: - YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (YUPApplication) -}; - -//============================================================================== - -/** Initialises YUP's Windowing classes. - - @see shutdownYup_Windowing() -*/ -YUP_API void YUP_CALLTYPE initialiseYup_Windowing(); - -/** Clears up any static data being used by YUP's Windowing classes. - - @see initialiseYup_Windowing() -*/ -YUP_API void YUP_CALLTYPE shutdownYup_Windowing(); - -//============================================================================== -/** A utility object that helps you initialise and shutdown YUP Windowing correctly - using an RAII pattern. - - @note initialiseYup_GUI() or ScopedYupInitialiser_GUI must be called before this. -*/ -class YUP_API ScopedYupInitialiser_Windowing final -{ -public: - /** The constructor simply calls initialiseYup_Windowing(). */ - ScopedYupInitialiser_Windowing(); + ScopedYupInitialiser_Windowing initialiser; - /** The destructor simply calls shutdownYup_Windowing(). */ - ~ScopedYupInitialiser_Windowing(); - - YUP_DECLARE_NON_COPYABLE (ScopedYupInitialiser_Windowing) - YUP_DECLARE_NON_MOVEABLE (ScopedYupInitialiser_Windowing) - -private: - static std::atomic_int numScopedInitInstances; + YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (YUPApplication) }; } // namespace yup diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 153280811..7f974ade2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -88,7 +88,9 @@ set (target_gtest_modules GTest::gmock) if (YUP_PLATFORM_DESKTOP) - list (APPEND target_modules yup_audio_plugin_host) + list (APPEND target_modules + yup_audio_plugin_host + yup_audio_plugin_client) endif() if (NOT YUP_PLATFORM_EMSCRIPTEN) @@ -175,6 +177,42 @@ source_group (TREE ${CMAKE_CURRENT_LIST_DIR}/ FILES ${sources}) source_group (TREE ${CMAKE_CURRENT_LIST_DIR}/ FILES ${imported_sources}) set_source_files_properties (${imported_sources} PROPERTIES HEADER_FILE_ONLY TRUE) +# ==== Plugin wrapper standalone test TUs (compiled directly, not HEADER_FILE_ONLY) +if (YUP_PLATFORM_DESKTOP) + if (TARGET clap) + target_link_libraries (${target_name} PRIVATE clap) + target_sources (${target_name} PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/yup_audio_plugin_client_clap.cpp") + endif() + + if (TARGET sdk) + target_link_libraries (${target_name} PRIVATE sdk) + target_sources (${target_name} PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/yup_audio_plugin_client_vst3.cpp") + if (TARGET sdk_hosting) + target_link_libraries (${target_name} PRIVATE sdk_hosting) + endif() + endif() + + if (YUP_PLATFORM_MAC AND TARGET base-sdk-auv2) + target_link_libraries (${target_name} PRIVATE base-sdk-auv2) + target_sources (${target_name} PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/yup_audio_plugin_client_au.mm") + set_source_files_properties ( + "${CMAKE_CURRENT_LIST_DIR}/yup_audio_plugin_client_au.mm" + PROPERTIES COMPILE_FLAGS "-fobjc-arc") + endif() + + if (YUP_PLATFORM_MAC) + target_sources (${target_name} PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/yup_audio_plugin_client_auv3.mm") + set_source_files_properties ( + "${CMAKE_CURRENT_LIST_DIR}/yup_audio_plugin_client_auv3.mm" + PROPERTIES COMPILE_FLAGS "-fobjc-arc") + target_link_libraries (${target_name} PRIVATE "-framework CoreAudioKit") + endif() +endif() + target_compile_options (${target_name} PRIVATE $<$:-Wno-subobject-linkage>) diff --git a/tests/yup_audio_plugin_client.cpp b/tests/yup_audio_plugin_client.cpp new file mode 100644 index 000000000..402a651af --- /dev/null +++ b/tests/yup_audio_plugin_client.cpp @@ -0,0 +1,22 @@ +/* + ============================================================================== + + 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_audio_plugin_client/yup_AudioPluginUtilities.cpp" diff --git a/tests/yup_audio_plugin_client/yup_AudioPluginUtilities.cpp b/tests/yup_audio_plugin_client/yup_AudioPluginUtilities.cpp new file mode 100644 index 000000000..7cc0e3e62 --- /dev/null +++ b/tests/yup_audio_plugin_client/yup_AudioPluginUtilities.cpp @@ -0,0 +1,665 @@ +/* + ============================================================================== + + 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 + +#include +#include + +using namespace yup; + +namespace +{ + +//============================================================================== +/** A minimal test processor with a known parameter set and bus layout. */ +class UtilitiesTestProcessor final : public AudioProcessor +{ +public: + UtilitiesTestProcessor() + : AudioProcessor ("UtilitiesTest", + AudioBusLayout ({ AudioBus ("Input", AudioBus::Type::Audio, AudioBus::Direction::Input, 2) }, + { AudioBus ("Output", AudioBus::Type::Audio, AudioBus::Direction::Output, 2) })) + { + // Float parameter, automatable, modulatable — host ID 100 + auto floatMeta = AudioParameter::Metadata {}; + floatMeta.name = "Gain"; + floatMeta.hostParameterID = 100; + floatMeta.valueRange = { 0.0f, 1.0f }; + floatMeta.defaultValue = 0.5f; + floatMeta.setModulatable (true); + addParameter (new AudioParameter ("gain", floatMeta)); + + // Stepped integer parameter, enumerated — host ID 200 + auto steppedMeta = AudioParameter::Metadata {}; + steppedMeta.name = "Mode"; + steppedMeta.hostParameterID = 200; + steppedMeta.valueRange = { 0.0f, 4.0f, 1.0f }; + steppedMeta.defaultValue = 0.0f; + steppedMeta.setStepped (true); + steppedMeta.setEnum (true); + addParameter (new AudioParameter ("mode", steppedMeta)); + + // Read-only parameter (meter) — host ID 300 + auto readOnlyMeta = AudioParameter::Metadata {}; + readOnlyMeta.name = "Meter"; + readOnlyMeta.hostParameterID = 300; + readOnlyMeta.valueRange = { -60.0f, 0.0f }; + readOnlyMeta.defaultValue = -60.0f; + readOnlyMeta.setReadOnly (true); + readOnlyMeta.setAutomatable (false); + addParameter (new AudioParameter ("meter", readOnlyMeta)); + + // Non-automatable parameter — host ID 400 + auto nonAutoMeta = AudioParameter::Metadata {}; + nonAutoMeta.name = "Internal"; + nonAutoMeta.hostParameterID = 400; + nonAutoMeta.valueRange = { 0.0f, 100.0f }; + nonAutoMeta.defaultValue = 50.0f; + nonAutoMeta.setAutomatable (false); + addParameter (new AudioParameter ("internal", nonAutoMeta)); + } + + void prepareToPlay (const AudioSpec&) override { prepared = true; } + + void releaseResources() override { prepared = false; } + + void processBlock (AudioProcessContext&) override + { + ++floatProcessCallCount; + } + + void processBlock (AudioProcessContext&) override + { + ++doubleProcessCallCount; + } + + void processBlockBypassed (AudioProcessContext&) override + { + ++floatBypassCallCount; + } + + void processBlockBypassed (AudioProcessContext&) override + { + ++doubleBypassCallCount; + } + + 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 {} + + Result loadStateFromMemory (const MemoryBlock& block) override + { + lastLoadedState = block; + return Result::ok(); + } + + Result saveStateIntoMemory (MemoryBlock& block) override + { + block = lastSavedState; + return Result::ok(); + } + + bool hasEditor() const override { return false; } + + bool prepared = false; + int floatProcessCallCount = 0; + int doubleProcessCallCount = 0; + int floatBypassCallCount = 0; + int doubleBypassCallCount = 0; + MemoryBlock lastLoadedState; + MemoryBlock lastSavedState; +}; + +//============================================================================== +/** A processor with colliding host parameter IDs for collision tests. */ +class CollidingIDProcessor final : public AudioProcessor +{ +public: + CollidingIDProcessor() + : AudioProcessor ("CollidingID", + AudioBusLayout ({}, {})) + { + // Fill IDs 0, 1, 2 + for (uint32 i = 0; i < 3; ++i) + { + auto meta = AudioParameter::Metadata {}; + meta.name = "Param" + String (static_cast (i)); + meta.hostParameterID = i; + addParameter (new AudioParameter ("p" + String (static_cast (i)), meta)); + } + } + + void prepareToPlay (const AudioSpec&) override {} + + void releaseResources() override {} + + void processBlock (AudioProcessContext&) override {} + + 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 {} + + Result loadStateFromMemory (const MemoryBlock&) override { return Result::ok(); } + + Result saveStateIntoMemory (MemoryBlock&) override { return Result::ok(); } + + bool hasEditor() const override { return false; } +}; + +//============================================================================== +/** A processor with a multi-bus layout for channel-count tests. */ +class MultiBusProcessor final : public AudioProcessor +{ +public: + MultiBusProcessor() + : AudioProcessor ("MultiBus", + AudioBusLayout ({ AudioBus ("Main In", AudioBus::Type::Audio, AudioBus::Direction::Input, 2), + AudioBus ("Sidechain In", AudioBus::Type::Audio, AudioBus::Direction::Input, 1), + AudioBus ("MIDI In", AudioBus::Type::Midi, AudioBus::Direction::Input, 1) }, + { AudioBus ("Main Out", AudioBus::Type::Audio, AudioBus::Direction::Output, 2), + AudioBus ("Aux Out", AudioBus::Type::Audio, AudioBus::Direction::Output, 2) })) + { + } + + void prepareToPlay (const AudioSpec&) override {} + + void releaseResources() override {} + + void processBlock (AudioProcessContext&) override {} + + 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 {} + + Result loadStateFromMemory (const MemoryBlock&) override { return Result::ok(); } + + Result saveStateIntoMemory (MemoryBlock&) override { return Result::ok(); } + + bool hasEditor() const override { return false; } +}; + +} // namespace + +//============================================================================== +// writeWrapperBypassState / readWrapperBypassState +//============================================================================== + +class WrapperBypassStateTests : public ::testing::Test +{ +protected: + static constexpr int kMagic = 0x12345678; + static constexpr int kVersion = 3; +}; + +TEST_F (WrapperBypassStateTests, RoundTripWithPayload) +{ + MemoryBlock payload (128); + payload.fillWith (0xAB); + + auto written = writeWrapperBypassState (kMagic, kVersion, true, payload, true); + auto result = readWrapperBypassState (written, kMagic, kVersion); + + EXPECT_TRUE (result.hasWrapperState); + EXPECT_TRUE (result.isBypassed); + EXPECT_TRUE (result.hasProcessorState); + EXPECT_EQ (128u, result.processorState.getSize()); + EXPECT_EQ (0, std::memcmp (payload.getData(), result.processorState.getData(), 128)); +} + +TEST_F (WrapperBypassStateTests, RoundTripNotBypassed) +{ + MemoryBlock payload (64); + payload.fillWith (0xCD); + + auto written = writeWrapperBypassState (kMagic, kVersion, false, payload, true); + auto result = readWrapperBypassState (written, kMagic, kVersion); + + EXPECT_TRUE (result.hasWrapperState); + EXPECT_FALSE (result.isBypassed); + EXPECT_TRUE (result.hasProcessorState); + EXPECT_EQ (64u, result.processorState.getSize()); + EXPECT_EQ (0, std::memcmp (payload.getData(), result.processorState.getData(), 64)); +} + +TEST_F (WrapperBypassStateTests, RoundTripEmptyPayload) +{ + MemoryBlock emptyPayload; + + auto written = writeWrapperBypassState (kMagic, kVersion, false, emptyPayload, false); + auto result = readWrapperBypassState (written, kMagic, kVersion); + + EXPECT_TRUE (result.hasWrapperState); + EXPECT_FALSE (result.isBypassed); + EXPECT_FALSE (result.hasProcessorState); + EXPECT_EQ (0u, result.processorState.getSize()); +} + +TEST_F (WrapperBypassStateTests, FallsBackOnMagicMismatch) +{ + MemoryBlock rawData (32); + rawData.fillWith (0xEF); + + auto result = readWrapperBypassState (rawData, kMagic, kVersion); + + EXPECT_FALSE (result.hasWrapperState); + EXPECT_FALSE (result.isBypassed); + EXPECT_EQ (32u, result.processorState.getSize()); + EXPECT_EQ (0, std::memcmp (rawData.getData(), result.processorState.getData(), 32)); +} + +TEST_F (WrapperBypassStateTests, FallsBackOnVersionMismatch) +{ + MemoryBlock payload (8); + payload.fillWith (0x11); + + auto written = writeWrapperBypassState (kMagic, kVersion, true, payload, true); + + // Read with wrong version + auto result = readWrapperBypassState (written, kMagic, kVersion + 1); + + EXPECT_FALSE (result.hasWrapperState); + // Fallback returns the whole blob as processor state + EXPECT_EQ (written.getSize(), result.processorState.getSize()); +} + +TEST_F (WrapperBypassStateTests, HandlesCorruptedNegativeSize) +{ + // Manually craft a block with a negative size field + MemoryBlock data; + MemoryOutputStream output (data, false); + output.writeInt (kMagic); + output.writeInt (kVersion); + output.writeBool (false); + output.writeBool (true); + output.writeInt64 (-1); // Negative size + output.flush(); + + auto result = readWrapperBypassState (data, kMagic, kVersion); + + EXPECT_FALSE (result.hasWrapperState); + EXPECT_FALSE (result.hasProcessorState); + EXPECT_EQ (data.getSize(), result.processorState.getSize()); +} + +TEST_F (WrapperBypassStateTests, HandlesCorruptedSizeExceedingRemaining) +{ + MemoryBlock data; + MemoryOutputStream output (data, false); + output.writeInt (kMagic); + output.writeInt (kVersion); + output.writeBool (false); + output.writeBool (true); + output.writeInt64 (999999); // Size exceeds available data + output.flush(); + + auto result = readWrapperBypassState (data, kMagic, kVersion); + + EXPECT_FALSE (result.hasWrapperState); + EXPECT_FALSE (result.hasProcessorState); + EXPECT_EQ (data.getSize(), result.processorState.getSize()); +} + +//============================================================================== +// findFirstUnusedHostParameterID / getBypassHostParameterID +//============================================================================== + +class HostParameterIDTests : public ::testing::Test +{ +protected: + CollidingIDProcessor processor; +}; + +TEST_F (HostParameterIDTests, ReturnsPreferredWhenNoCollision) +{ + EXPECT_EQ (10u, findFirstUnusedHostParameterID (processor, 10)); +} + +TEST_F (HostParameterIDTests, SkipsCollision) +{ + // ID 0 is occupied, so preferred 0 should return 3 (first free after 0,1,2) + EXPECT_EQ (3u, findFirstUnusedHostParameterID (processor, 0)); +} + +TEST_F (HostParameterIDTests, SkipsMultipleCollisions) +{ + // ID 1 is occupied, preferred 1 → skips to 3 + EXPECT_EQ (3u, findFirstUnusedHostParameterID (processor, 1)); +} + +TEST_F (HostParameterIDTests, BypassIDStartsAtParameterCount) +{ + // 3 parameters, so bypass scan starts at 3 + EXPECT_EQ (3u, getBypassHostParameterID (processor)); +} + +//============================================================================== +// getTotalAudioOutputChannels / getTotalAudioInputChannels +//============================================================================== + +class ChannelCountTests : public ::testing::Test +{ +protected: + MultiBusProcessor processor; +}; + +TEST_F (ChannelCountTests, CountsAudioInputChannels) +{ + // 2 (Main In) + 1 (Sidechain In) = 3, MIDI In excluded + EXPECT_EQ (3, getTotalAudioInputChannels (processor)); +} + +TEST_F (ChannelCountTests, CountsAudioOutputChannels) +{ + // 2 (Main Out) + 2 (Aux Out) = 4 + EXPECT_EQ (4, getTotalAudioOutputChannels (processor)); +} + +/** A processor with explicitly-role-tagged sidechain input buses. */ +class RoleTaggedSidechainProcessor final : public AudioProcessor +{ +public: + RoleTaggedSidechainProcessor() + : AudioProcessor ("RoleTaggedSidechain", + AudioBusLayout ({ AudioBus ("Main", AudioBus::Type::Audio, AudioBus::Direction::Input, 2), + AudioBus ("SC", AudioBus::Type::Audio, AudioBus::Direction::Input, 1, AudioBus::Role::Auxiliary), + AudioBus ("MIDI", AudioBus::Type::Midi, AudioBus::Direction::Input, 1) }, + { AudioBus ("Out", AudioBus::Type::Audio, AudioBus::Direction::Output, 2) })) + { + } + + void prepareToPlay (const AudioSpec&) override {} + + void releaseResources() override {} + + void processBlock (AudioProcessContext&) override {} + + 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 {} + + Result loadStateFromMemory (const MemoryBlock&) override { return Result::ok(); } + + Result saveStateIntoMemory (MemoryBlock&) override { return Result::ok(); } + + bool hasEditor() const override { return false; } +}; + +TEST (AudioInputChannelCountTests, CountsAudioChannelsInRoleTaggedSidechainLayout) +{ + RoleTaggedSidechainProcessor processor; + + // 2 (Main) + 1 (Sidechain) = 3 audio input channels, MIDI excluded + EXPECT_EQ (3, getTotalAudioInputChannels (processor)); + // 2 (Out) = 2 audio output channels + EXPECT_EQ (2, getTotalAudioOutputChannels (processor)); +} + +/** A processor with no audio inputs (e.g. a pure tone generator). */ +class NoAudioInputProcessor final : public AudioProcessor +{ +public: + NoAudioInputProcessor() + : AudioProcessor ("NoAudioInput", + AudioBusLayout ({}, + { AudioBus ("Out", AudioBus::Type::Audio, AudioBus::Direction::Output, 2) })) + { + } + + void prepareToPlay (const AudioSpec&) override {} + + void releaseResources() override {} + + void processBlock (AudioProcessContext&) override {} + + 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 {} + + Result loadStateFromMemory (const MemoryBlock&) override { return Result::ok(); } + + Result saveStateIntoMemory (MemoryBlock&) override { return Result::ok(); } + + bool hasEditor() const override { return false; } +}; + +TEST (AudioInputChannelCountTests, CountsZeroWhenNoAudioInputs) +{ + NoAudioInputProcessor processor; + + EXPECT_EQ (0, getTotalAudioInputChannels (processor)); + EXPECT_EQ (2, getTotalAudioOutputChannels (processor)); +} + +//============================================================================== +// getDefaultParameterChangeCapacity +//============================================================================== + +TEST (DefaultCapacityTests, ReturnsExpectedFormula) +{ + UtilitiesTestProcessor processor; + // 4 parameters → 4 * 4 + 32 = 48 + EXPECT_EQ (48, getDefaultParameterChangeCapacity (processor)); +} + +//============================================================================== +// addParameterChangeByHostParameterID +//============================================================================== + +class ParameterChangeByHostIDTests : public ::testing::Test +{ +protected: + void SetUp() override + { + changes.reserve (getDefaultParameterChangeCapacity (processor)); + } + + UtilitiesTestProcessor processor; + ParameterChangeBuffer changes; +}; + +TEST_F (ParameterChangeByHostIDTests, AddsChangeForValidHostID) +{ + // host ID 100 maps to "Gain" (index 0) + EXPECT_TRUE (addParameterChangeByHostParameterID (processor, changes, 100, 0.75f, 0)); + + ASSERT_EQ (1, changes.getNumChanges()); + EXPECT_EQ (0, changes.begin()->parameterIndex); + EXPECT_FLOAT_EQ (0.75f, changes.begin()->normalizedValue); + EXPECT_EQ (0, changes.begin()->sampleOffset); +} + +TEST_F (ParameterChangeByHostIDTests, AddsChangeForSteppedParameter) +{ + // host ID 200 maps to "Mode" (index 1) + EXPECT_TRUE (addParameterChangeByHostParameterID (processor, changes, 200, 1.0f, 64)); + + ASSERT_EQ (1, changes.getNumChanges()); + EXPECT_EQ (1, changes.begin()->parameterIndex); + EXPECT_FLOAT_EQ (1.0f, changes.begin()->normalizedValue); + EXPECT_EQ (64, changes.begin()->sampleOffset); +} + +TEST_F (ParameterChangeByHostIDTests, ReturnsFalseForInvalidHostID) +{ + EXPECT_FALSE (addParameterChangeByHostParameterID (processor, changes, 999, 0.5f, 0)); + EXPECT_EQ (0, changes.getNumChanges()); +} + +TEST_F (ParameterChangeByHostIDTests, ReturnsFalseForOutOfRangeIndex) +{ + // host ID 400 maps to "Internal" (index 3), which is valid + EXPECT_TRUE (addParameterChangeByHostParameterID (processor, changes, 400, 0.5f, 0)); + + // host ID beyond any known param + EXPECT_FALSE (addParameterChangeByHostParameterID (processor, changes, 99999, 0.5f, 0)); +} + +//============================================================================== +// applyParameterChangesToProcessor +//============================================================================== + +class ApplyParameterChangesTests : public ::testing::Test +{ +protected: + UtilitiesTestProcessor processor; +}; + +TEST_F (ApplyParameterChangesTests, AppliesChangesToParameters) +{ + ParameterChangeBuffer changes; + changes.reserve (getDefaultParameterChangeCapacity (processor)); + changes.addChange (0, 0.25f, 0); // Gain + changes.addChange (1, 0.75f, 0); // Mode + + applyParameterChangesToProcessor (processor, changes); + + // Normalized values are applied through setNormalizedValue + // AudioParameter stores denormalized values + auto gainParam = processor.getParameterByHostID (100); + auto modeParam = processor.getParameterByHostID (200); + ASSERT_NE (nullptr, gainParam); + ASSERT_NE (nullptr, modeParam); + EXPECT_FLOAT_EQ (0.25f, gainParam->getValue()); + EXPECT_FLOAT_EQ (3.0f, modeParam->getValue()); // 0.75 * 4 = 3.0 +} + +TEST_F (ApplyParameterChangesTests, SkipsOutOfRangeIndices) +{ + ParameterChangeBuffer changes; + changes.reserve (getDefaultParameterChangeCapacity (processor)); + changes.addChange (0, 0.5f, 0); + changes.addChange (999, 0.5f, 0); // Out of range + + applyParameterChangesToProcessor (processor, changes); + + auto gainParam = processor.getParameterByHostID (100); + ASSERT_NE (nullptr, gainParam); + EXPECT_FLOAT_EQ (0.5f, gainParam->getValue()); +} + +//============================================================================== +// endActiveParameterGestures +//============================================================================== + +class EndGestureTests : public ::testing::Test +{ +protected: + UtilitiesTestProcessor processor; +}; + +TEST_F (EndGestureTests, EndsInProgressGestures) +{ + auto gainParam = processor.getParameterByHostID (100); + ASSERT_NE (nullptr, gainParam); + + gainParam->beginChangeGesture(); + EXPECT_TRUE (gainParam->isPerformingChangeGesture()); + + endActiveParameterGestures (&processor); + + EXPECT_FALSE (gainParam->isPerformingChangeGesture()); +} + +TEST_F (EndGestureTests, HandlesNestedGestures) +{ + auto gainParam = processor.getParameterByHostID (100); + ASSERT_NE (nullptr, gainParam); + + gainParam->beginChangeGesture(); + gainParam->beginChangeGesture(); + EXPECT_TRUE (gainParam->isPerformingChangeGesture()); + + endActiveParameterGestures (&processor); + + EXPECT_FALSE (gainParam->isPerformingChangeGesture()); +} + +TEST_F (EndGestureTests, NullProcessorIsSafe) +{ + EXPECT_NO_FATAL_FAILURE (endActiveParameterGestures (nullptr)); +} + +//============================================================================== +// processAudioBlock +//============================================================================== + +class ProcessAudioBlockTests : public ::testing::Test +{ +protected: + UtilitiesTestProcessor processor; +}; + +TEST_F (ProcessAudioBlockTests, RoutesToProcessBlockWhenNotBypassed) +{ + AudioBuffer audio (2, 64); + MidiBuffer midi; + ParameterChangeBuffer params; + AudioProcessContext ctx { audio, midi, params }; + + processAudioBlock (processor, ctx, false); + + EXPECT_EQ (1, processor.floatProcessCallCount); + EXPECT_EQ (0, processor.floatBypassCallCount); +} + +TEST_F (ProcessAudioBlockTests, RoutesToBypassedWhenBypassed) +{ + AudioBuffer audio (2, 64); + MidiBuffer midi; + ParameterChangeBuffer params; + AudioProcessContext ctx { audio, midi, params }; + + processAudioBlock (processor, ctx, true); + + EXPECT_EQ (0, processor.floatProcessCallCount); + EXPECT_EQ (1, processor.floatBypassCallCount); +} diff --git a/tests/yup_audio_plugin_client/yup_TestPluginProcessor.h b/tests/yup_audio_plugin_client/yup_TestPluginProcessor.h new file mode 100644 index 000000000..b3cabd8b8 --- /dev/null +++ b/tests/yup_audio_plugin_client/yup_TestPluginProcessor.h @@ -0,0 +1,167 @@ +/* + ============================================================================== + + 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 + +//============================================================================== +/** Shared test processor used by all plugin wrapper test TUs. + + Provides a known set of parameters so that per-format tests can verify + correct parameter mapping, state serialization, and audio processing + through the wrapper code. + + The bus layout is configurable — each test TU can supply its own layout + (e.g. 1-in/1-out for AU safety, 2-in/2-out for VST3 multi-channel tests). +*/ +class TestPluginProcessor final : public yup::AudioProcessor +{ +public: + /** Constructs with a custom bus layout. */ + explicit TestPluginProcessor (yup::AudioBusLayout layout) + : AudioProcessor ("TestPlugin", std::move (layout)) + { + // Float parameter, automatable, modulatable — host ID 100 + auto floatMeta = yup::AudioParameter::Metadata {}; + floatMeta.name = "Gain"; + floatMeta.hostParameterID = 100; + floatMeta.valueRange = { 0.0f, 1.0f }; + floatMeta.defaultValue = 0.5f; + floatMeta.setModulatable (true); + addParameter (new yup::AudioParameter ("gain", floatMeta)); + + // Stepped integer parameter, enumerated — host ID 200 + auto steppedMeta = yup::AudioParameter::Metadata {}; + steppedMeta.name = "Mode"; + steppedMeta.hostParameterID = 200; + steppedMeta.valueRange = { 0.0f, 4.0f, 1.0f }; + steppedMeta.defaultValue = 0.0f; + steppedMeta.setStepped (true); + steppedMeta.setEnum (true); + addParameter (new yup::AudioParameter ("mode", steppedMeta)); + + // Read-only parameter (meter) — host ID 300 + auto readOnlyMeta = yup::AudioParameter::Metadata {}; + readOnlyMeta.name = "Meter"; + readOnlyMeta.hostParameterID = 300; + readOnlyMeta.valueRange = { -60.0f, 0.0f }; + readOnlyMeta.defaultValue = -60.0f; + readOnlyMeta.setReadOnly (true); + readOnlyMeta.setAutomatable (false); + addParameter (new yup::AudioParameter ("meter", readOnlyMeta)); + + // Non-automatable parameter — host ID 400 + auto nonAutoMeta = yup::AudioParameter::Metadata {}; + nonAutoMeta.name = "Internal"; + nonAutoMeta.hostParameterID = 400; + nonAutoMeta.valueRange = { 0.0f, 100.0f }; + nonAutoMeta.defaultValue = 50.0f; + nonAutoMeta.setAutomatable (false); + addParameter (new yup::AudioParameter ("internal", nonAutoMeta)); + } + + void prepareToPlay (const yup::AudioSpec&) override { prepared = true; } + + void releaseResources() override { prepared = false; } + + void processBlock (yup::AudioProcessContext&) override { ++processCallCount; } + + void processBlockBypassed (yup::AudioProcessContext&) override { ++bypassCallCount; } + + 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& block) override + { + lastLoadedState = block; + return yup::Result::ok(); + } + + yup::Result saveStateIntoMemory (yup::MemoryBlock& block) override + { + block = lastSavedState; + return yup::Result::ok(); + } + + bool hasEditor() const override { return false; } + + bool supportsDoublePrecisionProcessing() const override { return supportsDouble; } + + bool prepared = false; + bool supportsDouble = false; + int processCallCount = 0; + int bypassCallCount = 0; + yup::MemoryBlock lastLoadedState; + yup::MemoryBlock lastSavedState; +}; + +//============================================================================== +/** Convenience: a 1-in/1-out layout safe for AU wrappers. */ +inline yup::AudioBusLayout testPluginBusLayoutMono() +{ + return yup::AudioBusLayout ( + { yup::AudioBus ("Input", yup::AudioBus::Type::Audio, yup::AudioBus::Direction::Input, 1) }, + { yup::AudioBus ("Output", yup::AudioBus::Type::Audio, yup::AudioBus::Direction::Output, 1) }); +} + +/** Convenience: a 2-in/2-out layout for multi-channel tests. */ +inline yup::AudioBusLayout testPluginBusLayoutStereo() +{ + return yup::AudioBusLayout ( + { yup::AudioBus ("Input", yup::AudioBus::Type::Audio, yup::AudioBus::Direction::Input, 2) }, + { yup::AudioBus ("Output", yup::AudioBus::Type::Audio, yup::AudioBus::Direction::Output, 2) }); +} + +/** Convenience: a sidechain layout with 2 main inputs, 1 sidechain input, 2 main outputs. + The sidechain bus is auxiliary and active by default. */ +inline yup::AudioBusLayout testPluginBusLayoutWithSidechain() +{ + return yup::AudioBusLayout ( + { yup::AudioBus ("Main Input", yup::AudioBus::Type::Audio, yup::AudioBus::Direction::Input, 2), + yup::AudioBus ("Sidechain Input", yup::AudioBus::Type::Audio, yup::AudioBus::Direction::Input, 1, yup::AudioBus::Role::Auxiliary) }, + { yup::AudioBus ("Main Output", yup::AudioBus::Type::Audio, yup::AudioBus::Direction::Output, 2) }); +} + +/** Convenience: a sidechain layout where the auxiliary bus is NOT active by default. */ +inline yup::AudioBusLayout testPluginBusLayoutWithInactiveSidechain() +{ + return yup::AudioBusLayout ( + { yup::AudioBus ("Main Input", yup::AudioBus::Type::Audio, yup::AudioBus::Direction::Input, 2), + yup::AudioBus ("Sidechain Input", yup::AudioBus::Type::Audio, yup::AudioBus::Direction::Input, 1, yup::AudioBus::Role::Auxiliary, false) }, + { yup::AudioBus ("Main Output", yup::AudioBus::Type::Audio, yup::AudioBus::Direction::Output, 2) }); +} + +/** Convenience: a layout with an auxiliary output bus. */ +inline yup::AudioBusLayout testPluginBusLayoutWithAuxOutput() +{ + return yup::AudioBusLayout ( + { yup::AudioBus ("Main Input", yup::AudioBus::Type::Audio, yup::AudioBus::Direction::Input, 2) }, + { yup::AudioBus ("Main Output", yup::AudioBus::Type::Audio, yup::AudioBus::Direction::Output, 2), + yup::AudioBus ("Aux Output", yup::AudioBus::Type::Audio, yup::AudioBus::Direction::Output, 2, yup::AudioBus::Role::Auxiliary) }); +} diff --git a/tests/yup_audio_plugin_client_au.mm b/tests/yup_audio_plugin_client_au.mm new file mode 100644 index 000000000..11c366ccc --- /dev/null +++ b/tests/yup_audio_plugin_client_au.mm @@ -0,0 +1,424 @@ +/* + ============================================================================== + + 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 + +// ============================================================================= +#define YUP_AUDIO_PLUGIN_ENABLE_AU 1 +#define YupPlugin_Id "test.au.plugin" +#define YupPlugin_Name "Test AU Plugin" +#define YupPlugin_Vendor "TestVendor" +#define YupPlugin_Version "1.0.0" +#define YupPlugin_IsSynth 0 +#define YupPlugin_IsMono 0 + +// ============================================================================= +#include "yup_audio_plugin_client/yup_TestPluginProcessor.h" + +#define YUP_AUDIO_PLUGIN_CREATE_FUNCTION createPluginProcessorAU +#include "yup_audio_plugin_client/au/yup_audio_plugin_client_AU.mm" + +extern "C" yup::AudioProcessor* createPluginProcessorAU() +{ + return new TestPluginProcessor (testPluginBusLayoutStereo()); +} + +// ============================================================================= +#include + +// ============================================================================= +// Tests +// ============================================================================= + +namespace +{ + +// Four-char codes for our test AU +constexpr OSType kTestAUType = 'auef'; +constexpr OSType kTestAUSubType = 'tst1'; +constexpr OSType kTestAUManuf = 'test'; + +// Register the test component once — subsequent tests reuse via AudioComponentFindNext +static const AudioComponent kRegisteredComponent = [] +{ + return ausdk::AUBaseProcessFactory::Register ( + kTestAUType, + kTestAUSubType, + kTestAUManuf, + CFSTR ("Test AU"), + 0); +}(); + +} // namespace + +//------------------------------------------------------------------------------ +// Registration test +//------------------------------------------------------------------------------ + +TEST (AUWrapperTest, RegisterComponentSucceeds) +{ + EXPECT_NE (nullptr, kRegisteredComponent); +} + +//------------------------------------------------------------------------------ +// Helper: instantiate an AudioUnit for a test fixture +static AudioUnit instantiateTestAU() +{ + AudioComponentDescription desc {}; + desc.componentType = kTestAUType; + desc.componentSubType = kTestAUSubType; + desc.componentManufacturer = kTestAUManuf; + + const auto found = AudioComponentFindNext (nullptr, &desc); + if (found == nullptr) + return nullptr; + + AudioUnit au = nullptr; + AudioComponentInstanceNew (found, &au); + return au; +} + +//------------------------------------------------------------------------------ +// Instantiation and lifecycle tests +//------------------------------------------------------------------------------ + +class AUInstanceTests : public ::testing::Test +{ +protected: + void SetUp() override + { + audioUnit = instantiateTestAU(); + ASSERT_NE (nullptr, audioUnit); + } + + void TearDown() override + { + if (audioUnit != nullptr) + { + AudioUnitUninitialize (audioUnit); + AudioComponentInstanceDispose (audioUnit); + } + } + + AudioUnit audioUnit = nullptr; +}; + +TEST_F (AUInstanceTests, InitializeSucceeds) +{ + const auto status = AudioUnitInitialize (audioUnit); + EXPECT_EQ (noErr, status); +} + +TEST_F (AUInstanceTests, UninitializeAfterInitialize) +{ + ASSERT_EQ (noErr, AudioUnitInitialize (audioUnit)); + const auto status = AudioUnitUninitialize (audioUnit); + EXPECT_EQ (noErr, status); +} + +//------------------------------------------------------------------------------ +// Parameter tests +//------------------------------------------------------------------------------ + +class AUParameterTests : public ::testing::Test +{ +protected: + void SetUp() override + { + audioUnit = instantiateTestAU(); + ASSERT_NE (nullptr, audioUnit); + ASSERT_EQ (noErr, AudioUnitInitialize (audioUnit)); + } + + void TearDown() override + { + if (audioUnit != nullptr) + { + AudioUnitUninitialize (audioUnit); + AudioComponentInstanceDispose (audioUnit); + } + } + + AudioUnit audioUnit = nullptr; +}; + +TEST_F (AUParameterTests, ParameterListIsRetrievable) +{ + UInt32 dataSize = 0; + const auto status = AudioUnitGetPropertyInfo ( + audioUnit, + kAudioUnitProperty_ParameterList, + kAudioUnitScope_Global, + 0, + &dataSize, + nullptr); + + EXPECT_EQ (noErr, status); + EXPECT_GT (dataSize, 0u); + + const auto numParams = dataSize / sizeof (AudioUnitParameterID); + EXPECT_GE (numParams, 2u); + + std::vector paramIDs (numParams); + const auto getStatus = AudioUnitGetProperty ( + audioUnit, + kAudioUnitProperty_ParameterList, + kAudioUnitScope_Global, + 0, + paramIDs.data(), + &dataSize); + + EXPECT_EQ (noErr, getStatus); +} + +TEST_F (AUParameterTests, ParameterInfoIsValid) +{ + UInt32 dataSize = 0; + ASSERT_EQ (noErr, AudioUnitGetPropertyInfo ( + audioUnit, + kAudioUnitProperty_ParameterList, + kAudioUnitScope_Global, + 0, + &dataSize, + nullptr)); + + const auto numParams = dataSize / sizeof (AudioUnitParameterID); + ASSERT_GE (numParams, 1u); + + std::vector paramIDs (numParams); + ASSERT_EQ (noErr, AudioUnitGetProperty ( + audioUnit, + kAudioUnitProperty_ParameterList, + kAudioUnitScope_Global, + 0, + paramIDs.data(), + &dataSize)); + + AudioUnitParameterInfo info {}; + dataSize = sizeof (info); + + const auto status = AudioUnitGetProperty ( + audioUnit, + kAudioUnitProperty_ParameterInfo, + kAudioUnitScope_Global, + paramIDs[0], + &info, + &dataSize); + + EXPECT_EQ (noErr, status); + EXPECT_GT (info.name[0], 0); +} + +TEST_F (AUParameterTests, GetAndSetParameter) +{ + UInt32 dataSize = 0; + ASSERT_EQ (noErr, AudioUnitGetPropertyInfo ( + audioUnit, + kAudioUnitProperty_ParameterList, + kAudioUnitScope_Global, + 0, + &dataSize, + nullptr)); + + const auto numParams = dataSize / sizeof (AudioUnitParameterID); + ASSERT_GE (numParams, 1u); + + std::vector paramIDs (numParams); + ASSERT_EQ (noErr, AudioUnitGetProperty ( + audioUnit, + kAudioUnitProperty_ParameterList, + kAudioUnitScope_Global, + 0, + paramIDs.data(), + &dataSize)); + + AudioUnitParameterValue value = 0.0f; + auto status = AudioUnitGetParameter (audioUnit, paramIDs[0], kAudioUnitScope_Global, 0, &value); + EXPECT_EQ (noErr, status); + + status = AudioUnitSetParameter (audioUnit, paramIDs[0], kAudioUnitScope_Global, 0, 0.5f, 0); + EXPECT_EQ (noErr, status); + + AudioUnitParameterValue newValue = 0.0f; + status = AudioUnitGetParameter (audioUnit, paramIDs[0], kAudioUnitScope_Global, 0, &newValue); + EXPECT_EQ (noErr, status); + EXPECT_NEAR (0.5f, newValue, 0.001f); +} + +//------------------------------------------------------------------------------ +// State save/load tests +//------------------------------------------------------------------------------ + +class AUStateTests : public ::testing::Test +{ +protected: + void SetUp() override + { + audioUnit = instantiateTestAU(); + ASSERT_NE (nullptr, audioUnit); + ASSERT_EQ (noErr, AudioUnitInitialize (audioUnit)); + } + + void TearDown() override + { + if (audioUnit != nullptr) + { + AudioUnitUninitialize (audioUnit); + AudioComponentInstanceDispose (audioUnit); + } + } + + AudioUnit audioUnit = nullptr; +}; + +TEST_F (AUStateTests, GetClassInfoProducesPropertyList) +{ + UInt32 dataSize = 0; + Boolean writable = false; + + auto status = AudioUnitGetPropertyInfo ( + audioUnit, + kAudioUnitProperty_ClassInfo, + kAudioUnitScope_Global, + 0, + &dataSize, + &writable); + + EXPECT_EQ (noErr, status); + EXPECT_TRUE (writable); + EXPECT_GT (dataSize, 0u); +} + +TEST_F (AUStateTests, RenderProducesOutput) +{ + constexpr UInt32 numFrames = 64; + constexpr UInt32 numChannels = 2; + + AudioBufferList bufferList {}; + bufferList.mNumberBuffers = numChannels; + + std::vector bufferData (numFrames * numChannels, 0.0f); + for (UInt32 i = 0; i < numChannels; ++i) + { + bufferList.mBuffers[i].mNumberChannels = 1; + bufferList.mBuffers[i].mDataByteSize = numFrames * sizeof (float); + bufferList.mBuffers[i].mData = bufferData.data() + (i * numFrames); + } + + AudioTimeStamp timeStamp {}; + timeStamp.mSampleTime = 0; + timeStamp.mFlags = kAudioTimeStampSampleTimeValid; + + AudioUnitRenderActionFlags actionFlags = 0; + + // Effect AUs require an input connection — set an input callback providing + // silence so AUEffectBase::Render doesn't return kAudioUnitErr_NoConnection + AURenderCallbackStruct inputCallback {}; + inputCallback.inputProc = [] (void*, AudioUnitRenderActionFlags*, const AudioTimeStamp*, UInt32, UInt32 inNumberFrames, AudioBufferList* ioData) -> OSStatus + { + for (UInt32 i = 0; i < ioData->mNumberBuffers; ++i) + if (ioData->mBuffers[i].mData != nullptr) + std::memset (ioData->mBuffers[i].mData, 0, inNumberFrames * sizeof (float)); + return noErr; + }; + + ASSERT_EQ (noErr, AudioUnitSetProperty ( + audioUnit, + kAudioUnitProperty_SetRenderCallback, + kAudioUnitScope_Input, + 0, + &inputCallback, + sizeof (inputCallback))); + + const auto status = AudioUnitRender ( + audioUnit, + &actionFlags, + &timeStamp, + 0, + numFrames, + &bufferList); + + EXPECT_EQ (noErr, status); +} + +//------------------------------------------------------------------------------ +// Bypass tests +//------------------------------------------------------------------------------ + +class AUBypassTests : public ::testing::Test +{ +protected: + void SetUp() override + { + audioUnit = instantiateTestAU(); + ASSERT_NE (nullptr, audioUnit); + ASSERT_EQ (noErr, AudioUnitInitialize (audioUnit)); + } + + void TearDown() override + { + if (audioUnit != nullptr) + { + AudioUnitUninitialize (audioUnit); + AudioComponentInstanceDispose (audioUnit); + } + } + + void setBypass (bool shouldBypass) + { + const UInt32 bypassed = shouldBypass ? 1u : 0u; + ASSERT_EQ (noErr, AudioUnitSetProperty (audioUnit, + kAudioUnitProperty_BypassEffect, + kAudioUnitScope_Global, + 0, + &bypassed, + sizeof (bypassed))); + } + + UInt32 getBypass() const + { + UInt32 bypassed = 0u; + UInt32 dataSize = sizeof (bypassed); + EXPECT_EQ (noErr, AudioUnitGetProperty (audioUnit, + kAudioUnitProperty_BypassEffect, + kAudioUnitScope_Global, + 0, + &bypassed, + &dataSize)); + return bypassed; + } + + AudioUnit audioUnit = nullptr; +}; + +TEST_F (AUBypassTests, BypassEffectDefaultsToOff) +{ + EXPECT_EQ (0u, getBypass()); +} + +TEST_F (AUBypassTests, BypassEffectPropertyRoundTrips) +{ + setBypass (true); + EXPECT_EQ (1u, getBypass()); + + setBypass (false); + EXPECT_EQ (0u, getBypass()); +} diff --git a/tests/yup_audio_plugin_client_auv3.mm b/tests/yup_audio_plugin_client_auv3.mm new file mode 100644 index 000000000..ccb7470a6 --- /dev/null +++ b/tests/yup_audio_plugin_client_auv3.mm @@ -0,0 +1,1282 @@ +/* + ============================================================================== + + 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 + +#include +#include + +// ============================================================================= +#define YUP_AUDIO_PLUGIN_ENABLE_AUv3 1 +#define YupPlugin_Id "test.auv3.plugin" +#define YupPlugin_Name "Test AUv3 Plugin" +#define YupPlugin_Vendor "TestVendor" +#define YupPlugin_Version "1.0.0" +#define YupPlugin_IsSynth 0 +#define YupPlugin_IsMono 0 + +// ============================================================================= +#include "yup_audio_plugin_client/yup_TestPluginProcessor.h" + +#define YUP_AUDIO_PLUGIN_CREATE_FUNCTION createPluginProcessorAUv3 +#include "yup_audio_plugin_client/auv3/yup_audio_plugin_client_AUv3.mm" + +// ============================================================================= +#include + +// ============================================================================= +// Tests +// ============================================================================= + +using namespace yup; + +namespace +{ + +// Four-char codes for our test AUv3 +constexpr OSType kTestAUv3Type = 'auef'; +constexpr OSType kTestAUv3SubType = 'tst3'; +constexpr OSType kTestAUv3Manuf = 'test'; + +// ============================================================================= +// Layout switching for testing different bus configurations. +static AudioBusLayout gCustomLayout = testPluginBusLayoutStereo(); +static bool gUseCustomLayout = false; + +// Original factory uses the global flag to support multiple layouts. +extern "C" yup::AudioProcessor* createPluginProcessorAUv3() +{ + if (gUseCustomLayout) + return new TestPluginProcessor (gCustomLayout); + + return new TestPluginProcessor (testPluginBusLayoutStereo()); +} + +// ============================================================================= +// Scoped RAII helper that switches the processor layout for a test scope +struct ScopedProcessorLayout +{ + explicit ScopedProcessorLayout (AudioBusLayout layout) + { + gCustomLayout = std::move (layout); + gUseCustomLayout = true; + } + + ~ScopedProcessorLayout() + { + gUseCustomLayout = false; + } + + YUP_DECLARE_NON_COPYABLE (ScopedProcessorLayout) +}; + +// ============================================================================= +// Helper: create a minimal AudioComponentDescription for testing +AudioComponentDescription makeTestDescription() +{ + AudioComponentDescription desc {}; + desc.componentType = kTestAUv3Type; + desc.componentSubType = kTestAUv3SubType; + desc.componentManufacturer = kTestAUv3Manuf; + return desc; +} + +// ============================================================================= +// Helper: instantiate an AUAudioUnit via the dynamic ObjC subclass (direct path). +AUAudioUnit* instantiateTestAUAudioUnit (NSError** outError = nullptr) +{ + ignoreUnused (outError); + + static AUAudioUnitSubclass auClass; + + // Raw allocated instance - no init called. The object has the correct + // isa and ivar layout, so setThis / _this work. + auto* au = auClass.createInstance(); + + if (au == nil) + return nil; + + // Manually construct the C++ wrapper and wire it up - this is what + // initWithComponentDescription:options:error: does in production. + const auto desc = makeTestDescription(); + auto* cpp = new AudioPluginProcessorAUv3 (au, desc, 0, nullptr); + AUAudioUnitSubclass::setThis (au, cpp); + + return au; +} + +} // namespace + +//============================================================================== +// Direct instantiation tests (non-factory path via ObjC init) +//============================================================================== + +class AUv3DirectInstanceTests : public ::testing::Test +{ +protected: + void SetUp() override + { + audioUnit = instantiateTestAUAudioUnit(); + ASSERT_NE (nil, audioUnit); + + cpp = AUAudioUnitSubclass::_this (audioUnit); + ASSERT_NE (nullptr, cpp); + } + + void TearDown() override + { + if (cpp != nullptr) + { + AUAudioUnitSubclass::setThis (audioUnit, nullptr); + delete cpp; + } + cpp = nullptr; + audioUnit = nil; + } + + AUAudioUnit* audioUnit = nil; + AudioPluginProcessorAUv3* cpp = nullptr; +}; + +TEST_F (AUv3DirectInstanceTests, AudioUnitReferenceIsNonNullAfterConstruction) +{ + // Bug #7 fix: the C++ wrapper must store the real AUAudioUnit reference + EXPECT_NE (nil, cpp->getAudioUnit()); +} + +TEST_F (AUv3DirectInstanceTests, AudioUnitReferenceMatchesObjCInstance) +{ + // The stored au reference must be the same ObjC object + EXPECT_EQ (audioUnit, cpp->getAudioUnit()); +} + +TEST_F (AUv3DirectInstanceTests, ProcessorIsNonNull) +{ + EXPECT_NE (nullptr, cpp->getProcessor()); +} + +TEST_F (AUv3DirectInstanceTests, ParameterTreeIsAccessible) +{ + auto* tree = cpp->getParameterTree(); + ASSERT_NE (nil, tree); + + // The test processor has 4 parameters + EXPECT_GE ([[tree allParameters] count], 4u); +} + +TEST_F (AUv3DirectInstanceTests, InputBussesAreAccessible) +{ + auto* busses = cpp->getInputBusses(); + EXPECT_NE (nil, busses); +} + +TEST_F (AUv3DirectInstanceTests, OutputBussesAreAccessible) +{ + auto* busses = cpp->getOutputBusses(); + EXPECT_NE (nil, busses); +} + +TEST_F (AUv3DirectInstanceTests, ChannelCapabilitiesAreAccessible) +{ + auto* caps = cpp->getChannelCapabilities(); + EXPECT_NE (nil, caps); +} + +//============================================================================== +// Render resource tests +//============================================================================== + +class AUv3RenderResourceTests : public ::testing::Test +{ +protected: + void SetUp() override + { + audioUnit = instantiateTestAUAudioUnit(); + ASSERT_NE (nil, audioUnit); + cpp = AUAudioUnitSubclass::_this (audioUnit); + ASSERT_NE (nullptr, cpp); + } + + void TearDown() override + { + if (cpp != nullptr && cpp->isRenderResourcesAllocated()) + cpp->deallocateRenderResources(); + + if (cpp != nullptr) + { + AUAudioUnitSubclass::setThis (audioUnit, nullptr); + delete cpp; + } + cpp = nullptr; + audioUnit = nil; + } + + AUAudioUnit* audioUnit = nil; + AudioPluginProcessorAUv3* cpp = nullptr; +}; + +TEST_F (AUv3RenderResourceTests, AllocateRenderResourcesSucceeds) +{ + NSError* error = nil; + const auto ok = cpp->allocateRenderResourcesAndReturnError (&error); + EXPECT_TRUE (ok); + EXPECT_EQ (nil, error); +} + +TEST_F (AUv3RenderResourceTests, AllocatedFlagIsSetAfterAllocation) +{ + ASSERT_TRUE (cpp->allocateRenderResourcesAndReturnError (nullptr)); + EXPECT_TRUE (cpp->isRenderResourcesAllocated()); +} + +TEST_F (AUv3RenderResourceTests, DeallocateClearsAllocatedFlag) +{ + ASSERT_TRUE (cpp->allocateRenderResourcesAndReturnError (nullptr)); + cpp->deallocateRenderResources(); + EXPECT_FALSE (cpp->isRenderResourcesAllocated()); +} + +TEST_F (AUv3RenderResourceTests, DoubleAllocationIsSafe) +{ + ASSERT_TRUE (cpp->allocateRenderResourcesAndReturnError (nullptr)); + EXPECT_TRUE (cpp->allocateRenderResourcesAndReturnError (nullptr)); + EXPECT_TRUE (cpp->isRenderResourcesAllocated()); +} + +TEST_F (AUv3RenderResourceTests, DeallocateWithoutAllocationIsSafe) +{ + EXPECT_FALSE (cpp->isRenderResourcesAllocated()); + cpp->deallocateRenderResources(); + EXPECT_FALSE (cpp->isRenderResourcesAllocated()); +} + +//============================================================================== +// Factory path tests (via YUPAUv3ViewController) +//============================================================================== + +class AUv3FactoryInstanceTests : public ::testing::Test +{ +protected: + void SetUp() override + { + viewController = [[YUPAUv3ViewController alloc] initWithNibName:nil bundle:nil]; + ASSERT_NE (nil, viewController); + } + + void TearDown() override + { + audioUnit = nil; + viewController = nil; + } + + AUAudioUnit* createAudioUnit() + { + const auto desc = makeTestDescription(); + NSError* error = nil; + audioUnit = [viewController createAudioUnitWithComponentDescription:desc error:&error]; + return audioUnit; + } + + YUPAUv3ViewController* viewController = nil; + AUAudioUnit* audioUnit = nil; +}; + +TEST_F (AUv3FactoryInstanceTests, CreateAudioUnitReturnsNonNull) +{ + auto* au = createAudioUnit(); + EXPECT_NE (nil, au); +} + +//============================================================================== +// Processor identity tests (Bug #8: editor must use render processor) +//============================================================================== + +TEST_F (AUv3FactoryInstanceTests, ViewControllerProcessorIsSetAfterCreatingAudioUnit) +{ + createAudioUnit(); + + Ivar ivar = class_getInstanceVariable ([viewController class], "cpp"); + ASSERT_NE (nullptr, ivar); + + using VP = std::unique_ptr; + auto* vcCppPtr = reinterpret_cast ( + reinterpret_cast ((__bridge void*) viewController) + ivar_getOffset (ivar)); + + ASSERT_NE (nullptr, vcCppPtr); + ASSERT_NE (nullptr, vcCppPtr->get()); + + auto* editorProcessor = (*vcCppPtr)->getProcessor(); + ignoreUnused (editorProcessor); +} + +//============================================================================== +// Render tests +//============================================================================== + +class AUv3RenderTests : public ::testing::Test +{ +protected: + void SetUp() override + { + audioUnit = instantiateTestAUAudioUnit(); + ASSERT_NE (nil, audioUnit); + cpp = AUAudioUnitSubclass::_this (audioUnit); + ASSERT_NE (nullptr, cpp); + ASSERT_TRUE (cpp->allocateRenderResourcesAndReturnError (nullptr)); + } + + void TearDown() override + { + if (cpp != nullptr && cpp->isRenderResourcesAllocated()) + cpp->deallocateRenderResources(); + + if (cpp != nullptr) + { + AUAudioUnitSubclass::setThis (audioUnit, nullptr); + delete cpp; + } + + cpp = nullptr; + audioUnit = nil; + } + + AUAudioUnit* audioUnit = nil; + AudioPluginProcessorAUv3* cpp = nullptr; +}; + +TEST_F (AUv3RenderTests, InternalRenderBlockIsAccessible) +{ + auto block = cpp->getInternalRenderBlock(); + EXPECT_NE (nil, block); +} + +TEST_F (AUv3RenderTests, RenderBlockRejectsOversizedFrameCount) +{ + auto block = cpp->getInternalRenderBlock(); + ASSERT_NE (nil, block); + + AudioUnitRenderActionFlags flags = 0; + AudioTimeStamp timestamp {}; + timestamp.mSampleTime = 0; + AudioBufferList outputBufferList {}; + outputBufferList.mNumberBuffers = 0; + + // A frame count of 0 must be accepted (0 <= allocatedMaximumFrames). + AUAudioUnitStatus status = block (&flags, ×tamp, 0, 0, &outputBufferList, nullptr, nullptr); + EXPECT_EQ (noErr, status); + + // A frame count exceeding the pre-allocated buffer capacity must be rejected. + status = block (&flags, ×tamp, std::numeric_limits::max(), 0, &outputBufferList, nullptr, nullptr); + EXPECT_EQ (kAudioUnitErr_TooManyFramesToProcess, status); +} + +//============================================================================== +// State tests +//============================================================================== + +class AUv3StateTests : public ::testing::Test +{ +protected: + void SetUp() override + { + audioUnit = instantiateTestAUAudioUnit(); + ASSERT_NE (nil, audioUnit); + cpp = AUAudioUnitSubclass::_this (audioUnit); + ASSERT_NE (nullptr, cpp); + } + + void TearDown() override + { + if (cpp != nullptr) + { + AUAudioUnitSubclass::setThis (audioUnit, nullptr); + delete cpp; + } + + cpp = nullptr; + audioUnit = nil; + } + + AUAudioUnit* audioUnit = nil; + AudioPluginProcessorAUv3* cpp = nullptr; +}; + +TEST_F (AUv3StateTests, FullStateIsRetrievable) +{ + auto* state = cpp->getFullState(); + EXPECT_NE (nil, state); +} + +TEST_F (AUv3StateTests, FactoryPresetsAreAccessible) +{ + auto* presets = cpp->getFactoryPresets(); + EXPECT_NE (nil, presets); +} + +TEST_F (AUv3StateTests, LatencyReturnsValidValue) +{ + const auto latency = cpp->getLatency(); + EXPECT_GE (latency, 0.0); +} + +TEST_F (AUv3StateTests, TailTimeReturnsValidValue) +{ + const auto tail = cpp->getTailTime(); + EXPECT_GE (tail, 0.0); +} + +//============================================================================== +// Sidechain bus layout tests (Bug: sidechain input handling) +//============================================================================== + +class AUv3SidechainInstanceTests : public ::testing::Test +{ +protected: + void SetUp() override + { + layoutGuard = std::make_unique (testPluginBusLayoutWithSidechain()); + audioUnit = instantiateTestAUAudioUnit(); + ASSERT_NE (nil, audioUnit); + cpp = AUAudioUnitSubclass::_this (audioUnit); + ASSERT_NE (nullptr, cpp); + } + + void TearDown() override + { + if (cpp != nullptr) + { + AUAudioUnitSubclass::setThis (audioUnit, nullptr); + delete cpp; + } + + cpp = nullptr; + audioUnit = nil; + layoutGuard.reset(); + } + + std::unique_ptr layoutGuard; + AUAudioUnit* audioUnit = nil; + AudioPluginProcessorAUv3* cpp = nullptr; +}; + +TEST_F (AUv3SidechainInstanceTests, InputBusCountIncludesSidechain) +{ + // 2 audio input buses: Main (2 ch) + Sidechain (1 ch) + auto* busses = cpp->getInputBusses(); + ASSERT_NE (nil, busses); + EXPECT_EQ (2u, [busses count]); +} + +TEST_F (AUv3SidechainInstanceTests, OutputBusCountExcludesSidechain) +{ + // 1 audio output bus: Main (2 ch) + auto* busses = cpp->getOutputBusses(); + ASSERT_NE (nil, busses); + EXPECT_EQ (1u, [busses count]); +} + +TEST_F (AUv3SidechainInstanceTests, ChannelCapabilitiesArePerBus) +{ + // Bug fix: channel capabilities should list each audio bus individually, + // not just the max. For sidechain layout: 2 input audio buses + 1 output = 3 entries. + auto* caps = cpp->getChannelCapabilities(); + ASSERT_NE (nil, caps); + EXPECT_EQ (3u, [caps count]); + + // Input bus 0: 2 channels, input bus 1: 1 channel, output bus 0: 2 channels + EXPECT_EQ (2, [[caps objectAtIndexedSubscript:0] integerValue]); + EXPECT_EQ (1, [[caps objectAtIndexedSubscript:1] integerValue]); + EXPECT_EQ (2, [[caps objectAtIndexedSubscript:2] integerValue]); +} + +TEST_F (AUv3SidechainInstanceTests, RenderResourcesAllocateWithSidechain) +{ + NSError* error = nil; + EXPECT_TRUE (cpp->allocateRenderResourcesAndReturnError (&error)); + EXPECT_EQ (nil, error); + EXPECT_TRUE (cpp->isRenderResourcesAllocated()); +} + +TEST_F (AUv3SidechainInstanceTests, ShouldChangeToFormatRejectsMismatchedChannels) +{ + // Bug fix: shouldChangeToFormat must use exact match (==), not <= + // The sidechain bus expects exactly 1 channel + + auto* inputBusses = cpp->getInputBusses(); + ASSERT_NE (nil, inputBusses); + ASSERT_GE ([inputBusses count], 2u); + + auto* sidechainBus = [inputBusses objectAtIndexedSubscript:1]; + ASSERT_NE (nil, sidechainBus); + + AVAudioFormat* validFormat = [[AVAudioFormat alloc] initStandardFormatWithSampleRate:44100.0 channels:1]; + EXPECT_TRUE (cpp->shouldChangeToFormat (validFormat, sidechainBus)); + + AVAudioFormat* tooManyChannels = [[AVAudioFormat alloc] initStandardFormatWithSampleRate:44100.0 channels:2]; + EXPECT_FALSE (cpp->shouldChangeToFormat (tooManyChannels, sidechainBus)); + + AVAudioFormat* zeroChannels = [[AVAudioFormat alloc] initStandardFormatWithSampleRate:44100.0 channels:0]; + EXPECT_FALSE (cpp->shouldChangeToFormat (zeroChannels, sidechainBus)); +} + +TEST_F (AUv3SidechainInstanceTests, ShouldChangeToFormatRejectsFloat64) +{ + // Float64 must be rejected when the processor does not support double precision. + auto* inputBusses = cpp->getInputBusses(); + ASSERT_NE (nil, inputBusses); + ASSERT_GE ([inputBusses count], 1u); + + auto* mainBus = [inputBusses objectAtIndexedSubscript:0]; + ASSERT_NE (nil, mainBus); + + AVAudioFormat* float64Format = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat64 + sampleRate:44100.0 + channels:2 + interleaved:NO]; + ASSERT_NE (nil, float64Format); + EXPECT_FALSE (cpp->shouldChangeToFormat (float64Format, mainBus)); +} + +TEST_F (AUv3SidechainInstanceTests, ShouldChangeToFormatAcceptsFloat64WhenSupported) +{ + // Float64 must be accepted when the processor supports double precision. + auto* proc = static_cast (cpp->getProcessor()); + ASSERT_NE (nullptr, proc); + proc->supportsDouble = true; + + auto* inputBusses = cpp->getInputBusses(); + ASSERT_NE (nil, inputBusses); + ASSERT_GE ([inputBusses count], 1u); + + auto* mainBus = [inputBusses objectAtIndexedSubscript:0]; + ASSERT_NE (nil, mainBus); + + AVAudioFormat* float64Format = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat64 + sampleRate:44100.0 + channels:2 + interleaved:NO]; + ASSERT_NE (nil, float64Format); + EXPECT_TRUE (cpp->shouldChangeToFormat (float64Format, mainBus)); +} + +TEST_F (AUv3SidechainInstanceTests, ShouldChangeToFormatAcceptsInterleaved) +{ + // Interleaved formats - the render callback handles deinterleave/interleave via AudioData converters. + auto* inputBusses = cpp->getInputBusses(); + ASSERT_NE (nil, inputBusses); + ASSERT_GE ([inputBusses count], 1u); + + auto* mainBus = [inputBusses objectAtIndexedSubscript:0]; + ASSERT_NE (nil, mainBus); + + AVAudioFormat* interleavedFormat = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32 + sampleRate:44100.0 + channels:2 + interleaved:YES]; + ASSERT_NE (nil, interleavedFormat); + EXPECT_TRUE (cpp->shouldChangeToFormat (interleavedFormat, mainBus)); +} + +TEST_F (AUv3SidechainInstanceTests, ShouldChangeToFormatAcceptsFloat32NonInterleaved) +{ + // The canonical format (Float32, non-interleaved, correct channel count) must be accepted. + auto* inputBusses = cpp->getInputBusses(); + ASSERT_NE (nil, inputBusses); + ASSERT_GE ([inputBusses count], 1u); + + auto* mainBus = [inputBusses objectAtIndexedSubscript:0]; + ASSERT_NE (nil, mainBus); + + AVAudioFormat* float32Format = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32 + sampleRate:44100.0 + channels:2 + interleaved:NO]; + ASSERT_NE (nil, float32Format); + EXPECT_TRUE (cpp->shouldChangeToFormat (float32Format, mainBus)); + + // Also verify the sidechain bus accepts its correct format + if ([inputBusses count] >= 2u) + { + auto* sidechainBus = [inputBusses objectAtIndexedSubscript:1]; + ASSERT_NE (nil, sidechainBus); + + AVAudioFormat* scFormat = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32 + sampleRate:44100.0 + channels:1 + interleaved:NO]; + ASSERT_NE (nil, scFormat); + EXPECT_TRUE (cpp->shouldChangeToFormat (scFormat, sidechainBus)); + } +} + +//============================================================================== +// Sidechain with inactive auxiliary bus +//============================================================================== + +class AUv3InactiveSidechainTests : public ::testing::Test +{ +protected: + void SetUp() override + { + layoutGuard = std::make_unique (testPluginBusLayoutWithInactiveSidechain()); + audioUnit = instantiateTestAUAudioUnit(); + ASSERT_NE (nil, audioUnit); + cpp = AUAudioUnitSubclass::_this (audioUnit); + ASSERT_NE (nullptr, cpp); + } + + void TearDown() override + { + if (cpp != nullptr) + { + AUAudioUnitSubclass::setThis (audioUnit, nullptr); + delete cpp; + } + + cpp = nullptr; + audioUnit = nil; + layoutGuard.reset(); + } + + std::unique_ptr layoutGuard; + AUAudioUnit* audioUnit = nil; + AudioPluginProcessorAUv3* cpp = nullptr; +}; + +TEST_F (AUv3InactiveSidechainTests, InputBusCountIncludesInactiveSidechain) +{ + auto* busses = cpp->getInputBusses(); + ASSERT_NE (nil, busses); + // Both active and inactive sidechain buses are still audio buses + EXPECT_EQ (2u, [busses count]); +} + +TEST_F (AUv3InactiveSidechainTests, RenderResourcesAllocateWithInactiveSidechain) +{ + NSError* error = nil; + EXPECT_TRUE (cpp->allocateRenderResourcesAndReturnError (&error)); + EXPECT_EQ (nil, error); +} + +//============================================================================== +// Sidechain with auxiliary output bus +//============================================================================== + +class AUv3AuxOutputTests : public ::testing::Test +{ +protected: + void SetUp() override + { + layoutGuard = std::make_unique (testPluginBusLayoutWithAuxOutput()); + audioUnit = instantiateTestAUAudioUnit(); + ASSERT_NE (nil, audioUnit); + cpp = AUAudioUnitSubclass::_this (audioUnit); + ASSERT_NE (nullptr, cpp); + } + + void TearDown() override + { + if (cpp != nullptr) + { + AUAudioUnitSubclass::setThis (audioUnit, nullptr); + delete cpp; + } + + cpp = nullptr; + audioUnit = nil; + layoutGuard.reset(); + } + + std::unique_ptr layoutGuard; + AUAudioUnit* audioUnit = nil; + AudioPluginProcessorAUv3* cpp = nullptr; +}; + +TEST_F (AUv3AuxOutputTests, OutputBusCountIncludesAuxiliaryOutput) +{ + auto* busses = cpp->getOutputBusses(); + ASSERT_NE (nil, busses); + EXPECT_EQ (2u, [busses count]); +} + +TEST_F (AUv3AuxOutputTests, ChannelCapabilitiesIncludeAuxiliaryOutput) +{ + auto* caps = cpp->getChannelCapabilities(); + ASSERT_NE (nil, caps); + // 1 input audio bus + 2 output audio buses = 3 entries + EXPECT_EQ (3u, [caps count]); +} + +//============================================================================== +// Parameter value tests +//============================================================================== + +class AUv3ParameterTests : public ::testing::Test +{ +protected: + void SetUp() override + { + audioUnit = instantiateTestAUAudioUnit(); + ASSERT_NE (nil, audioUnit); + cpp = AUAudioUnitSubclass::_this (audioUnit); + ASSERT_NE (nullptr, cpp); + tree = cpp->getParameterTree(); + ASSERT_NE (nil, tree); + } + + void TearDown() override + { + tree = nil; + if (cpp != nullptr) + { + AUAudioUnitSubclass::setThis (audioUnit, nullptr); + delete cpp; + } + + cpp = nullptr; + audioUnit = nil; + } + + AUAudioUnit* audioUnit = nil; + AudioPluginProcessorAUv3* cpp = nullptr; + AUParameterTree* tree = nil; +}; + +TEST_F (AUv3ParameterTests, ParameterTreeHasExpectedCount) +{ + auto* allParams = [tree allParameters]; + ASSERT_NE (nil, allParams); + // TestPluginProcessor: Gain (100), Mode (200), Meter (300), Internal (400) = 4 + EXPECT_EQ (4u, [allParams count]); +} + +TEST_F (AUv3ParameterTests, ParameterByAddressIsFound) +{ + auto* gainParam = [tree parameterWithAddress:100]; + EXPECT_NE (nil, gainParam); + EXPECT_EQ (100u, [gainParam address]); +} + +TEST_F (AUv3ParameterTests, ParameterByInvalidAddressIsNull) +{ + auto* param = [tree parameterWithAddress:99999]; + EXPECT_EQ (nil, param); +} + +TEST_F (AUv3ParameterTests, ParameterMetadataIsValid) +{ + auto* gainParam = [tree parameterWithAddress:100]; + ASSERT_NE (nil, gainParam); + + EXPECT_GT ([[gainParam displayName] length], 0u); + EXPECT_GT ([[gainParam identifier] length], 0u); + EXPECT_NEAR (0.5f, [gainParam value], 0.001f); +} + +TEST_F (AUv3ParameterTests, GetValueViaValueProvider) +{ + auto* gainParam = [tree parameterWithAddress:100]; + ASSERT_NE (nil, gainParam); + + AUValue value = [gainParam value]; + EXPECT_NEAR (0.5f, value, 0.001f); +} + +TEST_F (AUv3ParameterTests, SetValueViaValueObserver) +{ + auto* gainParam = [tree parameterWithAddress:100]; + ASSERT_NE (nil, gainParam); + + [gainParam setValue:0.25f originator:nil atHostTime:0 eventType:AUParameterAutomationEventTypeValue]; + + EXPECT_NEAR (0.25f, [gainParam value], 0.001f); +} + +TEST_F (AUv3ParameterTests, ValueRoundTrip) +{ + auto* modeParam = [tree parameterWithAddress:200]; + ASSERT_NE (nil, modeParam); + + [modeParam setValue:3.0f originator:nil atHostTime:0 eventType:AUParameterAutomationEventTypeValue]; + EXPECT_NEAR (3.0f, [modeParam value], 0.001f); + + [modeParam setValue:0.0f originator:nil atHostTime:0 eventType:AUParameterAutomationEventTypeValue]; + EXPECT_NEAR (0.0f, [modeParam value], 0.001f); +} + +TEST_F (AUv3ParameterTests, SteppedParameterHasCorrectRange) +{ + auto* modeParam = [tree parameterWithAddress:200]; + ASSERT_NE (nil, modeParam); + + EXPECT_NEAR (0.0f, [modeParam minValue], 0.001f); + EXPECT_NEAR (4.0f, [modeParam maxValue], 0.001f); +} + +TEST_F (AUv3ParameterTests, ReadOnlyParameterHasCorrectRange) +{ + auto* meterParam = [tree parameterWithAddress:300]; + ASSERT_NE (nil, meterParam); + + EXPECT_NEAR (-60.0f, [meterParam minValue], 0.001f); + EXPECT_NEAR (0.0f, [meterParam maxValue], 0.001f); +} + +TEST_F (AUv3ParameterTests, SteppedParameterHasValueStrings) +{ + // Mode (address 200) is a stepped enum parameter — should have value strings + auto* modeParam = [tree parameterWithAddress:200]; + ASSERT_NE (nil, modeParam); + + auto* valueStrings = [modeParam valueStrings]; + EXPECT_NE (nil, valueStrings); + EXPECT_GE ([valueStrings count], 1u); +} + +TEST_F (AUv3ParameterTests, ContinuousParameterHasNoValueStrings) +{ + // Gain (address 100) is a continuous parameter — should not have value strings + auto* gainParam = [tree parameterWithAddress:100]; + ASSERT_NE (nil, gainParam); + + EXPECT_EQ (nil, [gainParam valueStrings]); +} + +TEST_F (AUv3ParameterTests, ReadOnlyParameterRoundTripPreservesValue) +{ + // Meter (address 300) has range [-60, 0] — tests non-zero-minimum normalization + auto* meterParam = [tree parameterWithAddress:300]; + ASSERT_NE (nil, meterParam); + + // Set the AU parameter to a value in the middle of the range + [meterParam setValue:-30.0f originator:nil atHostTime:0 eventType:AUParameterAutomationEventTypeValue]; + EXPECT_NEAR (-30.0f, [meterParam value], 0.001f); + + // Verify the yup parameter's real value matches (not normalized) + auto* proc = static_cast (cpp->getProcessor()); + ASSERT_NE (nullptr, proc); + auto yupParams = proc->getParameters(); + ASSERT_GE (yupParams.size(), 4u); + auto* meterYupParam = yupParams[2].get(); // index 2 = Meter + ASSERT_NE (nullptr, meterYupParam); + EXPECT_NEAR (-30.0f, meterYupParam->getValue(), 0.001f); + + // Normalized value should be 0.5 (halfway between -60 and 0) + EXPECT_NEAR (0.5f, meterYupParam->getNormalizedValue(), 0.01f); +} + +TEST_F (AUv3ParameterTests, ReadOnlyParameterEndpointsRoundTrip) +{ + auto* meterParam = [tree parameterWithAddress:300]; + ASSERT_NE (nil, meterParam); + + auto* proc = static_cast (cpp->getProcessor()); + auto yupParams = proc->getParameters(); + ASSERT_GE (yupParams.size(), 4u); + auto* meterYupParam = yupParams[2].get(); + + // Minimum value: AU -60 → YUP real -60, normalized 0.0 + [meterParam setValue:-60.0f originator:nil atHostTime:0 eventType:AUParameterAutomationEventTypeValue]; + EXPECT_NEAR (-60.0f, meterYupParam->getValue(), 0.001f); + EXPECT_NEAR (0.0f, meterYupParam->getNormalizedValue(), 0.01f); + + // Maximum value: AU 0 → YUP real 0, normalized 1.0 + [meterParam setValue:0.0f originator:nil atHostTime:0 eventType:AUParameterAutomationEventTypeValue]; + EXPECT_NEAR (0.0f, meterYupParam->getValue(), 0.001f); + EXPECT_NEAR (1.0f, meterYupParam->getNormalizedValue(), 0.01f); +} + +TEST_F (AUv3ParameterTests, GainParameterEndpointsRoundTrip) +{ + // Gain (address 100) has range [0, 1] — tests linear zero-based normalization + auto* gainParam = [tree parameterWithAddress:100]; + ASSERT_NE (nil, gainParam); + + auto* proc = static_cast (cpp->getProcessor()); + auto yupParams = proc->getParameters(); + ASSERT_GE (yupParams.size(), 1u); + auto* gainYupParam = yupParams[0].get(); + + // Minimum value + [gainParam setValue:0.0f originator:nil atHostTime:0 eventType:AUParameterAutomationEventTypeValue]; + EXPECT_NEAR (0.0f, gainYupParam->getValue(), 0.001f); + EXPECT_NEAR (0.0f, gainYupParam->getNormalizedValue(), 0.001f); + + // Maximum value + [gainParam setValue:1.0f originator:nil atHostTime:0 eventType:AUParameterAutomationEventTypeValue]; + EXPECT_NEAR (1.0f, gainYupParam->getValue(), 0.001f); + EXPECT_NEAR (1.0f, gainYupParam->getNormalizedValue(), 0.001f); + + // Midpoint + [gainParam setValue:0.5f originator:nil atHostTime:0 eventType:AUParameterAutomationEventTypeValue]; + EXPECT_NEAR (0.5f, gainYupParam->getValue(), 0.001f); + EXPECT_NEAR (0.5f, gainYupParam->getNormalizedValue(), 0.001f); +} + +TEST_F (AUv3ParameterTests, StringConversionUsesRealValue) +{ + auto* gainParam = [tree parameterWithAddress:100]; + ASSERT_NE (nil, gainParam); + + // The implementorStringFromValueCallback should return a string for the real (denormalized) value. + // Since the Gain param has range [0, 1] and default 0.5, the string should represent 0.5. + AUValue val = 0.5f; + auto* displayString = [gainParam stringFromValue:&val]; + ASSERT_NE (nil, displayString); + EXPECT_GT ([displayString length], 0u); + + // For a stepped param like Mode, value strings should match + auto* modeParam = [tree parameterWithAddress:200]; + ASSERT_NE (nil, modeParam); + + AUValue modeVal = 0.0f; + auto* modeString = [modeParam stringFromValue:&modeVal]; + ASSERT_NE (nil, modeString); + EXPECT_GT ([modeString length], 0u); +} + +//============================================================================== +// Bypass tests +//============================================================================== + +class AUv3BypassTests : public ::testing::Test +{ +protected: + void SetUp() override + { + audioUnit = instantiateTestAUAudioUnit(); + ASSERT_NE (nil, audioUnit); + cpp = AUAudioUnitSubclass::_this (audioUnit); + ASSERT_NE (nullptr, cpp); + } + + void TearDown() override + { + if (cpp != nullptr) + { + AUAudioUnitSubclass::setThis (audioUnit, nullptr); + delete cpp; + } + + cpp = nullptr; + audioUnit = nil; + } + + AUAudioUnit* audioUnit = nil; + AudioPluginProcessorAUv3* cpp = nullptr; +}; + +TEST_F (AUv3BypassTests, ShouldBypassEffectDefaultsToFalse) +{ + EXPECT_FALSE (cpp->getShouldBypassEffect()); +} + +TEST_F (AUv3BypassTests, SetShouldBypassEffectTogglesState) +{ + cpp->setShouldBypassEffect (true); + EXPECT_TRUE (cpp->getShouldBypassEffect()); + + cpp->setShouldBypassEffect (false); + EXPECT_FALSE (cpp->getShouldBypassEffect()); +} + +TEST_F (AUv3BypassTests, FullStateRoundTripsBypassState) +{ + cpp->setShouldBypassEffect (true); + + auto* state = cpp->getFullState(); + ASSERT_NE (nil, state); + + cpp->setShouldBypassEffect (false); + EXPECT_FALSE (cpp->getShouldBypassEffect()); + + cpp->setFullState (state); + EXPECT_TRUE (cpp->getShouldBypassEffect()); +} + +TEST_F (AUv3BypassTests, LegacyRawStateFallsBackToProcessorState) +{ + // Pre-bypass-fix presets store raw processor state without the wrapper magic. + // Loading one must restore the processor state and leave bypass untouched. + cpp->setShouldBypassEffect (true); + + auto* proc = static_cast (cpp->getProcessor()); + ASSERT_NE (nullptr, proc); + + const uint8_t legacyData[] = { 0x01, 0x02, 0x03, 0x04, 0x05 }; + auto* rawData = [[NSData alloc] initWithBytes:legacyData length:sizeof (legacyData)]; + auto* state = @{ (__bridge NSString*) getAUProcessorStateKey() : rawData }; + + cpp->setFullState (state); + + EXPECT_TRUE (cpp->getShouldBypassEffect()); // bypass untouched by legacy state + ASSERT_EQ (sizeof (legacyData), proc->lastLoadedState.getSize()); + EXPECT_EQ (0, std::memcmp (legacyData, proc->lastLoadedState.getData(), sizeof (legacyData))); +} + +//============================================================================== +// Bypass render tests +//============================================================================== + +class AUv3BypassRenderTests : public ::testing::Test +{ +protected: + void SetUp() override + { + audioUnit = instantiateTestAUAudioUnit(); + ASSERT_NE (nil, audioUnit); + audioUnit.maximumFramesToRender = 512; + cpp = AUAudioUnitSubclass::_this (audioUnit); + ASSERT_NE (nullptr, cpp); + ASSERT_TRUE (cpp->allocateRenderResourcesAndReturnError (nullptr)); + } + + void TearDown() override + { + if (cpp != nullptr && cpp->isRenderResourcesAllocated()) + cpp->deallocateRenderResources(); + + if (cpp != nullptr) + { + AUAudioUnitSubclass::setThis (audioUnit, nullptr); + delete cpp; + } + + cpp = nullptr; + audioUnit = nil; + } + + AUAudioUnit* audioUnit = nil; + AudioPluginProcessorAUv3* cpp = nullptr; +}; + +TEST_F (AUv3BypassRenderTests, RenderRoutesToBypassedPathWhenBypassed) +{ + auto* proc = static_cast (cpp->getProcessor()); + ASSERT_NE (nullptr, proc); + + cpp->setShouldBypassEffect (true); + + auto block = cpp->getInternalRenderBlock(); + ASSERT_NE (nil, block); + + AudioUnitRenderActionFlags flags = 0; + AudioTimeStamp timestamp {}; + timestamp.mSampleTime = 0; + + constexpr AUAudioFrameCount frameCount = 64; + float ch0[64] = {}; + float ch1[64] = {}; + + AudioBufferList outputBufferList {}; + outputBufferList.mNumberBuffers = 2; + outputBufferList.mBuffers[0].mNumberChannels = 1; + outputBufferList.mBuffers[0].mData = ch0; + outputBufferList.mBuffers[0].mDataByteSize = sizeof (ch0); + outputBufferList.mBuffers[1].mNumberChannels = 1; + outputBufferList.mBuffers[1].mData = ch1; + outputBufferList.mBuffers[1].mDataByteSize = sizeof (ch1); + + const AUAudioUnitStatus status = block (&flags, ×tamp, frameCount, 0, &outputBufferList, nullptr, nullptr); + EXPECT_EQ (noErr, status); + + EXPECT_EQ (1, proc->bypassCallCount); + EXPECT_EQ (0, proc->processCallCount); +} + +TEST_F (AUv3BypassRenderTests, RenderRoutesToProcessPathWhenNotBypassed) +{ + auto* proc = static_cast (cpp->getProcessor()); + ASSERT_NE (nullptr, proc); + + auto block = cpp->getInternalRenderBlock(); + ASSERT_NE (nil, block); + + AudioUnitRenderActionFlags flags = 0; + AudioTimeStamp timestamp {}; + timestamp.mSampleTime = 0; + + constexpr AUAudioFrameCount frameCount = 64; + float ch0[64] = {}; + float ch1[64] = {}; + + AudioBufferList outputBufferList {}; + outputBufferList.mNumberBuffers = 2; + outputBufferList.mBuffers[0].mNumberChannels = 1; + outputBufferList.mBuffers[0].mData = ch0; + outputBufferList.mBuffers[0].mDataByteSize = sizeof (ch0); + outputBufferList.mBuffers[1].mNumberChannels = 1; + outputBufferList.mBuffers[1].mData = ch1; + outputBufferList.mBuffers[1].mDataByteSize = sizeof (ch1); + + const AUAudioUnitStatus status = block (&flags, ×tamp, frameCount, 0, &outputBufferList, nullptr, nullptr); + EXPECT_EQ (noErr, status); + + EXPECT_EQ (0, proc->bypassCallCount); + EXPECT_EQ (1, proc->processCallCount); +} + +//============================================================================== +// Sidechain bus name tests +//============================================================================== + +TEST_F (AUv3SidechainInstanceTests, MainInputBusHasName) +{ + auto* busses = cpp->getInputBusses(); + ASSERT_NE (nil, busses); + ASSERT_GE ([busses count], 1u); + + auto* mainBus = [busses objectAtIndexedSubscript:0]; + ASSERT_NE (nil, mainBus); + EXPECT_GT ([[mainBus name] length], 0u); +} + +TEST_F (AUv3SidechainInstanceTests, SidechainInputBusHasName) +{ + auto* busses = cpp->getInputBusses(); + ASSERT_NE (nil, busses); + ASSERT_GE ([busses count], 2u); + + auto* scBus = [busses objectAtIndexedSubscript:1]; + ASSERT_NE (nil, scBus); + EXPECT_GT ([[scBus name] length], 0u); +} + +TEST_F (AUv3SidechainInstanceTests, MainOutputBusHasName) +{ + auto* busses = cpp->getOutputBusses(); + ASSERT_NE (nil, busses); + ASSERT_GE ([busses count], 1u); + + auto* mainBus = [busses objectAtIndexedSubscript:0]; + ASSERT_NE (nil, mainBus); + EXPECT_GT ([[mainBus name] length], 0u); +} + +//============================================================================== +// State save/load round-trip tests +//============================================================================== + +class AUv3StateRoundTripTests : public ::testing::Test +{ +protected: + void SetUp() override + { + audioUnit = instantiateTestAUAudioUnit(); + ASSERT_NE (nil, audioUnit); + cpp = AUAudioUnitSubclass::_this (audioUnit); + ASSERT_NE (nullptr, cpp); + } + + void TearDown() override + { + if (cpp != nullptr) + { + AUAudioUnitSubclass::setThis (audioUnit, nullptr); + delete cpp; + } + + cpp = nullptr; + audioUnit = nil; + } + + AUAudioUnit* audioUnit = nil; + AudioPluginProcessorAUv3* cpp = nullptr; +}; + +TEST_F (AUv3StateRoundTripTests, FullStateIsRetrievable) +{ + auto* state = cpp->getFullState(); + ASSERT_NE (nil, state); +} + +TEST_F (AUv3StateRoundTripTests, FullStateContainsProcessorStateWhenPopulated) +{ + auto* proc = static_cast (cpp->getProcessor()); + ASSERT_NE (nullptr, proc); + + proc->lastSavedState = MemoryBlock ("test", 4); + + auto* state = cpp->getFullState(); + ASSERT_NE (nil, state); + EXPECT_NE (nil, state[@"YUPProcessorState"]); +} + +TEST_F (AUv3StateRoundTripTests, SetFullStateDoesNotCrash) +{ + auto* state = cpp->getFullState(); + ASSERT_NE (nil, state); + + cpp->setFullState (state); +} + +TEST_F (AUv3StateRoundTripTests, YupProcessorStateRoundTrip) +{ + // Populate processor state so saveStateIntoMemory produces data + auto* proc = static_cast (cpp->getProcessor()); + ASSERT_NE (nullptr, proc); + + const uint8_t testData[] = { 0xde, 0xad, 0xbe, 0xef }; + proc->lastSavedState = MemoryBlock (testData, sizeof (testData)); + + // Save full state + auto* savedState = cpp->getFullState(); + ASSERT_NE (nil, savedState); + ASSERT_NE (nil, savedState[@"YUPProcessorState"]); + + // Modify the saved state on the processor so we can detect restoration + proc->lastSavedState = MemoryBlock(); + + // Restore + cpp->setFullState (savedState); + + // Verify the processor received the original state + EXPECT_EQ (sizeof (testData), proc->lastLoadedState.getSize()); + EXPECT_EQ (0, std::memcmp (testData, proc->lastLoadedState.getData(), sizeof (testData))); +} + +TEST_F (AUv3StateRoundTripTests, SetFullStateWithMissingYupKeyDoesNotCrash) +{ + // setFullState should handle a dictionary without YUPProcessorState gracefully + auto* emptyState = [[NSDictionary alloc] init]; + cpp->setFullState (emptyState); + SUCCEED() << "setFullState did not crash with malformed YUPProcessorState"; +} + +TEST_F (AUv3StateRoundTripTests, SetFullStateWithMalformedYupKeyDoesNotCrash) +{ + // setFullState should handle a malformed YUPProcessorState value gracefully + auto* badState = @{ @"YUPProcessorState": @"not-data" }; + cpp->setFullState (badState); + SUCCEED() << "setFullState did not crash with malformed YUPProcessorState"; +} + +TEST_F (AUv3StateRoundTripTests, LatencyIsPreservedAcrossStateRestore) +{ + const auto initialLatency = cpp->getLatency(); + EXPECT_GE (initialLatency, 0.0); + + auto* state = cpp->getFullState(); + ASSERT_NE (nil, state); + cpp->setFullState (state); + + EXPECT_EQ (initialLatency, cpp->getLatency()); +} diff --git a/tests/yup_audio_plugin_client_clap.cpp b/tests/yup_audio_plugin_client_clap.cpp new file mode 100644 index 000000000..9b159b5d1 --- /dev/null +++ b/tests/yup_audio_plugin_client_clap.cpp @@ -0,0 +1,798 @@ +/* + ============================================================================== + + 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 + +#include + +// ============================================================================= +#define YUP_AUDIO_PLUGIN_ENABLE_CLAP 1 +#define YupPlugin_Id "test.clap.plugin" +#define YupPlugin_Name "Test CLAP Plugin" +#define YupPlugin_Vendor "TestVendor" +#define YupPlugin_Version "1.0.0" +#define YupPlugin_Description "Test CLAP wrapper for yup" +#define YupPlugin_URL "https://test.example" +#define YupPlugin_Email "test@example.com" +#define YupPlugin_IsSynth 0 +#define YupPlugin_IsMono 0 + +// ============================================================================= +#include +#include "yup_audio_plugin_client/yup_TestPluginProcessor.h" + +#define YUP_AUDIO_PLUGIN_CREATE_FUNCTION createPluginProcessorCLAP +#include "yup_audio_plugin_client/clap/yup_audio_plugin_client_CLAP.cpp" + +extern "C" yup::AudioProcessor* createPluginProcessorCLAP() +{ + return new TestPluginProcessor (testPluginBusLayoutWithSidechain()); +} + +// ============================================================================= +// Tests +// ============================================================================= + +using namespace yup; + +namespace +{ + +// Minimal clap_host_t — same zero-extension pattern used during CLAP scanning +clap_host_t makeMinimalHost() +{ + clap_host_t host {}; + host.clap_version = CLAP_VERSION; + host.host_data = nullptr; + host.name = "TestHost"; + host.vendor = "TestVendor"; + host.url = ""; + host.version = "0.0.0"; + host.get_extension = [] (const clap_host_t*, const char*) -> const void* + { + return nullptr; + }; + host.request_restart = [] (const clap_host_t*) {}; + host.request_process = [] (const clap_host_t*) {}; + host.request_callback = [] (const clap_host_t*) {}; + return host; +} + +} // namespace + +//------------------------------------------------------------------------------ +// Factory tests +//------------------------------------------------------------------------------ + +TEST (CLAPWrapperTest, EntryPointProvidesFactory) +{ + // clap_entry is the extern "C" symbol exported by the wrapper + const auto* factory = static_cast ( + clap_entry.get_factory (CLAP_PLUGIN_FACTORY_ID)); + ASSERT_NE (nullptr, factory); +} + +TEST (CLAPWrapperTest, FactoryReturnsNullForWrongID) +{ + const auto* result = clap_entry.get_factory ("wrong.factory.id"); + EXPECT_EQ (nullptr, result); +} + +TEST (CLAPWrapperTest, FactoryReportsOnePlugin) +{ + const auto* factory = static_cast ( + clap_entry.get_factory (CLAP_PLUGIN_FACTORY_ID)); + ASSERT_NE (nullptr, factory); + + EXPECT_EQ (1u, factory->get_plugin_count (factory)); + EXPECT_NE (nullptr, factory->get_plugin_descriptor (factory, 0)); + EXPECT_EQ (nullptr, factory->get_plugin_descriptor (factory, 1)); +} + +TEST (CLAPWrapperTest, DescriptorMatchesMetadata) +{ + const auto* factory = static_cast ( + clap_entry.get_factory (CLAP_PLUGIN_FACTORY_ID)); + ASSERT_NE (nullptr, factory); + + const auto* desc = factory->get_plugin_descriptor (factory, 0); + ASSERT_NE (nullptr, desc); + EXPECT_STREQ ("test.clap.plugin", desc->id); + EXPECT_STREQ ("Test CLAP Plugin", desc->name); + EXPECT_STREQ ("TestVendor", desc->vendor); +} + +TEST (CLAPWrapperTest, CreatePluginReturnsNullForWrongID) +{ + auto host = makeMinimalHost(); + const auto* factory = static_cast ( + clap_entry.get_factory (CLAP_PLUGIN_FACTORY_ID)); + ASSERT_NE (nullptr, factory); + + const auto* plugin = factory->create_plugin (factory, &host, "wrong.id"); + EXPECT_EQ (nullptr, plugin); +} + +TEST (CLAPWrapperTest, CreatePluginSucceeds) +{ + auto host = makeMinimalHost(); + const auto* factory = static_cast ( + clap_entry.get_factory (CLAP_PLUGIN_FACTORY_ID)); + ASSERT_NE (nullptr, factory); + + const auto* plugin = factory->create_plugin (factory, &host, "test.clap.plugin"); + ASSERT_NE (nullptr, plugin); + + // Clean up + plugin->destroy (plugin); +} + +//------------------------------------------------------------------------------ +// Lifecycle tests +//------------------------------------------------------------------------------ + +class CLAPLifecycleTests : public ::testing::Test +{ +protected: + void SetUp() override + { + host = makeMinimalHost(); + const auto* factory = static_cast ( + clap_entry.get_factory (CLAP_PLUGIN_FACTORY_ID)); + plugin = factory->create_plugin (factory, &host, "test.clap.plugin"); + ASSERT_NE (nullptr, plugin); + } + + void TearDown() override + { + if (plugin != nullptr) + plugin->destroy (plugin); + } + + clap_host_t host; + const clap_plugin_t* plugin = nullptr; +}; + +TEST_F (CLAPLifecycleTests, InitSucceeds) +{ + EXPECT_TRUE (plugin->init (plugin)); +} + +TEST_F (CLAPLifecycleTests, ActivateAndDeactivate) +{ + ASSERT_TRUE (plugin->init (plugin)); + EXPECT_TRUE (plugin->activate (plugin, 44100.0, 512, 1024)); + plugin->deactivate (plugin); +} + +//------------------------------------------------------------------------------ +// Parameter extension tests +//------------------------------------------------------------------------------ + +class CLAPParamsTests : public ::testing::Test +{ +protected: + void SetUp() override + { + host = makeMinimalHost(); + const auto* factory = static_cast ( + clap_entry.get_factory (CLAP_PLUGIN_FACTORY_ID)); + plugin = factory->create_plugin (factory, &host, "test.clap.plugin"); + ASSERT_NE (nullptr, plugin); + ASSERT_TRUE (plugin->init (plugin)); + + paramsExt = static_cast ( + plugin->get_extension (plugin, CLAP_EXT_PARAMS)); + } + + void TearDown() override + { + if (plugin != nullptr) + plugin->destroy (plugin); + } + + clap_host_t host; + const clap_plugin_t* plugin = nullptr; + const clap_plugin_params_t* paramsExt = nullptr; +}; + +TEST_F (CLAPParamsTests, ExtensionIsAvailable) +{ + ASSERT_NE (nullptr, paramsExt); +} + +TEST_F (CLAPParamsTests, CountIncludesBypassParameter) +{ + ASSERT_NE (nullptr, paramsExt); + // 4 processor parameters + 1 synthetic bypass = 5 + EXPECT_EQ (5u, paramsExt->count (plugin)); +} + +TEST_F (CLAPParamsTests, GetInfoForFloatParameter) +{ + ASSERT_NE (nullptr, paramsExt); + + clap_param_info_t info {}; + EXPECT_TRUE (paramsExt->get_info (plugin, 0, &info)); + + EXPECT_EQ (100u, info.id); // host ID + EXPECT_EQ (static_cast (CLAP_PARAM_IS_AUTOMATABLE | CLAP_PARAM_IS_MODULATABLE), + info.flags & (CLAP_PARAM_IS_AUTOMATABLE | CLAP_PARAM_IS_MODULATABLE)); +} + +TEST_F (CLAPParamsTests, GetInfoForSteppedEnumParameter) +{ + ASSERT_NE (nullptr, paramsExt); + + clap_param_info_t info {}; + EXPECT_TRUE (paramsExt->get_info (plugin, 1, &info)); + + EXPECT_EQ (200u, info.id); + EXPECT_NE (0u, info.flags & CLAP_PARAM_IS_STEPPED); + EXPECT_EQ (4u, info.max_value - info.min_value); // 0 to 4 +} + +TEST_F (CLAPParamsTests, GetInfoForReadOnlyParameter) +{ + ASSERT_NE (nullptr, paramsExt); + + clap_param_info_t info {}; + EXPECT_TRUE (paramsExt->get_info (plugin, 2, &info)); + + EXPECT_EQ (300u, info.id); + EXPECT_EQ (0u, info.flags & CLAP_PARAM_IS_AUTOMATABLE); +} + +TEST_F (CLAPParamsTests, GetValueAndValueToText) +{ + ASSERT_NE (nullptr, paramsExt); + + double value = 0.0; + EXPECT_TRUE (paramsExt->get_value (plugin, 100, &value)); + + char buffer[64] {}; + EXPECT_TRUE (paramsExt->value_to_text (plugin, 100, value, buffer, sizeof (buffer))); + EXPECT_GT (std::strlen (buffer), 0u); +} + +TEST_F (CLAPParamsTests, TextToValueRoundTrip) +{ + ASSERT_NE (nullptr, paramsExt); + + char buffer[64] {}; + ASSERT_TRUE (paramsExt->value_to_text (plugin, 100, 0.5, buffer, sizeof (buffer))); + + double parsed = 0.0; + EXPECT_TRUE (paramsExt->text_to_value (plugin, 100, buffer, &parsed)); + EXPECT_NEAR (0.5, parsed, 0.01); +} + +TEST_F (CLAPParamsTests, BypassParameterIsLast) +{ + ASSERT_NE (nullptr, paramsExt); + + const uint32_t count = paramsExt->count (plugin); + ASSERT_GE (count, 1u); + + clap_param_info_t info {}; + EXPECT_TRUE (paramsExt->get_info (plugin, count - 1, &info)); + + EXPECT_NE (0u, info.flags & CLAP_PARAM_IS_BYPASS); +} + +//------------------------------------------------------------------------------ +// Bypass parameter handling tests +//------------------------------------------------------------------------------ + +namespace +{ + +// Minimal CLAP input event list holding a single event (or none when null) +struct SingleInputEventList final : clap_input_events_t +{ + explicit SingleInputEventList (const clap_event_header_t* eventToDeliver) + : event (eventToDeliver) + { + this->ctx = this; + this->size = [] (const clap_input_events_t* list) -> uint32_t + { + return static_cast (list)->event != nullptr ? 1u : 0u; + }; + this->get = [] (const clap_input_events_t* list, uint32_t index) -> const clap_event_header_t* + { + return index == 0 ? static_cast (list)->event : nullptr; + }; + } + + const clap_event_header_t* event = nullptr; +}; + +// Minimal memory-backed CLAP output stream +struct MemoryOStream final : clap_ostream_t +{ + MemoryOStream() + { + this->ctx = this; + this->write = [] (const clap_ostream_t* stream, const void* buffer, uint64_t size) -> int64_t + { + auto* self = static_cast (stream->ctx); + self->data.append (buffer, static_cast (size)); + return static_cast (size); + }; + } + + yup::MemoryBlock data; +}; + +// Minimal memory-backed CLAP input stream +struct MemoryIStream final : clap_istream_t +{ + explicit MemoryIStream (const yup::MemoryBlock& source) + : data (source) + { + this->ctx = this; + this->read = [] (const clap_istream_t* stream, void* buffer, uint64_t size) -> int64_t + { + auto* self = static_cast (stream->ctx); + const auto remaining = self->data.getSize() - self->position; + const auto bytesToRead = std::min (size, remaining); + + if (bytesToRead > 0) + std::memcpy (buffer, static_cast (self->data.getData()) + self->position, static_cast (bytesToRead)); + + self->position += static_cast (bytesToRead); + return static_cast (bytesToRead); + }; + } + + yup::MemoryBlock data; + size_t position = 0; +}; + +} // namespace + +class CLAPBypassTests : public ::testing::Test +{ +protected: + void SetUp() override + { + host = makeMinimalHost(); + createPlugin(); + } + + void TearDown() override + { + if (plugin != nullptr) + plugin->destroy (plugin); + } + + void createPlugin() + { + const auto* factory = static_cast ( + clap_entry.get_factory (CLAP_PLUGIN_FACTORY_ID)); + plugin = factory->create_plugin (factory, &host, "test.clap.plugin"); + ASSERT_NE (nullptr, plugin); + ASSERT_TRUE (plugin->init (plugin)); + + processor = static_cast (getWrapper (plugin)->getProcessor()); + ASSERT_NE (nullptr, processor); + + paramsExt = static_cast ( + plugin->get_extension (plugin, CLAP_EXT_PARAMS)); + ASSERT_NE (nullptr, paramsExt); + + stateExt = static_cast ( + plugin->get_extension (plugin, CLAP_EXT_STATE)); + ASSERT_NE (nullptr, stateExt); + + // The wrapper-owned bypass parameter is always the last one + const auto count = paramsExt->count (plugin); + ASSERT_GE (count, 1u); + + clap_param_info_t info {}; + ASSERT_TRUE (paramsExt->get_info (plugin, count - 1, &info)); + bypassParameterID = info.id; + } + + // Runs the plugin's process function with valid audio buffers so the wrapper + // reaches processAudioBlock. Optionally delivers a bypass parameter change. + void runProcess (const clap_event_header_t* event = nullptr) + { + SingleInputEventList inputEvents (event); + + float mainInputData[2][64] = {}; + float sidechainInputData[1][64] = {}; + float mainOutputData[2][64] = {}; + + float* mainInputChannels[2] = { mainInputData[0], mainInputData[1] }; + float* sidechainInputChannels[1] = { sidechainInputData[0] }; + float* mainOutputChannels[2] = { mainOutputData[0], mainOutputData[1] }; + + clap_audio_buffer_t mainInput {}; + mainInput.data32 = mainInputChannels; + mainInput.channel_count = 2; + + clap_audio_buffer_t sidechainInput {}; + sidechainInput.data32 = sidechainInputChannels; + sidechainInput.channel_count = 1; + + clap_audio_buffer_t mainOutput {}; + mainOutput.data32 = mainOutputChannels; + mainOutput.channel_count = 2; + + clap_audio_buffer_t inputs[] = { mainInput, sidechainInput }; + clap_audio_buffer_t outputs[] = { mainOutput }; + + clap_process_t process {}; + process.frames_count = 64; + process.in_events = &inputEvents; + process.audio_inputs = inputs; + process.audio_inputs_count = 2; + process.audio_outputs = outputs; + process.audio_outputs_count = 1; + + EXPECT_EQ (CLAP_PROCESS_CONTINUE, plugin->process (plugin, &process)); + } + + void sendBypassValue (double value) + { + clap_event_param_value_t paramEvent {}; + paramEvent.header.size = sizeof (paramEvent); + paramEvent.header.time = 0; + paramEvent.header.space_id = CLAP_CORE_EVENT_SPACE_ID; + paramEvent.header.type = CLAP_EVENT_PARAM_VALUE; + paramEvent.header.flags = 0; + paramEvent.param_id = bypassParameterID; + paramEvent.cookie = nullptr; + paramEvent.note_id = -1; + paramEvent.port_index = -1; + paramEvent.channel = -1; + paramEvent.key = -1; + paramEvent.value = value; + + runProcess (reinterpret_cast (¶mEvent)); + } + + clap_host_t host; + const clap_plugin_t* plugin = nullptr; + const clap_plugin_params_t* paramsExt = nullptr; + const clap_plugin_state_t* stateExt = nullptr; + TestPluginProcessor* processor = nullptr; + clap_id bypassParameterID = CLAP_INVALID_ID; +}; + +TEST_F (CLAPBypassTests, DefaultsToNotBypassed) +{ + double value = 1.0; + ASSERT_TRUE (paramsExt->get_value (plugin, bypassParameterID, &value)); + EXPECT_DOUBLE_EQ (0.0, value); +} + +TEST_F (CLAPBypassTests, ValueToTextAndTextToValue) +{ + char buffer[64] {}; + ASSERT_TRUE (paramsExt->value_to_text (plugin, bypassParameterID, 1.0, buffer, sizeof (buffer))); + EXPECT_STREQ ("On", buffer); + + ASSERT_TRUE (paramsExt->value_to_text (plugin, bypassParameterID, 0.0, buffer, sizeof (buffer))); + EXPECT_STREQ ("Off", buffer); + + double value = 0.0; + ASSERT_TRUE (paramsExt->text_to_value (plugin, bypassParameterID, "On", &value)); + EXPECT_DOUBLE_EQ (1.0, value); + + ASSERT_TRUE (paramsExt->text_to_value (plugin, bypassParameterID, "Off", &value)); + EXPECT_DOUBLE_EQ (0.0, value); +} + +TEST_F (CLAPBypassTests, BypassEventRoutesToBypassedPath) +{ + sendBypassValue (1.0); + + EXPECT_EQ (1, processor->bypassCallCount); + EXPECT_EQ (0, processor->processCallCount); + + double value = 0.0; + ASSERT_TRUE (paramsExt->get_value (plugin, bypassParameterID, &value)); + EXPECT_DOUBLE_EQ (1.0, value); +} + +TEST_F (CLAPBypassTests, NonBypassEventRoutesToProcessPath) +{ + sendBypassValue (0.0); + + EXPECT_EQ (0, processor->bypassCallCount); + EXPECT_EQ (1, processor->processCallCount); + + double value = 1.0; + ASSERT_TRUE (paramsExt->get_value (plugin, bypassParameterID, &value)); + EXPECT_DOUBLE_EQ (0.0, value); +} + +TEST_F (CLAPBypassTests, StateRoundTripsBypassState) +{ + sendBypassValue (1.0); + EXPECT_EQ (1, processor->bypassCallCount); + + MemoryOStream out; + ASSERT_TRUE (stateExt->save (plugin, &out)); + ASSERT_GT (out.data.getSize(), 0u); + + // Recreate the plugin and restore the saved state + plugin->destroy (plugin); + plugin = nullptr; + + createPlugin(); + + MemoryIStream in (out.data); + ASSERT_TRUE (stateExt->load (plugin, &in)); + + double value = 0.0; + ASSERT_TRUE (paramsExt->get_value (plugin, bypassParameterID, &value)); + EXPECT_DOUBLE_EQ (1.0, value); + + // A fresh process call without bypass events must keep routing to the + // bypass path because the loaded state restored the wrapper's bypass flag + runProcess(); + EXPECT_EQ (1, processor->bypassCallCount); + EXPECT_EQ (0, processor->processCallCount); +} + +//------------------------------------------------------------------------------ +// State extension tests +//------------------------------------------------------------------------------ + +class CLAPStateTests : public ::testing::Test +{ +protected: + void SetUp() override + { + host = makeMinimalHost(); + const auto* factory = static_cast ( + clap_entry.get_factory (CLAP_PLUGIN_FACTORY_ID)); + plugin = factory->create_plugin (factory, &host, "test.clap.plugin"); + ASSERT_NE (nullptr, plugin); + ASSERT_TRUE (plugin->init (plugin)); + + stateExt = static_cast ( + plugin->get_extension (plugin, CLAP_EXT_STATE)); + } + + void TearDown() override + { + if (plugin != nullptr) + plugin->destroy (plugin); + } + + clap_host_t host; + const clap_plugin_t* plugin = nullptr; + const clap_plugin_state_t* stateExt = nullptr; +}; + +TEST_F (CLAPStateTests, ExtensionIsAvailable) +{ + ASSERT_NE (nullptr, stateExt); +} + +//------------------------------------------------------------------------------ +// Audio ports extension tests +//------------------------------------------------------------------------------ + +class CLAPAudioPortsTests : public ::testing::Test +{ +protected: + void SetUp() override + { + host = makeMinimalHost(); + const auto* factory = static_cast ( + clap_entry.get_factory (CLAP_PLUGIN_FACTORY_ID)); + plugin = factory->create_plugin (factory, &host, "test.clap.plugin"); + ASSERT_NE (nullptr, plugin); + ASSERT_TRUE (plugin->init (plugin)); + + portsExt = static_cast ( + plugin->get_extension (plugin, CLAP_EXT_AUDIO_PORTS)); + } + + void TearDown() override + { + if (plugin != nullptr) + plugin->destroy (plugin); + } + + clap_host_t host; + const clap_plugin_t* plugin = nullptr; + const clap_plugin_audio_ports_t* portsExt = nullptr; +}; + +TEST_F (CLAPAudioPortsTests, ExtensionIsAvailable) +{ + ASSERT_NE (nullptr, portsExt); +} + +TEST_F (CLAPAudioPortsTests, InputCountMatchesLayout) +{ + ASSERT_NE (nullptr, portsExt); + // 2 input buses (Main Input + Sidechain Input) → 2 ports + EXPECT_EQ (2u, portsExt->count (plugin, true)); +} + +TEST_F (CLAPAudioPortsTests, OutputCountMatchesLayout) +{ + ASSERT_NE (nullptr, portsExt); + // 1 output bus with 2 channels → 1 port + EXPECT_EQ (1u, portsExt->count (plugin, false)); +} + +//------------------------------------------------------------------------------ +// Note ports extension tests +//------------------------------------------------------------------------------ + +class CLAPNotePortsTests : public ::testing::Test +{ +protected: + void SetUp() override + { + host = makeMinimalHost(); + const auto* factory = static_cast ( + clap_entry.get_factory (CLAP_PLUGIN_FACTORY_ID)); + plugin = factory->create_plugin (factory, &host, "test.clap.plugin"); + ASSERT_NE (nullptr, plugin); + ASSERT_TRUE (plugin->init (plugin)); + + notePortsExt = static_cast ( + plugin->get_extension (plugin, CLAP_EXT_NOTE_PORTS)); + } + + void TearDown() override + { + if (plugin != nullptr) + plugin->destroy (plugin); + } + + clap_host_t host; + const clap_plugin_t* plugin = nullptr; + const clap_plugin_note_ports_t* notePortsExt = nullptr; +}; + +TEST_F (CLAPNotePortsTests, ExtensionIsAvailable) +{ + ASSERT_NE (nullptr, notePortsExt); +} + +TEST_F (CLAPNotePortsTests, ReportsZeroPortsForNonSynth) +{ + ASSERT_NE (nullptr, notePortsExt); + EXPECT_EQ (0u, notePortsExt->count (plugin, true)); + EXPECT_EQ (0u, notePortsExt->count (plugin, false)); +} + +//------------------------------------------------------------------------------ +// Latency and tail tests +//------------------------------------------------------------------------------ + +class CLAPLatencyTailTests : public ::testing::Test +{ +protected: + void SetUp() override + { + host = makeMinimalHost(); + const auto* factory = static_cast ( + clap_entry.get_factory (CLAP_PLUGIN_FACTORY_ID)); + plugin = factory->create_plugin (factory, &host, "test.clap.plugin"); + ASSERT_NE (nullptr, plugin); + ASSERT_TRUE (plugin->init (plugin)); + + latencyExt = static_cast ( + plugin->get_extension (plugin, CLAP_EXT_LATENCY)); + tailExt = static_cast ( + plugin->get_extension (plugin, CLAP_EXT_TAIL)); + } + + void TearDown() override + { + if (plugin != nullptr) + plugin->destroy (plugin); + } + + clap_host_t host; + const clap_plugin_t* plugin = nullptr; + const clap_plugin_latency_t* latencyExt = nullptr; + const clap_plugin_tail_t* tailExt = nullptr; +}; + +TEST_F (CLAPLatencyTailTests, ExtensionsAreAvailable) +{ + ASSERT_NE (nullptr, latencyExt); + ASSERT_NE (nullptr, tailExt); +} + +TEST_F (CLAPLatencyTailTests, LatencyIsZero) +{ + ASSERT_NE (nullptr, latencyExt); + EXPECT_EQ (0u, latencyExt->get (plugin)); +} + +TEST_F (CLAPLatencyTailTests, TailIsZero) +{ + ASSERT_NE (nullptr, tailExt); + EXPECT_EQ (0u, tailExt->get (plugin)); +} + +//------------------------------------------------------------------------------ +// Sidechain audio port tests (AudioBus::Role → CLAP_AUDIO_PORT_IS_MAIN) +//------------------------------------------------------------------------------ + +TEST_F (CLAPAudioPortsTests, MainAudioPortHasIsMainFlag) +{ + ASSERT_NE (nullptr, portsExt); + + const auto numInputs = portsExt->count (plugin, true); + ASSERT_GE (numInputs, 1u); + + clap_audio_port_info_t info {}; + ASSERT_TRUE (portsExt->get (plugin, 0, true, &info)); + + EXPECT_NE (0u, info.flags & CLAP_AUDIO_PORT_IS_MAIN); + EXPECT_EQ (2u, info.channel_count); // Main Input = 2 channels +} + +TEST_F (CLAPAudioPortsTests, AuxiliaryAudioPortLacksIsMainFlag) +{ + ASSERT_NE (nullptr, portsExt); + + const auto numInputs = portsExt->count (plugin, true); + ASSERT_GE (numInputs, 2u); + + clap_audio_port_info_t info {}; + ASSERT_TRUE (portsExt->get (plugin, 1, true, &info)); + + EXPECT_EQ (0u, info.flags & CLAP_AUDIO_PORT_IS_MAIN); + EXPECT_EQ (1u, info.channel_count); // Sidechain Input = 1 channel +} + +TEST_F (CLAPAudioPortsTests, OutputPortHasIsMainFlag) +{ + ASSERT_NE (nullptr, portsExt); + + const auto numOutputs = portsExt->count (plugin, false); + ASSERT_GE (numOutputs, 1u); + + clap_audio_port_info_t info {}; + ASSERT_TRUE (portsExt->get (plugin, 0, false, &info)); + + EXPECT_NE (0u, info.flags & CLAP_AUDIO_PORT_IS_MAIN); + EXPECT_EQ (2u, info.channel_count); // Main Output = 2 channels +} + +TEST_F (CLAPAudioPortsTests, SidechainPortNameIsExposed) +{ + ASSERT_NE (nullptr, portsExt); + + const auto numInputs = portsExt->count (plugin, true); + ASSERT_GE (numInputs, 2u); + + clap_audio_port_info_t info {}; + ASSERT_TRUE (portsExt->get (plugin, 1, true, &info)); + + // The port name should be non-empty + EXPECT_NE (0, info.name[0]); +} diff --git a/tests/yup_audio_plugin_client_vst3.cpp b/tests/yup_audio_plugin_client_vst3.cpp new file mode 100644 index 000000000..d10acf96b --- /dev/null +++ b/tests/yup_audio_plugin_client_vst3.cpp @@ -0,0 +1,790 @@ +/* + ============================================================================== + + 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 + +// ============================================================================= +#define YUP_AUDIO_PLUGIN_ENABLE_VST3 1 +#define YupPlugin_Id "test.vst3.plugin" +#define YupPlugin_Name "Test VST3 Plugin" +#define YupPlugin_Vendor "TestVendor" +#define YupPlugin_Version "1.0.0" +#define YupPlugin_URL "https://test.example" +#define YupPlugin_Email "test@example.com" +#define YupPlugin_IsSynth 0 + +// ============================================================================= +#include +#include "yup_audio_plugin_client/yup_TestPluginProcessor.h" + +#define YUP_AUDIO_PLUGIN_CREATE_FUNCTION createPluginProcessorVST3 +#include "yup_audio_plugin_client/vst3/yup_audio_plugin_client_VST3.cpp" + +extern "C" yup::AudioProcessor* createPluginProcessorVST3() +{ + return new TestPluginProcessor (testPluginBusLayoutWithInactiveSidechain()); +} + +// ============================================================================= +namespace +{ + +class MemoryStream : public Steinberg::IBStream +{ +public: + MemoryStream() = default; + + // --- FUnknown --- + tresult PLUGIN_API queryInterface (const Steinberg::TUID, void**) override { return Steinberg::kNoInterface; } + + uint32 PLUGIN_API addRef() override { return 1; } + + uint32 PLUGIN_API release() override { return 1; } + + // --- IBStream --- + tresult PLUGIN_API read (void* buffer, int32 numBytes, int32* numBytesRead) override + { + if (buffer == nullptr || numBytes < 0) + return Steinberg::kInvalidArgument; + + const auto bytesToRead = std::min (static_cast (numBytes), data.getSize() - readPos); + if (bytesToRead > 0) + std::memcpy (buffer, static_cast (data.getData()) + readPos, bytesToRead); + + readPos += bytesToRead; + + if (numBytesRead != nullptr) + *numBytesRead = static_cast (bytesToRead); + + return Steinberg::kResultOk; + } + + tresult PLUGIN_API write (void* buffer, int32 numBytes, int32* numBytesWritten) override + { + if (buffer == nullptr || numBytes < 0) + return Steinberg::kInvalidArgument; + + data.append (buffer, static_cast (numBytes)); + + if (numBytesWritten != nullptr) + *numBytesWritten = numBytes; + + return Steinberg::kResultOk; + } + + tresult PLUGIN_API seek (int64 pos, int32 mode, int64* result) override + { + int64 newPos = static_cast (readPos); + + switch (mode) + { + case kIBSeekSet: + newPos = pos; + break; + case kIBSeekCur: + newPos = static_cast (readPos) + pos; + break; + case kIBSeekEnd: + newPos = static_cast (data.getSize()) + pos; + break; + default: + return Steinberg::kInvalidArgument; + } + + if (newPos < 0 || static_cast (newPos) > data.getSize()) + return Steinberg::kInvalidArgument; + + readPos = static_cast (newPos); + + if (result != nullptr) + *result = newPos; + + return Steinberg::kResultOk; + } + + tresult PLUGIN_API tell (int64* pos) override + { + if (pos == nullptr) + return Steinberg::kInvalidArgument; + + *pos = static_cast (readPos); + return Steinberg::kResultOk; + } + + yup::MemoryBlock getData() const { return data; } + +private: + yup::MemoryBlock data; + size_t readPos = 0; +}; + +// Minimal IParamValueQueue delivering a single value for one parameter +class SingleParamValueQueue final : public Steinberg::Vst::IParamValueQueue +{ +public: + SingleParamValueQueue (Steinberg::Vst::ParamID id, Steinberg::Vst::ParamValue value) + : paramId (id) + , paramValue (value) + { + } + + tresult PLUGIN_API queryInterface (const Steinberg::TUID, void**) override { return Steinberg::kNoInterface; } + + uint32 PLUGIN_API addRef() override { return 1; } + + uint32 PLUGIN_API release() override { return 1; } + + Steinberg::Vst::ParamID PLUGIN_API getParameterId() override { return paramId; } + + int32 PLUGIN_API getPointCount() override { return 1; } + + tresult PLUGIN_API getPoint (int32 index, int32& sampleOffset, Steinberg::Vst::ParamValue& value) override + { + if (index != 0) + return Steinberg::kResultFalse; + + sampleOffset = 0; + value = paramValue; + return Steinberg::kResultOk; + } + + tresult PLUGIN_API addPoint (int32, Steinberg::Vst::ParamValue, int32&) override { return Steinberg::kResultFalse; } + +private: + Steinberg::Vst::ParamID paramId = 0; + Steinberg::Vst::ParamValue paramValue = 0.0; +}; + +// Minimal IParameterChanges holding a single parameter value queue +class SingleParameterChanges final : public Steinberg::Vst::IParameterChanges +{ +public: + explicit SingleParameterChanges (SingleParamValueQueue& queue) + : queueRef (queue) + { + } + + tresult PLUGIN_API queryInterface (const Steinberg::TUID, void**) override { return Steinberg::kNoInterface; } + + uint32 PLUGIN_API addRef() override { return 1; } + + uint32 PLUGIN_API release() override { return 1; } + + int32 PLUGIN_API getParameterCount() override { return 1; } + + Steinberg::Vst::IParamValueQueue* PLUGIN_API getParameterData (int32 index) override + { + return index == 0 ? &queueRef : nullptr; + } + + Steinberg::Vst::IParamValueQueue* PLUGIN_API addParameterData (const Steinberg::Vst::ParamID&, int32&) override + { + return nullptr; + } + +private: + SingleParamValueQueue& queueRef; +}; + +} // namespace + +// ============================================================================= +// Tests +// ============================================================================= + +using namespace yup; + +//------------------------------------------------------------------------------ +// Factory tests +//------------------------------------------------------------------------------ + +TEST (VST3WrapperTest, GetPluginFactoryReturnsNonNull) +{ + auto* factory = GetPluginFactory(); + ASSERT_NE (nullptr, factory); +} + +TEST (VST3WrapperTest, FactoryHasClasses) +{ + auto* factory = GetPluginFactory(); + ASSERT_NE (nullptr, factory); + + const auto count = factory->countClasses(); + EXPECT_GE (count, 2); + + for (int32 i = 0; i < count; ++i) + { + Steinberg::PClassInfo info {}; + EXPECT_EQ (Steinberg::kResultOk, factory->getClassInfo (i, &info)); + } +} + +TEST (VST3WrapperTest, CreateProcessorInstance) +{ + auto* factory = GetPluginFactory(); + ASSERT_NE (nullptr, factory); + + Steinberg::FUnknown* component = nullptr; + const auto count = factory->countClasses(); + + for (int32 i = 0; i < count; ++i) + { + Steinberg::PClassInfo info {}; + ASSERT_EQ (Steinberg::kResultOk, factory->getClassInfo (i, &info)); + + if (std::strcmp (info.category, kVstAudioEffectClass) == 0) + { + EXPECT_EQ (Steinberg::kResultOk, factory->createInstance (info.cid, Steinberg::Vst::IComponent::iid, (void**) &component)); + break; + } + } + + ASSERT_NE (nullptr, component); + + Steinberg::Vst::IAudioProcessor* audioProc = nullptr; + EXPECT_EQ (Steinberg::kResultOk, component->queryInterface (Steinberg::Vst::IAudioProcessor::iid, (void**) &audioProc)); + ASSERT_NE (nullptr, audioProc); + + audioProc->release(); + component->release(); +} + +TEST (VST3WrapperTest, CreateControllerInstance) +{ + auto* factory = GetPluginFactory(); + ASSERT_NE (nullptr, factory); + + Steinberg::FUnknown* controller = nullptr; + const auto count = factory->countClasses(); + + for (int32 i = 0; i < count; ++i) + { + Steinberg::PClassInfo info {}; + ASSERT_EQ (Steinberg::kResultOk, factory->getClassInfo (i, &info)); + + if (std::strcmp (info.category, kVstComponentControllerClass) == 0) + { + EXPECT_EQ (Steinberg::kResultOk, factory->createInstance (info.cid, Steinberg::Vst::IEditController::iid, (void**) &controller)); + break; + } + } + + ASSERT_NE (nullptr, controller); + controller->release(); +} + +//------------------------------------------------------------------------------ +// Processor tests +//------------------------------------------------------------------------------ + +class VST3ProcessorTests : public ::testing::Test +{ +protected: + void SetUp() override + { + factory = GetPluginFactory(); + ASSERT_NE (nullptr, factory); + + const auto count = factory->countClasses(); + for (int32 i = 0; i < count; ++i) + { + Steinberg::PClassInfo info {}; + ASSERT_EQ (Steinberg::kResultOk, factory->getClassInfo (i, &info)); + + if (std::strcmp (info.category, kVstAudioEffectClass) == 0) + { + std::memcpy (processorUID, info.cid, sizeof (Steinberg::TUID)); + ASSERT_EQ (Steinberg::kResultOk, + factory->createInstance (info.cid, + Steinberg::Vst::IComponent::iid, + (void**) &component)); + + ASSERT_EQ (Steinberg::kResultOk, + component->queryInterface (Steinberg::Vst::IAudioProcessor::iid, + (void**) &audioProcessor)); + } + else if (std::strcmp (info.category, kVstComponentControllerClass) == 0) + { + std::memcpy (controllerUID, info.cid, sizeof (Steinberg::TUID)); + } + } + + ASSERT_NE (nullptr, component); + ASSERT_NE (nullptr, audioProcessor); + } + + void TearDown() override + { + if (audioProcessor != nullptr) + audioProcessor->release(); + + if (component != nullptr) + component->release(); + } + + Steinberg::IPluginFactory* factory = nullptr; + Steinberg::TUID processorUID {}; + Steinberg::TUID controllerUID {}; + Steinberg::Vst::IComponent* component = nullptr; + Steinberg::Vst::IAudioProcessor* audioProcessor = nullptr; +}; + +TEST_F (VST3ProcessorTests, InitializeSucceeds) +{ + EXPECT_EQ (Steinberg::kResultOk, component->initialize (nullptr)); +} + +TEST_F (VST3ProcessorTests, CanProcessSinglePrecision) +{ + EXPECT_EQ (Steinberg::kResultTrue, audioProcessor->canProcessSampleSize (Steinberg::Vst::kSample32)); +} + +TEST_F (VST3ProcessorTests, CannotProcessDoublePrecisionByDefault) +{ + EXPECT_NE (Steinberg::kResultTrue, audioProcessor->canProcessSampleSize (Steinberg::Vst::kSample64)); +} + +TEST_F (VST3ProcessorTests, BusActivationWorks) +{ + // Buses are created in initialize() per VST3 convention + ASSERT_EQ (Steinberg::kResultOk, component->initialize (nullptr)); + + const auto numInputs = component->getBusCount (Steinberg::Vst::kAudio, Steinberg::Vst::kInput); + EXPECT_GE (numInputs, 1); + + const auto numOutputs = component->getBusCount (Steinberg::Vst::kAudio, Steinberg::Vst::kOutput); + EXPECT_GE (numOutputs, 1); + + for (int32 i = 0; i < numInputs; ++i) + { + Steinberg::Vst::BusInfo info {}; + EXPECT_EQ (Steinberg::kResultOk, + component->getBusInfo (Steinberg::Vst::kAudio, Steinberg::Vst::kInput, i, info)); + } + + for (int32 i = 0; i < numOutputs; ++i) + { + Steinberg::Vst::BusInfo info {}; + EXPECT_EQ (Steinberg::kResultOk, + component->getBusInfo (Steinberg::Vst::kAudio, Steinberg::Vst::kOutput, i, info)); + } + + // Verify activation cycle: setupProcessing → setActive(true) → setActive(false) + Steinberg::Vst::ProcessSetup setup {}; + setup.sampleRate = 44100.0; + setup.maxSamplesPerBlock = 512; + setup.processMode = Steinberg::Vst::kRealtime; + setup.symbolicSampleSize = Steinberg::Vst::kSample32; + ASSERT_EQ (Steinberg::kResultOk, audioProcessor->setupProcessing (setup)); + + EXPECT_EQ (Steinberg::kResultOk, component->setActive (true)); + EXPECT_EQ (Steinberg::kResultOk, component->setActive (false)); +} + +TEST_F (VST3ProcessorTests, GetControllerClassID) +{ + Steinberg::TUID cid {}; + const auto result = component->getControllerClassId (cid); + if (result == Steinberg::kResultOk) + { + EXPECT_EQ (0, std::memcmp (cid, controllerUID, sizeof (Steinberg::TUID))); + } +} + +//------------------------------------------------------------------------------ +// State save/load tests +//------------------------------------------------------------------------------ + +TEST_F (VST3ProcessorTests, StateSaveAndLoadRoundTrip) +{ + ASSERT_EQ (Steinberg::kResultOk, component->initialize (nullptr)); + + MemoryStream saveStream; + EXPECT_EQ (Steinberg::kResultOk, component->getState (&saveStream)); + + auto savedData = saveStream.getData(); + EXPECT_GT (savedData.getSize(), 0u); + + MemoryStream loadStream; + int32 written = 0; + loadStream.write (const_cast (savedData.getData()), + static_cast (savedData.getSize()), + &written); + ASSERT_EQ (static_cast (savedData.getSize()), written); + + loadStream.seek (0, MemoryStream::kIBSeekSet, nullptr); + + EXPECT_EQ (Steinberg::kResultOk, component->setState (&loadStream)); +} + +//------------------------------------------------------------------------------ +// EditController tests +//------------------------------------------------------------------------------ + +class VST3ControllerTests : public ::testing::Test +{ +protected: + void SetUp() override + { + factory = GetPluginFactory(); + ASSERT_NE (nullptr, factory); + + const auto count = factory->countClasses(); + for (int32 i = 0; i < count; ++i) + { + Steinberg::PClassInfo info {}; + ASSERT_EQ (Steinberg::kResultOk, factory->getClassInfo (i, &info)); + + if (std::strcmp (info.category, kVstAudioEffectClass) == 0) + { + ASSERT_EQ (Steinberg::kResultOk, + factory->createInstance (info.cid, + Steinberg::Vst::IComponent::iid, + (void**) &component)); + } + else if (std::strcmp (info.category, kVstComponentControllerClass) == 0) + { + ASSERT_EQ (Steinberg::kResultOk, + factory->createInstance (info.cid, + Steinberg::Vst::IEditController::iid, + (void**) &controller)); + } + } + + ASSERT_NE (nullptr, component); + ASSERT_NE (nullptr, controller); + } + + void TearDown() override + { + if (component != nullptr) + component->release(); + + if (controller != nullptr) + controller->release(); + } + + Steinberg::IPluginFactory* factory = nullptr; + Steinberg::Vst::IComponent* component = nullptr; + Steinberg::Vst::IEditController* controller = nullptr; +}; + +TEST_F (VST3ControllerTests, InitializeSucceeds) +{ + EXPECT_EQ (Steinberg::kResultOk, controller->initialize (nullptr)); +} + +TEST_F (VST3ControllerTests, ParameterCountIsExpected) +{ + ASSERT_EQ (Steinberg::kResultOk, component->initialize (nullptr)); + ASSERT_EQ (Steinberg::kResultOk, controller->initialize (nullptr)); + + // Before the processor connects via IMessage, the controller has 0 parameters + EXPECT_EQ (0, controller->getParameterCount()); +} + +TEST_F (VST3ControllerTests, TerminateCleansUp) +{ + ASSERT_EQ (Steinberg::kResultOk, component->initialize (nullptr)); + ASSERT_EQ (Steinberg::kResultOk, controller->initialize (nullptr)); + + EXPECT_EQ (Steinberg::kResultOk, controller->terminate()); + EXPECT_EQ (Steinberg::kResultOk, component->terminate()); +} + +//------------------------------------------------------------------------------ +// Sidechain bus tests (AudioBus::Role → VST3 BusInfo) +//------------------------------------------------------------------------------ + +class VST3SidechainBusTests : public VST3ProcessorTests +{ +}; + +TEST_F (VST3SidechainBusTests, MainBusReportsMainType) +{ + ASSERT_EQ (Steinberg::kResultOk, component->initialize (nullptr)); + + const auto numInputs = component->getBusCount (Steinberg::Vst::kAudio, Steinberg::Vst::kInput); + ASSERT_GE (numInputs, 1); + + // The first audio bus should be the main input (kMain) + Steinberg::Vst::BusInfo info {}; + ASSERT_EQ (Steinberg::kResultOk, + component->getBusInfo (Steinberg::Vst::kAudio, Steinberg::Vst::kInput, 0, info)); + EXPECT_EQ (Steinberg::Vst::kMain, info.busType); +} + +TEST_F (VST3SidechainBusTests, SidechainBusReportsAuxiliaryType) +{ + ASSERT_EQ (Steinberg::kResultOk, component->initialize (nullptr)); + + const auto numInputs = component->getBusCount (Steinberg::Vst::kAudio, Steinberg::Vst::kInput); + ASSERT_GE (numInputs, 2); + + // The second audio bus should be the auxiliary sidechain input (kAux) + Steinberg::Vst::BusInfo info {}; + ASSERT_EQ (Steinberg::kResultOk, + component->getBusInfo (Steinberg::Vst::kAudio, Steinberg::Vst::kInput, 1, info)); + EXPECT_EQ (Steinberg::Vst::kAux, info.busType); +} + +TEST_F (VST3SidechainBusTests, MainBusHasDefaultActiveFlag) +{ + ASSERT_EQ (Steinberg::kResultOk, component->initialize (nullptr)); + + const auto numInputs = component->getBusCount (Steinberg::Vst::kAudio, Steinberg::Vst::kInput); + ASSERT_GE (numInputs, 1); + + Steinberg::Vst::BusInfo info {}; + ASSERT_EQ (Steinberg::kResultOk, + component->getBusInfo (Steinberg::Vst::kAudio, Steinberg::Vst::kInput, 0, info)); + EXPECT_NE (0u, info.flags & Steinberg::Vst::BusInfo::kDefaultActive); +} + +TEST_F (VST3SidechainBusTests, InactiveSidechainBusLacksDefaultActiveFlag) +{ + ASSERT_EQ (Steinberg::kResultOk, component->initialize (nullptr)); + + const auto numInputs = component->getBusCount (Steinberg::Vst::kAudio, Steinberg::Vst::kInput); + ASSERT_GE (numInputs, 2); + + Steinberg::Vst::BusInfo info {}; + ASSERT_EQ (Steinberg::kResultOk, + component->getBusInfo (Steinberg::Vst::kAudio, Steinberg::Vst::kInput, 1, info)); + EXPECT_EQ (0u, info.flags & Steinberg::Vst::BusInfo::kDefaultActive); +} + +TEST_F (VST3SidechainBusTests, MainOutputBusReportsMainType) +{ + ASSERT_EQ (Steinberg::kResultOk, component->initialize (nullptr)); + + const auto numOutputs = component->getBusCount (Steinberg::Vst::kAudio, Steinberg::Vst::kOutput); + ASSERT_GE (numOutputs, 1); + + Steinberg::Vst::BusInfo info {}; + ASSERT_EQ (Steinberg::kResultOk, + component->getBusInfo (Steinberg::Vst::kAudio, Steinberg::Vst::kOutput, 0, info)); + EXPECT_EQ (Steinberg::Vst::kMain, info.busType); +} + +TEST_F (VST3SidechainBusTests, SidechainInputBusNameIsExposed) +{ + ASSERT_EQ (Steinberg::kResultOk, component->initialize (nullptr)); + + const auto numInputs = component->getBusCount (Steinberg::Vst::kAudio, Steinberg::Vst::kInput); + ASSERT_GE (numInputs, 2); + + Steinberg::Vst::BusInfo info {}; + ASSERT_EQ (Steinberg::kResultOk, + component->getBusInfo (Steinberg::Vst::kAudio, Steinberg::Vst::kInput, 1, info)); + + // The bus name should be non-empty + EXPECT_NE (0, info.name[0]); +} + +//------------------------------------------------------------------------------ +// Bypass tests +//------------------------------------------------------------------------------ + +class VST3BypassTests : public ::testing::Test +{ +protected: + void SetUp() override + { + factory = GetPluginFactory(); + ASSERT_NE (nullptr, factory); + + const auto count = factory->countClasses(); + for (int32 i = 0; i < count; ++i) + { + Steinberg::PClassInfo info {}; + ASSERT_EQ (Steinberg::kResultOk, factory->getClassInfo (i, &info)); + + if (std::strcmp (info.category, kVstAudioEffectClass) == 0) + { + ASSERT_EQ (Steinberg::kResultOk, + factory->createInstance (info.cid, + Steinberg::Vst::IComponent::iid, + (void**) &component)); + break; + } + } + + ASSERT_NE (nullptr, component); + processor = static_cast (static_cast (component)->getProcessor()); + ASSERT_NE (nullptr, processor); + + ASSERT_EQ (Steinberg::kResultOk, + component->queryInterface (Steinberg::Vst::IAudioProcessor::iid, + (void**) &audioProcessor)); + ASSERT_NE (nullptr, audioProcessor); + + ASSERT_EQ (Steinberg::kResultOk, component->initialize (nullptr)); + + Steinberg::Vst::ProcessSetup setup {}; + setup.sampleRate = 44100.0; + setup.maxSamplesPerBlock = 512; + setup.processMode = Steinberg::Vst::kRealtime; + setup.symbolicSampleSize = Steinberg::Vst::kSample32; + ASSERT_EQ (Steinberg::kResultOk, audioProcessor->setupProcessing (setup)); + + ASSERT_EQ (Steinberg::kResultOk, component->setActive (true)); + } + + void TearDown() override + { + if (component != nullptr) + component->setActive (false); + + if (audioProcessor != nullptr) + audioProcessor->release(); + + if (component != nullptr) + component->release(); + } + + Steinberg::Vst::ParamID getBypassParameterID() const + { + return static_cast (getVST3BypassParameterID (*processor)); + } + + // Runs one process block with a single parameter change on the bypass tag + void processWithBypassValue (Steinberg::Vst::ParamValue value) + { + SingleParamValueQueue queue (getBypassParameterID(), value); + SingleParameterChanges changes (queue); + + float inputMainData[2][64] = {}; + float* inputMainChannels[2] = { inputMainData[0], inputMainData[1] }; + + Steinberg::Vst::AudioBusBuffers inputMain {}; + inputMain.numChannels = 2; + inputMain.channelBuffers32 = inputMainChannels; + + float inputSidechainData[1][64] = {}; + float* inputSidechainChannels[1] = { inputSidechainData[0] }; + + Steinberg::Vst::AudioBusBuffers inputSidechain {}; + inputSidechain.numChannels = 1; + inputSidechain.channelBuffers32 = inputSidechainChannels; + + float outputData[2][64] = {}; + float* outputChannels[2] = { outputData[0], outputData[1] }; + + Steinberg::Vst::AudioBusBuffers outputBus {}; + outputBus.numChannels = 2; + outputBus.channelBuffers32 = outputChannels; + + Steinberg::Vst::AudioBusBuffers inputs[] = { inputMain, inputSidechain }; + Steinberg::Vst::AudioBusBuffers outputs[] = { outputBus }; + + Steinberg::Vst::ProcessData data {}; + data.processMode = Steinberg::Vst::kRealtime; + data.symbolicSampleSize = Steinberg::Vst::kSample32; + data.numSamples = 64; + data.numInputs = 2; + data.numOutputs = 1; + data.inputs = inputs; + data.outputs = outputs; + data.inputParameterChanges = &changes; + + EXPECT_EQ (Steinberg::kResultOk, audioProcessor->process (data)); + } + + // Runs one process block without any parameter changes + void processBlock() + { + float outputData[2][64] = {}; + float* outputChannels[2] = { outputData[0], outputData[1] }; + + Steinberg::Vst::AudioBusBuffers outputBus {}; + outputBus.numChannels = 2; + outputBus.channelBuffers32 = outputChannels; + + Steinberg::Vst::AudioBusBuffers outputs[] = { outputBus }; + + Steinberg::Vst::ProcessData data {}; + data.processMode = Steinberg::Vst::kRealtime; + data.symbolicSampleSize = Steinberg::Vst::kSample32; + data.numSamples = 64; + data.numOutputs = 1; + data.outputs = outputs; + + EXPECT_EQ (Steinberg::kResultOk, audioProcessor->process (data)); + } + + Steinberg::IPluginFactory* factory = nullptr; + Steinberg::Vst::IComponent* component = nullptr; + Steinberg::Vst::IAudioProcessor* audioProcessor = nullptr; + TestPluginProcessor* processor = nullptr; +}; + +TEST_F (VST3BypassTests, BypassChangeRoutesToBypassedPath) +{ + processWithBypassValue (1.0); + + EXPECT_EQ (1, processor->bypassCallCount); + EXPECT_EQ (0, processor->processCallCount); +} + +TEST_F (VST3BypassTests, BypassChangeRoutesToProcessPath) +{ + processWithBypassValue (0.0); + + EXPECT_EQ (0, processor->bypassCallCount); + EXPECT_EQ (1, processor->processCallCount); +} + +TEST_F (VST3BypassTests, StateRoundTripsBypassState) +{ + processWithBypassValue (1.0); + EXPECT_EQ (1, processor->bypassCallCount); + + MemoryStream saveStream; + EXPECT_EQ (Steinberg::kResultOk, component->getState (&saveStream)); + + const auto savedData = saveStream.getData(); + EXPECT_GT (savedData.getSize(), 0u); + + // Un-bypass so restoring the state actually proves persistence + processWithBypassValue (0.0); + EXPECT_EQ (1, processor->bypassCallCount); + EXPECT_EQ (1, processor->processCallCount); + + MemoryStream loadStream; + int32 written = 0; + loadStream.write (const_cast (savedData.getData()), + static_cast (savedData.getSize()), + &written); + ASSERT_EQ (static_cast (savedData.getSize()), written); + + loadStream.seek (0, MemoryStream::kIBSeekSet, nullptr); + + EXPECT_EQ (Steinberg::kResultOk, component->setState (&loadStream)); + + // A block without parameter changes must now route to the bypass path + processor->bypassCallCount = 0; + processor->processCallCount = 0; + + processBlock(); + EXPECT_EQ (1, processor->bypassCallCount); + EXPECT_EQ (0, processor->processCallCount); +} diff --git a/tests/yup_audio_processors.cpp b/tests/yup_audio_processors.cpp index d7df35670..cc4fe8ce6 100644 --- a/tests/yup_audio_processors.cpp +++ b/tests/yup_audio_processors.cpp @@ -21,6 +21,9 @@ #include "mocks/yup_audio_processors.h" +#include "yup_audio_processors/yup_AudioBus.cpp" +#include "yup_audio_processors/yup_AudioBusBufferView.cpp" +#include "yup_audio_processors/yup_AudioBusLayout.cpp" #include "yup_audio_processors/yup_AudioParameter.cpp" #include "yup_audio_processors/yup_AudioProcessContext.cpp" #include "yup_audio_processors/yup_ParameterChangeBuffer.cpp" diff --git a/tests/yup_audio_processors/yup_AudioBus.cpp b/tests/yup_audio_processors/yup_AudioBus.cpp new file mode 100644 index 000000000..c96d6dad9 --- /dev/null +++ b/tests/yup_audio_processors/yup_AudioBus.cpp @@ -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. + + ============================================================================== +*/ + +#include + +#include + +using namespace yup; + +//============================================================================== + +TEST (AudioBusTests, DefaultRoleIsMain) +{ + AudioBus bus ("Main Input", AudioBus::Type::Audio, AudioBus::Direction::Input, 2); + EXPECT_EQ (AudioBus::Role::Main, bus.getRole()); +} + +TEST (AudioBusTests, DefaultIsActive) +{ + AudioBus bus ("Main Input", AudioBus::Type::Audio, AudioBus::Direction::Input, 2); + EXPECT_TRUE (bus.isDefaultActive()); +} + +TEST (AudioBusTests, ExplicitRoleAuxiliary) +{ + AudioBus bus ("Sidechain", AudioBus::Type::Audio, AudioBus::Direction::Input, 2, AudioBus::Role::Auxiliary); + EXPECT_EQ (AudioBus::Role::Auxiliary, bus.getRole()); +} + +TEST (AudioBusTests, ExplicitNotDefaultActive) +{ + AudioBus bus ("Sidechain", AudioBus::Type::Audio, AudioBus::Direction::Input, 2, AudioBus::Role::Auxiliary, false); + EXPECT_FALSE (bus.isDefaultActive()); +} + +TEST (AudioBusTests, RoleDoesNotAffectOtherProperties) +{ + AudioBus bus ("Sidechain", AudioBus::Type::Audio, AudioBus::Direction::Input, 2, AudioBus::Role::Auxiliary, false); + EXPECT_EQ ("Sidechain", bus.getName()); + EXPECT_EQ (AudioBus::Type::Audio, bus.getType()); + EXPECT_EQ (AudioBus::Direction::Input, bus.getDirection()); + EXPECT_EQ (2, bus.getNumChannels()); + EXPECT_TRUE (bus.isStereo()); +} + +TEST (AudioBusTests, BackwardCompatibleConstructors) +{ + // Old-style constructors (no Role, no isDefaultActive) should compile and default correctly + AudioBus bus ("Input", AudioBus::Type::Audio, AudioBus::Direction::Input, 1); + EXPECT_EQ (AudioBus::Role::Main, bus.getRole()); + EXPECT_TRUE (bus.isDefaultActive()); + EXPECT_TRUE (bus.isMono()); +} + +TEST (AudioBusTests, MainOutputBus) +{ + AudioBus bus ("Main Output", AudioBus::Type::Audio, AudioBus::Direction::Output, 2); + EXPECT_EQ (AudioBus::Role::Main, bus.getRole()); + EXPECT_EQ (AudioBus::Direction::Output, bus.getDirection()); +} + +TEST (AudioBusTests, MidiBusRoleIsMainByDefault) +{ + AudioBus bus ("MIDI In", AudioBus::Type::Midi, AudioBus::Direction::Input, 0); + EXPECT_EQ (AudioBus::Role::Main, bus.getRole()); + EXPECT_TRUE (bus.isDefaultActive()); +} diff --git a/tests/yup_audio_processors/yup_AudioBusBufferView.cpp b/tests/yup_audio_processors/yup_AudioBusBufferView.cpp new file mode 100644 index 000000000..37fc03d90 --- /dev/null +++ b/tests/yup_audio_processors/yup_AudioBusBufferView.cpp @@ -0,0 +1,141 @@ +/* + ============================================================================== + + 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 + +#include + +using namespace yup; + +//============================================================================== + +TEST (AudioBusBufferViewTests, DefaultConstructedIsEmpty) +{ + AudioBusBufferView view; + EXPECT_EQ (0, view.getNumChannels()); + EXPECT_EQ (nullptr, view.getChannels()); + EXPECT_EQ (AudioBus::Role::Main, view.getRole()); +} + +TEST (AudioBusBufferViewTests, ConstructedWithChannels) +{ + float ch0[16], ch1[16]; + const float* ptrs[] = { ch0, ch1 }; + + AudioBusBufferView view (ptrs, 2, AudioBus::Role::Auxiliary); + EXPECT_EQ (2, view.getNumChannels()); + EXPECT_EQ (AudioBus::Role::Auxiliary, view.getRole()); + EXPECT_EQ (ch0, view.getReadPointer (0)); + EXPECT_EQ (ch1, view.getReadPointer (1)); +} + +TEST (AudioBusBufferViewTests, GetReadPointerOutOfRange) +{ + float ch0[16]; + const float* ptrs[] = { ch0 }; + + AudioBusBufferView view (ptrs, 1); + EXPECT_EQ (ch0, view.getReadPointer (0)); + EXPECT_EQ (nullptr, view.getReadPointer (1)); + EXPECT_EQ (nullptr, view.getReadPointer (-1)); +} + +TEST (AudioBusBufferViewTests, GetWritePointerOutOfRange) +{ + float ch0[16]; + float* ptrs[] = { ch0 }; + + AudioBusBufferView view (ptrs, 1); + EXPECT_EQ (ch0, view.getWritePointer (0)); + EXPECT_EQ (nullptr, view.getWritePointer (1)); + EXPECT_EQ (nullptr, view.getWritePointer (-1)); +} + +TEST (AudioBusBufferViewTests, ConstViewConstructedWithMutablePointers) +{ + float ch0[16], ch1[16]; + float* ptrs[] = { ch0, ch1 }; + + AudioBusBufferView view (ptrs, 2); + EXPECT_EQ (2, view.getNumChannels()); + EXPECT_EQ (ch0, view.getReadPointer (0)); +} + +TEST (AudioBusBufferViewTests, MutableViewCanWrite) +{ + float ch0[16] = {}; + float* ptrs[] = { ch0 }; + + AudioBusBufferView view (ptrs, 1); + auto* writePtr = view.getWritePointer (0); + ASSERT_NE (nullptr, writePtr); + writePtr[0] = 3.14f; + EXPECT_FLOAT_EQ (3.14f, ch0[0]); +} + +TEST (AudioBusBufferViewTests, NullChannelPointers) +{ + AudioBusBufferView view (nullptr, 2, AudioBus::Role::Auxiliary); + EXPECT_EQ (2, view.getNumChannels()); + EXPECT_EQ (nullptr, view.getReadPointer (0)); + EXPECT_EQ (nullptr, view.getReadPointer (1)); + EXPECT_EQ (AudioBus::Role::Auxiliary, view.getRole()); +} + +TEST (AudioBusBufferViewTests, NullChannelPointersMutableView) +{ + AudioBusBufferView view (nullptr, 2, AudioBus::Role::Auxiliary); + EXPECT_EQ (2, view.getNumChannels()); + EXPECT_EQ (nullptr, view.getWritePointer (0)); + EXPECT_EQ (nullptr, view.getWritePointer (1)); + EXPECT_EQ (nullptr, view.getReadPointer (0)); + EXPECT_EQ (AudioBus::Role::Auxiliary, view.getRole()); +} + +TEST (AudioBusBufferViewTests, StereoBus) +{ + float left[8], right[8]; + const float* ptrs[] = { left, right }; + + AudioBusBufferView view (ptrs, 2); + EXPECT_EQ (2, view.getNumChannels()); + EXPECT_EQ (left, view.getReadPointer (0)); + EXPECT_EQ (right, view.getReadPointer (1)); +} + +TEST (AudioBusBufferViewTests, MonoBus) +{ + float mono[8]; + const float* ptrs[] = { mono }; + + AudioBusBufferView view (ptrs, 1); + EXPECT_EQ (1, view.getNumChannels()); + EXPECT_EQ (mono, view.getReadPointer (0)); +} + +TEST (AudioBusBufferViewTests, DefaultRoleIsMain) +{ + float ch0[8]; + const float* ptrs[] = { ch0 }; + + AudioBusBufferView view (ptrs, 1); + EXPECT_EQ (AudioBus::Role::Main, view.getRole()); +} diff --git a/tests/yup_audio_processors/yup_AudioBusLayout.cpp b/tests/yup_audio_processors/yup_AudioBusLayout.cpp new file mode 100644 index 000000000..3633da3f9 --- /dev/null +++ b/tests/yup_audio_processors/yup_AudioBusLayout.cpp @@ -0,0 +1,91 @@ +/* + ============================================================================== + + 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 + +#include + +using namespace yup; + +//============================================================================== + +TEST (AudioBusLayoutTests, GetAudioBusRoleReturnsMainForFirstAudioBus) +{ + AudioBusLayout layout ( + { AudioBus ("Main", AudioBus::Type::Audio, AudioBus::Direction::Input, 2), + AudioBus ("Sidechain", AudioBus::Type::Audio, AudioBus::Direction::Input, 2, AudioBus::Role::Auxiliary) }, + { AudioBus ("Main Out", AudioBus::Type::Audio, AudioBus::Direction::Output, 2) }); + + EXPECT_EQ (AudioBus::Role::Main, layout.getAudioBusRole (0, true)); +} + +TEST (AudioBusLayoutTests, GetAudioBusRoleReturnsAuxiliaryForSecondAudioBus) +{ + AudioBusLayout layout ( + { AudioBus ("Main", AudioBus::Type::Audio, AudioBus::Direction::Input, 2), + AudioBus ("Sidechain", AudioBus::Type::Audio, AudioBus::Direction::Input, 2, AudioBus::Role::Auxiliary) }, + {}); + + EXPECT_EQ (AudioBus::Role::Auxiliary, layout.getAudioBusRole (1, true)); +} + +TEST (AudioBusLayoutTests, GetAudioBusRoleSkipsMidiBuses) +{ + AudioBusLayout layout ( + { AudioBus ("Main", AudioBus::Type::Audio, AudioBus::Direction::Input, 2), + AudioBus ("MIDI In", AudioBus::Type::Midi, AudioBus::Direction::Input, 0), + AudioBus ("Sidechain", AudioBus::Type::Audio, AudioBus::Direction::Input, 2, AudioBus::Role::Auxiliary) }, + {}); + + // MIDI buses do not consume an audio-bus index + EXPECT_EQ (AudioBus::Role::Main, layout.getAudioBusRole (0, true)); + EXPECT_EQ (AudioBus::Role::Auxiliary, layout.getAudioBusRole (1, true)); +} + +TEST (AudioBusLayoutTests, GetAudioBusRoleReturnsMainForOutOfRange) +{ + AudioBusLayout layout ( + { AudioBus ("Main", AudioBus::Type::Audio, AudioBus::Direction::Input, 2) }, + {}); + + EXPECT_EQ (AudioBus::Role::Main, layout.getAudioBusRole (5, true)); + EXPECT_EQ (AudioBus::Role::Main, layout.getAudioBusRole (-1, true)); +} + +TEST (AudioBusLayoutTests, GetAudioBusRoleReturnsMainForEmptyLayout) +{ + AudioBusLayout layout; + + EXPECT_EQ (AudioBus::Role::Main, layout.getAudioBusRole (0, true)); + EXPECT_EQ (AudioBus::Role::Main, layout.getAudioBusRole (0, false)); +} + +TEST (AudioBusLayoutTests, GetAudioBusRoleForOutputBuses) +{ + AudioBusLayout layout ( + {}, + { AudioBus ("Main Out", AudioBus::Type::Audio, AudioBus::Direction::Output, 2), + AudioBus ("Aux Out", AudioBus::Type::Audio, AudioBus::Direction::Output, 2, AudioBus::Role::Auxiliary) }); + + EXPECT_EQ (AudioBus::Role::Main, layout.getAudioBusRole (0, false)); + EXPECT_EQ (AudioBus::Role::Auxiliary, layout.getAudioBusRole (1, false)); + EXPECT_EQ (AudioBus::Role::Main, layout.getAudioBusRole (0, true)); +} diff --git a/tests/yup_audio_processors/yup_AudioParameter.cpp b/tests/yup_audio_processors/yup_AudioParameter.cpp index e710e8e5e..6ad7e38e9 100644 --- a/tests/yup_audio_processors/yup_AudioParameter.cpp +++ b/tests/yup_audio_processors/yup_AudioParameter.cpp @@ -505,6 +505,105 @@ TEST (AudioParameterTests, RemovedListenerDoesNotReceiveFurtherChanges) EXPECT_EQ (0, listener.valueChangedCount); } +//============================================================================== +// ParameterUnit tests +//============================================================================== + +TEST (AudioParameterTests, GenericUnitIsDefault) +{ + auto parameter = makeParameter ("gain", "Gain"); + EXPECT_EQ (AudioParameter::ParameterUnit::Generic, parameter->getUnit()); + EXPECT_TRUE (parameter->getUnitName().isEmpty()); +} + +TEST (AudioParameterTests, WithUnitSetsCorrectUnit) +{ + auto parameter = AudioParameterBuilder() + .withID ("freq") + .withName ("Frequency") + .withRange (20.0f, 20000.0f) + .withDefault (1000.0f) + .withUnit (AudioParameter::ParameterUnit::Hertz) + .build(); + + EXPECT_EQ (AudioParameter::ParameterUnit::Hertz, parameter->getUnit()); + EXPECT_TRUE (parameter->getUnitName().isEmpty()); +} + +TEST (AudioParameterTests, WithUnitCustomSetsUnitName) +{ + auto parameter = AudioParameterBuilder() + .withID ("level") + .withName ("Level") + .withRange (-60.0f, 0.0f) + .withDefault (-18.0f) + .withUnit (AudioParameter::ParameterUnit::Custom, "dBFS") + .build(); + + EXPECT_EQ (AudioParameter::ParameterUnit::Custom, parameter->getUnit()); + EXPECT_EQ (String ("dBFS"), parameter->getUnitName()); +} + +TEST (AudioParameterTests, AllUnitValuesCanBeSet) +{ + // Verify every ParameterUnit value compiles and stores correctly + struct TestCase + { + AudioParameter::ParameterUnit unit; + String expectedLabel; + }; + + const TestCase cases[] = { + { AudioParameter::ParameterUnit::Generic, "" }, + { AudioParameter::ParameterUnit::Percent, "%" }, + { AudioParameter::ParameterUnit::Decibels, "dB" }, + { AudioParameter::ParameterUnit::Hertz, "Hz" }, + { AudioParameter::ParameterUnit::Milliseconds, "ms" }, + { AudioParameter::ParameterUnit::Seconds, "s" }, + { AudioParameter::ParameterUnit::Degrees, "deg" }, + { AudioParameter::ParameterUnit::Cents, "ct" }, + { AudioParameter::ParameterUnit::Semitones, "st" }, + { AudioParameter::ParameterUnit::Octaves, "oct" }, + { AudioParameter::ParameterUnit::BPM, "bpm" }, + { AudioParameter::ParameterUnit::Beats, "beats" }, + { AudioParameter::ParameterUnit::Ratio, "" }, + { AudioParameter::ParameterUnit::LinearGain, "" }, + { AudioParameter::ParameterUnit::Pan, "" }, + { AudioParameter::ParameterUnit::MIDINoteNumber, "" }, + }; + + for (const auto& tc : cases) + { + auto parameter = AudioParameterBuilder() + .withID ("p") + .withName ("Param") + .withRange (0.0f, 1.0f) + .withDefault (0.5f) + .withUnit (tc.unit) + .build(); + + EXPECT_EQ (tc.unit, parameter->getUnit()); + EXPECT_STREQ (tc.expectedLabel.toRawUTF8(), + AudioParameter::getParameterUnitShortName (parameter->getUnit(), parameter->getUnitName())); + } +} + +TEST (AudioParameterTests, UnitIsRoundTrippedThroughBuilder) +{ + auto parameter = AudioParameterBuilder() + .withID ("pan") + .withName ("Pan") + .withRange (-1.0f, 1.0f) + .withDefault (0.0f) + .withUnit (AudioParameter::ParameterUnit::Pan) + .build(); + + EXPECT_EQ (AudioParameter::ParameterUnit::Pan, parameter->getUnit()); + EXPECT_EQ (-1.0f, parameter->getMinimumValue()); + EXPECT_EQ (1.0f, parameter->getMaximumValue()); + EXPECT_EQ (0.0f, parameter->getDefaultValue()); +} + TEST (AudioProcessorTests, DuplicateParameterIDsAreIgnored) { TestAudioProcessor processor; diff --git a/tests/yup_audio_processors/yup_AudioProcessContext.cpp b/tests/yup_audio_processors/yup_AudioProcessContext.cpp index e6c81c3f2..aec9a5b45 100644 --- a/tests/yup_audio_processors/yup_AudioProcessContext.cpp +++ b/tests/yup_audio_processors/yup_AudioProcessContext.cpp @@ -114,3 +114,204 @@ TEST (AudioProcessContextTests, DoubleBufferVersion) EXPECT_EQ (32, context.audio.getNumSamples()); EXPECT_EQ (nullptr, context.playHead); } + +//============================================================================== +// Per-bus view tests + +TEST (AudioProcessContextTests, PerBusViewsDefaultToEmpty) +{ + AudioBuffer audio (2, 16); + MidiBuffer midi; + ParameterChangeBuffer params; + + AudioProcessContext context { audio, midi, params }; + + EXPECT_EQ (0u, context.inputs.size()); + EXPECT_EQ (0u, context.outputs.size()); +} + +TEST (AudioProcessContextTests, GetMainInputReturnsEmptyWhenNoInputs) +{ + AudioBuffer audio (2, 16); + MidiBuffer midi; + ParameterChangeBuffer params; + + AudioProcessContext context { audio, midi, params }; + + const auto& mainInput = context.getMainInput(); + EXPECT_EQ (0, mainInput.getNumChannels()); +} + +TEST (AudioProcessContextTests, GetMainOutputReturnsEmptyWhenNoOutputs) +{ + AudioBuffer audio (2, 16); + MidiBuffer midi; + ParameterChangeBuffer params; + + AudioProcessContext context { audio, midi, params }; + + auto& mainOutput = context.getMainOutput(); + EXPECT_EQ (0, mainOutput.getNumChannels()); +} + +TEST (AudioProcessContextTests, GetAuxiliaryInputReturnsEmptyWhenNoInputs) +{ + AudioBuffer audio (2, 16); + MidiBuffer midi; + ParameterChangeBuffer params; + + AudioProcessContext context { audio, midi, params }; + + const auto& auxInput = context.getAuxiliaryInput (0); + EXPECT_EQ (0, auxInput.getNumChannels()); +} + +TEST (AudioProcessContextTests, PerBusViewsAreAccessible) +{ + float mainLeft[16], mainRight[16]; + float sideLeft[16], sideRight[16]; + float outLeft[16], outRight[16]; + + const float* inputPtrs[] = { mainLeft, mainRight }; + const float* sidePtrs[] = { sideLeft, sideRight }; + float* outputPtrs[] = { outLeft, outRight }; + + AudioBusBufferView inputViewsArr[] = { + { inputPtrs, 2, AudioBus::Role::Main }, + { sidePtrs, 2, AudioBus::Role::Auxiliary } + }; + AudioBusBufferView outputViewsArr[] = { + { outputPtrs, 2, AudioBus::Role::Main } + }; + + AudioBuffer audio (outputPtrs, 2, 0, 16); + MidiBuffer midi; + ParameterChangeBuffer params; + + AudioProcessContext context { + audio, midi, params, nullptr, { inputViewsArr, 2 }, { outputViewsArr, 1 } + }; + + EXPECT_EQ (2u, context.inputs.size()); + EXPECT_EQ (1u, context.outputs.size()); +} + +TEST (AudioProcessContextTests, GetMainInputFindsFirstMainInputBus) +{ + float mainLeft[16], mainRight[16]; + float sideLeft[16]; + float outLeft[16], outRight[16]; + + const float* inputPtrs[] = { mainLeft, mainRight }; + const float* sidePtrs[] = { sideLeft }; + float* outputPtrs[] = { outLeft, outRight }; + + AudioBusBufferView inputViewsArr[] = { + { inputPtrs, 2, AudioBus::Role::Main }, + { sidePtrs, 1, AudioBus::Role::Auxiliary } + }; + AudioBusBufferView outputViewsArr[] = { + { outputPtrs, 2, AudioBus::Role::Main } + }; + + AudioBuffer audio (outputPtrs, 2, 0, 16); + MidiBuffer midi; + ParameterChangeBuffer params; + + AudioProcessContext context { + audio, midi, params, nullptr, { inputViewsArr, 2 }, { outputViewsArr, 1 } + }; + + const auto& mainInput = context.getMainInput(); + EXPECT_EQ (2, mainInput.getNumChannels()); + EXPECT_EQ (AudioBus::Role::Main, mainInput.getRole()); +} + +TEST (AudioProcessContextTests, GetAuxiliaryInputFindsAuxBus) +{ + float mainLeft[16]; + float sideLeft[16], sideRight[16]; + float outLeft[16]; + + const float* inputPtrs[] = { mainLeft }; + const float* sidePtrs[] = { sideLeft, sideRight }; + float* outputPtrs[] = { outLeft }; + + AudioBusBufferView inputViewsArr[] = { + { inputPtrs, 1, AudioBus::Role::Main }, + { sidePtrs, 2, AudioBus::Role::Auxiliary } + }; + AudioBusBufferView outputViewsArr[] = { + { outputPtrs, 1, AudioBus::Role::Main } + }; + + AudioBuffer audio (outputPtrs, 1, 0, 16); + MidiBuffer midi; + ParameterChangeBuffer params; + + AudioProcessContext context { + audio, midi, params, nullptr, { inputViewsArr, 2 }, { outputViewsArr, 1 } + }; + + const auto& auxInput = context.getAuxiliaryInput (0); + EXPECT_EQ (2, auxInput.getNumChannels()); + EXPECT_EQ (AudioBus::Role::Auxiliary, auxInput.getRole()); +} + +TEST (AudioProcessContextTests, GetAuxiliaryInputOutOfRangeReturnsEmpty) +{ + float mainLeft[16]; + float outLeft[16]; + + const float* inputPtrs[] = { mainLeft }; + float* outputPtrs[] = { outLeft }; + + AudioBusBufferView inputViewsArr[] = { + { inputPtrs, 1, AudioBus::Role::Main } + }; + AudioBusBufferView outputViewsArr[] = { + { outputPtrs, 1, AudioBus::Role::Main } + }; + + AudioBuffer audio (outputPtrs, 1, 0, 16); + MidiBuffer midi; + ParameterChangeBuffer params; + + AudioProcessContext context { + audio, midi, params, nullptr, { inputViewsArr, 1 }, { outputViewsArr, 1 } + }; + + const auto& auxInput0 = context.getAuxiliaryInput (0); + EXPECT_EQ (0, auxInput0.getNumChannels()); + + const auto& auxInput1 = context.getAuxiliaryInput (1); + EXPECT_EQ (0, auxInput1.getNumChannels()); +} + +TEST (AudioProcessContextTests, GetMainOutputFindsFirstMainOutputBus) +{ + float inLeft[16]; + float outLeft[16], outRight[16]; + + const float* inputPtrs[] = { inLeft }; + float* outputPtrs[] = { outLeft, outRight }; + + AudioBusBufferView inputViewsArr[] = { + { inputPtrs, 1, AudioBus::Role::Main } + }; + AudioBusBufferView outputViewsArr[] = { + { outputPtrs, 2, AudioBus::Role::Main } + }; + + AudioBuffer audio (outputPtrs, 2, 0, 16); + MidiBuffer midi; + ParameterChangeBuffer params; + + AudioProcessContext context { + audio, midi, params, nullptr, { inputViewsArr, 1 }, { outputViewsArr, 1 } + }; + + auto& mainOutput = context.getMainOutput(); + EXPECT_EQ (2, mainOutput.getNumChannels()); + EXPECT_EQ (AudioBus::Role::Main, mainOutput.getRole()); +} diff --git a/tests/yup_gui/yup_ComboBox.cpp b/tests/yup_gui/yup_ComboBox.cpp index 047d43d18..55a8b1e58 100644 --- a/tests/yup_gui/yup_ComboBox.cpp +++ b/tests/yup_gui/yup_ComboBox.cpp @@ -42,10 +42,24 @@ class ComboBoxTest : public ::testing::Test protected: void SetUp() override { + oldTheme = ApplicationTheme::getGlobalTheme(); + theme = new ApplicationTheme(); + ApplicationTheme::setGlobalTheme (theme); + comboBox = std::make_unique ("testComboBox"); comboBox->setBounds (0, 0, 200, 30); } + void TearDown() override + { + comboBox.reset(); + ApplicationTheme::setGlobalTheme (oldTheme.get()); + theme = nullptr; + oldTheme = nullptr; + } + + ApplicationTheme::Ptr theme; + ApplicationTheme::Ptr oldTheme; std::unique_ptr comboBox; }; diff --git a/tests/yup_gui/yup_PopupMenu.cpp b/tests/yup_gui/yup_PopupMenu.cpp index 30ac91c74..ec8c47b6f 100644 --- a/tests/yup_gui/yup_PopupMenu.cpp +++ b/tests/yup_gui/yup_PopupMenu.cpp @@ -44,6 +44,10 @@ class PopupMenuTest : public ::testing::Test protected: void SetUp() override { + oldTheme = ApplicationTheme::getGlobalTheme(); + theme = new ApplicationTheme(); + ApplicationTheme::setGlobalTheme (theme); + parentComponent = std::make_unique ("testParent"); parentComponent->setBounds (0, 0, 800, 600); @@ -53,6 +57,15 @@ class PopupMenuTest : public ::testing::Test parentComponent->addAndMakeVisible (*targetComponent); } + void TearDown() override + { + ApplicationTheme::setGlobalTheme (oldTheme.get()); + theme = nullptr; + oldTheme = nullptr; + } + + ApplicationTheme::Ptr theme; + ApplicationTheme::Ptr oldTheme; std::unique_ptr parentComponent; std::unique_ptr targetComponent; };