From 08ae56b88e8126879bd275ec1437c2f2874c6f51 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:01:38 +0900 Subject: [PATCH 01/17] options --- Multiplatform/Controllers/AppContext.swift | 117 ++++++++++++++++++- Multiplatform/Views/AudioControlsPanel.swift | 97 +++++++++++++++ Multiplatform/Views/RoomView.swift | 58 ++++++++- 3 files changed, 268 insertions(+), 4 deletions(-) diff --git a/Multiplatform/Controllers/AppContext.swift b/Multiplatform/Controllers/AppContext.swift index 62bb471..ebaae96 100644 --- a/Multiplatform/Controllers/AppContext.swift +++ b/Multiplatform/Controllers/AppContext.swift @@ -101,6 +101,30 @@ final class AppContext: NSObject, ObservableObject { } } + @Published var runtimeEchoCancellation: Bool = true + @Published var runtimeNoiseSuppression: Bool = true + @Published var runtimeAutoGainControl: Bool = true + @Published var runtimeHighPassFilter: Bool = false + @Published var runtimeEchoCancellationMode: AudioProcessingMode = .automatic + @Published var runtimeNoiseSuppressionMode: AudioProcessingMode = .automatic + @Published var runtimeAutoGainControlMode: AudioProcessingMode = .automatic + @Published var runtimeHighPassFilterMode: AudioProcessingMode = .automatic + @Published var runtimeAudioProcessingStatus: String = "" + @Published var builtInAudioProcessingSummary: String = "" + + var runtimeAudioProcessingOptions: AudioProcessingOptions { + AudioProcessingOptions( + echoCancellation: runtimeEchoCancellation, + autoGainControl: runtimeAutoGainControl, + noiseSuppression: runtimeNoiseSuppression, + highPassFilter: runtimeHighPassFilter, + echoCancellationMode: runtimeEchoCancellationMode, + autoGainControlMode: runtimeAutoGainControlMode, + noiseSuppressionMode: runtimeNoiseSuppressionMode, + highPassFilterMode: runtimeHighPassFilterMode + ) + } + @Published var micMuteMode: MicrophoneMuteMode = .voiceProcessing { didSet { do { @@ -127,7 +151,10 @@ final class AppContext: NSObject, ObservableObject { didSet { Task { do { - try await AudioManager.shared.setRecordingAlwaysPreparedMode(isRecordingAlwaysPreparedMode) + try await AudioManager.shared.setRecordingAlwaysPreparedMode( + isRecordingAlwaysPreparedMode, + audioProcessingOptions: runtimeAudioProcessingOptions + ) } catch { print("Failed to set recording always prepared mode: \(error)") } @@ -226,11 +253,12 @@ final class AppContext: NSObject, ObservableObject { isVoiceProcessingEnabled = AudioManager.shared.isVoiceProcessingEnabled isVoiceProcessingAGCEnabled = AudioManager.shared.isVoiceProcessingAGCEnabled isRecordingAlwaysPreparedMode = AudioManager.shared.isRecordingAlwaysPreparedMode + refreshBuiltInAudioProcessingState() updateAudioDeviceSelections() } } -private extension AppContext { +extension AppContext { func updateAudioDeviceSelections() { if !inputDevices.contains(where: { $0.id == inputDevice.id }) { if let defaultInput = inputDevices.first(where: { $0.isDefault }) { @@ -248,6 +276,91 @@ private extension AppContext { } } } + + func refreshBuiltInAudioProcessingState() { + let state = AudioManager.shared.builtInAudioProcessingState + let engineAvailability = AudioManager.shared.engineAvailability + let topology = switch state.topology { + case .independent: "independent" + case .echoCancellationAndNoiseSuppressionCoupled: "AEC/NS coupled" + } + let audioProcessingOptions = runtimeAudioProcessingOptions + builtInAudioProcessingSummary = [ + "LiveKit audio processing diagnostics", + "generatedAt: \(ISO8601DateFormatter().string(from: Date()))", + "platform: \(platformName)", + "", + "App voice processing controls", + " voiceProcessingEnabled: \(boolSummary(AudioManager.shared.isVoiceProcessingEnabled))", + " voiceProcessingBypassed: \(boolSummary(AudioManager.shared.isVoiceProcessingBypassed))", + " voiceProcessingAGCEnabled: \(boolSummary(AudioManager.shared.isVoiceProcessingAGCEnabled))", + "", + "Runtime AudioProcessingOptions request", + " echoCancellation: \(componentRequest(audioProcessingOptions.echoCancellation, audioProcessingOptions.echoCancellationMode))", + " noiseSuppression: \(componentRequest(audioProcessingOptions.noiseSuppression, audioProcessingOptions.noiseSuppressionMode))", + " autoGainControl: \(componentRequest(audioProcessingOptions.autoGainControl, audioProcessingOptions.autoGainControlMode))", + " highPassFilter: \(componentRequest(audioProcessingOptions.highPassFilter, audioProcessingOptions.highPassFilterMode))", + "", + "Audio engine", + " engineRunning: \(boolSummary(AudioManager.shared.isEngineRunning))", + " inputAvailable requested: \(boolSummary(isAudioEngineInputAvailable))", + " inputAvailable effective: \(boolSummary(engineAvailability.isInputAvailable))", + " outputAvailable requested: \(boolSummary(isAudioEngineOutputAvailable))", + " outputAvailable effective: \(boolSummary(engineAvailability.isOutputAvailable))", + "", + "Built-in audio processing topology", + " topology: \(topology)", + " echoCancellation: \(componentSummary(state.echoCancellation))", + " noiseSuppression: \(componentSummary(state.noiseSuppression))", + " autoGainControl: \(componentSummary(state.autoGainControl))", + "", + "Apple Voice Processing I/O state", + " voiceProcessingEnabled requested: \(optionalSummary(state.isVoiceProcessingEnabledRequested))", + " voiceProcessingEnabled active: \(optionalSummary(state.isVoiceProcessingEnabledActive))", + " voiceProcessingBypassed requested: \(optionalSummary(state.isVoiceProcessingBypassedRequested))", + " voiceProcessingBypassed active: \(optionalSummary(state.isVoiceProcessingBypassedActive))", + " voiceProcessingAGC requested: \(optionalSummary(state.isVoiceProcessingAGCEnabledRequested))", + " voiceProcessingAGC active: \(optionalSummary(state.isVoiceProcessingAGCEnabledActive))", + "", + "Notes", + " requested values come from the ADM state.", + " active values come from the platform input node when available.", + " active values can be unknown before the input path is configured.", + " subscribe-only playback does not configure the input path.", + ].joined(separator: "\n") + } + + func componentSummary(_ state: BuiltInAudioProcessingComponentState) -> String { + "available: \(boolSummary(state.isAvailable)), " + + "requested: \(optionalSummary(state.isRequested)), " + + "active: \(optionalSummary(state.isActive))" + } + + func optionalSummary(_ value: Bool?) -> String { + value.map { $0 ? "on" : "off" } ?? "unknown" + } + + func boolSummary(_ value: Bool) -> String { + value ? "on" : "off" + } + + func componentRequest(_ enabled: Bool, _ mode: AudioProcessingMode) -> String { + "enabled: \(boolSummary(enabled)), mode: \(mode.description)" + } + + var platformName: String { + #if os(iOS) + "iOS" + #elseif os(macOS) + "macOS" + #elseif os(visionOS) + "visionOS" + #elseif os(tvOS) + "tvOS" + #else + "unknown" + #endif + } } // MARK: - AudioClips diff --git a/Multiplatform/Views/AudioControlsPanel.swift b/Multiplatform/Views/AudioControlsPanel.swift index d69d1f1..9356453 100644 --- a/Multiplatform/Views/AudioControlsPanel.swift +++ b/Multiplatform/Views/AudioControlsPanel.swift @@ -17,9 +17,16 @@ import LiveKit import SwiftUI +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + #if !os(tvOS) struct AudioControlsPanel: View { @EnvironmentObject var appCtx: AppContext + @EnvironmentObject var room: Room private var inputDeviceSelection: Binding { Binding( @@ -88,6 +95,50 @@ struct AudioControlsPanel: View { Toggle("Auto gain control (AGC)", isOn: $appCtx.isVoiceProcessingAGCEnabled) } + Section(header: Text("Runtime Audio Processing")) { + Toggle("Echo cancellation", isOn: $appCtx.runtimeEchoCancellation) + modePicker("Echo mode", selection: $appCtx.runtimeEchoCancellationMode) + + Toggle("Noise suppression", isOn: $appCtx.runtimeNoiseSuppression) + modePicker("Noise mode", selection: $appCtx.runtimeNoiseSuppressionMode) + + Toggle("Auto gain control", isOn: $appCtx.runtimeAutoGainControl) + modePicker("Gain mode", selection: $appCtx.runtimeAutoGainControlMode) + + Toggle("High-pass filter", isOn: $appCtx.runtimeHighPassFilter) + modePicker("HPF mode", selection: $appCtx.runtimeHighPassFilterMode) + + HStack { + Button("Apply to local mic") { + applyRuntimeAudioProcessingOptions() + } + Button("Get diagnostics") { + appCtx.refreshBuiltInAudioProcessingState() + } + Button("Copy diagnostics") { + copyAudioProcessingDiagnostics() + } + } + .buttonStyle(.bordered) + + if !appCtx.runtimeAudioProcessingStatus.isEmpty { + Text(appCtx.runtimeAudioProcessingStatus) + .font(.caption) + .foregroundColor(.secondary) + } + + if !appCtx.builtInAudioProcessingSummary.isEmpty { + ScrollView { + Text(appCtx.builtInAudioProcessingSummary) + .font(.caption2.monospaced()) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .frame(minHeight: 180, maxHeight: 260) + } + } + Section(header: Text("Recording")) { Toggle("Always prepared", isOn: $appCtx.isRecordingAlwaysPreparedMode) Text("Keeps mic pipeline warmed for low-latency publish.") @@ -178,6 +229,52 @@ struct AudioControlsPanel: View { } private extension AudioControlsPanel { + var localMicrophoneTrack: LocalAudioTrack? { + room.localParticipant.audioTracks + .first(where: { $0.source == .microphone })? + .track as? LocalAudioTrack + } + + func modePicker(_ title: String, selection: Binding) -> some View { + Picker(title, selection: selection) { + ForEach(AudioProcessingMode.allCases, id: \.self) { mode in + Text(mode.description).tag(mode) + } + } + } + + func applyRuntimeAudioProcessingOptions() { + guard let localMicrophoneTrack else { + appCtx.runtimeAudioProcessingStatus = "Publish the microphone first." + appCtx.refreshBuiltInAudioProcessingState() + return + } + + do { + let result = try localMicrophoneTrack.setAudioProcessingOptions(appCtx.runtimeAudioProcessingOptions) + appCtx.runtimeAudioProcessingStatus = if result.message.isEmpty { + "Audio processing options: \(result.code)" + } else { + "Audio processing options: \(result.code): \(result.message)" + } + } catch { + appCtx.runtimeAudioProcessingStatus = "Failed: \(error)" + } + appCtx.refreshBuiltInAudioProcessingState() + } + + func copyAudioProcessingDiagnostics() { + appCtx.refreshBuiltInAudioProcessingState() + let diagnostics = appCtx.builtInAudioProcessingSummary + #if canImport(UIKit) + UIPasteboard.general.string = diagnostics + #elseif canImport(AppKit) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(diagnostics, forType: .string) + #endif + appCtx.runtimeAudioProcessingStatus = "Diagnostics copied." + } + func micMuteModeDescription(for mode: MicrophoneMuteMode) -> String { switch mode { case .voiceProcessing: diff --git a/Multiplatform/Views/RoomView.swift b/Multiplatform/Views/RoomView.swift index a87183b..cf76144 100644 --- a/Multiplatform/Views/RoomView.swift +++ b/Multiplatform/Views/RoomView.swift @@ -18,6 +18,10 @@ import LiveKit import SFSafeSymbols import SwiftUI +#if os(macOS) +import AppKit +#endif + #if !os(macOS) && !os(tvOS) let adaptiveMin = 170.0 let toolbarPlacement: ToolbarItemPlacement = .bottomBar @@ -76,6 +80,10 @@ struct RoomView: View { @State var isMicrophonePublishingBusy = false @State var isScreenSharePublishingBusy = false @State var isARCameraPublishingBusy = false + #if os(macOS) + @State private var audioPanelWidth: CGFloat = 420 + @State private var audioPanelDragStartWidth: CGFloat? + #endif @State private var screenPickerPresented = false @State private var publishOptionsPickerPresented = false @@ -101,6 +109,22 @@ struct RoomView: View { #if !os(tvOS) func audioControlsPanel(geometry: GeometryProxy) -> some View { + #if os(macOS) + let maxPanelWidth = min(760, max(320, geometry.size.width * 0.65)) + return HStack(spacing: 0) { + if !geometry.isTall { + audioPanelResizeHandle(maxWidth: maxPanelWidth) + } + AudioControlsPanel() + .background(Color.lkGray1) + .cornerRadius(8) + .frame( + minWidth: 0, + maxWidth: geometry.isTall ? .infinity : min(audioPanelWidth, maxPanelWidth) + ) + .frame(width: geometry.isTall ? nil : min(audioPanelWidth, maxPanelWidth)) + } + #else AudioControlsPanel() .background(Color.lkGray1) .cornerRadius(8) @@ -108,6 +132,34 @@ struct RoomView: View { minWidth: 0, maxWidth: geometry.isTall ? .infinity : 320 ) + #endif + } + #endif + + #if os(macOS) + func audioPanelResizeHandle(maxWidth: CGFloat) -> some View { + Rectangle() + .fill(Color.secondary.opacity(0.18)) + .frame(width: 6) + .contentShape(Rectangle()) + .gesture( + DragGesture() + .onChanged { value in + let startWidth = audioPanelDragStartWidth ?? audioPanelWidth + audioPanelDragStartWidth = startWidth + audioPanelWidth = min(max(startWidth - value.translation.width, 320), maxWidth) + } + .onEnded { _ in + audioPanelDragStartWidth = nil + } + ) + .onHover { hovering in + if hovering { + NSCursor.resizeLeftRight.push() + } else { + NSCursor.pop() + } + } } #endif @@ -359,8 +411,10 @@ struct RoomView: View { Task { isMicrophonePublishingBusy = true defer { Task { @MainActor in isMicrophonePublishingBusy = false } } - let options = AudioCaptureOptions(noiseSuppression: false, highpassFilter: false) - _ = try? await room.localParticipant.setMicrophone(enabled: !isMicrophoneEnabled, captureOptions: options) + let options = AudioCaptureOptions(audioProcessingOptions: appCtx.runtimeAudioProcessingOptions) + _ = try? await room.localParticipant.setMicrophone(enabled: !isMicrophoneEnabled, + captureOptions: options) + appCtx.refreshBuiltInAudioProcessingState() } }, label: { From 289be51c3bdd4fd6a60f3e33e6bae2f1a031b61b Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:50:07 +0900 Subject: [PATCH 02/17] update --- Multiplatform/Controllers/AppContext.swift | 150 ++++++++++++++++++- Multiplatform/Views/AudioControlsPanel.swift | 118 +++++++++++++-- Multiplatform/Views/RoomView.swift | 9 +- 3 files changed, 261 insertions(+), 16 deletions(-) diff --git a/Multiplatform/Controllers/AppContext.swift b/Multiplatform/Controllers/AppContext.swift index ebaae96..f004969 100644 --- a/Multiplatform/Controllers/AppContext.swift +++ b/Multiplatform/Controllers/AppContext.swift @@ -111,6 +111,8 @@ final class AppContext: NSObject, ObservableObject { @Published var runtimeHighPassFilterMode: AudioProcessingMode = .automatic @Published var runtimeAudioProcessingStatus: String = "" @Published var builtInAudioProcessingSummary: String = "" + @Published private(set) var appliedRuntimeAudioProcessingOptions = AudioProcessingOptions() + @Published var runtimeAudioProcessingEffectiveStates: [AudioProcessingEffectiveState] = [] var runtimeAudioProcessingOptions: AudioProcessingOptions { AudioProcessingOptions( @@ -258,7 +260,26 @@ final class AppContext: NSObject, ObservableObject { } } +struct AudioProcessingEffectiveState: Identifiable, Sendable { + let id: String + let title: String + let result: AudioProcessingEffectiveResult + let detail: String +} + +enum AudioProcessingEffectiveResult: String, Sendable { + case platform = "Platform" + case software = "Software" + case disabled = "Disabled" + case unavailable = "Unavailable" + case unknown = "Unknown" +} + extension AppContext { + func markRuntimeAudioProcessingOptionsApplied(_ options: AudioProcessingOptions? = nil) { + appliedRuntimeAudioProcessingOptions = options ?? runtimeAudioProcessingOptions + } + func updateAudioDeviceSelections() { if !inputDevices.contains(where: { $0.id == inputDevice.id }) { if let defaultInput = inputDevices.first(where: { $0.isDefault }) { @@ -284,7 +305,9 @@ extension AppContext { case .independent: "independent" case .echoCancellationAndNoiseSuppressionCoupled: "AEC/NS coupled" } - let audioProcessingOptions = runtimeAudioProcessingOptions + let audioProcessingOptions = appliedRuntimeAudioProcessingOptions + let effectiveStates = audioProcessingEffectiveStates(for: audioProcessingOptions, builtInState: state) + runtimeAudioProcessingEffectiveStates = effectiveStates builtInAudioProcessingSummary = [ "LiveKit audio processing diagnostics", "generatedAt: \(ISO8601DateFormatter().string(from: Date()))", @@ -295,12 +318,18 @@ extension AppContext { " voiceProcessingBypassed: \(boolSummary(AudioManager.shared.isVoiceProcessingBypassed))", " voiceProcessingAGCEnabled: \(boolSummary(AudioManager.shared.isVoiceProcessingAGCEnabled))", "", - "Runtime AudioProcessingOptions request", + "Runtime AudioProcessingOptions applied", " echoCancellation: \(componentRequest(audioProcessingOptions.echoCancellation, audioProcessingOptions.echoCancellationMode))", " noiseSuppression: \(componentRequest(audioProcessingOptions.noiseSuppression, audioProcessingOptions.noiseSuppressionMode))", " autoGainControl: \(componentRequest(audioProcessingOptions.autoGainControl, audioProcessingOptions.autoGainControlMode))", " highPassFilter: \(componentRequest(audioProcessingOptions.highPassFilter, audioProcessingOptions.highPassFilterMode))", "", + "Current effective processing", + " echoCancellation: \(effectiveStateSummary(effectiveStates[0]))", + " noiseSuppression: \(effectiveStateSummary(effectiveStates[1]))", + " autoGainControl: \(effectiveStateSummary(effectiveStates[2]))", + " highPassFilter: \(effectiveStateSummary(effectiveStates[3]))", + "", "Audio engine", " engineRunning: \(boolSummary(AudioManager.shared.isEngineRunning))", " inputAvailable requested: \(boolSummary(isAudioEngineInputAvailable))", @@ -325,17 +354,134 @@ extension AppContext { "Notes", " requested values come from the ADM state.", " active values come from the platform input node when available.", + " software effective state is inferred from the applied request and platform state.", " active values can be unknown before the input path is configured.", " subscribe-only playback does not configure the input path.", ].joined(separator: "\n") } + func audioProcessingEffectiveStates( + for options: AudioProcessingOptions, + builtInState state: BuiltInAudioProcessingState + ) -> [AudioProcessingEffectiveState] { + [ + audioProcessingEffectiveState( + id: "aec", + title: "AEC", + enabled: options.echoCancellation, + mode: options.echoCancellationMode, + platform: state.echoCancellation + ), + audioProcessingEffectiveState( + id: "ns", + title: "NS", + enabled: options.noiseSuppression, + mode: options.noiseSuppressionMode, + platform: state.noiseSuppression + ), + audioProcessingEffectiveState( + id: "agc", + title: "AGC", + enabled: options.autoGainControl, + mode: options.autoGainControlMode, + platform: state.autoGainControl + ), + audioProcessingEffectiveState( + id: "hpf", + title: "HPF", + enabled: options.highPassFilter, + mode: options.highPassFilterMode, + platform: nil + ), + ] + } + + func audioProcessingEffectiveState( + id: String, + title: String, + enabled: Bool, + mode: AudioProcessingMode, + platform: BuiltInAudioProcessingComponentState? + ) -> AudioProcessingEffectiveState { + if let platform, platform.isActive == true { + return AudioProcessingEffectiveState( + id: id, + title: title, + result: .platform, + detail: enabled ? "platform effect is active" : "platform effect is active despite disabled request" + ) + } + + guard enabled else { + return AudioProcessingEffectiveState( + id: id, + title: title, + result: .disabled, + detail: "disabled by applied request" + ) + } + + guard let platform else { + return AudioProcessingEffectiveState( + id: id, + title: title, + result: mode == .platform ? .unavailable : .software, + detail: mode == .platform ? "no platform backend exists for this component" : "software processing requested" + ) + } + + switch mode { + case .software: + return AudioProcessingEffectiveState( + id: id, + title: title, + result: .software, + detail: "software processing requested" + ) + case .automatic: + if !platform.isAvailable { + return AudioProcessingEffectiveState( + id: id, + title: title, + result: .software, + detail: "platform unavailable, using software fallback" + ) + } + if platform.isActive == false { + return AudioProcessingEffectiveState( + id: id, + title: title, + result: .software, + detail: "platform inactive, using software fallback" + ) + } + return AudioProcessingEffectiveState( + id: id, + title: title, + result: .unknown, + detail: "waiting for platform readback" + ) + case .platform: + let detail = platform.isAvailable ? "platform requested but not active" : "platform unavailable" + return AudioProcessingEffectiveState( + id: id, + title: title, + result: .unavailable, + detail: detail + ) + } + } + func componentSummary(_ state: BuiltInAudioProcessingComponentState) -> String { "available: \(boolSummary(state.isAvailable)), " + "requested: \(optionalSummary(state.isRequested)), " + "active: \(optionalSummary(state.isActive))" } + func effectiveStateSummary(_ state: AudioProcessingEffectiveState) -> String { + "result: \(state.result.rawValue), \(state.detail)" + } + func optionalSummary(_ value: Bool?) -> String { value.map { $0 ? "on" : "off" } ?? "unknown" } diff --git a/Multiplatform/Views/AudioControlsPanel.swift b/Multiplatform/Views/AudioControlsPanel.swift index 9356453..9efe94f 100644 --- a/Multiplatform/Views/AudioControlsPanel.swift +++ b/Multiplatform/Views/AudioControlsPanel.swift @@ -96,17 +96,21 @@ struct AudioControlsPanel: View { } Section(header: Text("Runtime Audio Processing")) { - Toggle("Echo cancellation", isOn: $appCtx.runtimeEchoCancellation) - modePicker("Echo mode", selection: $appCtx.runtimeEchoCancellationMode) + processingRow("Echo cancellation", + isOn: $appCtx.runtimeEchoCancellation, + mode: $appCtx.runtimeEchoCancellationMode) - Toggle("Noise suppression", isOn: $appCtx.runtimeNoiseSuppression) - modePicker("Noise mode", selection: $appCtx.runtimeNoiseSuppressionMode) + processingRow("Noise suppression", + isOn: $appCtx.runtimeNoiseSuppression, + mode: $appCtx.runtimeNoiseSuppressionMode) - Toggle("Auto gain control", isOn: $appCtx.runtimeAutoGainControl) - modePicker("Gain mode", selection: $appCtx.runtimeAutoGainControlMode) + processingRow("Auto gain control", + isOn: $appCtx.runtimeAutoGainControl, + mode: $appCtx.runtimeAutoGainControlMode) - Toggle("High-pass filter", isOn: $appCtx.runtimeHighPassFilter) - modePicker("HPF mode", selection: $appCtx.runtimeHighPassFilterMode) + processingRow("High-pass filter", + isOn: $appCtx.runtimeHighPassFilter, + mode: $appCtx.runtimeHighPassFilterMode) HStack { Button("Apply to local mic") { @@ -127,6 +131,10 @@ struct AudioControlsPanel: View { .foregroundColor(.secondary) } + if !appCtx.runtimeAudioProcessingEffectiveStates.isEmpty { + AudioProcessingEffectiveStateBox(states: appCtx.runtimeAudioProcessingEffectiveStates) + } + if !appCtx.builtInAudioProcessingSummary.isEmpty { ScrollView { Text(appCtx.builtInAudioProcessingSummary) @@ -228,6 +236,81 @@ struct AudioControlsPanel: View { } } +private struct AudioProcessingEffectiveStateBox: View { + let states: [AudioProcessingEffectiveState] + + private var columns: [GridItem] { + [GridItem(.adaptive(minimum: 120), spacing: 8, alignment: .top)] + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Current effective state") + .font(.caption.weight(.semibold)) + .foregroundColor(.secondary) + + LazyVGrid(columns: columns, alignment: .leading, spacing: 8) { + ForEach(states) { state in + AudioProcessingEffectiveStateItem(state: state) + } + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.secondary.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.secondary.opacity(0.18)) + ) + } +} + +private struct AudioProcessingEffectiveStateItem: View { + let state: AudioProcessingEffectiveState + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Circle() + .fill(state.result.tintColor) + .frame(width: 10, height: 10) + .padding(.top, 4) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(state.title) + .font(.caption.weight(.semibold)) + Text(state.result.rawValue) + .font(.caption) + .foregroundColor(.primary) + } + Text(state.detail) + .font(.caption2) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private extension AudioProcessingEffectiveResult { + var tintColor: Color { + switch self { + case .platform, .software: + return .green + case .disabled: + return .gray + case .unavailable: + return .red + case .unknown: + return .secondary + } + } +} + private extension AudioControlsPanel { var localMicrophoneTrack: LocalAudioTrack? { room.localParticipant.audioTracks @@ -235,11 +318,21 @@ private extension AudioControlsPanel { .track as? LocalAudioTrack } - func modePicker(_ title: String, selection: Binding) -> some View { - Picker(title, selection: selection) { - ForEach(AudioProcessingMode.allCases, id: \.self) { mode in - Text(mode.description).tag(mode) + func processingRow(_ title: String, isOn: Binding, mode: Binding) -> some View { + HStack(spacing: 12) { + Toggle(title, isOn: isOn) + .lineLimit(1) + + Spacer(minLength: 8) + + Picker("Mode", selection: mode) { + ForEach(AudioProcessingMode.allCases, id: \.self) { mode in + Text(mode.description).tag(mode) + } } + .labelsHidden() + .pickerStyle(.menu) + .frame(minWidth: 110, maxWidth: 150, alignment: .trailing) } } @@ -252,6 +345,7 @@ private extension AudioControlsPanel { do { let result = try localMicrophoneTrack.setAudioProcessingOptions(appCtx.runtimeAudioProcessingOptions) + appCtx.markRuntimeAudioProcessingOptionsApplied() appCtx.runtimeAudioProcessingStatus = if result.message.isEmpty { "Audio processing options: \(result.code)" } else { diff --git a/Multiplatform/Views/RoomView.swift b/Multiplatform/Views/RoomView.swift index cf76144..694fca4 100644 --- a/Multiplatform/Views/RoomView.swift +++ b/Multiplatform/Views/RoomView.swift @@ -411,9 +411,14 @@ struct RoomView: View { Task { isMicrophonePublishingBusy = true defer { Task { @MainActor in isMicrophonePublishingBusy = false } } - let options = AudioCaptureOptions(audioProcessingOptions: appCtx.runtimeAudioProcessingOptions) - _ = try? await room.localParticipant.setMicrophone(enabled: !isMicrophoneEnabled, + let isEnablingMicrophone = !isMicrophoneEnabled + let audioProcessingOptions = appCtx.runtimeAudioProcessingOptions + let options = AudioCaptureOptions(audioProcessingOptions: audioProcessingOptions) + _ = try? await room.localParticipant.setMicrophone(enabled: isEnablingMicrophone, captureOptions: options) + if isEnablingMicrophone { + appCtx.markRuntimeAudioProcessingOptionsApplied(audioProcessingOptions) + } appCtx.refreshBuiltInAudioProcessingState() } }, From f82a175247a02ad16f748e7343fed20b8a791b60 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:03:09 +0900 Subject: [PATCH 03/17] simplify --- Multiplatform/Controllers/AppContext.swift | 188 +++++++------------ Multiplatform/Views/AudioControlsPanel.swift | 5 +- Multiplatform/Views/RoomView.swift | 3 - 3 files changed, 70 insertions(+), 126 deletions(-) diff --git a/Multiplatform/Controllers/AppContext.swift b/Multiplatform/Controllers/AppContext.swift index f004969..8337106 100644 --- a/Multiplatform/Controllers/AppContext.swift +++ b/Multiplatform/Controllers/AppContext.swift @@ -111,7 +111,6 @@ final class AppContext: NSObject, ObservableObject { @Published var runtimeHighPassFilterMode: AudioProcessingMode = .automatic @Published var runtimeAudioProcessingStatus: String = "" @Published var builtInAudioProcessingSummary: String = "" - @Published private(set) var appliedRuntimeAudioProcessingOptions = AudioProcessingOptions() @Published var runtimeAudioProcessingEffectiveStates: [AudioProcessingEffectiveState] = [] var runtimeAudioProcessingOptions: AudioProcessingOptions { @@ -270,16 +269,12 @@ struct AudioProcessingEffectiveState: Identifiable, Sendable { enum AudioProcessingEffectiveResult: String, Sendable { case platform = "Platform" case software = "Software" + case softwareAndPlatform = "Software + Platform" case disabled = "Disabled" - case unavailable = "Unavailable" case unknown = "Unknown" } extension AppContext { - func markRuntimeAudioProcessingOptionsApplied(_ options: AudioProcessingOptions? = nil) { - appliedRuntimeAudioProcessingOptions = options ?? runtimeAudioProcessingOptions - } - func updateAudioDeviceSelections() { if !inputDevices.contains(where: { $0.id == inputDevice.id }) { if let defaultInput = inputDevices.first(where: { $0.isDefault }) { @@ -299,14 +294,19 @@ extension AppContext { } func refreshBuiltInAudioProcessingState() { - let state = AudioManager.shared.builtInAudioProcessingState + let runtimeState = AudioManager.shared.audioProcessingRuntimeState + let state = runtimeState?.builtIn ?? AudioManager.shared.builtInAudioProcessingState let engineAvailability = AudioManager.shared.engineAvailability let topology = switch state.topology { case .independent: "independent" case .echoCancellationAndNoiseSuppressionCoupled: "AEC/NS coupled" } - let audioProcessingOptions = appliedRuntimeAudioProcessingOptions - let effectiveStates = audioProcessingEffectiveStates(for: audioProcessingOptions, builtInState: state) + let audioProcessingOptions = runtimeAudioProcessingOptions + let effectiveStates: [AudioProcessingEffectiveState] = if let runtimeState { + audioProcessingEffectiveStates(for: runtimeState) + } else { + [] + } runtimeAudioProcessingEffectiveStates = effectiveStates builtInAudioProcessingSummary = [ "LiveKit audio processing diagnostics", @@ -318,17 +318,29 @@ extension AppContext { " voiceProcessingBypassed: \(boolSummary(AudioManager.shared.isVoiceProcessingBypassed))", " voiceProcessingAGCEnabled: \(boolSummary(AudioManager.shared.isVoiceProcessingAGCEnabled))", "", - "Runtime AudioProcessingOptions applied", + "Runtime AudioProcessingOptions controls", " echoCancellation: \(componentRequest(audioProcessingOptions.echoCancellation, audioProcessingOptions.echoCancellationMode))", " noiseSuppression: \(componentRequest(audioProcessingOptions.noiseSuppression, audioProcessingOptions.noiseSuppressionMode))", " autoGainControl: \(componentRequest(audioProcessingOptions.autoGainControl, audioProcessingOptions.autoGainControlMode))", " highPassFilter: \(componentRequest(audioProcessingOptions.highPassFilter, audioProcessingOptions.highPassFilterMode))", "", "Current effective processing", - " echoCancellation: \(effectiveStateSummary(effectiveStates[0]))", - " noiseSuppression: \(effectiveStateSummary(effectiveStates[1]))", - " autoGainControl: \(effectiveStateSummary(effectiveStates[2]))", - " highPassFilter: \(effectiveStateSummary(effectiveStates[3]))", + " available: \(boolSummary(runtimeState != nil))", + " echoCancellation: \(effectiveStateSummary(runtimeState?.echoCancellation))", + " noiseSuppression: \(effectiveStateSummary(runtimeState?.noiseSuppression))", + " autoGainControl: \(effectiveStateSummary(runtimeState?.autoGainControl))", + " highPassFilter: \(effectiveStateSummary(runtimeState?.highPassFilter))", + "", + "Publisher WebRTC runtime state", + " available: \(boolSummary(runtimeState != nil))", + " hasAudioProcessingModule: \(boolSummary(runtimeState?.hasAudioProcessingModule ?? false))", + " hasAudioProcessingConfig: \(boolSummary(runtimeState?.hasAudioProcessingConfig ?? false))", + " hasRequestedAudioProcessingOptions: \(boolSummary(runtimeState?.hasRequestedAudioProcessingOptions ?? false))", + " hasResolvedAudioProcessingOptions: \(boolSummary(runtimeState?.hasResolvedAudioProcessingOptions ?? false))", + " echoCancellation: \(runtimeComponentSummary(runtimeState?.echoCancellation))", + " noiseSuppression: \(runtimeComponentSummary(runtimeState?.noiseSuppression))", + " autoGainControl: \(runtimeComponentSummary(runtimeState?.autoGainControl))", + " highPassFilter: \(runtimeComponentSummary(runtimeState?.highPassFilter))", "", "Audio engine", " engineRunning: \(boolSummary(AudioManager.shared.isEngineRunning))", @@ -354,122 +366,32 @@ extension AppContext { "Notes", " requested values come from the ADM state.", " active values come from the platform input node when available.", - " software effective state is inferred from the applied request and platform state.", + " current effective state comes from publisher WebRTC runtime state.", " active values can be unknown before the input path is configured.", " subscribe-only playback does not configure the input path.", ].joined(separator: "\n") } - func audioProcessingEffectiveStates( - for options: AudioProcessingOptions, - builtInState state: BuiltInAudioProcessingState - ) -> [AudioProcessingEffectiveState] { + func audioProcessingEffectiveStates(for state: AudioProcessingRuntimeState) -> [AudioProcessingEffectiveState] { [ - audioProcessingEffectiveState( - id: "aec", - title: "AEC", - enabled: options.echoCancellation, - mode: options.echoCancellationMode, - platform: state.echoCancellation - ), - audioProcessingEffectiveState( - id: "ns", - title: "NS", - enabled: options.noiseSuppression, - mode: options.noiseSuppressionMode, - platform: state.noiseSuppression - ), - audioProcessingEffectiveState( - id: "agc", - title: "AGC", - enabled: options.autoGainControl, - mode: options.autoGainControlMode, - platform: state.autoGainControl - ), - audioProcessingEffectiveState( - id: "hpf", - title: "HPF", - enabled: options.highPassFilter, - mode: options.highPassFilterMode, - platform: nil - ), + audioProcessingEffectiveState(id: "aec", title: "AEC", component: state.echoCancellation), + audioProcessingEffectiveState(id: "ns", title: "NS", component: state.noiseSuppression), + audioProcessingEffectiveState(id: "agc", title: "AGC", component: state.autoGainControl), + audioProcessingEffectiveState(id: "hpf", title: "HPF", component: state.highPassFilter), ] } func audioProcessingEffectiveState( id: String, title: String, - enabled: Bool, - mode: AudioProcessingMode, - platform: BuiltInAudioProcessingComponentState? + component: AudioProcessingComponentRuntimeState ) -> AudioProcessingEffectiveState { - if let platform, platform.isActive == true { - return AudioProcessingEffectiveState( - id: id, - title: title, - result: .platform, - detail: enabled ? "platform effect is active" : "platform effect is active despite disabled request" - ) - } - - guard enabled else { - return AudioProcessingEffectiveState( - id: id, - title: title, - result: .disabled, - detail: "disabled by applied request" - ) - } - - guard let platform else { - return AudioProcessingEffectiveState( - id: id, - title: title, - result: mode == .platform ? .unavailable : .software, - detail: mode == .platform ? "no platform backend exists for this component" : "software processing requested" - ) - } - - switch mode { - case .software: - return AudioProcessingEffectiveState( - id: id, - title: title, - result: .software, - detail: "software processing requested" - ) - case .automatic: - if !platform.isAvailable { - return AudioProcessingEffectiveState( - id: id, - title: title, - result: .software, - detail: "platform unavailable, using software fallback" - ) - } - if platform.isActive == false { - return AudioProcessingEffectiveState( - id: id, - title: title, - result: .software, - detail: "platform inactive, using software fallback" - ) - } - return AudioProcessingEffectiveState( - id: id, - title: title, - result: .unknown, - detail: "waiting for platform readback" - ) - case .platform: - let detail = platform.isAvailable ? "platform requested but not active" : "platform unavailable" - return AudioProcessingEffectiveState( - id: id, - title: title, - result: .unavailable, - detail: detail - ) - } + AudioProcessingEffectiveState( + id: id, + title: title, + result: effectiveResult(component.effective), + detail: runtimeComponentDetail(component) + ) } func componentSummary(_ state: BuiltInAudioProcessingComponentState) -> String { @@ -478,8 +400,36 @@ extension AppContext { "active: \(optionalSummary(state.isActive))" } - func effectiveStateSummary(_ state: AudioProcessingEffectiveState) -> String { - "result: \(state.result.rawValue), \(state.detail)" + func effectiveStateSummary(_ component: AudioProcessingComponentRuntimeState?) -> String { + guard let component else { return "runtime state unavailable" } + return "result: \(component.effective.description), \(runtimeComponentDetail(component))" + } + + func runtimeComponentSummary(_ component: AudioProcessingComponentRuntimeState?) -> String { + guard let component else { return "unknown" } + return "effective: \(component.effective.description), " + + "requested: \(optionalSummary(component.isRequestedEnabled)) / \(component.requestedMode?.description ?? "unknown"), " + + "resolvedSoftwareEnabled: \(optionalSummary(component.isResolvedSoftwareEnabled)), " + + "softwareEnabled: \(optionalSummary(component.isSoftwareEnabled)), " + + "platform: available: \(boolSummary(component.isPlatformAvailable)), " + + "requested: \(optionalSummary(component.isPlatformRequested)), " + + "active: \(optionalSummary(component.isPlatformActive))" + } + + func runtimeComponentDetail(_ component: AudioProcessingComponentRuntimeState) -> String { + let software = optionalSummary(component.isSoftwareEnabled) + let platform = optionalSummary(component.isPlatformActive) + return "software: \(software), platform: \(platform)" + } + + func effectiveResult(_ implementation: AudioProcessingImplementation) -> AudioProcessingEffectiveResult { + switch implementation { + case .unknown: .unknown + case .disabled: .disabled + case .software: .software + case .platform: .platform + case .softwareAndPlatform: .softwareAndPlatform + } } func optionalSummary(_ value: Bool?) -> String { diff --git a/Multiplatform/Views/AudioControlsPanel.swift b/Multiplatform/Views/AudioControlsPanel.swift index 9efe94f..3f3e5d3 100644 --- a/Multiplatform/Views/AudioControlsPanel.swift +++ b/Multiplatform/Views/AudioControlsPanel.swift @@ -299,12 +299,10 @@ private struct AudioProcessingEffectiveStateItem: View { private extension AudioProcessingEffectiveResult { var tintColor: Color { switch self { - case .platform, .software: + case .platform, .software, .softwareAndPlatform: return .green case .disabled: return .gray - case .unavailable: - return .red case .unknown: return .secondary } @@ -345,7 +343,6 @@ private extension AudioControlsPanel { do { let result = try localMicrophoneTrack.setAudioProcessingOptions(appCtx.runtimeAudioProcessingOptions) - appCtx.markRuntimeAudioProcessingOptionsApplied() appCtx.runtimeAudioProcessingStatus = if result.message.isEmpty { "Audio processing options: \(result.code)" } else { diff --git a/Multiplatform/Views/RoomView.swift b/Multiplatform/Views/RoomView.swift index 694fca4..4c2542f 100644 --- a/Multiplatform/Views/RoomView.swift +++ b/Multiplatform/Views/RoomView.swift @@ -416,9 +416,6 @@ struct RoomView: View { let options = AudioCaptureOptions(audioProcessingOptions: audioProcessingOptions) _ = try? await room.localParticipant.setMicrophone(enabled: isEnablingMicrophone, captureOptions: options) - if isEnablingMicrophone { - appCtx.markRuntimeAudioProcessingOptionsApplied(audioProcessingOptions) - } appCtx.refreshBuiltInAudioProcessingState() } }, From 2e14d77888706cdad4c40b3bb16491f8d8842301 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:21:11 +0800 Subject: [PATCH 04/17] Adopt SDK audio processing state v2 surface Diagnostics read the factory-owned engine-wide state and the renamed platform (formerly built-in) device state: requested shows as one optional request, the resolved/active split replaces tri-state booleans, and the redundant has* flags are gone. --- Multiplatform/Controllers/AppContext.swift | 110 ++++++++----------- Multiplatform/Views/AudioControlsPanel.swift | 14 +-- Multiplatform/Views/RoomView.swift | 2 +- 3 files changed, 55 insertions(+), 71 deletions(-) diff --git a/Multiplatform/Controllers/AppContext.swift b/Multiplatform/Controllers/AppContext.swift index 8337106..169f9f4 100644 --- a/Multiplatform/Controllers/AppContext.swift +++ b/Multiplatform/Controllers/AppContext.swift @@ -110,7 +110,7 @@ final class AppContext: NSObject, ObservableObject { @Published var runtimeAutoGainControlMode: AudioProcessingMode = .automatic @Published var runtimeHighPassFilterMode: AudioProcessingMode = .automatic @Published var runtimeAudioProcessingStatus: String = "" - @Published var builtInAudioProcessingSummary: String = "" + @Published var audioProcessingSummary: String = "" @Published var runtimeAudioProcessingEffectiveStates: [AudioProcessingEffectiveState] = [] var runtimeAudioProcessingOptions: AudioProcessingOptions { @@ -254,7 +254,7 @@ final class AppContext: NSObject, ObservableObject { isVoiceProcessingEnabled = AudioManager.shared.isVoiceProcessingEnabled isVoiceProcessingAGCEnabled = AudioManager.shared.isVoiceProcessingAGCEnabled isRecordingAlwaysPreparedMode = AudioManager.shared.isRecordingAlwaysPreparedMode - refreshBuiltInAudioProcessingState() + refreshAudioProcessingState() updateAudioDeviceSelections() } } @@ -293,22 +293,17 @@ extension AppContext { } } - func refreshBuiltInAudioProcessingState() { - let runtimeState = AudioManager.shared.audioProcessingRuntimeState - let state = runtimeState?.builtIn ?? AudioManager.shared.builtInAudioProcessingState + func refreshAudioProcessingState() { + let state = AudioManager.shared.audioProcessingState + let platformState = AudioManager.shared.platformAudioProcessingState let engineAvailability = AudioManager.shared.engineAvailability - let topology = switch state.topology { + let topology = switch platformState.topology { case .independent: "independent" case .echoCancellationAndNoiseSuppressionCoupled: "AEC/NS coupled" } let audioProcessingOptions = runtimeAudioProcessingOptions - let effectiveStates: [AudioProcessingEffectiveState] = if let runtimeState { - audioProcessingEffectiveStates(for: runtimeState) - } else { - [] - } - runtimeAudioProcessingEffectiveStates = effectiveStates - builtInAudioProcessingSummary = [ + runtimeAudioProcessingEffectiveStates = audioProcessingEffectiveStates(for: state) + audioProcessingSummary = [ "LiveKit audio processing diagnostics", "generatedAt: \(ISO8601DateFormatter().string(from: Date()))", "platform: \(platformName)", @@ -325,22 +320,17 @@ extension AppContext { " highPassFilter: \(componentRequest(audioProcessingOptions.highPassFilter, audioProcessingOptions.highPassFilterMode))", "", "Current effective processing", - " available: \(boolSummary(runtimeState != nil))", - " echoCancellation: \(effectiveStateSummary(runtimeState?.echoCancellation))", - " noiseSuppression: \(effectiveStateSummary(runtimeState?.noiseSuppression))", - " autoGainControl: \(effectiveStateSummary(runtimeState?.autoGainControl))", - " highPassFilter: \(effectiveStateSummary(runtimeState?.highPassFilter))", + " echoCancellation: \(effectiveStateSummary(state.echoCancellation))", + " noiseSuppression: \(effectiveStateSummary(state.noiseSuppression))", + " autoGainControl: \(effectiveStateSummary(state.autoGainControl))", + " highPassFilter: \(effectiveStateSummary(state.highPassFilter))", "", - "Publisher WebRTC runtime state", - " available: \(boolSummary(runtimeState != nil))", - " hasAudioProcessingModule: \(boolSummary(runtimeState?.hasAudioProcessingModule ?? false))", - " hasAudioProcessingConfig: \(boolSummary(runtimeState?.hasAudioProcessingConfig ?? false))", - " hasRequestedAudioProcessingOptions: \(boolSummary(runtimeState?.hasRequestedAudioProcessingOptions ?? false))", - " hasResolvedAudioProcessingOptions: \(boolSummary(runtimeState?.hasResolvedAudioProcessingOptions ?? false))", - " echoCancellation: \(runtimeComponentSummary(runtimeState?.echoCancellation))", - " noiseSuppression: \(runtimeComponentSummary(runtimeState?.noiseSuppression))", - " autoGainControl: \(runtimeComponentSummary(runtimeState?.autoGainControl))", - " highPassFilter: \(runtimeComponentSummary(runtimeState?.highPassFilter))", + "Engine audio processing state", + " hasAudioProcessingModule: \(boolSummary(state.hasAudioProcessingModule))", + " echoCancellation: \(runtimeComponentSummary(state.echoCancellation))", + " noiseSuppression: \(runtimeComponentSummary(state.noiseSuppression))", + " autoGainControl: \(runtimeComponentSummary(state.autoGainControl))", + " highPassFilter: \(runtimeComponentSummary(state.highPassFilter))", "", "Audio engine", " engineRunning: \(boolSummary(AudioManager.shared.isEngineRunning))", @@ -349,30 +339,30 @@ extension AppContext { " outputAvailable requested: \(boolSummary(isAudioEngineOutputAvailable))", " outputAvailable effective: \(boolSummary(engineAvailability.isOutputAvailable))", "", - "Built-in audio processing topology", + "Platform audio processing topology", " topology: \(topology)", - " echoCancellation: \(componentSummary(state.echoCancellation))", - " noiseSuppression: \(componentSummary(state.noiseSuppression))", - " autoGainControl: \(componentSummary(state.autoGainControl))", + " echoCancellation: \(componentSummary(platformState.echoCancellation))", + " noiseSuppression: \(componentSummary(platformState.noiseSuppression))", + " autoGainControl: \(componentSummary(platformState.autoGainControl))", "", "Apple Voice Processing I/O state", - " voiceProcessingEnabled requested: \(optionalSummary(state.isVoiceProcessingEnabledRequested))", - " voiceProcessingEnabled active: \(optionalSummary(state.isVoiceProcessingEnabledActive))", - " voiceProcessingBypassed requested: \(optionalSummary(state.isVoiceProcessingBypassedRequested))", - " voiceProcessingBypassed active: \(optionalSummary(state.isVoiceProcessingBypassedActive))", - " voiceProcessingAGC requested: \(optionalSummary(state.isVoiceProcessingAGCEnabledRequested))", - " voiceProcessingAGC active: \(optionalSummary(state.isVoiceProcessingAGCEnabledActive))", + " voiceProcessingEnabled requested: \(boolSummary(platformState.isVoiceProcessingEnabledRequested))", + " voiceProcessingEnabled active: \(boolSummary(platformState.isVoiceProcessingEnabledActive))", + " voiceProcessingBypassed requested: \(boolSummary(platformState.isVoiceProcessingBypassedRequested))", + " voiceProcessingBypassed active: \(boolSummary(platformState.isVoiceProcessingBypassedActive))", + " voiceProcessingAGC requested: \(boolSummary(platformState.isVoiceProcessingAGCEnabledRequested))", + " voiceProcessingAGC active: \(boolSummary(platformState.isVoiceProcessingAGCEnabledActive))", "", "Notes", + " engine state comes from the factory-owned audio processing module.", " requested values come from the ADM state.", " active values come from the platform input node when available.", - " current effective state comes from publisher WebRTC runtime state.", - " active values can be unknown before the input path is configured.", + " active values read off before the input path is configured.", " subscribe-only playback does not configure the input path.", ].joined(separator: "\n") } - func audioProcessingEffectiveStates(for state: AudioProcessingRuntimeState) -> [AudioProcessingEffectiveState] { + func audioProcessingEffectiveStates(for state: AudioProcessingState) -> [AudioProcessingEffectiveState] { [ audioProcessingEffectiveState(id: "aec", title: "AEC", component: state.echoCancellation), audioProcessingEffectiveState(id: "ns", title: "NS", component: state.noiseSuppression), @@ -384,7 +374,7 @@ extension AppContext { func audioProcessingEffectiveState( id: String, title: String, - component: AudioProcessingComponentRuntimeState + component: AudioProcessingComponentState ) -> AudioProcessingEffectiveState { AudioProcessingEffectiveState( id: id, @@ -394,32 +384,30 @@ extension AppContext { ) } - func componentSummary(_ state: BuiltInAudioProcessingComponentState) -> String { + func componentSummary(_ state: PlatformAudioProcessingComponentState) -> String { "available: \(boolSummary(state.isAvailable)), " + - "requested: \(optionalSummary(state.isRequested)), " + - "active: \(optionalSummary(state.isActive))" + "requested: \(boolSummary(state.isRequested)), " + + "active: \(boolSummary(state.isActive))" } - func effectiveStateSummary(_ component: AudioProcessingComponentRuntimeState?) -> String { - guard let component else { return "runtime state unavailable" } - return "result: \(component.effective.description), \(runtimeComponentDetail(component))" + func effectiveStateSummary(_ component: AudioProcessingComponentState) -> String { + "result: \(component.effective.description), \(runtimeComponentDetail(component))" } - func runtimeComponentSummary(_ component: AudioProcessingComponentRuntimeState?) -> String { - guard let component else { return "unknown" } + func runtimeComponentSummary(_ component: AudioProcessingComponentState) -> String { + let requested = component.requested + .map { "\(boolSummary($0.isEnabled)) / \($0.mode.description)" } ?? "none" return "effective: \(component.effective.description), " + - "requested: \(optionalSummary(component.isRequestedEnabled)) / \(component.requestedMode?.description ?? "unknown"), " + - "resolvedSoftwareEnabled: \(optionalSummary(component.isResolvedSoftwareEnabled)), " + - "softwareEnabled: \(optionalSummary(component.isSoftwareEnabled)), " + + "requested: \(requested), " + + "softwareResolved: \(boolSummary(component.isSoftwareResolved)), " + + "softwareActive: \(boolSummary(component.isSoftwareActive)), " + "platform: available: \(boolSummary(component.isPlatformAvailable)), " + - "requested: \(optionalSummary(component.isPlatformRequested)), " + - "active: \(optionalSummary(component.isPlatformActive))" + "resolved: \(boolSummary(component.isPlatformResolved)), " + + "active: \(boolSummary(component.isPlatformActive))" } - func runtimeComponentDetail(_ component: AudioProcessingComponentRuntimeState) -> String { - let software = optionalSummary(component.isSoftwareEnabled) - let platform = optionalSummary(component.isPlatformActive) - return "software: \(software), platform: \(platform)" + func runtimeComponentDetail(_ component: AudioProcessingComponentState) -> String { + "software: \(boolSummary(component.isSoftwareActive)), platform: \(boolSummary(component.isPlatformActive))" } func effectiveResult(_ implementation: AudioProcessingImplementation) -> AudioProcessingEffectiveResult { @@ -432,10 +420,6 @@ extension AppContext { } } - func optionalSummary(_ value: Bool?) -> String { - value.map { $0 ? "on" : "off" } ?? "unknown" - } - func boolSummary(_ value: Bool) -> String { value ? "on" : "off" } diff --git a/Multiplatform/Views/AudioControlsPanel.swift b/Multiplatform/Views/AudioControlsPanel.swift index 3f3e5d3..653b009 100644 --- a/Multiplatform/Views/AudioControlsPanel.swift +++ b/Multiplatform/Views/AudioControlsPanel.swift @@ -117,7 +117,7 @@ struct AudioControlsPanel: View { applyRuntimeAudioProcessingOptions() } Button("Get diagnostics") { - appCtx.refreshBuiltInAudioProcessingState() + appCtx.refreshAudioProcessingState() } Button("Copy diagnostics") { copyAudioProcessingDiagnostics() @@ -135,9 +135,9 @@ struct AudioControlsPanel: View { AudioProcessingEffectiveStateBox(states: appCtx.runtimeAudioProcessingEffectiveStates) } - if !appCtx.builtInAudioProcessingSummary.isEmpty { + if !appCtx.audioProcessingSummary.isEmpty { ScrollView { - Text(appCtx.builtInAudioProcessingSummary) + Text(appCtx.audioProcessingSummary) .font(.caption2.monospaced()) .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -337,7 +337,7 @@ private extension AudioControlsPanel { func applyRuntimeAudioProcessingOptions() { guard let localMicrophoneTrack else { appCtx.runtimeAudioProcessingStatus = "Publish the microphone first." - appCtx.refreshBuiltInAudioProcessingState() + appCtx.refreshAudioProcessingState() return } @@ -351,12 +351,12 @@ private extension AudioControlsPanel { } catch { appCtx.runtimeAudioProcessingStatus = "Failed: \(error)" } - appCtx.refreshBuiltInAudioProcessingState() + appCtx.refreshAudioProcessingState() } func copyAudioProcessingDiagnostics() { - appCtx.refreshBuiltInAudioProcessingState() - let diagnostics = appCtx.builtInAudioProcessingSummary + appCtx.refreshAudioProcessingState() + let diagnostics = appCtx.audioProcessingSummary #if canImport(UIKit) UIPasteboard.general.string = diagnostics #elseif canImport(AppKit) diff --git a/Multiplatform/Views/RoomView.swift b/Multiplatform/Views/RoomView.swift index 4c2542f..a9227c2 100644 --- a/Multiplatform/Views/RoomView.swift +++ b/Multiplatform/Views/RoomView.swift @@ -416,7 +416,7 @@ struct RoomView: View { let options = AudioCaptureOptions(audioProcessingOptions: audioProcessingOptions) _ = try? await room.localParticipant.setMicrophone(enabled: isEnablingMicrophone, captureOptions: options) - appCtx.refreshBuiltInAudioProcessingState() + appCtx.refreshAudioProcessingState() } }, label: { From 32c6c3ac091843b69c1d843a9931eba49318edad Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:34:40 +0900 Subject: [PATCH 05/17] Point SDK dependency at hiroshi/runtime-vp branch --- .../xcshareddata/swiftpm/Package.resolved | 15 +++------------ LiveKitExample.xcodeproj/project.pbxproj | 4 ++-- .../xcshareddata/swiftpm/Package.resolved | 8 ++++---- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/LiveKitExample-dev.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LiveKitExample-dev.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2f915e7..641a7a4 100644 --- a/LiveKitExample-dev.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LiveKitExample-dev.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,15 +1,6 @@ { - "originHash" : "619822ad96a3acbd3c79418fb8ac2ae63c9e2b1a552f2363e0e88475c819709a", + "originHash" : "c463e7964fb36769bfc0bd7b81beb1ce595d027d7d6558ff4e0ce259cb7f680f", "pins" : [ - { - "identity" : "components-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/livekit/components-swift", - "state" : { - "revision" : "9f28db3ae5d2b51f3033ea725c952ddacb5657d0", - "version" : "0.1.7" - } - }, { "identity" : "keychainaccess", "kind" : "remoteSourceControl", @@ -69,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/livekit/webrtc-xcframework.git", "state" : { - "revision" : "53d268c89d242791f0771fb022fe4b423f2263d9", - "version" : "144.7559.8" + "revision" : "883f7f39f0733be564eb033d7fa6ce10960f7b7e", + "version" : "144.7559.10" } } ], diff --git a/LiveKitExample.xcodeproj/project.pbxproj b/LiveKitExample.xcodeproj/project.pbxproj index 9f51c09..6720bfd 100644 --- a/LiveKitExample.xcodeproj/project.pbxproj +++ b/LiveKitExample.xcodeproj/project.pbxproj @@ -800,8 +800,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/livekit/client-sdk-swift"; requirement = { - kind = exactVersion; - version = 2.15.0; + branch = "hiroshi/runtime-vp"; + kind = branch; }; }; B5C2EF142D0114C800FAC766 /* XCRemoteSwiftPackageReference "components-swift" */ = { diff --git a/LiveKitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LiveKitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7acafab..f252f32 100644 --- a/LiveKitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LiveKitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/livekit/client-sdk-swift", "state" : { - "revision" : "9859018394f1b34e3e04ae859debce96d855e1d8", - "version" : "2.15.0" + "branch" : "hiroshi/runtime-vp", + "revision" : "70d0e9d5f9f5fcfcdf709cc5cf081d1bc0510adb" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/livekit/webrtc-xcframework.git", "state" : { - "revision" : "53d268c89d242791f0771fb022fe4b423f2263d9", - "version" : "144.7559.8" + "revision" : "883f7f39f0733be564eb033d7fa6ce10960f7b7e", + "version" : "144.7559.10" } } ], From 9fd6d62c5e5b1aa7f2fade7316c0092a5124b184 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:48:15 +0900 Subject: [PATCH 06/17] Adopt final audio processing API Per-effect mode types drive the pickers generically, so the high-pass filter row only offers its legal modes. setAudioProcessingOptions now returns a success-only result and throws on failure. Diagnostics use the path-grouped component state and the renamed platformVoiceProcessingState, and the voice processing toggle follows the platform-allowed rename. --- .../xcshareddata/swiftpm/Package.resolved | 6 +- Multiplatform/Controllers/AppContext.swift | 90 +++++++++++-------- Multiplatform/Views/AudioControlsPanel.swift | 18 ++-- Multiplatform/Views/RoomView.swift | 3 +- 4 files changed, 65 insertions(+), 52 deletions(-) diff --git a/LiveKitExample-dev.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LiveKitExample-dev.xcworkspace/xcshareddata/swiftpm/Package.resolved index 641a7a4..787c652 100644 --- a/LiveKitExample-dev.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LiveKitExample-dev.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c463e7964fb36769bfc0bd7b81beb1ce595d027d7d6558ff4e0ce259cb7f680f", + "originHash" : "f359d3fa1879b25a103ae805c292948c78bffefec51dbd204406f0bdf71dacc7", "pins" : [ { "identity" : "keychainaccess", @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/livekit/webrtc-xcframework.git", "state" : { - "revision" : "883f7f39f0733be564eb033d7fa6ce10960f7b7e", - "version" : "144.7559.10" + "revision" : "46f2af86f06b9a8a9158d37cadda5cb5a214e4c4", + "version" : "144.7559.11" } } ], diff --git a/Multiplatform/Controllers/AppContext.swift b/Multiplatform/Controllers/AppContext.swift index 169f9f4..c6337da 100644 --- a/Multiplatform/Controllers/AppContext.swift +++ b/Multiplatform/Controllers/AppContext.swift @@ -90,13 +90,13 @@ final class AppContext: NSObject, ObservableObject { didSet { AudioManager.shared.isVoiceProcessingAGCEnabled = isVoiceProcessingAGCEnabled } } - @Published var isVoiceProcessingEnabled: Bool = true { + @Published var isPlatformVoiceProcessingAllowed: Bool = true { didSet { - guard oldValue != isVoiceProcessingEnabled else { return } + guard oldValue != isPlatformVoiceProcessingAllowed else { return } do { - try AudioManager.shared.setVoiceProcessingEnabled(isVoiceProcessingEnabled) + try AudioManager.shared.setPlatformVoiceProcessingAllowed(isPlatformVoiceProcessingAllowed) } catch { - print("Failed to set voice processing enabled: \(error)") + print("Failed to set platform voice processing allowed: \(error)") } } } @@ -105,10 +105,10 @@ final class AppContext: NSObject, ObservableObject { @Published var runtimeNoiseSuppression: Bool = true @Published var runtimeAutoGainControl: Bool = true @Published var runtimeHighPassFilter: Bool = false - @Published var runtimeEchoCancellationMode: AudioProcessingMode = .automatic - @Published var runtimeNoiseSuppressionMode: AudioProcessingMode = .automatic - @Published var runtimeAutoGainControlMode: AudioProcessingMode = .automatic - @Published var runtimeHighPassFilterMode: AudioProcessingMode = .automatic + @Published var runtimeEchoCancellationMode: EchoCancellationMode = .automatic + @Published var runtimeNoiseSuppressionMode: NoiseSuppressionMode = .automatic + @Published var runtimeAutoGainControlMode: AutoGainControlMode = .automatic + @Published var runtimeHighPassFilterMode: HighpassFilterMode = .automatic @Published var runtimeAudioProcessingStatus: String = "" @Published var audioProcessingSummary: String = "" @Published var runtimeAudioProcessingEffectiveStates: [AudioProcessingEffectiveState] = [] @@ -118,11 +118,24 @@ final class AppContext: NSObject, ObservableObject { echoCancellation: runtimeEchoCancellation, autoGainControl: runtimeAutoGainControl, noiseSuppression: runtimeNoiseSuppression, - highPassFilter: runtimeHighPassFilter, + highpassFilter: runtimeHighPassFilter, echoCancellationMode: runtimeEchoCancellationMode, autoGainControlMode: runtimeAutoGainControlMode, noiseSuppressionMode: runtimeNoiseSuppressionMode, - highPassFilterMode: runtimeHighPassFilterMode + highpassFilterMode: runtimeHighPassFilterMode + ) + } + + var runtimeAudioCaptureOptions: AudioCaptureOptions { + AudioCaptureOptions( + echoCancellation: runtimeEchoCancellation, + autoGainControl: runtimeAutoGainControl, + noiseSuppression: runtimeNoiseSuppression, + highpassFilter: runtimeHighPassFilter, + echoCancellationMode: runtimeEchoCancellationMode, + autoGainControlMode: runtimeAutoGainControlMode, + noiseSuppressionMode: runtimeNoiseSuppressionMode, + highpassFilterMode: runtimeHighPassFilterMode ) } @@ -251,7 +264,7 @@ final class AppContext: NSObject, ObservableObject { inputDevices = AudioManager.shared.inputDevices outputDevice = AudioManager.shared.outputDevice inputDevice = AudioManager.shared.inputDevice - isVoiceProcessingEnabled = AudioManager.shared.isVoiceProcessingEnabled + isPlatformVoiceProcessingAllowed = AudioManager.shared.isPlatformVoiceProcessingAllowed isVoiceProcessingAGCEnabled = AudioManager.shared.isVoiceProcessingAGCEnabled isRecordingAlwaysPreparedMode = AudioManager.shared.isRecordingAlwaysPreparedMode refreshAudioProcessingState() @@ -295,7 +308,7 @@ extension AppContext { func refreshAudioProcessingState() { let state = AudioManager.shared.audioProcessingState - let platformState = AudioManager.shared.platformAudioProcessingState + let platformState = AudioManager.shared.platformVoiceProcessingState let engineAvailability = AudioManager.shared.engineAvailability let topology = switch platformState.topology { case .independent: "independent" @@ -309,7 +322,7 @@ extension AppContext { "platform: \(platformName)", "", "App voice processing controls", - " voiceProcessingEnabled: \(boolSummary(AudioManager.shared.isVoiceProcessingEnabled))", + " platformVoiceProcessingAllowed: \(boolSummary(AudioManager.shared.isPlatformVoiceProcessingAllowed))", " voiceProcessingBypassed: \(boolSummary(AudioManager.shared.isVoiceProcessingBypassed))", " voiceProcessingAGCEnabled: \(boolSummary(AudioManager.shared.isVoiceProcessingAGCEnabled))", "", @@ -317,20 +330,20 @@ extension AppContext { " echoCancellation: \(componentRequest(audioProcessingOptions.echoCancellation, audioProcessingOptions.echoCancellationMode))", " noiseSuppression: \(componentRequest(audioProcessingOptions.noiseSuppression, audioProcessingOptions.noiseSuppressionMode))", " autoGainControl: \(componentRequest(audioProcessingOptions.autoGainControl, audioProcessingOptions.autoGainControlMode))", - " highPassFilter: \(componentRequest(audioProcessingOptions.highPassFilter, audioProcessingOptions.highPassFilterMode))", + " highPassFilter: \(componentRequest(audioProcessingOptions.highpassFilter, audioProcessingOptions.highpassFilterMode))", "", "Current effective processing", " echoCancellation: \(effectiveStateSummary(state.echoCancellation))", " noiseSuppression: \(effectiveStateSummary(state.noiseSuppression))", " autoGainControl: \(effectiveStateSummary(state.autoGainControl))", - " highPassFilter: \(effectiveStateSummary(state.highPassFilter))", + " highPassFilter: \(effectiveStateSummary(state.highpassFilter))", "", "Engine audio processing state", " hasAudioProcessingModule: \(boolSummary(state.hasAudioProcessingModule))", " echoCancellation: \(runtimeComponentSummary(state.echoCancellation))", " noiseSuppression: \(runtimeComponentSummary(state.noiseSuppression))", " autoGainControl: \(runtimeComponentSummary(state.autoGainControl))", - " highPassFilter: \(runtimeComponentSummary(state.highPassFilter))", + " highPassFilter: \(runtimeComponentSummary(state.highpassFilter))", "", "Audio engine", " engineRunning: \(boolSummary(AudioManager.shared.isEngineRunning))", @@ -346,12 +359,12 @@ extension AppContext { " autoGainControl: \(componentSummary(platformState.autoGainControl))", "", "Apple Voice Processing I/O state", - " voiceProcessingEnabled requested: \(boolSummary(platformState.isVoiceProcessingEnabledRequested))", - " voiceProcessingEnabled active: \(boolSummary(platformState.isVoiceProcessingEnabledActive))", - " voiceProcessingBypassed requested: \(boolSummary(platformState.isVoiceProcessingBypassedRequested))", - " voiceProcessingBypassed active: \(boolSummary(platformState.isVoiceProcessingBypassedActive))", - " voiceProcessingAGC requested: \(boolSummary(platformState.isVoiceProcessingAGCEnabledRequested))", - " voiceProcessingAGC active: \(boolSummary(platformState.isVoiceProcessingAGCEnabledActive))", + " voiceProcessingEnabled requested: \(boolSummary(platformState.voiceProcessingEnabled.isRequested))", + " voiceProcessingEnabled active: \(boolSummary(platformState.voiceProcessingEnabled.isActive))", + " voiceProcessingBypassed requested: \(boolSummary(platformState.voiceProcessingBypassed.isRequested))", + " voiceProcessingBypassed active: \(boolSummary(platformState.voiceProcessingBypassed.isActive))", + " voiceProcessingAGC requested: \(boolSummary(platformState.voiceProcessingAGCEnabled.isRequested))", + " voiceProcessingAGC active: \(boolSummary(platformState.voiceProcessingAGCEnabled.isActive))", "", "Notes", " engine state comes from the factory-owned audio processing module.", @@ -367,14 +380,14 @@ extension AppContext { audioProcessingEffectiveState(id: "aec", title: "AEC", component: state.echoCancellation), audioProcessingEffectiveState(id: "ns", title: "NS", component: state.noiseSuppression), audioProcessingEffectiveState(id: "agc", title: "AGC", component: state.autoGainControl), - audioProcessingEffectiveState(id: "hpf", title: "HPF", component: state.highPassFilter), + audioProcessingEffectiveState(id: "hpf", title: "HPF", component: state.highpassFilter), ] } - func audioProcessingEffectiveState( + func audioProcessingEffectiveState( id: String, title: String, - component: AudioProcessingComponentState + component: AudioProcessingComponentState ) -> AudioProcessingEffectiveState { AudioProcessingEffectiveState( id: id, @@ -384,30 +397,31 @@ extension AppContext { ) } - func componentSummary(_ state: PlatformAudioProcessingComponentState) -> String { + func componentSummary(_ state: PlatformVoiceProcessingComponentState) -> String { "available: \(boolSummary(state.isAvailable)), " + "requested: \(boolSummary(state.isRequested)), " + "active: \(boolSummary(state.isActive))" } - func effectiveStateSummary(_ component: AudioProcessingComponentState) -> String { + func effectiveStateSummary(_ component: AudioProcessingComponentState) -> String { "result: \(component.effective.description), \(runtimeComponentDetail(component))" } - func runtimeComponentSummary(_ component: AudioProcessingComponentState) -> String { + func runtimeComponentSummary(_ component: AudioProcessingComponentState) -> String { let requested = component.requested - .map { "\(boolSummary($0.isEnabled)) / \($0.mode.description)" } ?? "none" + .map { "\(boolSummary($0.isEnabled)) / \($0.mode)" } ?? "none" + let platform = component.platform + .map { "available: on, resolved: \(boolSummary($0.isResolved)), active: \(boolSummary($0.isActive))" } + ?? "available: off" return "effective: \(component.effective.description), " + "requested: \(requested), " + - "softwareResolved: \(boolSummary(component.isSoftwareResolved)), " + - "softwareActive: \(boolSummary(component.isSoftwareActive)), " + - "platform: available: \(boolSummary(component.isPlatformAvailable)), " + - "resolved: \(boolSummary(component.isPlatformResolved)), " + - "active: \(boolSummary(component.isPlatformActive))" + "softwareResolved: \(boolSummary(component.software.isResolved)), " + + "softwareActive: \(boolSummary(component.software.isActive)), " + + "platform: \(platform)" } - func runtimeComponentDetail(_ component: AudioProcessingComponentState) -> String { - "software: \(boolSummary(component.isSoftwareActive)), platform: \(boolSummary(component.isPlatformActive))" + func runtimeComponentDetail(_ component: AudioProcessingComponentState) -> String { + "software: \(boolSummary(component.software.isActive)), platform: \(boolSummary(component.platform?.isActive ?? false))" } func effectiveResult(_ implementation: AudioProcessingImplementation) -> AudioProcessingEffectiveResult { @@ -424,8 +438,8 @@ extension AppContext { value ? "on" : "off" } - func componentRequest(_ enabled: Bool, _ mode: AudioProcessingMode) -> String { - "enabled: \(boolSummary(enabled)), mode: \(mode.description)" + func componentRequest(_ enabled: Bool, _ mode: Mode) -> String { + "enabled: \(boolSummary(enabled)), mode: \(mode)" } var platformName: String { diff --git a/Multiplatform/Views/AudioControlsPanel.swift b/Multiplatform/Views/AudioControlsPanel.swift index 653b009..103a856 100644 --- a/Multiplatform/Views/AudioControlsPanel.swift +++ b/Multiplatform/Views/AudioControlsPanel.swift @@ -90,7 +90,7 @@ struct AudioControlsPanel: View { } Section(header: Text("Voice Processing")) { - Toggle("Voice processing enabled", isOn: $appCtx.isVoiceProcessingEnabled) + Toggle("Platform voice processing allowed", isOn: $appCtx.isPlatformVoiceProcessingAllowed) Toggle("Bypass voice processing", isOn: $appCtx.isVoiceProcessingBypassed) Toggle("Auto gain control (AGC)", isOn: $appCtx.isVoiceProcessingAGCEnabled) } @@ -316,7 +316,11 @@ private extension AudioControlsPanel { .track as? LocalAudioTrack } - func processingRow(_ title: String, isOn: Binding, mode: Binding) -> some View { + func processingRow( + _ title: String, + isOn: Binding, + mode: Binding + ) -> some View where Mode.AllCases: RandomAccessCollection { HStack(spacing: 12) { Toggle(title, isOn: isOn) .lineLimit(1) @@ -324,8 +328,8 @@ private extension AudioControlsPanel { Spacer(minLength: 8) Picker("Mode", selection: mode) { - ForEach(AudioProcessingMode.allCases, id: \.self) { mode in - Text(mode.description).tag(mode) + ForEach(Mode.allCases, id: \.self) { mode in + Text(String(describing: mode)).tag(mode) } } .labelsHidden() @@ -343,11 +347,7 @@ private extension AudioControlsPanel { do { let result = try localMicrophoneTrack.setAudioProcessingOptions(appCtx.runtimeAudioProcessingOptions) - appCtx.runtimeAudioProcessingStatus = if result.message.isEmpty { - "Audio processing options: \(result.code)" - } else { - "Audio processing options: \(result.code): \(result.message)" - } + appCtx.runtimeAudioProcessingStatus = "Audio processing options: \(result)" } catch { appCtx.runtimeAudioProcessingStatus = "Failed: \(error)" } diff --git a/Multiplatform/Views/RoomView.swift b/Multiplatform/Views/RoomView.swift index a9227c2..8bc1b7c 100644 --- a/Multiplatform/Views/RoomView.swift +++ b/Multiplatform/Views/RoomView.swift @@ -412,8 +412,7 @@ struct RoomView: View { isMicrophonePublishingBusy = true defer { Task { @MainActor in isMicrophonePublishingBusy = false } } let isEnablingMicrophone = !isMicrophoneEnabled - let audioProcessingOptions = appCtx.runtimeAudioProcessingOptions - let options = AudioCaptureOptions(audioProcessingOptions: audioProcessingOptions) + let options = appCtx.runtimeAudioCaptureOptions _ = try? await room.localParticipant.setMicrophone(enabled: isEnablingMicrophone, captureOptions: options) appCtx.refreshAudioProcessingState() From 2f7b5127983e19b02c8f92ac81a69f048de7d333 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 17 Jul 2026 03:04:56 +0900 Subject: [PATCH 07/17] Remove hasAudioProcessingModule from diagnostics summary The SDK dropped the flag from AudioProcessingState. --- Multiplatform/Controllers/AppContext.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Multiplatform/Controllers/AppContext.swift b/Multiplatform/Controllers/AppContext.swift index c6337da..c11a868 100644 --- a/Multiplatform/Controllers/AppContext.swift +++ b/Multiplatform/Controllers/AppContext.swift @@ -339,7 +339,6 @@ extension AppContext { " highPassFilter: \(effectiveStateSummary(state.highpassFilter))", "", "Engine audio processing state", - " hasAudioProcessingModule: \(boolSummary(state.hasAudioProcessingModule))", " echoCancellation: \(runtimeComponentSummary(state.echoCancellation))", " noiseSuppression: \(runtimeComponentSummary(state.noiseSuppression))", " autoGainControl: \(runtimeComponentSummary(state.autoGainControl))", From 2ebad9c373f3acfe26a2f3d363d6bff52e282cdc Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:14:40 +0900 Subject: [PATCH 08/17] Pin SDK to release/2.15.2, bump to 2.15.2 (20260717.1) Pins the release branch revision ae672652 so LiveKitSDK.version reports 2.15.2. TestFlight build 2.15.2b20260717.1. --- LiveKitExample.xcodeproj/project.pbxproj | 12 ++++++------ .../xcshareddata/swiftpm/Package.resolved | 7 +++---- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/LiveKitExample.xcodeproj/project.pbxproj b/LiveKitExample.xcodeproj/project.pbxproj index 6720bfd..59ea39a 100644 --- a/LiveKitExample.xcodeproj/project.pbxproj +++ b/LiveKitExample.xcodeproj/project.pbxproj @@ -650,7 +650,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 20260609; + CURRENT_PROJECT_VERSION = 20260717.1; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 76TVFCUKK7; @@ -672,7 +672,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MARKETING_VERSION = 2.15.0; + MARKETING_VERSION = 2.15.2; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -719,7 +719,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 20260609; + CURRENT_PROJECT_VERSION = 20260717.1; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 76TVFCUKK7; @@ -736,7 +736,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MARKETING_VERSION = 2.15.0; + MARKETING_VERSION = 2.15.2; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -800,8 +800,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/livekit/client-sdk-swift"; requirement = { - branch = "hiroshi/runtime-vp"; - kind = branch; + kind = revision; + revision = ae6726528191ec5b82af53d82e0dabad8f92ddd0; }; }; B5C2EF142D0114C800FAC766 /* XCRemoteSwiftPackageReference "components-swift" */ = { diff --git a/LiveKitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LiveKitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f252f32..17f1033 100644 --- a/LiveKitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LiveKitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/livekit/client-sdk-swift", "state" : { - "branch" : "hiroshi/runtime-vp", - "revision" : "70d0e9d5f9f5fcfcdf709cc5cf081d1bc0510adb" + "revision" : "ae6726528191ec5b82af53d82e0dabad8f92ddd0" } }, { @@ -60,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/livekit/webrtc-xcframework.git", "state" : { - "revision" : "883f7f39f0733be564eb033d7fa6ce10960f7b7e", - "version" : "144.7559.10" + "revision" : "46f2af86f06b9a8a9158d37cadda5cb5a214e4c4", + "version" : "144.7559.11" } } ], From 036fbd39994f7879d1a800599f1f3d9a5c8f9b6f Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:50:37 +0900 Subject: [PATCH 09/17] Bump SDK to 2.15.2 --- LiveKitExample.xcodeproj/project.pbxproj | 4 ++-- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/LiveKitExample.xcodeproj/project.pbxproj b/LiveKitExample.xcodeproj/project.pbxproj index 59ea39a..b6ab772 100644 --- a/LiveKitExample.xcodeproj/project.pbxproj +++ b/LiveKitExample.xcodeproj/project.pbxproj @@ -800,8 +800,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/livekit/client-sdk-swift"; requirement = { - kind = revision; - revision = ae6726528191ec5b82af53d82e0dabad8f92ddd0; + kind = exactVersion; + version = 2.15.2; }; }; B5C2EF142D0114C800FAC766 /* XCRemoteSwiftPackageReference "components-swift" */ = { diff --git a/LiveKitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LiveKitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 17f1033..bf7a5ea 100644 --- a/LiveKitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LiveKitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/livekit/client-sdk-swift", "state" : { - "revision" : "ae6726528191ec5b82af53d82e0dabad8f92ddd0" + "revision" : "77b5aad07909e23adf97d39f205ef7e18e2ceff5", + "version" : "2.15.2" } }, { From a2336ed0ea5b2585a9bbf7df868ae86860a12256 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:58:13 +0900 Subject: [PATCH 10/17] Set room-default audio capture options from panel state at connect Demonstrates RoomOptions(defaultAudioCaptureOptions:), the way most apps configure processing modes. The mic toggle still passes options explicitly, which takes precedence and picks up panel changes made after connecting. --- Multiplatform/Controllers/RoomContext.swift | 5 ++++- Multiplatform/Views/ConnectView.swift | 5 +++-- Multiplatform/Views/RoomContextView.swift | 2 +- Multiplatform/Views/RoomView.swift | 2 ++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Multiplatform/Controllers/RoomContext.swift b/Multiplatform/Controllers/RoomContext.swift index a237192..b02a6cc 100644 --- a/Multiplatform/Controllers/RoomContext.swift +++ b/Multiplatform/Controllers/RoomContext.swift @@ -144,7 +144,9 @@ final class RoomContext: ObservableObject { _connectTask?.cancel() } - func connect(entry: ConnectionHistory? = nil) async throws -> Room { + func connect(entry: ConnectionHistory? = nil, + audioCaptureOptions: AudioCaptureOptions? = nil) async throws -> Room + { if let entry { url = entry.url token = entry.token @@ -172,6 +174,7 @@ final class RoomContext: ObservableObject { appAudio: true, useBroadcastExtension: true ), + defaultAudioCaptureOptions: audioCaptureOptions ?? AudioCaptureOptions(), defaultVideoPublishOptions: VideoPublishOptions( simulcast: simulcast ), diff --git a/Multiplatform/Views/ConnectView.swift b/Multiplatform/Views/ConnectView.swift index f27e612..b4cb011 100644 --- a/Multiplatform/Views/ConnectView.swift +++ b/Multiplatform/Views/ConnectView.swift @@ -98,7 +98,7 @@ struct ConnectView: View { LKButton(title: "Connect") { Task { @MainActor in do { - let room = try await roomCtx.connect() + let room = try await roomCtx.connect(audioCaptureOptions: appCtx.runtimeAudioCaptureOptions) appCtx.connectionHistory.update(room: room, e2ee: roomCtx.isE2eeEnabled, e2eeKey: roomCtx.e2eeKey) } catch { print("Failed to connect: \(error)") @@ -112,7 +112,8 @@ struct ConnectView: View { Button { Task { @MainActor in do { - let room = try await roomCtx.connect(entry: entry) + let room = try await roomCtx.connect(entry: entry, + audioCaptureOptions: appCtx.runtimeAudioCaptureOptions) appCtx.connectionHistory.update(room: room, e2ee: roomCtx.isE2eeEnabled, e2eeKey: roomCtx.e2eeKey) } catch { print("Failed to connect: \(error)") diff --git a/Multiplatform/Views/RoomContextView.swift b/Multiplatform/Views/RoomContextView.swift index 906751f..e9fd47a 100644 --- a/Multiplatform/Views/RoomContextView.swift +++ b/Multiplatform/Views/RoomContextView.swift @@ -60,7 +60,7 @@ struct RoomContextView: View { roomCtx.e2eeKey = e2eeKey if !roomCtx.token.isEmpty { do { - let room = try await roomCtx.connect() + let room = try await roomCtx.connect(audioCaptureOptions: appCtx.runtimeAudioCaptureOptions) appCtx.connectionHistory.update(room: room, e2ee: e2ee, e2eeKey: e2eeKey) } catch { print("Failed to connect: \(error)") diff --git a/Multiplatform/Views/RoomView.swift b/Multiplatform/Views/RoomView.swift index 8bc1b7c..5c820ab 100644 --- a/Multiplatform/Views/RoomView.swift +++ b/Multiplatform/Views/RoomView.swift @@ -412,6 +412,8 @@ struct RoomView: View { isMicrophonePublishingBusy = true defer { Task { @MainActor in isMicrophonePublishingBusy = false } } let isEnablingMicrophone = !isMicrophoneEnabled + // Passing options here overrides the room default set at connect, + // picking up panel changes made after connecting. let options = appCtx.runtimeAudioCaptureOptions _ = try? await room.localParticipant.setMicrophone(enabled: isEnablingMicrophone, captureOptions: options) From ef06cd92ac75a013cd6869a76f089dca0a133d58 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:58:43 +0900 Subject: [PATCH 11/17] Disable mode picker while its effect is off The mode is irrelevant while the effect is disabled; graying the picker makes that visible. --- Multiplatform/Views/AudioControlsPanel.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Multiplatform/Views/AudioControlsPanel.swift b/Multiplatform/Views/AudioControlsPanel.swift index 103a856..b28ba3b 100644 --- a/Multiplatform/Views/AudioControlsPanel.swift +++ b/Multiplatform/Views/AudioControlsPanel.swift @@ -334,6 +334,7 @@ private extension AudioControlsPanel { } .labelsHidden() .pickerStyle(.menu) + .disabled(!isOn.wrappedValue) .frame(minWidth: 110, maxWidth: 150, alignment: .trailing) } } From 7e960f8ce91e47fc97dd46ce70f82889b2b0e4a6 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:59:17 +0900 Subject: [PATCH 12/17] Explain applied versus stored in the status line --- Multiplatform/Views/AudioControlsPanel.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Multiplatform/Views/AudioControlsPanel.swift b/Multiplatform/Views/AudioControlsPanel.swift index b28ba3b..b03dbaf 100644 --- a/Multiplatform/Views/AudioControlsPanel.swift +++ b/Multiplatform/Views/AudioControlsPanel.swift @@ -348,7 +348,10 @@ private extension AudioControlsPanel { do { let result = try localMicrophoneTrack.setAudioProcessingOptions(appCtx.runtimeAudioProcessingOptions) - appCtx.runtimeAudioProcessingStatus = "Audio processing options: \(result)" + appCtx.runtimeAudioProcessingStatus = switch result { + case .applied: "Audio processing options: applied" + case .stored: "Audio processing options: stored — applies when the mic is sending" + } } catch { appCtx.runtimeAudioProcessingStatus = "Failed: \(error)" } From 3dec4640ebef1e847dd64bedd9c381d09a77634f Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:59:54 +0900 Subject: [PATCH 13/17] Add processing presets from SDK values Default restores the communication profile via the AudioProcessingOptions initializer; No processing uses the .noProcessing preset. --- Multiplatform/Controllers/AppContext.swift | 11 +++++++++++ Multiplatform/Views/AudioControlsPanel.swift | 12 ++++++++++++ 2 files changed, 23 insertions(+) diff --git a/Multiplatform/Controllers/AppContext.swift b/Multiplatform/Controllers/AppContext.swift index c11a868..c28fed3 100644 --- a/Multiplatform/Controllers/AppContext.swift +++ b/Multiplatform/Controllers/AppContext.swift @@ -139,6 +139,17 @@ final class AppContext: NSObject, ObservableObject { ) } + func setRuntimeProcessingControls(_ options: AudioProcessingOptions) { + runtimeEchoCancellation = options.echoCancellation + runtimeAutoGainControl = options.autoGainControl + runtimeNoiseSuppression = options.noiseSuppression + runtimeHighPassFilter = options.highpassFilter + runtimeEchoCancellationMode = options.echoCancellationMode + runtimeAutoGainControlMode = options.autoGainControlMode + runtimeNoiseSuppressionMode = options.noiseSuppressionMode + runtimeHighPassFilterMode = options.highpassFilterMode + } + @Published var micMuteMode: MicrophoneMuteMode = .voiceProcessing { didSet { do { diff --git a/Multiplatform/Views/AudioControlsPanel.swift b/Multiplatform/Views/AudioControlsPanel.swift index b03dbaf..261009d 100644 --- a/Multiplatform/Views/AudioControlsPanel.swift +++ b/Multiplatform/Views/AudioControlsPanel.swift @@ -112,6 +112,18 @@ struct AudioControlsPanel: View { isOn: $appCtx.runtimeHighPassFilter, mode: $appCtx.runtimeHighPassFilterMode) + HStack { + Text("Presets") + .foregroundColor(.secondary) + Button("Default") { + appCtx.setRuntimeProcessingControls(AudioProcessingOptions()) + } + Button("No processing") { + appCtx.setRuntimeProcessingControls(.noProcessing) + } + } + .buttonStyle(.bordered) + HStack { Button("Apply to local mic") { applyRuntimeAudioProcessingOptions() From 71447eadb839f527aa6d5c95554a734f974a9a76 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:53:09 +0900 Subject: [PATCH 14/17] Update Package.resolved --- .../xcshareddata/swiftpm/Package.resolved | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/LiveKitExample-dev.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LiveKitExample-dev.xcworkspace/xcshareddata/swiftpm/Package.resolved index 787c652..5285cdb 100644 --- a/LiveKitExample-dev.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LiveKitExample-dev.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f359d3fa1879b25a103ae805c292948c78bffefec51dbd204406f0bdf71dacc7", + "originHash" : "649726dbcfe1a05ec4b7f7de9bfc8bfc7da4ca529035a2019e083625beb23d5b", "pins" : [ { "identity" : "keychainaccess", @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-docc-plugin.git", "state" : { - "revision" : "d1691545d53581400b1de9b0472d45eb25c19fed", - "version" : "1.4.4" + "revision" : "647c708be89f834fa6a6d4945442793a77ddf5b6", + "version" : "1.5.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "2547102afd04fe49f1b286090f13ebce07284980", - "version" : "1.31.1" + "revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8", + "version" : "1.38.1" } }, { From de2389f424c40878fbf86eb357040d255226c203 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:51:20 +0900 Subject: [PATCH 15/17] Use panel audio processing options when publishing the mic Also derive capture options from the processing options and replace the AudioProcessingEffectiveResult wrapper with the SDK's AudioProcessingImplementation. --- Multiplatform/Controllers/AppContext.swift | 41 ++++++-------------- Multiplatform/Views/AudioControlsPanel.swift | 6 +-- Multiplatform/Views/RoomView.swift | 4 +- 3 files changed, 18 insertions(+), 33 deletions(-) diff --git a/Multiplatform/Controllers/AppContext.swift b/Multiplatform/Controllers/AppContext.swift index c28fed3..b61cd2e 100644 --- a/Multiplatform/Controllers/AppContext.swift +++ b/Multiplatform/Controllers/AppContext.swift @@ -127,15 +127,16 @@ final class AppContext: NSObject, ObservableObject { } var runtimeAudioCaptureOptions: AudioCaptureOptions { - AudioCaptureOptions( - echoCancellation: runtimeEchoCancellation, - autoGainControl: runtimeAutoGainControl, - noiseSuppression: runtimeNoiseSuppression, - highpassFilter: runtimeHighPassFilter, - echoCancellationMode: runtimeEchoCancellationMode, - autoGainControlMode: runtimeAutoGainControlMode, - noiseSuppressionMode: runtimeNoiseSuppressionMode, - highpassFilterMode: runtimeHighPassFilterMode + let options = runtimeAudioProcessingOptions + return AudioCaptureOptions( + echoCancellation: options.echoCancellation, + autoGainControl: options.autoGainControl, + noiseSuppression: options.noiseSuppression, + highpassFilter: options.highpassFilter, + echoCancellationMode: options.echoCancellationMode, + autoGainControlMode: options.autoGainControlMode, + noiseSuppressionMode: options.noiseSuppressionMode, + highpassFilterMode: options.highpassFilterMode ) } @@ -286,18 +287,10 @@ final class AppContext: NSObject, ObservableObject { struct AudioProcessingEffectiveState: Identifiable, Sendable { let id: String let title: String - let result: AudioProcessingEffectiveResult + let result: AudioProcessingImplementation let detail: String } -enum AudioProcessingEffectiveResult: String, Sendable { - case platform = "Platform" - case software = "Software" - case softwareAndPlatform = "Software + Platform" - case disabled = "Disabled" - case unknown = "Unknown" -} - extension AppContext { func updateAudioDeviceSelections() { if !inputDevices.contains(where: { $0.id == inputDevice.id }) { @@ -402,7 +395,7 @@ extension AppContext { AudioProcessingEffectiveState( id: id, title: title, - result: effectiveResult(component.effective), + result: component.effective, detail: runtimeComponentDetail(component) ) } @@ -434,16 +427,6 @@ extension AppContext { "software: \(boolSummary(component.software.isActive)), platform: \(boolSummary(component.platform?.isActive ?? false))" } - func effectiveResult(_ implementation: AudioProcessingImplementation) -> AudioProcessingEffectiveResult { - switch implementation { - case .unknown: .unknown - case .disabled: .disabled - case .software: .software - case .platform: .platform - case .softwareAndPlatform: .softwareAndPlatform - } - } - func boolSummary(_ value: Bool) -> String { value ? "on" : "off" } diff --git a/Multiplatform/Views/AudioControlsPanel.swift b/Multiplatform/Views/AudioControlsPanel.swift index 261009d..7d822f6 100644 --- a/Multiplatform/Views/AudioControlsPanel.swift +++ b/Multiplatform/Views/AudioControlsPanel.swift @@ -293,7 +293,7 @@ private struct AudioProcessingEffectiveStateItem: View { HStack(spacing: 4) { Text(state.title) .font(.caption.weight(.semibold)) - Text(state.result.rawValue) + Text(state.result.description) .font(.caption) .foregroundColor(.primary) } @@ -308,7 +308,7 @@ private struct AudioProcessingEffectiveStateItem: View { } } -private extension AudioProcessingEffectiveResult { +private extension AudioProcessingImplementation { var tintColor: Color { switch self { case .platform, .software, .softwareAndPlatform: @@ -353,7 +353,7 @@ private extension AudioControlsPanel { func applyRuntimeAudioProcessingOptions() { guard let localMicrophoneTrack else { - appCtx.runtimeAudioProcessingStatus = "Publish the microphone first." + appCtx.runtimeAudioProcessingStatus = "Audio processing options: stored — applies when the mic is published" appCtx.refreshAudioProcessingState() return } diff --git a/Multiplatform/Views/RoomView.swift b/Multiplatform/Views/RoomView.swift index 5f7ffea..8b87572 100644 --- a/Multiplatform/Views/RoomView.swift +++ b/Multiplatform/Views/RoomView.swift @@ -359,7 +359,9 @@ struct RoomView: View { Task { isMicrophonePublishingBusy = true defer { Task { @MainActor in isMicrophonePublishingBusy = false } } - _ = try? await room.localParticipant.setMicrophone(enabled: !isMicrophoneEnabled) + // Use the audio processing options stored in the controls panel when publishing + _ = try? await room.localParticipant.setMicrophone(enabled: !isMicrophoneEnabled, + captureOptions: isMicrophoneEnabled ? nil : appCtx.runtimeAudioCaptureOptions) } }, label: { From eb80326bdf2b607005229532aaa25bc322682833 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:51:44 +0900 Subject: [PATCH 16/17] Capitalize audio processing mode and detail labels --- Multiplatform/Controllers/AppContext.swift | 2 +- Multiplatform/Views/AudioControlsPanel.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Multiplatform/Controllers/AppContext.swift b/Multiplatform/Controllers/AppContext.swift index b61cd2e..42367a4 100644 --- a/Multiplatform/Controllers/AppContext.swift +++ b/Multiplatform/Controllers/AppContext.swift @@ -424,7 +424,7 @@ extension AppContext { } func runtimeComponentDetail(_ component: AudioProcessingComponentState) -> String { - "software: \(boolSummary(component.software.isActive)), platform: \(boolSummary(component.platform?.isActive ?? false))" + "Software: \(boolSummary(component.software.isActive)), Platform: \(boolSummary(component.platform?.isActive ?? false))" } func boolSummary(_ value: Bool) -> String { diff --git a/Multiplatform/Views/AudioControlsPanel.swift b/Multiplatform/Views/AudioControlsPanel.swift index 7d822f6..f9acad7 100644 --- a/Multiplatform/Views/AudioControlsPanel.swift +++ b/Multiplatform/Views/AudioControlsPanel.swift @@ -341,7 +341,7 @@ private extension AudioControlsPanel { Picker("Mode", selection: mode) { ForEach(Mode.allCases, id: \.self) { mode in - Text(String(describing: mode)).tag(mode) + Text(String(describing: mode).capitalized).tag(mode) } } .labelsHidden() From f62cecbeac61f876adb9aa0af55296a690c27e0a Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:55:18 +0900 Subject: [PATCH 17/17] Capitalize on and off state labels --- Multiplatform/Controllers/AppContext.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Multiplatform/Controllers/AppContext.swift b/Multiplatform/Controllers/AppContext.swift index 42367a4..4d61bf3 100644 --- a/Multiplatform/Controllers/AppContext.swift +++ b/Multiplatform/Controllers/AppContext.swift @@ -414,8 +414,8 @@ extension AppContext { let requested = component.requested .map { "\(boolSummary($0.isEnabled)) / \($0.mode)" } ?? "none" let platform = component.platform - .map { "available: on, resolved: \(boolSummary($0.isResolved)), active: \(boolSummary($0.isActive))" } - ?? "available: off" + .map { "available: On, resolved: \(boolSummary($0.isResolved)), active: \(boolSummary($0.isActive))" } + ?? "available: Off" return "effective: \(component.effective.description), " + "requested: \(requested), " + "softwareResolved: \(boolSummary(component.software.isResolved)), " + @@ -428,7 +428,7 @@ extension AppContext { } func boolSummary(_ value: Bool) -> String { - value ? "on" : "off" + value ? "On" : "Off" } func componentRequest(_ enabled: Bool, _ mode: Mode) -> String {