From c61c543a650ae1eca99cbe7aa24f9cce9adbd7d5 Mon Sep 17 00:00:00 2001 From: samuelzielke Date: Thu, 22 Oct 2026 10:56:35 +0200 Subject: [PATCH] feat(intercom): intercom tab with QR code, clinic interface setting, PGM preview and talkback Preview comes from the NDI proxy stream (lowest bandwidth); talkback taps the headset mic and is gated by the talk key. Co-Authored-By: Claude Opus 5.5 --- App/Sources/AppSettings.swift | 6 + App/Sources/AudioController.swift | 7 +- App/Sources/ContentView.swift | 6 +- App/Sources/Coordinator.swift | 4 + App/Sources/IntercomController.swift | 155 +++++++++++++++++ App/Sources/IntercomView.swift | 163 ++++++++++++++++++ App/Sources/MusubiApp.swift | 1 + App/Sources/SettingsView.swift | 25 ++- .../Sources/AudioMatrix/AudioDevices.swift | 10 ++ .../Sources/AudioMatrix/AudioFeed.swift | 8 + .../Sources/AudioMatrix/AudioMatrix.swift | 31 +++- .../Sources/NDIKit/NDIReceiver.swift | 9 +- .../IntercomTests/BrowserEndToEndTests.swift | 13 +- project.yml | 5 +- 14 files changed, 432 insertions(+), 11 deletions(-) create mode 100644 App/Sources/IntercomController.swift create mode 100644 App/Sources/IntercomView.swift diff --git a/App/Sources/AppSettings.swift b/App/Sources/AppSettings.swift index 8d7e116..1c80684 100644 --- a/App/Sources/AppSettings.swift +++ b/App/Sources/AppSettings.swift @@ -15,12 +15,18 @@ final class AppSettings { didSet { UserDefaults.standard.set(interfaceName, forKey: "interfaceName") } } + /// BSD name of the clinic network interface for the intercom (e.g. "en0"); empty = intercom off. + var clinicInterfaceName: String { + didSet { UserDefaults.standard.set(clinicInterfaceName, forKey: "clinicInterfaceName") } + } + /// Name other Macs see (the computer name, e.g. "Mac mini von Samuel"). let machineName: String = (SCDynamicStoreCopyComputerName(nil, nil) as String?) ?? Host.current().localizedName ?? "Musubi" init() { role = Role(rawValue: UserDefaults.standard.string(forKey: "role") ?? "") ?? .control interfaceName = UserDefaults.standard.string(forKey: "interfaceName") ?? "en0" + clinicInterfaceName = UserDefaults.standard.string(forKey: "clinicInterfaceName") ?? "" } var launchAtLogin: Bool { diff --git a/App/Sources/AudioController.swift b/App/Sources/AudioController.swift index 02b6a22..d3e96a8 100644 --- a/App/Sources/AudioController.swift +++ b/App/Sources/AudioController.swift @@ -20,6 +20,8 @@ final class AudioController { var selectedDeviceUID: String? /// Called after routes or their settings changed (for autosave and scenes). var onChange: (() -> Void)? + /// Talk key pressed or released (also used by the intercom talkback). + var onTalkChange: ((Bool) -> Void)? private var ndiReceivers: [String: NDIReceiver] = [:] private var meterTimer: Timer? @@ -28,7 +30,10 @@ final class AudioController { init() { selectedDeviceUID = Self.preferredOutput(in: matrix.outputDevices)?.uid - talkKey.onChange = { [weak self] active in self?.matrix.talkActive = active } + talkKey.onChange = { [weak self] active in + self?.matrix.talkActive = active + self?.onTalkChange?(active) + } talkKey.register() meterTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in MainActor.assumeIsolated { self?.updateMeters() } diff --git a/App/Sources/ContentView.swift b/App/Sources/ContentView.swift index 20e1254..8ae3b1f 100644 --- a/App/Sources/ContentView.swift +++ b/App/Sources/ContentView.swift @@ -7,7 +7,7 @@ struct ContentView: View { let coordinator: Coordinator @AppStorage("detailTab") private var tab = DetailTab.screens - enum DetailTab: String { case screens, sound } + enum DetailTab: String { case screens, sound, intercom } var body: some View { NavigationSplitView { @@ -17,6 +17,7 @@ struct ContentView: View { switch tab { case .screens: ScreensView(coordinator: coordinator) case .sound: SoundView(audio: coordinator.audio, sourceList: sourceList) + case .intercom: IntercomView(intercom: coordinator.intercom, sourceList: sourceList, audio: coordinator.audio) } } .navigationTitle("Musubi") @@ -33,6 +34,9 @@ struct ContentView: View { Picker("Ansicht", selection: $tab) { Text("Bildschirme").tag(DetailTab.screens) Text("Ton").tag(DetailTab.sound) + if coordinator.settings.role == .control { + Text("Intercom").tag(DetailTab.intercom) + } } .pickerStyle(.segmented) .fixedSize() diff --git a/App/Sources/Coordinator.swift b/App/Sources/Coordinator.swift index efff146..3cdd2cd 100644 --- a/App/Sources/Coordinator.swift +++ b/App/Sources/Coordinator.swift @@ -10,6 +10,7 @@ final class Coordinator { let outputs: OutputController let audio: AudioController let store = SceneStore() + let intercom: IntercomController private(set) var live = LiveState() private(set) var nodeServer: NodeServer? @@ -23,6 +24,8 @@ final class Coordinator { self.settings = settings self.outputs = outputs self.audio = audio + intercom = IntercomController(settings: settings, audio: audio) + audio.onTalkChange = { [weak intercom] active in intercom?.setTalkActive(active) } } /// Restores the last live state, starts remote control, then applies launch arguments. @@ -37,6 +40,7 @@ final class Coordinator { outputs.onChange = { [weak self] in self?.localChanged() } audio.onChange = { [weak self] in self?.localChanged() } await startRemote() + await intercom.restart() outputs.applyLaunchRoutes() audio.applyLaunchRoutes() diff --git a/App/Sources/IntercomController.swift b/App/Sources/IntercomController.swift new file mode 100644 index 0000000..f606317 --- /dev/null +++ b/App/Sources/IntercomController.swift @@ -0,0 +1,155 @@ +import AudioMatrix +import Foundation +import Intercom +import MusubiCore +import NDIKit +import Observation + +/// App-side glue for the intercom (control only): server on the clinic interface, +/// PGM preview from an NDI proxy stream, talkback from the headset mic gated by the talk key. +@MainActor @Observable +final class IntercomController { + let server = IntercomServer() + + /// NDI source shown on the phones (usually PGM). + var previewSourceName: String { + didSet { + UserDefaults.standard.set(previewSourceName, forKey: "intercomPreviewSource") + startPreview() + } + } + + /// Input device whose signal the phones hear while the talk key is down. + var talkbackDeviceUID: String { + didSet { + UserDefaults.standard.set(talkbackDeviceUID, forKey: "talkbackDeviceUID") + startTalkback() + } + } + + private let settings: AppSettings + private let audio: AudioController + private var preview: NDIReceiver? + private var tap: (feed: AudioFeed, deviceUID: String)? + private var pump: TalkbackPump? + + init(settings: AppSettings, audio: AudioController) { + self.settings = settings + self.audio = audio + previewSourceName = UserDefaults.standard.string(forKey: "intercomPreviewSource") ?? "" + talkbackDeviceUID = UserDefaults.standard.string(forKey: "talkbackDeviceUID") ?? "" + } + + var isEnabled: Bool { settings.role == .control && !settings.clinicInterfaceName.isEmpty } + + /// Technical and clinic network on the same interface is fine at home, never on site. + var sharesTechnicalInterface: Bool { settings.clinicInterfaceName == settings.interfaceName } + + /// (Re)starts or stops everything according to role and clinic interface; call after settings changes. + func restart() async { + stopAll() + guard isEnabled else { return } + await server.start(interfaceName: settings.clinicInterfaceName) + startPreview() + startTalkback() + } + + func setTalkActive(_ active: Bool) { + pump?.gateOpen = active + } + + private func stopAll() { + server.stop() + preview?.stop() + preview = nil + stopTalkback() + } + + private func startPreview() { + preview?.stop() + preview = nil + guard isEnabled, !previewSourceName.isEmpty else { return } + let input = server.input + preview = NDIReceiver(source: Source(ndiName: previewSourceName), receiverName: "Musubi Intercom", + bandwidth: .lowest, onFrame: { input.pushVideo($0) }) + Log.intercom.info("Preview from \(self.previewSourceName, privacy: .public)") + } + + private func startTalkback() { + stopTalkback() + guard isEnabled else { return } + let uid = talkbackDeviceUID.isEmpty ? defaultTalkbackDevice() : talkbackDeviceUID + guard let uid, audio.matrix.inputDevices.contains(where: { $0.uid == uid }) else { return } + let feed = audio.matrix.addInputTap(deviceUID: uid) + tap = (feed, uid) + let pump = TalkbackPump(feed: feed, input: server.input) + pump.gateOpen = audio.talkKey.isActive + pump.start() + self.pump = pump + } + + private func stopTalkback() { + pump?.stop() + pump = nil + if let tap { audio.matrix.removeInputTap(tap.feed, deviceUID: tap.deviceUID) } + tap = nil + } + + /// The mic already routed in the sound matrix, else the system's default input. + func defaultTalkbackDevice() -> String? { + for route in audio.matrix.routes { + if case .input(let uid) = route.source { return uid } + } + return AudioDevices.defaultInput()?.uid ?? audio.matrix.inputDevices.first?.uid + } + + /// Name of the device "Automatisch" currently resolves to. + var automaticTalkbackName: String? { + defaultTalkbackDevice().flatMap { uid in audio.matrix.inputDevices.first { $0.uid == uid }?.name } + } +} + +/// Moves mic audio from the tap to the Opus encoder every 10 ms on its own queue. +/// Sends silence while the talk key is up, so the phones' audio clock keeps running. +private final class TalkbackPump: @unchecked Sendable { + private let feed: AudioFeed + private let input: IntercomInput + private let queue = DispatchQueue(label: "studio.zielke.musubi.intercom.talkback", qos: .userInitiated) + private var timer: DispatchSourceTimer? + private let lock = NSLock() + private var open = false + private var stereo = [Float](repeating: 0, count: 4096 * 2) + private var mono = [Float](repeating: 0, count: 4096) + + init(feed: AudioFeed, input: IntercomInput) { + self.feed = feed + self.input = input + } + + var gateOpen: Bool { + get { lock.withLock { open } } + set { lock.withLock { open = newValue } } + } + + func start() { + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now(), repeating: .milliseconds(10), leeway: .milliseconds(2)) + timer.setEventHandler { [weak self] in self?.drain() } + timer.resume() + self.timer = timer + } + + func stop() { + timer?.cancel() + timer = nil + } + + private func drain() { + let rate = feed.currentSampleRate + let frames = stereo.withUnsafeMutableBufferPointer { feed.read(into: $0.baseAddress!, frames: 4096) } + guard frames > 0, rate > 0 else { return } + let open = gateOpen + for i in 0..: View { + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Space.unit) { content } + .padding(Theme.Space.card) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.Color.kasumi, in: RoundedRectangle(cornerRadius: Theme.Radius.card)) + .overlay(RoundedRectangle(cornerRadius: Theme.Radius.card).stroke(Theme.Color.usuzumi, lineWidth: Theme.Stroke.line)) + } +} + +/// Black on white with a quiet zone: what phone cameras read best, regardless of the dark theme. +private struct QRCode: View { + let text: String + + var body: some View { + if let image = Self.image(for: text) { + Image(decorative: image, scale: 1) + .interpolation(.none) + .resizable() + .padding(Theme.Space.unit) + .background(.white, in: RoundedRectangle(cornerRadius: Theme.Radius.button)) + } + } + + static func image(for text: String) -> CGImage? { + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(text.utf8) + filter.correctionLevel = "M" + guard let output = filter.outputImage else { return nil } + return CIContext().createCGImage(output, from: output.extent) + } +} diff --git a/App/Sources/MusubiApp.swift b/App/Sources/MusubiApp.swift index da6c17e..624fd76 100644 --- a/App/Sources/MusubiApp.swift +++ b/App/Sources/MusubiApp.swift @@ -39,4 +39,5 @@ enum Log { static let ndi = Logger(subsystem: "studio.zielke.musubi", category: "ndi") static let audio = Logger(subsystem: "studio.zielke.musubi", category: "audio") static let remote = Logger(subsystem: "studio.zielke.musubi", category: "scene") + static let intercom = Logger(subsystem: "studio.zielke.musubi", category: "intercom") } diff --git a/App/Sources/SettingsView.swift b/App/Sources/SettingsView.swift index c21602c..16d504a 100644 --- a/App/Sources/SettingsView.swift +++ b/App/Sources/SettingsView.swift @@ -27,6 +27,22 @@ struct SettingsView: View { Text("Regie und Knoten finden sich nur über diese Schnittstelle.") .font(Theme.Font.caption) .foregroundStyle(Theme.Color.nezumi) + if settings.role == .control { + Picker("Klinik-Netz", selection: Binding(get: { settings.clinicInterfaceName }, set: { settings.clinicInterfaceName = $0; restartIntercom() })) { + Text("Aus – kein Intercom").tag("") + ForEach(interfaces) { interface in + Text(verbatim: "\(interface.name) · \(interface.kind)").tag(interface.name) + } + if !settings.clinicInterfaceName.isEmpty, !interfaces.contains(where: { $0.name == settings.clinicInterfaceName }) { + Text(verbatim: "\(settings.clinicInterfaceName) · nicht verbunden").tag(settings.clinicInterfaceName) + } + } + Text(settings.clinicInterfaceName == settings.interfaceName + ? "Gleiche Schnittstelle wie das Technik-Netz: nur zum Testen. Im Einsatz getrennt halten." + : "Handys erreichen das Intercom nur über diese Schnittstelle.") + .font(Theme.Font.caption) + .foregroundStyle(settings.clinicInterfaceName == settings.interfaceName ? Theme.Color.kohaku : Theme.Color.nezumi) + } Toggle("Beim Anmelden starten", isOn: Binding(get: { launchAtLogin }, set: { settings.launchAtLogin = $0 launchAtLogin = settings.launchAtLogin @@ -42,6 +58,13 @@ struct SettingsView: View { } private func restart() { - Task { await coordinator.startRemote() } + Task { + await coordinator.startRemote() + await coordinator.intercom.restart() + } + } + + private func restartIntercom() { + Task { await coordinator.intercom.restart() } } } diff --git a/Packages/MusubiKit/Sources/AudioMatrix/AudioDevices.swift b/Packages/MusubiKit/Sources/AudioMatrix/AudioDevices.swift index f5dfb23..ea2919c 100644 --- a/Packages/MusubiKit/Sources/AudioMatrix/AudioDevices.swift +++ b/Packages/MusubiKit/Sources/AudioMatrix/AudioDevices.swift @@ -26,6 +26,16 @@ public enum AudioDevices { return ids.compactMap(device(for:)) } + /// The input device selected in System Settings › Sound. + public static func defaultInput() -> AudioDevice? { + var address = AudioObjectPropertyAddress(mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) + var id = AudioDeviceID(0) + var size = UInt32(MemoryLayout.size) + guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &id) == noErr else { return nil } + return device(for: id) + } + public static func device(uid: String) -> AudioDevice? { all().first { $0.uid == uid } } diff --git a/Packages/MusubiKit/Sources/AudioMatrix/AudioFeed.swift b/Packages/MusubiKit/Sources/AudioMatrix/AudioFeed.swift index eb6d9dc..b3338aa 100644 --- a/Packages/MusubiKit/Sources/AudioMatrix/AudioFeed.swift +++ b/Packages/MusubiKit/Sources/AudioMatrix/AudioFeed.swift @@ -37,6 +37,14 @@ public final class AudioFeed: @unchecked Sendable { musubi_ring_write(ring, scratch, UInt32(frames)) } + /// Consumer side for taps: reads up to `frames` interleaved stereo frames; returns the number read. + public func read(into interleaved: UnsafeMutablePointer, frames: Int) -> Int { + Int(musubi_ring_read(ring, interleaved, UInt32(frames))) + } + + /// Sample rate of the most recent push (0 before the first). + public var currentSampleRate: Double { sampleRate } + private func ensureScratch(_ frames: Int) { guard frames > scratchFrames else { return } scratch.deallocate() diff --git a/Packages/MusubiKit/Sources/AudioMatrix/AudioMatrix.swift b/Packages/MusubiKit/Sources/AudioMatrix/AudioMatrix.swift index a0bc1fc..f076ffb 100644 --- a/Packages/MusubiKit/Sources/AudioMatrix/AudioMatrix.swift +++ b/Packages/MusubiKit/Sources/AudioMatrix/AudioMatrix.swift @@ -38,6 +38,8 @@ public final class AudioMatrix { private var retired: [RouteRenderer] = [] private var outputEngines: [String: OutputEngine] = [:] private var inputEngines: [String: InputEngine] = [:] + /// Extra consumers of input devices (intercom talkback), keyed by device UID. + private var inputTaps: [String: [AudioFeed]] = [:] private var deviceObserver: AnyObject? private let log = Logger(subsystem: "studio.zielke.musubi", category: "audio") @@ -82,7 +84,7 @@ public final class AudioMatrix { if ndiFanouts[name]?.isEmpty == true { ndiFanouts[name] = nil } case .input(let uid): inputEngines[uid]?.fanout.remove(renderer.feed) - if !routes.contains(where: { $0.source == route.source }) { + if !routes.contains(where: { $0.source == route.source }), inputTaps[uid, default: []].isEmpty { inputEngines.removeValue(forKey: uid)?.stop() } } @@ -139,6 +141,26 @@ public final class AudioMatrix { bufferedFrames: Int(musubi_ring_available(renderer.feed.ring)), correction: renderer.correction) } + // MARK: - Taps + + /// Captures an input device for another consumer (e.g. intercom talkback), independent of routes. + /// The caller reads the feed with `AudioFeed.read`; the talk key does not gate taps. + public func addInputTap(deviceUID: String) -> AudioFeed { + let feed = AudioFeed() + inputTaps[deviceUID, default: []].append(feed) + inputEngine(for: deviceUID)?.fanout.add(feed) + return feed + } + + public func removeInputTap(_ feed: AudioFeed, deviceUID: String) { + inputTaps[deviceUID]?.removeAll { $0 === feed } + if inputTaps[deviceUID]?.isEmpty == true { inputTaps[deviceUID] = nil } + inputEngines[deviceUID]?.fanout.remove(feed) + if inputTaps[deviceUID] == nil, !routes.contains(where: { $0.source == .input(deviceUID: deviceUID) }) { + inputEngines.removeValue(forKey: deviceUID)?.stop() + } + } + // MARK: - Engines private func retire(_ renderer: RouteRenderer) { @@ -217,6 +239,13 @@ public final class AudioMatrix { engine.fanout.remove(renderer.feed) engine.fanout.add(renderer.feed) } + for (uid, feeds) in inputTaps where present.contains(uid) { + guard let engine = inputEngine(for: uid) else { continue } + for feed in feeds { + engine.fanout.remove(feed) + engine.fanout.add(feed) + } + } rebuildOutputs() } } diff --git a/Packages/MusubiKit/Sources/NDIKit/NDIReceiver.swift b/Packages/MusubiKit/Sources/NDIKit/NDIReceiver.swift index b66e3ac..93fd4c4 100644 --- a/Packages/MusubiKit/Sources/NDIKit/NDIReceiver.swift +++ b/Packages/MusubiKit/Sources/NDIKit/NDIReceiver.swift @@ -11,6 +11,8 @@ import OSLog public final class NDIReceiver: @unchecked Sendable { public enum Bandwidth: Sendable { case highest + /// The sender's proxy stream (about 640×360) where available, e.g. for phone previews. + case lowest /// Audio and metadata only; the sender transmits no video to this receiver. case audioOnly } @@ -62,6 +64,11 @@ public final class NDIReceiver: @unchecked Sendable { onFrame: FrameHandler? = nil, onAudio: AudioHandler? = nil ) { + let ndiBandwidth: NDIlib_recv_bandwidth_e = switch bandwidth { + case .highest: NDIlib_recv_bandwidth_highest + case .lowest: NDIlib_recv_bandwidth_lowest + case .audioOnly: NDIlib_recv_bandwidth_audio_only + } let created: NDIlib_recv_instance_t? = source.ndiName.withCString { name in receiverName.withCString { recvName in var ndiSource = NDIlib_source_t() @@ -69,7 +76,7 @@ public final class NDIReceiver: @unchecked Sendable { var settings = NDIlib_recv_create_v3_t( source_to_connect_to: ndiSource, color_format: NDIlib_recv_color_format_UYVY_BGRA, - bandwidth: bandwidth == .audioOnly ? NDIlib_recv_bandwidth_audio_only : NDIlib_recv_bandwidth_highest, + bandwidth: ndiBandwidth, allow_video_fields: false, p_ndi_recv_name: recvName ) diff --git a/Packages/MusubiKit/Tests/IntercomTests/BrowserEndToEndTests.swift b/Packages/MusubiKit/Tests/IntercomTests/BrowserEndToEndTests.swift index 789e27f..a5ba7d3 100644 --- a/Packages/MusubiKit/Tests/IntercomTests/BrowserEndToEndTests.swift +++ b/Packages/MusubiKit/Tests/IntercomTests/BrowserEndToEndTests.swift @@ -5,19 +5,21 @@ import Testing /// Real browser against the real server: headless Chrome opens the phone page, connects and decodes. /// Opt-in: `MUSUBI_E2E=1 MUSUBI_E2E_INTERFACE=en0 swift test --filter BrowserEndToEnd`. +/// With `MUSUBI_E2E_URL=http://…:8080/` the browser joins a running Musubi instead (connection only). @Suite(.enabled(if: ProcessInfo.processInfo.environment["MUSUBI_E2E"] == "1")) @MainActor struct BrowserEndToEndTests { static let chrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" @Test(.timeLimit(.minutes(1))) func chromeReceivesTalkbackAndPreview() async throws { - let interfaceName = ProcessInfo.processInfo.environment["MUSUBI_E2E_INTERFACE"] ?? "en0" + let environment = ProcessInfo.processInfo.environment + let external = environment["MUSUBI_E2E_URL"].flatMap(URL.init(string:)) let server = IntercomServer() - await server.start(interfaceName: interfaceName) + if external == nil { await server.start(interfaceName: environment["MUSUBI_E2E_INTERFACE"] ?? "en0") } defer { server.stop() } - let url = try #require(server.url, "Intercom did not start: \(server.error ?? "-")") + let url = try #require(external ?? server.url, "Intercom did not start: \(server.error ?? "-")") let feeder = Feeder(input: server.input) - feeder.start() + if external == nil { feeder.start() } defer { feeder.stop() } let profile = FileManager.default.temporaryDirectory.appendingPathComponent("musubi-chrome-\(UUID().uuidString)") @@ -61,7 +63,7 @@ import Testing })() """) let parts = last.split(separator: ",") - if parts.count >= 3, parts[0] == "connected", Int(parts[1]) ?? 0 >= 20, Int(parts[2]) ?? 0 >= 50 { break } + if parts.first == "connected", external != nil || (Int(parts[1]) ?? 0 >= 20 && Int(parts[2]) ?? 0 >= 50) { break } } let delay = try await devtools.evaluate(""" (async () => { @@ -76,6 +78,7 @@ import Testing print("Browser: \(last) · video jitter buffer \(delay) ms") let parts = last.split(separator: ",").prefix(3) #expect(parts.first == "connected", "state: \(last)") + guard external == nil else { return } #expect(Int(parts.dropFirst().first ?? "") ?? 0 >= 20, "video frames decoded: \(last)") #expect(Int(parts.last ?? "") ?? 0 >= 50, "audio packets received: \(last)") #expect(server.participants.first?.name == "Testgerät") diff --git a/project.yml b/project.yml index dd2ce75..7741609 100644 --- a/project.yml +++ b/project.yml @@ -37,7 +37,7 @@ targets: CFBundleShortVersionString: "0.1.0" CFBundleVersion: "1" LSApplicationCategoryType: public.app-category.video - NSLocalNetworkUsageDescription: Musubi sucht NDI-Quellen im Technik-Netz. + NSLocalNetworkUsageDescription: Musubi sucht NDI-Quellen im Technik-Netz und verbindet Handys im Klinik-Netz. NSMicrophoneUsageDescription: Musubi legt dein Headset-Mikro per Sprechtaste auf einen Ausgangskanal. NSBonjourServices: [_ndi._tcp, _musubi._tcp] settings: @@ -75,6 +75,8 @@ targets: product: DesignSystem - package: MusubiKit product: Remote + - package: MusubiKit + product: Intercom - framework: Vendor/NDI/lib/libndi.dylib embed: true codeSign: true @@ -89,6 +91,7 @@ schemes: - package: MusubiKit/MusubiCoreTests - package: MusubiKit/AudioMatrixTests - package: MusubiKit/RemoteTests + - package: MusubiKit/IntercomTests run: config: Debug archive: