From c3941df7e900647b489836c8eaae0cbf134f065b Mon Sep 17 00:00:00 2001 From: TrueZhuanjia Date: Mon, 8 Sep 2025 16:47:27 +0800 Subject: [PATCH] - feat: mic redirection (with Sunshine-Foundation) --- VoidLink.xcodeproj/project.pbxproj | 4 + VoidLink/Base.lproj/iPad.storyboard | 28 +- VoidLink/Base.lproj/iPhone.storyboard | 20 + VoidLink/Database/DataManager.h | 1 + VoidLink/Database/DataManager.m | 2 + VoidLink/Database/TemporarySettings.h | 1 + VoidLink/Database/TemporarySettings.m | 1 + VoidLink/Input/MicHandler.swift | 460 ++++++++++++++++++ VoidLink/Limelight-Info.plist | 6 +- .../VoidLink v1.0.xcdatamodel/contents | 1 + VoidLink/Localization/Localizable.xcstrings | 104 ++++ VoidLink/Stream/Connection.m | 2 + VoidLink/Stream/StreamConfiguration.h | 1 + .../ViewControllers/MainFrameViewController.m | 4 + .../ViewControllers/SettingsViewController.h | 9 +- .../ViewControllers/SettingsViewController.m | 60 ++- .../StreamFrameViewController.h | 1 + .../StreamFrameViewController.m | 27 +- VoidLink/VoidLink-Bridging-Header.h | 2 + VoidLink/mul.lproj/iPad.xcstrings | 30 ++ VoidLink/mul.lproj/iPhone.xcstrings | 30 ++ moonlight-common/moonlight-common-c | 2 +- 22 files changed, 781 insertions(+), 15 deletions(-) create mode 100644 VoidLink/Input/MicHandler.swift diff --git a/VoidLink.xcodeproj/project.pbxproj b/VoidLink.xcodeproj/project.pbxproj index 86213210..5d404ea8 100644 --- a/VoidLink.xcodeproj/project.pbxproj +++ b/VoidLink.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 1829CE3F2E2154DF0074A99A /* SceneDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 1829CE3E2E2154DF0074A99A /* SceneDelegate.m */; }; 18341A042DEB662C0094E5F2 /* FixedTintImageView.m in Sources */ = {isa = PBXBuildFile; fileRef = 18341A032DEB662C0094E5F2 /* FixedTintImageView.m */; }; + 1850C82A2E667E4000845308 /* MicHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1850C8292E667E4000845308 /* MicHandler.swift */; }; 187F4B282E04658A00EECC59 /* WidgetPanelStackView.m in Sources */ = {isa = PBXBuildFile; fileRef = 187F4B272E04658A00EECC59 /* WidgetPanelStackView.m */; }; 1888C1922E24C33100108730 /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1888C1912E24C33100108730 /* AboutViewController.swift */; }; 1888C1932E24C41000108730 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1888C1902E24C2C800108730 /* AboutView.swift */; }; @@ -155,6 +156,7 @@ 1829CE3E2E2154DF0074A99A /* SceneDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SceneDelegate.m; sourceTree = ""; }; 18341A022DEB65AB0094E5F2 /* FixedTintImageView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FixedTintImageView.h; sourceTree = ""; }; 18341A032DEB662C0094E5F2 /* FixedTintImageView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FixedTintImageView.m; sourceTree = ""; }; + 1850C8292E667E4000845308 /* MicHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicHandler.swift; sourceTree = ""; }; 187F4B232E04633100EECC59 /* WidgetPanelStackView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WidgetPanelStackView.h; sourceTree = ""; }; 187F4B272E04658A00EECC59 /* WidgetPanelStackView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = WidgetPanelStackView.m; sourceTree = ""; }; 1888C1902E24C2C800108730 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -687,6 +689,7 @@ 9819CC21254F180F008A7C8E /* AbsoluteTouchHandler.m */, 988FCD3F293B091B003050E2 /* KeyboardInputField.h */, 988FCD40293B091B003050E2 /* KeyboardInputField.m */, + 1850C8292E667E4000845308 /* MicHandler.swift */, 84E1CDEB2C4F67D300547904 /* CommandManager.swift */, 84203F222C5F24C00032EEED /* OnScreenWidgetView.swift */, ); @@ -1014,6 +1017,7 @@ FB89462919F646E200339C8A /* mkcert.c in Sources */, FB9AFD281A7C84ED00872C98 /* HttpResponse.m in Sources */, FB89463019F646E200339C8A /* StreamConfiguration.m in Sources */, + 1850C82A2E667E4000845308 /* MicHandler.swift in Sources */, FBD3495319FF36FB002D2A60 /* SWRevealViewController.m in Sources */, 84203F232C5F24C00032EEED /* OnScreenWidgetView.swift in Sources */, 97E21BB52E2186A3000D5DCA /* imgui_impl_metal.mm in Sources */, diff --git a/VoidLink/Base.lproj/iPad.storyboard b/VoidLink/Base.lproj/iPad.storyboard index 43893ff1..c71299a0 100644 --- a/VoidLink/Base.lproj/iPad.storyboard +++ b/VoidLink/Base.lproj/iPad.storyboard @@ -1012,6 +1012,24 @@ + + + + + + + + + + + + + @@ -1074,6 +1092,8 @@ + + @@ -1790,7 +1810,7 @@ - + @@ -1799,13 +1819,13 @@ - + - + - + diff --git a/VoidLink/Base.lproj/iPhone.storyboard b/VoidLink/Base.lproj/iPhone.storyboard index 4db4ff38..204887ba 100644 --- a/VoidLink/Base.lproj/iPhone.storyboard +++ b/VoidLink/Base.lproj/iPhone.storyboard @@ -1708,6 +1708,24 @@ + + + + + + + + + + + + + @@ -1769,6 +1787,8 @@ + + diff --git a/VoidLink/Database/DataManager.h b/VoidLink/Database/DataManager.h index ffe6a8be..94e412b8 100644 --- a/VoidLink/Database/DataManager.h +++ b/VoidLink/Database/DataManager.h @@ -79,6 +79,7 @@ typedef NS_ENUM(NSInteger, SettingsMenuMode) { multiController:(BOOL)multiController swapABXYButtons:(BOOL)swapABXYButtons audioOnPC:(BOOL)audioOnPC + redirectMic:(BOOL)redirectMic preferredCodec:(uint32_t)preferredCodec enableYUV444:(BOOL)enableYUV444 enablePIP:(BOOL)enablePIP diff --git a/VoidLink/Database/DataManager.m b/VoidLink/Database/DataManager.m index 6140fd17..c31e76be 100644 --- a/VoidLink/Database/DataManager.m +++ b/VoidLink/Database/DataManager.m @@ -80,6 +80,7 @@ multiController:(BOOL)multiController swapABXYButtons:(BOOL)swapABXYButtons audioOnPC:(BOOL)audioOnPC + redirectMic:(BOOL)redirectMic preferredCodec:(uint32_t)preferredCodec enableYUV444:(BOOL)enableYUV444 enablePIP:(BOOL)enablePIP @@ -127,6 +128,7 @@ settingsToSave.multiController = multiController; settingsToSave.swapABXYButtons = swapABXYButtons; settingsToSave.playAudioOnPC = audioOnPC; + settingsToSave.redirectMic = redirectMic; settingsToSave.preferredCodec = preferredCodec; settingsToSave.enableYUV444 = enableYUV444; settingsToSave.enablePIP = enablePIP; diff --git a/VoidLink/Database/TemporarySettings.h b/VoidLink/Database/TemporarySettings.h index bfa557b2..d48e2fa5 100644 --- a/VoidLink/Database/TemporarySettings.h +++ b/VoidLink/Database/TemporarySettings.h @@ -54,6 +54,7 @@ @property (nonatomic) BOOL multiController; @property (nonatomic) BOOL swapABXYButtons; @property (nonatomic) BOOL playAudioOnPC; +@property (nonatomic) BOOL redirectMic; @property (nonatomic) BOOL optimizeGames; @property (nonatomic) BOOL enableHdr; @property (nonatomic) BOOL btMouseSupport; diff --git a/VoidLink/Database/TemporarySettings.m b/VoidLink/Database/TemporarySettings.m index f7446b48..6fde4369 100644 --- a/VoidLink/Database/TemporarySettings.m +++ b/VoidLink/Database/TemporarySettings.m @@ -90,6 +90,7 @@ self.enablePIP = settings.enablePIP; self.frameQueueSize = settings.frameQueueSize; self.playAudioOnPC = settings.playAudioOnPC; + self.redirectMic = settings.redirectMic; self.enableHdr = settings.enableHdr; self.optimizeGames = settings.optimizeGames; self.multiController = settings.multiController; diff --git a/VoidLink/Input/MicHandler.swift b/VoidLink/Input/MicHandler.swift new file mode 100644 index 00000000..49c47449 --- /dev/null +++ b/VoidLink/Input/MicHandler.swift @@ -0,0 +1,460 @@ +// +// MicHandler.swift +// VoidLink +// +// Created by True砖家 on 2025/9/2. +// Copyright © 2025 True砖家 on Bilibili. All rights reserved. + +import AVFoundation + +@objc public protocol MicHandlerDelegate: AnyObject { + @objc optional func micHandlerDidFinishPlayback(_ handler: MicHandler) + @objc optional func micHandler(_ handler: MicHandler, didFailWithError error: NSError) +} + +@objcMembers +public class MicHandler: NSObject { + + private let engine = AVAudioEngine() + private let playerNode = AVAudioPlayerNode() + private var audioSink: Any? + private var micInputFormat: AVAudioFormat! + private var isRecording = false + + private var pcm16Buffer: [Int16] = [] + private let bufferQueue = DispatchQueue(label: "pcm.buffer.queue") + private var timer: DispatchSourceTimer? + + /* + private var recordedBuffers: [AVAudioPCMBuffer] = [] + private var recordedPCM16: [Data] = [] + private var recordedOpusPackets: [Data] = [] + */ + + private var opusEncoder: OpaquePointer? + private var opusDecoder: OpaquePointer? + + private var sequenceNumber: UInt16 = 0 + private let ssrc: UInt32 = 0x12345678 + + private var globalTimestamp: TimeInterval = 0 + + + public weak var delegate: MicHandlerDelegate? + + public override init() { + super.init() + do { + try configureSession() + try configureEngine() + } catch { + notify(error) + } + } + + + /* ----------- Mic permission -------------*/ + /// 请求麦克风权限 + /// - Parameter completion: 可选 block,如果为 nil 且未授权,会弹窗提示跳转系统设置 + @objc static func requestPermission(_ completion: ((Bool) -> Void)? = nil) { + let permission = AVAudioSession.sharedInstance().recordPermission + switch permission { + case .granted: + completion?(true) + case .denied: + if let callback = completion { + callback(false) + } else { + showSettingsAlert() + } + case .undetermined: + AVAudioSession.sharedInstance().requestRecordPermission { granted in + DispatchQueue.main.async { + if granted { + completion?(true) + } else { + if let callback = completion { + callback(false) + } else { + showSettingsAlert() + } + } + } + } + @unknown default: + if let callback = completion { + callback(false) + } else { + showSettingsAlert() + } + } + } + + /// 配置音频会话 + @objc static func configureAudioSession() throws { + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) + try audioSession.setActive(true) + } + + /// 弹窗提示用户跳转系统设置(英文版) + private static func showSettingsAlert() { + guard let topVC = topViewController() else { return } + let alert = UIAlertController( + title: SwiftLocalizationHelper.localizedString(forKey: "Microphone Permission") , + message: SwiftLocalizationHelper.localizedString(forKey: "micPermissionTip"), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: SwiftLocalizationHelper.localizedString(forKey: "Cancel"), style: .cancel, handler: nil)) + alert.addAction(UIAlertAction(title: SwiftLocalizationHelper.localizedString(forKey: "Go to Settings") , style: .default, handler: { _ in + openSettings() + })) + topVC.present(alert, animated: true, completion: nil) + } + + /// 打开系统设置 + @objc static func openSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString), + UIApplication.shared.canOpenURL(url) else { return } + UIApplication.shared.open(url) + } + + /// 检查麦克风权限状态(返回 Int,OC 可用) + @objc static func permissionGranted() -> Bool { + return AVAudioSession.sharedInstance().recordPermission == AVAudioSession.RecordPermission.granted + } + + /// 获取最顶层 UIViewController + private static func topViewController(base: UIViewController? = UIApplication.shared.keyWindow?.rootViewController) -> UIViewController? { + if let nav = base as? UINavigationController { + return topViewController(base: nav.visibleViewController) + } + if let tab = base as? UITabBarController, let selected = tab.selectedViewController { + return topViewController(base: selected) + } + if let presented = base?.presentedViewController { + return topViewController(base: presented) + } + return base + } + /* ----------------------------------------*/ + + + /* ----------- Audio Session -------------*/ + + private func configureOpus(sampleRate: Int32, channels: Int) throws { + var err: Int32 = 0 + guard let enc = opus_encoder_create(sampleRate, Int32(channels), OPUS_APPLICATION_VOIP, &err), err == OPUS_OK else { + throw NSError(domain: "Opus", code: Int(err), userInfo: nil) + } + + opusEncoder = enc + + guard let dec = opus_decoder_create(sampleRate, Int32(channels), &err), err == OPUS_OK else { + throw NSError(domain: "Opus", code: Int(err), userInfo: nil) + } + opusDecoder = dec + + // Optional: But defaults are fine. Only change when needed: + opus_encoder_ctl_wrapper(enc, Int32(OPUS_SET_BITRATE_REQUEST), opus_int32(64000)) // Set bitrate + opus_encoder_ctl_wrapper(enc, Int32(OPUS_SET_COMPLEXITY_REQUEST), opus_int32(5)) // Set complexity + opus_encoder_ctl_wrapper(enc, Int32(OPUS_SET_SIGNAL_REQUEST), OPUS_SIGNAL_MUSIC) // Set signal type + } + + @objc public func startTapping() { + // recordedBuffers.removeAll() + isRecording = true + /* + DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { [weak self] in + guard let self = self else { return } + self.isRecording = false + self.stop() + self.playbackRecorded() + self.playbackOpus() + }*/ + } + + @objc public func stopTapping(stopEngine:Bool) { + isRecording = false + // engine.inputNode.removeTap(onBus: 0) + if(stopEngine){ + playerNode.stop() + engine.stop() + } + } + + private func configureSession() throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, mode: .videoRecording, options: [.mixWithOthers, .defaultToSpeaker, .allowBluetooth]) + try session.setActive(true) + } + + private func sendOpusFrame() { + bufferQueue.sync { + if pcm16Buffer.count >= 960 { + let chunk = Array(pcm16Buffer.prefix(960)) + var packet = [UInt8](repeating: 0, count: 4000) + guard let enc = self.opusEncoder else {return} + let outBytes = opus_encode(enc, chunk, 960, &packet, Int32(packet.count)) + sendMicrophoneData(packet, outBytes) + pcm16Buffer.removeFirst(960) + } + } + } + + private func configureEngine() throws { + let input = engine.inputNode + micInputFormat = input.inputFormat(forBus: 0) + + try self.configureOpus(sampleRate: Int32(micInputFormat.sampleRate), channels: Int(micInputFormat.channelCount)) + + + let pcm16Format = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: micInputFormat.sampleRate, channels: micInputFormat.channelCount, interleaved: true) + + let converter = AVAudioConverter(from: micInputFormat, to: pcm16Format!) + + if #available(iOS 13.0, tvOS 13.0, *) { + try AVAudioSession.sharedInstance().setPreferredIOBufferDuration(0.02) + + let sinkNode = AVAudioSinkNode { timestamp, frameCount, audioBufferList -> OSStatus in + // 访问 AudioBufferList 数据 + // print("sink interval: \((CACurrentMediaTime()-self.globalTimestamp)*1000),frameCount: \(frameCount)") + // self.globalTimestamp = CACurrentMediaTime() + + guard self.isRecording else { return noErr} + + let abl = audioBufferList.pointee.mBuffers + guard let data = abl.mData?.assumingMemoryBound(to: Float.self) else { return noErr } + let samples = UnsafeBufferPointer(start: data, count: Int(frameCount)) + + // 转 float32 -> int16 + var chunk = [Int16](repeating: 0, count: samples.count) + for i in 0...allocate(capacity: maxFrames * channels) + defer { pcmBuf.deallocate() } + + let frameCount = opus_decode(dec, + [UInt8](packet), + Int32(packet.count), + pcmBuf, + Int32(maxFrames), + 0) + if frameCount < 0 { + print("Opus decode error: \(frameCount)") + continue + } + + // 构造源格式 AVAudioPCMBuffer (PCM16) + let sourceFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, + sampleRate: micInputFormat.sampleRate, + channels: micInputFormat.channelCount, + interleaved: true)! + + guard let sourceBuffer = AVAudioPCMBuffer(pcmFormat: sourceFormat, + frameCapacity: AVAudioFrameCount(frameCount)) else { continue } + sourceBuffer.frameLength = AVAudioFrameCount(frameCount) + + // 填充 PCM16 数据到 sourceBuffer + let srcPointer = sourceBuffer.int16ChannelData![0] + for i in 0...size * channels) + guard let buf = AVAudioPCMBuffer(pcmFormat: micInputFormat, frameCapacity: AVAudioFrameCount(frameCount)) else { continue } + buf.frameLength = buf.frameCapacity + + // PCM16 -> Float32 + pcmData.withUnsafeBytes { rawBuf in + let pcmPtr = rawBuf.bindMemory(to: Int16.self).baseAddress! + for i in 0.. AVAudioPCMBuffer? { + guard let out = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: buffer.frameLength) else { return nil } + out.frameLength = buffer.frameLength + let channels = Int(format.channelCount) + for ch in 0.. AVAudioPCMBuffer? { + let total = buffers.reduce(0) { $0 + $1.frameLength } + guard let out = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: total) else { return nil } + out.frameLength = total + + var writePos: AVAudioFrameCount = 0 + let channels = Int(format.channelCount) + + for b in buffers { + let frames = Int(b.frameLength) + for ch in 0..Bluetooth access allows Moonlight to connect to Citrix X1 mice. NSBluetoothPeripheralUsageDescription Bluetooth access allows Moonlight to connect to Citrix X1 mice. + NSMicrophoneUsageDescription + VoidLink uses microphone to stream your voice to gaming PC. NSBonjourServices _nvstream._tcp NSLocalNetworkUsageDescription - Moonlight uses the local network to connect to your gaming PC for streaming. + VoidLink uses the local network to connect to your gaming PC for streaming. NSMotionUsageDescription - Moonlight uses the device Gyro and Acceleration data to emulate motion controls when streaming. + VoidLink uses the device Gyro and Acceleration data to emulate motion controls when streaming. UIApplicationSceneManifest UISceneConfigurations diff --git a/VoidLink/Limelight.xcdatamodeld/VoidLink v1.0.xcdatamodel/contents b/VoidLink/Limelight.xcdatamodeld/VoidLink v1.0.xcdatamodel/contents index e2aadfd8..1f8d307e 100644 --- a/VoidLink/Limelight.xcdatamodeld/VoidLink v1.0.xcdatamodel/contents +++ b/VoidLink/Limelight.xcdatamodeld/VoidLink v1.0.xcdatamodel/contents @@ -51,6 +51,7 @@ + diff --git a/VoidLink/Localization/Localizable.xcstrings b/VoidLink/Localization/Localizable.xcstrings index bec77480..3647f7be 100644 --- a/VoidLink/Localization/Localizable.xcstrings +++ b/VoidLink/Localization/Localizable.xcstrings @@ -2177,6 +2177,29 @@ } } }, + "Go to Settings" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "前往系统设置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "前往系統設定" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "前往系統設定" + } + } + } + }, "Got it!" : { "extractionState" : "manual", "localizations" : { @@ -2788,6 +2811,58 @@ } } }, + "micPermissionTip" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "- Please grant microphone permission in your system settings, then re-enable “Redirect Mic.”\n\n- Currently, mic redirection is only supported with Sunshine-Foundation (a Sunshine fork).\nThis feature is experimental, and the microphone audio stream is NOT ENCRYPTED at this stage.\n\n- iOS 12 mic redirection has known audio quality issue that can’t be fixed due to technical reasons." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "- 请前往系统设置授予 App 麦克风权限,再回到此菜单启用麦克风重定向。\n\n- 目前麦克风重定向仅支持 Sunshine 基地版,且为实验性功能。 须注意,现阶段的 Mic 音频流 【未经加密】。\n\n- iOS 12 的麦克风重定向存在因技术原因无法解决的音质问题。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "- 請前往系統設定授予 App 麥克風權限,再回到此選單啟用麥克風轉送。\n\n- 目前麥克風轉送僅支援 Sunshine 基地版,且為實驗性功能。須注意,現階段的 Mic 音訊流【未經加密】。\n\n- iOS 12 的麥克風轉送因技術原因存在無法解決的音質問題。" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "- 請前往系統設定授予 App 麥克風權限,再回到此選單啟用麥克風轉送。\n\n- 目前麥克風轉送僅支援 Sunshine 基地版,且為實驗性功能。須注意,現階段的 Mic 音訊流【未經加密】。\n\n- iOS 12 的麥克風轉送因技術原因存在無法解決的音質問題。" + } + } + } + }, + "Microphone Permission" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "麦克风权限" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "麥克風權限" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "麥克風權限" + } + } + } + }, "Minimum stick offset (0~32766)" : { "extractionState" : "manual", "localizations" : { @@ -4154,6 +4229,35 @@ } } }, + "redirectMicStackTip" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "- Currently, mic redirection is only supported with Sunshine-Foundation (a Sunshine fork).\nThis feature is experimental, and the microphone audio stream is NOT ENCRYPTED at this stage.\n\n- iOS 12 mic redirection has known audio quality issue that can’t be fixed due to technical reasons." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "- 目前麦克风重定向仅支持 Sunshine 基地版,且为实验性功能。 须注意,现阶段的 Mic 音频流 【未经加密】。\n\n- iOS 12 的麦克风重定向存在因技术原因无法解决的音质问题。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "- 目前麥克風轉送僅支援 Sunshine 基地版,且為實驗性功能。須注意,現階段的 Mic 音訊流【未經加密】。\n\n- iOS 12 的麥克風轉送因技術原因存在無法解決的音質問題。" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "- 目前麥克風轉送僅支援 Sunshine 基地版,且為實驗性功能。須注意,現階段的 Mic 音訊流【未經加密】。\n\n- iOS 12 的麥克風轉送因技術原因存在無法解決的音質問題。" + } + } + } + }, "Remove" : { "extractionState" : "manual", "localizations" : { diff --git a/VoidLink/Stream/Connection.m b/VoidLink/Stream/Connection.m index 1c8cb8c6..767032cf 100644 --- a/VoidLink/Stream/Connection.m +++ b/VoidLink/Stream/Connection.m @@ -18,6 +18,7 @@ #include "Limelight.h" #include "opus_multistream.h" +#include "VoidLink-Swift.h" @implementation Connection { SERVER_INFORMATION _serverInfo; @@ -468,6 +469,7 @@ void ClSetControllerLED(uint16_t controllerNumber, uint8_t r, uint8_t g, uint8_t _streamConfig.bitrate = config.bitRate; _streamConfig.supportedVideoFormats = config.supportedVideoFormats; _streamConfig.audioConfiguration = config.audioConfiguration; + _streamConfig.redirectMic = config.redirectMic && [MicHandler permissionGranted]; // Since we require iOS 12 or above, we're guaranteed to be running // on a 64-bit device with ARMv8 crypto instructions, so we don't diff --git a/VoidLink/Stream/StreamConfiguration.h b/VoidLink/Stream/StreamConfiguration.h index 3be8136b..13fe8348 100644 --- a/VoidLink/Stream/StreamConfiguration.h +++ b/VoidLink/Stream/StreamConfiguration.h @@ -31,6 +31,7 @@ @property int gamepadMask; @property BOOL optimizeGameSettings; @property BOOL playAudioOnPC; +@property BOOL redirectMic; @property BOOL swapABXYButtons; @property BOOL asyncNativeTouchPriority; @property int gyroMode; diff --git a/VoidLink/ViewControllers/MainFrameViewController.m b/VoidLink/ViewControllers/MainFrameViewController.m index ee3aff84..e442bcd6 100644 --- a/VoidLink/ViewControllers/MainFrameViewController.m +++ b/VoidLink/ViewControllers/MainFrameViewController.m @@ -820,6 +820,7 @@ static NSMutableSet* hostList; _streamConfig.bitRate = [streamSettings.bitrate intValue]; _streamConfig.optimizeGameSettings = streamSettings.optimizeGames; _streamConfig.playAudioOnPC = streamSettings.playAudioOnPC; + _streamConfig.redirectMic = streamSettings.redirectMic; _streamConfig.swapABXYButtons = streamSettings.swapABXYButtons; _streamConfig.asyncNativeTouchPriority = streamSettings.asyncNativeTouchPriority; // new streamConfig segment _streamConfig.gyroMode = [streamSettings.gyroMode intValue]; @@ -1193,6 +1194,9 @@ static NSMutableSet* hostList; // Enable frame pacing mode selector only if not in stream view AND not in performance mode BOOL shouldEnableFramePacing = !_settingsExpandedInStreamView && (settingsViewController.renderingBackendSelector.selectedSegmentIndex != RENDER_METAL); [settingsViewController.framePacingModeSelector setEnabled:shouldEnableFramePacing]; + // Disable mic switch if sunshine does not support mic redirection + [settingsViewController.redirectMicSwitch setEnabled:!_settingsExpandedInStreamView||streamFrameViewController.micStreamInitialized]; + if(_settingsExpandedInStreamView && !streamFrameViewController.micStreamInitialized) [settingsViewController.redirectMicSwitch setOn:false]; } - (void)revealController:(SWRevealViewController *)revealController didMoveToPosition:(FrontViewPosition)position { diff --git a/VoidLink/ViewControllers/SettingsViewController.h b/VoidLink/ViewControllers/SettingsViewController.h index b57faf1e..4fc82921 100644 --- a/VoidLink/ViewControllers/SettingsViewController.h +++ b/VoidLink/ViewControllers/SettingsViewController.h @@ -16,7 +16,8 @@ #import "CustomEdgeSlideGestureRecognizer.h" #import "MenuSectionView.h" -@interface SettingsViewController : UIViewController +@interface SettingsViewController : UIViewController + @property (strong, nonatomic) IBOutlet UINavigationBar *navigationBar; @property (strong, nonatomic) UIStackView *parentStack; @property (strong, nonatomic) IBOutlet UIStackView *resolutionStack; @@ -125,6 +126,12 @@ @property (strong, nonatomic) IBOutlet UIStackView *emulatedControllerTypeStack; @property (strong, nonatomic) IBOutlet UISegmentedControl *emulatedControllerTypeSelector; +@property (strong, nonatomic) IBOutlet UIStackView *redirectMicStack; +@property (strong, nonatomic) IBOutlet UISwitch *redirectMicSwitch; + + +@property (nonatomic, strong) MicHandler *micHandler; + #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wunguarded-availability" diff --git a/VoidLink/ViewControllers/SettingsViewController.m b/VoidLink/ViewControllers/SettingsViewController.m index cfdaf773..ec937a86 100644 --- a/VoidLink/ViewControllers/SettingsViewController.m +++ b/VoidLink/ViewControllers/SettingsViewController.m @@ -303,6 +303,21 @@ BOOL isCustomResolution(int resolutionSelected) { [self updateResolutionDisplayLabel]; } +- (void)checkAndRequestMicPermission{ + if(![MicHandler permissionGranted]) [MicHandler requestPermission:nil]; +/* + AVAudioSessionRecordPermission permission = [MicHandler permissionGranted]; + switch (permission) { + case AVAudioSessionRecordPermissionGranted: + NSLog(@"AVAudioSessionRecordPermissionGranted"); + break; + case AVAudioSessionRecordPermissionDenied: + default: + [MicHandler requestPermission:nil]; + NSLog(@"AVAudioSessionRecordPermissionDenied"); + }*/ +} + // this will also be called back when device orientation changes - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coordinator { [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; @@ -315,8 +330,25 @@ BOOL isCustomResolution(int resolutionSelected) { }); } + +- (void)micHandlerDidFinishPlayback:(MicHandler *)handler { + NSLog(@"Playback finished"); +} + +- (void)micHandler:(MicHandler *)handler didFailWithError:(NSError *)error { + NSLog(@"Mic error: %@", error); +} + - (void)viewWillAppear:(BOOL)animated{ [super viewWillAppear:NO]; + + /* + [self checkAndRequestMicPermission]; + self.micHandler = [MicHandler new]; + [self.micHandler startTapping]; + */ + + [self updateParentStackHorizontalConstraints]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(deviceOrientationDidChange:) // handle orientation change since i made portrait mode available @@ -634,6 +666,7 @@ BOOL isCustomResolution(int resolutionSelected) { } [self addSetting:self.audioOnPcStack ofId:@"audioOnPcStack" withInfoTag:NO withDynamicLabel:NO to:audioSection]; + [self addSetting:self.redirectMicStack ofId:@"redirectMicStack" withInfoTag:YES withDynamicLabel:NO to:audioSection]; [self addSetting:self.audioConfigStack ofId:@"audioConfigStack" withInfoTag:NO withDynamicLabel:NO to:audioSection]; [audioSection addToParentStack:_parentStack]; [audioSection setExpanded:YES]; @@ -1098,8 +1131,10 @@ BOOL isCustomResolution(int resolutionSelected) { tipText = [LocalizationHelper localizedStringForKey:@"framePacingStackTip"]; showOnlineDocAction = false; } - - + if([sender.superview.accessibilityIdentifier isEqualToString: @"redirectMicStack"]){ + tipText = [LocalizationHelper localizedStringForKey:@"redirectMicStackTip"]; + showOnlineDocAction = false; + } UIAlertController *tipsAlertController = [UIAlertController alertControllerWithTitle: [LocalizationHelper localizedStringForKey:@"Tips"] message:tipText preferredStyle:UIAlertControllerStyleAlert]; @@ -1486,6 +1521,10 @@ BOOL isCustomResolution(int resolutionSelected) { [self.audioOnPcSwitch setOn:currentSettings.playAudioOnPC]; + [self.redirectMicSwitch setOn:currentSettings.redirectMic]; + [self.redirectMicSwitch addTarget:self action:@selector(redirectMicSwitchFlipped:) forControlEvents:UIControlEventValueChanged]; + [self redirectMicSwitchFlipped:self.redirectMicSwitch]; + _lastSelectedResolutionIndex = resolution; [self.resolutionSelector setSelectedSegmentIndex:resolution]; [self.resolutionSelector addTarget:self action:@selector(newResolutionChosen) forControlEvents:UIControlEventValueChanged]; @@ -1807,7 +1846,7 @@ BOOL isCustomResolution(int resolutionSelected) { oswDynamicLabel.hidden = !customOscEnabled; NSLog(@"oswDynamicLabel.hidden %d", oswDynamicLabel.hidden); [self handleOswGestureChange]; - if(customOscEnabled && !justEnteredSettingsView) { + if(customOscEnabled && !justEnteredSettingsView && !self.mainFrameViewController.settingsExpandedInStreamView) { // [self.keyboardToggleFingerNumSlider setValue:3.0]; // [self keyboardToggleFingerNumSliderMoved]; [self showCustomOswTip]; @@ -1959,6 +1998,10 @@ BOOL isCustomResolution(int resolutionSelected) { } } +- (void)redirectMicSwitchFlipped:(UISwitch* )sender{ + if(sender.isOn) [self checkAndRequestMicPermission]; +} + - (void)enableOswForNativeTouchSwitchFlipped:(UISwitch *)sender{ //self.onScreenWidgetStack.hidden = !sender.isOn; [self setHidden:!sender.isOn forStack:_onScreenWidgetStack]; @@ -2309,11 +2352,16 @@ BOOL isCustomResolution(int resolutionSelected) { [self findDynamicLabelFromStack:_graphOpacityStack].text = [LocalizationHelper localizedStringForKey:@" %d%% opacity ",(int)sender.value]; } -- (void) saveSettings { - +- (void)preSavingActions{ if(self.mainFrameViewController.settingsExpandedInStreamView){ [self.mainFrameViewController requestForBitrate:(uint32_t)_bitrate]; } + + if(![MicHandler permissionGranted]) [self.redirectMicSwitch setOn:false]; +} + +- (void) saveSettings { + [self preSavingActions]; DataManager* dataMan = [[DataManager alloc] init]; Settings* currentSettings = [dataMan retrieveSettings]; @@ -2350,6 +2398,7 @@ BOOL isCustomResolution(int resolutionSelected) { NSInteger gyroMode = self.gyroModeSelector.selectedSegmentIndex; NSInteger emulatedControllerType = [self segmentIndexToControllerType:self.emulatedControllerTypeSelector.selectedSegmentIndex]; //self.emulatedControllerTypeSelector.selectedSegmentIndex; BOOL audioOnPC = self.audioOnPcSwitch.isOn; + BOOL redirectMic = self.redirectMicSwitch.isOn; uint32_t preferredCodec = [self getChosenCodecPreference]; BOOL enableYUV444 = self.yuv444Switch.isOn; BOOL enablePIP = self.pipSwitch.isOn; @@ -2395,6 +2444,7 @@ BOOL isCustomResolution(int resolutionSelected) { multiController:multiController swapABXYButtons:swapABXYButtons audioOnPC:audioOnPC + redirectMic:redirectMic preferredCodec:preferredCodec enableYUV444:enableYUV444 enablePIP:enablePIP diff --git a/VoidLink/ViewControllers/StreamFrameViewController.h b/VoidLink/ViewControllers/StreamFrameViewController.h index 2cc609e9..b499e67e 100644 --- a/VoidLink/ViewControllers/StreamFrameViewController.h +++ b/VoidLink/ViewControllers/StreamFrameViewController.h @@ -35,6 +35,7 @@ @property (nonatomic, strong) AVPictureInPictureController *pipController API_AVAILABLE(ios(9.0)); @property (nonatomic, strong) AVPictureInPictureControllerContentSource *pipContentSource API_AVAILABLE(ios(15.0)); // Needed for iOS 15+ layer-based PiP @property (nonatomic, assign) MainFrameViewController *mainFrameViewcontroller; +@property (nonatomic, assign) bool micStreamInitialized; @property (nonatomic, strong) MetalViewController *metalViewController; @property (nonatomic, strong) ImGuiRenderer *imguiView; diff --git a/VoidLink/ViewControllers/StreamFrameViewController.m b/VoidLink/ViewControllers/StreamFrameViewController.m index 0a29c7bf..59e79257 100644 --- a/VoidLink/ViewControllers/StreamFrameViewController.m +++ b/VoidLink/ViewControllers/StreamFrameViewController.m @@ -88,6 +88,7 @@ CustomTapGestureRecognizer *_oscLayoutTapRecoginizer; LayoutOnScreenControlsViewController *_layoutOnScreenControlsVC; ToolboxViewController* toolBoxViewController; + MicHandler* micHandler; #else UITapGestureRecognizer *_menuTapGestureRecognizer; UITapGestureRecognizer *_menuDoubleTapGestureRecognizer; @@ -356,7 +357,12 @@ [self->_streamView disableOnScreenControls]; //don't know why but this must be called outside the streamview class, just put it here. execute in streamview class cause hang [self.mainFrameViewcontroller reloadStreamConfig]; // reload streamconfig - NSLog(@"viewJustloaded: %d", viewJustLoaded); + if([MicHandler permissionGranted] && _settings.redirectMic){ + [micHandler startTapping]; + } + else [micHandler stopTappingWithStopEngine:false]; + + NSLog(@"viewJustloaded: %d, redirectMic:%d", viewJustLoaded, _settings.redirectMic); if(!viewJustLoaded) [_controllerSupport updateControllerSupport:self.streamConfig delegate:self]; else viewJustLoaded = false; // reload controllerSupport obj, this is mandatory for OSC reload,especially when the stream view is launched without OSC @@ -919,6 +925,8 @@ _extWindow = nil; + if(_streamConfig.redirectMic) [micHandler stopTappingWithStopEngine:true]; + self.mainFrameViewcontroller.settingsExpandedInStreamView = false; // reset this flag to false } @@ -1111,7 +1119,6 @@ - (void)disconnectRemoteSession { Log(LOG_I, @"Settings view disconnect the session in stream view"); [self returnToMainFrame]; - } - (void)disconnectAndQuitApp{ @@ -1250,6 +1257,22 @@ } - (void) stageComplete:(const char*)stageName { + _micStreamInitialized = false; + if(strcmp(stageName, "mic stream establishment")==0){ + if(_streamConfig.redirectMic){ + dispatch_after((int64_t)(5 * NSEC_PER_SEC), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ + dispatch_async(dispatch_get_main_queue(), ^{ + self->_micStreamInitialized = true; + self->micHandler = [MicHandler new]; + [self->micHandler startTapping]; + }); + }); + } + } + if(strcmp(stageName, "mic stream unsupported or unintialized")==0){ + NSLog(@"blablablala"); + _micStreamInitialized = false; + } } - (void) stageFailed:(const char*)stageName withError:(int)errorCode portTestFlags:(int)portTestFlags { diff --git a/VoidLink/VoidLink-Bridging-Header.h b/VoidLink/VoidLink-Bridging-Header.h index da39191d..6b6ed4c5 100644 --- a/VoidLink/VoidLink-Bridging-Header.h +++ b/VoidLink/VoidLink-Bridging-Header.h @@ -5,4 +5,6 @@ #include #import "OnScreenControls.h" #import "OnScreenButtonState.h" +#import "opus.h" +#import "opus_defines.h" // #import "RelativeTouchHandler.h" diff --git a/VoidLink/mul.lproj/iPad.xcstrings b/VoidLink/mul.lproj/iPad.xcstrings index 96bfe3a9..f922fb66 100644 --- a/VoidLink/mul.lproj/iPad.xcstrings +++ b/VoidLink/mul.lproj/iPad.xcstrings @@ -1987,6 +1987,36 @@ } } }, + "jaM-yS-ldb.text" : { + "comment" : "Class = \"UILabel\"; text = \"Redirect Mic (Experimental)\"; ObjectID = \"jaM-yS-ldb\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Redirect Mic (Experimental)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "麦克风重定向(实验性)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "麥克風轉送(實驗性)" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "麥克風轉送(實驗性)" + } + } + } + }, "JaY-XA-1V6.segmentTitles[0]" : { "comment" : "Class = \"UISegmentedControl\"; JaY-XA-1V6.segmentTitles[0] = \"Xbox 360\"; ObjectID = \"JaY-XA-1V6\";", "extractionState" : "extracted_with_value", diff --git a/VoidLink/mul.lproj/iPhone.xcstrings b/VoidLink/mul.lproj/iPhone.xcstrings index ef4f3e27..5d4a893b 100644 --- a/VoidLink/mul.lproj/iPhone.xcstrings +++ b/VoidLink/mul.lproj/iPhone.xcstrings @@ -2107,6 +2107,36 @@ } } }, + "nz2-5g-YSQ.text" : { + "comment" : "Class = \"UILabel\"; text = \"Redirect Mic (Experimental)\"; ObjectID = \"nz2-5g-YSQ\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Redirect Mic (Experimental)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "麦克风重定向(实验性)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "麥克風轉送(實驗性)" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "麥克風轉送(實驗性)" + } + } + } + }, "o9I-Gt-YRd.text" : { "comment" : "Class = \"UILabel\"; text = \"Slide Activated\"; ObjectID = \"o9I-Gt-YRd\";", "extractionState" : "extracted_with_value", diff --git a/moonlight-common/moonlight-common-c b/moonlight-common/moonlight-common-c index 7f3a3a02..a3825173 160000 --- a/moonlight-common/moonlight-common-c +++ b/moonlight-common/moonlight-common-c @@ -1 +1 @@ -Subproject commit 7f3a3a02c7955a9aff2eafb63989b833aff146cc +Subproject commit a3825173402aad2d4ff1993b69c5bbb631c9ed19