From c4ef756d0aaa51ee64dea8fc5d416cd36cb82e4f Mon Sep 17 00:00:00 2001 From: pkv Date: Mon, 11 May 2026 22:20:37 +0200 Subject: [PATCH] mac-au: Add an AUv3 host as filter This adds an AUv3 host to obs-studio. The host is loaded as a filter. It is compatible with both v2 and v3 AudioUnits. Sidechain is supported. Signed-off-by: pkv --- plugins/CMakeLists.txt | 1 + plugins/mac-au/CMakeLists.txt | 36 ++ plugins/mac-au/data/locale/en-US.ini | 7 + plugins/mac-au/mac-au-plugin.h | 85 +++ plugins/mac-au/mac-au-plugin.mm | 580 +++++++++++++++++ plugins/mac-au/mac-au-scan.h | 61 ++ plugins/mac-au/mac-au-scan.mm | 129 ++++ plugins/mac-au/mac-au.cpp | 935 +++++++++++++++++++++++++++ plugins/mac-au/mac-au.h | 93 +++ plugins/mac-au/plugin-main.cpp | 56 ++ 10 files changed, 1983 insertions(+) create mode 100644 plugins/mac-au/CMakeLists.txt create mode 100644 plugins/mac-au/data/locale/en-US.ini create mode 100644 plugins/mac-au/mac-au-plugin.h create mode 100644 plugins/mac-au/mac-au-plugin.mm create mode 100644 plugins/mac-au/mac-au-scan.h create mode 100644 plugins/mac-au/mac-au-scan.mm create mode 100644 plugins/mac-au/mac-au.cpp create mode 100644 plugins/mac-au/mac-au.h create mode 100644 plugins/mac-au/plugin-main.cpp diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index c12f015c8b85ae..e052950ad4982e 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -49,6 +49,7 @@ add_obs_plugin(linux-jack PLATFORMS LINUX FREEBSD OPENBSD) add_obs_plugin(linux-pipewire PLATFORMS LINUX FREEBSD OPENBSD) add_obs_plugin(linux-pulseaudio PLATFORMS LINUX FREEBSD OPENBSD) add_obs_plugin(linux-v4l2 PLATFORMS LINUX FREEBSD OPENBSD) +add_obs_plugin(mac-au PLATFORMS MACOS) add_obs_plugin(mac-avcapture PLATFORMS MACOS) add_obs_plugin(mac-capture PLATFORMS MACOS) add_obs_plugin(mac-syphon PLATFORMS MACOS) diff --git a/plugins/mac-au/CMakeLists.txt b/plugins/mac-au/CMakeLists.txt new file mode 100644 index 00000000000000..5ac62dbf23de30 --- /dev/null +++ b/plugins/mac-au/CMakeLists.txt @@ -0,0 +1,36 @@ +cmake_minimum_required(VERSION 3.28...3.30) + +add_library(mac-au MODULE) +add_library(OBS::au ALIAS mac-au) + +target_sources( + mac-au + PRIVATE mac-au.cpp mac-au.h mac-au-scan.mm mac-au-scan.h mac-au-plugin.mm mac-au-plugin.h plugin-main.cpp +) + +target_link_libraries( + mac-au + PRIVATE + OBS::libobs + "$" + "$" + "$" + "$" + "$" + "$" + "$" +) + +set_source_files_properties(mac-au-plugin.mm mac-au-scan.mm PROPERTIES COMPILE_FLAGS "-fobjc-arc") + +set_target_properties_obs( + mac-au + PROPERTIES FOLDER plugins + PREFIX "" + XCODE_ATTRIBUTE_CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION YES + XCODE_ATTRIBUTE_GCC_WARN_SHADOW YES +) + +if(CMAKE_VERSION VERSION_LESS_EQUAL 3.25.0) + set_property(TARGET mac-au PROPERTY XCODE_LINK_BUILD_PHASE_MODE BUILT_ONLY) +endif() diff --git a/plugins/mac-au/data/locale/en-US.ini b/plugins/mac-au/data/locale/en-US.ini new file mode 100644 index 00000000000000..4092fbaec57701 --- /dev/null +++ b/plugins/mac-au/data/locale/en-US.ini @@ -0,0 +1,7 @@ +AU.Filter="AU host filter" +AU.Plugin="AU Plugin" +AU.Button="Open/Close AU Editor" +AU.NOGUI="Warning: this AU has no GUI." +AU.ERR="This AU failed to initialize.\nCheck the log for more info." +AU.Select="Select an AU ..." +AU.SidechainSource="Sidechain source" diff --git a/plugins/mac-au/mac-au-plugin.h b/plugins/mac-au/mac-au-plugin.h new file mode 100644 index 00000000000000..cb6f6ea215faa1 --- /dev/null +++ b/plugins/mac-au/mac-au-plugin.h @@ -0,0 +1,85 @@ +/***************************************************************************** +Copyright (C) 2025 by pkv@obsproject.com + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*****************************************************************************/ + +#pragma once + +#include "mac-au-scan.h" + +#include +#include + +#ifdef __OBJC__ +#import +#import +#import +#else +typedef void AVAudioUnit; +typedef void AUAudioUnit; +typedef void AUViewControllerBase; +typedef void NSWindow; +#endif + +struct au_data; + +struct au_plugin { + char uid[32]; + char name[64]; + NSString *title; + char vendor[64]; + + OSType type; + OSType subtype; + OSType manufacturer; + bool is_v3; + + AVAudioUnit *av_unit; + AUAudioUnit *au; + void *objc; + + bool has_view; + bool editor_is_visible; + + uint32_t max_frames; + double sample_rate; + uint32_t channels; + double sample_time; + + int num_in_audio_buses; + int num_out_audio_buses; + int num_enabled_in_audio_buses; + int num_enabled_out_audio_buses; + int sidechain_num_channels; +}; + +#ifdef __cplusplus +extern "C" { +#endif + + struct au_plugin *au_plugin_create(const struct au_descriptor *desc, double sr, uint32_t frames, uint32_t channels); + void au_plugin_destroy(struct au_plugin *p); + void au_plugin_process(struct au_plugin *p, float *const *channels, float *const *sc_channels, + bool sidechain_enabled, uint32_t num_frames, uint32_t num_channels); + + CFDataRef au_plugin_save_state(struct au_plugin *p); + void au_plugin_load_state(struct au_plugin *p, CFDataRef data); + + void au_plugin_show_editor(struct au_plugin *p); + void au_plugin_hide_editor(struct au_plugin *p); + +#ifdef __cplusplus +} +#endif diff --git a/plugins/mac-au/mac-au-plugin.mm b/plugins/mac-au/mac-au-plugin.mm new file mode 100644 index 00000000000000..3110cfef772826 --- /dev/null +++ b/plugins/mac-au/mac-au-plugin.mm @@ -0,0 +1,580 @@ +/***************************************************************************** +Copyright (C) 2025 by pkv@obsproject.com + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*****************************************************************************/ + +#import "mac-au-plugin.h" + +#import +#import + +@interface AUPluginObjC : NSObject +@property (strong, nonatomic) NSPanel *window; +@property (strong, nonatomic) NSView *editorView; +@property (strong, nonatomic) NSViewController *viewController; +@property (assign, nonatomic) struct au_plugin *plugin; + +@property (assign, nonatomic) BOOL resizingFromHost; +@property (assign, nonatomic) BOOL resizingFromPlugin; +@property (assign, nonatomic) BOOL hasSeenPluginResize; +@property (assign, nonatomic) BOOL pluginCanResizeFromHost; + +// New methods +- (void)startObservingPluginResize; +- (void)pluginViewDidResize:(NSNotification *)n; + +@end + +@implementation AUPluginObjC +// we intercept the close call in order to sync correctly UI w/ the SHow/Hide button in Properties +- (BOOL)windowShouldClose:(NSPanel *)sender +{ + [sender orderOut:nil]; + if (self.plugin) { + self.plugin->editor_is_visible = false; + } + return NO; +} + +// The resizing logic gave me headaches. AUv3 and Apple AUv2 resizing works directly. But for other AUv2 the host window and +// the AU view would resize independently. I noticed though that for these AUv2, Logic Pro blocks the resizing of both the +// container and the AU view. Reaper however allows direct resizing of these AUv2 GUI with an auto-adjustment of the host +// container, but if the resizing is initiated by the host container, the AU view doesn't budge ... It gave me the idea to +// combine both approaches of Logic Pro + Reaper and so I blocked resizing of the host container while allowing it if +// initiated by the AU view. +- (void)windowDidResize:(NSNotification *)notification +{ + if (!self.window || !self.editorView || !self.pluginCanResizeFromHost || self.resizingFromPlugin) { + return; + } + + self.resizingFromHost = YES; + NSView *parent = self.window.contentView; + self.editorView.frame = parent.bounds; + self.resizingFromHost = NO; +} + +- (void)startObservingPluginResize +{ + if (!self.editorView) { + return; + } + + self.hasSeenPluginResize = NO; + [self.editorView setPostsFrameChangedNotifications:YES]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(pluginViewDidResize:) + name:NSViewFrameDidChangeNotification + object:self.editorView]; +} + +- (void)pluginViewDidResize:(NSNotification *)n +{ + if (self.resizingFromHost) { + return; + } + + if (!self.hasSeenPluginResize) { + self.hasSeenPluginResize = YES; + return; + } + + self.resizingFromPlugin = YES; + + NSView *view = self.editorView; + NSWindow *window = self.window; + + if (!view || !window) { + self.resizingFromPlugin = NO; + return; + } + + NSRect contentRect = NSMakeRect(0, 0, view.frame.size.width, view.frame.size.height); + NSRect newFrame = [window frameRectForContentRect:contentRect]; + NSRect oldFrame = window.frame; + newFrame.origin.x = oldFrame.origin.x; + newFrame.origin.y = oldFrame.origin.y + oldFrame.size.height - newFrame.size.height; + // this is where we tell the host container to resize + [window setFrame:newFrame display:YES animate:NO]; + + self.resizingFromPlugin = NO; +} + +@end + +bool scan_buses(const struct au_descriptor *desc, struct au_plugin *p, double sample_rate, uint32_t channels) +{ + NSError *err = nil; + AVAudioFormat *format = [[AVAudioFormat alloc] initStandardFormatWithSampleRate:sample_rate + channels:(AVAudioChannelCount) channels]; + + p->num_in_audio_buses = (int) p->au.inputBusses.count; + p->num_out_audio_buses = (int) p->au.outputBusses.count; + p->num_enabled_in_audio_buses = 0; + p->num_enabled_out_audio_buses = 0; + p->sidechain_num_channels = 0; + + NSLog(@"[AU filter] AU %s has %d input buses, %d output buses", desc->name, p->num_in_audio_buses, + p->num_out_audio_buses); + + // Main input Bus + if (p->num_in_audio_buses > 0) { + AUAudioUnitBus *inBus = p->au.inputBusses[0]; + if (![inBus setFormat:format error:&err]) { + NSLog(@"[AU filter] AU %s cannot set %u-ch main input format: %@", desc->name, channels, err); + return false; + } + inBus.enabled = YES; + p->num_enabled_in_audio_buses++; + } + + // Main output bus + if (p->num_out_audio_buses > 0) { + AUAudioUnitBus *outBus = p->au.outputBusses[0]; + if (![outBus setFormat:format error:&err]) { + NSLog(@"[AU filter] AU %s cannot set %u-ch main output format: %@", desc->name, channels, err); + return false; + } + outBus.enabled = YES; + p->num_enabled_out_audio_buses++; + + if ([outBus respondsToSelector:@selector(supportedChannelLayoutTags)]) { + NSArray *layouts = outBus.supportedChannelLayoutTags; + NSMutableString *s = [NSMutableString stringWithString:@"["]; + for (NSUInteger i = 0; i < layouts.count; i++) { + unsigned int tag = [layouts[i] unsignedIntValue]; + [s appendFormat:@"0x%08X", tag]; + + if (i + 1 < layouts.count) { + [s appendString:@","]; + } + } + [s appendString:@"]"]; + NSLog(@"[AU filter] AU %s main output layouts = %s", desc->name, [s UTF8String]); + } + } + + // sidechain bus: we support only mono, stereo or same number of channels as obs + if (p->num_in_audio_buses > 1) { + AUAudioUnitBus *scBus = p->au.inputBusses[1]; + NSArray *candidates = @[@1, @2, @(channels)]; + uint32_t sc = 0; + + for (NSNumber *n in candidates) { + uint32_t nCh = n.unsignedIntValue; + AVAudioFormat *scfmt = [[AVAudioFormat alloc] initStandardFormatWithSampleRate:sample_rate + channels:(AVAudioChannelCount) nCh]; + if ([scBus setFormat:scfmt error:nil]) { + sc = nCh; + break; + } + } + + if (sc > 0) { + scBus.enabled = YES; + p->sidechain_num_channels = sc; + p->num_enabled_in_audio_buses++; + NSLog(@"[AU filter] AU %s sidechain enabled (%u ch)", desc->name, sc); + } else { + scBus.enabled = NO; + NSLog(@"[AU filter] AU %s sidechain bus present but no matching channel count", desc->name); + } + } + + return true; +} + +struct au_plugin *au_plugin_create(const struct au_descriptor *desc, double sample_rate, uint32_t frames, + uint32_t channels) +{ + if (!desc) { + return NULL; + } + + struct au_plugin *p = (struct au_plugin *) calloc(1, sizeof(*p)); + if (!p) { + return NULL; + } + + AUPluginObjC *objc = [[AUPluginObjC alloc] init]; + objc.plugin = p; + p->objc = (__bridge_retained void *) objc; + + strlcpy(p->uid, desc->uid, sizeof(p->uid)); + strlcpy(p->name, desc->name, sizeof(p->name)); + + NSString *nameStr = [NSString stringWithUTF8String:desc->name]; + NSString *vendorStr = [NSString stringWithUTF8String:desc->vendor]; + p->title = [NSString stringWithFormat:@"%@ – %@", nameStr, vendorStr]; + strlcpy(p->vendor, desc->vendor, sizeof(p->vendor)); + p->sample_rate = sample_rate; + p->channels = channels; + p->max_frames = frames; + + @autoreleasepool { + __block BOOL done = NO; + __block AVAudioUnit *created = nil; + + [AVAudioUnit instantiateWithComponentDescription:desc->desc options:0 + completionHandler:^(AVAudioUnit *av, NSError *err) { + if (av && !err) { + created = av; + } + done = YES; + }]; + + while (!done) + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.001]]; + + if (!created) { + free(p); + return NULL; + } + + p->av_unit = created; + p->au = created.AUAudioUnit; + p->has_view = p->au.providesUserInterface; + if (!p->has_view) { + NSLog(@"[AU filter] '%s'has no GUI", p->name); + } + } + + if (!scan_buses(desc, p, sample_rate, channels)) { + CFRelease((CFTypeRef) p->objc); + free(p); + return NULL; + } + + NSError *err = nil; + if (![p->au allocateRenderResourcesAndReturnError:&err]) { + NSLog(@"[AU filter] allocateRenderResources failed for '%s': %@", p->name, err); + CFRelease((CFTypeRef) p->objc); + free(p); + return NULL; + } + + return p; +} + +void au_plugin_destroy(struct au_plugin *p) +{ + if (!p) { + return; + } + + if (p->objc) { + CFRelease((CFTypeRef) p->objc); + p->objc = NULL; + } + + if (p->au) { + [p->au deallocateRenderResources]; + p->au = nil; + } + + p->av_unit = nil; + + free(p); +} + +// main processing function; the incoming audio data is in float *const *channels; the processed audio will be copied into +// the input buffers. This is what will be retrieved on obs side in the output deque. +void au_plugin_process(struct au_plugin *p, float *const *channels, float *const *sc_channels, bool sidechain_enabled, + uint32_t num_frames, uint32_t num_channels) +{ + if (!p || !p->au) { + return; + } + + if (num_channels == 0 || num_channels > 32) { + return; + } + + struct { + AudioBufferList list; + AudioBuffer extraBuffers[31]; + } bufList; + + AudioBufferList *abl = &bufList.list; + abl->mNumberBuffers = num_channels; + + for (uint32_t ch = 0; ch < num_channels; ch++) { + abl->mBuffers[ch].mNumberChannels = 1; + abl->mBuffers[ch].mDataByteSize = num_frames * sizeof(float); + abl->mBuffers[ch].mData = (void *) channels[ch]; + } + + AudioTimeStamp ts; + memset(&ts, 0, sizeof(ts)); + ts.mFlags = kAudioTimeStampSampleTimeValid; + ts.mSampleTime = p->sample_time; + + AURenderPullInputBlock pullInput = ^AUAudioUnitStatus(AudioUnitRenderActionFlags *actionFlags, + const AudioTimeStamp *timestamp, AUAudioFrameCount frameCount, + NSInteger inputBusNumber, AudioBufferList *inputData) { + (void) actionFlags; + (void) timestamp; + + // main input bus + if (inputBusNumber == 0) { + inputData->mNumberBuffers = num_channels; + + for (uint32_t ch = 0; ch < num_channels; ch++) { + inputData->mBuffers[ch].mNumberChannels = 1; + inputData->mBuffers[ch].mDataByteSize = frameCount * sizeof(float); + inputData->mBuffers[ch].mData = (void *) channels[ch]; + } + return noErr; + } + + // sidechain bus + if (inputBusNumber == 1) { + if (!sidechain_enabled || p->sidechain_num_channels == 0 || sc_channels == NULL) { + return kAudioUnitErr_NoConnection; + } + + inputData->mNumberBuffers = p->sidechain_num_channels; + + for (int ch = 0; ch < p->sidechain_num_channels; ch++) { + inputData->mBuffers[ch].mNumberChannels = 1; + inputData->mBuffers[ch].mDataByteSize = frameCount * sizeof(float); + inputData->mBuffers[ch].mData = (void *) sc_channels[ch]; + } + + return noErr; + } + return kAudioUnitErr_NoConnection; + }; + + AURenderBlock renderBlock = p->au.renderBlock; + if (!renderBlock) { + return; + } + + AudioUnitRenderActionFlags flags = 0; + + renderBlock(&flags, &ts, (AUAudioFrameCount) num_frames, 0, abl, pullInput); + + p->sample_time += num_frames; +} + +CFDataRef au_plugin_save_state(struct au_plugin *p) +{ + if (!p || !p->au) { + return NULL; + } + + NSDictionary *state = p->au.fullStateForDocument; + if (!state) { + return NULL; + } + + NSError *error = nil; + NSData *data = [NSPropertyListSerialization dataWithPropertyList:state format:NSPropertyListBinaryFormat_v1_0 + options:0 + error:&error]; + if (!data || error) { + return NULL; + } + + return CFDataCreate(kCFAllocatorDefault, (const UInt8 *) [data bytes], (CFIndex)[data length]); +} + +void au_plugin_load_state(struct au_plugin *p, CFDataRef data) +{ + if (!p || !p->au || !data) { + return; + } + + NSError *error = nil; + + NSDictionary *state = [NSPropertyListSerialization propertyListWithData:(__bridge NSData *) data + options:NSPropertyListImmutable + format:nil + error:&error]; + if (!state || error) { + return; + } + + p->au.fullStateForDocument = state; +} + +static NSWindow *FindOBSFiltersWindow(void) +{ + for (NSWindow *w in NSApp.windows) { + if (![w isKindOfClass:[NSPanel class]] && w.isVisible && w.canBecomeMainWindow) { + return w; + } + } + return NSApp.mainWindow; +} + +static NSPanel *au_create_editor_window(AUPluginObjC *objc) +{ + const CGFloat W = 640.0; + const CGFloat H = 480.0; + + // try positioning the AU GUI to the left of Properties + NSUInteger style = NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable; + NSWindow *anchor = FindOBSFiltersWindow(); + NSScreen *screen = anchor.screen ?: [NSScreen mainScreen]; + NSRect S = screen.visibleFrame; + NSRect A = anchor.frame; + + NSRect AUFrame = [NSWindow frameRectForContentRect:NSMakeRect(0, 0, W, H) styleMask:style]; + CGFloat Ex = A.origin.x - AUFrame.size.width - 20; + CGFloat Ey = A.origin.y + (A.size.height / 2.0) - (AUFrame.size.height / 2.0); + + if (Ex < S.origin.x + 20) { + Ex = S.origin.x + 20; + } + + if (Ey < S.origin.y + 20) { + Ey = S.origin.y + 20; + } + + if (Ey + AUFrame.size.height > S.origin.y + S.size.height - 20) { + Ey = S.origin.y + S.size.height - AUFrame.size.height - 20; + } + + // create AU panel + NSRect contentRect = NSMakeRect(Ex, Ey, W, H); + NSPanel *panel = [[NSPanel alloc] initWithContentRect:contentRect styleMask:style backing:NSBackingStoreBuffered + defer:YES]; + + panel.delegate = objc; + panel.title = objc.plugin->title; + panel.floatingPanel = YES; + panel.collectionBehavior = NSWindowCollectionBehaviorFullScreenAuxiliary | + NSWindowCollectionBehaviorCanJoinAllSpaces; + panel.becomesKeyOnlyIfNeeded = NO; + panel.hidesOnDeactivate = NO; + + return panel; +} + +void au_plugin_show_editor(struct au_plugin *p) +{ + if (!p || !p->au || !p->objc || !p->has_view) { + return; + } + + AUPluginObjC *objc = (__bridge AUPluginObjC *) p->objc; + if (!objc.window) { + objc.window = au_create_editor_window(objc); + if (!objc.window) { + return; + } + } + + NSPanel *win = objc.window; + + if (!objc.viewController) { + __block AUViewControllerBase *vc = nil; + __block BOOL done = NO; + + [p->au requestViewControllerWithCompletionHandler:^(AUViewControllerBase *viewController) { + vc = viewController; + done = YES; + }]; + + while (!done) { + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.001]]; + } + + if (!vc) { + NSLog(@"[AU filter] AU '%s' returned NULL viewController", p->name); + return; + } + + objc.viewController = vc; + } + + if (!objc.editorView) { + CGSize size = CGSizeZero; + + if ([objc.viewController respondsToSelector:@selector(preferredContentSize)]) { + size = objc.viewController.preferredContentSize; + } + + if (size.width <= 0.0 || size.height <= 0.0) { + size = objc.viewController.view.frame.size; + } + + if (size.width < 120.0) { + size.width = 120.0; + } + + if (size.height < 120.0) { + size.height = 120.0; + } + + [win setContentSize:size]; + } + + NSView *parent = win.contentView; + NSView *view = objc.viewController.view; + + if (objc.editorView && objc.editorView != view) { + [objc.editorView removeFromSuperview]; + objc.editorView = nil; + } + + if (view.superview != parent) { + view.frame = parent.bounds; + view.autoresizingMask = 0; + [parent addSubview:view]; + } + + objc.editorView = view; + [objc startObservingPluginResize]; + + // block host window resizing for AUv2 except Apple's + BOOL is_v3 = p->is_v3; + BOOL is_apple = (strstr(p->vendor, "Apple") != NULL); + objc.pluginCanResizeFromHost = is_v3 || is_apple; + + NSUInteger mask = win.styleMask; + if (objc.pluginCanResizeFromHost) { + mask |= NSWindowStyleMaskResizable; + } else { + mask &= ~NSWindowStyleMaskResizable; + } + + [win setStyleMask:mask]; + + [NSApp activateIgnoringOtherApps:YES]; + [win makeKeyAndOrderFront:nil]; + + p->editor_is_visible = true; +} + +void au_plugin_hide_editor(struct au_plugin *p) +{ + if (!p || !p->objc) { + return; + } + + AUPluginObjC *objc = (__bridge AUPluginObjC *) p->objc; + NSPanel *win = objc.window; + p->editor_is_visible = false; + + if (!win) { + return; + } + + if (win.isVisible) { + [win orderOut:nil]; + } +} diff --git a/plugins/mac-au/mac-au-scan.h b/plugins/mac-au/mac-au-scan.h new file mode 100644 index 00000000000000..1c28075f8f626e --- /dev/null +++ b/plugins/mac-au/mac-au-scan.h @@ -0,0 +1,61 @@ +/***************************************************************************** +Copyright (C) 2025 by pkv@obsproject.com + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*****************************************************************************/ + +#pragma once + +#include +#include + +#ifdef __OBJC__ +#import +#else +typedef void AVAudioUnitComponentManager; +typedef void AVAudioUnitComponent; +typedef void NSArray; +typedef void NSPredicate; +typedef void NSString; +#endif + +struct au_descriptor { + AudioComponent component; + AudioComponentDescription desc; + char name[64]; + char vendor[64]; + char version[64]; + bool is_v3; + OSType type; + OSType subtype; + OSType manufacturer; + char uid[32]; +}; + +struct au_list { + struct au_descriptor *items; + int count; +}; + +#ifdef __cplusplus +extern "C" { +#endif + + struct au_list au_scan_all_effects(void); + void au_free_list(struct au_list *list); + const struct au_descriptor *au_find_by_uid(const char *uid); + +#ifdef __cplusplus +} +#endif diff --git a/plugins/mac-au/mac-au-scan.mm b/plugins/mac-au/mac-au-scan.mm new file mode 100644 index 00000000000000..ef00e7dae15f9b --- /dev/null +++ b/plugins/mac-au/mac-au-scan.mm @@ -0,0 +1,129 @@ +/***************************************************************************** +Copyright (C) 2025 by pkv@obsproject.com + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*****************************************************************************/ + +#import "mac-au-scan.h" + +#import +#import +#import + +#import +#import + +static void get_name(AudioComponent comp, char out[256]) +{ + CFStringRef cfName = NULL; + + if (AudioComponentCopyName(comp, &cfName) != noErr || !cfName) { + out[0] = 0; + return; + } + + Boolean ok = CFStringGetCString(cfName, out, 256, kCFStringEncodingUTF8); + if (!ok) { + out[0] = 0; + } + + CFRelease(cfName); +} + +static int au_name_compare(const void *a, const void *b) +{ + const struct au_descriptor *da = (const struct au_descriptor *) a; + const struct au_descriptor *db = (const struct au_descriptor *) b; + + return strcasecmp(da->name, db->name); +} + +struct au_list au_scan_all_effects(void) +{ + struct au_list result = {0}; + + AVAudioUnitComponentManager *manager = [AVAudioUnitComponentManager sharedAudioUnitComponentManager]; + NSPredicate *predicate = [NSPredicate predicateWithFormat:@"typeName CONTAINS %@", @"Effect"]; + NSArray *components = [manager componentsMatchingPredicate:predicate]; + + for (AVAudioUnitComponent *comp in components) { + AudioComponentDescription desc = comp.audioComponentDescription; + + desc.componentFlags = 0; + desc.componentFlagsMask = 0; + + AudioComponent c = AudioComponentFindNext(NULL, &desc); + if (!c) { + continue; + } + + AudioComponentDescription realDesc; + OSStatus err = AudioComponentGetDescription(c, &realDesc); + if (err != noErr) { + continue; + } + + result.items = + (struct au_descriptor *) realloc(result.items, sizeof(struct au_descriptor) * (result.count + 1)); + struct au_descriptor *d = &result.items[result.count]; + + d->component = c; + d->desc = realDesc; + d->is_v3 = (realDesc.componentFlags & kAudioComponentFlag_IsV3AudioUnit) != 0; + strlcpy(d->name, [[comp name] UTF8String], sizeof(d->name)); + strlcpy(d->vendor, [[comp manufacturerName] UTF8String], sizeof(d->vendor)); + strlcpy(d->version, [[comp versionString] UTF8String], sizeof(d->version)); + d->type = d->desc.componentType; + d->subtype = d->desc.componentSubType; + d->manufacturer = d->desc.componentManufacturer; + snprintf(d->uid, sizeof(d->uid), "%08X-%08X-%08X", (unsigned) d->type, (unsigned) d->subtype, + (unsigned) d->manufacturer); + + result.count++; + } + + if (result.count > 1) { + qsort(result.items, result.count, sizeof(struct au_descriptor), au_name_compare); + } + + return result; +} + +void au_free_list(struct au_list *list) +{ + if (!list || !list->items) { + return; + } + + free(list->items); + list->items = NULL; + list->count = 0; +} + +extern struct au_list g_au_list; +const struct au_descriptor *au_find_by_uid(const char *uid) +{ + if (!uid || !g_au_list.items) { + return NULL; + } + + for (int i = 0; i < g_au_list.count; i++) { + const struct au_descriptor *d = &g_au_list.items[i]; + if (strcmp(d->uid, uid) == 0) { + return d; + } + } + + return NULL; +} diff --git a/plugins/mac-au/mac-au.cpp b/plugins/mac-au/mac-au.cpp new file mode 100644 index 00000000000000..eca9e3007a69eb --- /dev/null +++ b/plugins/mac-au/mac-au.cpp @@ -0,0 +1,935 @@ +/***************************************************************************** +Copyright (C) 2025 by pkv@obsproject.com + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*****************************************************************************/ + +#include "mac-au.h" + +#include "mac-au-plugin.h" + +#include + +#define MT_ obs_module_text +#define S_PLUGIN "au_uid" +#define S_EDITOR "au_open_gui" +#define S_SIDECHAIN_SOURCE "sidechain_source" +#define S_NOGUI "au_noview" +#define S_ERR "au_error" + +#define TEXT_EDITOR MT_("AU.Button") +#define TEXT_PLUGIN MT_("AU.Plugin") +#define TEXT_SIDECHAIN_SOURCE MT_("AU.SidechainSource") +#define TEXT_NOGUI MT_("AU.NOGUI") +#define TEXT_ERR MT_("AU.ERR") + +/* -------------------------------------------------------- */ +#define do_log(level, format, ...) \ + blog(level, "[AU filter: '%s'] " format, obs_source_get_name(ad->context), ## __VA_ARGS__) + +#define warn(format, ...) do_log(LOG_WARNING, format, ## __VA_ARGS__) +#define info(format, ...) do_log(LOG_INFO, format, ## __VA_ARGS__) + +#ifdef _DEBUG +#define debug(format, ...) do_log(LOG_DEBUG, format, ## __VA_ARGS__) +#endif +/* -------------------------------------------------------- */ +struct AuPluginDeleter { + void operator()(au_plugin *p) const noexcept + { + if (p) { + au_plugin_destroy(p); + } + } +}; + +struct au_audio_info { + uint32_t frames; + uint64_t timestamp; +}; + +struct sidechain_prop_info { + obs_property_t *sources; + obs_source_t *parent; +}; + +static inline enum speaker_layout convert_speaker_layout(uint8_t channels) +{ + switch (channels) { + case 0: + return SPEAKERS_UNKNOWN; + case 1: + return SPEAKERS_MONO; + case 2: + return SPEAKERS_STEREO; + case 3: + return SPEAKERS_2POINT1; + case 4: + return SPEAKERS_4POINT0; + case 5: + return SPEAKERS_4POINT1; + case 6: + return SPEAKERS_5POINT1; + case 8: + return SPEAKERS_7POINT1; + default: + return SPEAKERS_UNKNOWN; + } +} + +static inline obs_source_t *get_sidechain(struct au_data *ad) +{ + if (ad->weak_sidechain) { + return obs_weak_source_get_source(ad->weak_sidechain); + } + return NULL; +} +/* --------------------- deque mgt -------------------------- */ + +static inline void clear_deque(struct deque *buf) +{ + deque_pop_front(buf, NULL, buf->size); +} + +static void reset_data(struct au_data *ad) +{ + for (size_t i = 0; i < ad->channels; i++) { + clear_deque(&ad->input_buffers[i]); + clear_deque(&ad->output_buffers[i]); + } + + clear_deque(&ad->info_buffer); +} + +static void reset_sidechain_data(struct au_data *ad) +{ + for (size_t i = 0; i < ad->channels; i++) { + clear_deque(&ad->sc_input_buffers[i]); + } +} + +/* --------------------- main functions ------------------- */ +static const char *au_name(void *unused) +{ + UNUSED_PARAMETER(unused); + return obs_module_text("AU.Filter"); +} + +void au_save_state(void *data, obs_data_t *settings) +{ + struct au_data *ad = static_cast(data); + if (!ad) { + return; + } + + auto plugin = std::atomic_load(&ad->plugin); + if (!plugin) { + return; + } + + CFDataRef state = au_plugin_save_state(plugin.get()); + if (!state) { + return; + } + + const UInt8 *bytes = CFDataGetBytePtr(state); + CFIndex len = CFDataGetLength(state); + + size_t hex_len = (size_t)len * 2 + 1; + char *hex = (char *)bmalloc(hex_len); + if (!hex) { + CFRelease(state); + return; + } + + static const char hexdigits[] = "0123456789ABCDEF"; + + for (CFIndex i = 0; i < len; i++) { + UInt8 b = bytes[i]; + hex[2 * i] = hexdigits[b >> 4]; + hex[2 * i + 1] = hexdigits[b & 0xF]; + } + hex[2 * len] = '\0'; + + obs_data_set_string(settings, "au_state", hex); + obs_data_set_string(settings, "au_uid", ad->uid); + + bfree(hex); + CFRelease(state); +} + +static void sidechain_capture(void *data, obs_source_t *source, const struct audio_data *audio, bool muted); +static void au_destroy(void *data) +{ + struct au_data *ad = static_cast(data); + if (!ad) { + return; + } + + ad->bypass.store(true, std::memory_order_relaxed); + ad->sidechain_enabled.store(false, std::memory_order_relaxed); + + if (ad->weak_sidechain) { + obs_source_t *sidechain = get_sidechain(ad); + if (sidechain) { + obs_source_remove_audio_capture_callback(sidechain, sidechain_capture, ad); + obs_source_release(sidechain); + } + obs_weak_source_release(ad->weak_sidechain); + } + + if (ad->sc_resampler) { + ad->sc_resampler.reset(); + } + + for (size_t i = 0; i < ad->channels; i++) { + deque_free(&ad->input_buffers[i]); + deque_free(&ad->output_buffers[i]); + } + for (size_t i = 0; i < ad->channels; i++) { + deque_free(&ad->sc_input_buffers[i]); + } + + bfree(ad->copy_buffers[0]); + bfree(ad->sc_copy_buffers[0]); + deque_free(&ad->info_buffer); + da_free(ad->output_data); + + auto plugin = std::atomic_load(&ad->plugin); + if (plugin) { + std::atomic_store(&ad->plugin, std::shared_ptr{}); + } + + delete ad; +} + +static bool create_new_au(au_data *ad, const char *uid) +{ + // Find new plugin descriptor + const struct au_descriptor *desc = au_find_by_uid(uid); + if (!desc) { + info("AU '%s' was not found on this mac.", uid); + ad->name[0] = 0; + ad->has_sidechain.store(false, std::memory_order_relaxed); + ad->sc_channels = 0; + ad->bypass.store(true, std::memory_order_relaxed); + ad->last_init_failed = true; + + return false; + } + + // Create new AU instance + struct au_plugin *raw = + au_plugin_create(desc, (double)ad->sample_rate, (uint32_t)ad->frames, (uint32_t)ad->channels); + + if (!raw) { + info("AU '%s' failed to initialize.", desc->name); + ad->name[0] = 0; + ad->has_sidechain.store(false, std::memory_order_relaxed); + ad->sc_channels = 0; + ad->bypass.store(true, std::memory_order_relaxed); + ad->last_init_failed = true; + return false; + } + + std::shared_ptr plugin(raw, AuPluginDeleter{}); + + strncpy(ad->name, desc->name, sizeof(ad->name)); + ad->name[sizeof(ad->name) - 1] = '\0'; + + strncpy(ad->uid, desc->uid, sizeof(ad->uid)); + ad->uid[sizeof(ad->uid) - 1] = '\0'; + + uint32_t sc_channels = plugin->sidechain_num_channels; + bool has_sc = (sc_channels == 1 || sc_channels == 2); + if (!has_sc) { + sc_channels = 0; + } + + bool has_view = plugin->has_view; + + std::atomic_store(&ad->plugin, plugin); + ad->sc_channels = sc_channels; + ad->has_sidechain.store(has_sc, std::memory_order_relaxed); + ad->has_view.store(has_view, std::memory_order_relaxed); + ad->plugin->is_v3 = desc->is_v3; + + info("AU %s (vendor: %s, version : %s, %s) initialized.", desc->name, desc->vendor, desc->version, + desc->is_v3 ? "AUv3" : "AUv2"); + + return true; +} + +static void destroy_current_au(au_data *ad, obs_data_t *settings) +{ + if (!ad) { + return; + } + + ad->bypass.store(true, std::memory_order_relaxed); + auto plugin = std::atomic_load(&ad->plugin); + if (plugin) { + au_plugin_hide_editor(plugin.get()); + } + + std::atomic_store(&ad->plugin, std::shared_ptr{}); + + ad->uid[0] = 0; + ad->name[0] = 0; + ad->has_sidechain.store(false, std::memory_order_relaxed); + ad->sc_channels = 0; + ad->has_view.store(false, std::memory_order_relaxed); + + // clear sc stuff + obs_weak_source_t *old_weak = nullptr; + { + std::lock_guard lock(ad->sidechain_update_mutex); + if (ad->weak_sidechain) { + old_weak = ad->weak_sidechain; + ad->weak_sidechain = nullptr; + } + ad->sidechain_name.clear(); + ad->sidechain_enabled.store(false, std::memory_order_relaxed); + obs_data_set_string(settings, S_SIDECHAIN_SOURCE, NULL); + } + + if (old_weak) { + obs_source_t *old_sidechain = obs_weak_source_get_source(old_weak); + if (old_sidechain) { + obs_source_remove_audio_capture_callback(old_sidechain, sidechain_capture, ad); + obs_source_release(old_sidechain); + } + obs_weak_source_release(old_weak); + } +} + +static void au_load_state(au_data *ad, obs_data_t *settings) +{ + if (!ad) { + return; + } + + auto plugin = std::atomic_load(&ad->plugin); + if (!plugin) { + return; + } + + const char *hex = obs_data_get_string(settings, "au_state"); + if (hex && *hex) { + if ((strlen(hex) & 1) != 0) { + return; + } + + size_t len = strlen(hex) / 2; + std::vector bytes(len); + for (size_t i = 0; i < len; i++) { + char tmp[3] = {hex[2 * i], hex[2 * i + 1], 0}; + char *end = nullptr; + unsigned long v = strtoul(tmp, &end, 16); + if (!end || *end != '\0' || v > 0xFF) { + return; + } + bytes.push_back((uint8_t)v); + } + + CFDataRef d = CFDataCreate(kCFAllocatorDefault, bytes.data(), bytes.size()); + if (d) { + au_plugin_load_state(plugin.get(), d); + CFRelease(d); + } + } +} + +// code ported from obs-filters/compressor.c +static void sidechain_swap(au_data *ad, obs_data *settings) +{ + if (!ad) { + return; + } + + if (!ad->has_sidechain.load(std::memory_order_relaxed)) { + return; + } + + ad->sidechain_enabled.store(false, std::memory_order_relaxed); + + const char *sc_name_c = obs_data_get_string(settings, S_SIDECHAIN_SOURCE); + std::string sc_name = sc_name_c ? sc_name_c : std::string(); + bool valid = !sc_name.empty() && sc_name != "none"; + + obs_weak_source_t *old_weak = nullptr; + + { + std::lock_guard lk(ad->sidechain_update_mutex); + + if (!valid) { + if (ad->weak_sidechain) { + old_weak = ad->weak_sidechain; + ad->weak_sidechain = nullptr; + } + + if (!ad->sidechain_name.empty()) { + ad->sidechain_name.clear(); + } + } else { + if (ad->sidechain_name.empty() || ad->sidechain_name != sc_name) { + + if (ad->weak_sidechain) { + old_weak = ad->weak_sidechain; + ad->weak_sidechain = nullptr; + } + + ad->sidechain_name.clear(); + ad->sidechain_name = sc_name; + ad->sidechain_check_time = os_gettime_ns() - 3000000000ULL; + } + } + } + ad->sidechain_enabled.store(valid, std::memory_order_relaxed); + + if (old_weak) { + obs_source_t *s = obs_weak_source_get_source(old_weak); + if (s) { + obs_source_remove_audio_capture_callback(s, sidechain_capture, ad); + obs_source_release(s); + } + obs_weak_source_release(old_weak); + } +} + +static void au_update(void *data, obs_data_t *settings) +{ + auto *ad = static_cast(data); + if (!ad) { + return; + } + + const char *uid = obs_data_get_string(settings, S_PLUGIN); + std::string uid_str = uid ? uid : ""; + + // 1) No plugin selected → bypass and clear state + if (uid_str.empty()) { + destroy_current_au(ad, settings); + return; + } + + bool initial_load = (ad->uid[0] == 0); + bool swapping = (strncmp(ad->uid, uid, sizeof(ad->uid)) != 0); + // 2) Swap AUs + if (swapping) { + strncpy(ad->uid, uid, sizeof(ad->uid)); // we always store the uid even in failed state + ad->last_init_failed = false; + + if (!initial_load) { + destroy_current_au(ad, settings); + } + + if (!create_new_au(ad, uid)) { + return; + } + + if (initial_load) { + au_load_state(ad, settings); + } + ad->bypass.store(false, std::memory_order_relaxed); + } + + // 3) Sidechain swap logic (taken from obs-filters/compressor.c; quite tricky since part is done during video_tick) + auto plugin = std::atomic_load(&ad->plugin); + if (!plugin || !ad->has_sidechain.load(std::memory_order_relaxed)) { + return; + } + + sidechain_swap(ad, settings); +} + +static void *au_create(obs_data_t *settings, obs_source_t *filter) +{ + auto *ad = new au_data(); + + ad->context = filter; + std::atomic_store(&ad->plugin, std::shared_ptr{}); + ad->uid[0] = 0; + + ad->frames = (size_t)FRAME_SIZE; + ad->channels = audio_output_get_channels(obs_get_audio()); + ad->sample_rate = audio_output_get_sample_rate(obs_get_audio()); + ad->layout = audio_output_get_info(obs_get_audio())->speakers; + + ad->has_sidechain.store(false, std::memory_order_relaxed); + ad->sidechain_enabled.store(false, std::memory_order_relaxed); + ad->latency = 1000000000LL / (1000 / BUFFER_SIZE_MSEC); + + // allocate copy buffers(which are *contiguous* for the channels) + size_t channels = ad->channels; + size_t frames = (size_t)FRAME_SIZE; + ad->copy_buffers[0] = (float *)bmalloc((size_t)FRAME_SIZE * channels * sizeof(float)); + ad->sc_copy_buffers[0] = (float *)bmalloc(FRAME_SIZE * MAX_AUDIO_CHANNELS * sizeof(float)); + + for (size_t c = 1; c < channels; ++c) { + ad->copy_buffers[c] = ad->copy_buffers[c - 1] + frames; + } + + for (size_t c = 1; c < 2; ++c) { + ad->sc_copy_buffers[c] = ad->sc_copy_buffers[c - 1] + frames; + } + + // reserve deque buffers + for (size_t i = 0; i < channels; i++) { + deque_reserve(&ad->input_buffers[i], frames * sizeof(float)); + deque_reserve(&ad->output_buffers[i], frames * sizeof(float)); + deque_reserve(&ad->sc_input_buffers[i], frames * sizeof(float)); + } + + ad->bypass.store(true, std::memory_order_relaxed); + au_update(ad, settings); + return ad; +} + +/* -------------- audio processing (incl. sc) --------------- */ +static inline void preprocess_input(struct au_data *ad) +{ + if (!ad) { + return; + } + + int num_channels = (int)ad->channels; + int sc_num_channels = (int)ad->sc_channels; + int frames = (int)ad->frames; + bool has_sc = ad->has_sidechain.load(std::memory_order_relaxed); + bool sc_enabled = ad->sidechain_enabled.load(std::memory_order_relaxed); + size_t segment_size = ad->frames * sizeof(float); + + // Ensure sidechain deque capacity + if (has_sc && sc_enabled) { + std::lock_guard lock(ad->sidechain_mutex); + for (int i = 0; i < sc_num_channels; i++) { + if (ad->sc_input_buffers[i].size < segment_size) { + deque_push_back_zero(&ad->sc_input_buffers[i], segment_size); + } + } + } + + // Pop from input deque into our working buffers + for (int i = 0; i < num_channels; i++) { + deque_pop_front(&ad->input_buffers[i], ad->copy_buffers[i], (size_t)frames * sizeof(float)); + } + + // Pop sidechain into working buffers + if (has_sc && sc_enabled) { + { + std::lock_guard lock(ad->sidechain_mutex); + for (int i = 0; i < sc_num_channels; i++) { + deque_pop_front(&ad->sc_input_buffers[i], ad->sc_copy_buffers[i], + (size_t)frames * sizeof(float)); + } + } + // Optional resampling if SC layout differs + bool needs_resampling = (ad->channels != ad->sc_channels) && + (ad->sc_channels == 1 || ad->sc_channels == 2); + auto sc_resampler = std::atomic_load(&ad->sc_resampler); + if (needs_resampling && sc_resampler) { + uint8_t *resampled[2] = {nullptr, nullptr}; + uint32_t out_frames = 0; + uint64_t ts_offset = 0; + + if (audio_resampler_resample(sc_resampler.get(), resampled, &out_frames, &ts_offset, + (const uint8_t **)ad->sc_copy_buffers, (uint32_t)frames)) { + // Copy the resampled data back into sc_copy_buffers + for (int ch = 0; ch < sc_num_channels; ++ch) { + memcpy(ad->sc_copy_buffers[ch], resampled[ch], + (size_t)out_frames * sizeof(float)); + } + } + } + } +} + +static inline void process(struct au_data *ad, const std::shared_ptr &plugin) +{ + if (!ad || !plugin) { + return; + } + + int num_channels = (int)ad->channels; + int frames = (int)ad->frames; + bool bypass = ad->bypass.load(std::memory_order_relaxed); + bool has_sc = ad->has_sidechain.load(std::memory_order_relaxed); + bool sc_enabled = ad->sidechain_enabled.load(std::memory_order_relaxed); + preprocess_input(ad); + + if (!bypass && plugin->num_enabled_out_audio_buses) { + // Sidechain pointer array if enabled + float *const *sc_ptrs = nullptr; + if (has_sc && sc_enabled && ad->sc_channels > 0) { + sc_ptrs = ad->sc_copy_buffers; + } + + au_plugin_process(plugin.get(), ad->copy_buffers, sc_ptrs, sc_enabled, (uint32_t)frames, + (uint32_t)num_channels); + } + // Push processed segment to output + size_t segment_size = ad->frames * sizeof(float); + for (size_t i = 0; i < ad->channels; i++) { + deque_push_back(&ad->output_buffers[i], ad->copy_buffers[i], segment_size); + } +} + +static struct obs_audio_data *au_filter_audio(void *data, struct obs_audio_data *audio) +{ + auto *ad = static_cast(data); + if (!ad) { + return audio; + } + + auto plugin = std::atomic_load(&ad->plugin); + if (!plugin || !plugin->num_enabled_out_audio_buses) { + return audio; + } + + bool bypass = ad->bypass.load(std::memory_order_relaxed); + if (bypass) { + return audio; + } + + // If timestamp has dramatically changed, consider it a new stream of audio data. Clear all circular buffers to + // prevent old audio data from being processed as part of the new data. + if (ad->last_timestamp) { + int64_t diff = llabs((int64_t)ad->last_timestamp - (int64_t)audio->timestamp); + if (diff > 1000000000LL) { + reset_data(ad); + } + } + ad->last_timestamp = audio->timestamp; + + struct au_audio_info info; + size_t segment_size = ad->frames * sizeof(float); + size_t out_size; + // push audio packet info (timestamp/frame count) to info deque + info.frames = audio->frames; + info.timestamp = audio->timestamp; + deque_push_back(&ad->info_buffer, &info, sizeof(info)); + + // push back current audio data to input deque + for (size_t i = 0; i < ad->channels; i++) { + deque_push_back(&ad->input_buffers[i], audio->data[i], audio->frames * sizeof(float)); + } + + // pop/process each 10ms segments, push back to output deque + while (ad->input_buffers[0].size >= segment_size) { + process(ad, plugin); + } + + // peek front of info deque, check to see if we have enough to pop the expected packet size, if not, return null + memset(&info, 0, sizeof(info)); + deque_peek_front(&ad->info_buffer, &info, sizeof(info)); + out_size = info.frames * sizeof(float); + + if (ad->output_buffers[0].size < out_size) { + return NULL; + } + + // if there's enough audio data buffered in the output deque,pop and return a packet + deque_pop_front(&ad->info_buffer, NULL, sizeof(info)); + da_resize(ad->output_data, out_size * ad->channels); + + for (size_t i = 0; i < ad->channels; i++) { + ad->output_audio.data[i] = (uint8_t *)&ad->output_data.array[i * out_size]; + + deque_pop_front(&ad->output_buffers[i], ad->output_audio.data[i], out_size); + } + + ad->running_sample_count += info.frames; + ad->system_time = os_gettime_ns(); + ad->output_audio.frames = info.frames; + ad->output_audio.timestamp = info.timestamp - ad->latency; + return &ad->output_audio; +} + +static void sidechain_capture(void *data, obs_source_t *source, const struct audio_data *audio, bool muted) +{ + UNUSED_PARAMETER(source); + UNUSED_PARAMETER(muted); + auto *ad = static_cast(data); + if (!ad) { + return; + } + + auto plugin = std::atomic_load(&ad->plugin); + if (!plugin) { + return; + } + + bool bypass = ad->bypass.load(std::memory_order_relaxed); + bool sc_enabled = ad->sidechain_enabled.load(std::memory_order_relaxed); + + if (bypass || !sc_enabled) { + return; + } + + if (ad->sc_channels != 1 && ad->sc_channels != 2) { + return; + } + // If timestamp has dramatically changed, consider it a new stream of audio data. Clear all circular buffers to + // prevent old audio data from being processed as part of the new data. + if (ad->sc_last_timestamp) { + int64_t diff = llabs((int64_t)ad->sc_last_timestamp - (int64_t)audio->timestamp); + + if (diff > 1000000000LL) { + std::lock_guard lock(ad->sidechain_mutex); + reset_sidechain_data(ad); + } + } + + ad->sc_last_timestamp = audio->timestamp; + + // push back current audio data to input deque + { + std::lock_guard lock(ad->sidechain_mutex); + for (size_t i = 0; i < ad->channels; i++) { + deque_push_back(&ad->sc_input_buffers[i], audio->data[i], audio->frames * sizeof(float)); + } + } +} + +// written after obs-filters/compressor-filter.c for the sidechain logic +static void au_tick(void *data, float seconds) +{ + auto *ad = static_cast(data); + if (!ad) { + return; + } + + bool has_sc = ad->has_sidechain.load(std::memory_order_relaxed); + if (!has_sc) { + return; + } + + std::string new_name = {}; + + { + std::lock_guard lock(ad->sidechain_update_mutex); + if (!ad->sidechain_name.empty() && !ad->weak_sidechain) { + uint64_t t = os_gettime_ns(); + + if (t - ad->sidechain_check_time > 3000000000) { + new_name = ad->sidechain_name; + ad->sidechain_check_time = t; + } + } + } + + if (!new_name.empty()) { + obs_source_t *sidechain = obs_get_source_by_name(new_name.c_str()); + obs_weak_source_t *weak_sidechain = sidechain ? obs_source_get_weak_source(sidechain) : NULL; + + { + std::lock_guard lock(ad->sidechain_update_mutex); + if (!ad->sidechain_name.empty() && ad->sidechain_name == new_name) { + ad->weak_sidechain = weak_sidechain; + weak_sidechain = NULL; + } + } + + if (sidechain) { + // downmix or upmix if channel count is mismatched + bool needs_resampling = ad->channels != ad->sc_channels; + if (needs_resampling) { + struct resample_info src, dst; + src.samples_per_sec = ad->sample_rate; + src.format = AUDIO_FORMAT_FLOAT_PLANAR; + src.speakers = convert_speaker_layout((uint8_t)ad->channels); + + dst.samples_per_sec = ad->sample_rate; + dst.format = AUDIO_FORMAT_FLOAT_PLANAR; + dst.speakers = convert_speaker_layout((uint8_t)ad->sc_channels); + + audio_resampler *raw = audio_resampler_create(&dst, &src); + if (!raw) { + std::atomic_store(&ad->sc_resampler, std::shared_ptr{}); + } else { + std::shared_ptr sp(raw, [](audio_resampler *r) { + if (r) { + audio_resampler_destroy(r); + } + }); + std::atomic_store(&ad->sc_resampler, sp); + } + } else { + std::atomic_store(&ad->sc_resampler, std::shared_ptr{}); + } + obs_source_add_audio_capture_callback(sidechain, sidechain_capture, ad); + ad->sidechain_enabled.store(true, std::memory_order_relaxed); + obs_weak_source_release(weak_sidechain); + obs_source_release(sidechain); + } + } + UNUSED_PARAMETER(seconds); +} + +/* ---------------- properties functions --------------------- */ + +static bool au_show_gui_callback(obs_properties_t *props, obs_property_t *p, void *data) +{ + UNUSED_PARAMETER(props); + UNUSED_PARAMETER(p); + auto *ad = static_cast(data); + if (!ad) { + return false; + } + + auto plugin = std::atomic_load(&ad->plugin); + + if (!plugin) { + return false; + } + + if (!plugin->has_view) { + return false; + } + + if (!plugin->editor_is_visible) { + au_plugin_show_editor(plugin.get()); + } else { + au_plugin_hide_editor(plugin.get()); + } + + return true; +} + +static bool add_sources(void *data, obs_source_t *source) +{ + struct sidechain_prop_info *info = (struct sidechain_prop_info *)data; + uint32_t caps = obs_source_get_output_flags(source); + + if (source == info->parent) { + return true; + } + if ((caps & OBS_SOURCE_AUDIO) == 0) { + return true; + } + + const char *name = obs_source_get_name(source); + obs_property_list_add_string(info->sources, name, name); + return true; +} + +bool on_au_changed_cb(void *data, obs_properties_t *props, obs_property_t *property, obs_data_t *settings) +{ + UNUSED_PARAMETER(property); + UNUSED_PARAMETER(settings); + auto *ad = static_cast(data); + if (!ad) { + return false; + } + + bool has_sc = ad->has_sidechain.load(std::memory_order_relaxed); + + obs_property_t *p = obs_properties_get(props, S_SIDECHAIN_SOURCE); + + if (has_sc) { + obs_source_t *parent = obs_filter_get_parent(ad->context); + obs_property_list_clear(p); + obs_property_list_add_string(p, obs_module_text("None"), "none"); + struct sidechain_prop_info info = {p, parent}; + obs_enum_sources(add_sources, &info); + obs_property_set_visible(p, true); + } else { + obs_property_set_visible(p, false); + } + + obs_property_t *button = obs_properties_get(props, S_EDITOR); + obs_property_set_visible(button, ad->has_view.load(std::memory_order_relaxed)); + + obs_property_t *noview = obs_properties_get(props, S_NOGUI); + obs_property_set_visible(noview, !ad->has_view && !ad->last_init_failed); + + obs_property_t *err = obs_properties_get(props, S_ERR); + if (err) { + obs_properties_remove_by_name(props, S_ERR); + } + + if (ad->last_init_failed) { + obs_property_t *err2 = obs_properties_add_text(props, S_ERR, TEXT_ERR, OBS_TEXT_INFO); + obs_property_text_set_info_type(err2, OBS_TEXT_INFO_ERROR); + } + return true; +} + +extern struct au_list g_au_list; +static obs_properties_t *au_properties(void *data) +{ + obs_properties_t *props = obs_properties_create(); + obs_property_t *sources; + auto *ad = static_cast(data); + if (!ad) { + return props; + } + + obs_property_t *aulist = + obs_properties_add_list(props, S_PLUGIN, TEXT_PLUGIN, OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_STRING); + obs_property_list_add_string(aulist, obs_module_text("AU.Select"), ""); + + for (int i = 0; i < g_au_list.count; i++) { + struct au_descriptor *d = &g_au_list.items[i]; + std::string name = d->name; + std::string vendor = d->vendor; + std::string label; + + if (!vendor.empty()) { + label = name + " (" + vendor + ")"; + } else { + label = name; + } + + const char *value = d->uid; + obs_property_list_add_string(aulist, label.c_str(), value); + } + + obs_property_t *button = + obs_properties_add_button2(props, S_EDITOR, obs_module_text(TEXT_EDITOR), au_show_gui_callback, NULL); + obs_property_set_visible(button, ad->has_view.load(std::memory_order_relaxed)); + + sources = obs_properties_add_list(props, S_SIDECHAIN_SOURCE, TEXT_SIDECHAIN_SOURCE, OBS_COMBO_TYPE_LIST, + OBS_COMBO_FORMAT_STRING); + obs_property_set_modified_callback2(aulist, on_au_changed_cb, data); + + if (ad->last_init_failed) { + obs_property_t *err = obs_properties_add_text(props, S_ERR, TEXT_ERR, OBS_TEXT_INFO); + obs_property_text_set_info_type(err, OBS_TEXT_INFO_ERROR); + } + + obs_property_t *noview = obs_properties_add_text(props, S_NOGUI, TEXT_NOGUI, OBS_TEXT_INFO); + obs_property_text_set_info_type(noview, OBS_TEXT_INFO_WARNING); + obs_property_set_visible(noview, !ad->has_view); + + return props; +} + +void register_aufilter_source() +{ + struct obs_source_info au_filter = {}; + au_filter.id = "au_filter"; + au_filter.type = OBS_SOURCE_TYPE_FILTER; + au_filter.output_flags = OBS_SOURCE_AUDIO; + au_filter.get_name = au_name; + au_filter.create = au_create; + au_filter.destroy = au_destroy; + au_filter.update = au_update; + au_filter.filter_audio = au_filter_audio; + au_filter.get_properties = au_properties; + au_filter.save = au_save_state; + au_filter.video_tick = au_tick; + obs_register_source(&au_filter); +} diff --git a/plugins/mac-au/mac-au.h b/plugins/mac-au/mac-au.h new file mode 100644 index 00000000000000..e3aa751ff95a55 --- /dev/null +++ b/plugins/mac-au/mac-au.h @@ -0,0 +1,93 @@ +/***************************************************************************** +Copyright (C) 2025 by pkv@obsproject.com + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*****************************************************************************/ + +#pragma once + +#include "mac-au-scan.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include + +#define MAX_PREPROC_CHANNELS 8 +#define MAX_SC_CHANNELS 2 +#define BUFFER_SIZE_MSEC 10 +#define FRAME_SIZE 480 + +struct au_plugin; + +struct au_data { + obs_source_t *context; + + std::shared_ptr plugin = nullptr; + char uid[32]; + char name[64]; + + uint32_t sample_rate; + size_t frames; + size_t channels; + enum speaker_layout layout; + int64_t running_sample_count = 0; + uint64_t system_time = 0; + uint64_t last_timestamp; + uint64_t latency; + + struct deque info_buffer; + struct deque input_buffers[MAX_PREPROC_CHANNELS]; + struct deque output_buffers[MAX_PREPROC_CHANNELS]; + struct deque sc_input_buffers[MAX_PREPROC_CHANNELS]; + + /* PCM buffers */ + float *copy_buffers[MAX_PREPROC_CHANNELS]; + float *sc_copy_buffers[MAX_PREPROC_CHANNELS]; + + /* output data */ + struct obs_audio_data output_audio; + DARRAY(float) output_data; + + /* state vars */ + std::atomic bypass; + std::atomic sidechain_enabled; + std::atomic has_view; + std::atomic_flag init_in_progress = ATOMIC_FLAG_INIT; + + /* Sidechain */ + std::atomic has_sidechain; + obs_weak_source_t *weak_sidechain = nullptr; + std::string sidechain_name = ""; + uint64_t sidechain_check_time = 0; + std::shared_ptr sc_resampler; + size_t sc_channels = 0; + uint64_t sc_last_timestamp = 0; + uint64_t sc_latency; + std::mutex sidechain_update_mutex; + std::mutex sidechain_mutex; + + /* error messages */ + bool last_init_failed; +}; diff --git a/plugins/mac-au/plugin-main.cpp b/plugins/mac-au/plugin-main.cpp new file mode 100644 index 00000000000000..b10eaf4b10f3f0 --- /dev/null +++ b/plugins/mac-au/plugin-main.cpp @@ -0,0 +1,56 @@ +/***************************************************************************** +Copyright (C) 2025 by pkv@obsproject.com + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*****************************************************************************/ + +#include "mac-au-scan.h" + +#include + +const char *PLUGIN_VERSION = "1.0.0"; +OBS_DECLARE_MODULE() +OBS_MODULE_USE_DEFAULT_LOCALE("mac-au", "en-US") +MODULE_EXPORT const char *obs_module_description(void) +{ + return "macOS AudioUnit host"; +} + +extern void register_aufilter_source(); + +struct au_list g_au_list; + +bool obs_module_load(void) +{ + g_au_list = au_scan_all_effects(); + + blog(LOG_INFO, "[AU filter]: Found %d Audio Units :", g_au_list.count); + if (!g_au_list.count) { + blog(LOG_INFO, "[AU filter]: No Audio Units found; AU filter disabled"); + return false; + } + for (int i = 0; i < g_au_list.count; i++) { + struct au_descriptor *d = &g_au_list.items[i]; + blog(LOG_INFO, "[AU filter]: %i. %s (%s, version: %s, %s)", i + 1, d->name, d->vendor, d->version, + d->is_v3 ? "AUv3" : "AUv2"); + } + register_aufilter_source(); + blog(LOG_INFO, "MAC-AU filter loaded successfully (version %s)", PLUGIN_VERSION); + return true; +} + +void obs_module_unload(void) +{ + au_free_list(&g_au_list); +}