From 4f0be349478d0ccb3020157495f2b3c2eea91ff6 Mon Sep 17 00:00:00 2001 From: True Zhuanjia Date: Sun, 16 Aug 2026 18:03:50 +0800 Subject: [PATCH] dualSense haptic rumbling by qin2333's PCM convertor --- .gitmodules | 3 + VoidLink.xcodeproj/project.pbxproj | 16 +- VoidLink/Input/ControllerNavigator.swift | 6 +- VoidLink/Input/ControllerSupport.h | 11 +- VoidLink/Input/ControllerSupport.m | 59 ++++++ VoidLink/Input/ControllerUtil.swift | 258 +++++++++++++++++++++++ VoidLink/Input/HapticContext.h | 3 + VoidLink/Input/HapticContext.m | 87 ++++++++ VoidLink/Stream/Connection.m | 18 ++ VoidLink/VoidLink-Bridging-Header.h | 1 + 10 files changed, 455 insertions(+), 7 deletions(-) diff --git a/.gitmodules b/.gitmodules index 152009bc..6fb05b6d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ url=https://github.com/TrueZhuangJia/voidlink-c.git [submodule "third_party/imgui"] path = third_party/imgui url = https://github.com/ocornut/imgui.git +[submodule "third_party/moonlight-audio-haptics"] + path = third_party/moonlight-audio-haptics + url = https://github.com/AlkaidLab/moonlight-audio-haptics.git diff --git a/VoidLink.xcodeproj/project.pbxproj b/VoidLink.xcodeproj/project.pbxproj index 469a7552..dcbf8a99 100644 --- a/VoidLink.xcodeproj/project.pbxproj +++ b/VoidLink.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + A1D500022FFC000100000001 /* authored_haptics_engine.cpp in Sources */ = {isa = PBXBuildFile; fileRef = A1D500012FFC000100000001 /* authored_haptics_engine.cpp */; settings = {COMPILER_FLAGS = "-std=c++17"; }; }; 180345E12E819877004AF36F /* libopus-sim.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 180345E02E819877004AF36F /* libopus-sim.a */; }; 180E6E372E93BFDB0067C73A /* AlertControllerUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180E6E362E93BFDB0067C73A /* AlertControllerUtil.swift */; }; 1817B61E2E6FD04D000D938D /* SafeTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1817B61D2E6FD04D000D938D /* SafeTimer.swift */; }; @@ -187,6 +188,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + A1D500012FFC000100000001 /* authored_haptics_engine.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; name = authored_haptics_engine.cpp; path = "third_party/moonlight-audio-haptics/src/core/authored_haptics_engine.cpp"; sourceTree = SOURCE_ROOT; }; 180345E02E819877004AF36F /* libopus-sim.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libopus-sim.a"; sourceTree = ""; }; 180E6E362E93BFDB0067C73A /* AlertControllerUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertControllerUtil.swift; sourceTree = ""; }; 1817B61D2E6FD04D000D938D /* SafeTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeTimer.swift; sourceTree = ""; }; @@ -1139,6 +1141,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A1D500022FFC000100000001 /* authored_haptics_engine.cpp in Sources */, FB290D0719B2C406004C83CF /* Limelight.xcdatamodeld in Sources */, 9819CC22254F180F008A7C8E /* AbsoluteTouchHandler.m in Sources */, 9819CC14254F107A008A7C8E /* RelativeTouchHandler.m in Sources */, @@ -1345,7 +1348,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.trzj.VoidLinkPreview; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG VOIDLINK_PREVIEW $(inherited)"; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; @@ -1395,6 +1398,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.trzj.VoidLinkPreview; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "VOIDLINK_PREVIEW $(inherited)"; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; @@ -1529,10 +1533,13 @@ GCC_PREFIX_HEADER = "VoidLink/Limelight-Prefix.pch"; HEADER_SEARCH_PATHS = ( "$(inherited)", + "$(PROJECT_DIR)/third_party/moonlight-audio-haptics/include", /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include, "$(PROJECT_DIR)/moonlight-common/moonlight-common-c/src", "$(SDKROOT)/usr/include/libxml2/**", - "$(PROJECT_DIR)/libs/**", + "$(PROJECT_DIR)/libs/FFmpeg/include", + "$(PROJECT_DIR)/libs/SDL2/include", + "$(PROJECT_DIR)/libs/opus/include/opus", "$(PROJECT_DIR)/Limelight/Input/CustomOSC", "$(PROJECT_DIR)/Limelight/ViewControllers/CustomOSCViewControl", "$(PROJECT_DIR)/Limelight/ViewControllers/CustomOSCViewControl/Cells", @@ -1597,10 +1604,13 @@ GCC_PREFIX_HEADER = "VoidLink/Limelight-Prefix.pch"; HEADER_SEARCH_PATHS = ( "$(inherited)", + "$(PROJECT_DIR)/third_party/moonlight-audio-haptics/include", /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include, "$(PROJECT_DIR)/moonlight-common/moonlight-common-c/src", "$(SDKROOT)/usr/include/libxml2/**", - "$(PROJECT_DIR)/libs/**", + "$(PROJECT_DIR)/libs/FFmpeg/include", + "$(PROJECT_DIR)/libs/SDL2/include", + "$(PROJECT_DIR)/libs/opus/include/opus", "$(PROJECT_DIR)/Limelight/Input/CustomOSC", "$(PROJECT_DIR)/Limelight/ViewControllers/CustomOSCViewControl", "$(PROJECT_DIR)/Limelight/ViewControllers/CustomOSCViewControl/Cells", diff --git a/VoidLink/Input/ControllerNavigator.swift b/VoidLink/Input/ControllerNavigator.swift index bfbe043c..7fab20ce 100644 --- a/VoidLink/Input/ControllerNavigator.swift +++ b/VoidLink/Input/ControllerNavigator.swift @@ -360,7 +360,7 @@ final class ControllerNavigator: NSObject { guard uiNavigationDelegate === delegate else { return } /* if previousUINavigationDelegate is StreamFrameViewController { - ControllerSupport.sharedInstance().reinitiatePrimaryController() + ControllerSupport.sharedInstance()?.reinitiatePrimaryController() return } */ @@ -378,7 +378,7 @@ final class ControllerNavigator: NSObject { // GamepadNavigationIllustrationHud.updateNavigationElements(controllerMouseNavigationElements) return } - ControllerSupport.sharedInstance().reinitiatePrimaryController() + ControllerSupport.sharedInstance()?.reinitiatePrimaryController() GamepadNavigationIllustrationHud.updateHud() return; } @@ -769,7 +769,7 @@ final class ControllerNavigator: NSObject { radialMenuDelayTimer?.clean() stickReleasedInRadialMenu = false if radialMenuView == nil, uiNavigationDelegate is StreamFrameViewController { - ControllerSupport.sharedInstance().sendNavigationButtonPress() + ControllerSupport.sharedInstance()?.sendNavigationButtonPress() return } radialMenuView?.dismiss() diff --git a/VoidLink/Input/ControllerSupport.h b/VoidLink/Input/ControllerSupport.h index b97103d2..ae44d42a 100644 --- a/VoidLink/Input/ControllerSupport.h +++ b/VoidLink/Input/ControllerSupport.h @@ -26,7 +26,7 @@ @property (readonly) bool shallDisableGyroHotSwitch; -+(ControllerSupport*) sharedInstance; ++(nullable ControllerSupport*) sharedInstance; -(id) initWithConfig:(StreamConfiguration*)streamConfig delegate:(id)delegate; -(void) connectionEstablished; @@ -55,6 +55,15 @@ -(void) setAdaptiveTriggers:(uint16_t)controllerNumber eventFlags:(uint8_t)eventFlags typeLeft:(uint8_t)typeLeft typeRight:(uint8_t)typeRight left:(const uint8_t*)left right:(const uint8_t*)right; +-(void) renderDualSenseHaptics:(uint16_t)controllerNumber + leftAmplitude:(float)leftAmplitude + leftSharpness:(float)leftSharpness + leftTransient:(float)leftTransient + rightAmplitude:(float)rightAmplitude + rightSharpness:(float)rightSharpness + rightTransient:(float)rightTransient + delaySeconds:(double)delaySeconds; +-(void) cancelScheduledDualSenseHaptics; -(void) updateTimerStateForOsc; -(uint16_t) getActiveGamepadMask; diff --git a/VoidLink/Input/ControllerSupport.m b/VoidLink/Input/ControllerSupport.m index 8f87e5cc..31ce2b9f 100644 --- a/VoidLink/Input/ControllerSupport.m +++ b/VoidLink/Input/ControllerSupport.m @@ -139,6 +139,8 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger, bool _controllerGyroSwitchHoldPressed; ControllerGyroSwitchMode _gyroSwitchMode; + uint64_t _authoredHapticsGeneration; + __weak MotionHandler* motionHandler; } @@ -560,6 +562,55 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger, }); } +-(void) renderDualSenseHaptics:(uint16_t)controllerNumber + leftAmplitude:(float)leftAmplitude + leftSharpness:(float)leftSharpness + leftTransient:(float)leftTransient + rightAmplitude:(float)rightAmplitude + rightSharpness:(float)rightSharpness + rightTransient:(float)rightTransient + delaySeconds:(double)delaySeconds { + __block uint64_t generation; + @synchronized(self) { + generation = _authoredHapticsGeneration; + } + dispatch_block_t renderBlock = ^{ + @synchronized(self) { + if (generation != self->_authoredHapticsGeneration) { + return; + } + } + if (@available(iOS 14.5, tvOS 14.5, *)) { + VoidController* controller = [self->_voidControllers objectForKey:@(controllerNumber)]; + if (controller == nil || + ![controller.gamepad.extendedGamepad isKindOfClass:[GCDualSenseGamepad class]]) { + return; + } + + [controller.lowFreqMotor setAuthoredAmplitude:leftAmplitude + sharpness:leftSharpness + transientStrength:leftTransient]; + [controller.highFreqMotor setAuthoredAmplitude:rightAmplitude + sharpness:rightSharpness + transientStrength:rightTransient]; + } + }; + + if (delaySeconds > 0.0) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delaySeconds * NSEC_PER_SEC)), + dispatch_get_main_queue(), renderBlock); + } + else { + dispatch_async(dispatch_get_main_queue(), renderBlock); + } +} + +-(void) cancelScheduledDualSenseHaptics { + @synchronized(self) { + _authoredHapticsGeneration++; + } +} + -(void) updateLeftStick:(VoidController*)controller x:(short)x y:(short)y { @synchronized(controller) { @@ -990,6 +1041,12 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger, } } } + + if (@available(iOS 14.5, tvOS 14.5, *)) { + if ([controller.extendedGamepad isKindOfClass:[GCDualSenseGamepad class]]) { + capabilities |= LI_CCAP_DS5_HAPTICS_PCM; + } + } // Detect supported haptics localities if (controller.haptics) { @@ -2063,6 +2120,8 @@ double rc_expo(double x, double expo) { -(void) cleanup { + [ControllerUtil stopAllDualSenseHaptics]; + if (VLSharedControllerSupport == self) { VLSharedControllerSupport = nil; } diff --git a/VoidLink/Input/ControllerUtil.swift b/VoidLink/Input/ControllerUtil.swift index c518391d..2f89cf67 100644 --- a/VoidLink/Input/ControllerUtil.swift +++ b/VoidLink/Input/ControllerUtil.swift @@ -11,6 +11,9 @@ import GameController import Combine import CoreGraphics import Foundation +#if !VOIDLINK_PREVIEW +import Collections +#endif @objc enum ControllerElementType: Int { case button @@ -260,6 +263,261 @@ import Foundation @objc class ControllerUtil: NSObject { @objc static weak var delegate: ControllerUtilDelegate? + +#if !VOIDLINK_PREVIEW + private struct DualSensePCMChunk { + let controllerNumber: UInt16 + let flags: UInt8 + let sequenceNumber: UInt32 + let presentationTimeUs: UInt64 + let frameCount: UInt16 + let pcmData: Data + } + + private final class AuthoredEngineState { + var engine: OpaquePointer? + var renderHostAnchorUs: UInt64? + var renderLocalAnchor = 0.0 + + init?() { + var config = AhAuthoredConfig() + guard ah_authored_config_init(&config, 48_000) == AH_STATUS_OK else { + return nil + } + guard ah_authored_create(&config, &engine) == AH_STATUS_OK, engine != nil else { + return nil + } + } + + deinit { + ah_authored_destroy(engine) + } + + func renderDelay(for timestampUs: UInt64, resetTimeline: Bool) -> TimeInterval { + let now = ProcessInfo.processInfo.systemUptime + if resetTimeline || renderHostAnchorUs == nil { + renderHostAnchorUs = timestampUs + renderLocalAnchor = now + 0.01 + } + let anchor = renderHostAnchorUs ?? timestampUs + let hostDelta = timestampUs >= anchor ? Double(timestampUs - anchor) / 1_000_000.0 : 0 + return min(max(renderLocalAnchor + hostDelta - now, 0), 0.08) + } + } + + private static let dualSenseHapticsLock = NSLock() + private static let dualSenseHapticsWorker = DispatchQueue( + label: "com.voidlink.dualsense-authored-haptics", + qos: .userInteractive + ) + private static let maxQueuedDualSensePCMChunks = 8 + private static var dualSensePCMQueue = Deque() + private static var dualSenseWorkerScheduled = false + private static var dualSenseForcedDiscontinuities = Set() + private static var dualSenseAuthoredEngines = [UInt16: AuthoredEngineState]() + + @objc(enqueueDualSenseHapticsPCMWithControllerNumber:flags:sequenceNumber:presentationTimeUs:frameCount:pcmData:) + static func enqueueDualSenseHapticsPCM( + controllerNumber: UInt16, + flags: UInt8, + sequenceNumber: UInt32, + presentationTimeUs: UInt64, + frameCount: UInt16, + pcmData: Data + ) { + let expectedByteCount = Int(frameCount) * 2 * MemoryLayout.size + guard frameCount <= 480, pcmData.count == expectedByteCount else { + NSLog("DualSense haptics: rejected malformed PCM chunk for controller %u", controllerNumber) + return + } + + let chunk = DualSensePCMChunk( + controllerNumber: controllerNumber, + flags: flags, + sequenceNumber: sequenceNumber, + presentationTimeUs: presentationTimeUs, + frameCount: frameCount, + pcmData: pcmData + ) + + dualSenseHapticsLock.lock() + if dualSensePCMQueue.count >= maxQueuedDualSensePCMChunks, + let dropped = dualSensePCMQueue.popFirst() { + dualSenseForcedDiscontinuities.insert(dropped.controllerNumber) + } + dualSensePCMQueue.append(chunk) + let shouldScheduleWorker = !dualSenseWorkerScheduled + dualSenseWorkerScheduled = true + dualSenseHapticsLock.unlock() + + if shouldScheduleWorker { + dualSenseHapticsWorker.async { + drainDualSensePCMQueue() + } + } + } + + @objc static func stopAllDualSenseHaptics() { + ControllerSupport.sharedInstance()?.cancelScheduledDualSenseHaptics() + + dualSenseHapticsLock.lock() + dualSensePCMQueue.removeAll(keepingCapacity: true) + dualSenseForcedDiscontinuities.removeAll(keepingCapacity: true) + dualSenseHapticsLock.unlock() + + dualSenseHapticsWorker.async { + let controllerNumbers = Array(dualSenseAuthoredEngines.keys) + dualSenseAuthoredEngines.removeAll(keepingCapacity: false) + for controllerNumber in controllerNumbers { + renderDualSenseHaptics(controllerNumber: controllerNumber, left: nil, right: nil, delay: 0) + } + } + } + + private static func drainDualSensePCMQueue() { + while true { + dualSenseHapticsLock.lock() + guard var chunk = dualSensePCMQueue.popFirst() else { + dualSenseWorkerScheduled = false + dualSenseHapticsLock.unlock() + return + } + if dualSenseForcedDiscontinuities.remove(chunk.controllerNumber) != nil { + chunk = DualSensePCMChunk( + controllerNumber: chunk.controllerNumber, + flags: chunk.flags | 0x04, + sequenceNumber: chunk.sequenceNumber, + presentationTimeUs: chunk.presentationTimeUs, + frameCount: chunk.frameCount, + pcmData: chunk.pcmData + ) + } + dualSenseHapticsLock.unlock() + processDualSensePCMChunk(chunk) + } + } + + private static func processDualSensePCMChunk(_ chunk: DualSensePCMChunk) { + let isStreamStart = (chunk.flags & 0x01) != 0 + let isStreamEnd = (chunk.flags & 0x02) != 0 + let isDiscontinuity = (chunk.flags & 0x04) != 0 + + if isStreamStart || dualSenseAuthoredEngines[chunk.controllerNumber] == nil { + dualSenseAuthoredEngines[chunk.controllerNumber] = AuthoredEngineState() + NSLog("DualSense haptics: stream started for controller %u", chunk.controllerNumber) + } + guard let state = dualSenseAuthoredEngines[chunk.controllerNumber], + let engine = state.engine else { + NSLog("DualSense haptics: failed to create authored engine for controller %u", chunk.controllerNumber) + return + } + + var inputFlags: UInt32 = 0 + if isStreamStart { inputFlags |= UInt32(AH_AUTHORED_INPUT_STREAM_START) } + if isDiscontinuity { inputFlags |= UInt32(AH_AUTHORED_INPUT_DISCONTINUITY) } + if isStreamEnd { inputFlags |= UInt32(AH_AUTHORED_INPUT_STREAM_END) } + + var samples = [Int16](repeating: 0, count: Int(chunk.frameCount) * 2) + chunk.pcmData.withUnsafeBytes { rawBuffer in + let bytes = rawBuffer.bindMemory(to: UInt8.self) + for index in samples.indices { + let offset = index * 2 + let value = UInt16(bytes[offset]) | (UInt16(bytes[offset + 1]) << 8) + samples[index] = Int16(bitPattern: value) + } + } + + let outputCapacity = ah_authored_get_max_output_frames( + engine, + UInt32(chunk.frameCount), + inputFlags + ) + var outputFrames = [AhAuthoredHapticFrame]( + repeating: AhAuthoredHapticFrame(), + count: Int(outputCapacity) + ) + var outputCount: UInt32 = 0 + let status = samples.withUnsafeBufferPointer { sampleBuffer -> AhStatus in + var input = AhAuthoredProcessInput() + input.struct_size = UInt32(MemoryLayout.size) + input.interleaved_pcm = sampleBuffer.baseAddress + input.frame_count = UInt32(chunk.frameCount) + input.flags = inputFlags + input.first_sample_time_us = chunk.presentationTimeUs + input.sequence_number = chunk.sequenceNumber + return ah_authored_process_i16( + engine, + &input, + &outputFrames, + outputCapacity, + &outputCount + ) + } + + guard status == AH_STATUS_OK || status == AH_STATUS_OUTPUT_AVAILABLE else { + NSLog("DualSense haptics: authored conversion failed with status %d", status) + ah_authored_reset(engine) + renderDualSenseHaptics(controllerNumber: chunk.controllerNumber, left: nil, right: nil, delay: 0) + return + } + + var finalRenderDelay = 0.0 + for index in 0..= 0.05f) { + CHHapticEventParameter* intensity = [[CHHapticEventParameter alloc] + initWithParameterID:CHHapticEventParameterIDHapticIntensity value:transientStrength]; + CHHapticEventParameter* eventSharpness = [[CHHapticEventParameter alloc] + initWithParameterID:CHHapticEventParameterIDHapticSharpness value:sharpness]; + CHHapticEvent* event = [[CHHapticEvent alloc] + initWithEventType:CHHapticEventTypeHapticTransient + parameters:@[intensity, eventSharpness] + relativeTime:0]; + CHHapticPattern* pattern = [[CHHapticPattern alloc] + initWithEvents:@[event] parameters:@[] error:&error]; + id transientPlayer = pattern == nil ? nil : + [_hapticEngine createPlayerWithPattern:pattern error:&error]; + [transientPlayer startAtTime:CHHapticTimeImmediate error:&error]; + if (error != nil) { + Log(LOG_W, @"Controller %d: Authored haptic transient failed: %@", _playerIndex, error); + } + } +} + -(id) initDeviceEngineContextWithGamepad:(GCController*)gamepad API_AVAILABLE(ios(13.0), tvos(13.0)) { NSError *error = nil; _hapticEngine = [[CHHapticEngine alloc] initAndReturnError:&error]; diff --git a/VoidLink/Stream/Connection.m b/VoidLink/Stream/Connection.m index 6949cbf5..45b64346 100644 --- a/VoidLink/Stream/Connection.m +++ b/VoidLink/Stream/Connection.m @@ -588,6 +588,22 @@ void ClSetAdaptiveTriggers(uint16_t controllerNumber, uint8_t eventFlags, right:right]; } +void ClDs5HapticsPcm(const LI_DS5_HAPTICS_PCM_FRAME* frame) +{ + if (frame == NULL || frame->pcmData == NULL) { + return; + } + + // common-c owns pcmData only for this callback, so copy before returning. + NSData* pcmData = [NSData dataWithBytes:frame->pcmData length:frame->pcmDataLength]; + [ControllerUtil enqueueDualSenseHapticsPCMWithControllerNumber:frame->controllerNumber + flags:frame->flags + sequenceNumber:frame->sequenceNumber + presentationTimeUs:frame->presentationTimeUs + frameCount:frame->frameCount + pcmData:pcmData]; +} + -(void) terminate { // Interrupt any action blocking LiStartConnection(). This is @@ -597,6 +613,7 @@ void ClSetAdaptiveTriggers(uint16_t controllerNumber, uint8_t eventFlags, LiInterruptConnection(); [audioPlayerNode stop]; [audioEngine stop]; + [ControllerUtil stopAllDualSenseHaptics]; [[NSNotificationCenter defaultCenter] removeObserver:self]; // We dispatch this async to get out because this can be invoked @@ -750,6 +767,7 @@ void ClSetAdaptiveTriggers(uint16_t controllerNumber, uint8_t eventFlags, _clCallbacks.setMotionEventState = ClSetMotionEventState; _clCallbacks.setControllerLED = ClSetControllerLED; _clCallbacks.setAdaptiveTriggers = ClSetAdaptiveTriggers; + _clCallbacks.ds5HapticsPcm = ClDs5HapticsPcm; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleAudioSessionInterruption:) diff --git a/VoidLink/VoidLink-Bridging-Header.h b/VoidLink/VoidLink-Bridging-Header.h index e6166c71..38e80c6e 100644 --- a/VoidLink/VoidLink-Bridging-Header.h +++ b/VoidLink/VoidLink-Bridging-Header.h @@ -3,6 +3,7 @@ // #include +#include #import "SettingsViewController.h" #import "MainFrameViewController.h" #import "LoadingFrameViewController.h"