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 <noreply@anthropic.com>
main
Samuel Zielke 1 day ago
parent 1dc6b58d8a
commit c61c543a65

@ -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 {

@ -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() }

@ -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()

@ -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()

@ -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..<frames { mono[i] = open ? stereo[i * 2] : 0 }
mono.withUnsafeBufferPointer { input.pushTalkback(UnsafeBufferPointer(rebasing: $0[0..<frames]), sampleRate: rate) }
}
}

@ -0,0 +1,163 @@
import CoreImage.CIFilterBuiltins
import DesignSystem
import Intercom
import SwiftUI
/// Intercom on the control: QR code for the phones, who is connected, what they see and hear.
struct IntercomView: View {
let intercom: IntercomController
let sourceList: SourceListModel
let audio: AudioController
@Environment(\.openSettings) private var openSettings
private var server: IntercomServer { intercom.server }
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Theme.Space.card) {
if !intercom.isEnabled {
VStack(alignment: .leading, spacing: Theme.Space.unit) {
Text("Intercom ist aus. Wähl in den Einstellungen das Klinik-Netz, dann verbinden sich Handys per QR-Code.")
.font(Theme.Font.body)
.foregroundStyle(Theme.Color.nezumi)
Button("Einstellungen öffnen") { openSettings() }
}
} else {
if let error = server.error {
Text(error).font(Theme.Font.body).foregroundStyle(Theme.Color.kohaku)
}
if intercom.sharesTechnicalInterface {
Text("Technik- und Klinik-Netz laufen über dieselbe Schnittstelle. Zum Testen okay, im Einsatz müssen sie getrennt sein.")
.font(Theme.Font.caption)
.foregroundStyle(Theme.Color.kohaku)
}
HStack(alignment: .top, spacing: Theme.Space.card) {
joinCard
VStack(alignment: .leading, spacing: Theme.Space.card) {
sourcesCard
participantsCard
}
}
}
}
.padding(Theme.Space.card)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(Theme.Color.sumi)
}
private var joinCard: some View {
Card {
Text("Handy verbinden").font(Theme.Font.title).foregroundStyle(Theme.Color.washi)
if let url = server.url {
QRCode(text: url.absoluteString)
.frame(width: 200, height: 200)
Text(verbatim: url.absoluteString)
.font(Theme.Font.number)
.lineLimit(1)
.minimumScaleFactor(0.6)
.foregroundStyle(Theme.Color.washi)
.textSelection(.enabled)
Text("Handy ins Klinik-Netz, QR-Code scannen, Name und Rolle eintragen.")
.font(Theme.Font.caption)
.foregroundStyle(Theme.Color.nezumi)
.fixedSize(horizontal: false, vertical: true)
} else {
Text("Startet …").font(Theme.Font.body).foregroundStyle(Theme.Color.nezumi)
}
}
.frame(width: 240)
}
private var sourcesCard: some View {
Card {
Picker("Vorschau", selection: Binding(get: { intercom.previewSourceName }, set: { intercom.previewSourceName = $0 })) {
Text("Keine").tag("")
ForEach(sourceList.sources, id: \.ndiName) { source in
Text(verbatim: source.ndiName).tag(source.ndiName)
}
if !intercom.previewSourceName.isEmpty, !sourceList.sources.contains(where: { $0.ndiName == intercom.previewSourceName }) {
Text(verbatim: "\(intercom.previewSourceName) · nicht gefunden").tag(intercom.previewSourceName)
}
}
Picker("Talkback", selection: Binding(get: { intercom.talkbackDeviceUID }, set: { intercom.talkbackDeviceUID = $0 })) {
if let name = intercom.automaticTalkbackName {
Text("Automatisch · \(name)").tag("")
} else {
Text("Automatisch").tag("")
}
ForEach(audio.matrix.inputDevices) { device in
Text(verbatim: device.name).tag(device.uid)
}
}
Text("Handys hören das Mikro, solange die Sprechtaste (\(audio.talkKey.displayName)) gedrückt ist.")
.font(Theme.Font.caption)
.foregroundStyle(Theme.Color.nezumi)
}
}
private var participantsCard: some View {
Card {
HStack {
Text("Verbunden").font(Theme.Font.title).foregroundStyle(Theme.Color.washi)
Spacer()
Text(verbatim: "\(server.participants.filter(\.connected).count)")
.font(Theme.Font.number)
.foregroundStyle(Theme.Color.nezumi)
}
if server.participants.isEmpty {
Text("Noch niemand da. Scann den QR-Code mit einem Handy.")
.font(Theme.Font.body)
.foregroundStyle(Theme.Color.nezumi)
}
ForEach(server.participants) { participant in
HStack(spacing: Theme.Space.unit) {
Circle()
.fill(participant.connected ? Theme.Color.moegi : Theme.Color.kohaku)
.frame(width: 8, height: 8)
Text(verbatim: participant.name).font(Theme.Font.body).foregroundStyle(Theme.Color.washi)
Text(verbatim: participant.role).font(Theme.Font.caption).foregroundStyle(Theme.Color.nezumi)
Spacer()
if !participant.connected {
Text("verbindet …").font(Theme.Font.caption).foregroundStyle(Theme.Color.nezumi)
}
}
}
}
}
}
private struct Card<Content: View>: 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)
}
}

@ -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")
}

@ -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() }
}
}

@ -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<AudioDeviceID>.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 }
}

@ -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<Float>, 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()

@ -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()
}
}

@ -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
)

@ -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")

@ -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:

Loading…
Cancel
Save

Powered by TurnKey Linux.