From df9a1fa138e851cf659711ddf8a79e63d0aea38d Mon Sep 17 00:00:00 2001 From: Samuel Zielke Date: Wed, 21 Oct 2026 10:22:23 +0200 Subject: [PATCH] feat: scaffold M0 app, MusubiKit modules and NDI source discovery - XcodeGen project: macOS 14, universal (arm64 + x86_64), studio.zielke.musubi - MusubiKit package: MusubiCore, NDIKit (+ CNDI module map), VideoOutput, AudioMatrix, DesignSystem (symlink to design/Theme.swift) - Main window with NDI source list, NDI version logged at launch - Swift Testing suite for MusubiCore models Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + App/Resources/Localizable.xcstrings | 5 ++ App/Sources/ContentView.swift | 78 +++++++++++++++++ App/Sources/MusubiApp.swift | 22 +++++ App/Sources/SourceListModel.swift | 37 ++++++++ Packages/MusubiKit/Package.swift | 32 +++++++ .../Sources/AudioMatrix/AudioMatrix.swift | 3 + .../MusubiKit/Sources/CNDI/module.modulemap | 5 ++ Packages/MusubiKit/Sources/CNDI/shim.h | 2 + .../Sources/DesignSystem/Theme.swift | 1 + .../MusubiKit/Sources/MusubiCore/Models.swift | 57 +++++++++++++ Packages/MusubiKit/Sources/NDIKit/NDI.swift | 14 +++ .../MusubiKit/Sources/NDIKit/NDIFinder.swift | 59 +++++++++++++ .../Sources/VideoOutput/VideoOutput.swift | 3 + .../Tests/MusubiCoreTests/ModelTests.swift | 28 ++++++ README.md | 20 +++++ Vendor/README.md | 2 +- design/Theme.swift | 43 +++++----- docs/05-testplan-hardware.md | 5 ++ project.yml | 85 +++++++++++++++++++ 20 files changed, 482 insertions(+), 22 deletions(-) create mode 100644 App/Resources/Localizable.xcstrings create mode 100644 App/Sources/ContentView.swift create mode 100644 App/Sources/MusubiApp.swift create mode 100644 App/Sources/SourceListModel.swift create mode 100644 Packages/MusubiKit/Package.swift create mode 100644 Packages/MusubiKit/Sources/AudioMatrix/AudioMatrix.swift create mode 100644 Packages/MusubiKit/Sources/CNDI/module.modulemap create mode 100644 Packages/MusubiKit/Sources/CNDI/shim.h create mode 120000 Packages/MusubiKit/Sources/DesignSystem/Theme.swift create mode 100644 Packages/MusubiKit/Sources/MusubiCore/Models.swift create mode 100644 Packages/MusubiKit/Sources/NDIKit/NDI.swift create mode 100644 Packages/MusubiKit/Sources/NDIKit/NDIFinder.swift create mode 100644 Packages/MusubiKit/Sources/VideoOutput/VideoOutput.swift create mode 100644 Packages/MusubiKit/Tests/MusubiCoreTests/ModelTests.swift create mode 100644 project.yml diff --git a/.gitignore b/.gitignore index 9c58025..3b483bd 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ Vendor/NDI/ *.p12 Secrets/ Vendor/ATEM/ +# XcodeGen output (generate with `xcodegen`) +Musubi.xcodeproj/ +App/Info.plist diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings new file mode 100644 index 0000000..d743b15 --- /dev/null +++ b/App/Resources/Localizable.xcstrings @@ -0,0 +1,5 @@ +{ + "sourceLanguage" : "de", + "strings" : {}, + "version" : "1.0" +} diff --git a/App/Sources/ContentView.swift b/App/Sources/ContentView.swift new file mode 100644 index 0000000..9d5fb08 --- /dev/null +++ b/App/Sources/ContentView.swift @@ -0,0 +1,78 @@ +import DesignSystem +import MusubiCore +import SwiftUI + +struct ContentView: View { + let sourceList: SourceListModel + + var body: some View { + NavigationSplitView { + SourceListView(sourceList: sourceList) + .navigationSplitViewColumnWidth(min: 220, ideal: 260) + } detail: { + EmptyStateView(text: "Noch keine Szene. Zieh eine Quelle auf einen Bildschirm.") + } + .navigationTitle("Musubi") + .navigationSubtitle("NDI rein. Bild, Ton und Stimme raus.") + } +} + +private struct SourceListView: View { + let sourceList: SourceListModel + + var body: some View { + 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) + } + .scrollContentBackground(.hidden) + } + } + } + .background(Theme.Color.kasumi) + .safeAreaInset(edge: .top, spacing: 0) { + Text("Quellen") + .font(Theme.Font.title) + .foregroundStyle(Theme.Color.washi) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Theme.Space.card) + } + } +} + +private struct SourceRow: View { + let source: Source + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Space.unit / 2) { + Text(verbatim: source.label) + .font(Theme.Font.body) + .foregroundStyle(Theme.Color.washi) + Text(verbatim: source.ndiName) + .font(Theme.Font.caption.monospaced()) + .foregroundStyle(Theme.Color.nezumi) + } + .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) + } +} diff --git a/App/Sources/MusubiApp.swift b/App/Sources/MusubiApp.swift new file mode 100644 index 0000000..9f6e453 --- /dev/null +++ b/App/Sources/MusubiApp.swift @@ -0,0 +1,22 @@ +import DesignSystem +import NDIKit +import OSLog +import SwiftUI + +@main +struct MusubiApp: App { + @State private var sourceList = SourceListModel() + + var body: some SwiftUI.Scene { + Window("Musubi", id: "main") { + ContentView(sourceList: sourceList) + .preferredColorScheme(.dark) + .task { sourceList.start() } + } + .defaultSize(width: 960, height: 600) + } +} + +enum Log { + static let ndi = Logger(subsystem: "studio.zielke.musubi", category: "ndi") +} diff --git a/App/Sources/SourceListModel.swift b/App/Sources/SourceListModel.swift new file mode 100644 index 0000000..b5f77fe --- /dev/null +++ b/App/Sources/SourceListModel.swift @@ -0,0 +1,37 @@ +import MusubiCore +import NDIKit +import Observation + +@MainActor @Observable +final class SourceListModel { + enum State { case idle, searching, unavailable } + + private(set) var sources: [Source] = [] + private(set) var state: State = .idle + private var finder: NDIFinder? + + func start() { + guard state == .idle else { return } + + guard NDI.initialize() else { + Log.ndi.error("NDI initialization failed (unsupported CPU)") + state = .unavailable + return + } + Log.ndi.info("NDI \(NDI.version, privacy: .public)") + + guard let finder = NDIFinder() else { + Log.ndi.error("Could not create NDI finder") + state = .unavailable + return + } + self.finder = finder + state = .searching + + Task { [weak self] in + for await list in finder.sources { + self?.sources = list.sorted { $0.ndiName.localizedStandardCompare($1.ndiName) == .orderedAscending } + } + } + } +} diff --git a/Packages/MusubiKit/Package.swift b/Packages/MusubiKit/Package.swift new file mode 100644 index 0000000..1e6c0eb --- /dev/null +++ b/Packages/MusubiKit/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "MusubiKit", + defaultLocalization: "de", + platforms: [.macOS(.v14)], + products: [ + .library(name: "MusubiCore", targets: ["MusubiCore"]), + .library(name: "NDIKit", targets: ["NDIKit"]), + .library(name: "VideoOutput", targets: ["VideoOutput"]), + .library(name: "AudioMatrix", targets: ["AudioMatrix"]), + .library(name: "DesignSystem", targets: ["DesignSystem"]), + ], + targets: [ + // Pure logic: models, scenes, settings. + .target(name: "MusubiCore"), + + // NDI SDK (C) via module map. Headers and libndi live in Vendor/NDI/ (not checked in). + .systemLibrary(name: "CNDI"), + .target(name: "NDIKit", dependencies: ["CNDI", "MusubiCore"]), + + // Placeholders, filled in M1 (video) and M2 (audio). + .target(name: "VideoOutput", dependencies: ["MusubiCore"]), + .target(name: "AudioMatrix", dependencies: ["MusubiCore"]), + + // Theme.swift is a symlink to design/Theme.swift (single source of truth). + .target(name: "DesignSystem"), + + .testTarget(name: "MusubiCoreTests", dependencies: ["MusubiCore"]), + ] +) diff --git a/Packages/MusubiKit/Sources/AudioMatrix/AudioMatrix.swift b/Packages/MusubiKit/Sources/AudioMatrix/AudioMatrix.swift new file mode 100644 index 0000000..f9206b1 --- /dev/null +++ b/Packages/MusubiKit/Sources/AudioMatrix/AudioMatrix.swift @@ -0,0 +1,3 @@ +// Source → output channel routing via Core Audio / AVAudioEngine. +// Implemented in milestone 2. +import MusubiCore diff --git a/Packages/MusubiKit/Sources/CNDI/module.modulemap b/Packages/MusubiKit/Sources/CNDI/module.modulemap new file mode 100644 index 0000000..a17220c --- /dev/null +++ b/Packages/MusubiKit/Sources/CNDI/module.modulemap @@ -0,0 +1,5 @@ +module CNDI [system] { + header "shim.h" + link "ndi" + export * +} diff --git a/Packages/MusubiKit/Sources/CNDI/shim.h b/Packages/MusubiKit/Sources/CNDI/shim.h new file mode 100644 index 0000000..8197671 --- /dev/null +++ b/Packages/MusubiKit/Sources/CNDI/shim.h @@ -0,0 +1,2 @@ +// Pulls in the NDI SDK from Vendor/NDI/ (see Vendor/README.md). +#include "../../../../Vendor/NDI/include/Processing.NDI.Lib.h" diff --git a/Packages/MusubiKit/Sources/DesignSystem/Theme.swift b/Packages/MusubiKit/Sources/DesignSystem/Theme.swift new file mode 120000 index 0000000..dfddadf --- /dev/null +++ b/Packages/MusubiKit/Sources/DesignSystem/Theme.swift @@ -0,0 +1 @@ +../../../../design/Theme.swift \ No newline at end of file diff --git a/Packages/MusubiKit/Sources/MusubiCore/Models.swift b/Packages/MusubiKit/Sources/MusubiCore/Models.swift new file mode 100644 index 0000000..e89c5f4 --- /dev/null +++ b/Packages/MusubiKit/Sources/MusubiCore/Models.swift @@ -0,0 +1,57 @@ +import Foundation + +/// An NDI source as seen on the network. +public struct Source: Codable, Hashable, Sendable { + public var ndiName: String + public var label: String + + public init(ndiName: String, label: String? = nil) { + self.ndiName = ndiName + self.label = label ?? Source.defaultLabel(for: ndiName) + } + + /// NDI names look like "MACHINE (Stream)". The stream part is the friendlier label. + public static func defaultLabel(for ndiName: String) -> String { + guard let open = ndiName.firstIndex(of: "("), + let close = ndiName.lastIndex(of: ")"), + open < close + else { return ndiName } + let inner = ndiName[ndiName.index(after: open).. Bool { + NDIlib_initialize() + } + + public static var version: String { + guard let cString = NDIlib_version() else { return "unknown" } + return String(cString: cString) + } +} diff --git a/Packages/MusubiKit/Sources/NDIKit/NDIFinder.swift b/Packages/MusubiKit/Sources/NDIKit/NDIFinder.swift new file mode 100644 index 0000000..2e501e0 --- /dev/null +++ b/Packages/MusubiKit/Sources/NDIKit/NDIFinder.swift @@ -0,0 +1,59 @@ +import CNDI +import Foundation +import MusubiCore + +/// Discovers NDI sources on a dedicated thread and publishes the current list. +public final class NDIFinder: @unchecked Sendable { + // Only touched on `thread`, except for creation and `stop()`. + private let instance: NDIlib_find_instance_t + private let lock = NSLock() + private var running = true + private var thread: Thread? + + public let sources: AsyncStream<[Source]> + private let continuation: AsyncStream<[Source]>.Continuation + + public init?(showLocalSources: Bool = true) { + var settings = NDIlib_find_create_t(show_local_sources: showLocalSources, p_groups: nil, p_extra_ips: nil) + guard let instance = NDIlib_find_create_v2(&settings) else { return nil } + self.instance = instance + (sources, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + + let thread = Thread { [weak self] in self?.run() } + thread.name = "studio.zielke.musubi.ndi-finder" + thread.qualityOfService = .utility + self.thread = thread + thread.start() + } + + deinit { + stop() + NDIlib_find_destroy(instance) + } + + public func stop() { + lock.withLock { running = false } + continuation.finish() + } + + private var isRunning: Bool { lock.withLock { running } } + + private func run() { + continuation.yield(currentSources()) + while isRunning { + // Returns early when the list changed, otherwise after the timeout. + if NDIlib_find_wait_for_sources(instance, 1000) { + continuation.yield(currentSources()) + } + } + } + + private func currentSources() -> [Source] { + var count: UInt32 = 0 + guard let list = NDIlib_find_get_current_sources(instance, &count) else { return [] } + return (0.. SwiftUI.Color { @@ -36,11 +37,11 @@ enum Theme { } extension SwiftUI.Color { - init(hex: UInt32) { self.init(nsColor: NSColor(hex: hex)) } + public init(hex: UInt32) { self.init(nsColor: NSColor(hex: hex)) } } extension NSColor { - convenience init(hex: UInt32) { + public convenience init(hex: UInt32) { self.init(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, diff --git a/docs/05-testplan-hardware.md b/docs/05-testplan-hardware.md index 36a4fea..975cf99 100644 --- a/docs/05-testplan-hardware.md +++ b/docs/05-testplan-hardware.md @@ -26,6 +26,11 @@ Sobald die bestellte Hardware da ist, wird der echte Aufbau zu Hause nachgestell ## Phase B – mit Musubi (je nach Meilenstein) +- [ ] M0: Release-Build erzeugen (Xcode → Product → Archive oder `xcodebuild -scheme Musubi -configuration Release`), `lipo -info Musubi.app/Contents/MacOS/Musubi` zeigt `x86_64 arm64` +- [ ] M0: App auf MacBook starten; in Console.app (Filter `studio.zielke.musubi`) steht die NDI-Version +- [ ] M0: App auf Mac mini 2018 kopieren, per Rechtsklick → Öffnen starten (ad-hoc signiert, Gatekeeper fragt); Rückfrage „Lokales Netzwerk“ erlauben +- [ ] M0: NDI Tools Test Patterns (oder OBS + DistroAV) auf einem anderen Gerät starten → Quelle erscheint auf beiden Macs in der Quellenliste + - [ ] 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: PGM-Ton über M2 Out 2 → Ui12 Kanal 4; Talkback M2 Out 1 → Ui12 Kanal 3 (nur Aux 2) diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..f0471f4 --- /dev/null +++ b/project.yml @@ -0,0 +1,85 @@ +name: Musubi +options: + bundleIdPrefix: studio.zielke + deploymentTarget: + macOS: "14.0" + developmentLanguage: de + createIntermediateGroups: true + +packages: + MusubiKit: + path: Packages/MusubiKit + +settings: + base: + SWIFT_VERSION: "6.0" + MACOSX_DEPLOYMENT_TARGET: "14.0" + ARCHS: "arm64 x86_64" + LOCALIZATION_PREFERS_STRING_CATALOGS: YES + SWIFT_EMIT_LOC_STRINGS: YES + configs: + Debug: + ONLY_ACTIVE_ARCH: YES + Release: + ONLY_ACTIVE_ARCH: NO + +targets: + Musubi: + type: application + platform: macOS + sources: + - App/Sources + - App/Resources + info: + path: App/Info.plist + properties: + CFBundleDisplayName: Musubi + CFBundleShortVersionString: "0.1.0" + CFBundleVersion: "1" + LSApplicationCategoryType: public.app-category.video + NSLocalNetworkUsageDescription: Musubi sucht NDI-Quellen im Technik-Netz. + NSBonjourServices: [_ndi._tcp] + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: studio.zielke.musubi + # 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: + - name: Check NDI SDK + basedOnDependencyAnalysis: false + script: | + if [ ! -f "${SRCROOT}/Vendor/NDI/include/Processing.NDI.Lib.h" ] || [ ! -f "${SRCROOT}/Vendor/NDI/lib/libndi.dylib" ]; then + echo "error: NDI SDK fehlt. Kopiere Header und libndi.dylib nach Vendor/NDI/ (siehe Vendor/README.md)." + exit 1 + fi + dependencies: + - package: MusubiKit + product: MusubiCore + - package: MusubiKit + product: NDIKit + - package: MusubiKit + product: VideoOutput + - package: MusubiKit + product: AudioMatrix + - package: MusubiKit + product: DesignSystem + - framework: Vendor/NDI/lib/libndi.dylib + embed: true + codeSign: true + +schemes: + Musubi: + build: + targets: + Musubi: all + test: + targets: + - package: MusubiKit/MusubiCoreTests + run: + config: Debug + archive: + config: Release