- 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
parent
86d1b44000
commit
df9a1fa138
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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…
Reference in new issue