- 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