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 <noreply@anthropic.com>
main
Samuel Zielke 2 days ago
parent 86d1b44000
commit df9a1fa138

3
.gitignore vendored

@ -18,3 +18,6 @@ Vendor/NDI/
*.p12 *.p12
Secrets/ Secrets/
Vendor/ATEM/ Vendor/ATEM/
# XcodeGen output (generate with `xcodegen`)
Musubi.xcodeproj/
App/Info.plist

@ -0,0 +1,5 @@
{
"sourceLanguage" : "de",
"strings" : {},
"version" : "1.0"
}

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

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

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

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

@ -0,0 +1,3 @@
// Source → output channel routing via Core Audio / AVAudioEngine.
// Implemented in milestone 2.
import MusubiCore

@ -0,0 +1,5 @@
module CNDI [system] {
header "shim.h"
link "ndi"
export *
}

@ -0,0 +1,2 @@
// Pulls in the NDI SDK from Vendor/NDI/ (see Vendor/README.md).
#include "../../../../Vendor/NDI/include/Processing.NDI.Lib.h"

@ -0,0 +1 @@
../../../../design/Theme.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)..<close].trimmingCharacters(in: .whitespaces)
return inner.isEmpty ? ndiName : inner
}
}
public enum Output: Codable, Hashable, Sendable {
case screen(displayID: UInt32)
case audioChannel(deviceUID: String, channel: Int)
}
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
}
}

@ -0,0 +1,14 @@
import CNDI
/// Process-wide NDI runtime.
public enum NDI {
/// Initializes the NDI library. Returns false if the CPU is unsupported.
public static func initialize() -> Bool {
NDIlib_initialize()
}
public static var version: String {
guard let cString = NDIlib_version() else { return "unknown" }
return String(cString: cString)
}
}

@ -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..<Int(count)).compactMap { index in
guard let name = list[index].p_ndi_name else { return nil }
return Source(ndiName: String(cString: name))
}
}
}

@ -0,0 +1,3 @@
// Video presentation (Metal / AVSampleBufferDisplayLayer), one fullscreen window per NSScreen.
// Implemented in milestone 1.
import MusubiCore

@ -0,0 +1,28 @@
import Foundation
import Testing
@testable import MusubiCore
@Suite struct ModelTests {
@Test func sceneRoundTripsThroughJSON() throws {
let pgm = Source(ndiName: "ZOWIEBOX-PGM (PGM)")
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)
#expect(decoded == scene)
}
@Test(arguments: [
("ZOWIEBOX-PGM (PGM)", "PGM"),
("MACBOOK (Test Pattern)", "Test Pattern"),
("NoParens", "NoParens"),
("EMPTY ()", "EMPTY ()"),
])
func defaultLabelUsesStreamName(ndiName: String, expected: String) {
#expect(Source.defaultLabel(for: ndiName) == expected)
}
}

@ -29,6 +29,26 @@ Planung abgeschlossen, Entwicklung startet mit Meilenstein 0. Siehe [docs/04-fah
| [docs/05-testplan-hardware.md](docs/05-testplan-hardware.md) | Tests, sobald die Hardware da ist | | [docs/05-testplan-hardware.md](docs/05-testplan-hardware.md) | Tests, sobald die Hardware da ist |
| [docs/06-netzwerk.md](docs/06-netzwerk.md) | IP-Plan, Schnittstellen, Relay als Plan B | | [docs/06-netzwerk.md](docs/06-netzwerk.md) | IP-Plan, Schnittstellen, Relay als Plan B |
## Bauen
```bash
brew install xcodegen # einmalig
xcodegen # erzeugt Musubi.xcodeproj aus project.yml
open Musubi.xcodeproj
```
Das `.xcodeproj` ist generiert und nicht im Repo. Nach Änderungen an `project.yml` erneut `xcodegen` ausführen.
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, 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 |
## Voraussetzungen ## Voraussetzungen
- Xcode (aktuell), macOS auf Apple Silicon zum Entwickeln - Xcode (aktuell), macOS auf Apple Silicon zum Entwickeln

2
Vendor/README.md vendored

@ -5,7 +5,7 @@
Das NDI SDK wird **nicht** eingecheckt (Lizenzbedingungen). Das NDI SDK wird **nicht** eingecheckt (Lizenzbedingungen).
1. NDI SDK für macOS von ndi.video herunterladen und installieren. 1. NDI SDK für macOS von ndi.video herunterladen und installieren.
2. Header und `libndi` nach `Vendor/NDI/` kopieren: 2. Header und `libndi` nach `Vendor/NDI/` kopieren (aus `/Library/NDI SDK for Apple/`: `include/` und `lib/macOS/libndi.dylib`):
``` ```
Vendor/NDI/include/Processing.NDI.Lib.h (+ übrige Header) Vendor/NDI/include/Processing.NDI.Lib.h (+ übrige Header)
Vendor/NDI/lib/libndi.dylib (Universal: arm64 + x86_64) Vendor/NDI/lib/libndi.dylib (Universal: arm64 + x86_64)

@ -1,30 +1,31 @@
import AppKit
import SwiftUI import SwiftUI
/// Musubi design tokens. Source of truth: design/tokens.json and docs/03-design.md. /// Musubi design tokens. Source of truth: design/tokens.json and docs/03-design.md.
enum Theme { public enum Theme {
enum Color { public enum Color {
static let sumi = adaptive(dark: 0x14151B, light: 0xF4F4F6) // background public static let sumi = adaptive(dark: 0x14151B, light: 0xF4F4F6) // background
static let kasumi = adaptive(dark: 0x1E2029, light: 0xFFFFFF) // surface public static let kasumi = adaptive(dark: 0x1E2029, light: 0xFFFFFF) // surface
static let usuzumi = adaptive(dark: 0x2B2E3A, light: 0xDADBE2) // lines public static let usuzumi = adaptive(dark: 0x2B2E3A, light: 0xDADBE2) // lines
static let washi = adaptive(dark: 0xECE9E2, light: 0x16171D) // text public static let washi = adaptive(dark: 0xECE9E2, light: 0x16171D) // text
static let nezumi = adaptive(dark: 0x8E8C96, light: 0x6A6974) // muted text public static let nezumi = adaptive(dark: 0x8E8C96, light: 0x6A6974) // muted text
static let ai = adaptive(dark: 0x8A99F2, light: 0x4F63D9) // the one brand accent public static let ai = adaptive(dark: 0x8A99F2, light: 0x4F63D9) // the one brand accent
// Status colours – only for states, never decoration. // Status colours – only for states, never decoration.
static let shu = SwiftUI.Color(hex: 0xE5484D) // tally PGM, talk active public static let shu = SwiftUI.Color(hex: 0xE5484D) // tally PGM, talk active
static let moegi = SwiftUI.Color(hex: 0x3DBE7A) // tally preview, connection ok public static let moegi = SwiftUI.Color(hex: 0x3DBE7A) // tally preview, connection ok
static let kohaku = SwiftUI.Color(hex: 0xE8A23A) // warning public static let kohaku = SwiftUI.Color(hex: 0xE8A23A) // warning
} }
enum Radius { static let card: CGFloat = 12, button: CGFloat = 8, chip: CGFloat = 6 } public enum Radius { public static let card: CGFloat = 12, button: CGFloat = 8, chip: CGFloat = 6 }
enum Space { static let unit: CGFloat = 8, card: CGFloat = 16 } public enum Space { public static let unit: CGFloat = 8, card: CGFloat = 16 }
enum Stroke { static let line: CGFloat = 1, cord: CGFloat = 2, knot: CGFloat = 6 } public enum Stroke { public static let line: CGFloat = 1, cord: CGFloat = 2, knot: CGFloat = 6 }
enum Font { public enum Font {
static let title = SwiftUI.Font.system(.title3, weight: .semibold) public static let title = SwiftUI.Font.system(.title3, weight: .semibold)
static let body = SwiftUI.Font.system(.body) public static let body = SwiftUI.Font.system(.body)
static let caption = SwiftUI.Font.system(.caption) public static let caption = SwiftUI.Font.system(.caption)
static let number = SwiftUI.Font.system(.body, design: .monospaced).monospacedDigit() public static let number = SwiftUI.Font.system(.body, design: .monospaced).monospacedDigit()
} }
private static func adaptive(dark: UInt32, light: UInt32) -> SwiftUI.Color { private static func adaptive(dark: UInt32, light: UInt32) -> SwiftUI.Color {
@ -36,11 +37,11 @@ enum Theme {
} }
extension SwiftUI.Color { extension SwiftUI.Color {
init(hex: UInt32) { self.init(nsColor: NSColor(hex: hex)) } public init(hex: UInt32) { self.init(nsColor: NSColor(hex: hex)) }
} }
extension NSColor { extension NSColor {
convenience init(hex: UInt32) { public convenience init(hex: UInt32) {
self.init(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, self.init(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255,
green: CGFloat((hex >> 8) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255,
blue: CGFloat(hex & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255,

@ -26,6 +26,11 @@ Sobald die bestellte Hardware da ist, wird der echte Aufbau zu Hause nachgestell
## Phase B – mit Musubi (je nach Meilenstein) ## 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: Mac mini zeigt PGM + Multiview 4 h ohne Aussetzer; CPU-Last: ___ %
- [ ] M1: Latenz Multiview messen (Uhr mit Millisekunden vor Kamera → ATEM → ZowieBox → Musubi): ___ ms - [ ] 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) - [ ] M2: PGM-Ton über M2 Out 2 → Ui12 Kanal 4; Talkback M2 Out 1 → Ui12 Kanal 3 (nur Aux 2)

@ -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
Loading…
Cancel
Save

Powered by TurnKey Linux.