Compare commits

..

No commits in common. '439e2505ebd8d3ef7b1e57a2198183b304266077' and '2d6d0236d563a1e349d48b64e474bd5d77543e3f' have entirely different histories.

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Hardened runtime: headset microphone for the talk key. -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>

@ -1,36 +0,0 @@
import Foundation
import MusubiCore
import Observation
import ServiceManagement
import SystemConfiguration
/// Persistent app settings (UserDefaults).
@MainActor @Observable
final class AppSettings {
var role: Role {
didSet { UserDefaults.standard.set(role.rawValue, forKey: "role") }
}
/// BSD name of the technical network interface, e.g. "en7".
var interfaceName: String {
didSet { UserDefaults.standard.set(interfaceName, forKey: "interfaceName") }
}
/// 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"
}
var launchAtLogin: Bool {
get { SMAppService.mainApp.status == .enabled }
set {
do {
if newValue { try SMAppService.mainApp.register() } else { try SMAppService.mainApp.unregister() }
} catch {
Log.ndi.error("Login item: \(error.localizedDescription, privacy: .public)")
}
}
}
}

@ -18,8 +18,6 @@ final class AudioController {
/// Highest peak per route since the last stats line.
private(set) var minutePeaks: [UUID: Float] = [:]
var selectedDeviceUID: String?
/// Called after routes or their settings changed (for autosave and scenes).
var onChange: (() -> Void)?
private var ndiReceivers: [String: NDIReceiver] = [:]
private var meterTimer: Timer?
@ -49,90 +47,11 @@ final class AudioController {
}
matrix.addRoute(source, deviceUID: device.uid, channel: channel)
syncNDIReceivers()
onChange?()
}
func remove(_ id: UUID) {
matrix.removeRoute(id)
syncNDIReceivers()
onChange?()
}
func setGain(_ id: UUID, db: Float) {
matrix.setGain(id, db: db)
onChange?()
}
func setMuted(_ id: UUID, _ muted: Bool) {
matrix.setMuted(id, muted)
onChange?()
}
func setSelection(_ id: UUID, _ selection: ChannelSelection) {
matrix.setSelection(id, selection)
onChange?()
}
// MARK: - Scenes
/// The current routes as a scene configuration.
func currentAudio() -> [AudioAssignment] {
matrix.routes.map(assignment(for:))
}
/// Replaces all routes: keeps identical ones (no gap), updates settings, adds and removes the rest.
/// Devices are matched by UID, then by name; missing devices start as soon as they are plugged in.
func apply(_ audio: [AudioAssignment]) {
let desired = Dictionary(audio.map(resolve).map { ($0.key, $0) }, uniquingKeysWith: { first, _ in first })
let current = Dictionary(matrix.routes.map { (assignment(for: $0).key, $0) }, uniquingKeysWith: { first, _ in first })
for (key, route) in current where desired[key] == nil {
matrix.removeRoute(route.id)
}
for (key, assignment) in desired {
let id: UUID
if let existing = current[key] {
id = existing.id
} else {
let source: AudioMatrix.SourceRef = switch assignment.source {
case .ndi(let name): .ndi(name: name)
case .input(let uid, _): .input(deviceUID: uid)
}
id = matrix.addRoute(source, deviceUID: assignment.deviceUID, channel: assignment.channel, selection: assignment.selection)
}
matrix.setSelection(id, assignment.selection)
matrix.setGain(id, db: assignment.gainDB)
matrix.setMuted(id, assignment.muted)
}
if let first = audio.first {
selectedDeviceUID = resolve(first).deviceUID
}
syncNDIReceivers()
onChange?()
}
private func assignment(for route: AudioMatrix.Route) -> AudioAssignment {
let source: AudioAssignment.SourceSpec = switch route.source {
case .ndi(let name): .ndi(name: name)
case .input(let uid): .input(uid: uid, name: matrix.devices.first { $0.uid == uid }?.name ?? uid)
}
let deviceName = matrix.devices.first { $0.uid == route.deviceUID }?.name ?? route.deviceUID
return AudioAssignment(source: source, deviceUID: route.deviceUID, deviceName: deviceName, channel: route.channel,
selection: route.selection, gainDB: route.gainDB, muted: route.muted)
}
private func resolve(_ assignment: AudioAssignment) -> AudioAssignment {
var resolved = assignment
if !matrix.devices.contains(where: { $0.uid == assignment.deviceUID }),
let byName = matrix.outputDevices.first(where: { $0.name == assignment.deviceName }) {
resolved.deviceUID = byName.uid
}
if case .input(let uid, let name) = assignment.source,
!matrix.devices.contains(where: { $0.uid == uid }),
let byName = matrix.inputDevices.first(where: { $0.name == name }) {
resolved.source = .input(uid: byName.uid, name: name)
}
return resolved
}
func label(for source: AudioMatrix.SourceRef) -> String {

@ -4,31 +4,25 @@ import SwiftUI
struct ContentView: View {
let sourceList: SourceListModel
let coordinator: Coordinator
let outputs: OutputController
let audio: AudioController
@AppStorage("detailTab") private var tab = DetailTab.screens
enum DetailTab: String { case screens, sound }
var body: some View {
NavigationSplitView {
SourceListView(sourceList: sourceList, audio: coordinator.audio)
SourceListView(sourceList: sourceList)
.navigationSplitViewColumnWidth(min: 220, ideal: 260)
} detail: {
switch tab {
case .screens: ScreensView(coordinator: coordinator)
case .sound: SoundView(audio: coordinator.audio, sourceList: sourceList)
case .screens: ScreensView(outputs: outputs)
case .sound: SoundView(audio: audio)
}
}
.navigationTitle("Musubi")
.navigationSubtitle("NDI rein. Bild, Ton und Stimme raus.")
.toolbar {
ToolbarItem(placement: .navigation) {
if coordinator.settings.role == .control {
SceneMenu(coordinator: coordinator)
} else {
Text("Knoten").font(Theme.Font.caption).foregroundStyle(Theme.Color.nezumi)
}
}
ToolbarItem(placement: .principal) {
Picker("Ansicht", selection: $tab) {
Text("Bildschirme").tag(DetailTab.screens)
@ -37,7 +31,7 @@ struct ContentView: View {
.pickerStyle(.segmented)
.fixedSize()
}
if coordinator.outputs.isSleepBlocked {
if outputs.isSleepBlocked {
ToolbarItem(placement: .primaryAction) {
Text("Ruhezustand blockiert")
.font(Theme.Font.caption)
@ -50,39 +44,24 @@ struct ContentView: View {
private struct SourceListView: View {
let sourceList: SourceListModel
let audio: AudioController
var body: some View {
List {
Section("NDI") {
switch sourceList.state {
case .unavailable:
HintRow(text: "NDI lässt sich nicht starten. Prüfe die Installation des NDI SDK.")
case .idle, .searching:
if sourceList.sources.isEmpty {
HintRow(text: "Keine NDI-Quellen gefunden. Prüfe, ob das Technik-Netz an en7 hängt.")
} else {
ForEach(sourceList.sources, id: \.ndiName) { source in
SourceRow(source: source)
.draggable(source.ndiName)
}
}
}
}
Section("Mikrofone") {
ForEach(audio.matrix.inputDevices) { device in
Label {
Text(verbatim: device.name)
.font(Theme.Font.body)
.foregroundStyle(Theme.Color.washi)
} icon: {
Image(systemName: "mic").foregroundStyle(Theme.Color.nezumi)
Group {
switch sourceList.state {
case .unavailable:
EmptyStateView(text: "NDI lässt sich nicht starten. Prüfe die Installation des NDI SDK.")
case .idle, .searching:
if sourceList.sources.isEmpty {
EmptyStateView(text: "Keine NDI-Quellen gefunden. Prüfe, ob das Technik-Netz an en7 hängt.")
} else {
List(sourceList.sources, id: \.ndiName) { source in
SourceRow(source: source)
.draggable(source.ndiName)
}
.draggable("input:\(device.uid)")
.scrollContentBackground(.hidden)
}
}
}
.scrollContentBackground(.hidden)
.background(Theme.Color.kasumi)
.safeAreaInset(edge: .top, spacing: 0) {
Text("Quellen")
@ -94,17 +73,6 @@ private struct SourceListView: View {
}
}
private struct HintRow: View {
let text: LocalizedStringKey
var body: some View {
Text(text)
.font(Theme.Font.caption)
.foregroundStyle(Theme.Color.nezumi)
.fixedSize(horizontal: false, vertical: true)
}
}
private struct SourceRow: View {
let source: Source
@ -120,3 +88,17 @@ private struct SourceRow: View {
.padding(.vertical, Theme.Space.unit / 2)
}
}
private struct EmptyStateView: View {
let text: LocalizedStringKey
var body: some View {
Text(text)
.font(Theme.Font.body)
.foregroundStyle(Theme.Color.nezumi)
.multilineTextAlignment(.center)
.padding(Theme.Space.card * 2)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Theme.Color.sumi)
}
}

@ -1,242 +0,0 @@
import Foundation
import MusubiCore
import Observation
import Remote
/// Ties outputs, audio, scenes and remote control together according to the role.
@MainActor @Observable
final class Coordinator {
let settings: AppSettings
let outputs: OutputController
let audio: AudioController
let store = SceneStore()
private(set) var live = LiveState()
private(set) var nodeServer: NodeServer?
private(set) var browser: NodeBrowser?
private var statusTimer: Timer?
private var cpu = ProcessCPU()
private var restoring = false
init(settings: AppSettings, outputs: OutputController, audio: AudioController) {
self.settings = settings
self.outputs = outputs
self.audio = audio
}
/// Restores the last live state, starts remote control, then applies launch arguments.
func start() async {
live = store.loadLiveState()
restoring = true
outputs.apply(live.local.video)
audio.apply(live.local.audio)
restoring = false
Log.ndi.info("Restored \(self.live.sceneName ?? "live state", privacy: .public): \(self.live.local.video.count) video, \(self.live.local.audio.count) audio")
outputs.onChange = { [weak self] in self?.localChanged() }
audio.onChange = { [weak self] in self?.localChanged() }
await startRemote()
outputs.applyLaunchRoutes()
audio.applyLaunchRoutes()
applyLoginItemArgument()
if let index = ProcessInfo.processInfo.arguments.firstIndex(of: "--load-scene"),
index + 1 < ProcessInfo.processInfo.arguments.count {
let name = ProcessInfo.processInfo.arguments[index + 1]
if let scene = store.scenes.first(where: { $0.name == name }) { loadScene(scene) }
else { Log.remote.error("--load-scene: no scene named \(name, privacy: .public)") }
}
statusTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.sendStatus() }
}
}
/// `--login-item on|off`: set up a headless node over SSH, where the settings window is hidden behind outputs.
private func applyLoginItemArgument(_ arguments: [String] = ProcessInfo.processInfo.arguments) {
guard let index = arguments.firstIndex(of: "--login-item"), index + 1 < arguments.count else { return }
settings.launchAtLogin = arguments[index + 1] == "on"
Log.ndi.info("Launch at login: \(self.settings.launchAtLogin ? "on" : "off", privacy: .public)")
}
/// (Re)starts the Bonjour side that fits the role; call after role or interface changes.
func startRemote() async {
nodeServer?.stop()
nodeServer = nil
browser?.stop()
browser = nil
switch settings.role {
case .node:
let server = NodeServer(name: settings.machineName)
server.onCommand = { [weak self] in self?.handle($0) }
nodeServer = server
await server.start(interfaceName: settings.interfaceName)
case .control:
let browser = NodeBrowser(name: settings.machineName)
browser.onNodeConnected = { [weak self] in self?.nodeConnected($0) }
self.browser = browser
await browser.start(interfaceName: settings.interfaceName)
}
}
var remoteError: String? { nodeServer?.error ?? browser?.error }
// MARK: - Control: nodes
/// Shows a source on a node's screen.
func show(_ assignment: VideoAssignment, onNode name: String) {
Log.remote.info("UI: show \(assignment.source.ndiName, privacy: .public) on \(name, privacy: .public)/\(assignment.screen.name, privacy: .public)")
var config = desiredConfig(forNode: name)
config.video.removeAll { $0.screen == assignment.screen }
config.video.append(assignment)
live.nodes[name] = config
browser?.node(named: name)?.send(.show(assignment))
markModified()
}
func stop(_ screen: ScreenRef, onNode name: String) {
Log.remote.info("UI: stop \(name, privacy: .public)/\(screen.name, privacy: .public)")
var config = desiredConfig(forNode: name)
config.video.removeAll { $0.screen == screen }
live.nodes[name] = config
browser?.node(named: name)?.send(.stop(screen))
markModified()
}
private func desiredConfig(forNode name: String) -> MachineConfig {
live.nodes[name] ?? browser?.node(named: name)?.status?.config ?? .empty
}
/// A node (re)connected: it gets what the control wants, or the control adopts what the node shows.
private func nodeConnected(_ node: NodeBrowser.Node) {
if let desired = live.nodes[node.name] {
node.send(.apply(desired))
Log.remote.info("Sent configuration to \(node.name, privacy: .public): \(desired.video.count) video")
} else {
adoptWhenStatusArrives(node)
}
}
private func adoptWhenStatusArrives(_ node: NodeBrowser.Node) {
Task { [weak self] in
for _ in 0..<10 {
try? await Task.sleep(for: .seconds(1))
guard let self, live.nodes[node.name] == nil else { return }
if let config = node.status?.config {
Log.remote.info("Adopted configuration of \(node.name, privacy: .public): \(config.video.count) video")
live.nodes[node.name] = config
store.saveLiveState(live)
return
}
}
}
}
// MARK: - Node: commands from the control
private func handle(_ message: RemoteMessage) {
Log.remote.info("Command: \(String(describing: message).prefix(200), privacy: .public)")
switch message {
case .apply(let config):
outputs.apply(config.video)
audio.apply(config.audio)
case .show(let assignment):
outputs.show(assignment)
case .stop(let screen):
outputs.stop(screen)
case .hello, .status:
break
}
}
private func sendStatus() {
let cpuPercent = cpu.sample()
guard let nodeServer else { return }
nodeServer.send(NodeStatus(
screens: outputs.screenStatuses(),
config: MachineConfig(video: outputs.desired, audio: audio.currentAudio()),
cpuPercent: cpuPercent,
sleepBlocked: outputs.isSleepBlocked
))
}
// MARK: - Scenes (control)
var currentSceneName: String? { live.sceneName }
var isModified: Bool { live.modified }
func saveScene(as name: String? = nil) {
var scene = Scene(
id: name == nil ? (live.sceneID ?? UUID()) : UUID(),
name: name ?? live.sceneName ?? "Szene",
control: live.local,
nodes: currentNodeConfigs()
)
if let name, let existing = store.scenes.first(where: { $0.name == name }) { scene.id = existing.id }
store.save(scene)
live.sceneID = scene.id
live.sceneName = scene.name
live.modified = false
store.saveLiveState(live)
}
func loadScene(_ scene: Scene) {
restoring = true
outputs.apply(scene.control.video)
audio.apply(scene.control.audio)
restoring = false
live = LiveState(local: scene.control, nodes: scene.nodes, sceneID: scene.id, sceneName: scene.name, modified: false)
for (name, config) in scene.nodes {
browser?.node(named: name)?.send(.apply(config))
}
store.saveLiveState(live)
Log.ndi.info("Scene \(scene.name, privacy: .public) loaded")
}
func exportableScene() -> Scene {
Scene(id: live.sceneID ?? UUID(), name: live.sceneName ?? "Szene", control: live.local, nodes: currentNodeConfigs())
}
private func currentNodeConfigs() -> [String: MachineConfig] {
var nodes = live.nodes
for node in browser?.nodes ?? [] where nodes[node.name] == nil {
if let config = node.status?.config { nodes[node.name] = config }
}
return nodes
}
// MARK: - Live state
private func localChanged() {
live.local = MachineConfig(video: outputs.desired, audio: audio.currentAudio())
guard !restoring else { return }
markModified()
}
private func markModified() {
Log.remote.debug("Live state saved: local \(self.live.local.video.count) video, nodes \(self.live.nodes.mapValues(\.video.count), privacy: .public)")
if live.sceneID != nil { live.modified = true }
store.saveLiveState(live)
}
}
/// CPU usage of this process since the last sample, in percent of one core.
struct ProcessCPU {
private var lastCPU = ProcessCPU.cpuSeconds()
private var lastWall = ProcessInfo.processInfo.systemUptime
mutating func sample() -> Double {
let cpu = Self.cpuSeconds(), wall = ProcessInfo.processInfo.systemUptime
defer { lastCPU = cpu; lastWall = wall }
return wall > lastWall ? (cpu - lastCPU) / (wall - lastWall) * 100 : 0
}
static func cpuSeconds() -> Double {
var usage = rusage()
getrusage(RUSAGE_SELF, &usage)
func seconds(_ t: timeval) -> Double { Double(t.tv_sec) + Double(t.tv_usec) / 1_000_000 }
return seconds(usage.ru_utime) + seconds(usage.ru_stime)
}
}

@ -1,4 +1,3 @@
import AppKit
import DesignSystem
import NDIKit
import OSLog
@ -6,37 +5,25 @@ import SwiftUI
@main
struct MusubiApp: App {
@NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
@State private var sourceList = SourceListModel()
@State private var outputs = OutputController()
@State private var audio = AudioController()
var body: some SwiftUI.Scene {
Window("Musubi", id: "main") {
ContentView(sourceList: appDelegate.sourceList, coordinator: appDelegate.coordinator)
ContentView(sourceList: sourceList, outputs: outputs, audio: audio)
.preferredColorScheme(.dark)
.task {
sourceList.start()
outputs.applyLaunchRoutes()
audio.applyLaunchRoutes()
}
}
.defaultSize(width: 960, height: 600)
Settings {
SettingsView(coordinator: appDelegate.coordinator)
.preferredColorScheme(.dark)
}
}
}
/// Starts discovery, outputs, audio and remote control at launch – independent of any window.
/// As a login item macOS may launch Musubi without opening its window.
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
let sourceList = SourceListModel()
let coordinator = Coordinator(settings: AppSettings(), outputs: OutputController(), audio: AudioController())
func applicationDidFinishLaunching(_ notification: Notification) {
sourceList.start()
Task { await coordinator.start() }
}
}
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")
}

@ -1,7 +1,6 @@
import AppKit
import MusubiCore
import NDIKit
import Remote
import Observation
import VideoOutput
@ -21,8 +20,7 @@ final class ActiveOutput {
let receiver: NDIReceiver
let window: OutputWindow
let displayID: CGDirectDisplayID
/// Mode before Musubi switched to 50 Hz; restored when the output ends.
var originalMode: CGDisplayMode?
let originalMode: CGDisplayMode?
var snapshot: NDIReceiver.Snapshot
init(source: Source, receiver: NDIReceiver, window: OutputWindow, displayID: CGDirectDisplayID, originalMode: CGDisplayMode?) {
@ -41,11 +39,6 @@ final class OutputController {
private(set) var screens: [ScreenInfo] = []
private(set) var outputs: [CGDirectDisplayID: ActiveOutput] = [:]
/// What should be shown. Assignments for screens that are not connected stay here and start
/// as soon as the screen appears (monitors often come up late after a reboot).
private(set) var desired: [VideoAssignment] = []
/// Called after the desired configuration changed (for autosave and remote status).
var onChange: (() -> Void)?
/// Bumped once per second so views re-read the receiver snapshots.
private(set) var tick = 0
@ -58,135 +51,37 @@ final class OutputController {
init() {
refreshScreens()
NotificationCenter.default.addObserver(forName: NSApplication.didChangeScreenParametersNotification, object: nil, queue: .main) { [weak self] _ in
// Deferred: the notification can arrive synchronously inside a display mode switch.
DispatchQueue.main.async { MainActor.assumeIsolated { self?.screensChanged() } }
MainActor.assumeIsolated { self?.screensChanged() }
}
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.updateSnapshots() }
}
}
/// Shows `source` on a screen and remembers it.
func show(_ source: Source, on displayID: CGDirectDisplayID) {
guard let ref = screenRef(for: displayID) else { return }
show(VideoAssignment(screen: ref, source: source))
}
func show(_ assignment: VideoAssignment) {
desired.removeAll { $0.screen == assignment.screen }
desired.append(assignment)
reconcile()
onChange?()
}
/// Stops the output on a screen and forgets it.
func stop(_ displayID: CGDirectDisplayID) {
guard let ref = screenRef(for: displayID) else { return halt(displayID) }
stop(ref)
}
func stop(_ ref: ScreenRef) {
let names = NSScreen.screens.map(\.localizedName)
let index = ScreenMatching.index(of: ref, in: names)
desired.removeAll { $0.screen == ref || (index != nil && ScreenMatching.index(of: $0.screen, in: names) == index) }
reconcile()
onChange?()
}
/// Replaces the whole video configuration (scene load, remote apply, restore).
func apply(_ video: [VideoAssignment]) {
desired = video
reconcile()
onChange?()
}
private var reconciling = false
private var reconcileAgain = false
/// Starts and stops outputs so that connected screens match `desired`.
/// Never nested: display mode switches post screen-change notifications while we are in here.
private func reconcile() {
guard !reconciling else {
reconcileAgain = true
return
}
reconciling = true
defer { reconciling = false }
repeat {
reconcileAgain = false
reconcileOnce()
} while reconcileAgain
}
private func reconcileOnce() {
let screens = NSScreen.screens
let resolved = ScreenMatching.resolve(desired, in: screens.map(\.localizedName))
let wanted = Dictionary(uniqueKeysWithValues: resolved.map { (screens[$0.key].displayID, $0.value) })
let current = outputs.mapValues(\.source)
let plan = Reconcile.plan(current: current, desired: wanted)
plan.stop.forEach(halt)
for (displayID, source) in plan.start {
start(source, on: displayID)
}
}
func screenRef(for displayID: CGDirectDisplayID) -> ScreenRef? {
guard let index = NSScreen.screens.firstIndex(where: { $0.displayID == displayID }) else { return nil }
return ScreenRef(name: NSScreen.screens[index].localizedName, index: index)
}
/// Screens with their outputs, for the remote status.
func screenStatuses() -> [ScreenStatus] {
screens.compactMap { screen in
guard let ref = screenRef(for: screen.id) else { return nil }
let output = outputs[screen.id].map { output -> OutputStatus in
let s = output.snapshot
let state: OutputStatus.State = switch s.state {
case .connecting: .connecting
case .live: .live
case .stalled: .stalled
}
return OutputStatus(source: output.source, state: state, width: s.width, height: s.height, frameRate: s.frameRate,
lostFrames: s.stats.missing + s.stats.droppedByReceiver, lossRate: s.stats.lossRate)
}
return ScreenStatus(screen: ref, width: screen.width, height: screen.height, refreshRate: screen.refreshRate, output: output)
}
}
private func start(_ source: Source, on displayID: CGDirectDisplayID) {
guard let screen = NSScreen.screens.first(where: { $0.displayID == displayID }) else { return }
// Switching sources on a screen keeps its 50 Hz mode instead of bouncing to 60 Hz and back.
let previous = outputs.removeValue(forKey: displayID)
previous?.receiver.stop()
stop(displayID)
let window = OutputWindow(screen: screen)
let sink = window.sink
let receiverName = "Musubi \(screen.localizedName)"
guard let receiver = NDIReceiver(source: source, receiverName: receiverName, onFrame: { sink.display($0) }) else {
Log.ndi.error("Could not create receiver for \(source.ndiName, privacy: .public)")
previous?.window.orderOut(nil)
if let original = previous?.originalMode { DisplayModeSwitcher.restore(original, on: displayID) }
return
}
window.onCancel = { [weak self] in self?.stop(displayID) }
window.setFrame(screen.frame, display: true)
window.orderFrontRegardless()
previous?.window.orderOut(nil)
// Register before the mode switch: it posts a screen-change notification.
let output = ActiveOutput(source: source, receiver: receiver, window: window, displayID: displayID, originalMode: previous?.originalMode)
outputs[displayID] = output
let mode = DisplayModeSwitcher.setRefreshRate(Self.targetRefreshRate, on: displayID)
if output.originalMode == nil { output.originalMode = mode.original }
window.onCancel = { [weak self] in self?.stop(displayID) }
window.setFrame(screen.frame, display: true)
window.orderFrontRegardless()
outputs[displayID] = ActiveOutput(source: source, receiver: receiver, window: window, displayID: displayID, originalMode: mode.original)
Log.ndi.info("Output \(source.ndiName, privacy: .public) → \(screen.localizedName, privacy: .public) @ \(mode.refreshRate) Hz")
updateSleepBlocker()
refreshScreens()
}
private func halt(_ displayID: CGDirectDisplayID) {
func stop(_ displayID: CGDirectDisplayID) {
guard let output = outputs.removeValue(forKey: displayID) else { return }
output.receiver.stop()
output.window.orderOut(nil)
@ -226,19 +121,17 @@ final class OutputController {
}
private func screensChanged() {
let connected = Set(NSScreen.screens.map(\.displayID))
for displayID in outputs.keys where !connected.contains(displayID) {
Log.ndi.notice("Display \(displayID) disconnected, stopping its output")
stop(displayID)
}
// Keep windows covering their screen after resolution changes.
for output in outputs.values {
if let screen = NSScreen.screens.first(where: { $0.displayID == output.displayID }) {
output.window.setFrame(screen.frame, display: true)
}
}
let connected = Set(NSScreen.screens.map(\.displayID))
for displayID in outputs.keys where !connected.contains(displayID) {
Log.ndi.notice("Display \(displayID) disconnected, output paused until it returns")
halt(displayID)
}
// Screens that came (back) get their remembered output.
reconcile()
refreshScreens()
}

@ -1,93 +0,0 @@
import DesignSystem
import MusubiCore
import SwiftUI
import UniformTypeIdentifiers
/// Toolbar menu: current scene name and scene actions (control only).
struct SceneMenu: View {
let coordinator: Coordinator
@State private var askingName = false
@State private var newName = ""
@State private var exporting = false
@State private var importing = false
@State private var importError: String?
var body: some View {
Menu {
if !coordinator.store.scenes.isEmpty {
Section("Szene laden") {
ForEach(coordinator.store.scenes) { scene in
Button(scene.name) { coordinator.loadScene(scene) }
}
}
}
Button("Speichern") {
if coordinator.currentSceneName == nil { askName() } else { coordinator.saveScene() }
}
.keyboardShortcut("s")
Button("Speichern als …") { askName() }
.keyboardShortcut("s", modifiers: [.command, .shift])
Divider()
Button("Exportieren …") { exporting = true }
.disabled(coordinator.currentSceneName == nil)
Button("Importieren …") { importing = true }
} label: {
HStack(spacing: Theme.Space.unit / 2) {
Text(verbatim: coordinator.currentSceneName ?? String(localized: "Keine Szene"))
if coordinator.isModified {
Text("· geändert").foregroundStyle(Theme.Color.nezumi)
}
}
}
.fixedSize()
.help("Szene")
.alert("Szene speichern", isPresented: $askingName) {
TextField("Name", text: $newName)
Button("Speichern") {
let name = newName.trimmingCharacters(in: .whitespaces)
if !name.isEmpty { coordinator.saveScene(as: name) }
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Speichert, was Regie und Knoten gerade zeigen und wohin der Ton geht.")
}
.fileExporter(isPresented: $exporting, document: SceneDocument(scene: coordinator.exportableScene()),
contentType: .json, defaultFilename: coordinator.currentSceneName ?? "Szene") { _ in }
.fileImporter(isPresented: $importing, allowedContentTypes: [.json]) { result in
do {
let scene = try coordinator.store.importScene(from: result.get())
coordinator.loadScene(scene)
} catch {
importError = error.localizedDescription
}
}
.alert("Import fehlgeschlagen", isPresented: Binding(get: { importError != nil }, set: { if !$0 { importError = nil } })) {
Button("OK") {}
} message: {
Text("Die Datei ist keine Musubi-Szene.")
}
}
private func askName() {
newName = coordinator.currentSceneName ?? ""
askingName = true
}
}
struct SceneDocument: FileDocument {
static let readableContentTypes: [UTType] = [.json]
var scene: MusubiCore.Scene
init(scene: MusubiCore.Scene) { self.scene = scene }
init(configuration: ReadConfiguration) throws {
guard let data = configuration.file.regularFileContents else { throw CocoaError(.fileReadCorruptFile) }
scene = try JSONDecoder().decode(MusubiCore.Scene.self, from: data)
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return FileWrapper(regularFileWithContents: try encoder.encode(scene))
}
}

@ -1,92 +0,0 @@
import Foundation
import MusubiCore
import Observation
/// What is running right now, saved continuously so a restart (or power loss) brings it back.
struct LiveState: Codable, Equatable {
var local = MachineConfig.empty
/// Control only: what each node should show.
var nodes: [String: MachineConfig] = [:]
var sceneID: UUID?
var sceneName: String?
/// The live state differs from the saved scene.
var modified = false
}
/// Saved scenes and the live state in ~/Library/Application Support/Musubi.
@MainActor @Observable
final class SceneStore {
private(set) var scenes: [Scene] = []
private let directory: URL
private var saveTask: Task<Void, Never>?
init() {
directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appending(path: "Musubi", directoryHint: .isDirectory)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
scenes = (try? load([Scene].self, from: "scenes.json")) ?? []
}
// MARK: Live state
func loadLiveState() -> LiveState {
(try? load(LiveState.self, from: "live-state.json")) ?? LiveState()
}
/// Debounced: many small changes (gain slider) end up as one write.
func saveLiveState(_ state: LiveState) {
saveTask?.cancel()
saveTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return }
self?.write(state, to: "live-state.json")
}
}
// MARK: Scenes
func save(_ scene: Scene) {
if let index = scenes.firstIndex(where: { $0.id == scene.id }) {
scenes[index] = scene
} else {
scenes.append(scene)
scenes.sort { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}
write(scenes, to: "scenes.json")
}
func delete(_ id: UUID) {
scenes.removeAll { $0.id == id }
write(scenes, to: "scenes.json")
}
func scene(_ id: UUID?) -> Scene? {
scenes.first { $0.id == id }
}
/// Imports a scene file; a scene with the same name is replaced.
func importScene(from url: URL) throws -> Scene {
let accessing = url.startAccessingSecurityScopedResource()
defer { if accessing { url.stopAccessingSecurityScopedResource() } }
var scene = try JSONDecoder().decode(Scene.self, from: Data(contentsOf: url))
if let existing = scenes.first(where: { $0.name == scene.name }) { scene.id = existing.id }
save(scene)
return scene
}
// MARK: Files
private func load<T: Decodable>(_ type: T.Type, from name: String) throws -> T {
try JSONDecoder().decode(type, from: Data(contentsOf: directory.appending(path: name)))
}
private func write<T: Encodable>(_ value: T, to name: String) {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try encoder.encode(value).write(to: directory.appending(path: name), options: .atomic)
} catch {
Log.ndi.error("Saving \(name, privacy: .public) failed: \(error.localizedDescription, privacy: .public)")
}
}
}

@ -1,198 +1,60 @@
import DesignSystem
import MusubiCore
import Remote
import SwiftUI
/// Local screens and, on the control, the screens of every node. Drop a source on a card to show it there.
/// Cards for all connected screens; drop a source on a card to show it fullscreen there.
struct ScreensView: View {
let coordinator: Coordinator
private var outputs: OutputController { coordinator.outputs }
let outputs: OutputController
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Theme.Space.card) {
RoleBanner(coordinator: coordinator)
SectionTitle(text: coordinator.settings.machineName, detail: coordinator.settings.role == .control ? "Regie" : "Knoten")
if outputs.outputs.isEmpty && coordinator.settings.role == .control && (coordinator.browser?.nodes.isEmpty ?? true) {
Text("Bildschirme")
.font(Theme.Font.title)
.foregroundStyle(Theme.Color.washi)
if outputs.outputs.isEmpty {
Text("Noch keine Szene. Zieh eine Quelle auf einen Bildschirm.")
.font(Theme.Font.body)
.foregroundStyle(Theme.Color.nezumi)
}
CardGrid {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 260), spacing: Theme.Space.card)], spacing: Theme.Space.card) {
ForEach(outputs.screens) { screen in
ScreenCard(
title: screen.name,
detail: Self.format(width: screen.width, height: screen.height, hz: screen.refreshRate),
output: outputs.outputs[screen.id].map { OutputCardModel(local: $0) },
tick: outputs.tick,
onStop: { outputs.stop(screen.id) }
)
ScreenCard(screen: screen, output: outputs.outputs[screen.id], tick: outputs.tick) {
outputs.stop(screen.id)
}
.dropDestination(for: String.self) { names, _ in
guard let name = names.first, !name.hasPrefix("input:") else { return false }
guard let name = names.first else { return false }
outputs.show(Source(ndiName: name), on: screen.id)
return true
}
}
}
if let browser = coordinator.browser {
ForEach(browser.nodes) { node in
NodeSection(coordinator: coordinator, node: node)
}
}
}
.padding(Theme.Space.card)
}
.background(Theme.Color.sumi)
}
static func format(width: Int, height: Int, hz: Double) -> String {
"\(width) × \(height) · \(hz.formatted(.number.precision(.fractionLength(0...2)))) Hz"
}
}
private struct RoleBanner: View {
let coordinator: Coordinator
var body: some View {
if let error = coordinator.remoteError {
Text(error).font(Theme.Font.body).foregroundStyle(Theme.Color.kohaku)
} else if coordinator.settings.role == .node, let server = coordinator.nodeServer {
Text(server.controlName.map { "Knoten · gesteuert von \($0)" } ?? "Knoten · wartet auf die Regie")
.font(Theme.Font.body)
.foregroundStyle(Theme.Color.nezumi)
}
}
}
private struct NodeSection: View {
let coordinator: Coordinator
let node: NodeBrowser.Node
var body: some View {
VStack(alignment: .leading, spacing: Theme.Space.card) {
HStack(spacing: Theme.Space.unit) {
Circle()
.fill(node.isConnected ? Theme.Color.moegi : Theme.Color.kohaku)
.frame(width: Theme.Stroke.knot, height: Theme.Stroke.knot)
SectionTitle(text: node.name, detail: "Knoten")
Spacer()
if let status = node.status, node.isConnected {
Text(verbatim: "CPU \(Int(status.cpuPercent.rounded())) %")
.font(Theme.Font.number)
.foregroundStyle(Theme.Color.nezumi)
if status.sleepBlocked {
Text("Ruhezustand blockiert").font(Theme.Font.caption).foregroundStyle(Theme.Color.nezumi)
}
}
}
if !node.isConnected {
Text("\(node.name) antwortet nicht. Neuer Versuch läuft.")
.font(Theme.Font.body)
.foregroundStyle(Theme.Color.kohaku)
}
CardGrid {
ForEach(node.status?.screens ?? []) { screen in
ScreenCard(
title: screen.screen.name,
detail: ScreensView.format(width: screen.width, height: screen.height, hz: screen.refreshRate),
output: screen.output.map { OutputCardModel(remote: $0) },
tick: 0,
onStop: { coordinator.stop(screen.screen, onNode: node.name) }
)
.opacity(node.isConnected ? 1 : 0.5)
.dropDestination(for: String.self) { names, _ in
guard let name = names.first, !name.hasPrefix("input:") else { return false }
coordinator.show(VideoAssignment(screen: screen.screen, source: Source(ndiName: name)), onNode: node.name)
return true
}
}
}
}
.padding(.top, Theme.Space.card)
}
}
private struct SectionTitle: View {
let text: String
let detail: LocalizedStringKey
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: Theme.Space.unit) {
Text(verbatim: text).font(Theme.Font.title).foregroundStyle(Theme.Color.washi)
Text(detail).font(Theme.Font.caption).foregroundStyle(Theme.Color.nezumi)
}
}
}
private struct CardGrid<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 260), spacing: Theme.Space.card)], spacing: Theme.Space.card) {
content
}
}
}
/// What a screen card shows about an output, for local and remote screens alike.
struct OutputCardModel {
var label: String
var state: OutputStatus.State
var retryIn: Double?
var width: Int
var height: Int
var frameRate: Double
var lostFrames: Int64
var lossRate: Double
@MainActor init(local output: ActiveOutput) {
let s = output.snapshot
label = output.source.label
switch s.state {
case .connecting: state = .connecting
case .live: state = .live
case .stalled(let retry): state = .stalled; retryIn = retry
}
width = s.width
height = s.height
frameRate = s.frameRate
lostFrames = s.stats.missing + s.stats.droppedByReceiver
lossRate = s.stats.lossRate
}
init(remote status: OutputStatus) {
label = status.source.label
state = status.state
width = status.width
height = status.height
frameRate = status.frameRate
lostFrames = status.lostFrames
lossRate = status.lossRate
}
}
private struct ScreenCard: View {
let title: String
let detail: String
let output: OutputCardModel?
let screen: ScreenInfo
let output: ActiveOutput?
let tick: Int
let onStop: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: Theme.Space.unit) {
Text(verbatim: title)
Text(verbatim: screen.name)
.font(Theme.Font.title)
.foregroundStyle(Theme.Color.washi)
Text(verbatim: detail)
Text(verbatim: "\(screen.width) × \(screen.height) · \(screen.refreshRate.formatted(.number.precision(.fractionLength(0...2)))) Hz")
.font(Theme.Font.number)
.foregroundStyle(Theme.Color.nezumi)
Divider().overlay(Theme.Color.usuzumi)
if let output {
OutputStatusView(output: output)
OutputStatus(output: output, tick: tick)
Button("Ausgabe beenden", action: onStop)
.buttonStyle(.bordered)
} else {
@ -211,16 +73,18 @@ private struct ScreenCard: View {
}
}
private struct OutputStatusView: View {
let output: OutputCardModel
private struct OutputStatus: View {
let output: ActiveOutput
let tick: Int
var body: some View {
let snapshot = output.snapshot
VStack(alignment: .leading, spacing: Theme.Space.unit / 2) {
Text(verbatim: output.label)
Text(verbatim: output.source.label)
.font(Theme.Font.body)
.foregroundStyle(Theme.Color.washi)
switch output.state {
switch snapshot.state {
case .connecting:
Text("Verbinde …")
.font(Theme.Font.caption)
@ -228,25 +92,20 @@ private struct OutputStatusView: View {
case .live:
HStack(spacing: Theme.Space.unit / 2) {
Circle().fill(Theme.Color.moegi).frame(width: Theme.Stroke.knot, height: Theme.Stroke.knot)
Text(verbatim: "\(output.width) × \(output.height) · \(output.frameRate.formatted(.number.precision(.fractionLength(0...2)))) fps")
Text(verbatim: "\(snapshot.width) × \(snapshot.height) · \(snapshot.frameRate.formatted(.number.precision(.fractionLength(0...2)))) fps")
.font(Theme.Font.number)
.foregroundStyle(Theme.Color.nezumi)
}
case .stalled:
if let retry = output.retryIn {
Text("\(output.label) antwortet nicht mehr. Neuer Versuch in \(Int(retry.rounded(.up))) s.")
.font(Theme.Font.caption)
.foregroundStyle(Theme.Color.kohaku)
} else {
Text("\(output.label) antwortet nicht mehr.")
.font(Theme.Font.caption)
.foregroundStyle(Theme.Color.kohaku)
}
case .stalled(let retryIn):
Text("\(output.source.label) antwortet nicht mehr. Neuer Versuch in \(Int(retryIn.rounded(.up))) s.")
.font(Theme.Font.caption)
.foregroundStyle(Theme.Color.kohaku)
}
Text("\(output.lostFrames) Frames verloren (\(output.lossRate.formatted(.percent.precision(.fractionLength(2)))))")
let lost = snapshot.stats.missing + snapshot.stats.droppedByReceiver
Text("\(lost) Frames verloren (\(snapshot.stats.lossRate.formatted(.percent.precision(.fractionLength(2)))))")
.font(Theme.Font.number)
.foregroundStyle(output.lossRate >= 0.001 ? Theme.Color.kohaku : Theme.Color.nezumi)
.foregroundStyle(snapshot.stats.lossRate >= 0.001 ? Theme.Color.kohaku : Theme.Color.nezumi)
}
}
}

@ -1,47 +0,0 @@
import DesignSystem
import MusubiCore
import Remote
import SwiftUI
struct SettingsView: View {
let coordinator: Coordinator
@State private var interfaces: [NetworkInterfaces.Info] = []
@State private var launchAtLogin = false
private var settings: AppSettings { coordinator.settings }
var body: some View {
Form {
Picker("Rolle", selection: Binding(get: { settings.role }, set: { settings.role = $0; restart() })) {
Text("Regie – steuert Knoten, verwaltet Szenen").tag(Role.control)
Text("Knoten – folgt der Regie").tag(Role.node)
}
Picker("Technik-Netz", selection: Binding(get: { settings.interfaceName }, set: { settings.interfaceName = $0; restart() })) {
ForEach(interfaces) { interface in
Text(verbatim: "\(interface.name) · \(interface.kind)").tag(interface.name)
}
if !interfaces.contains(where: { $0.name == settings.interfaceName }) {
Text(verbatim: "\(settings.interfaceName) · nicht verbunden").tag(settings.interfaceName)
}
}
Text("Regie und Knoten finden sich nur über diese Schnittstelle.")
.font(Theme.Font.caption)
.foregroundStyle(Theme.Color.nezumi)
Toggle("Beim Anmelden starten", isOn: Binding(get: { launchAtLogin }, set: {
settings.launchAtLogin = $0
launchAtLogin = settings.launchAtLogin
}))
LabeledContent("Name im Netz") { Text(verbatim: settings.machineName) }
}
.formStyle(.grouped)
.frame(width: 460)
.task {
interfaces = await NetworkInterfaces.available()
launchAtLogin = settings.launchAtLogin
}
}
private func restart() {
Task { await coordinator.startRemote() }
}
}

@ -1,17 +1,16 @@
import AudioMatrix
import DesignSystem
import MusubiCore
import SwiftUI
/// Channel strips of the selected output device; drop an NDI source or a microphone onto a strip.
struct SoundView: View {
let audio: AudioController
let sourceList: SourceListModel
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Theme.Space.card) {
header
MicrophoneChips(audio: audio)
if let device = audio.selectedDevice {
if let error = audio.matrix.errors[device.uid] {
Text(error).font(Theme.Font.body).foregroundStyle(Theme.Color.kohaku)
@ -23,7 +22,7 @@ struct SoundView: View {
}
LazyVGrid(columns: [GridItem(.adaptive(minimum: 300), spacing: Theme.Space.card)], spacing: Theme.Space.card) {
ForEach(0..<device.outputChannels, id: \.self) { channel in
ChannelStrip(audio: audio, sourceList: sourceList, device: device, channel: channel)
ChannelStrip(audio: audio, device: device, channel: channel)
}
}
} else {
@ -58,9 +57,34 @@ struct SoundView: View {
}
}
private struct MicrophoneChips: View {
let audio: AudioController
var body: some View {
VStack(alignment: .leading, spacing: Theme.Space.unit) {
Text("Mikrofone – über die Sprechtaste")
.font(Theme.Font.caption)
.foregroundStyle(Theme.Color.nezumi)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Theme.Space.unit) {
ForEach(audio.matrix.inputDevices) { device in
Text(verbatim: device.name)
.font(Theme.Font.caption)
.foregroundStyle(Theme.Color.washi)
.padding(.horizontal, Theme.Space.unit)
.padding(.vertical, Theme.Space.unit / 2)
.background(Theme.Color.kasumi, in: RoundedRectangle(cornerRadius: Theme.Radius.chip))
.overlay(RoundedRectangle(cornerRadius: Theme.Radius.chip).strokeBorder(Theme.Color.usuzumi))
.draggable("input:\(device.uid)")
}
}
}
}
}
}
private struct ChannelStrip: View {
let audio: AudioController
let sourceList: SourceListModel
let device: AudioDevice
let channel: Int
@State private var targeted = false
@ -69,41 +93,19 @@ private struct ChannelStrip: View {
audio.matrix.routes.filter { $0.deviceUID == device.uid && $0.channel == channel }
}
private var addMenu: some View {
Menu {
Section("NDI") {
ForEach(sourceList.sources, id: \.ndiName) { source in
Button(source.label) { audio.add(.ndi(name: source.ndiName), channel: channel) }
}
}
Section("Mikrofone") {
ForEach(audio.matrix.inputDevices) { input in
Button(input.name) { audio.add(.input(deviceUID: input.uid), channel: channel) }
}
}
} label: {
Image(systemName: "plus")
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
.help("Quelle hinzufügen")
}
var body: some View {
VStack(alignment: .leading, spacing: Theme.Space.unit) {
HStack {
Text(verbatim: "Out \(channel + 1)").font(Theme.Font.title).foregroundStyle(Theme.Color.washi)
Spacer()
LevelText(db: audio.channelMeters["\(device.uid)#\(channel)"] ?? Level.floorDB)
addMenu
}
LevelMeter(db: audio.channelMeters["\(device.uid)#\(channel)"] ?? Level.floorDB)
Divider().overlay(Theme.Color.usuzumi)
if routes.isEmpty {
Text("Quelle oder Mikrofon hierher ziehen")
Text("Quelle hierher ziehen")
.font(Theme.Font.body)
.foregroundStyle(Theme.Color.nezumi)
}
@ -146,7 +148,7 @@ private struct RouteRow: View {
.foregroundStyle(Theme.Color.washi)
.lineLimit(1)
Spacer()
Picker("Kanal", selection: Binding(get: { route.selection }, set: { audio.setSelection(route.id, $0) })) {
Picker("Kanal", selection: Binding(get: { route.selection }, set: { audio.matrix.setSelection(route.id, $0) })) {
Text("L").tag(ChannelSelection.left)
Text("R").tag(ChannelSelection.right)
Text("L+R").tag(ChannelSelection.mono)
@ -155,7 +157,7 @@ private struct RouteRow: View {
.pickerStyle(.segmented)
.fixedSize()
Button {
audio.setMuted(route.id, !route.muted)
audio.matrix.setMuted(route.id, !route.muted)
} label: {
Text("M").font(Theme.Font.caption.bold())
}
@ -171,16 +173,13 @@ private struct RouteRow: View {
.help("Route entfernen")
}
HStack(spacing: Theme.Space.unit) {
Slider(value: Binding(get: { Double(route.gainDB) }, set: { audio.setGain(route.id, db: Float($0)) }),
Slider(value: Binding(get: { Double(route.gainDB) }, set: { audio.matrix.setGain(route.id, db: Float($0)) }),
in: -60...12)
.controlSize(.small)
Text(verbatim: route.gainDB <= Level.floorDB ? "−∞ dB" : String(format: "%+.1f dB", route.gainDB))
.font(Theme.Font.number)
.foregroundStyle(route.gainDB == 0 ? Theme.Color.nezumi : Theme.Color.washi)
.foregroundStyle(Theme.Color.nezumi)
.frame(width: 70, alignment: .trailing)
.contentShape(Rectangle())
.onTapGesture(count: 2) { audio.setGain(route.id, db: 0) }
.help("Doppelklick: Pegel auf 0 dB")
}
LevelMeter(db: audio.meters[route.id] ?? Level.floorDB)
if let count = audio.dropouts[route.id], count > 0 {
@ -188,12 +187,6 @@ private struct RouteRow: View {
}
}
.padding(.vertical, Theme.Space.unit / 2)
.contextMenu {
Button("Pegel auf 0 dB") { audio.setGain(route.id, db: 0) }
Button(route.muted ? "Stummschaltung aufheben" : "Stummschalten") { audio.setMuted(route.id, !route.muted) }
Divider()
Button("Route entfernen", role: .destructive) { audio.remove(route.id) }
}
}
}

@ -11,7 +11,6 @@ let package = Package(
.library(name: "VideoOutput", targets: ["VideoOutput"]),
.library(name: "AudioMatrix", targets: ["AudioMatrix"]),
.library(name: "DesignSystem", targets: ["DesignSystem"]),
.library(name: "Remote", targets: ["Remote"]),
],
targets: [
// Pure logic: models, scenes, settings.
@ -29,11 +28,7 @@ let package = Package(
// Theme.swift is a symlink to design/Theme.swift (single source of truth).
.target(name: "DesignSystem"),
// Control ↔ node over Bonjour/TCP on the technical interface (M3).
.target(name: "Remote", dependencies: ["MusubiCore"]),
.testTarget(name: "MusubiCoreTests", dependencies: ["MusubiCore"]),
.testTarget(name: "AudioMatrixTests", dependencies: ["AudioMatrix", "CAudioRing"]),
.testTarget(name: "RemoteTests", dependencies: ["Remote", "MusubiCore"]),
]
)

@ -1,6 +1,5 @@
import CAudioRing
import Foundation
import MusubiCore
import Observation
import OSLog

@ -12,3 +12,17 @@ public enum Level {
gain <= 0 ? floorDB : max(floorDB, 20 * log10f(gain))
}
}
/// Which part of a stereo source feeds a mono output channel.
public enum ChannelSelection: String, Codable, CaseIterable, Sendable {
case left, right, mono
@inline(__always)
public func sample(left: Float, right: Float) -> Float {
switch self {
case .left: left
case .right: right
case .mono: 0.5 * (left + right)
}
}
}

@ -1,6 +1,5 @@
import CAudioRing
import Foundation
import MusubiCore
/// Real-time state of one route: feed → resampler → gain → one output channel.
///

@ -21,23 +21,37 @@ public struct Source: Codable, Hashable, Sendable {
}
}
public enum Role: String, Codable, CaseIterable, Sendable {
/// MacBook at the control desk: controls nodes, owns scenes.
case control
/// Mac mini on stage: follows the control, keeps its last state on its own.
case node
public enum Output: Codable, Hashable, Sendable {
case screen(displayID: UInt32)
case audioChannel(deviceUID: String, channel: Int)
}
/// Which part of a stereo source feeds a mono output channel.
public enum ChannelSelection: String, Codable, CaseIterable, Sendable {
case left, right, mono
@inline(__always)
public func sample(left: Float, right: Float) -> Float {
switch self {
case .left: left
case .right: right
case .mono: 0.5 * (left + right)
}
public struct Route: Codable, Hashable, Sendable {
public enum Mode: String, Codable, Sendable { case videoAndAudio, videoOnly, audioOnly }
public var source: Source
public var output: Output
public var mode: Mode
public init(source: Source, output: Output, mode: Mode) {
self.source = source
self.output = output
self.mode = mode
}
}
public enum Role: String, Codable, Sendable { case control, node }
public struct Scene: Codable, Identifiable, Hashable, Sendable {
public var id: UUID
public var name: String
public var role: Role
public var routes: [Route]
public init(id: UUID = UUID(), name: String, role: Role, routes: [Route] = []) {
self.id = id
self.name = name
self.role = role
self.routes = routes
}
}

@ -1,91 +0,0 @@
import Foundation
/// A screen identified across restarts and machines: by name first, position as fallback.
/// (CGDirectDisplayIDs differ between Macs and can change after a reboot.)
public struct ScreenRef: Codable, Hashable, Sendable {
public var name: String
/// Position in the system's screen list (0 = main screen).
public var index: Int
public init(name: String, index: Int) {
self.name = name
self.index = index
}
}
/// One NDI source fullscreen on one screen.
public struct VideoAssignment: Codable, Hashable, Sendable {
public var screen: ScreenRef
public var source: Source
public init(screen: ScreenRef, source: Source) {
self.screen = screen
self.source = source
}
}
/// One audio source on one output channel.
public struct AudioAssignment: Codable, Hashable, Sendable {
public enum SourceSpec: Codable, Hashable, Sendable {
case ndi(name: String)
/// Input device by UID; the name is the fallback when the UID changes (e.g. other USB port).
case input(uid: String, name: String)
}
public var source: SourceSpec
public var deviceUID: String
public var deviceName: String
public var channel: Int // 0-based
public var selection: ChannelSelection
public var gainDB: Float
public var muted: Bool
public init(source: SourceSpec, deviceUID: String, deviceName: String, channel: Int,
selection: ChannelSelection = .mono, gainDB: Float = 0, muted: Bool = false) {
self.source = source
self.deviceUID = deviceUID
self.deviceName = deviceName
self.channel = channel
self.selection = selection
self.gainDB = gainDB
self.muted = muted
}
/// Identity of the route (what goes where), without its settings.
public var key: String {
let sourceKey = switch source {
case .ndi(let name): "ndi:\(name)"
case .input(let uid, _): "input:\(uid)"
}
return "\(sourceKey)→\(deviceUID)#\(channel)"
}
}
/// Everything one Mac outputs.
public struct MachineConfig: Codable, Hashable, Sendable {
public var video: [VideoAssignment]
public var audio: [AudioAssignment]
public init(video: [VideoAssignment] = [], audio: [AudioAssignment] = []) {
self.video = video
self.audio = audio
}
public static let empty = MachineConfig()
}
/// A complete setup of the control Mac and its nodes, e.g. "OP-Live".
public struct Scene: Codable, Identifiable, Hashable, Sendable {
public var id: UUID
public var name: String
public var control: MachineConfig
/// Node name (computer name) → what that node shows.
public var nodes: [String: MachineConfig]
public init(id: UUID = UUID(), name: String, control: MachineConfig = .empty, nodes: [String: MachineConfig] = [:]) {
self.id = id
self.name = name
self.control = control
self.nodes = nodes
}
}

@ -1,42 +0,0 @@
/// Finds the connected screen a `ScreenRef` means.
public enum ScreenMatching {
/// - Parameter names: names of the connected screens in system order.
/// - Returns: index into `names`, or nil if the screen is not connected.
public static func index(of ref: ScreenRef, in names: [String]) -> Int? {
let sameName = names.indices.filter { names[$0] == ref.name }
switch sameName.count {
case 1:
return sameName[0]
case 0:
// Renamed or different monitor model: only trust the position if the count still fits.
return nil
default:
// Identical monitors: prefer the remembered position, else the first free match.
return sameName.contains(ref.index) ? ref.index : sameName.first
}
}
/// Resolves all assignments; screens claimed twice keep the first assignment.
public static func resolve(_ assignments: [VideoAssignment], in names: [String]) -> [Int: Source] {
var result: [Int: Source] = [:]
for assignment in assignments {
guard let index = index(of: assignment.screen, in: names), result[index] == nil else { continue }
result[index] = assignment.source
}
return result
}
}
/// What to stop and start to get from the current state to the desired one.
public enum Reconcile {
public struct Plan<Key: Hashable, Value: Equatable> {
public var stop: [Key]
public var start: [Key: Value]
}
public static func plan<Key: Hashable, Value: Equatable>(current: [Key: Value], desired: [Key: Value]) -> Plan<Key, Value> {
let stop = current.keys.filter { desired[$0] == nil }
let start = desired.filter { current[$0.key] != $0.value }
return Plan(stop: stop, start: start)
}
}

@ -1,54 +0,0 @@
import Foundation
import Network
/// Network interfaces that services can be bound to (e.g. en7 = technical network).
public enum NetworkInterfaces {
public struct Info: Hashable, Sendable, Identifiable {
public var id: String { name }
public let name: String
public let kind: String
}
/// Currently available interfaces (wired, Wi-Fi, other), excluding loopback.
public static func available() async -> [Info] {
await withCheckedContinuation { continuation in
let monitor = NWPathMonitor()
let once = OnceFlag()
monitor.pathUpdateHandler = { path in
guard once.claim() else { return }
monitor.cancel()
continuation.resume(returning: path.availableInterfaces
.filter { $0.type != .loopback }
.map { Info(name: $0.name, kind: describe($0.type)) })
}
monitor.start(queue: .global(qos: .utility))
}
}
static func interface(named name: String) async -> NWInterface? {
await withCheckedContinuation { continuation in
let monitor = NWPathMonitor()
let once = OnceFlag()
monitor.pathUpdateHandler = { path in
guard once.claim() else { return }
monitor.cancel()
continuation.resume(returning: path.availableInterfaces.first { $0.name == name })
}
monitor.start(queue: .global(qos: .utility))
}
}
private static func describe(_ type: NWInterface.InterfaceType) -> String {
switch type {
case .wiredEthernet: "Ethernet"
case .wifi: "WLAN"
default: "Andere"
}
}
}
final class OnceFlag: @unchecked Sendable {
private let lock = NSLock()
private var done = false
func claim() -> Bool { lock.withLock { defer { done = true }; return !done } }
}

@ -1,85 +0,0 @@
import Foundation
import Network
import OSLog
/// One framed message connection (either side). Callbacks arrive on the main actor.
final class Link: @unchecked Sendable {
let connection: NWConnection
private let queue: DispatchQueue
private var framer = MessageFramer() // queue only
private let log = Logger(subsystem: "studio.zielke.musubi", category: "remote")
var onMessage: (@MainActor (RemoteMessage) -> Void)?
var onState: (@MainActor (NWConnection.State) -> Void)?
init(connection: NWConnection, queue: DispatchQueue) {
self.connection = connection
self.queue = queue
}
func start() {
connection.stateUpdateHandler = { [weak self] state in
guard let self else { return }
let handler = onState
Task { @MainActor in handler?(state) }
}
connection.start(queue: queue)
receive()
}
func cancel() {
connection.cancel()
}
func send(_ message: RemoteMessage) {
do {
let data = try MessageFramer.encode(message)
connection.send(content: data, completion: .contentProcessed { [log] error in
if let error { log.error("Send failed: \(error.localizedDescription, privacy: .public)") }
})
} catch {
log.error("Encode failed: \(error.localizedDescription, privacy: .public)")
}
}
private func receive() {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, isComplete, error in
guard let self else { return }
if let data, !data.isEmpty {
do {
let messages = try framer.append(data)
let handler = onMessage
if !messages.isEmpty {
Task { @MainActor in messages.forEach { handler?($0) } }
}
} catch {
log.error("Framing error, closing: \(String(describing: error), privacy: .public)")
connection.cancel()
return
}
}
if isComplete || error != nil {
connection.cancel()
return
}
receive()
}
}
}
extension NWParameters {
/// TCP bound to one interface, with Bonjour peer-to-peer off (technical network only).
static func musubi(interface: NWInterface?) -> NWParameters {
let parameters = NWParameters.tcp
parameters.includePeerToPeer = false
if let interface { parameters.requiredInterface = interface }
if let tcp = parameters.defaultProtocolStack.transportProtocol as? NWProtocolTCP.Options {
tcp.enableKeepalive = true
tcp.keepaliveIdle = 2
tcp.keepaliveInterval = 1
tcp.keepaliveCount = 3
tcp.noDelay = true
}
return parameters
}
}

@ -1,108 +0,0 @@
import Foundation
import MusubiCore
public enum RemoteProtocol {
public static let serviceType = "_musubi._tcp"
public static let version = 1
}
/// State of one screen on a node, as reported to the control.
public struct ScreenStatus: Codable, Hashable, Sendable, Identifiable {
public var id: ScreenRef { screen }
public var screen: ScreenRef
public var width: Int
public var height: Int
public var refreshRate: Double
public var output: OutputStatus?
public init(screen: ScreenRef, width: Int, height: Int, refreshRate: Double, output: OutputStatus?) {
self.screen = screen
self.width = width
self.height = height
self.refreshRate = refreshRate
self.output = output
}
}
public struct OutputStatus: Codable, Hashable, Sendable {
public enum State: String, Codable, Sendable { case connecting, live, stalled }
public var source: Source
public var state: State
public var width: Int
public var height: Int
public var frameRate: Double
public var lostFrames: Int64
public var lossRate: Double
public init(source: Source, state: State, width: Int, height: Int, frameRate: Double, lostFrames: Int64, lossRate: Double) {
self.source = source
self.state = state
self.width = width
self.height = height
self.frameRate = frameRate
self.lostFrames = lostFrames
self.lossRate = lossRate
}
}
public struct NodeStatus: Codable, Hashable, Sendable {
public var screens: [ScreenStatus]
/// What the node currently outputs (so a control can adopt it, e.g. when saving a scene).
public var config: MachineConfig
public var cpuPercent: Double
public var sleepBlocked: Bool
public init(screens: [ScreenStatus], config: MachineConfig, cpuPercent: Double, sleepBlocked: Bool) {
self.screens = screens
self.config = config
self.cpuPercent = cpuPercent
self.sleepBlocked = sleepBlocked
}
}
public enum RemoteMessage: Codable, Hashable, Sendable {
/// First message in both directions.
case hello(name: String, role: Role, version: Int)
/// Node → control, once per second.
case status(NodeStatus)
/// Control → node: replace the whole configuration.
case apply(MachineConfig)
/// Control → node: show one source on one screen.
case show(VideoAssignment)
/// Control → node: stop the output on one screen.
case stop(ScreenRef)
}
/// Length-prefixed JSON framing: 4-byte big-endian length, then the JSON body.
public struct MessageFramer: Sendable {
public static let maxMessageSize = 4 * 1024 * 1024
private var buffer = Data()
public init() {}
public static func encode(_ message: RemoteMessage) throws -> Data {
let body = try JSONEncoder().encode(message)
var length = UInt32(body.count).bigEndian
return Data(bytes: &length, count: 4) + body
}
public enum FramingError: Error { case tooLarge(Int) }
/// Appends received bytes and returns all complete messages. Malformed JSON is skipped.
public mutating func append(_ data: Data) throws -> [RemoteMessage] {
buffer.append(data)
var messages: [RemoteMessage] = []
while buffer.count >= 4 {
let length = buffer.prefix(4).reduce(0) { Int($0) << 8 | Int($1) }
guard length <= Self.maxMessageSize else { throw FramingError.tooLarge(length) }
guard buffer.count >= 4 + length else { break }
let body = buffer.subdata(in: buffer.startIndex + 4 ..< buffer.startIndex + 4 + length)
buffer.removeFirst(4 + length)
if let message = try? JSONDecoder().decode(RemoteMessage.self, from: body) {
messages.append(message)
}
}
return messages
}
}

@ -1,187 +0,0 @@
import Foundation
import MusubiCore
import Network
import Observation
import OSLog
/// Control side: finds nodes via Bonjour and keeps a connection to each of them.
@MainActor @Observable
public final class NodeBrowser {
@MainActor @Observable
public final class Node: Identifiable {
public nonisolated let id: String // Bonjour service name = node name
public fileprivate(set) var isConnected = false
public fileprivate(set) var status: NodeStatus?
fileprivate var link: Link?
fileprivate var endpoint: NWEndpoint?
fileprivate var retryTask: Task<Void, Never>?
init(name: String) { id = name }
public var name: String { id }
public func send(_ message: RemoteMessage) {
link?.send(message)
}
}
public private(set) var nodes: [Node] = []
public private(set) var error: String?
/// A node said hello (first contact or after a reconnect).
public var onNodeConnected: ((Node) -> Void)?
private let name: String
private var browser: NWBrowser?
private var interface: NWInterface?
private var interfaceName: String?
private var retryTask: Task<Void, Never>?
private let queue = DispatchQueue(label: "studio.zielke.musubi.remote.control")
private let log = Logger(subsystem: "studio.zielke.musubi", category: "remote")
public init(name: String) {
self.name = name
}
public func start(interfaceName: String) async {
stop()
self.interfaceName = interfaceName
guard let interface = await NetworkInterfaces.interface(named: interfaceName) else {
error = "Technik-Schnittstelle \(interfaceName) nicht gefunden."
scheduleRestart()
return
}
self.interface = interface
let browser = NWBrowser(for: .bonjour(type: RemoteProtocol.serviceType, domain: nil), using: .musubi(interface: interface))
browser.browseResultsChangedHandler = { [weak self] results, _ in
let found = results.compactMap { result -> (String, NWEndpoint)? in
guard case .service(let name, _, _, _) = result.endpoint else { return nil }
return (name, result.endpoint)
}
Task { @MainActor in self?.update(found) }
}
browser.stateUpdateHandler = { [weak self] state in
Task { @MainActor in self?.browserChanged(state) }
}
browser.start(queue: queue)
self.browser = browser
log.info("Control: browsing on \(interfaceName, privacy: .public)")
}
private func browserChanged(_ state: NWBrowser.State) {
switch state {
case .ready:
error = nil
case .failed(let failure), .waiting(let failure):
if case .dns(let code) = failure, code == -65555 {
error = "macOS blockiert das lokale Netzwerk. Erlaube Musubi unter Systemeinstellungen › Datenschutz & Sicherheit › Lokales Netzwerk."
} else {
error = "Knotensuche gestört: \(failure.localizedDescription)"
}
log.error("Control: browser \(String(describing: failure), privacy: .public)")
if case .failed = state { scheduleRestart() }
default:
break
}
}
private func scheduleRestart() {
retryTask?.cancel()
retryTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(5))
guard !Task.isCancelled, let self, let interfaceName else { return }
await start(interfaceName: interfaceName)
}
}
public func stop() {
retryTask?.cancel()
retryTask = nil
browser?.cancel()
browser = nil
for node in nodes {
node.retryTask?.cancel()
node.link?.cancel()
}
nodes = []
}
public func node(named name: String) -> Node? {
nodes.first { $0.id == name }
}
private func update(_ found: [(String, NWEndpoint)]) {
for (name, endpoint) in found where name != self.name {
let node = self.node(named: name) ?? {
let node = Node(name: name)
nodes.append(node)
nodes.sort { $0.id.localizedStandardCompare($1.id) == .orderedAscending }
return node
}()
node.endpoint = endpoint
if node.link == nil { connect(node) }
}
// Nodes that vanished from Bonjour stay listed while connected; the connection decides.
}
private func connect(_ node: Node) {
guard let endpoint = node.endpoint else { return }
node.retryTask?.cancel()
let link = Link(connection: NWConnection(to: endpoint, using: .musubi(interface: interface)), queue: queue)
link.onMessage = { [weak self, weak node] message in
guard let self, let node, node.link === link else { return }
received(message, from: node)
}
link.onState = { [weak self, weak node] state in
guard let self, let node, node.link === link else { return }
switch state {
case .ready:
link.send(.hello(name: self.name, role: .control, version: RemoteProtocol.version))
case .failed, .cancelled:
if node.isConnected { log.notice("Control: lost \(node.id, privacy: .public)") }
node.isConnected = false
node.link = nil
scheduleRetry(node)
case .waiting(let reason):
log.notice("Control: \(node.id, privacy: .public) waiting: \(reason.localizedDescription, privacy: .public)")
link.cancel()
default:
break
}
}
node.link = link
link.start()
// A connection can hang in preparing/waiting (e.g. local network access pending); start over.
Task { [weak self, weak node] in
try? await Task.sleep(for: .seconds(6))
guard let self, let node, node.link === link, !node.isConnected else { return }
log.notice("Control: \(node.id, privacy: .public) not ready after 6 s, retrying")
link.cancel()
node.link = nil
scheduleRetry(node)
}
}
private func scheduleRetry(_ node: Node) {
node.retryTask = Task { [weak self, weak node] in
try? await Task.sleep(for: .seconds(3))
guard !Task.isCancelled, let self, let node, node.link == nil else { return }
connect(node)
}
}
private func received(_ message: RemoteMessage, from node: Node) {
switch message {
case .hello(let name, let role, _):
guard role == .node else { return }
node.isConnected = true
log.info("Control: connected to \(name, privacy: .public)")
onNodeConnected?(node)
case .status(let status):
node.status = status
case .apply, .show, .stop:
break
}
}
}

@ -1,136 +0,0 @@
import Foundation
import MusubiCore
import Network
import Observation
import OSLog
/// Node side: advertises itself via Bonjour on the technical interface and accepts one control at a time.
@MainActor @Observable
public final class NodeServer {
public private(set) var controlName: String?
public private(set) var error: String?
public private(set) var isListening = false
/// Called for `apply`, `show` and `stop` from the control.
public var onCommand: ((RemoteMessage) -> Void)?
private let name: String
private var interfaceName: String?
private var retryTask: Task<Void, Never>?
private var listener: NWListener?
private var link: Link?
private let queue = DispatchQueue(label: "studio.zielke.musubi.remote.node")
private let log = Logger(subsystem: "studio.zielke.musubi", category: "remote")
public init(name: String) {
self.name = name
}
public func start(interfaceName: String) async {
stop()
self.interfaceName = interfaceName
guard let interface = await NetworkInterfaces.interface(named: interfaceName) else {
error = "Technik-Schnittstelle \(interfaceName) nicht gefunden."
log.error("Node: interface \(interfaceName, privacy: .public) not found")
scheduleRetry()
return
}
do {
let listener = try NWListener(using: .musubi(interface: interface))
listener.service = NWListener.Service(name: name, type: RemoteProtocol.serviceType)
listener.newConnectionHandler = { [weak self] connection in
Task { @MainActor in self?.accept(connection) }
}
listener.stateUpdateHandler = { [weak self] state in
Task { @MainActor in self?.listenerChanged(state) }
}
listener.start(queue: queue)
self.listener = listener
log.info("Node: advertising \(self.name, privacy: .public) on \(interfaceName, privacy: .public)")
} catch {
self.error = "Fernsteuerung lässt sich nicht starten."
log.error("Node: listener failed: \(error.localizedDescription, privacy: .public)")
}
}
public func stop() {
retryTask?.cancel()
retryTask = nil
link?.cancel()
link = nil
listener?.cancel()
listener = nil
isListening = false
controlName = nil
}
public func send(_ status: NodeStatus) {
guard controlName != nil else { return }
link?.send(.status(status))
}
private func listenerChanged(_ state: NWListener.State) {
switch state {
case .ready:
isListening = true
error = nil
case .failed(let failure):
isListening = false
if case .dns(let code) = failure, code == -65555 {
// kDNSServiceErr_NoAuth: macOS has not (yet) allowed local network access.
error = "macOS blockiert das lokale Netzwerk. Erlaube Musubi unter Systemeinstellungen › Datenschutz & Sicherheit › Lokales Netzwerk."
} else {
error = "Fernsteuerung gestört: \(failure.localizedDescription)"
}
log.error("Node: listener failed: \(failure.localizedDescription, privacy: .public)")
scheduleRetry()
case .cancelled: isListening = false
default: break
}
}
/// Tries again every 5 s, e.g. until local network access is granted or the cable is plugged in.
private func scheduleRetry() {
retryTask?.cancel()
retryTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(5))
guard !Task.isCancelled, let self, let interfaceName else { return }
await start(interfaceName: interfaceName)
}
}
/// The newest control wins; the previous connection is closed.
private func accept(_ connection: NWConnection) {
link?.cancel()
controlName = nil
let link = Link(connection: connection, queue: queue)
link.onMessage = { [weak self] in self?.received($0, from: link) }
link.onState = { [weak self] state in
guard let self, self.link === link else { return }
switch state {
case .ready:
link.send(.hello(name: self.name, role: .node, version: RemoteProtocol.version))
case .failed, .cancelled:
log.notice("Node: control \(self.controlName ?? "?", privacy: .public) disconnected")
self.controlName = nil
self.link = nil
default: break
}
}
self.link = link
link.start()
}
private func received(_ message: RemoteMessage, from link: Link) {
guard self.link === link else { return }
switch message {
case .hello(let name, _, _):
controlName = name
log.info("Node: controlled by \(name, privacy: .public)")
case .apply, .show, .stop:
onCommand?(message)
case .status:
break
}
}
}

@ -26,16 +26,6 @@ public final class OutputWindow: NSWindow {
public override var canBecomeKey: Bool { true }
public override func orderFrontRegardless() {
super.orderFrontRegardless()
(contentView as? VideoView)?.hideCursorIfInside()
}
public override func orderOut(_ sender: Any?) {
(contentView as? VideoView)?.showCursor()
super.orderOut(sender)
}
public override func cancelOperation(_ sender: Any?) {
onCancel?()
}
@ -45,7 +35,6 @@ public final class OutputWindow: NSWindow {
@MainActor
final class VideoView: NSView {
let displayLayer = AVSampleBufferDisplayLayer()
private var cursorHidden = false
init() {
super.init(frame: .zero)
@ -61,28 +50,9 @@ final class VideoView: NSView {
override func updateTrackingAreas() {
super.updateTrackingAreas()
trackingAreas.forEach(removeTrackingArea)
addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], owner: self))
}
override func mouseEntered(with event: NSEvent) { hideCursor() }
override func mouseExited(with event: NSEvent) { showCursor() }
/// The cursor may already rest on the screen when the output opens; no enter event comes then.
func hideCursorIfInside() {
guard let window, window.frame.contains(NSEvent.mouseLocation) else { return }
hideCursor()
addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], owner: self))
}
// NSCursor.hide/unhide are counted, so keep them balanced.
private func hideCursor() {
guard !cursorHidden else { return }
NSCursor.hide()
cursorHidden = true
}
func showCursor() {
guard cursorHidden else { return }
NSCursor.unhide()
cursorHidden = false
}
override func mouseEntered(with event: NSEvent) { NSCursor.setHiddenUntilMouseMoves(true) }
override func mouseMoved(with event: NSEvent) { NSCursor.setHiddenUntilMouseMoves(true) }
}

@ -2,7 +2,6 @@ import CAudioRing
import Foundation
import Testing
@testable import AudioMatrix
import MusubiCore
@Suite struct RingTests {
@Test func writesAndReadsAcrossWrap() throws {

@ -5,18 +5,10 @@ import Testing
@Suite struct ModelTests {
@Test func sceneRoundTripsThroughJSON() throws {
let pgm = Source(ndiName: "ZOWIEBOX-PGM (PGM)")
let mv = Source(ndiName: "ZOWIEBOX-MV (MV)")
let scene = Scene(
name: "OP-Live",
control: MachineConfig(audio: [
AudioAssignment(source: .ndi(name: pgm.ndiName), deviceUID: "MOTU-UID", deviceName: "M2", channel: 1, gainDB: -6),
AudioAssignment(source: .input(uid: "POLY-UID", name: "Poly BT700"), deviceUID: "MOTU-UID", deviceName: "M2", channel: 0),
]),
nodes: ["Mac mini von Samuel": MachineConfig(video: [
VideoAssignment(screen: ScreenRef(name: "Field Monitor", index: 1), source: pgm),
VideoAssignment(screen: ScreenRef(name: "HDMI TO USB", index: 0), source: mv),
])]
)
let scene = Scene(name: "OP-Live", role: .node, routes: [
Route(source: pgm, output: .screen(displayID: 2), mode: .videoOnly),
Route(source: pgm, output: .audioChannel(deviceUID: "MOTU-M2", channel: 2), mode: .audioOnly),
])
let data = try JSONEncoder().encode(scene)
let decoded = try JSONDecoder().decode(Scene.self, from: data)
@ -24,15 +16,6 @@ import Testing
#expect(decoded == scene)
}
@Test func audioKeyIgnoresSettings() {
var a = AudioAssignment(source: .ndi(name: "X"), deviceUID: "D", deviceName: "M2", channel: 1)
let key = a.key
a.gainDB = -12
a.muted = true
#expect(a.key == key)
#expect(key == "ndi:X→D#1")
}
@Test(arguments: [
("ZOWIEBOX-PGM (PGM)", "PGM"),
("MACBOOK (Test Pattern)", "Test Pattern"),

@ -1,43 +0,0 @@
import Testing
@testable import MusubiCore
@Suite struct ScreenMatchingTests {
@Test func matchesByNameRegardlessOfOrder() {
let ref = ScreenRef(name: "Field Monitor", index: 1)
#expect(ScreenMatching.index(of: ref, in: ["Field Monitor", "HDMI TO USB"]) == 0)
}
@Test func identicalMonitorsUsePosition() {
let names = ["LG", "LG", "Built-in"]
#expect(ScreenMatching.index(of: ScreenRef(name: "LG", index: 1), in: names) == 1)
#expect(ScreenMatching.index(of: ScreenRef(name: "LG", index: 2), in: names) == 0)
}
@Test func missingScreenIsNil() {
#expect(ScreenMatching.index(of: ScreenRef(name: "Beamer", index: 0), in: ["Field Monitor"]) == nil)
}
@Test func resolveKeepsFirstClaim() {
let a = Source(ndiName: "A (a)"), b = Source(ndiName: "B (b)")
let resolved = ScreenMatching.resolve([
VideoAssignment(screen: ScreenRef(name: "X", index: 0), source: a),
VideoAssignment(screen: ScreenRef(name: "X", index: 0), source: b),
VideoAssignment(screen: ScreenRef(name: "Gone", index: 3), source: b),
], in: ["X"])
#expect(resolved == [0: a])
}
}
@Suite struct ReconcileTests {
@Test func stopsRemovedStartsNewAndChanged() {
let plan = Reconcile.plan(current: [1: "A", 2: "B", 3: "C"], desired: [2: "B", 3: "X", 4: "D"])
#expect(Set(plan.stop) == [1])
#expect(plan.start == [3: "X", 4: "D"])
}
@Test func nothingToDoWhenEqual() {
let plan = Reconcile.plan(current: [1: "A"], desired: [1: "A"])
#expect(plan.stop.isEmpty)
#expect(plan.start.isEmpty)
}
}

@ -1,47 +0,0 @@
import Foundation
import Testing
import MusubiCore
@testable import Remote
@Suite struct FramingTests {
let show = RemoteMessage.show(VideoAssignment(screen: ScreenRef(name: "Field Monitor", index: 1), source: Source(ndiName: "PGM (pgm)")))
@Test func roundTripsMessages() throws {
var framer = MessageFramer()
let data = try MessageFramer.encode(.hello(name: "MacBook", role: .control, version: 1)) + MessageFramer.encode(show)
#expect(try framer.append(data) == [.hello(name: "MacBook", role: .control, version: 1), show])
}
@Test func reassemblesSplitData() throws {
var framer = MessageFramer()
let data = try MessageFramer.encode(show)
var received: [RemoteMessage] = []
for byte in data { received += try framer.append(Data([byte])) }
#expect(received == [show])
}
@Test func skipsMalformedBody() throws {
var framer = MessageFramer()
var bad = Data([0, 0, 0, 3]) + Data("{x}".utf8)
bad += try MessageFramer.encode(.stop(ScreenRef(name: "A", index: 0)))
#expect(try framer.append(bad) == [.stop(ScreenRef(name: "A", index: 0))])
}
@Test func rejectsHugeLength() {
var framer = MessageFramer()
#expect(throws: MessageFramer.FramingError.self) {
try framer.append(Data([0xFF, 0xFF, 0xFF, 0xFF]))
}
}
@Test func statusRoundTrips() throws {
let status = NodeStatus(
screens: [ScreenStatus(screen: ScreenRef(name: "Field Monitor", index: 1), width: 1920, height: 1080, refreshRate: 50,
output: OutputStatus(source: Source(ndiName: "PGM (pgm)"), state: .live, width: 1920, height: 1080,
frameRate: 50, lostFrames: 3, lossRate: 0.0001))],
config: MachineConfig(video: [VideoAssignment(screen: ScreenRef(name: "Field Monitor", index: 1), source: Source(ndiName: "PGM (pgm)"))]),
cpuPercent: 51.2, sleepBlocked: true)
var framer = MessageFramer()
#expect(try framer.append(MessageFramer.encode(.status(status))) == [.status(status)])
}
}

@ -14,7 +14,7 @@ Erster Einsatz: OP-Liveübertragung (ATEM im OP, Regie im Saal, eine Netzwerklei
## Status
M0 abgenommen. M1 (Bild), M2 (Ton-Matrix) und M3 (Szenen und Fernsteuerung) umgesetzt, Langzeittests und Teile der Abnahmen offen. Siehe [docs/04-fahrplan.md](docs/04-fahrplan.md).
Planung abgeschlossen, Entwicklung startet mit Meilenstein 0. Siehe [docs/04-fahrplan.md](docs/04-fahrplan.md).
## Dokumente
@ -38,32 +38,14 @@ open Musubi.xcodeproj
```
Das `.xcodeproj` ist generiert und nicht im Repo. Nach Änderungen an `project.yml` erneut `xcodegen` ausführen.
Signiert wird mit **Developer ID Application** (Team `XY4C2RG3L5`), Hardened Runtime an. Ohne dieses Zertifikat im Schlüsselbund `CODE_SIGN_IDENTITY`/`DEVELOPMENT_TEAM` in `project.yml` anpassen.
**Installieren immer nach `/Applications`** – in privaten Ordnern blockiert macOS Bonjour (Fernsteuerung).
### Startargumente (für Tests und Knoten ohne Bildschirm)
| Argument | Wirkung |
|---|---|
| `--route "<NDI-Name>=<Bildschirm-Index>"` | Quelle im Vollbild auf Bildschirm (0 = Hauptbildschirm) |
| `--audio-route "ndi:<NDI-Name>=<Gerät>:<Kanal>"` | NDI-Ton auf Ausgangskanal (Gerätename als Teilstring, Kanal ab 1) |
| `--audio-route "input:<Eingang>=<Gerät>:<Kanal>"` | Mikrofon (über die Sprechtaste) auf Ausgangskanal |
| `--load-scene "<Name>"` | gespeicherte Szene laden |
| `--login-item on\|off` | Beim Anmelden starten |
Rolle und Schnittstelle ohne Bildschirm: `defaults write studio.zielke.musubi role node` bzw. `interfaceName en7`.
Protokolle: Console.app, Subsystem `studio.zielke.musubi`. Minuten-Statistik: `~/Library/Logs/Musubi/stats-*.csv` (Bild), `audio-*.csv` (Ton).
Ohne NDI SDK in `Vendor/NDI/` bricht der Build mit einem Hinweis ab. Tests (Swift Testing, 44 Stück): im Schema `Musubi` (⌘U) oder `cd Packages/MusubiKit && swift test` – beides braucht das SDK, weil alle Module mitgebaut werden.
Ohne NDI SDK in `Vendor/NDI/` bricht der Build mit einem Hinweis ab. Tests: im Schema `Musubi` (⌘U) oder `cd Packages/MusubiKit && swift test` – beides braucht das SDK, weil alle Module mitgebaut werden.
## Struktur
| Pfad | Inhalt |
|---|---|
| `App/` | App-Target: Einstieg, Koordinator (Rolle, Szenen, Live-Stand), Oberfläche, String Catalog |
| `Packages/MusubiKit/` | Module `MusubiCore`, `NDIKit` (+ `CNDI`-Modulemap), `VideoOutput`, `AudioMatrix` (+ `CAudioRing`), `Remote`, `DesignSystem` |
| `App/` | App-Target: Einstieg, Hauptfenster, String Catalog |
| `Packages/MusubiKit/` | Module `MusubiCore`, `NDIKit` (+ `CNDI`-Modulemap), `VideoOutput`, `AudioMatrix`, `DesignSystem` |
| `design/Theme.swift` | Design-Tokens; im Package als Symlink eingebunden |
| `project.yml` | XcodeGen-Projektdefinition |

@ -32,19 +32,18 @@ Jeder Meilenstein endet mit einem Abnahmetest. Erst wenn er bestanden ist, begin
- **Abnahme:** 4 Stunden, 2 Quellen, 2 Bildschirme auf dem Mac mini 2018 – keine sichtbaren Aussetzer, CPU-Last notiert, verlorene Frames < 0,1 %.
## M2 – Ton-Matrix
- [x] NDI-Ton einer Quelle auf beliebige Ausgangskanäle (z. B. MOTU M2 Out 2)
- [x] Reiner Audio-Empfang (ohne Video-Bandbreite)
- [x] Mikrofon-Eingang (Headset) auf Kanal (Out 1), Sprechtaste (Tastenkürzel, gedrückt halten / umschalten)
- [x] Pegelanzeige, Gain, Mute je Route
- [x] Drift-Ausgleich zwischen NDI-Takt und Interface-Takt
- [ ] NDI-Ton einer Quelle auf beliebige Ausgangskanäle (z. B. MOTU M2 Out 2)
- [ ] Reiner Audio-Empfang (ohne Video-Bandbreite)
- [ ] Mikrofon-Eingang (Headset) auf Kanal (Out 1), Sprechtaste (Tastenkürzel, gedrückt halten / umschalten)
- [ ] Pegelanzeige, Gain, Mute je Route
- [ ] Drift-Ausgleich zwischen NDI-Takt und Interface-Takt
- **Abnahme:** 4 Stunden ohne Knacksen und ohne hörbaren Versatz; Sprechtaste reagiert ohne Verzögerung.
## M3 – Szenen und Fernsteuerung
- [x] Szenen speichern, laden, exportieren (JSON)
- [x] Rolle Regie/Knoten; Knoten per Bonjour finden
- [x] Regie setzt Szene am Knoten; Knoten meldet Status zurück
- [x] Beim Start letzte Szene laden
- **Stand 2026-10-21:** umgesetzt; Neustart Mac mini einzeln bestanden, Durchlauf in einem Stück und Neustart der Regie (MacBook) offen.
- [ ] Szenen speichern, laden, exportieren (JSON)
- [ ] Rolle Regie/Knoten; Knoten per Bonjour finden
- [ ] Regie setzt Szene am Knoten; Knoten meldet Status zurück
- [ ] Beim Start letzte Szene laden
- **Abnahme:** Beide Macs neu starten – die Szene „OP-Live“ stellt sich ohne Eingriff wieder her.
## M4 – Intercom Stufe 1
@ -79,29 +78,3 @@ _(nach jedem Meilenstein ergänzen)_
- Hardened Runtime + Ad-hoc-Signatur → dyld lehnt die eingebettete `libndi.dylib` ab („different Team IDs“). Vorerst aus; mit echter Team-ID für die Notarisierung wieder einschalten.
- Mac mini im Leerlauf mit Quellensuche: ca. 2 % CPU, ca. 52 MB RAM.
- Mac mini ist per SSH erreichbar (Entfernte Anmeldung + Bildschirmfreigabe): App per `scp` verteilen, Quarantäne entfernen, Logs mit `log stream` mitlesen.
### M1 – Bild (umgesetzt 2026-10-21, Abnahme offen)
- „NDI Test Patterns“ sendet ein Standbild nur einmal. Eine gehaltene NDI-Verbindung zählt deshalb als lebendig; neu verbunden wird nur, wenn Frames **und** Verbindung fehlen.
- Volles NDI 1080p50 mit bewegtem Bild: ~128 Mbit/s, ~50 % eines Kerns auf dem Mac mini – fast nur NDI-Empfang/Dekodierung. HX3 (ZowieBox) noch zu messen.
- Verlorene Frames können vom **Sender** kommen: OBS auf dem Mac Studio ließ Frames aus, während dort Release-Builds liefen (~335 Frames in 6 min). Für Langzeittests darf auf dem Sender nichts Schweres laufen.
- Mauszeiger über Ausgaben per `NSCursor.hide/unhide` (gezählt, ausbalanciert), nicht `setHiddenUntilMouseMoves`.
### M2 – Ton-Matrix (umgesetzt 2026-10-21, Langzeittest offen)
- Core Audio direkt (AUHAL je Gerät) statt `AVAudioEngine`: einzelne Kanäle beschreiben, mehrere Quellen pro Kanal mischen, Render-Callback garantiert ohne Allokation.
- Lock-freier Ringpuffer in C (`stdatomic`), weil Swifts `Atomic` erst ab macOS 15 verfügbar ist.
- NDI liefert beim Verbinden einen Rückstau (~140 ms). Nach dem Vorfüllen wird einmalig auf die Ziel-Latenz gekürzt, sonst startet der Ton mit zu viel Verzögerung.
- OBS/DistroAV sendet Audio in 1024er-Blöcken mit bis zu ~36 ms Lücke. 50 ms Puffer gaben vereinzelte Unterläufe → NDI-Routen 80 ms, Mikro-Routen 12 ms.
- Drift-Regler langsam auslegen (Sekunden): der Füllstand ist durch die Blöcke ein Sägezahn. Im Betrieb ±150 ppm Korrektur, 0 Aussetzer in 2 min auf MOTU Out 2, CPU 0,3 %.
- Entfernte Routen werden 2 s auf dem Main Thread geparkt, damit der Render-Thread nie den letzten Verweis loslässt (sonst `free` im Callback).
- Sprechtaste global per Carbon-Hotkey (⌃⌥ Leertaste) – braucht keine Bedienungshilfen-Freigabe und meldet Drücken und Loslassen.
### M3 – Szenen und Fernsteuerung (umgesetzt 2026-10-21, Abnahme teilweise)
- Szene = Konfiguration der Regie + Konfiguration je Knoten. Bildschirme werden über den **Namen** erkannt (Position nur bei gleichnamigen Monitoren), weil `CGDirectDisplayID` je Rechner/Neustart wechselt.
- Musubi speichert den Live-Stand laufend (`~/Library/Application Support/Musubi/live-state.json`) und stellt ihn beim Start wieder her – nicht die zuletzt gespeicherte Szene. Ausgaben für fehlende Bildschirme bleiben vorgemerkt und starten, sobald der Bildschirm erscheint.
- Die Regie ist maßgeblich: Beim (Wieder-)Verbinden schickt sie ihre Soll-Belegung an den Knoten. Kennt sie den Knoten noch nicht, übernimmt sie dessen Stand.
- **Bonjour braucht die App in `/Applications`.** In privaten Ordnern (`~/Applications`, Build-Ordner) kann `mDNSResponder` die Info.plist nicht lesen und lehnt `NSBonjourServices` ab (`NoAuth -65555`). NDI ist nicht betroffen (eigenes Multicast).
- Direkt nach dem Start meldet macOS einige Sekunden „Local network prohibited“. Verbindungen brauchen daher Zeitgrenze + Wiederholung (6 s / 3 s).
- Signatur mit Developer ID + Hardened Runtime: Freigaben (lokales Netzwerk, Mikrofon) bleiben über Builds erhalten. Mikrofon braucht das Entitlement `com.apple.security.device.audio-input`.
- **Als Anmeldeobjekt startet macOS die App ohne Fenster.** Alles Wichtige startet deshalb in `applicationDidFinishLaunching`, nicht in einer SwiftUI-`.task`.
- Der Wechsel auf 50 Hz löst synchron „Bildschirme geändert“ aus. Ohne Schutz startete jede Ausgabe doppelt (zweites Fenster blieb nach „Beenden“ stehen). Ausgabe jetzt vor dem Moduswechsel eintragen, Abgleich nie verschachtelt.
- Offen: Beim ersten Neustart-Versuch hatte die Regie kurz vorher „keine Ausgaben“ für den Knoten gespeichert – Ursache nicht reproduzierbar (evtl. Klick auf „Ausgabe beenden“). Jede Änderung wird jetzt protokolliert (Kategorie `scene`).

@ -31,24 +31,15 @@ Sobald die bestellte Hardware da ist, wird der echte Aufbau zu Hause nachgestell
- [x] M0: App auf Mac mini 2018 kopieren, per Rechtsklick → Öffnen starten (ad-hoc signiert, Gatekeeper fragt); Rückfrage „Lokales Netzwerk“ erlauben
- [x] M0: NDI Tools Test Patterns (oder OBS + DistroAV) auf einem anderen Gerät starten → Quelle erscheint auf beiden Macs in der Quellenliste
- [ ] M1 Langzeittest (Build `m1-candidate` oder neuer, ohne ZowieBoxen): OBS sendet „pgm“ (1080p50, volles NDI, bewegtes Bild), Test Patterns als zweite Quelle. Start per SSH vom Mac Studio:
- [ ] M1 Langzeittest (Build `m1-candidate`, ohne ZowieBoxen): OBS sendet „pgm“ (1080p50, volles NDI, bewegtes Bild), Test Patterns als zweite Quelle. Start per SSH vom Mac Studio:
`ssh samuelzielke@10.0.100.76 'open ~/Applications/Musubi.app --args --route "MACSTUDONSAMUEL (pgm)=1" --route "MACSTUDONSAMUEL (Test Patterns)=0"'`
Nach 4 h: `~/Library/Logs/Musubi/stats-<Datum>.csv` auswerten (Verlustrate < 0,1 %, CPU-Verlauf, keine `stalled`-Zeilen ohne Ursache).
- [ ] M1: Neu verbinden: NDI-Ausgang in OBS 10 s stoppen → Bild kommt von selbst zurück, Karte zeigt „… antwortet nicht mehr. Neuer Versuch in … s.“
- [ ] M1: Esc auf einer Ausgabe schließt sie, Bildschirm springt auf vorige Frequenz zurück
- [ ] M1: Mac mini zeigt PGM + Multiview 4 h ohne Aussetzer; CPU-Last: ___ %
- [ ] M1: Latenz Multiview messen (Uhr mit Millisekunden vor Kamera → ATEM → ZowieBox → Musubi): ___ ms
- [ ] M2 Langzeittest (4 h, MOTU M2 am Regie-Mac): OBS-Ton als Quelle, Start mit
`open Musubi.app --args --audio-route "ndi:MACSTUDONSAMUEL (pgm)=M2:2"`, Headset per Ansicht „Ton“ auf Out 1.
Danach `~/Library/Logs/Musubi/audio-<Datum>.csv` auswerten: Spalte `dropouts` bleibt 0, `peak_dbfs` zeigt Signal. Auf dem Sender (OBS) nichts Schweres laufen lassen.
- [ ] M2: Sprechtaste (⌃⌥ Leertaste, gedrückt halten und umschalten) – Ton auf Out 1 ohne spürbare Verzögerung, Knopf rot nur während des Sprechens
- [ ] M2: MOTU während des Betriebs abziehen und wieder anstecken → Ton kommt von selbst zurück
- [ ] M2: PGM-Ton über M2 Out 2 → Ui12 Kanal 4; Talkback M2 Out 1 → Ui12 Kanal 3 (nur Aux 2)
- [ ] M3 Vorbereitung Knoten (Mac mini): Musubi in `/Applications` (nicht `~/Applications`, sonst kein Bonjour), Rolle Knoten (`defaults write studio.zielke.musubi role node`), Technik-Schnittstelle (`defaults write studio.zielke.musubi interfaceName en7`), Autostart (`open /Applications/Musubi.app --args --login-item on`), automatische Anmeldung an, FileVault aus, „Nach Stromausfall neu starten“ an
- [ ] M3 Vorbereitung Regie (MacBook): Musubi in `/Applications`, Einstellungen (⌘,): Rolle Regie, Technik-Netz, „Beim Anmelden starten“
- [x] M3: Mac mini neu starten → Musubi startet ohne Fenster, stellt beide Ausgaben wieder her, Regie verbindet sich (2026-10-21)
- [ ] M3: Szene „OP-Live“ übersteht Neustart beider Macs (Durchlauf in einem Stück; Regie = MacBook)
- [ ] M3: Netzwerkkabel zwischen den Macs 30 s ziehen → Knoten zeigt weiter, Regie verbindet sich danach von selbst
- [ ] M3: Szene „OP-Live“ übersteht Neustart beider Macs
- [ ] M4/M5: Handy über zweite Schnittstelle: Hören, Sprechen, Vorschau
- [ ] M6: Tally auf dem Handy folgt dem Micro Panel

@ -39,18 +39,14 @@ targets:
LSApplicationCategoryType: public.app-category.video
NSLocalNetworkUsageDescription: Musubi sucht NDI-Quellen im Technik-Netz.
NSMicrophoneUsageDescription: Musubi legt dein Headset-Mikro per Sprechtaste auf einen Ausgangskanal.
NSBonjourServices: [_ndi._tcp, _musubi._tcp]
NSBonjourServices: [_ndi._tcp]
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: studio.zielke.musubi
# Stable signing identity: macOS keys privacy grants (local network, microphone) to it.
# libndi.dylib is re-signed on copy with the same team, so library validation passes.
CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: "Developer ID Application"
DEVELOPMENT_TEAM: XY4C2RG3L5
ENABLE_HARDENED_RUNTIME: YES
CODE_SIGN_ENTITLEMENTS: App/Musubi.entitlements
OTHER_CODE_SIGN_FLAGS: --timestamp=none
# Ad-hoc signing for now. Hardened runtime needs a real Team ID, otherwise
# library validation rejects the embedded libndi.dylib. Enable for notarization.
CODE_SIGN_IDENTITY: "-"
ENABLE_HARDENED_RUNTIME: NO
LIBRARY_SEARCH_PATHS: $(SRCROOT)/Vendor/NDI/lib
LD_RUNPATH_SEARCH_PATHS: "@executable_path/../Frameworks"
preBuildScripts:
@ -72,8 +68,6 @@ targets:
product: AudioMatrix
- package: MusubiKit
product: DesignSystem
- package: MusubiKit
product: Remote
- framework: Vendor/NDI/lib/libndi.dylib
embed: true
codeSign: true
@ -86,8 +80,6 @@ schemes:
test:
targets:
- package: MusubiKit/MusubiCoreTests
- package: MusubiKit/AudioMatrixTests
- package: MusubiKit/RemoteTests
run:
config: Debug
archive:

Loading…
Cancel
Save

Powered by TurnKey Linux.