Skip to content

Commit 6dc7a33

Browse files
JacobCoffeeclaude
andcommitted
monitor output: hear your mic through speakers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a994de0 commit 6dc7a33

3 files changed

Lines changed: 61 additions & 17 deletions

File tree

App/Loopbacker/Sources/Models/AudioSource.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ struct AudioSource: Identifiable, Equatable, Codable {
4141
var isMuted: Bool
4242
/// The CoreAudio device UID for this source (used by AudioRouter)
4343
var deviceUID: String
44+
/// Physical output device UID for monitoring (hear yourself through speakers)
45+
var monitorOutputUID: String
46+
var monitorOutputName: String
4447

4548
init(
4649
id: UUID = UUID(),
@@ -50,7 +53,9 @@ struct AudioSource: Identifiable, Equatable, Codable {
5053
isEnabled: Bool = true,
5154
isPassThrough: Bool = false,
5255
isMuted: Bool = false,
53-
deviceUID: String = ""
56+
deviceUID: String = "",
57+
monitorOutputUID: String = "",
58+
monitorOutputName: String = ""
5459
) {
5560
self.id = id
5661
self.name = name
@@ -60,11 +65,12 @@ struct AudioSource: Identifiable, Equatable, Codable {
6065
self.isPassThrough = isPassThrough
6166
self.isMuted = isMuted
6267
self.deviceUID = deviceUID
68+
self.monitorOutputUID = monitorOutputUID
69+
self.monitorOutputName = monitorOutputName
6370
}
6471

65-
// Backward-compatible decoding: isMuted may not exist in old configs
6672
enum CodingKeys: String, CodingKey {
67-
case id, name, icon, channels, isEnabled, isPassThrough, isMuted, deviceUID
73+
case id, name, icon, channels, isEnabled, isPassThrough, isMuted, deviceUID, monitorOutputUID, monitorOutputName
6874
}
6975

7076
init(from decoder: Decoder) throws {
@@ -77,6 +83,8 @@ struct AudioSource: Identifiable, Equatable, Codable {
7783
isPassThrough = try container.decode(Bool.self, forKey: .isPassThrough)
7884
isMuted = (try? container.decode(Bool.self, forKey: .isMuted)) ?? false
7985
deviceUID = try container.decode(String.self, forKey: .deviceUID)
86+
monitorOutputUID = (try? container.decode(String.self, forKey: .monitorOutputUID)) ?? ""
87+
monitorOutputName = (try? container.decode(String.self, forKey: .monitorOutputName)) ?? ""
8088
}
8189
}
8290

App/Loopbacker/Sources/Views/ContentView.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ struct ContentView: View {
2121
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
2222
syncAudioRouting(sources: routingState.sources, routes: routingState.routes)
2323
syncOutputRouting(destinations: routingState.outputDestinations)
24+
// Start monitoring for any sources that have a saved monitor output
25+
for source in routingState.sources where !source.monitorOutputUID.isEmpty && source.isEnabled && !source.isMuted {
26+
audioRouter.startMonitoring(sourceDeviceUID: source.deviceUID, outputDeviceUID: source.monitorOutputUID)
27+
}
2428
}
2529
}
2630
.onChange(of: routingState.sources) { _, newSources in
@@ -58,6 +62,11 @@ struct ContentView: View {
5862
audioRouter.stopRouting(sourceDeviceUID: source.deviceUID)
5963
}
6064
}
65+
66+
// Monitor: only manage stop. Start is handled by the picker in SourceCardView.
67+
if !shouldRoute && !source.monitorOutputUID.isEmpty {
68+
audioRouter.stopOutputRouting(virtualDeviceUID: "monitor:\(source.deviceUID)")
69+
}
6170
}
6271
}
6372

App/Loopbacker/Sources/Views/SourceCardView.swift

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import SwiftUI
33
struct SourceCardView: View {
44
@Binding var source: AudioSource
55
@EnvironmentObject var routingState: RoutingState
6+
@EnvironmentObject var audioDeviceManager: AudioDeviceManager
7+
@EnvironmentObject var audioRouter: AudioRouter
68
@State private var isHovering = false
79
@State private var showOptions = false
810

@@ -155,24 +157,19 @@ struct SourceCardView: View {
155157
Divider()
156158
.background(LoopbackerTheme.border)
157159

158-
// Pass-Through toggle
159-
HStack {
160-
Text("Pass-Thru")
161-
.font(.system(size: 11))
160+
// Monitor output picker
161+
HStack(spacing: 6) {
162+
Image(systemName: "speaker.wave.2")
163+
.font(.system(size: 10))
162164
.foregroundColor(LoopbackerTheme.textSecondary)
163-
.tooltip("Also play this source through your speakers for monitoring")
164165

165-
Spacer()
166+
Text("Monitor:")
167+
.font(.system(size: 11))
168+
.foregroundColor(LoopbackerTheme.textSecondary)
166169

167-
Toggle("", isOn: $source.isPassThrough)
168-
.toggleStyle(.switch)
169-
.controlSize(.mini)
170-
.tint(LoopbackerTheme.accent)
171-
.onChange(of: source.isPassThrough) { _, _ in
172-
routingState.save()
173-
}
174-
.tooltip("Pass audio through without processing (monitor mode)")
170+
monitorPicker
175171
}
172+
.tooltip("Hear this source through a physical output (speakers/headphones)")
176173

177174
// Mute toggle
178175
HStack {
@@ -245,6 +242,36 @@ struct SourceCardView: View {
245242
.padding(.bottom, 4)
246243
}
247244

245+
// MARK: - Monitor output picker
246+
247+
@ViewBuilder
248+
private var monitorPicker: some View {
249+
let devices = audioDeviceManager.outputDevices
250+
251+
Picker("", selection: Binding(
252+
get: { source.monitorOutputUID },
253+
set: { newUID in
254+
if !source.monitorOutputUID.isEmpty {
255+
audioRouter.stopOutputRouting(virtualDeviceUID: "monitor:\(source.deviceUID)")
256+
}
257+
source.monitorOutputUID = newUID
258+
source.monitorOutputName = devices.first(where: { $0.uid == newUID })?.name ?? ""
259+
routingState.save()
260+
if !newUID.isEmpty && source.isEnabled && !source.isMuted {
261+
audioRouter.startMonitoring(sourceDeviceUID: source.deviceUID, outputDeviceUID: newUID)
262+
}
263+
}
264+
)) {
265+
Text("None").tag("")
266+
ForEach(devices) { device in
267+
Text(device.name).tag(device.uid)
268+
}
269+
}
270+
.pickerStyle(.menu)
271+
.frame(maxWidth: .infinity, alignment: .leading)
272+
.tint(LoopbackerTheme.accent)
273+
}
274+
248275
// MARK: - Options toggle button
249276

250277
private var optionsToggle: some View {

0 commit comments

Comments
 (0)