Compare commits

..

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

@ -1,12 +0,0 @@
{ "images" : [
{ "filename" : "icon_16x16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" },
{ "filename" : "icon_16x16@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" },
{ "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" },
{ "filename" : "icon_32x32@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" },
{ "filename" : "icon_128x128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" },
{ "filename" : "icon_128x128@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" },
{ "filename" : "icon_256x256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" },
{ "filename" : "icon_256x256@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" },
{ "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" },
{ "filename" : "icon_512x512@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" }
], "info" : { "author" : "xcode", "version" : 1 } }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

@ -1 +0,0 @@
{ "info" : { "author" : "xcode", "version" : 1 } }

@ -15,18 +15,12 @@ final class AppSettings {
didSet { UserDefaults.standard.set(interfaceName, forKey: "interfaceName") }
}
/// BSD name of the clinic network interface for the intercom (e.g. "en0"); empty = intercom off.
var clinicInterfaceName: String {
didSet { UserDefaults.standard.set(clinicInterfaceName, forKey: "clinicInterfaceName") }
}
/// 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"
clinicInterfaceName = UserDefaults.standard.string(forKey: "clinicInterfaceName") ?? ""
}
var launchAtLogin: Bool {

@ -20,8 +20,6 @@ final class AudioController {
var selectedDeviceUID: String?
/// Called after routes or their settings changed (for autosave and scenes).
var onChange: (() -> Void)?
/// Talk key pressed or released (also used by the intercom talkback).
var onTalkChange: ((Bool) -> Void)?
private var ndiReceivers: [String: NDIReceiver] = [:]
private var meterTimer: Timer?
@ -30,10 +28,7 @@ final class AudioController {
init() {
selectedDeviceUID = Self.preferredOutput(in: matrix.outputDevices)?.uid
talkKey.onChange = { [weak self] active in
self?.matrix.talkActive = active
self?.onTalkChange?(active)
}
talkKey.onChange = { [weak self] active in self?.matrix.talkActive = active }
talkKey.register()
meterTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.updateMeters() }

@ -7,7 +7,7 @@ struct ContentView: View {
let coordinator: Coordinator
@AppStorage("detailTab") private var tab = DetailTab.screens
enum DetailTab: String { case screens, sound, intercom }
enum DetailTab: String { case screens, sound }
var body: some View {
NavigationSplitView {
@ -17,7 +17,6 @@ struct ContentView: View {
switch tab {
case .screens: ScreensView(coordinator: coordinator)
case .sound: SoundView(audio: coordinator.audio, sourceList: sourceList)
case .intercom: IntercomView(intercom: coordinator.intercom, sourceList: sourceList, audio: coordinator.audio)
}
}
.navigationTitle("Musubi")
@ -34,9 +33,6 @@ struct ContentView: View {
Picker("Ansicht", selection: $tab) {
Text("Bildschirme").tag(DetailTab.screens)
Text("Ton").tag(DetailTab.sound)
if coordinator.settings.role == .control {
Text("Intercom").tag(DetailTab.intercom)
}
}
.pickerStyle(.segmented)
.fixedSize()

@ -10,7 +10,6 @@ final class Coordinator {
let outputs: OutputController
let audio: AudioController
let store = SceneStore()
let intercom: IntercomController
private(set) var live = LiveState()
private(set) var nodeServer: NodeServer?
@ -24,8 +23,6 @@ final class Coordinator {
self.settings = settings
self.outputs = outputs
self.audio = audio
intercom = IntercomController(settings: settings, audio: audio)
audio.onTalkChange = { [weak intercom] active in intercom?.setTalkActive(active) }
}
/// Restores the last live state, starts remote control, then applies launch arguments.
@ -40,7 +37,6 @@ final class Coordinator {
outputs.onChange = { [weak self] in self?.localChanged() }
audio.onChange = { [weak self] in self?.localChanged() }
await startRemote()
await intercom.restart()
outputs.applyLaunchRoutes()
audio.applyLaunchRoutes()

@ -1,155 +0,0 @@
import AudioMatrix
import Foundation
import Intercom
import MusubiCore
import NDIKit
import Observation
/// App-side glue for the intercom (control only): server on the clinic interface,
/// PGM preview from an NDI proxy stream, talkback from the headset mic gated by the talk key.
@MainActor @Observable
final class IntercomController {
let server = IntercomServer()
/// NDI source shown on the phones (usually PGM).
var previewSourceName: String {
didSet {
UserDefaults.standard.set(previewSourceName, forKey: "intercomPreviewSource")
startPreview()
}
}
/// Input device whose signal the phones hear while the talk key is down.
var talkbackDeviceUID: String {
didSet {
UserDefaults.standard.set(talkbackDeviceUID, forKey: "talkbackDeviceUID")
startTalkback()
}
}
private let settings: AppSettings
private let audio: AudioController
private var preview: NDIReceiver?
private var tap: (feed: AudioFeed, deviceUID: String)?
private var pump: TalkbackPump?
init(settings: AppSettings, audio: AudioController) {
self.settings = settings
self.audio = audio
previewSourceName = UserDefaults.standard.string(forKey: "intercomPreviewSource") ?? ""
talkbackDeviceUID = UserDefaults.standard.string(forKey: "talkbackDeviceUID") ?? ""
}
var isEnabled: Bool { settings.role == .control && !settings.clinicInterfaceName.isEmpty }
/// Technical and clinic network on the same interface is fine at home, never on site.
var sharesTechnicalInterface: Bool { settings.clinicInterfaceName == settings.interfaceName }
/// (Re)starts or stops everything according to role and clinic interface; call after settings changes.
func restart() async {
stopAll()
guard isEnabled else { return }
await server.start(interfaceName: settings.clinicInterfaceName)
startPreview()
startTalkback()
}
func setTalkActive(_ active: Bool) {
pump?.gateOpen = active
}
private func stopAll() {
server.stop()
preview?.stop()
preview = nil
stopTalkback()
}
private func startPreview() {
preview?.stop()
preview = nil
guard isEnabled, !previewSourceName.isEmpty else { return }
let input = server.input
preview = NDIReceiver(source: Source(ndiName: previewSourceName), receiverName: "Musubi Intercom",
bandwidth: .lowest, onFrame: { input.pushVideo($0) })
Log.intercom.info("Preview from \(self.previewSourceName, privacy: .public)")
}
private func startTalkback() {
stopTalkback()
guard isEnabled else { return }
let uid = talkbackDeviceUID.isEmpty ? defaultTalkbackDevice() : talkbackDeviceUID
guard let uid, audio.matrix.inputDevices.contains(where: { $0.uid == uid }) else { return }
let feed = audio.matrix.addInputTap(deviceUID: uid)
tap = (feed, uid)
let pump = TalkbackPump(feed: feed, input: server.input)
pump.gateOpen = audio.talkKey.isActive
pump.start()
self.pump = pump
}
private func stopTalkback() {
pump?.stop()
pump = nil
if let tap { audio.matrix.removeInputTap(tap.feed, deviceUID: tap.deviceUID) }
tap = nil
}
/// The mic already routed in the sound matrix, else the system's default input.
func defaultTalkbackDevice() -> String? {
for route in audio.matrix.routes {
if case .input(let uid) = route.source { return uid }
}
return AudioDevices.defaultInput()?.uid ?? audio.matrix.inputDevices.first?.uid
}
/// Name of the device "Automatisch" currently resolves to.
var automaticTalkbackName: String? {
defaultTalkbackDevice().flatMap { uid in audio.matrix.inputDevices.first { $0.uid == uid }?.name }
}
}
/// Moves mic audio from the tap to the Opus encoder every 10 ms on its own queue.
/// Sends silence while the talk key is up, so the phones' audio clock keeps running.
private final class TalkbackPump: @unchecked Sendable {
private let feed: AudioFeed
private let input: IntercomInput
private let queue = DispatchQueue(label: "studio.zielke.musubi.intercom.talkback", qos: .userInitiated)
private var timer: DispatchSourceTimer?
private let lock = NSLock()
private var open = false
private var stereo = [Float](repeating: 0, count: 4096 * 2)
private var mono = [Float](repeating: 0, count: 4096)
init(feed: AudioFeed, input: IntercomInput) {
self.feed = feed
self.input = input
}
var gateOpen: Bool {
get { lock.withLock { open } }
set { lock.withLock { open = newValue } }
}
func start() {
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now(), repeating: .milliseconds(10), leeway: .milliseconds(2))
timer.setEventHandler { [weak self] in self?.drain() }
timer.resume()
self.timer = timer
}
func stop() {
timer?.cancel()
timer = nil
}
private func drain() {
let rate = feed.currentSampleRate
let frames = stereo.withUnsafeMutableBufferPointer { feed.read(into: $0.baseAddress!, frames: 4096) }
guard frames > 0, rate > 0 else { return }
let open = gateOpen
for i in 0..<frames { mono[i] = open ? stereo[i * 2] : 0 }
mono.withUnsafeBufferPointer { input.pushTalkback(UnsafeBufferPointer(rebasing: $0[0..<frames]), sampleRate: rate) }
}
}

@ -1,163 +0,0 @@
import CoreImage.CIFilterBuiltins
import DesignSystem
import Intercom
import SwiftUI
/// Intercom on the control: QR code for the phones, who is connected, what they see and hear.
struct IntercomView: View {
let intercom: IntercomController
let sourceList: SourceListModel
let audio: AudioController
@Environment(\.openSettings) private var openSettings
private var server: IntercomServer { intercom.server }
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Theme.Space.card) {
if !intercom.isEnabled {
VStack(alignment: .leading, spacing: Theme.Space.unit) {
Text("Intercom ist aus. Wähl in den Einstellungen das Klinik-Netz, dann verbinden sich Handys per QR-Code.")
.font(Theme.Font.body)
.foregroundStyle(Theme.Color.nezumi)
Button("Einstellungen öffnen") { openSettings() }
}
} else {
if let error = server.error {
Text(error).font(Theme.Font.body).foregroundStyle(Theme.Color.kohaku)
}
if intercom.sharesTechnicalInterface {
Text("Technik- und Klinik-Netz laufen über dieselbe Schnittstelle. Zum Testen okay, im Einsatz müssen sie getrennt sein.")
.font(Theme.Font.caption)
.foregroundStyle(Theme.Color.kohaku)
}
HStack(alignment: .top, spacing: Theme.Space.card) {
joinCard
VStack(alignment: .leading, spacing: Theme.Space.card) {
sourcesCard
participantsCard
}
}
}
}
.padding(Theme.Space.card)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(Theme.Color.sumi)
}
private var joinCard: some View {
Card {
Text("Handy verbinden").font(Theme.Font.title).foregroundStyle(Theme.Color.washi)
if let url = server.url {
QRCode(text: url.absoluteString)
.frame(width: 200, height: 200)
Text(verbatim: url.absoluteString)
.font(Theme.Font.number)
.lineLimit(1)
.minimumScaleFactor(0.6)
.foregroundStyle(Theme.Color.washi)
.textSelection(.enabled)
Text("Handy ins Klinik-Netz, QR-Code scannen, Name und Rolle eintragen.")
.font(Theme.Font.caption)
.foregroundStyle(Theme.Color.nezumi)
.fixedSize(horizontal: false, vertical: true)
} else {
Text("Startet …").font(Theme.Font.body).foregroundStyle(Theme.Color.nezumi)
}
}
.frame(width: 240)
}
private var sourcesCard: some View {
Card {
Picker("Vorschau", selection: Binding(get: { intercom.previewSourceName }, set: { intercom.previewSourceName = $0 })) {
Text("Keine").tag("")
ForEach(sourceList.sources, id: \.ndiName) { source in
Text(verbatim: source.ndiName).tag(source.ndiName)
}
if !intercom.previewSourceName.isEmpty, !sourceList.sources.contains(where: { $0.ndiName == intercom.previewSourceName }) {
Text(verbatim: "\(intercom.previewSourceName) · nicht gefunden").tag(intercom.previewSourceName)
}
}
Picker("Talkback", selection: Binding(get: { intercom.talkbackDeviceUID }, set: { intercom.talkbackDeviceUID = $0 })) {
if let name = intercom.automaticTalkbackName {
Text("Automatisch · \(name)").tag("")
} else {
Text("Automatisch").tag("")
}
ForEach(audio.matrix.inputDevices) { device in
Text(verbatim: device.name).tag(device.uid)
}
}
Text("Handys hören das Mikro, solange die Sprechtaste (\(audio.talkKey.displayName)) gedrückt ist.")
.font(Theme.Font.caption)
.foregroundStyle(Theme.Color.nezumi)
}
}
private var participantsCard: some View {
Card {
HStack {
Text("Verbunden").font(Theme.Font.title).foregroundStyle(Theme.Color.washi)
Spacer()
Text(verbatim: "\(server.participants.filter(\.connected).count)")
.font(Theme.Font.number)
.foregroundStyle(Theme.Color.nezumi)
}
if server.participants.isEmpty {
Text("Noch niemand da. Scann den QR-Code mit einem Handy.")
.font(Theme.Font.body)
.foregroundStyle(Theme.Color.nezumi)
}
ForEach(server.participants) { participant in
HStack(spacing: Theme.Space.unit) {
Circle()
.fill(participant.connected ? Theme.Color.moegi : Theme.Color.kohaku)
.frame(width: 8, height: 8)
Text(verbatim: participant.name).font(Theme.Font.body).foregroundStyle(Theme.Color.washi)
Text(verbatim: participant.role).font(Theme.Font.caption).foregroundStyle(Theme.Color.nezumi)
Spacer()
if !participant.connected {
Text("verbindet …").font(Theme.Font.caption).foregroundStyle(Theme.Color.nezumi)
}
}
}
}
}
}
private struct Card<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: Theme.Space.unit) { content }
.padding(Theme.Space.card)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.Color.kasumi, in: RoundedRectangle(cornerRadius: Theme.Radius.card))
.overlay(RoundedRectangle(cornerRadius: Theme.Radius.card).stroke(Theme.Color.usuzumi, lineWidth: Theme.Stroke.line))
}
}
/// Black on white with a quiet zone: what phone cameras read best, regardless of the dark theme.
private struct QRCode: View {
let text: String
var body: some View {
if let image = Self.image(for: text) {
Image(decorative: image, scale: 1)
.interpolation(.none)
.resizable()
.padding(Theme.Space.unit)
.background(.white, in: RoundedRectangle(cornerRadius: Theme.Radius.button))
}
}
static func image(for text: String) -> CGImage? {
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(text.utf8)
filter.correctionLevel = "M"
guard let output = filter.outputImage else { return nil }
return CIContext().createCGImage(output, from: output.extent)
}
}

@ -39,5 +39,4 @@ 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")
static let intercom = Logger(subsystem: "studio.zielke.musubi", category: "intercom")
}

@ -27,22 +27,6 @@ struct SettingsView: View {
Text("Regie und Knoten finden sich nur über diese Schnittstelle.")
.font(Theme.Font.caption)
.foregroundStyle(Theme.Color.nezumi)
if settings.role == .control {
Picker("Klinik-Netz", selection: Binding(get: { settings.clinicInterfaceName }, set: { settings.clinicInterfaceName = $0; restartIntercom() })) {
Text("Aus – kein Intercom").tag("")
ForEach(interfaces) { interface in
Text(verbatim: "\(interface.name) · \(interface.kind)").tag(interface.name)
}
if !settings.clinicInterfaceName.isEmpty, !interfaces.contains(where: { $0.name == settings.clinicInterfaceName }) {
Text(verbatim: "\(settings.clinicInterfaceName) · nicht verbunden").tag(settings.clinicInterfaceName)
}
}
Text(settings.clinicInterfaceName == settings.interfaceName
? "Gleiche Schnittstelle wie das Technik-Netz: nur zum Testen. Im Einsatz getrennt halten."
: "Handys erreichen das Intercom nur über diese Schnittstelle.")
.font(Theme.Font.caption)
.foregroundStyle(settings.clinicInterfaceName == settings.interfaceName ? Theme.Color.kohaku : Theme.Color.nezumi)
}
Toggle("Beim Anmelden starten", isOn: Binding(get: { launchAtLogin }, set: {
settings.launchAtLogin = $0
launchAtLogin = settings.launchAtLogin
@ -58,13 +42,6 @@ struct SettingsView: View {
}
private func restart() {
Task {
await coordinator.startRemote()
await coordinator.intercom.restart()
}
}
private func restartIntercom() {
Task { await coordinator.intercom.restart() }
Task { await coordinator.startRemote() }
}
}

@ -12,7 +12,6 @@ let package = Package(
.library(name: "AudioMatrix", targets: ["AudioMatrix"]),
.library(name: "DesignSystem", targets: ["DesignSystem"]),
.library(name: "Remote", targets: ["Remote"]),
.library(name: "Intercom", targets: ["Intercom"]),
],
targets: [
// Pure logic: models, scenes, settings.
@ -33,13 +32,8 @@ let package = Package(
// Control ↔ node over Bonjour/TCP on the technical interface (M3).
.target(name: "Remote", dependencies: ["MusubiCore"]),
// Phones on the clinic interface (M4): HTTP signalling + minimal WebRTC
// (ICE-lite, DTLS 1.2, SRTP) built on CryptoKit/CommonCrypto only.
.target(name: "Intercom", dependencies: ["MusubiCore"]),
.testTarget(name: "MusubiCoreTests", dependencies: ["MusubiCore"]),
.testTarget(name: "AudioMatrixTests", dependencies: ["AudioMatrix", "CAudioRing"]),
.testTarget(name: "RemoteTests", dependencies: ["Remote", "MusubiCore"]),
.testTarget(name: "IntercomTests", dependencies: ["Intercom"]),
]
)

@ -26,16 +26,6 @@ public enum AudioDevices {
return ids.compactMap(device(for:))
}
/// The input device selected in System Settings › Sound.
public static func defaultInput() -> AudioDevice? {
var address = AudioObjectPropertyAddress(mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
var id = AudioDeviceID(0)
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &id) == noErr else { return nil }
return device(for: id)
}
public static func device(uid: String) -> AudioDevice? {
all().first { $0.uid == uid }
}

@ -37,14 +37,6 @@ public final class AudioFeed: @unchecked Sendable {
musubi_ring_write(ring, scratch, UInt32(frames))
}
/// Consumer side for taps: reads up to `frames` interleaved stereo frames; returns the number read.
public func read(into interleaved: UnsafeMutablePointer<Float>, frames: Int) -> Int {
Int(musubi_ring_read(ring, interleaved, UInt32(frames)))
}
/// Sample rate of the most recent push (0 before the first).
public var currentSampleRate: Double { sampleRate }
private func ensureScratch(_ frames: Int) {
guard frames > scratchFrames else { return }
scratch.deallocate()

@ -38,8 +38,6 @@ public final class AudioMatrix {
private var retired: [RouteRenderer] = []
private var outputEngines: [String: OutputEngine] = [:]
private var inputEngines: [String: InputEngine] = [:]
/// Extra consumers of input devices (intercom talkback), keyed by device UID.
private var inputTaps: [String: [AudioFeed]] = [:]
private var deviceObserver: AnyObject?
private let log = Logger(subsystem: "studio.zielke.musubi", category: "audio")
@ -84,7 +82,7 @@ public final class AudioMatrix {
if ndiFanouts[name]?.isEmpty == true { ndiFanouts[name] = nil }
case .input(let uid):
inputEngines[uid]?.fanout.remove(renderer.feed)
if !routes.contains(where: { $0.source == route.source }), inputTaps[uid, default: []].isEmpty {
if !routes.contains(where: { $0.source == route.source }) {
inputEngines.removeValue(forKey: uid)?.stop()
}
}
@ -141,26 +139,6 @@ public final class AudioMatrix {
bufferedFrames: Int(musubi_ring_available(renderer.feed.ring)), correction: renderer.correction)
}
// MARK: - Taps
/// Captures an input device for another consumer (e.g. intercom talkback), independent of routes.
/// The caller reads the feed with `AudioFeed.read`; the talk key does not gate taps.
public func addInputTap(deviceUID: String) -> AudioFeed {
let feed = AudioFeed()
inputTaps[deviceUID, default: []].append(feed)
inputEngine(for: deviceUID)?.fanout.add(feed)
return feed
}
public func removeInputTap(_ feed: AudioFeed, deviceUID: String) {
inputTaps[deviceUID]?.removeAll { $0 === feed }
if inputTaps[deviceUID]?.isEmpty == true { inputTaps[deviceUID] = nil }
inputEngines[deviceUID]?.fanout.remove(feed)
if inputTaps[deviceUID] == nil, !routes.contains(where: { $0.source == .input(deviceUID: deviceUID) }) {
inputEngines.removeValue(forKey: deviceUID)?.stop()
}
}
// MARK: - Engines
private func retire(_ renderer: RouteRenderer) {
@ -239,13 +217,6 @@ public final class AudioMatrix {
engine.fanout.remove(renderer.feed)
engine.fanout.add(renderer.feed)
}
for (uid, feeds) in inputTaps where present.contains(uid) {
guard let engine = inputEngine(for: uid) else { continue }
for feed in feeds {
engine.fanout.remove(feed)
engine.fanout.add(feed)
}
}
rebuildOutputs()
}
}

@ -1,155 +0,0 @@
import Foundation
import Network
import OSLog
struct HTTPRequest: Equatable {
var method: String
var path: String
var headers: [String: String]
var body: [UInt8]
}
struct HTTPResponse {
var status: Int
var contentType: String
var body: [UInt8]
static func text(_ status: Int, _ text: String) -> HTTPResponse {
HTTPResponse(status: status, contentType: "text/plain; charset=utf-8", body: Array(text.utf8))
}
static func json(_ value: some Encodable) -> HTTPResponse {
HTTPResponse(status: 200, contentType: "application/json", body: Array((try? JSONEncoder().encode(value)) ?? Data()))
}
var serialized: [UInt8] {
let reason = [200: "OK", 204: "No Content", 400: "Bad Request", 404: "Not Found", 405: "Method Not Allowed",
413: "Payload Too Large", 503: "Service Unavailable"][status] ?? "Error"
let head = "HTTP/1.1 \(status) \(reason)\r\nContent-Type: \(contentType)\r\nContent-Length: \(body.count)\r\n"
+ "Cache-Control: no-store\r\nConnection: close\r\n\r\n"
return Array(head.utf8) + body
}
}
/// Incremental HTTP/1.1 request parser (no chunked bodies; the phone page only sends small JSON).
struct HTTPRequestParser {
enum ParseError: Error { case malformed, tooLarge }
static let maxHeader = 8 * 1024
static let maxBody = 64 * 1024
private var buffer: [UInt8] = []
/// Appends bytes; returns the request once it is complete.
mutating func append(_ bytes: [UInt8]) throws -> HTTPRequest? {
buffer += bytes
guard let headerEnd = Self.find([13, 10, 13, 10], in: buffer) else {
if buffer.count > Self.maxHeader { throw ParseError.tooLarge }
return nil
}
guard let head = String(bytes: buffer[0..<headerEnd], encoding: .utf8) else { throw ParseError.malformed }
let lines = head.components(separatedBy: "\r\n")
let requestLine = lines[0].split(separator: " ")
guard requestLine.count == 3, requestLine[2].hasPrefix("HTTP/1.") else { throw ParseError.malformed }
var headers: [String: String] = [:]
for line in lines.dropFirst() {
guard let colon = line.firstIndex(of: ":") else { continue }
headers[line[..<colon].lowercased()] = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
}
let length = Int(headers["content-length"] ?? "0") ?? -1
guard length >= 0 else { throw ParseError.malformed }
guard length <= Self.maxBody else { throw ParseError.tooLarge }
let bodyStart = headerEnd + 4
guard buffer.count >= bodyStart + length else { return nil }
let path = String(requestLine[1].split(separator: "?", maxSplits: 1).first ?? "/")
return HTTPRequest(method: String(requestLine[0]), path: path, headers: headers,
body: Array(buffer[bodyStart..<bodyStart + length]))
}
private static func find(_ needle: [UInt8], in haystack: [UInt8]) -> Int? {
guard haystack.count >= needle.count else { return nil }
for i in 0...(haystack.count - needle.count) where haystack[i] == needle[0] {
if Array(haystack[i..<i + needle.count]) == needle { return i }
}
return nil
}
}
/// Minimal HTTP server bound to one interface. One request per connection.
final class HTTPServer: @unchecked Sendable {
typealias Handler = @Sendable (HTTPRequest) -> HTTPResponse
private let queue = DispatchQueue(label: "studio.zielke.musubi.intercom.http")
private let log = Logger(subsystem: "studio.zielke.musubi", category: "intercom")
private var listener: NWListener?
private let handler: Handler
init(handler: @escaping Handler) {
self.handler = handler
}
func start(interface: NWInterface, port: UInt16) async throws {
let parameters = NWParameters.tcp
parameters.requiredInterface = interface
parameters.allowLocalEndpointReuse = true
parameters.includePeerToPeer = false
let listener = try NWListener(using: parameters, on: NWEndpoint.Port(rawValue: port) ?? .any)
let once = OnceFlag()
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
listener.stateUpdateHandler = { [log] state in
switch state {
case .ready:
if once.claim() { continuation.resume() }
case .failed(let error):
log.error("HTTP failed: \(error.localizedDescription, privacy: .public)")
if once.claim() { continuation.resume(throwing: error) }
default: break
}
}
listener.newConnectionHandler = { [weak self] connection in self?.serve(connection) }
listener.start(queue: queue)
}
self.listener = listener
}
func stop() {
listener?.cancel()
listener = nil
}
private func serve(_ connection: NWConnection) {
connection.start(queue: queue)
read(connection, parser: ParserBox())
}
private func read(_ connection: NWConnection, parser: ParserBox) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1024) { [weak self] data, _, isComplete, error in
guard let self else { return }
do {
if let data, let request = try parser.value.append(Array(data)) {
// Signalling may block briefly (session setup); keep the HTTP queue free.
DispatchQueue.global(qos: .userInitiated).async {
self.respond(self.handler(request), on: connection)
}
return
}
} catch HTTPRequestParser.ParseError.tooLarge {
respond(.text(413, "Zu groß."), on: connection)
return
} catch {
respond(.text(400, "Ungültige Anfrage."), on: connection)
return
}
if isComplete || error != nil { connection.cancel() } else { read(connection, parser: parser) }
}
}
private func respond(_ response: HTTPResponse, on connection: NWConnection) {
connection.send(content: Data(response.serialized), completion: .contentProcessed { _ in connection.cancel() })
}
}
/// Per-connection parser state; only touched on the HTTP queue.
private final class ParserBox: @unchecked Sendable {
var value = HTTPRequestParser()
}

@ -1,218 +0,0 @@
import Foundation
import Network
import OSLog
/// Media transport for all phones: one UDP port on the clinic interface, demultiplexed into sessions.
/// Everything runs on one serial queue; public methods may be called from any thread.
final class IntercomEngine: @unchecked Sendable {
struct SessionInfo: Sendable, Equatable {
let id: UUID
let name: String
let role: String
let connected: Bool
}
var onSessionsChanged: (@Sendable ([SessionInfo]) -> Void)?
var onKeyFrameNeeded: (@Sendable () -> Void)?
let queue = DispatchQueue(label: "studio.zielke.musubi.intercom.media", qos: .userInteractive)
private let certificate: DTLSCertificate
private let log = Logger(subsystem: "studio.zielke.musubi", category: "intercom")
// queue only
private var listener: NWListener?
private var sessions: [UUID: PeerSession] = [:]
private var connections: [UUID: NWConnection] = [:]
private var timer: DispatchSourceTimer?
init(certificate: DTLSCertificate) {
self.certificate = certificate
}
/// Binds the media port; returns once listening (or throws).
func start(interface: NWInterface, port: UInt16) async throws {
let parameters = NWParameters.udp
parameters.requiredInterface = interface
parameters.allowLocalEndpointReuse = true
let listener = try NWListener(using: parameters, on: NWEndpoint.Port(rawValue: port) ?? .any)
let once = OnceFlag()
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
listener.stateUpdateHandler = { [log] state in
switch state {
case .ready:
if once.claim() { continuation.resume() }
case .failed(let error):
log.error("Media port failed: \(error.localizedDescription, privacy: .public)")
if once.claim() { continuation.resume(throwing: error) }
default: break
}
}
listener.newConnectionHandler = { [weak self] connection in self?.accept(connection) }
listener.start(queue: queue)
queue.async { self.listener = listener }
}
queue.async { self.startTimer() }
}
func stop() {
queue.sync {
timer?.cancel()
timer = nil
listener?.cancel()
listener = nil
connections.values.forEach { $0.cancel() }
connections = [:]
sessions = [:]
}
onSessionsChanged?([])
}
/// Creates a session for a browser offer and returns its id and the SDP answer.
func createSession(offer: String, name: String, role: String, address: String, port: UInt16) throws -> (UUID, String)? {
let accepted = try PeerSession.accept(offerSDP: offer, name: name, role: role, certificate: certificate,
address: address, port: port)
guard let accepted else { return nil }
queue.sync {
sessions[accepted.session.id] = accepted.session
publish()
}
log.info("Session for \(name, privacy: .public) (\(role, privacy: .public)) created")
return (accepted.session.id, accepted.answer)
}
func removeSession(_ id: UUID) {
queue.async {
self.sessions[id] = nil
self.connections.removeValue(forKey: id)?.cancel()
self.publish()
}
}
/// Sends one Opus packet (20 ms) to every connected phone.
func sendAudio(opus: [UInt8], timestamp: UInt32) {
queue.async {
for (id, session) in self.sessions {
guard let packet = session.audioPacket(opus: opus, timestamp: timestamp) else { continue }
self.connections[id]?.send(content: Data(packet), completion: .idempotent)
}
}
}
/// Sends one H.264 access unit to every connected phone.
func sendVideo(nalUnits: [[UInt8]], timestamp: UInt32) {
let payloads = H264Packetizer.payloads(for: nalUnits)
queue.async {
for (id, session) in self.sessions {
guard let connection = self.connections[id] else { continue }
for packet in session.videoPackets(payloads: payloads, timestamp: timestamp) {
connection.send(content: Data(packet), completion: .idempotent)
}
}
}
}
// MARK: - queue
private func accept(_ connection: NWConnection) {
connection.start(queue: queue)
receive(on: connection)
}
private func receive(on connection: NWConnection) {
connection.receiveMessage { [weak self] data, _, _, error in
guard let self else { return }
if let data, !data.isEmpty { self.handle(Array(data), from: connection) }
if error == nil { self.receive(on: connection) } else { connection.cancel() }
}
}
private func handle(_ packet: [UInt8], from connection: NWConnection) {
switch PeerSession.classify(packet) {
case .stun:
guard let message = STUN.parse(packet),
case .hostPort(let host, let port) = connection.endpoint else { return }
let address: [UInt8]
switch host {
case .ipv4(let v4): address = Array(v4.rawValue)
case .ipv6(let v6):
let raw = Array(v6.rawValue)
// IPv4-mapped (::ffff:a.b.c.d) on a dual-stack socket.
address = raw.prefix(12).elementsEqual([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF, 0xFF]) ? Array(raw.suffix(4)) : raw
default: return
}
let localUfrag = message.username?.split(separator: ":").first.map(String.init)
guard let session = sessions.values.first(where: { $0.localUfrag == localUfrag }),
let reply = session.answerSTUN(packet, message: message, address: address, port: port.rawValue) else { return }
if connections[session.id] !== connection {
connections[session.id] = connection
log.info("\(session.name, privacy: .public) reachable via \(String(describing: connection.endpoint), privacy: .public)")
}
connection.send(content: Data(reply), completion: .idempotent)
case .dtls:
guard let (id, session) = session(for: connection) else { return }
let result = session.receiveDTLS(packet)
for reply in result.replies { connection.send(content: Data(reply), completion: .idempotent) }
if result.becameReady {
log.info("\(session.name, privacy: .public) connected (DTLS-SRTP)")
publish()
onKeyFrameNeeded?()
} else if case .failed(let reason) = session.dtlsState {
log.error("DTLS with \(session.name, privacy: .public) failed: \(reason, privacy: .public)")
sessions[id] = nil
connections[id] = nil
publish()
}
case .rtcp:
if let (_, session) = session(for: connection), session.receiveRTCP(packet) { onKeyFrameNeeded?() }
case .other:
break
}
}
private func session(for connection: NWConnection) -> (UUID, PeerSession)? {
guard let id = connections.first(where: { $0.value === connection })?.key, let session = sessions[id] else { return nil }
return (id, session)
}
private func startTimer() {
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + 1, repeating: 1)
timer.setEventHandler { [weak self] in self?.tick() }
timer.resume()
self.timer = timer
}
private func tick() {
let now = Date()
var changed = false
for (id, session) in sessions {
if session.isExpired(now: now) {
log.info("\(session.name, privacy: .public) gone (no consent)")
sessions[id] = nil
connections.removeValue(forKey: id)?.cancel()
changed = true
continue
}
if let connection = connections[id] {
for report in session.senderReports(now: now) { connection.send(content: Data(report), completion: .idempotent) }
}
}
if changed { publish() }
}
private func publish() {
let infos = sessions.values
.sorted { $0.created < $1.created }
.map { SessionInfo(id: $0.id, name: $0.name, role: $0.role, connected: $0.isMediaReady) }
onSessionsChanged?(infos)
}
}
final class OnceFlag: @unchecked Sendable {
private let lock = NSLock()
private var done = false
func claim() -> Bool { lock.withLock { defer { done = true }; return !done } }
}

@ -1,238 +0,0 @@
import CoreVideo
import Darwin
import Foundation
import Network
import Observation
import OSLog
/// Intercom stage 1 (M4): phones on the clinic interface open a page, hear talkback and see PGM.
/// Web server and media port bind only to the chosen interface.
@MainActor @Observable
public final class IntercomServer {
public struct Participant: Identifiable, Equatable, Sendable {
public let id: UUID
public let name: String
public let role: String
public let connected: Bool
}
public static let httpPort: UInt16 = 8080
public static let mediaPort: UInt16 = 50_000
public private(set) var participants: [Participant] = []
/// Address phones open (also encoded in the QR code).
public private(set) var url: URL?
public private(set) var error: String?
public private(set) var isRunning = false
/// Thread-safe media input (PGM frames, talkback audio).
public nonisolated let input: IntercomInput
private var engine: IntercomEngine?
private var http: HTTPServer?
private var retryTask: Task<Void, Never>?
private let log = Logger(subsystem: "studio.zielke.musubi", category: "intercom")
public init() {
input = IntercomInput()
}
public func start(interfaceName: String) async {
stop()
guard let interface = await Self.interface(named: interfaceName), let address = Self.ipv4Address(of: interfaceName) else {
error = "Klinik-Schnittstelle \(interfaceName) hat keine Adresse. Neuer Versuch in 5 s."
log.error("Intercom: interface \(interfaceName, privacy: .public) unavailable")
scheduleRetry(interfaceName)
return
}
do {
let engine = IntercomEngine(certificate: try DTLSCertificate())
engine.onSessionsChanged = { [weak self, input] infos in
input.hasListeners = infos.contains { $0.connected }
Task { @MainActor in
self?.participants = infos.map { Participant(id: $0.id, name: $0.name, role: $0.role, connected: $0.connected) }
}
}
engine.onKeyFrameNeeded = { [input] in input.requestKeyFrame() }
try await engine.start(interface: interface, port: Self.mediaPort)
let mediaPort = Self.mediaPort
let http = HTTPServer { [weak engine] request in
guard let engine else { return .text(503, "Musubi startet neu.") }
return Self.route(request, engine: engine, address: address, mediaPort: mediaPort)
}
try await http.start(interface: interface, port: Self.httpPort)
input.attach(engine)
self.engine = engine
self.http = http
url = URL(string: "http://\(address):\(Self.httpPort)/")
isRunning = true
error = nil
log.info("Intercom on \(interfaceName, privacy: .public) at \(address, privacy: .public)")
} catch {
stop()
self.error = "Intercom lässt sich auf \(interfaceName) nicht starten. Neuer Versuch in 5 s."
log.error("Intercom start failed: \(error.localizedDescription, privacy: .public)")
scheduleRetry(interfaceName)
}
}
public func stop() {
retryTask?.cancel()
retryTask = nil
input.attach(nil)
http?.stop()
http = nil
engine?.stop()
engine = nil
participants = []
url = nil
isRunning = false
}
private func scheduleRetry(_ interfaceName: String) {
retryTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(5))
guard !Task.isCancelled else { return }
await self?.start(interfaceName: interfaceName)
}
}
// MARK: - Signalling
private struct OfferRequest: Decodable { let name: String; let role: String; let sdp: String }
private struct OfferResponse: Encodable { let id: UUID; let sdp: String }
private struct LeaveRequest: Decodable { let id: UUID }
nonisolated private static func route(_ request: HTTPRequest, engine: IntercomEngine, address: String, mediaPort: UInt16) -> HTTPResponse {
switch (request.method, request.path) {
case ("GET", "/"), ("GET", "/index.html"):
return HTTPResponse(status: 200, contentType: "text/html; charset=utf-8", body: Array(PhonePage.html.utf8))
case ("POST", "/api/offer"):
guard let offer = try? JSONDecoder().decode(OfferRequest.self, from: Data(request.body)) else {
return .text(400, "Angebot unlesbar.")
}
let name = String(offer.name.trimmingCharacters(in: .whitespacesAndNewlines).prefix(40))
let role = String(offer.role.trimmingCharacters(in: .whitespacesAndNewlines).prefix(40))
guard !name.isEmpty, !role.isEmpty else { return .text(400, "Name und Rolle fehlen.") }
do {
guard let (id, sdp) = try engine.createSession(offer: offer.sdp, name: name, role: role,
address: address, port: mediaPort) else {
return .text(400, "Dein Browser kann weder Opus noch H.264 empfangen.")
}
return .json(OfferResponse(id: id, sdp: sdp))
} catch {
return .text(400, "Angebot unvollständig.")
}
case ("POST", "/api/leave"):
if let leave = try? JSONDecoder().decode(LeaveRequest.self, from: Data(request.body)) { engine.removeSession(leave.id) }
return HTTPResponse(status: 204, contentType: "text/plain", body: [])
case (_, "/"), (_, "/api/offer"), (_, "/api/leave"):
return .text(405, "Methode nicht erlaubt.")
default:
return .text(404, "Nicht gefunden.")
}
}
// MARK: - Interfaces
private 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))
}
}
/// First IPv4 address of a BSD interface (e.g. "en0").
nonisolated static func ipv4Address(of interfaceName: String) -> String? {
var list: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&list) == 0, let first = list else { return nil }
defer { freeifaddrs(list) }
for entry in sequence(first: first, next: { $0.pointee.ifa_next }) {
let ifa = entry.pointee
guard String(cString: ifa.ifa_name) == interfaceName, let addr = ifa.ifa_addr,
addr.pointee.sa_family == UInt8(AF_INET) else { continue }
var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
guard getnameinfo(addr, socklen_t(addr.pointee.sa_len), &host, socklen_t(host.count), nil, 0, NI_NUMERICHOST) == 0 else { continue }
return String(decoding: host.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, as: UTF8.self)
}
return nil
}
}
/// Media input for the intercom; safe to call from any thread.
public final class IntercomInput: @unchecked Sendable {
private let lock = NSLock()
private var engine: IntercomEngine?
private var listeners = false
private var talkback = TalkbackEncoder()
private let preview = PreviewEncoder()
private var resamplePosition: Double = 0
private var resampleLast: Float = 0
private var scratch: [Float] = []
init() {
preview.onAccessUnit = { [weak self] nalUnits, timestamp in
self?.currentEngine?.sendVideo(nalUnits: nalUnits, timestamp: timestamp)
}
}
var hasListeners: Bool {
get { lock.withLock { listeners } }
set { lock.withLock { listeners = newValue } }
}
private var currentEngine: IntercomEngine? { lock.withLock { engine } }
func attach(_ engine: IntercomEngine?) {
lock.withLock {
self.engine = engine
listeners = false
}
}
func requestKeyFrame() { preview.requestKeyFrame() }
/// One PGM frame (any size, BGRA or UYVY). Dropped while no phone is connected.
public func pushVideo(_ pixelBuffer: CVPixelBuffer) {
guard hasListeners else { return }
preview.encode(pixelBuffer)
}
/// Talkback audio, mono, any sample rate. Pass silence while the talk key is up so the stream keeps its timing.
/// Call from one thread only (not the audio render thread).
public func pushTalkback(_ samples: UnsafeBufferPointer<Float>, sampleRate: Double) {
guard let engine = currentEngine, hasListeners, let talkback else { return }
let packets: [([UInt8], UInt32)]
if sampleRate == TalkbackEncoder.sampleRate {
packets = talkback.append(samples)
} else {
resample(samples, from: sampleRate)
packets = scratch.withUnsafeBufferPointer { talkback.append($0) }
}
for (packet, timestamp) in packets { engine.sendAudio(opus: packet, timestamp: timestamp) }
}
/// Linear interpolation to 48 kHz; good enough for speech.
private func resample(_ input: UnsafeBufferPointer<Float>, from rate: Double) {
scratch.removeAll(keepingCapacity: true)
let step = rate / TalkbackEncoder.sampleRate
var position = resamplePosition
while position < Double(input.count) {
let index = Int(position)
let fraction = Float(position - Double(index))
let previous = index == 0 ? resampleLast : input[index - 1]
scratch.append(previous + (input[index] - previous) * fraction)
position += step
}
resamplePosition = position - Double(input.count)
resampleLast = input.last ?? resampleLast
}
}

@ -1,160 +0,0 @@
import CoreMedia
import QuartzCore
import CoreVideo
import Foundation
import OSLog
import VideoToolbox
/// Encodes the PGM preview for phones: scaled down, 25 fps, H.264 constrained baseline, no B-frames.
/// `encode` may be called from the NDI receive thread; work happens on an own queue.
final class PreviewEncoder: @unchecked Sendable {
/// Called with the NAL units of one access unit and its 90 kHz timestamp.
var onAccessUnit: (@Sendable ([[UInt8]], UInt32) -> Void)?
let width: Int32
let height: Int32
let frameRate: Double
private let bitRate: Int
private let queue = DispatchQueue(label: "studio.zielke.musubi.intercom.video", qos: .userInitiated)
private let log = Logger(subsystem: "studio.zielke.musubi", category: "intercom")
// queue only
private var session: VTCompressionSession?
private var transfer: VTPixelTransferSession?
private var pool: CVPixelBufferPool?
private var forceKeyFrame = true
private var lastFrameTime: Double = -1
private var busy = false
private let start = CACurrentMediaTime()
init(width: Int32 = 960, height: Int32 = 540, frameRate: Double = 25, bitRate: Int = 1_200_000) {
self.width = width
self.height = height
self.frameRate = frameRate
self.bitRate = bitRate
}
deinit {
if let session { VTCompressionSessionInvalidate(session) }
if let transfer { VTPixelTransferSessionInvalidate(transfer) }
}
func requestKeyFrame() {
queue.async { self.forceKeyFrame = true }
}
/// Offers a frame; frames above the target rate, or while the encoder is still busy, are dropped.
func encode(_ pixelBuffer: CVPixelBuffer) {
let now = CACurrentMediaTime() - start
// CVPixelBuffer is immutable once handed over by the receiver.
nonisolated(unsafe) let pixelBuffer = pixelBuffer
queue.async {
guard !self.busy, now - self.lastFrameTime >= 1 / self.frameRate - 0.004 else { return }
self.lastFrameTime = now
self.encodeOnQueue(pixelBuffer, time: now)
}
}
private func encodeOnQueue(_ source: CVPixelBuffer, time: Double) {
guard let session = session ?? makeSession(), let scaled = scale(source) else { return }
var options: CFDictionary?
if forceKeyFrame {
options = [kVTEncodeFrameOptionKey_ForceKeyFrame: true] as CFDictionary
forceKeyFrame = false
}
busy = true
let pts = CMTime(seconds: time, preferredTimescale: 90_000)
let status = VTCompressionSessionEncodeFrame(session, imageBuffer: scaled, presentationTimeStamp: pts, duration: .invalid,
frameProperties: options, infoFlagsOut: nil) { [weak self] status, _, sample in
guard let self else { return }
self.queue.async { self.busy = false }
guard status == noErr, let sample else { return }
self.emit(sample, timestamp: UInt32(truncatingIfNeeded: Int64(time * 90_000)))
}
if status != noErr {
busy = false
log.error("Preview encode failed: \(status)")
}
}
private func makeSession() -> VTCompressionSession? {
var session: VTCompressionSession?
let status = VTCompressionSessionCreate(allocator: nil, width: width, height: height, codecType: kCMVideoCodecType_H264,
encoderSpecification: nil, imageBufferAttributes: nil, compressedDataAllocator: nil,
outputCallback: nil, refcon: nil, compressionSessionOut: &session)
guard status == noErr, let session else {
log.error("Preview encoder unavailable: \(status)")
return nil
}
let properties: [CFString: Any] = [
kVTCompressionPropertyKey_RealTime: true,
kVTCompressionPropertyKey_ProfileLevel: kVTProfileLevel_H264_ConstrainedBaseline_AutoLevel,
kVTCompressionPropertyKey_AllowFrameReordering: false,
kVTCompressionPropertyKey_AverageBitRate: bitRate,
kVTCompressionPropertyKey_ExpectedFrameRate: frameRate,
kVTCompressionPropertyKey_MaxKeyFrameInterval: Int(frameRate * 2),
kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration: 2,
]
for (key, value) in properties { VTSessionSetProperty(session, key: key, value: value as CFTypeRef) }
VTCompressionSessionPrepareToEncodeFrames(session)
self.session = session
return session
}
/// Scales any NDI frame (BGRA or UYVY, any size) into a pooled NV12 buffer of the preview size.
private func scale(_ source: CVPixelBuffer) -> CVPixelBuffer? {
if transfer == nil {
var newTransfer: VTPixelTransferSession?
VTPixelTransferSessionCreate(allocator: nil, pixelTransferSessionOut: &newTransfer)
if let newTransfer {
VTSessionSetProperty(newTransfer, key: kVTPixelTransferPropertyKey_ScalingMode, value: kVTScalingMode_Letterbox)
}
transfer = newTransfer
}
if pool == nil {
let attributes: [CFString: Any] = [
kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
kCVPixelBufferWidthKey: width,
kCVPixelBufferHeightKey: height,
kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
]
CVPixelBufferPoolCreate(nil, nil, attributes as CFDictionary, &pool)
}
guard let transfer, let pool else { return nil }
var target: CVPixelBuffer?
CVPixelBufferPoolCreatePixelBuffer(nil, pool, &target)
guard let target, VTPixelTransferSessionTransferImage(transfer, from: source, to: target) == noErr else { return nil }
return target
}
private func emit(_ sample: CMSampleBuffer, timestamp: UInt32) {
guard let block = CMSampleBufferGetDataBuffer(sample) else { return }
var length = 0
var pointer: UnsafeMutablePointer<CChar>?
guard CMBlockBufferGetDataPointer(block, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length,
dataPointerOut: &pointer) == noErr, let pointer else { return }
let avcc = [UInt8](UnsafeRawBufferPointer(start: pointer, count: length))
var nalUnits = H264Packetizer.nalUnits(avcc: avcc)
let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: false) as? [[CFString: Any]]
let isKeyFrame = !(attachments?.first?[kCMSampleAttachmentKey_NotSync] as? Bool ?? false)
if isKeyFrame, let format = CMSampleBufferGetFormatDescription(sample) {
nalUnits = Self.parameterSets(format) + nalUnits
}
onAccessUnit?(nalUnits, timestamp)
}
private static func parameterSets(_ format: CMFormatDescription) -> [[UInt8]] {
var count = 0
CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, parameterSetIndex: 0, parameterSetPointerOut: nil,
parameterSetSizeOut: nil, parameterSetCountOut: &count, nalUnitHeaderLengthOut: nil)
return (0..<count).compactMap { index in
var pointer: UnsafePointer<UInt8>?
var size = 0
guard CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, parameterSetIndex: index, parameterSetPointerOut: &pointer,
parameterSetSizeOut: &size, parameterSetCountOut: nil,
nalUnitHeaderLengthOut: nil) == noErr, let pointer else { return nil }
return Array(UnsafeBufferPointer(start: pointer, count: size))
}
}
}

@ -1,90 +0,0 @@
import AudioToolbox
import Foundation
import OSLog
/// Encodes mono 48 kHz Float32 into 20 ms Opus packets with AudioToolbox.
/// Not thread-safe; the caller feeds it from one thread.
final class TalkbackEncoder {
static let sampleRate = 48_000.0
static let frameSamples = 960 // 20 ms
private var converter: AudioConverterRef?
private var pending: [Float] = []
private var timestamp: UInt32 = .random(in: 0...UInt32.max)
private let log = Logger(subsystem: "studio.zielke.musubi", category: "intercom")
init?(bitRate: UInt32 = 32_000) {
var input = AudioStreamBasicDescription(
mSampleRate: Self.sampleRate, mFormatID: kAudioFormatLinearPCM,
mFormatFlags: kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked,
mBytesPerPacket: 4, mFramesPerPacket: 1, mBytesPerFrame: 4, mChannelsPerFrame: 1, mBitsPerChannel: 32, mReserved: 0)
var output = AudioStreamBasicDescription(
mSampleRate: Self.sampleRate, mFormatID: kAudioFormatOpus, mFormatFlags: 0,
mBytesPerPacket: 0, mFramesPerPacket: UInt32(Self.frameSamples), mBytesPerFrame: 0, mChannelsPerFrame: 1,
mBitsPerChannel: 0, mReserved: 0)
let status = AudioConverterNew(&input, &output, &converter)
guard status == noErr, let converter else {
log.error("Opus encoder unavailable: \(status)")
return nil
}
var rate = bitRate
AudioConverterSetProperty(converter, kAudioConverterEncodeBitRate, UInt32(MemoryLayout<UInt32>.size), &rate)
}
deinit { if let converter { AudioConverterDispose(converter) } }
/// Appends mono samples at 48 kHz; returns (packet, RTP timestamp) for every full 20 ms.
func append(_ samples: UnsafeBufferPointer<Float>) -> [([UInt8], UInt32)] {
pending += samples
var out: [([UInt8], UInt32)] = []
while pending.count >= Self.frameSamples {
let frame = Array(pending.prefix(Self.frameSamples))
pending.removeFirst(Self.frameSamples)
if let packet = encode(frame) { out.append((packet, timestamp)) }
timestamp &+= UInt32(Self.frameSamples)
}
return out
}
/// Input for one converter call; owns stable memory the converter may read after the callback returns.
private final class Feed {
let samples: UnsafeMutablePointer<Float>
let count: Int
var consumed = false
init(_ frame: [Float]) {
count = frame.count
samples = .allocate(capacity: count)
samples.initialize(from: frame, count: count)
}
deinit { samples.deallocate() }
}
private func encode(_ frame: [Float]) -> [UInt8]? {
guard let converter else { return nil }
let feed = Feed(frame)
var packetSize: UInt32 = 1500
var outBytes = [UInt8](repeating: 0, count: Int(packetSize))
var packets: UInt32 = 1
var description = AudioStreamPacketDescription()
let status = withExtendedLifetime(feed) { outBytes.withUnsafeMutableBytes { raw -> OSStatus in
var list = AudioBufferList(mNumberBuffers: 1, mBuffers: AudioBuffer(mNumberChannels: 1, mDataByteSize: packetSize, mData: raw.baseAddress))
let status = AudioConverterFillComplexBuffer(converter, { _, ioPackets, ioData, _, userData in
let feed = Unmanaged<Feed>.fromOpaque(userData!).takeUnretainedValue()
guard !feed.consumed else {
ioPackets.pointee = 0
return 1 // no more data for this call
}
feed.consumed = true
ioPackets.pointee = UInt32(feed.count)
ioData.pointee.mBuffers.mData = UnsafeMutableRawPointer(feed.samples)
ioData.pointee.mBuffers.mDataByteSize = UInt32(feed.count * 4)
ioData.pointee.mBuffers.mNumberChannels = 1
return noErr
}, Unmanaged.passUnretained(feed).toOpaque(), &packets, &list, &description)
packetSize = list.mBuffers.mDataByteSize
return status
} }
guard (status == noErr || status == 1), packets == 1, packetSize > 0 else { return nil }
return Array(outBytes.prefix(Int(packetSize)))
}
}

@ -1,151 +0,0 @@
/// The page phones open from the QR code. Colors follow design/tokens.json (dark).
enum PhonePage {
static let html = #"""
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#14151B">
<title>Musubi</title>
<style>
:root { --sumi:#14151B; --kasumi:#1E2029; --usuzumi:#2B2E3A; --washi:#ECE9E2; --nezumi:#8E8C96; --ai:#8A99F2; --moegi:#3DBE7A; --kohaku:#E8A23A; }
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--sumi); color: var(--washi);
font: 16px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
main { max-width: 720px; margin: 0 auto; padding: max(16px, env(safe-area-inset-top)) 16px 24px; }
h1 { font-size: 20px; font-weight: 600; margin: 8px 0 2px; }
.sub { color: var(--nezumi); margin: 0 0 16px; }
.card { background: var(--kasumi); border: 1px solid var(--usuzumi); border-radius: 12px; padding: 16px; }
label { display: block; color: var(--nezumi); font-size: 14px; margin: 0 0 6px; }
input { width: 100%; font: inherit; color: var(--washi); background: var(--sumi); border: 1px solid var(--usuzumi);
border-radius: 8px; padding: 12px; margin: 0 0 16px; }
input:focus { outline: 2px solid var(--ai); outline-offset: -1px; }
button { width: 100%; font: inherit; font-weight: 600; border: 0; border-radius: 8px; padding: 14px; cursor: pointer; }
.primary { background: var(--ai); color: var(--sumi); }
.secondary { background: var(--usuzumi); color: var(--washi); margin-top: 8px; }
video { width: 100%; aspect-ratio: 16 / 9; background: #000; border-radius: 12px; display: block; }
.status { display: flex; align-items: center; gap: 8px; color: var(--nezumi); margin: 12px 0; font-size: 14px; }
.dot { width: 8px; height: 8px; border-radius: 4px; background: var(--usuzumi); flex: none; }
.dot.ok { background: var(--moegi); }
.dot.warn { background: var(--kohaku); }
.who { color: var(--washi); }
[hidden] { display: none !important; }
</style>
</head>
<body>
<main>
<h1>Musubi</h1>
<p class="sub">Talkback hören, Sendung sehen.</p>
<form id="join" class="card">
<label for="name">Dein Name</label>
<input id="name" autocomplete="name" required placeholder="z. B. Anna">
<label for="role">Deine Rolle</label>
<input id="role" list="roles" required placeholder="z. B. Kamera 2">
<datalist id="roles"><option>Kamera 1</option><option>Kamera 2</option><option>Kamera 3</option><option>Technik</option></datalist>
<button class="primary" type="submit">Verbinden</button>
</form>
<section id="live" hidden>
<video id="video" playsinline autoplay></video>
<div class="status"><span id="dot" class="dot"></span><span id="status">Verbinde …</span></div>
<button id="unmute" class="primary" hidden>Ton einschalten</button>
<button id="leave" class="secondary">Trennen</button>
</section>
</main>
<script>
const $ = (s) => document.querySelector(s);
let pc = null, sessionId = null, wantConnected = false, retryTimer = null;
try {
$("#name").value = localStorage.getItem("musubi.name") || "";
$("#role").value = localStorage.getItem("musubi.role") || "";
} catch {}
function setStatus(text, kind) {
$("#status").textContent = text;
$("#dot").className = "dot" + (kind ? " " + kind : "");
}
async function connect() {
clearTimeout(retryTimer);
const name = $("#name").value.trim(), role = $("#role").value.trim();
setStatus("Verbinde …", "warn");
pc = new RTCPeerConnection({ iceServers: [] });
const current = pc;
pc.addTransceiver("audio", { direction: "recvonly" });
pc.addTransceiver("video", { direction: "recvonly" });
const stream = new MediaStream();
pc.ontrack = (e) => {
stream.addTrack(e.track);
const video = $("#video");
if (video.srcObject !== stream) video.srcObject = stream;
video.play().then(() => { $("#unmute").hidden = true; }).catch(() => { $("#unmute").hidden = false; });
};
pc.onconnectionstatechange = () => {
if (current !== pc) return;
const state = pc.connectionState;
if (state === "connected") setStatus("Verbunden als " + name + " · " + role, "ok");
if (state === "failed" || state === "disconnected" || state === "closed") reconnectSoon();
};
try {
await pc.setLocalDescription(await pc.createOffer());
const response = await fetch("/api/offer", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, role, sdp: pc.localDescription.sdp })
});
if (!response.ok) throw new Error(await response.text());
const answer = await response.json();
sessionId = answer.id;
await pc.setRemoteDescription({ type: "answer", sdp: answer.sdp });
} catch (error) {
reconnectSoon(error && error.message);
}
}
function reconnectSoon(reason) {
if (!wantConnected) return;
setStatus((reason || "Verbindung weg.") + " Neuer Versuch in 3 s.", "warn");
teardown();
clearTimeout(retryTimer);
retryTimer = setTimeout(() => { if (wantConnected) connect(); }, 3000);
}
function teardown() {
if (sessionId) navigator.sendBeacon("/api/leave", JSON.stringify({ id: sessionId }));
sessionId = null;
if (pc) { pc.onconnectionstatechange = null; pc.close(); pc = null; }
}
$("#join").addEventListener("submit", (e) => {
e.preventDefault();
try {
localStorage.setItem("musubi.name", $("#name").value.trim());
localStorage.setItem("musubi.role", $("#role").value.trim());
} catch {}
wantConnected = true;
$("#join").hidden = true;
$("#live").hidden = false;
$("#video").play().catch(() => {}); // unlock playback inside the tap
connect();
});
$("#unmute").addEventListener("click", () => {
$("#video").play().then(() => { $("#unmute").hidden = true; }).catch(() => {});
});
$("#leave").addEventListener("click", () => {
wantConnected = false;
clearTimeout(retryTimer);
teardown();
$("#live").hidden = true;
$("#join").hidden = false;
});
addEventListener("pagehide", teardown);
</script>
</body>
</html>
"""#
}

@ -1,109 +0,0 @@
import Foundation
/// Big-endian byte writer for the wire formats (STUN, DTLS, RTP).
struct ByteWriter {
private(set) var bytes: [UInt8] = []
init(capacity: Int = 64) { bytes.reserveCapacity(capacity) }
mutating func u8(_ v: UInt8) { bytes.append(v) }
mutating func u16(_ v: UInt16) { bytes += [UInt8(v >> 8), UInt8(v & 0xFF)] }
mutating func u24(_ v: Int) { bytes += [UInt8((v >> 16) & 0xFF), UInt8((v >> 8) & 0xFF), UInt8(v & 0xFF)] }
mutating func u32(_ v: UInt32) { for shift in stride(from: 24, through: 0, by: -8) { bytes.append(UInt8((v >> UInt32(shift)) & 0xFF)) } }
mutating func u48(_ v: UInt64) { for shift in stride(from: 40, through: 0, by: -8) { bytes.append(UInt8((v >> UInt64(shift)) & 0xFF)) } }
mutating func u64(_ v: UInt64) { for shift in stride(from: 56, through: 0, by: -8) { bytes.append(UInt8((v >> UInt64(shift)) & 0xFF)) } }
mutating func append<C: Collection>(_ data: C) where C.Element == UInt8 { bytes += data }
/// Writes `body` prefixed by its length in `lengthBytes` bytes (1, 2 or 3).
mutating func vector(_ lengthBytes: Int, _ body: (inout ByteWriter) -> Void) {
var inner = ByteWriter()
body(&inner)
let n = inner.bytes.count
switch lengthBytes {
case 1: u8(UInt8(n))
case 2: u16(UInt16(n))
default: u24(n)
}
bytes += inner.bytes
}
}
/// Big-endian reader that throws instead of trapping on short input.
struct ByteReader {
struct OutOfBounds: Error {}
let bytes: [UInt8]
private(set) var offset: Int
init<C: Collection>(_ bytes: C, offset: Int = 0) where C.Element == UInt8 {
self.bytes = Array(bytes)
self.offset = offset
}
var remaining: Int { bytes.count - offset }
var isAtEnd: Bool { offset >= bytes.count }
mutating func u8() throws -> UInt8 {
guard remaining >= 1 else { throw OutOfBounds() }
defer { offset += 1 }
return bytes[offset]
}
mutating func u16() throws -> UInt16 { try UInt16(read(2)) }
mutating func u24() throws -> Int { Int(try read(3)) }
mutating func u32() throws -> UInt32 { try UInt32(read(4)) }
mutating func u48() throws -> UInt64 { try read(6) }
mutating func u64() throws -> UInt64 { try read(8) }
mutating func take(_ n: Int) throws -> [UInt8] {
guard n >= 0, remaining >= n else { throw OutOfBounds() }
defer { offset += n }
return Array(bytes[offset..<offset + n])
}
mutating func skip(_ n: Int) throws { _ = try take(n) }
/// Reads a length-prefixed vector (`lengthBytes` = 1, 2 or 3).
mutating func vector(_ lengthBytes: Int) throws -> [UInt8] {
let n: Int = switch lengthBytes {
case 1: Int(try u8())
case 2: Int(try u16())
default: try u24()
}
return try take(n)
}
private mutating func read(_ n: Int) throws -> UInt64 {
guard remaining >= n else { throw OutOfBounds() }
var v: UInt64 = 0
for i in 0..<n { v = (v << 8) | UInt64(bytes[offset + i]) }
offset += n
return v
}
}
extension Array where Element == UInt8 {
init?(hex: String) {
let clean = hex.filter { !$0.isWhitespace && $0 != ":" }
guard clean.count.isMultiple(of: 2) else { return nil }
var out: [UInt8] = []
var index = clean.startIndex
while index < clean.endIndex {
let next = clean.index(index, offsetBy: 2)
guard let b = UInt8(clean[index..<next], radix: 16) else { return nil }
out.append(b)
index = next
}
self = out
}
var hex: String { map { String(format: "%02x", $0) }.joined() }
}
/// Compares in constant time (MACs, Finished messages).
func constantTimeEqual(_ a: [UInt8], _ b: [UInt8]) -> Bool {
guard a.count == b.count else { return false }
var diff: UInt8 = 0
for i in a.indices { diff |= a[i] ^ b[i] }
return diff == 0
}

@ -1,144 +0,0 @@
import CryptoKit
import Foundation
/// TLS 1.2 PRF with SHA-256 (RFC 5246 section 5).
enum TLSPRF {
static func compute(secret: [UInt8], label: String, seed: [UInt8], length: Int) -> [UInt8] {
let key = SymmetricKey(data: secret)
let fullSeed = Array(label.utf8) + seed
var a = Array(HMAC<SHA256>.authenticationCode(for: fullSeed, using: key))
var out: [UInt8] = []
out.reserveCapacity(length + 32)
while out.count < length {
out += HMAC<SHA256>.authenticationCode(for: a + fullSeed, using: key)
a = Array(HMAC<SHA256>.authenticationCode(for: a, using: key))
}
return Array(out.prefix(length))
}
}
/// Minimal DER encoding/decoding for one self-signed ECDSA certificate and for reading peer keys.
enum DER {
static func tlv(_ tag: UInt8, _ content: [UInt8]) -> [UInt8] {
var out = [tag]
let n = content.count
if n < 0x80 {
out.append(UInt8(n))
} else if n <= 0xFF {
out += [0x81, UInt8(n)]
} else {
out += [0x82, UInt8(n >> 8), UInt8(n & 0xFF)]
}
return out + content
}
static func sequence(_ items: [UInt8]...) -> [UInt8] { tlv(0x30, items.flatMap { $0 }) }
static func set(_ items: [UInt8]...) -> [UInt8] { tlv(0x31, items.flatMap { $0 }) }
static func integer(_ bytes: [UInt8]) -> [UInt8] {
var b = Array(bytes.drop { $0 == 0 })
if b.isEmpty { b = [0] }
if b[0] & 0x80 != 0 { b.insert(0, at: 0) }
return tlv(0x02, b)
}
static func oid(_ components: [UInt]) -> [UInt8] {
var body = [UInt8(components[0] * 40 + components[1])]
for c in components.dropFirst(2) {
var chunk = [UInt8(c & 0x7F)]
var v = c >> 7
while v > 0 {
chunk.insert(UInt8(v & 0x7F) | 0x80, at: 0)
v >>= 7
}
body += chunk
}
return tlv(0x06, body)
}
static func utf8(_ s: String) -> [UInt8] { tlv(0x0C, Array(s.utf8)) }
static func bitString(_ bytes: [UInt8]) -> [UInt8] { tlv(0x03, [0] + bytes) }
static func utcTime(_ date: Date) -> [UInt8] {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(identifier: "UTC")
f.dateFormat = "yyMMddHHmmss'Z'"
return tlv(0x17, Array(f.string(from: date).utf8))
}
/// One parsed element: tag and the full encoding range inside the source buffer.
struct Element {
let tag: UInt8
let header: Int
let range: Range<Int>
var contentRange: Range<Int> { range.lowerBound + header..<range.upperBound }
}
static func elements(_ bytes: [UInt8], in range: Range<Int>) -> [Element]? {
var out: [Element] = []
var i = range.lowerBound
while i < range.upperBound {
guard i + 2 <= range.upperBound else { return nil }
let tag = bytes[i]
var length = Int(bytes[i + 1])
var header = 2
if length & 0x80 != 0 {
let count = length & 0x7F
guard count <= 3, i + 2 + count <= range.upperBound else { return nil }
length = 0
for k in 0..<count { length = length << 8 | Int(bytes[i + 2 + k]) }
header += count
}
guard i + header + length <= range.upperBound else { return nil }
out.append(Element(tag: tag, header: header, range: i..<i + header + length))
i += header + length
}
return out
}
/// SubjectPublicKeyInfo of an X.509 certificate in DER.
static func subjectPublicKeyInfo(certificate: [UInt8]) -> [UInt8]? {
guard let cert = elements(certificate, in: 0..<certificate.count)?.first, cert.tag == 0x30,
let tbs = elements(certificate, in: cert.contentRange)?.first, tbs.tag == 0x30,
let fields = elements(certificate, in: tbs.contentRange) else { return nil }
// [0] version (optional), serial, signature, issuer, validity, subject, subjectPublicKeyInfo
let start = fields.first?.tag == 0xA0 ? 1 : 0
guard fields.count > start + 5 else { return nil }
return Array(certificate[fields[start + 5].range])
}
}
/// The self-signed ECDSA P-256 certificate that identifies this Musubi in DTLS.
/// Browsers only check it against the fingerprint in the SDP, not against a CA.
struct DTLSCertificate: Sendable {
let privateKey: P256.Signing.PrivateKey
let der: [UInt8]
/// SHA-256 fingerprint as written in SDP (`AB:CD:…`).
var fingerprint: String { Self.fingerprint(der) }
static func fingerprint(_ der: [UInt8]) -> String {
SHA256.hash(data: der).map { String(format: "%02X", $0) }.joined(separator: ":")
}
init(commonName: String = "Musubi", now: Date = Date()) throws {
let key = P256.Signing.PrivateKey()
let ecdsaWithSHA256 = DER.sequence(DER.oid([1, 2, 840, 10045, 4, 3, 2]))
let name = DER.sequence(DER.set(DER.sequence(DER.oid([2, 5, 4, 3]), DER.utf8(commonName))))
let validity = DER.sequence(DER.utcTime(now.addingTimeInterval(-86_400)), DER.utcTime(now.addingTimeInterval(30 * 86_400)))
let spki = DER.sequence(
DER.sequence(DER.oid([1, 2, 840, 10045, 2, 1]), DER.oid([1, 2, 840, 10045, 3, 1, 7])),
DER.bitString(Array(key.publicKey.x963Representation)))
var serial = [UInt8](repeating: 0, count: 8)
_ = SecRandomCopyBytes(kSecRandomDefault, serial.count, &serial)
serial[0] &= 0x7F
let tbs = DER.sequence(
DER.tlv(0xA0, DER.integer([2])),
DER.integer(serial),
ecdsaWithSHA256, name, validity, name, spki)
let signature = try key.signature(for: tbs).derRepresentation
self.privateKey = key
self.der = DER.sequence(tbs, ecdsaWithSHA256, DER.bitString(Array(signature)))
}
}

@ -1,420 +0,0 @@
import CryptoKit
import Foundation
/// Keying material for SRTP, exported from DTLS (RFC 5764 4.2).
struct SRTPKeyMaterial: Sendable, Equatable {
let clientKey: [UInt8]
let serverKey: [UInt8]
let clientSalt: [UInt8]
let serverSalt: [UInt8]
}
/// A DTLS 1.2 server for WebRTC, deliberately narrow:
/// one cipher suite (TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256), P-256, ECDSA client certificates
/// checked against the SDP fingerprint, the use_srtp extension with SRTP_AES128_CM_HMAC_SHA1_80
/// and extended master secret. No resumption, no renegotiation.
/// Synchronous state machine: feed datagrams, send what comes back. Not thread-safe.
final class DTLSServer {
enum State: Equatable {
case awaitingClientHello
case awaitingClientFlight
case established
case closed
case failed(String)
}
struct Failure: Error, CustomStringConvertible {
let description: String
let alert: UInt8
init(_ description: String, alert: UInt8 = 40) { self.description = description; self.alert = alert }
}
private enum ContentType: UInt8 { case changeCipherSpec = 20, alert = 21, handshake = 22, applicationData = 23 }
private enum HandshakeType: UInt8 {
case clientHello = 1, serverHello = 2, certificate = 11, serverKeyExchange = 12, certificateRequest = 13
case serverHelloDone = 14, certificateVerify = 15, clientKeyExchange = 16, finished = 20
}
static let cipherSuite: UInt16 = 0xC02B
static let srtpProfile: UInt16 = 0x0001
private static let version: UInt16 = 0xFEFD
private static let ecdsaSecp256r1SHA256: UInt16 = 0x0403
private(set) var state: State = .awaitingClientHello
private(set) var keyMaterial: SRTPKeyMaterial?
private let certificate: DTLSCertificate
private let expectedFingerprint: String?
private var transcript: [UInt8] = []
private var clientRandom: [UInt8] = []
private var serverRandom: [UInt8] = []
private var usesExtendedMasterSecret = false
private var echoRenegotiationInfo = false
private var echoPointFormats = false
private var ephemeral = P256.KeyAgreement.PrivateKey()
private var masterSecret: [UInt8] = []
private var clientPublicKey: P256.Signing.PublicKey?
private var readCipher: (key: SymmetricKey, iv: [UInt8])?
private var writeCipher: (key: SymmetricKey, iv: [UInt8])?
private var writeEpoch: UInt16 = 0
private var writeSequence: [UInt16: UInt64] = [:]
private var nextSendMessageSeq: UInt16 = 0
private var nextReceiveMessageSeq: UInt16 = 0
private var fragments: [UInt16: (type: UInt8, body: [UInt8?])] = [:]
private var lastFlight: [(type: ContentType, epoch: UInt16, body: [UInt8])] = []
private var flightTrigger: UInt16 = 0
/// `expectedFingerprint`: the peer's `a=fingerprint:sha-256` from the SDP offer.
init(certificate: DTLSCertificate, expectedFingerprint: String?) {
self.certificate = certificate
self.expectedFingerprint = expectedFingerprint?.uppercased()
}
/// RFC 7983 demultiplexing: DTLS content types 20…63.
static func looksLikeDTLS(_ packet: [UInt8]) -> Bool {
guard let first = packet.first else { return false }
return first >= 20 && first <= 63
}
/// Processes one datagram and returns the datagrams to send back.
func handle(_ datagram: [UInt8]) -> [[UInt8]] {
switch state {
case .closed, .failed: return []
default: break
}
do {
var output: [[UInt8]] = []
var reader = ByteReader(datagram)
while reader.remaining >= 13 {
let type = try reader.u8()
_ = try reader.u16()
let epoch = try reader.u16()
let sequence = try reader.u48()
let fragment = try reader.take(Int(try reader.u16()))
guard let content = ContentType(rawValue: type) else { continue }
let plain: [UInt8]
if epoch == 0 {
plain = fragment
} else if epoch == 1, readCipher != nil {
guard let decrypted = decrypt(fragment, type: type, epoch: epoch, sequence: sequence) else { continue }
plain = decrypted
} else {
continue
}
output += try process(content, epoch: epoch, plain)
}
return output
} catch let failure as Failure {
state = .failed(failure.description)
return [record(.alert, epoch: writeEpoch, body: [2, failure.alert])]
} catch {
state = .failed("Malformed DTLS record")
return [record(.alert, epoch: writeEpoch, body: [2, 50])]
}
}
// MARK: - Records
private func process(_ type: ContentType, epoch: UInt16, _ body: [UInt8]) throws -> [[UInt8]] {
switch type {
case .handshake:
return try receiveHandshake(body, epoch: epoch)
case .changeCipherSpec:
if state == .awaitingClientFlight, readCipher == nil, !masterSecret.isEmpty {
readCipher = keys().client
}
return []
case .alert:
if body.count >= 2, body[1] == 0 { state = .closed } else if body.first == 2 { state = .failed("Peer alert \(body.last ?? 0)") }
return []
case .applicationData:
return []
}
}
private func receiveHandshake(_ body: [UInt8], epoch: UInt16) throws -> [[UInt8]] {
var reader = ByteReader(body)
var output: [[UInt8]] = []
while reader.remaining >= 12 {
let type = try reader.u8()
let length = try reader.u24()
let messageSeq = try reader.u16()
let offset = try reader.u24()
let fragmentLength = try reader.u24()
let fragment = try reader.take(fragmentLength)
guard length <= 1 << 16, offset + fragmentLength <= length else { throw Failure("Bad fragment", alert: 50) }
if messageSeq < nextReceiveMessageSeq {
// Retransmitted flight: the peer missed ours.
if messageSeq == flightTrigger, !lastFlight.isEmpty { output += resendLastFlight() }
continue
}
var entry = fragments[messageSeq] ?? (type, [UInt8?](repeating: nil, count: length))
guard entry.type == type, entry.body.count == length else { throw Failure("Inconsistent fragments", alert: 50) }
for i in 0..<fragmentLength { entry.body[offset + i] = fragment[i] }
fragments[messageSeq] = entry
while let next = fragments[nextReceiveMessageSeq], !next.body.contains(where: { $0 == nil }) {
fragments[nextReceiveMessageSeq] = nil
let seq = nextReceiveMessageSeq
nextReceiveMessageSeq += 1
output += try handleMessage(next.type, next.body.map { $0! }, messageSeq: seq, epoch: epoch)
}
}
return output
}
private func handleMessage(_ type: UInt8, _ body: [UInt8], messageSeq: UInt16, epoch: UInt16) throws -> [[UInt8]] {
let full = handshakeHeader(type: type, length: body.count, messageSeq: messageSeq) + body
switch (state, HandshakeType(rawValue: type)) {
case (.awaitingClientHello, .clientHello):
try parseClientHello(body)
transcript += full
flightTrigger = messageSeq
return sendServerFlight()
case (.awaitingClientFlight, .certificate):
var r = ByteReader(body)
var list = ByteReader(try r.vector(3))
guard !list.isAtEnd else { throw Failure("Client sent no certificate", alert: 42) }
let der = try list.vector(3)
if let expectedFingerprint, DTLSCertificate.fingerprint(der) != expectedFingerprint {
throw Failure("Client certificate does not match the SDP fingerprint", alert: 42)
}
guard let spki = DER.subjectPublicKeyInfo(certificate: der),
let key = try? P256.Signing.PublicKey(derRepresentation: spki) else {
throw Failure("Unsupported client certificate", alert: 43)
}
clientPublicKey = key
transcript += full
case (.awaitingClientFlight, .clientKeyExchange):
var r = ByteReader(body)
let point = try r.vector(1)
guard let peer = try? P256.KeyAgreement.PublicKey(x963Representation: point),
let shared = try? ephemeral.sharedSecretFromKeyAgreement(with: peer) else {
throw Failure("Bad client key share", alert: 47)
}
let preMaster = shared.withUnsafeBytes { Array($0) }
transcript += full
if usesExtendedMasterSecret {
masterSecret = TLSPRF.compute(secret: preMaster, label: "extended master secret",
seed: Array(SHA256.hash(data: transcript)), length: 48)
} else {
masterSecret = TLSPRF.compute(secret: preMaster, label: "master secret",
seed: clientRandom + serverRandom, length: 48)
}
case (.awaitingClientFlight, .certificateVerify):
guard let clientPublicKey, !masterSecret.isEmpty else { throw Failure("Unexpected CertificateVerify", alert: 10) }
var r = ByteReader(body)
guard try r.u16() == Self.ecdsaSecp256r1SHA256 else { throw Failure("Unsupported signature algorithm", alert: 40) }
let signature = try r.vector(2)
guard let parsed = try? P256.Signing.ECDSASignature(derRepresentation: signature),
clientPublicKey.isValidSignature(parsed, for: transcript) else {
throw Failure("CertificateVerify failed", alert: 51)
}
transcript += full
case (.awaitingClientFlight, .finished):
guard epoch == 1, clientPublicKey != nil else { throw Failure("Finished before authentication", alert: 10) }
let expected = TLSPRF.compute(secret: masterSecret, label: "client finished",
seed: Array(SHA256.hash(data: transcript)), length: 12)
guard constantTimeEqual(body, expected) else { throw Failure("Client Finished does not verify", alert: 51) }
transcript += full
flightTrigger = messageSeq
let output = sendFinalFlight()
exportKeys()
state = .established
return output
default:
throw Failure("Unexpected handshake message \(type)", alert: 10)
}
return []
}
// MARK: - ClientHello
private func parseClientHello(_ body: [UInt8]) throws {
var r = ByteReader(body)
guard try r.u16() == Self.version else { throw Failure("Only DTLS 1.2 is supported", alert: 70) }
clientRandom = try r.take(32)
_ = try r.vector(1) // session id
_ = try r.vector(1) // cookie
var suites = ByteReader(try r.vector(2))
var offersSuite = false
while suites.remaining >= 2 {
let suite = try suites.u16()
if suite == Self.cipherSuite { offersSuite = true }
if suite == 0x00FF { echoRenegotiationInfo = true }
}
guard offersSuite else { throw Failure("Client does not offer ECDHE-ECDSA-AES128-GCM-SHA256") }
_ = try r.vector(1) // compression methods
var offersSRTP = false
var offersP256 = false
if !r.isAtEnd {
var extensions = ByteReader(try r.vector(2))
while extensions.remaining >= 4 {
let type = try extensions.u16()
var data = ByteReader(try extensions.vector(2))
switch type {
case 0x000E: // use_srtp
var profiles = ByteReader(try data.vector(2))
while profiles.remaining >= 2 { if try profiles.u16() == Self.srtpProfile { offersSRTP = true } }
case 0x000A: // supported_groups
var groups = ByteReader(try data.vector(2))
while groups.remaining >= 2 { if try groups.u16() == 23 { offersP256 = true } }
case 0x0017: usesExtendedMasterSecret = true
case 0xFF01: echoRenegotiationInfo = true
case 0x000B: echoPointFormats = true
default: break
}
}
} else {
offersP256 = true // no supported_groups extension means "any"
}
guard offersSRTP else { throw Failure("Client does not offer SRTP_AES128_CM_HMAC_SHA1_80") }
guard offersP256 else { throw Failure("Client does not offer P-256") }
}
// MARK: - Server flights
private func sendServerFlight() -> [[UInt8]] {
serverRandom = (0..<32).map { _ in UInt8.random(in: 0...255) }
var hello = ByteWriter()
hello.u16(Self.version)
hello.append(serverRandom)
hello.u8(0) // session id
hello.u16(Self.cipherSuite)
hello.u8(0) // no compression
hello.vector(2) { ext in
if echoRenegotiationInfo { ext.u16(0xFF01); ext.u16(1); ext.u8(0) }
if usesExtendedMasterSecret { ext.u16(0x0017); ext.u16(0) }
if echoPointFormats { ext.u16(0x000B); ext.u16(2); ext.u8(1); ext.u8(0) }
ext.u16(0x000E); ext.u16(5); ext.u16(2); ext.u16(Self.srtpProfile); ext.u8(0)
}
var cert = ByteWriter()
cert.vector(3) { list in list.vector(3) { $0.append(certificate.der) } }
var params = ByteWriter()
params.u8(3) // named_curve
params.u16(23) // secp256r1
params.vector(1) { $0.append(ephemeral.publicKey.x963Representation) }
let signed = (try? certificate.privateKey.signature(for: clientRandom + serverRandom + params.bytes).derRepresentation) ?? Data()
var keyExchange = params
keyExchange.u16(Self.ecdsaSecp256r1SHA256)
keyExchange.vector(2) { $0.append(signed) }
var request = ByteWriter()
request.vector(1) { $0.u8(64) } // ecdsa_sign
request.vector(2) { $0.u16(Self.ecdsaSecp256r1SHA256) }
request.u16(0) // no CA names
lastFlight = []
for (type, body) in [(HandshakeType.serverHello, hello.bytes), (.certificate, cert.bytes),
(.serverKeyExchange, keyExchange.bytes), (.certificateRequest, request.bytes),
(.serverHelloDone, [])] {
let message = handshakeHeader(type: type.rawValue, length: body.count, messageSeq: nextSendMessageSeq) + body
nextSendMessageSeq += 1
transcript += message
lastFlight.append((.handshake, 0, message))
}
state = .awaitingClientFlight
return resendLastFlight()
}
private func sendFinalFlight() -> [[UInt8]] {
let verify = TLSPRF.compute(secret: masterSecret, label: "server finished",
seed: Array(SHA256.hash(data: transcript)), length: 12)
let finished = handshakeHeader(type: HandshakeType.finished.rawValue, length: 12, messageSeq: nextSendMessageSeq) + verify
nextSendMessageSeq += 1
transcript += finished
writeCipher = keys().server
lastFlight = [(.changeCipherSpec, 0, [1]), (.handshake, 1, finished)]
return resendLastFlight()
}
/// Sends the last flight again with fresh record sequence numbers, all in one datagram.
private func resendLastFlight() -> [[UInt8]] {
[lastFlight.flatMap { record($0.type, epoch: $0.epoch, body: $0.body) }]
}
private func handshakeHeader(type: UInt8, length: Int, messageSeq: UInt16) -> [UInt8] {
var w = ByteWriter(capacity: 12)
w.u8(type)
w.u24(length)
w.u16(messageSeq)
w.u24(0)
w.u24(length)
return w.bytes
}
private func record(_ type: ContentType, epoch: UInt16, body: [UInt8]) -> [UInt8] {
let sequence = writeSequence[epoch, default: 0]
writeSequence[epoch] = sequence + 1
let payload = epoch == 0 ? body : encrypt(body, type: type.rawValue, epoch: epoch, sequence: sequence)
var w = ByteWriter(capacity: 13 + payload.count)
w.u8(type.rawValue)
w.u16(Self.version)
w.u16(epoch)
w.u48(sequence)
w.u16(UInt16(payload.count))
w.append(payload)
return w.bytes
}
// MARK: - Keys and AEAD
private func keys() -> (client: (key: SymmetricKey, iv: [UInt8]), server: (key: SymmetricKey, iv: [UInt8])) {
let block = TLSPRF.compute(secret: masterSecret, label: "key expansion", seed: serverRandom + clientRandom, length: 40)
return ((SymmetricKey(data: block[0..<16]), Array(block[32..<36])),
(SymmetricKey(data: block[16..<32]), Array(block[36..<40])))
}
private func exportKeys() {
let m = TLSPRF.compute(secret: masterSecret, label: "EXTRACTOR-dtls_srtp", seed: clientRandom + serverRandom, length: 60)
keyMaterial = SRTPKeyMaterial(clientKey: Array(m[0..<16]), serverKey: Array(m[16..<32]),
clientSalt: Array(m[32..<46]), serverSalt: Array(m[46..<60]))
}
private func additionalData(type: UInt8, epoch: UInt16, sequence: UInt64, length: Int) -> [UInt8] {
var w = ByteWriter(capacity: 13)
w.u16(epoch)
w.u48(sequence)
w.u8(type)
w.u16(Self.version)
w.u16(UInt16(length))
return w.bytes
}
private func encrypt(_ plain: [UInt8], type: UInt8, epoch: UInt16, sequence: UInt64) -> [UInt8] {
guard let writeCipher else { return plain }
var explicit = ByteWriter(capacity: 8)
explicit.u16(epoch)
explicit.u48(sequence)
guard let nonce = try? AES.GCM.Nonce(data: writeCipher.iv + explicit.bytes),
let sealed = try? AES.GCM.seal(plain, using: writeCipher.key, nonce: nonce,
authenticating: additionalData(type: type, epoch: epoch, sequence: sequence, length: plain.count))
else { return [] }
return explicit.bytes + Array(sealed.ciphertext) + Array(sealed.tag)
}
private func decrypt(_ fragment: [UInt8], type: UInt8, epoch: UInt16, sequence: UInt64) -> [UInt8]? {
guard let readCipher, fragment.count >= 8 + 16 else { return nil }
let explicit = Array(fragment[0..<8])
let ciphertext = Array(fragment[8..<fragment.count - 16])
let tag = Array(fragment[(fragment.count - 16)...])
guard let nonce = try? AES.GCM.Nonce(data: readCipher.iv + explicit),
let box = try? AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: tag),
let plain = try? AES.GCM.open(box, using: readCipher.key,
authenticating: additionalData(type: type, epoch: epoch, sequence: sequence, length: ciphertext.count))
else { return nil }
return Array(plain)
}
}

@ -1,150 +0,0 @@
import Foundation
/// One phone: ICE-lite credentials, DTLS, SRTP and the two outgoing RTP streams.
/// Owned and called by `IntercomEngine` on its queue only.
final class PeerSession {
struct Offer {
let answer: String
let session: PeerSession
}
enum Kind { case stun, dtls, rtcp, other }
let id = UUID()
let name: String
let role: String
let localUfrag: String
let localPassword: String
let remoteUfrag: String
let created = Date()
private(set) var lastConsent: Date?
private(set) var connectedSince: Date?
private var dtls: DTLSServer
private var srtp: SRTPContext?
private var audio: RTPStream?
private var video: RTPStream?
private var lastAudioTimestamp: UInt32 = 0
private var lastVideoTimestamp: UInt32 = 0
private var reportsDue = Date()
var isMediaReady: Bool { srtp != nil }
var dtlsState: DTLSServer.State { dtls.state }
private init(name: String, role: String, remoteUfrag: String, certificate: DTLSCertificate, remoteFingerprint: String) {
self.name = name
self.role = role
self.remoteUfrag = remoteUfrag
localUfrag = Self.token(8)
localPassword = Self.token(24)
dtls = DTLSServer(certificate: certificate, expectedFingerprint: remoteFingerprint)
}
/// Accepts a browser offer and returns the answer; nil if the offer has nothing Musubi can send.
static func accept(offerSDP: String, name: String, role: String, certificate: DTLSCertificate,
address: String, port: UInt16) throws -> Offer? {
let offer = try SDPOffer(offerSDP)
let session = PeerSession(name: name, role: role, remoteUfrag: offer.iceUfrag, certificate: certificate,
remoteFingerprint: offer.fingerprint)
var tracks: [SDPAnswer.Track] = []
for media in offer.media {
let ssrc = UInt32.random(in: 1...UInt32.max)
var payloadType: Int?
if media.port != 0 {
if media.kind == "audio", session.audio == nil, let pt = offer.opusPayloadType(in: media) {
payloadType = pt
session.audio = RTPStream(ssrc: ssrc, payloadType: UInt8(pt))
} else if media.kind == "video", session.video == nil, let pt = offer.h264PayloadType(in: media) {
payloadType = pt
session.video = RTPStream(ssrc: ssrc, payloadType: UInt8(pt))
}
}
tracks.append(.init(mid: media.mid, kind: media.kind, payloadType: payloadType, ssrc: ssrc))
}
guard session.audio != nil || session.video != nil else { return nil }
let answer = SDPAnswer(iceUfrag: session.localUfrag, icePwd: session.localPassword,
fingerprint: certificate.fingerprint, address: address, port: port, tracks: tracks)
return Offer(answer: answer.text, session: session)
}
static func classify(_ packet: [UInt8]) -> Kind {
if STUN.looksLikeSTUN(packet) { return .stun }
if DTLSServer.looksLikeDTLS(packet) { return .dtls }
if RTCPFeedback.isRTCP(packet) { return .rtcp }
return .other
}
/// Answers a connectivity check if it carries this session's credentials.
func answerSTUN(_ packet: [UInt8], message: STUN.Message, address: [UInt8], port: UInt16) -> [UInt8]? {
guard message.type == STUN.bindingRequest, message.username == "\(localUfrag):\(remoteUfrag)",
STUN.verify(packet, message: message, key: Array(localPassword.utf8)) else { return nil }
lastConsent = Date()
return STUN.bindingResponse(transactionID: message.transactionID, address: address, port: port,
key: Array(localPassword.utf8))
}
/// Feeds DTLS; returns datagrams to send and whether media just became ready.
func receiveDTLS(_ packet: [UInt8]) -> (replies: [[UInt8]], becameReady: Bool) {
let replies = dtls.handle(packet)
guard srtp == nil, let keys = dtls.keyMaterial else { return (replies, false) }
// DTLS server = answerer: we send with the server keys.
srtp = SRTPContext(masterKey: keys.serverKey, masterSalt: keys.serverSalt)
connectedSince = Date()
return (replies, true)
}
/// True if the browser asks for a key frame.
func receiveRTCP(_ packet: [UInt8]) -> Bool {
guard let srtpIn = inboundRTCP, let plain = srtpIn.unprotectRTCP(packet) else { return false }
return RTCPFeedback.requestsKeyFrame(plain)
}
private lazy var inboundRTCP: SRTPContext? = {
guard let keys = dtls.keyMaterial else { return nil }
return SRTPContext(masterKey: keys.clientKey, masterSalt: keys.clientSalt)
}()
func audioPacket(opus: [UInt8], timestamp: UInt32) -> [UInt8]? {
guard let srtp, audio != nil else { return nil }
lastAudioTimestamp = timestamp
return srtp.protectRTP(audio!.packet(timestamp: timestamp, marker: false, payload: opus))
}
func videoPackets(payloads: [[UInt8]], timestamp: UInt32) -> [[UInt8]] {
guard let srtp, video != nil else { return [] }
lastVideoTimestamp = timestamp
return payloads.enumerated().map { index, payload in
srtp.protectRTP(video!.packet(timestamp: timestamp, marker: index == payloads.count - 1, payload: payload))
}
}
/// Sender reports roughly once per second.
func senderReports(now: Date = Date()) -> [[UInt8]] {
guard let srtp, now >= reportsDue else { return [] }
reportsDue = now.addingTimeInterval(1)
var out: [[UInt8]] = []
if let audio {
out.append(srtp.protectRTCP(RTCPFeedback.senderReport(ssrc: audio.ssrc, rtpTimestamp: lastAudioTimestamp,
packets: audio.packetCount, octets: audio.octetCount, now: now)))
}
if let video {
out.append(srtp.protectRTCP(RTCPFeedback.senderReport(ssrc: video.ssrc, rtpTimestamp: lastVideoTimestamp,
packets: video.packetCount, octets: video.octetCount, now: now)))
}
return out
}
/// No consent for 30 s (RFC 7675), or never connected within 30 s, or DTLS ended.
func isExpired(now: Date = Date()) -> Bool {
switch dtls.state {
case .closed, .failed: return true
default: break
}
return now.timeIntervalSince(lastConsent ?? created) > 30
}
private static func token(_ length: Int) -> String {
let alphabet = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
return String((0..<length).map { _ in alphabet.randomElement()! })
}
}

@ -1,135 +0,0 @@
import Foundation
/// Writes RTP packets for one outgoing stream (RFC 3550).
struct RTPStream {
let ssrc: UInt32
let payloadType: UInt8
private(set) var sequence: UInt16 = .random(in: 0...UInt16.max)
private(set) var packetCount: UInt32 = 0
private(set) var octetCount: UInt32 = 0
init(ssrc: UInt32, payloadType: UInt8) {
self.ssrc = ssrc
self.payloadType = payloadType
}
mutating func packet(timestamp: UInt32, marker: Bool, payload: [UInt8]) -> [UInt8] {
var w = ByteWriter(capacity: 12 + payload.count)
w.u8(0x80)
w.u8((marker ? 0x80 : 0) | (payloadType & 0x7F))
w.u16(sequence)
w.u32(timestamp)
w.u32(ssrc)
w.append(payload)
sequence &+= 1
packetCount &+= 1
octetCount &+= UInt32(payload.count)
return w.bytes
}
}
/// H.264 packetization (RFC 6184, packetization-mode 1): single NAL units, STAP-A and FU-A.
enum H264Packetizer {
/// Largest RTP payload; keeps IP packets well below 1500 bytes after SRTP and IPv6 headers.
static let maxPayload = 1100
/// Splits one access unit (NAL units without start codes or length prefixes) into RTP payloads.
/// Small leading parameter sets (SPS/PPS) are aggregated into one STAP-A.
static func payloads(for nalUnits: [[UInt8]]) -> [[UInt8]] {
var out: [[UInt8]] = []
var aggregate: [[UInt8]] = []
func flushAggregate() {
if aggregate.count == 1 {
out.append(aggregate[0])
} else if aggregate.count > 1 {
var w = ByteWriter()
w.u8((aggregate.map { $0[0] & 0x60 }.max() ?? 0) | 24) // STAP-A, highest NRI
for nal in aggregate { w.u16(UInt16(nal.count)); w.append(nal) }
out.append(w.bytes)
}
aggregate = []
}
for nal in nalUnits where !nal.isEmpty {
let type = nal[0] & 0x1F
if type == 7 || type == 8 {
let size = aggregate.reduce(1) { $0 + 2 + $1.count } + 2 + nal.count
if size > maxPayload { flushAggregate() }
aggregate.append(nal)
continue
}
flushAggregate()
if nal.count <= maxPayload {
out.append(nal)
continue
}
let indicator = (nal[0] & 0xE0) | 28 // FU-A
var offset = 1
while offset < nal.count {
let end = min(offset + maxPayload - 2, nal.count)
var header = type
if offset == 1 { header |= 0x80 }
if end == nal.count { header |= 0x40 }
out.append([indicator, header] + nal[offset..<end])
offset = end
}
}
flushAggregate()
return out
}
/// Splits an AVCC buffer (4-byte big-endian length prefixes, as from VideoToolbox) into NAL units.
static func nalUnits(avcc: [UInt8]) -> [[UInt8]] {
var out: [[UInt8]] = []
var i = 0
while i + 4 <= avcc.count {
let length = Int(avcc[i]) << 24 | Int(avcc[i + 1]) << 16 | Int(avcc[i + 2]) << 8 | Int(avcc[i + 3])
i += 4
guard length > 0, i + length <= avcc.count else { break }
out.append(Array(avcc[i..<i + length]))
i += length
}
return out
}
}
/// RTCP feedback the browser sends that the sender must act on.
enum RTCPFeedback {
/// True if a (compound) RTCP packet asks for a key frame (PLI or FIR).
static func requestsKeyFrame(_ packet: [UInt8]) -> Bool {
var i = 0
while i + 4 <= packet.count {
let fmt = packet[i] & 0x1F
let type = packet[i + 1]
let length = ((Int(packet[i + 2]) << 8 | Int(packet[i + 3])) + 1) * 4
if (type == 206 && fmt == 1) || (type == 206 && fmt == 4) { return true }
guard length > 0 else { return false }
i += length
}
return false
}
/// RFC 7983: RTP/RTCP start with version 2 (first byte 128…191); RTCP payload types are 192…223.
static func isRTCP(_ packet: [UInt8]) -> Bool {
packet.count >= 8 && packet[0] >= 128 && packet[0] <= 191 && packet[1] >= 192 && packet[1] <= 223
}
/// Sender report so the browser can map RTP timestamps to wall clock (RFC 3550 6.4.1).
static func senderReport(ssrc: UInt32, rtpTimestamp: UInt32, packets: UInt32, octets: UInt32, now: Date = Date()) -> [UInt8] {
let ntp = now.timeIntervalSince1970 + 2_208_988_800
let seconds = UInt32(ntp)
let fraction = UInt32((ntp - Double(seconds)) * 4_294_967_296)
var w = ByteWriter(capacity: 28)
w.u8(0x80)
w.u8(200)
w.u16(6)
w.u32(ssrc)
w.u32(seconds)
w.u32(fraction)
w.u32(rtpTimestamp)
w.u32(packets)
w.u32(octets)
return w.bytes
}
}

@ -1,154 +0,0 @@
import Foundation
/// What Musubi needs from a browser's SDP offer (Unified Plan, BUNDLE, rtcp-mux).
struct SDPOffer: Equatable {
struct Media: Equatable {
var kind: String
var mid: String
var port: Int
var payloadTypes: [Int]
var rtpmap: [Int: String] = [:]
var fmtp: [Int: String] = [:]
}
var iceUfrag: String
var icePwd: String
/// `sha-256` fingerprint, uppercase hex with colons.
var fingerprint: String
var media: [Media]
enum ParseError: Error, Equatable { case missing(String) }
init(_ sdp: String) throws {
var sessionUfrag: String?, sessionPwd: String?, sessionFingerprint: String?
var mediaUfrag: String?, mediaPwd: String?, mediaFingerprint: String?
var media: [Media] = []
for raw in sdp.split(whereSeparator: \.isNewline) {
let line = raw.trimmingCharacters(in: .whitespaces)
if line.hasPrefix("m=") {
let parts = line.dropFirst(2).split(separator: " ")
guard parts.count >= 3 else { continue }
media.append(Media(kind: String(parts[0]), mid: "\(media.count)", port: Int(parts[1]) ?? 0,
payloadTypes: parts.dropFirst(3).compactMap { Int($0) }))
continue
}
guard line.hasPrefix("a=") else { continue }
let attribute = line.dropFirst(2)
let (name, value) = attribute.firstIndex(of: ":").map {
(String(attribute[..<$0]), String(attribute[attribute.index(after: $0)...]))
} ?? (String(attribute), "")
let inMedia = !media.isEmpty
switch name {
case "ice-ufrag": if inMedia { mediaUfrag = mediaUfrag ?? value } else { sessionUfrag = value }
case "ice-pwd": if inMedia { mediaPwd = mediaPwd ?? value } else { sessionPwd = value }
case "fingerprint":
let parts = value.split(separator: " ")
guard parts.count == 2, parts[0].lowercased() == "sha-256" else { continue }
if inMedia { mediaFingerprint = mediaFingerprint ?? parts[1].uppercased() } else { sessionFingerprint = parts[1].uppercased() }
case "mid" where inMedia:
media[media.count - 1].mid = value
case "rtpmap" where inMedia, "fmtp" where inMedia:
guard let space = value.firstIndex(of: " "), let pt = Int(value[..<space]) else { continue }
let rest = String(value[value.index(after: space)...])
if name == "rtpmap" { media[media.count - 1].rtpmap[pt] = rest } else { media[media.count - 1].fmtp[pt] = rest }
default: break
}
}
guard let ufrag = sessionUfrag ?? mediaUfrag else { throw ParseError.missing("ice-ufrag") }
guard let pwd = sessionPwd ?? mediaPwd else { throw ParseError.missing("ice-pwd") }
guard let fingerprint = sessionFingerprint ?? mediaFingerprint else { throw ParseError.missing("fingerprint") }
iceUfrag = ufrag
icePwd = pwd
self.fingerprint = fingerprint
self.media = media
}
/// Payload type for Opus at 48 kHz.
func opusPayloadType(in media: Media) -> Int? {
media.payloadTypes.first { media.rtpmap[$0]?.lowercased().hasPrefix("opus/48000") == true }
}
/// Payload type for H.264 with packetization-mode=1 in (constrained) baseline profile.
func h264PayloadType(in media: Media) -> Int? {
let candidates = media.payloadTypes.filter { pt in
guard media.rtpmap[pt]?.uppercased().hasPrefix("H264/90000") == true, let fmtp = media.fmtp[pt]?.lowercased() else { return false }
return fmtp.contains("packetization-mode=1")
}
func profile(_ pt: Int) -> String {
media.fmtp[pt]?.lowercased().split(separator: ";")
.first { $0.trimmingCharacters(in: .whitespaces).hasPrefix("profile-level-id=") }
.map { String($0.split(separator: "=").last ?? "") } ?? ""
}
return candidates.first { profile($0).hasPrefix("42e0") } ?? candidates.first { profile($0).hasPrefix("4200") }
}
}
/// Builds the ICE-lite, DTLS-passive, send-only answer.
struct SDPAnswer {
struct Track {
var mid: String
var kind: String
/// nil rejects the section (port 0).
var payloadType: Int?
var ssrc: UInt32
}
var iceUfrag: String
var icePwd: String
var fingerprint: String
var address: String
var port: UInt16
var tracks: [Track]
var text: String {
let accepted = tracks.filter { $0.payloadType != nil }
var lines = [
"v=0",
"o=- \(UInt32.random(in: 1...UInt32.max)) 2 IN IP4 \(address)",
"s=Musubi",
"t=0 0",
"a=ice-lite",
"a=group:BUNDLE \(accepted.map(\.mid).joined(separator: " "))",
"a=msid-semantic: WMS musubi",
]
for track in tracks {
guard let pt = track.payloadType else {
lines += ["m=\(track.kind) 0 UDP/TLS/RTP/SAVPF 0", "c=IN IP4 0.0.0.0", "a=mid:\(track.mid)", "a=inactive"]
continue
}
lines += [
"m=\(track.kind) \(port) UDP/TLS/RTP/SAVPF \(pt)",
"c=IN IP4 \(address)",
"a=rtcp:\(port) IN IP4 \(address)",
"a=ice-ufrag:\(iceUfrag)",
"a=ice-pwd:\(icePwd)",
"a=fingerprint:sha-256 \(fingerprint)",
"a=setup:passive",
"a=mid:\(track.mid)",
"a=sendonly",
"a=rtcp-mux",
"a=msid:musubi \(track.kind)",
]
if track.kind == "audio" {
lines += ["a=rtpmap:\(pt) opus/48000/2", "a=fmtp:\(pt) minptime=10;useinbandfec=1"]
} else {
lines += [
"a=rtpmap:\(pt) H264/90000",
"a=rtcp-fb:\(pt) nack pli",
"a=rtcp-fb:\(pt) ccm fir",
"a=fmtp:\(pt) level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
]
}
lines += [
"a=ssrc:\(track.ssrc) cname:musubi",
"a=ssrc:\(track.ssrc) msid:musubi \(track.kind)",
"a=candidate:1 1 udp 2130706431 \(address) \(port) typ host",
"a=end-of-candidates",
]
}
return lines.joined(separator: "\r\n") + "\r\n"
}
}

@ -1,139 +0,0 @@
import CommonCrypto
import CryptoKit
import Foundation
/// AES-128 in ECB mode, used as the block function for counter mode (CryptoKit has no AES-CTR).
final class AESBlock: @unchecked Sendable {
private var cryptor: CCCryptorRef?
init(key: [UInt8]) {
precondition(key.count == 16)
CCCryptorCreate(CCOperation(kCCEncrypt), CCAlgorithm(kCCAlgorithmAES), CCOptions(kCCOptionECBMode),
key, key.count, nil, &cryptor)
}
deinit { if let cryptor { CCCryptorRelease(cryptor) } }
/// Counter-mode keystream: AES(iv), AES(iv+1), … (counter in the last 16 bits, RFC 3711 4.1.1).
func keystream(iv: [UInt8], count: Int) -> [UInt8] {
let blocks = (count + 15) / 16
var input = [UInt8](repeating: 0, count: blocks * 16)
for b in 0..<blocks {
var block = iv
let counter = (UInt32(block[14]) << 8 | UInt32(block[15])) &+ UInt32(b)
block[14] = UInt8((counter >> 8) & 0xFF)
block[15] = UInt8(counter & 0xFF)
input.replaceSubrange(b * 16..<(b + 1) * 16, with: block)
}
var output = [UInt8](repeating: 0, count: input.count)
var moved = 0
CCCryptorUpdate(cryptor, input, input.count, &output, output.count, &moved)
return Array(output.prefix(count))
}
}
/// SRTP/SRTCP with the profile SRTP_AES128_CM_HMAC_SHA1_80 (RFC 3711, RFC 5764).
/// One context per direction; this side only sends RTP and reads RTCP feedback.
final class SRTPContext: @unchecked Sendable {
struct SessionKeys {
let cipher: AESBlock
let salt: [UInt8]
let auth: SymmetricKey
}
static let tagLength = 10
let rtp: SessionKeys
let rtcp: SessionKeys
private var rolloverCounters: [UInt32: (roc: UInt32, lastSeq: UInt16)] = [:]
private var srtcpIndex: UInt32 = 0
init(masterKey: [UInt8], masterSalt: [UInt8]) {
rtp = Self.derive(masterKey: masterKey, masterSalt: masterSalt, labels: (0, 1, 2))
rtcp = Self.derive(masterKey: masterKey, masterSalt: masterSalt, labels: (3, 4, 5))
}
/// Key derivation (RFC 3711 4.3) with key_derivation_rate 0.
static func deriveKey(masterKey: [UInt8], masterSalt: [UInt8], label: UInt8, length: Int) -> [UInt8] {
var x = masterSalt + [0, 0]
x[7] ^= label
return AESBlock(key: masterKey).keystream(iv: x, count: length)
}
private static func derive(masterKey: [UInt8], masterSalt: [UInt8], labels: (UInt8, UInt8, UInt8)) -> SessionKeys {
SessionKeys(
cipher: AESBlock(key: deriveKey(masterKey: masterKey, masterSalt: masterSalt, label: labels.0, length: 16)),
salt: deriveKey(masterKey: masterKey, masterSalt: masterSalt, label: labels.2, length: 14),
auth: SymmetricKey(data: deriveKey(masterKey: masterKey, masterSalt: masterSalt, label: labels.1, length: 20)))
}
/// IV = salt·2^16 ⊕ SSRC·2^64 ⊕ index·2^16.
private static func iv(salt: [UInt8], ssrc: UInt32, index: UInt64) -> [UInt8] {
var iv = salt + [0, 0]
for i in 0..<4 { iv[4 + i] ^= UInt8((ssrc >> UInt32(24 - 8 * i)) & 0xFF) }
for i in 0..<6 { iv[8 + i] ^= UInt8((index >> UInt64(40 - 8 * i)) & 0xFF) }
return iv
}
/// Encrypts and authenticates one RTP packet. Packets per SSRC must be passed in sending order.
func protectRTP(_ packet: [UInt8]) -> [UInt8] {
guard packet.count >= 12 else { return packet }
let headerLength = Self.rtpHeaderLength(packet)
let seq = UInt16(packet[2]) << 8 | UInt16(packet[3])
let ssrc = UInt32(packet[8]) << 24 | UInt32(packet[9]) << 16 | UInt32(packet[10]) << 8 | UInt32(packet[11])
var state = rolloverCounters[ssrc] ?? (0, seq)
if seq < state.lastSeq && state.lastSeq - seq > 0x8000 { state.roc &+= 1 }
state.lastSeq = seq
rolloverCounters[ssrc] = state
let index = UInt64(state.roc) << 16 | UInt64(seq)
var out = packet
let payloadCount = packet.count - headerLength
if payloadCount > 0 {
let stream = rtp.cipher.keystream(iv: Self.iv(salt: rtp.salt, ssrc: ssrc, index: index), count: payloadCount)
for i in 0..<payloadCount { out[headerLength + i] ^= stream[i] }
}
let roc = [UInt8(state.roc >> 24), UInt8((state.roc >> 16) & 0xFF), UInt8((state.roc >> 8) & 0xFF), UInt8(state.roc & 0xFF)]
let tag = HMAC<Insecure.SHA1>.authenticationCode(for: out + roc, using: rtp.auth)
return out + Array(tag).prefix(Self.tagLength)
}
/// Encrypts and authenticates one (compound) RTCP packet.
func protectRTCP(_ packet: [UInt8]) -> [UInt8] {
guard packet.count >= 8 else { return packet }
srtcpIndex = (srtcpIndex + 1) & 0x7FFF_FFFF
let ssrc = UInt32(packet[4]) << 24 | UInt32(packet[5]) << 16 | UInt32(packet[6]) << 8 | UInt32(packet[7])
var out = packet
let stream = rtcp.cipher.keystream(iv: Self.iv(salt: rtcp.salt, ssrc: ssrc, index: UInt64(srtcpIndex)), count: packet.count - 8)
for i in 0..<stream.count { out[8 + i] ^= stream[i] }
let e = srtcpIndex | 0x8000_0000
out += [UInt8(e >> 24), UInt8((e >> 16) & 0xFF), UInt8((e >> 8) & 0xFF), UInt8(e & 0xFF)]
let tag = HMAC<Insecure.SHA1>.authenticationCode(for: out, using: rtcp.auth)
return out + Array(tag).prefix(Self.tagLength)
}
/// Verifies and decrypts one SRTCP packet; nil if the tag does not match.
func unprotectRTCP(_ packet: [UInt8]) -> [UInt8]? {
guard packet.count >= 8 + 4 + Self.tagLength else { return nil }
let authEnd = packet.count - Self.tagLength
let tag = Array(packet[authEnd...])
let expected = Array(HMAC<Insecure.SHA1>.authenticationCode(for: packet[0..<authEnd], using: rtcp.auth)).prefix(Self.tagLength)
guard constantTimeEqual(tag, Array(expected)) else { return nil }
let e = UInt32(packet[authEnd - 4]) << 24 | UInt32(packet[authEnd - 3]) << 16 | UInt32(packet[authEnd - 2]) << 8 | UInt32(packet[authEnd - 1])
var out = Array(packet[0..<authEnd - 4])
guard e & 0x8000_0000 != 0 else { return out }
let ssrc = UInt32(packet[4]) << 24 | UInt32(packet[5]) << 16 | UInt32(packet[6]) << 8 | UInt32(packet[7])
let stream = rtcp.cipher.keystream(iv: Self.iv(salt: rtcp.salt, ssrc: ssrc, index: UInt64(e & 0x7FFF_FFFF)), count: out.count - 8)
for i in 0..<stream.count { out[8 + i] ^= stream[i] }
return out
}
static func rtpHeaderLength(_ packet: [UInt8]) -> Int {
var length = 12 + Int(packet[0] & 0x0F) * 4
if packet[0] & 0x10 != 0, packet.count >= length + 4 {
length += 4 + (Int(packet[length + 2]) << 8 | Int(packet[length + 3])) * 4
}
return min(length, packet.count)
}
}

@ -1,141 +0,0 @@
import CryptoKit
import Foundation
/// The subset of STUN (RFC 5389) an ICE-lite agent needs: answer Binding requests
/// with short-term credentials (RFC 8445), MESSAGE-INTEGRITY and FINGERPRINT.
enum STUN {
static let magicCookie: UInt32 = 0x2112_A442
static let bindingRequest: UInt16 = 0x0001
static let bindingSuccess: UInt16 = 0x0101
enum Attribute: UInt16 {
case username = 0x0006
case messageIntegrity = 0x0008
case xorMappedAddress = 0x0020
case priority = 0x0024
case useCandidate = 0x0025
case fingerprint = 0x8028
case iceControlled = 0x8029
case iceControlling = 0x802A
}
struct Message {
var type: UInt16
var transactionID: [UInt8]
/// Attributes in wire order, with the offset of each attribute header in the packet.
var attributes: [(type: UInt16, value: [UInt8], offset: Int)]
func value(_ attribute: Attribute) -> [UInt8]? {
attributes.first { $0.type == attribute.rawValue }?.value
}
var username: String? { value(.username).flatMap { String(bytes: $0, encoding: .utf8) } }
}
/// RFC 7983 demultiplexing: first byte 0–3 and the magic cookie.
static func looksLikeSTUN(_ packet: [UInt8]) -> Bool {
packet.count >= 20 && packet[0] < 4
&& packet[4] == 0x21 && packet[5] == 0x12 && packet[6] == 0xA4 && packet[7] == 0x42
}
static func parse(_ packet: [UInt8]) -> Message? {
guard looksLikeSTUN(packet) else { return nil }
var reader = ByteReader(packet)
do {
let type = try reader.u16()
let length = Int(try reader.u16())
guard length + 20 == packet.count, length.isMultiple(of: 4) else { return nil }
try reader.skip(4)
let transactionID = try reader.take(12)
var attributes: [(UInt16, [UInt8], Int)] = []
while !reader.isAtEnd {
let offset = reader.offset
let attrType = try reader.u16()
let attrLength = Int(try reader.u16())
let value = try reader.take(attrLength)
try reader.skip((4 - attrLength % 4) % 4)
attributes.append((attrType, value, offset))
}
return Message(type: type, transactionID: transactionID, attributes: attributes)
} catch {
return nil
}
}
/// Checks MESSAGE-INTEGRITY (HMAC-SHA1 with `key`) and, if present, FINGERPRINT.
static func verify(_ packet: [UInt8], message: Message, key: [UInt8]) -> Bool {
if let fp = message.attributes.first(where: { $0.type == Attribute.fingerprint.rawValue }) {
guard fp.value == fingerprint(packet, upTo: fp.offset) else { return false }
}
guard let mi = message.attributes.first(where: { $0.type == Attribute.messageIntegrity.rawValue }) else { return false }
return constantTimeEqual(mi.value, integrity(packet, upTo: mi.offset, key: key))
}
/// Builds a Binding success response with XOR-MAPPED-ADDRESS, MESSAGE-INTEGRITY and FINGERPRINT.
static func bindingResponse(transactionID: [UInt8], address: [UInt8], port: UInt16, key: [UInt8]) -> [UInt8] {
var w = ByteWriter(capacity: 80)
w.u16(bindingSuccess)
w.u16(0) // length, patched below
w.u32(magicCookie)
w.append(transactionID)
let cookie: [UInt8] = [0x21, 0x12, 0xA4, 0x42]
w.u16(Attribute.xorMappedAddress.rawValue)
w.u16(address.count == 4 ? 8 : 20)
w.u8(0)
w.u8(address.count == 4 ? 0x01 : 0x02)
w.u16(port ^ 0x2112)
let mask = cookie + transactionID
w.append(address.enumerated().map { $0.element ^ mask[$0.offset] })
var bytes = w.bytes
let miOffset = bytes.count
let mac = integrity(bytes, upTo: miOffset, key: key)
var tail = ByteWriter()
tail.u16(Attribute.messageIntegrity.rawValue)
tail.u16(20)
tail.append(mac)
bytes += tail.bytes
let fpOffset = bytes.count
let crc = fingerprint(bytes, upTo: fpOffset)
bytes += [0x80, 0x28, 0x00, 0x04] + crc
setLength(&bytes, bytes.count - 20)
return bytes
}
/// HMAC-SHA1 over the message up to `offset`, with the header length covering the MESSAGE-INTEGRITY attribute.
private static func integrity(_ packet: [UInt8], upTo offset: Int, key: [UInt8]) -> [UInt8] {
var prefix = Array(packet[0..<offset])
setLength(&prefix, offset - 20 + 24)
let mac = HMAC<Insecure.SHA1>.authenticationCode(for: prefix, using: SymmetricKey(data: key))
return Array(mac)
}
/// CRC-32 over the message up to `offset`, length covering FINGERPRINT, XOR 0x5354554E.
private static func fingerprint(_ packet: [UInt8], upTo offset: Int) -> [UInt8] {
var prefix = Array(packet[0..<offset])
setLength(&prefix, offset - 20 + 8)
let value = CRC32.checksum(prefix) ^ 0x5354_554E
return [UInt8(value >> 24), UInt8((value >> 16) & 0xFF), UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)]
}
private static func setLength(_ bytes: inout [UInt8], _ length: Int) {
bytes[2] = UInt8(length >> 8)
bytes[3] = UInt8(length & 0xFF)
}
}
enum CRC32 {
private static let table: [UInt32] = (0..<256).map { n in
var c = UInt32(n)
for _ in 0..<8 { c = (c & 1) != 0 ? 0xEDB8_8320 ^ (c >> 1) : c >> 1 }
return c
}
static func checksum(_ bytes: [UInt8]) -> UInt32 {
var crc: UInt32 = 0xFFFF_FFFF
for b in bytes { crc = table[Int((crc ^ UInt32(b)) & 0xFF)] ^ (crc >> 8) }
return crc ^ 0xFFFF_FFFF
}
}

@ -11,8 +11,6 @@ import OSLog
public final class NDIReceiver: @unchecked Sendable {
public enum Bandwidth: Sendable {
case highest
/// The sender's proxy stream (about 640×360) where available, e.g. for phone previews.
case lowest
/// Audio and metadata only; the sender transmits no video to this receiver.
case audioOnly
}
@ -64,11 +62,6 @@ public final class NDIReceiver: @unchecked Sendable {
onFrame: FrameHandler? = nil,
onAudio: AudioHandler? = nil
) {
let ndiBandwidth: NDIlib_recv_bandwidth_e = switch bandwidth {
case .highest: NDIlib_recv_bandwidth_highest
case .lowest: NDIlib_recv_bandwidth_lowest
case .audioOnly: NDIlib_recv_bandwidth_audio_only
}
let created: NDIlib_recv_instance_t? = source.ndiName.withCString { name in
receiverName.withCString { recvName in
var ndiSource = NDIlib_source_t()
@ -76,7 +69,7 @@ public final class NDIReceiver: @unchecked Sendable {
var settings = NDIlib_recv_create_v3_t(
source_to_connect_to: ndiSource,
color_format: NDIlib_recv_color_format_UYVY_BGRA,
bandwidth: ndiBandwidth,
bandwidth: bandwidth == .audioOnly ? NDIlib_recv_bandwidth_audio_only : NDIlib_recv_bandwidth_highest,
allow_video_fields: false,
p_ndi_recv_name: recvName
)

@ -1,175 +0,0 @@
import CoreVideo
import Foundation
import Testing
@testable import Intercom
/// Real browser against the real server: headless Chrome opens the phone page, connects and decodes.
/// Opt-in: `MUSUBI_E2E=1 MUSUBI_E2E_INTERFACE=en0 swift test --filter BrowserEndToEnd`.
/// With `MUSUBI_E2E_URL=http://…:8080/` the browser joins a running Musubi instead (connection only).
@Suite(.enabled(if: ProcessInfo.processInfo.environment["MUSUBI_E2E"] == "1"))
@MainActor struct BrowserEndToEndTests {
static let chrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
@Test(.timeLimit(.minutes(1))) func chromeReceivesTalkbackAndPreview() async throws {
let environment = ProcessInfo.processInfo.environment
let external = environment["MUSUBI_E2E_URL"].flatMap(URL.init(string:))
let server = IntercomServer()
if external == nil { await server.start(interfaceName: environment["MUSUBI_E2E_INTERFACE"] ?? "en0") }
defer { server.stop() }
let url = try #require(external ?? server.url, "Intercom did not start: \(server.error ?? "-")")
let feeder = Feeder(input: server.input)
if external == nil { feeder.start() }
defer { feeder.stop() }
let profile = FileManager.default.temporaryDirectory.appendingPathComponent("musubi-chrome-\(UUID().uuidString)")
let port = Int.random(in: 20_000...29_999)
let chrome = Process()
chrome.executableURL = URL(fileURLWithPath: Self.chrome)
chrome.arguments = ["--headless=new", "--remote-debugging-port=\(port)", "--user-data-dir=\(profile.path)",
"--autoplay-policy=no-user-gesture-required", "--no-first-run", "--no-default-browser-check",
url.absoluteString]
chrome.standardError = FileHandle.nullDevice
try chrome.run()
defer {
chrome.terminate()
try? FileManager.default.removeItem(at: profile)
}
let devtools = try await DevTools.connect(port: port)
for _ in 0..<50 {
if try await devtools.evaluate("!!document.querySelector('#join')") == "1" { break }
try await Task.sleep(for: .milliseconds(200))
}
_ = try await devtools.evaluate("""
document.querySelector('#name').value = 'Testgerät';
document.querySelector('#role').value = 'Kamera 1';
document.querySelector('#join').requestSubmit();
true
""")
var last = ""
for _ in 0..<40 {
try await Task.sleep(for: .milliseconds(500))
last = try await devtools.evaluate("""
(async () => {
if (!pc) return 'none,0,0,' + location.href + ' ' + document.querySelector('#status')?.textContent;
let video = 0, audio = 0;
(await pc.getStats()).forEach(r => {
if (r.type === 'inbound-rtp' && r.kind === 'video') video = r.framesDecoded || 0;
if (r.type === 'inbound-rtp' && r.kind === 'audio') audio = r.packetsReceived || 0;
});
return pc.connectionState + ',' + video + ',' + audio + ',' + document.querySelector('#status').textContent;
})()
""")
let parts = last.split(separator: ",")
if parts.first == "connected", external != nil || (Int(parts[1]) ?? 0 >= 20 && Int(parts[2]) ?? 0 >= 50) { break }
}
let delay = try await devtools.evaluate("""
(async () => {
let ms = -1;
(await pc.getStats()).forEach(r => {
if (r.type === 'inbound-rtp' && r.kind === 'video' && r.jitterBufferEmittedCount)
ms = Math.round(1000 * r.jitterBufferDelay / r.jitterBufferEmittedCount);
});
return ms;
})()
""")
print("Browser: \(last) · video jitter buffer \(delay) ms")
let parts = last.split(separator: ",").prefix(3)
#expect(parts.first == "connected", "state: \(last)")
guard external == nil else { return }
#expect(Int(parts.dropFirst().first ?? "") ?? 0 >= 20, "video frames decoded: \(last)")
#expect(Int(parts.last ?? "") ?? 0 >= 50, "audio packets received: \(last)")
#expect(server.participants.first?.name == "Testgerät")
#expect(server.participants.first?.connected == true)
}
}
/// Pushes a moving test picture (25 fps) and a 440 Hz tone (20 ms blocks at 44.1 kHz to exercise resampling).
private final class Feeder: @unchecked Sendable {
private let input: IntercomInput
private let queue = DispatchQueue(label: "e2e.feeder")
private var timer: DispatchSourceTimer?
private var tick = 0
private var phase = 0.0
init(input: IntercomInput) { self.input = input }
func start() {
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now(), repeating: .milliseconds(20))
timer.setEventHandler { [weak self] in self?.step() }
timer.resume()
self.timer = timer
}
func stop() { timer?.cancel() }
private func step() {
tick += 1
var samples = [Float](repeating: 0, count: 882)
for i in samples.indices {
samples[i] = Float(sin(phase) * 0.2)
phase += 2 * .pi * 440 / 44_100
}
samples.withUnsafeBufferPointer { input.pushTalkback($0, sampleRate: 44_100) }
guard tick.isMultiple(of: 2) else { return }
var buffer: CVPixelBuffer?
CVPixelBufferCreate(nil, 1280, 720, kCVPixelFormatType_32BGRA, [kCVPixelBufferIOSurfacePropertiesKey: [:]] as CFDictionary, &buffer)
guard let buffer else { return }
CVPixelBufferLockBaseAddress(buffer, [])
let base = CVPixelBufferGetBaseAddress(buffer)!.assumingMemoryBound(to: UInt8.self)
let stride = CVPixelBufferGetBytesPerRow(buffer)
for y in 0..<720 {
for x in 0..<1280 {
let p = base + y * stride + x * 4
p[0] = UInt8((x + tick * 8) & 0xFF)
p[1] = UInt8(y & 0xFF)
p[2] = UInt8((tick * 4) & 0xFF)
p[3] = 255
}
}
CVPixelBufferUnlockBaseAddress(buffer, [])
input.pushVideo(buffer)
}
}
/// Just enough of the Chrome DevTools Protocol: Runtime.evaluate on the first page.
private final class DevTools: @unchecked Sendable {
private let socket: URLSessionWebSocketTask
private var nextID = 0
private init(socket: URLSessionWebSocketTask) { self.socket = socket }
static func connect(port: Int) async throws -> DevTools {
struct Target: Decodable { let type: String; let webSocketDebuggerUrl: String? }
for _ in 0..<50 {
if let (data, _) = try? await URLSession.shared.data(from: URL(string: "http://127.0.0.1:\(port)/json")!),
let targets = try? JSONDecoder().decode([Target].self, from: data),
let page = targets.first(where: { $0.type == "page" }), let ws = page.webSocketDebuggerUrl {
let socket = URLSession.shared.webSocketTask(with: URL(string: ws)!)
socket.resume()
return DevTools(socket: socket)
}
try await Task.sleep(for: .milliseconds(200))
}
throw URLError(.cannotConnectToHost)
}
func evaluate(_ expression: String) async throws -> String {
nextID += 1
let id = nextID
let message: [String: Any] = ["id": id, "method": "Runtime.evaluate",
"params": ["expression": expression, "awaitPromise": true, "returnByValue": true]]
let data = try JSONSerialization.data(withJSONObject: message)
try await socket.send(.string(String(decoding: data, as: UTF8.self)))
while true {
guard case .string(let text) = try await socket.receive(),
let json = try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any],
json["id"] as? Int == id else { continue }
let result = (json["result"] as? [String: Any])?["result"] as? [String: Any]
return result?["value"].map { "\($0)" } ?? ""
}
}
}

@ -1,118 +0,0 @@
import CryptoKit
import Foundation
import Testing
@testable import Intercom
@Suite struct STUNTests {
// RFC 5769 2.1: sample request, password "VOkJxbRl1RmTxUk/WvJxBt".
let request = [UInt8](hex: """
000100582112a442b7e7a701bc34d686fa87dfae
802200105354554e207465737420636c69656e74
002400046e0001ff
80290008932ff9b151263b36
000600096576746a3a68367659202020
000800149aeaa70cbfd8cb56781ef2b5b2d3f249c1b571a2
80280004e57a3bcf
""")!
let password = Array("VOkJxbRl1RmTxUk/WvJxBt".utf8)
@Test func parsesAndVerifiesRFC5769Request() throws {
let message = try #require(STUN.parse(request))
#expect(message.type == STUN.bindingRequest)
#expect(message.username == "evtj:h6vY")
#expect(STUN.verify(request, message: message, key: password))
#expect(!STUN.verify(request, message: message, key: Array("wrong".utf8)))
}
@Test func rejectsTamperedRequest() throws {
var tampered = request
tampered[45] ^= 1
let message = try #require(STUN.parse(tampered))
#expect(!STUN.verify(tampered, message: message, key: password))
}
@Test func buildsVerifiableResponse() throws {
let tid = Array(request[8..<20])
let response = STUN.bindingResponse(transactionID: tid, address: [192, 0, 2, 1], port: 32853, key: password)
let message = try #require(STUN.parse(response))
#expect(message.type == STUN.bindingSuccess)
#expect(message.transactionID == tid)
#expect(STUN.verify(response, message: message, key: password))
// RFC 5769 2.2: XOR-MAPPED-ADDRESS for 192.0.2.1:32853.
#expect(message.value(.xorMappedAddress) == [UInt8](hex: "0001a147e112a643"))
}
@Test func demultiplexesByFirstByte() {
#expect(STUN.looksLikeSTUN(request))
#expect(!STUN.looksLikeSTUN([22, 0xFE, 0xFD] + [UInt8](repeating: 0, count: 30)))
}
}
@Suite struct KeyDerivationTests {
@Test func tlsPRFMatchesOpenSSL() {
let out = TLSPRF.compute(secret: [UInt8](hex: "9bbe436ba940f017b17652849a71db35")!, label: "test label",
seed: [UInt8](hex: "a0ba9f936cda311827a6f796ffd5198c")!, length: 100)
#expect(out.hex == """
e3f229ba727be17b8d122620557cd453c2aab21d07c3d495329b52d4e61edb5a6b301791e90d35c9c9a46b4e14baf9af\
0fa022f7077def17abfd3797c0564bab4fbc91666e9def9b97fce34f796789baa48082d122ee42c5a72e5a5110fff701\
87347b66
""")
}
// RFC 3711 B.2
@Test func aesCounterKeystream() {
let block = AESBlock(key: [UInt8](hex: "2B7E151628AED2A6ABF7158809CF4F3C")!)
let stream = block.keystream(iv: [UInt8](hex: "F0F1F2F3F4F5F6F7F8F9FAFBFCFD0000")!, count: 48)
#expect(stream.hex == "e03ead0935c95e80e166b16dd92b4eb4d23513162b02d0f72a43a2fe4a5f97ab41e95b3bb0a2e8dd477901e4fca894c0")
}
// RFC 3711 B.3
@Test func srtpKeyDerivation() {
let key = [UInt8](hex: "E1F97A0D3E018BE0D64FA32C06DE4139")!
let salt = [UInt8](hex: "0EC675AD498AFEEBB6960B3AABE6")!
#expect(SRTPContext.deriveKey(masterKey: key, masterSalt: salt, label: 0, length: 16).hex == "c61e7a93744f39ee10734afe3ff7a087")
#expect(SRTPContext.deriveKey(masterKey: key, masterSalt: salt, label: 2, length: 14).hex == "30cbbc08863d8c85d49db34a9ae1")
#expect(SRTPContext.deriveKey(masterKey: key, masterSalt: salt, label: 1, length: 20).hex == "cebe321f6ff7716b6fd4ab49af256a156d38baa4")
}
@Test func srtcpRoundTrips() throws {
let sender = SRTPContext(masterKey: Array(repeating: 7, count: 16), masterSalt: Array(repeating: 9, count: 14))
let receiver = SRTPContext(masterKey: Array(repeating: 7, count: 16), masterSalt: Array(repeating: 9, count: 14))
// PLI: V=2, FMT=1, PT=206, length 2, sender SSRC, media SSRC
let pli: [UInt8] = [0x81, 206, 0, 2, 1, 2, 3, 4, 5, 6, 7, 8]
let protected = sender.protectRTCP(pli)
#expect(protected != pli)
#expect(receiver.unprotectRTCP(protected) == pli)
var broken = protected
broken[9] ^= 1
#expect(receiver.unprotectRTCP(broken) == nil)
}
@Test func srtpEncryptsPayloadOnlyAndAppendsTag() {
let context = SRTPContext(masterKey: Array(repeating: 1, count: 16), masterSalt: Array(repeating: 2, count: 14))
let packet: [UInt8] = [0x80, 111, 0, 1, 0, 0, 0, 0, 0xAA, 0xBB, 0xCC, 0xDD] + Array(repeating: 0x55, count: 20)
let out = context.protectRTP(packet)
#expect(out.count == packet.count + 10)
#expect(Array(out.prefix(12)) == Array(packet.prefix(12)))
#expect(Array(out[12..<32]) != Array(packet[12...]))
}
}
@Suite struct CertificateTests {
@Test func selfSignedCertificateCarriesKeyAndValidSignature() throws {
let cert = try DTLSCertificate()
let spki = try #require(DER.subjectPublicKeyInfo(certificate: cert.der))
let publicKey = try P256.Signing.PublicKey(derRepresentation: spki)
#expect(publicKey.rawRepresentation == cert.privateKey.publicKey.rawRepresentation)
// Signature over TBSCertificate verifies with the embedded key.
let outer = try #require(DER.elements(cert.der, in: 0..<cert.der.count)?.first)
let parts = try #require(DER.elements(cert.der, in: outer.contentRange))
#expect(parts.count == 3)
let tbs = Array(cert.der[parts[0].range])
let signatureBits = Array(cert.der[parts[2].contentRange].dropFirst())
let signature = try P256.Signing.ECDSASignature(derRepresentation: signatureBits)
#expect(publicKey.isValidSignature(signature, for: tbs))
#expect(cert.fingerprint.count == 32 * 3 - 1)
}
}

@ -1,114 +0,0 @@
import CryptoKit
import Darwin
import Foundation
import Testing
@testable import Intercom
/// Handshake against `openssl s_client` (development tool only, not shipped).
/// Skipped when Homebrew OpenSSL is missing.
private let opensslPath = "/opt/homebrew/bin/openssl"
@Suite(.enabled(if: FileManager.default.isExecutableFile(atPath: opensslPath)))
struct DTLSInteropTests {
static let openssl = opensslPath
@Test(.timeLimit(.minutes(1)), arguments: [true, false])
func handshakeChecksFingerprintAndExportsSameSRTPKeys(fingerprintMatches: Bool) throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("musubi-dtls-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let keyPath = dir.appendingPathComponent("client.key").path
let certPath = dir.appendingPathComponent("client.pem").path
try run([Self.openssl, "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes",
"-keyout", keyPath, "-out", certPath, "-subj", "/CN=browser", "-days", "1"])
let der = try run([Self.openssl, "x509", "-in", certPath, "-outform", "DER"])
let expected = fingerprintMatches ? DTLSCertificate.fingerprint(Array(der)) : try DTLSCertificate().fingerprint
let server = DTLSServer(certificate: try DTLSCertificate(), expectedFingerprint: expected)
let socket = Darwin.socket(AF_INET, SOCK_DGRAM, 0)
defer { close(socket) }
var address = sockaddr_in()
address.sin_family = sa_family_t(AF_INET)
address.sin_addr.s_addr = inet_addr("127.0.0.1")
var length = socklen_t(MemoryLayout<sockaddr_in>.size)
_ = withUnsafePointer(to: &address) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(socket, $0, length) } }
_ = withUnsafeMutablePointer(to: &address) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(socket, $0, &length) } }
var timeout = timeval(tv_sec: 0, tv_usec: 200_000)
setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
let port = UInt16(bigEndian: address.sin_port)
let client = Process()
client.executableURL = URL(fileURLWithPath: Self.openssl)
client.arguments = ["s_client", "-dtls1_2", "-connect", "127.0.0.1:\(port)",
"-cipher", "ECDHE-ECDSA-AES128-GCM-SHA256", "-use_srtp", "SRTP_AES128_CM_SHA1_80",
"-cert", certPath, "-key", keyPath,
"-keymatexport", "EXTRACTOR-dtls_srtp", "-keymatexportlen", "60"]
let stdin = Pipe()
let stdout = Pipe()
client.standardInput = stdin
client.standardOutput = stdout
client.standardError = stdout
try client.run()
defer { client.terminate() }
let output = OutputCollector(stdout.fileHandleForReading)
var buffer = [UInt8](repeating: 0, count: 2048)
let deadline = Date().addingTimeInterval(fingerprintMatches ? 20 : 3)
while Date() < deadline, !output.text.contains("Keying material") {
if case .failed = server.state { break }
var peer = sockaddr_storage()
var peerLength = socklen_t(MemoryLayout<sockaddr_storage>.size)
let n = withUnsafeMutablePointer(to: &peer) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { recvfrom(socket, &buffer, buffer.count, 0, $0, &peerLength) }
}
guard n > 0 else { continue }
for reply in server.handle(Array(buffer[0..<n])) {
_ = withUnsafePointer(to: &peer) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { sendto(socket, reply, reply.count, 0, $0, peerLength) }
}
}
}
guard fingerprintMatches else {
#expect(server.state == .failed("Client certificate does not match the SDP fingerprint"))
#expect(server.keyMaterial == nil)
return
}
#expect(server.state == .established)
let text = output.text
let line = try #require(text.split(separator: "\n").first { $0.contains("Keying material:") })
let exported = String(line.split(separator: ":").last!).trimmingCharacters(in: .whitespaces).lowercased()
let keys = try #require(server.keyMaterial)
#expect(exported == (keys.clientKey + keys.serverKey + keys.clientSalt + keys.serverSalt).hex)
#expect(text.contains("SRTP Extension negotiated, profile=SRTP_AES128_CM_SHA1_80"))
}
@discardableResult
private func run(_ arguments: [String]) throws -> Data {
let process = Process()
process.executableURL = URL(fileURLWithPath: arguments[0])
process.arguments = Array(arguments.dropFirst())
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
try process.run()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return data
}
}
private final class OutputCollector: @unchecked Sendable {
private let lock = NSLock()
private var data = Data()
init(_ handle: FileHandle) {
handle.readabilityHandler = { [weak self] h in
let chunk = h.availableData
self?.lock.withLock { self?.data.append(chunk) }
}
}
var text: String { lock.withLock { String(decoding: data, as: UTF8.self) } }
}

@ -1,72 +0,0 @@
import CoreVideo
import Foundation
import Testing
@testable import Intercom
@Suite struct MediaTests {
@Test func opusEncodesTwentyMillisecondPackets() throws {
let encoder = try #require(TalkbackEncoder())
let tone = (0..<4800).map { Float(sin(Double($0) * 2 * .pi * 440 / 48_000) * 0.3) }
let packets = tone.withUnsafeBufferPointer { encoder.append($0) }
#expect(packets.count == 5)
#expect(packets.allSatisfy { !$0.0.isEmpty && $0.0.count < 400 })
let steps = zip(packets.dropFirst(), packets).map { $0.1 &- $1.1 }
#expect(steps.allSatisfy { $0 == 960 })
}
@Test(.timeLimit(.minutes(1))) func previewStartsWithParameterSetsAndKeyFrame() async throws {
let encoder = PreviewEncoder(width: 320, height: 180)
let received = Received()
encoder.onAccessUnit = { nalUnits, _ in received.add(nalUnits) }
var buffer: CVPixelBuffer?
CVPixelBufferCreate(nil, 1280, 720, kCVPixelFormatType_32BGRA,
[kCVPixelBufferIOSurfacePropertiesKey: [:]] as CFDictionary, &buffer)
let frame = try #require(buffer)
for _ in 0..<20 where received.first == nil {
encoder.encode(frame)
try await Task.sleep(for: .milliseconds(50))
}
let nalUnits = try #require(received.first)
let types = nalUnits.map { $0[0] & 0x1F }
#expect(types.prefix(2) == [7, 8]) // SPS, PPS
#expect(types.contains(5)) // IDR
// Constrained baseline: profile_idc 66, constraint_set1 flag.
#expect(nalUnits[0][1] == 66)
#expect(nalUnits[0][2] & 0x40 != 0)
}
private final class Received: @unchecked Sendable {
private let lock = NSLock()
private var units: [[[UInt8]]] = []
func add(_ nalUnits: [[UInt8]]) { lock.withLock { units.append(nalUnits) } }
var first: [[UInt8]]? { lock.withLock { units.first } }
}
}
@Suite struct HTTPTests {
@Test func parsesRequestAcrossChunks() throws {
var parser = HTTPRequestParser()
let raw = Array("POST /api/offer?x=1 HTTP/1.1\r\nHost: musubi\r\nContent-Length: 11\r\n\r\n{\"a\":\"bcd\"}".utf8)
#expect(try parser.append(Array(raw[0..<30])) == nil)
let request = try #require(try parser.append(Array(raw[30...])))
#expect(request.method == "POST")
#expect(request.path == "/api/offer")
#expect(request.headers["host"] == "musubi")
#expect(request.body == Array("{\"a\":\"bcd\"}".utf8))
}
@Test func rejectsOversizedBody() {
var parser = HTTPRequestParser()
#expect(throws: HTTPRequestParser.ParseError.self) {
try parser.append(Array("POST / HTTP/1.1\r\nContent-Length: 999999\r\n\r\n".utf8))
}
}
@Test func serializesResponse() {
let text = String(decoding: HTTPResponse.text(404, "Nicht gefunden.").serialized, as: UTF8.self)
#expect(text.hasPrefix("HTTP/1.1 404 Not Found\r\n"))
#expect(text.contains("Content-Length: 15\r\n"))
#expect(text.hasSuffix("\r\n\r\nNicht gefunden."))
}
}

@ -1,140 +0,0 @@
import Foundation
import Testing
@testable import Intercom
@Suite struct SDPTests {
// Shortened recvonly offer as Chrome/Safari produce it for two transceivers.
let offer = """
v=0\r
o=- 4611731400430051336 2 IN IP4 127.0.0.1\r
s=-\r
t=0 0\r
a=group:BUNDLE 0 1\r
a=extmap-allow-mixed\r
a=msid-semantic: WMS\r
m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8\r
c=IN IP4 0.0.0.0\r
a=rtcp:9 IN IP4 0.0.0.0\r
a=ice-ufrag:Ab3x\r
a=ice-pwd:k9Qmw0Rr1qY2yT7cXv8sJp4u\r
a=ice-options:trickle\r
a=fingerprint:sha-256 4c:1b:00:8f:6d:3f:9b:39:72:20:4e:69:ef:4a:16:1c:c2:39:d9:e4:3f:1e:f1:5b:3b:91:85:73:a0:cd:7a:4e\r
a=setup:actpass\r
a=mid:0\r
a=recvonly\r
a=rtcp-mux\r
a=rtpmap:111 opus/48000/2\r
a=fmtp:111 minptime=10;useinbandfec=1\r
a=rtpmap:63 red/48000/2\r
a=rtpmap:9 G722/8000\r
m=video 9 UDP/TLS/RTP/SAVPF 96 97 102 103 127 125\r
c=IN IP4 0.0.0.0\r
a=ice-ufrag:Ab3x\r
a=ice-pwd:k9Qmw0Rr1qY2yT7cXv8sJp4u\r
a=fingerprint:sha-256 4c:1b:00:8f:6d:3f:9b:39:72:20:4e:69:ef:4a:16:1c:c2:39:d9:e4:3f:1e:f1:5b:3b:91:85:73:a0:cd:7a:4e\r
a=setup:actpass\r
a=mid:1\r
a=recvonly\r
a=rtcp-mux\r
a=rtpmap:96 VP8/90000\r
a=rtpmap:97 rtx/90000\r
a=fmtp:97 apt=96\r
a=rtpmap:102 H264/90000\r
a=fmtp:102 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f\r
a=rtpmap:103 H264/90000\r
a=fmtp:103 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f\r
a=rtpmap:127 H264/90000\r
a=fmtp:127 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\r
a=rtpmap:125 H264/90000\r
a=fmtp:125 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640c1f\r
"""
@Test func parsesOffer() throws {
let parsed = try SDPOffer(offer)
#expect(parsed.iceUfrag == "Ab3x")
#expect(parsed.icePwd == "k9Qmw0Rr1qY2yT7cXv8sJp4u")
#expect(parsed.fingerprint.hasPrefix("4C:1B:00:8F"))
#expect(parsed.media.map(\.kind) == ["audio", "video"])
#expect(parsed.media.map(\.mid) == ["0", "1"])
#expect(parsed.opusPayloadType(in: parsed.media[0]) == 111)
// Constrained baseline with packetization-mode=1 wins.
#expect(parsed.h264PayloadType(in: parsed.media[1]) == 127)
}
@Test func missingCredentialsFail() {
#expect(throws: SDPOffer.ParseError.missing("ice-ufrag")) { try SDPOffer("v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n") }
}
@Test func answerIsIceLitePassiveSendOnly() throws {
let answer = SDPAnswer(iceUfrag: "musu", icePwd: "0123456789abcdefghijklmn", fingerprint: "AA:BB", address: "192.168.1.20",
port: 50000, tracks: [.init(mid: "0", kind: "audio", payloadType: 111, ssrc: 11),
.init(mid: "1", kind: "video", payloadType: 127, ssrc: 22)]).text
#expect(answer.contains("a=ice-lite\r\n"))
#expect(answer.contains("a=group:BUNDLE 0 1\r\n"))
#expect(answer.contains("m=audio 50000 UDP/TLS/RTP/SAVPF 111\r\n"))
#expect(answer.contains("m=video 50000 UDP/TLS/RTP/SAVPF 127\r\n"))
#expect(answer.contains("a=setup:passive\r\n"))
#expect(answer.contains("a=candidate:1 1 udp 2130706431 192.168.1.20 50000 typ host\r\n"))
#expect(!answer.contains("a=recvonly"))
}
@Test func rejectedTrackGetsPortZero() {
let answer = SDPAnswer(iceUfrag: "u", icePwd: "p", fingerprint: "F", address: "10.0.0.1", port: 1,
tracks: [.init(mid: "0", kind: "audio", payloadType: 111, ssrc: 1),
.init(mid: "1", kind: "video", payloadType: nil, ssrc: 2)]).text
#expect(answer.contains("a=group:BUNDLE 0\r\n"))
#expect(answer.contains("m=video 0 UDP/TLS/RTP/SAVPF 0\r\n"))
}
}
@Suite struct RTPTests {
@Test func headerFields() {
var stream = RTPStream(ssrc: 0x0102_0304, payloadType: 111)
let first = stream.packet(timestamp: 960, marker: true, payload: [9, 9])
let second = stream.packet(timestamp: 1920, marker: false, payload: [])
#expect(first[0] == 0x80)
#expect(first[1] == 0x80 | 111)
#expect(Array(first[4..<12]) == [0, 0, 3, 0xC0, 1, 2, 3, 4])
#expect(Array(first[12...]) == [9, 9])
let seq1 = UInt16(first[2]) << 8 | UInt16(first[3])
let seq2 = UInt16(second[2]) << 8 | UInt16(second[3])
#expect(seq2 == seq1 &+ 1)
}
@Test func aggregatesParameterSetsAndKeepsSmallNALs() {
let sps: [UInt8] = [0x67, 1, 2, 3]
let pps: [UInt8] = [0x68, 4]
let idr: [UInt8] = [0x65] + Array(repeating: 7, count: 100)
let payloads = H264Packetizer.payloads(for: [sps, pps, idr])
#expect(payloads.count == 2)
#expect(payloads[0] == [0x78, 0, 4] + sps + [0, 2] + pps) // STAP-A with NRI 3
#expect(payloads[1] == idr)
}
@Test func fragmentsLargeNALAndReassembles() {
let nal: [UInt8] = [0x65] + (0..<5000).map { UInt8($0 & 0xFF) }
let payloads = H264Packetizer.payloads(for: [nal])
#expect(payloads.count > 1)
#expect(payloads.allSatisfy { $0.count <= H264Packetizer.maxPayload })
#expect(payloads.first?[1] == 0x85) // start bit + type 5
#expect(payloads.last?[1] == 0x45) // end bit + type 5
#expect(payloads.allSatisfy { $0[0] == 0x7C }) // FU-A, NRI 3
let rebuilt = [UInt8(0x65)] + payloads.flatMap { $0.dropFirst(2) }
#expect(rebuilt == nal)
}
@Test func splitsAVCC() {
let avcc: [UInt8] = [0, 0, 0, 2, 0x67, 1, 0, 0, 0, 3, 0x65, 2, 3]
#expect(H264Packetizer.nalUnits(avcc: avcc) == [[0x67, 1], [0x65, 2, 3]])
}
@Test func detectsKeyFrameRequests() {
let receiverReport: [UInt8] = [0x80, 201, 0, 1, 0, 0, 0, 1]
let pli: [UInt8] = [0x81, 206, 0, 2, 0, 0, 0, 1, 0, 0, 0, 2]
#expect(RTCPFeedback.requestsKeyFrame(receiverReport + pli))
#expect(!RTCPFeedback.requestsKeyFrame(receiverReport))
#expect(RTCPFeedback.isRTCP(pli))
#expect(!RTCPFeedback.isRTCP([0x80, 111, 0, 1, 0, 0, 0, 0]))
}
}

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:c2pa="http://c2pa.org/manifest" version="1.1" style="display: block;" viewBox="0 0 2048 2048" width="2048" height="2048" preserveAspectRatio="none">
<path transform="translate(0,0)" fill="rgb(21,14,20)" d="M 0 0 L 2048 0 L 2048 2048 L 0 2048 L 0 0 z"/>
<path transform="translate(0,0)" fill="rgb(152,138,236)" d="M 816.883 793.441 C 836.331 792.928 842.893 814.805 827.674 824.609 C 821.795 828.396 802.719 828.192 794.535 829.26 C 747.359 835.526 703.783 857.857 671.146 892.493 C 593.958 974.529 602.228 1094.1 684.009 1169.56 C 722.859 1204.91 774.146 1223.39 826.612 1220.96 C 909.412 1217.03 978.854 1156.86 998.811 1077.07 C 1005.57 1053.72 1004.2 1030.03 1005.65 1006.21 C 1012.38 895.977 1110.08 796.628 1221.18 793.369 C 1347.67 789.658 1459.99 890.872 1463.84 1018.29 C 1465.12 1078.86 1442.3 1137.45 1400.38 1181.19 C 1354.25 1230.05 1297.68 1253.45 1231.08 1255.11 C 1226.53 1255.09 1218.71 1254.3 1216.33 1250.85 C 1197.56 1223.69 1228.22 1220.06 1246.45 1220.12 C 1257.69 1220.15 1277.99 1215.68 1286.06 1213.2 C 1322.25 1202.25 1354.75 1181.6 1380.02 1153.48 C 1414.42 1115.46 1432.32 1065.33 1429.77 1014.12 C 1426.49 962.776 1403.08 914.801 1364.63 880.617 C 1325.43 845.791 1273.19 825.084 1220.32 827.93 C 1136.53 832.439 1064.7 896.598 1044.53 977.026 C 1038 1003.09 1039.91 1019.01 1038.24 1044.68 C 1034.6 1095.04 1014.64 1142.83 981.39 1180.83 C 941.838 1225.15 889.095 1251.91 829.601 1255.08 C 766.827 1258.15 705.47 1235.76 659.427 1192.98 C 614.336 1151.69 587.291 1094.32 584.133 1033.26 C 579.689 934.699 641.535 848.818 730.544 811.116 C 759.386 798.9 785.97 794.61 816.883 793.441 z"/>
<path transform="translate(0,0)" fill="rgb(152,138,236)" d="M 774.016 930.277 C 779.771 929.957 788.048 930.075 793.097 929.918 C 861.696 927.783 919.597 923.65 913.114 1013.5 C 912.011 1028.8 915.044 1052.43 911.814 1070.1 C 910.404 1078.06 907.417 1085.65 903.027 1092.43 C 893.041 1107.74 879.319 1115.31 861.764 1118.74 C 849.029 1119.44 832.726 1119.02 819.752 1118.92 C 806.218 1118.82 784.939 1120.05 772.274 1117.77 C 764.504 1116.4 757.066 1113.56 750.354 1109.41 C 730.479 1097.07 721.18 1073.56 722.767 1050.77 C 724.271 1029.18 720.321 1000.67 723.976 979.945 C 725.47 971.465 728.67 963.376 733.384 956.17 C 743.314 941.015 756.809 933.926 774.016 930.277 z"/>
<path transform="translate(0,0)" fill="rgb(152,138,236)" d="M 1188.46 930.235 C 1202.06 929.592 1216.18 929.829 1229.85 930.048 C 1254.93 930.449 1279.33 925.792 1300.94 941.525 C 1328.11 961.31 1325.64 990.987 1325.12 1020.9 C 1324.85 1036.51 1326.71 1056.25 1323.72 1071 C 1321.91 1079.92 1318.15 1088.33 1312.7 1095.63 C 1302.32 1109.61 1289.16 1116.12 1272.36 1118.7 C 1259.68 1119.26 1245.27 1119.06 1232.49 1118.82 C 1207.13 1118.33 1183.86 1123.11 1161.64 1108.46 C 1149.79 1100.65 1138.49 1083.76 1137.28 1070.3 C 1135.67 1052.41 1134.4 993.239 1137.4 978.006 C 1139.06 969.412 1142.54 961.272 1147.6 954.131 C 1157.97 939.44 1171.5 933.174 1188.46 930.235 z"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

@ -1,14 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<rect x="100" y="100" width="824" height="824" rx="185" ry="185" fill="#14151B"/>
<rect x="100.5" y="100.5" width="823" height="823" rx="184.5" ry="184.5" fill="none" stroke="#2B2E3A" stroke-width="1"/>
<g transform="translate(512,512) scale(0.5) translate(-1012,-1030)">
<path transform="translate(0,0)" fill="#8A99F2" stroke="#8A99F2" stroke-width="30" d="M 1423.74 590.412 C 1432.62 589.023 1450.05 589.703 1458.7 591.042 C 1634.22 618.221 1709.94 844.291 1558.74 955.415 C 1481.66 1012.07 1404.89 1013.82 1314.17 1013.78 L 1210.78 1013.83 L 1133.44 1013.84 C 1121.91 1013.85 1107.96 1014.5 1096.56 1013.23 C 1081.06 1012.17 1082.63 989.201 1094.18 987.001 C 1108.75 984.229 1133.9 985.919 1148.61 985.933 L 1284.72 985.994 C 1366.44 985.983 1441.87 990.229 1516.62 948.955 C 1562.84 923.436 1593.54 886.616 1607.85 835.751 C 1610.2 824.681 1611.48 813.412 1611.68 802.098 C 1613.59 677.919 1482.24 577.854 1365.53 634.967 C 1266.77 683.296 1216.83 772.592 1155.59 859.106 C 1129.05 896.605 1095.83 938.188 1063.36 970.234 C 1055.93 961.489 1052.54 957.898 1042.28 952.581 C 1070.1 923.503 1096 892.655 1119.83 860.23 C 1196.89 754.431 1277.38 604.726 1423.74 590.412 z"/>
<path transform="translate(0,0)" fill="#8A99F2" stroke="#8A99F2" stroke-width="30" d="M 688.555 986.427 C 699.797 985.916 710.994 985.993 722.242 985.953 C 793.94 985.699 865.666 986.4 937.349 985.8 C 960.046 985.61 961.042 1013.84 937.24 1013.93 C 917.864 1014 898.302 1013.89 878.908 1013.87 L 756.07 1013.82 C 724.783 1013.85 694.909 1013.32 663.384 1016.58 C 551.377 1028.29 424.332 1104.62 437.972 1232.53 C 446.944 1316.68 533.218 1387.21 617.132 1374.91 C 739.683 1356.93 811.14 1217.12 880.269 1128.49 C 905.69 1094.06 943.396 1057.89 975.584 1030.09 C 982.157 1039.08 987.79 1044.06 997.096 1049.86 C 959.296 1081.43 929.946 1109.24 899.678 1148.95 C 862.227 1198.09 832.609 1247.81 791.475 1294.64 C 742.656 1350.23 688.353 1398.59 610.501 1403.8 C 559.982 1407.26 513.426 1389.25 475.323 1356.66 C 392.53 1285.84 386.017 1163.53 459.902 1083.5 C 519.97 1018.43 602.311 991.38 688.555 986.427 z"/>
<path transform="translate(0,0)" fill="#8A99F2" stroke="#8A99F2" stroke-width="30" d="M 1182.1 1157.07 C 1187.62 1156.68 1192.49 1156.82 1196.13 1161.71 C 1205.95 1174.92 1214.54 1189.04 1224.01 1202.56 C 1321.19 1341.27 1458.66 1431.32 1629.88 1441.28 C 1642.83 1449.27 1644.01 1461.13 1630.39 1469.16 C 1614.68 1470.07 1590.44 1466.52 1574.49 1463.98 C 1418.02 1439.05 1294.41 1349.62 1203.48 1222.17 C 1196.29 1212.08 1170.23 1179.2 1172.28 1168.11 C 1173.29 1162.63 1177.7 1159.76 1182.1 1157.07 z"/>
<path transform="translate(0,0)" fill="#8A99F2" stroke="#8A99F2" stroke-width="30" d="M 909.161 837.373 C 920.185 835.718 959.367 836.901 972.694 836.91 L 1110.41 836.776 C 1105.84 844.226 1095.74 857.338 1090.27 864.859 C 1041.28 865.412 992.092 864.309 943.083 864.776 C 930.709 864.894 917.32 864.166 905.151 866.234 C 870.911 872.959 843.482 896.579 834.402 930.819 C 831.107 943.243 831.77 956.003 831.844 968.751 L 803.829 969.043 C 803.634 934.424 805.09 910.614 827.705 881.836 C 848.584 855.265 876.06 841.427 909.161 837.373 z"/>
<path transform="translate(0,0)" fill="#8A99F2" stroke="#8A99F2" stroke-width="30" d="M 1208.49 1028.9 L 1236.66 1028.73 C 1236.34 1062.05 1225.15 1090.59 1201.18 1114.29 C 1187.47 1127.81 1170.39 1137.42 1151.72 1142.12 C 1129.76 1147.68 1093.64 1145.77 1069.78 1145.82 C 1033.76 1145.98 997.75 1145.94 961.738 1145.7 L 931.671 1145.89 C 939.5 1136.28 947.492 1127.32 955.715 1118.06 L 1068.41 1118.21 C 1086.69 1118.26 1125.38 1119.57 1141.24 1115.91 C 1156.14 1112.48 1169.81 1105.03 1180.76 1094.36 C 1199.92 1075.93 1208.12 1055.02 1208.49 1028.9 z"/>
<path transform="translate(0,0)" fill="#8A99F2" stroke="#8A99F2" stroke-width="30" d="M 1184.01 854.037 C 1191.06 857.38 1199.93 864.413 1205.35 870.048 C 1235.77 901.689 1236.76 928.198 1236.79 968.655 L 1220.94 968.783 L 1208.52 968.711 C 1208.78 929.3 1206.01 898.783 1167.71 877.341 L 1184.01 854.037 z"/>
<path transform="translate(0,0)" fill="#8A99F2" stroke="#8A99F2" stroke-width="30" d="M 803.098 1028.87 L 831.424 1028.84 C 830.78 1062.51 839.166 1090.57 871.548 1106.39 C 868.258 1111.04 865.082 1115.84 861.695 1120.41 C 860.243 1122.58 858.786 1124.51 857.288 1126.62 L 855.199 1129.6 C 815.501 1107.51 802.428 1072.29 803.098 1028.87 z"/>
<path transform="translate(0,0)" fill="#8A99F2" stroke="#8A99F2" stroke-width="30" d="M 1015.29 981.469 C 1026.2 978.746 1037.26 985.328 1040.07 996.212 C 1042.88 1007.1 1036.38 1018.21 1025.52 1021.1 C 1014.54 1024.03 1003.28 1017.44 1000.44 1006.44 C 997.597 995.433 1004.27 984.223 1015.29 981.469 z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.6 KiB

@ -1,14 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<rect x="100" y="100" width="824" height="824" rx="185" ry="185" fill="#14151B"/>
<rect x="100.5" y="100.5" width="823" height="823" rx="184.5" ry="184.5" fill="none" stroke="#2B2E3A" stroke-width="1"/>
<g transform="translate(512,512) scale(0.5) translate(-1012,-1030)">
<path transform="translate(0,0)" fill="#8A99F2" d="M 1423.74 590.412 C 1432.62 589.023 1450.05 589.703 1458.7 591.042 C 1634.22 618.221 1709.94 844.291 1558.74 955.415 C 1481.66 1012.07 1404.89 1013.82 1314.17 1013.78 L 1210.78 1013.83 L 1133.44 1013.84 C 1121.91 1013.85 1107.96 1014.5 1096.56 1013.23 C 1081.06 1012.17 1082.63 989.201 1094.18 987.001 C 1108.75 984.229 1133.9 985.919 1148.61 985.933 L 1284.72 985.994 C 1366.44 985.983 1441.87 990.229 1516.62 948.955 C 1562.84 923.436 1593.54 886.616 1607.85 835.751 C 1610.2 824.681 1611.48 813.412 1611.68 802.098 C 1613.59 677.919 1482.24 577.854 1365.53 634.967 C 1266.77 683.296 1216.83 772.592 1155.59 859.106 C 1129.05 896.605 1095.83 938.188 1063.36 970.234 C 1055.93 961.489 1052.54 957.898 1042.28 952.581 C 1070.1 923.503 1096 892.655 1119.83 860.23 C 1196.89 754.431 1277.38 604.726 1423.74 590.412 z"/>
<path transform="translate(0,0)" fill="#8A99F2" d="M 688.555 986.427 C 699.797 985.916 710.994 985.993 722.242 985.953 C 793.94 985.699 865.666 986.4 937.349 985.8 C 960.046 985.61 961.042 1013.84 937.24 1013.93 C 917.864 1014 898.302 1013.89 878.908 1013.87 L 756.07 1013.82 C 724.783 1013.85 694.909 1013.32 663.384 1016.58 C 551.377 1028.29 424.332 1104.62 437.972 1232.53 C 446.944 1316.68 533.218 1387.21 617.132 1374.91 C 739.683 1356.93 811.14 1217.12 880.269 1128.49 C 905.69 1094.06 943.396 1057.89 975.584 1030.09 C 982.157 1039.08 987.79 1044.06 997.096 1049.86 C 959.296 1081.43 929.946 1109.24 899.678 1148.95 C 862.227 1198.09 832.609 1247.81 791.475 1294.64 C 742.656 1350.23 688.353 1398.59 610.501 1403.8 C 559.982 1407.26 513.426 1389.25 475.323 1356.66 C 392.53 1285.84 386.017 1163.53 459.902 1083.5 C 519.97 1018.43 602.311 991.38 688.555 986.427 z"/>
<path transform="translate(0,0)" fill="#8A99F2" d="M 1182.1 1157.07 C 1187.62 1156.68 1192.49 1156.82 1196.13 1161.71 C 1205.95 1174.92 1214.54 1189.04 1224.01 1202.56 C 1321.19 1341.27 1458.66 1431.32 1629.88 1441.28 C 1642.83 1449.27 1644.01 1461.13 1630.39 1469.16 C 1614.68 1470.07 1590.44 1466.52 1574.49 1463.98 C 1418.02 1439.05 1294.41 1349.62 1203.48 1222.17 C 1196.29 1212.08 1170.23 1179.2 1172.28 1168.11 C 1173.29 1162.63 1177.7 1159.76 1182.1 1157.07 z"/>
<path transform="translate(0,0)" fill="#8A99F2" d="M 909.161 837.373 C 920.185 835.718 959.367 836.901 972.694 836.91 L 1110.41 836.776 C 1105.84 844.226 1095.74 857.338 1090.27 864.859 C 1041.28 865.412 992.092 864.309 943.083 864.776 C 930.709 864.894 917.32 864.166 905.151 866.234 C 870.911 872.959 843.482 896.579 834.402 930.819 C 831.107 943.243 831.77 956.003 831.844 968.751 L 803.829 969.043 C 803.634 934.424 805.09 910.614 827.705 881.836 C 848.584 855.265 876.06 841.427 909.161 837.373 z"/>
<path transform="translate(0,0)" fill="#8A99F2" d="M 1208.49 1028.9 L 1236.66 1028.73 C 1236.34 1062.05 1225.15 1090.59 1201.18 1114.29 C 1187.47 1127.81 1170.39 1137.42 1151.72 1142.12 C 1129.76 1147.68 1093.64 1145.77 1069.78 1145.82 C 1033.76 1145.98 997.75 1145.94 961.738 1145.7 L 931.671 1145.89 C 939.5 1136.28 947.492 1127.32 955.715 1118.06 L 1068.41 1118.21 C 1086.69 1118.26 1125.38 1119.57 1141.24 1115.91 C 1156.14 1112.48 1169.81 1105.03 1180.76 1094.36 C 1199.92 1075.93 1208.12 1055.02 1208.49 1028.9 z"/>
<path transform="translate(0,0)" fill="#8A99F2" d="M 1184.01 854.037 C 1191.06 857.38 1199.93 864.413 1205.35 870.048 C 1235.77 901.689 1236.76 928.198 1236.79 968.655 L 1220.94 968.783 L 1208.52 968.711 C 1208.78 929.3 1206.01 898.783 1167.71 877.341 L 1184.01 854.037 z"/>
<path transform="translate(0,0)" fill="#8A99F2" d="M 803.098 1028.87 L 831.424 1028.84 C 830.78 1062.51 839.166 1090.57 871.548 1106.39 C 868.258 1111.04 865.082 1115.84 861.695 1120.41 C 860.243 1122.58 858.786 1124.51 857.288 1126.62 L 855.199 1129.6 C 815.501 1107.51 802.428 1072.29 803.098 1028.87 z"/>
<path transform="translate(0,0)" fill="#8A99F2" d="M 1015.29 981.469 C 1026.2 978.746 1037.26 985.328 1040.07 996.212 C 1042.88 1007.1 1036.38 1018.21 1025.52 1021.1 C 1014.54 1024.03 1003.28 1017.44 1000.44 1006.44 C 997.597 995.433 1004.27 984.223 1015.29 981.469 z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="350 540 1330 980">
<path transform="translate(0,0)" fill="#8A99F2" d="M 1423.74 590.412 C 1432.62 589.023 1450.05 589.703 1458.7 591.042 C 1634.22 618.221 1709.94 844.291 1558.74 955.415 C 1481.66 1012.07 1404.89 1013.82 1314.17 1013.78 L 1210.78 1013.83 L 1133.44 1013.84 C 1121.91 1013.85 1107.96 1014.5 1096.56 1013.23 C 1081.06 1012.17 1082.63 989.201 1094.18 987.001 C 1108.75 984.229 1133.9 985.919 1148.61 985.933 L 1284.72 985.994 C 1366.44 985.983 1441.87 990.229 1516.62 948.955 C 1562.84 923.436 1593.54 886.616 1607.85 835.751 C 1610.2 824.681 1611.48 813.412 1611.68 802.098 C 1613.59 677.919 1482.24 577.854 1365.53 634.967 C 1266.77 683.296 1216.83 772.592 1155.59 859.106 C 1129.05 896.605 1095.83 938.188 1063.36 970.234 C 1055.93 961.489 1052.54 957.898 1042.28 952.581 C 1070.1 923.503 1096 892.655 1119.83 860.23 C 1196.89 754.431 1277.38 604.726 1423.74 590.412 z"/>
<path transform="translate(0,0)" fill="#8A99F2" d="M 688.555 986.427 C 699.797 985.916 710.994 985.993 722.242 985.953 C 793.94 985.699 865.666 986.4 937.349 985.8 C 960.046 985.61 961.042 1013.84 937.24 1013.93 C 917.864 1014 898.302 1013.89 878.908 1013.87 L 756.07 1013.82 C 724.783 1013.85 694.909 1013.32 663.384 1016.58 C 551.377 1028.29 424.332 1104.62 437.972 1232.53 C 446.944 1316.68 533.218 1387.21 617.132 1374.91 C 739.683 1356.93 811.14 1217.12 880.269 1128.49 C 905.69 1094.06 943.396 1057.89 975.584 1030.09 C 982.157 1039.08 987.79 1044.06 997.096 1049.86 C 959.296 1081.43 929.946 1109.24 899.678 1148.95 C 862.227 1198.09 832.609 1247.81 791.475 1294.64 C 742.656 1350.23 688.353 1398.59 610.501 1403.8 C 559.982 1407.26 513.426 1389.25 475.323 1356.66 C 392.53 1285.84 386.017 1163.53 459.902 1083.5 C 519.97 1018.43 602.311 991.38 688.555 986.427 z"/>
<path transform="translate(0,0)" fill="#8A99F2" d="M 1182.1 1157.07 C 1187.62 1156.68 1192.49 1156.82 1196.13 1161.71 C 1205.95 1174.92 1214.54 1189.04 1224.01 1202.56 C 1321.19 1341.27 1458.66 1431.32 1629.88 1441.28 C 1642.83 1449.27 1644.01 1461.13 1630.39 1469.16 C 1614.68 1470.07 1590.44 1466.52 1574.49 1463.98 C 1418.02 1439.05 1294.41 1349.62 1203.48 1222.17 C 1196.29 1212.08 1170.23 1179.2 1172.28 1168.11 C 1173.29 1162.63 1177.7 1159.76 1182.1 1157.07 z"/>
<path transform="translate(0,0)" fill="#8A99F2" d="M 909.161 837.373 C 920.185 835.718 959.367 836.901 972.694 836.91 L 1110.41 836.776 C 1105.84 844.226 1095.74 857.338 1090.27 864.859 C 1041.28 865.412 992.092 864.309 943.083 864.776 C 930.709 864.894 917.32 864.166 905.151 866.234 C 870.911 872.959 843.482 896.579 834.402 930.819 C 831.107 943.243 831.77 956.003 831.844 968.751 L 803.829 969.043 C 803.634 934.424 805.09 910.614 827.705 881.836 C 848.584 855.265 876.06 841.427 909.161 837.373 z"/>
<path transform="translate(0,0)" fill="#8A99F2" d="M 1208.49 1028.9 L 1236.66 1028.73 C 1236.34 1062.05 1225.15 1090.59 1201.18 1114.29 C 1187.47 1127.81 1170.39 1137.42 1151.72 1142.12 C 1129.76 1147.68 1093.64 1145.77 1069.78 1145.82 C 1033.76 1145.98 997.75 1145.94 961.738 1145.7 L 931.671 1145.89 C 939.5 1136.28 947.492 1127.32 955.715 1118.06 L 1068.41 1118.21 C 1086.69 1118.26 1125.38 1119.57 1141.24 1115.91 C 1156.14 1112.48 1169.81 1105.03 1180.76 1094.36 C 1199.92 1075.93 1208.12 1055.02 1208.49 1028.9 z"/>
<path transform="translate(0,0)" fill="#8A99F2" d="M 1184.01 854.037 C 1191.06 857.38 1199.93 864.413 1205.35 870.048 C 1235.77 901.689 1236.76 928.198 1236.79 968.655 L 1220.94 968.783 L 1208.52 968.711 C 1208.78 929.3 1206.01 898.783 1167.71 877.341 L 1184.01 854.037 z"/>
<path transform="translate(0,0)" fill="#8A99F2" d="M 803.098 1028.87 L 831.424 1028.84 C 830.78 1062.51 839.166 1090.57 871.548 1106.39 C 868.258 1111.04 865.082 1115.84 861.695 1120.41 C 860.243 1122.58 858.786 1124.51 857.288 1126.62 L 855.199 1129.6 C 815.501 1107.51 802.428 1072.29 803.098 1028.87 z"/>
<path transform="translate(0,0)" fill="#8A99F2" d="M 1015.29 981.469 C 1026.2 978.746 1037.26 985.328 1040.07 996.212 C 1042.88 1007.1 1036.38 1018.21 1025.52 1021.1 C 1014.54 1024.03 1003.28 1017.44 1000.44 1006.44 C 997.597 995.433 1004.27 984.223 1015.29 981.469 z"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

@ -68,12 +68,6 @@ Stil wie auf apps.zielke.studio: kurz, konkret, Du-Form.
3. **Ton:** Kanalzüge je Ausgangskanal mit Pegel, Gain, Mute; Sprechtaste groß unten rechts.
4. **Einstellungen:** Schnittstellen (Technik/Klinik), Audio-Gerät, Startverhalten.
## App-Icon
## App-Icon (Idee)
Eine Indigo-Schnur, die einen einfachen Knoten um ein kleines, abgerundetes Bildschirm-Rechteck bildet, auf Sumi-Grund. Kein Text.
- Quelle: `design/icon/musubi-icon.svg` (macOS-Raster 1024/824, Ai `#8A99F2` auf Sumi `#14151B`), Entwurf mit Artlist (Recraft Vector) erzeugt und nachbearbeitet.
- `musubi-icon-small.svg`: gleiche Form mit dickerer Schnur für 16–64 px.
- `musubi-mark.svg`: nur der Knoten, transparent (Website, Onboarding).
- `alt-two-screens.svg`: verworfene Alternative (zwei Bildschirme, eine Schnur).
- Eingebunden als `App/Resources/Assets.xcassets/AppIcon.appiconset`.

@ -44,14 +44,13 @@ Jeder Meilenstein endet mit einem Abnahmetest. Erst wenn er bestanden ist, begin
- [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:** Abnahme bestanden – Neustart beider Macs, „OP-Live“ stellt sich ohne Eingriff wieder her (von Samuel bestätigt).
- **Stand 2026-10-21:** umgesetzt; Neustart Mac mini einzeln bestanden, Durchlauf in einem Stück und Neustart der Regie (MacBook) offen.
- **Abnahme:** Beide Macs neu starten – die Szene „OP-Live“ stellt sich ohne Eingriff wieder her.
## M4 – Intercom Stufe 1
- [x] Web-Server nur auf der Klinik-Schnittstelle
- [x] QR-Code mit Adresse in der App
- [x] Handy-Seite: Name/Rolle, Talkback hören, PGM-Vorschau (WebRTC)
- **Stand 2026-10-22:** umgesetzt, mit Chrome (headless) am Mac geprüft; Abnahme mit iPhone und Android offen.
- [ ] Web-Server nur auf der Klinik-Schnittstelle
- [ ] QR-Code mit Adresse in der App
- [ ] Handy-Seite: Name/Rolle, Talkback hören, PGM-Vorschau (WebRTC)
- **Abnahme:** iPhone und Android im Heimnetz, Verzögerung der Vorschau gemessen < 0,5 s.
## M5 – Intercom Stufe 2
@ -96,16 +95,7 @@ _(nach jedem Meilenstein ergänzen)_
- 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.
### M4 – Intercom Stufe 1 (umgesetzt 2026-10-22, Abnahme offen)
- WebRTC ohne Drittanbieter (Entscheidung 2026-10-21): Musubi ist ICE-lite-Gegenstelle und DTLS-Server. Eigene, schmale Umsetzung auf CryptoKit/CommonCrypto: STUN (RFC 5389/8445), DTLS 1.2 mit genau einer Cipher-Suite (ECDHE-ECDSA-AES128-GCM-SHA256) + `use_srtp`, SRTP AES-CM-HMAC-SHA1-80. Network.framework/Security bieten kein DTLS-SRTP.
- Geprüft gegen RFC-Testvektoren (5769, 3711), OpenSSL (`s_client -dtls1_2 -use_srtp`, gleiche exportierte SRTP-Schlüssel, fremdes Zertifikat wird abgelehnt) und headless Chrome (Ende-zu-Ende: verbunden in ~12 ms, H.264 dekodiert, Opus empfangen). Opt-in-Test: `MUSUBI_E2E=1 swift test --filter BrowserEndToEnd`.
- Das Client-Zertifikat des Browsers wird gegen den Fingerprint aus dem SDP-Angebot geprüft; ohne Übereinstimmung kein Schlüssel.
- Signalisierung per HTTP-POST (Angebot rein, Antwort raus), keine Kandidaten vom Handy nötig: ICE-lite lernt die Adresse aus der ersten STUN-Prüfung (mDNS-Kandidaten der Browser stören nicht).
- Vorschau: NDI-Proxy-Stream (`bandwidth lowest`) → 960×540, 25 fps, H.264 Constrained Baseline, ~1,2 Mbit/s, Keyframe alle 2 s und auf PLI.
- Talkback: Abgriff am Headset-Mikro (`AudioMatrix.addInputTap`), Sprechtaste schaltet; sonst Stille, damit der Ton-Takt der Handys weiterläuft. Opus über AudioToolbox, 20 ms, 32 kbit/s.
- Ohne verbundenes Handy wird nichts kodiert.
### M3 – Szenen und Fernsteuerung (bestanden 2026-10-21)
### 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.

@ -47,17 +47,9 @@ Sobald die bestellte Hardware da ist, wird der echte Aufbau zu Hause nachgestell
- [ ] 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)
- [x] M3: Szene „OP-Live“ übersteht Neustart beider Macs (Durchlauf in einem Stück; Regie = MacBook) (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
- [ ] M4 Vorbereitung (MacBook, Heimnetz): Einstellungen (⌘,) → Klinik-Netz = WLAN-Schnittstelle (z. B. `en0`). Zum Testen darf es dieselbe wie das Technik-Netz sein (Hinweis in Bernstein ist dann richtig). Ansicht „Intercom“: Vorschau = PGM-Quelle (OBS „pgm“ oder Test Patterns), Talkback = Headset-Mikro.
- [ ] M4: macOS-Firewall (falls an): Musubi eingehende Verbindungen erlauben. Ports: TCP 8080 (Seite), UDP 50000 (Ton/Bild).
- [ ] M4: iPhone (Safari) im selben WLAN: QR-Code scannen → Seite lädt, Name/Rolle eintragen, „Verbinden“ → Bild erscheint, Musubi listet das Handy unter „Verbunden“ mit grünem Punkt.
- [ ] M4: Android (Chrome) dasselbe, gleichzeitig mit dem iPhone verbunden.
- [ ] M4: Talkback: Sprechtaste (⌃⌥ Leertaste) halten und sprechen → beide Handys hören dich; loslassen → still. Falls iOS „Ton einschalten“ zeigt, einmal tippen.
- [ ] M4: Vorschau-Verzögerung messen: Uhr mit Millisekunden als NDI-Quelle (z. B. OBS-Browserquelle mit Uhr) als Vorschau wählen, Monitor mit der Quelle und Handy nebeneinander fotografieren; Differenz = Verzögerung: iPhone ___ ms, Android ___ ms (Ziel < 500 ms).
- [ ] M4: WLAN am Handy 10 s aus → Seite meldet „Neuer Versuch in 3 s“ und verbindet sich von selbst wieder; Musubi entfernt das alte Handy nach spätestens 30 s.
- [ ] M4: Handy-Display sperren und entsperren → Verbindung kommt zurück (sonst notieren, wie sich iOS/Android verhalten).
- [ ] M5: Handy über zweite Schnittstelle: Sprechen
- [ ] M4/M5: Handy über zweite Schnittstelle: Hören, Sprechen, Vorschau
- [ ] M6: Tally auf dem Handy folgt dem Micro Panel
## Messwerte

@ -25,17 +25,6 @@ Keine Internetfreigabe, kein Routing zwischen den Netzen. Musubi bindet jeden Di
Maske 255.255.255.0, kein Gateway.
## Intercom: Ports und Bindung
Musubi bindet beide Dienste ausschließlich an die Klinik-Schnittstelle (Einstellungen → Klinik-Netz); über das Technik-Netz oder Loopback sind sie nicht erreichbar.
| Port | Protokoll | Zweck |
|---|---|---|
| 8080 | TCP (HTTP, ab M5 HTTPS) | Handy-Seite und Signalisierung (SDP-Angebot/-Antwort) |
| 50000 | UDP | WebRTC: ICE-lite, DTLS, SRTP (Talkback Opus, PGM-Vorschau H.264), alle Handys auf einem Port |
Für die Klinik-IT: Handys müssen den Mac auf diesen beiden Ports direkt erreichen (keine Client-Isolation im WLAN).
## Intercom: Reihenfolge der Wege
1. **Eigenes Netz der Klinik-IT** – lokal, schnellster Weg. Bevorzugt.

@ -37,13 +37,12 @@ targets:
CFBundleShortVersionString: "0.1.0"
CFBundleVersion: "1"
LSApplicationCategoryType: public.app-category.video
NSLocalNetworkUsageDescription: Musubi sucht NDI-Quellen im Technik-Netz und verbindet Handys im Klinik-Netz.
NSLocalNetworkUsageDescription: Musubi sucht NDI-Quellen im Technik-Netz.
NSMicrophoneUsageDescription: Musubi legt dein Headset-Mikro per Sprechtaste auf einen Ausgangskanal.
NSBonjourServices: [_ndi._tcp, _musubi._tcp]
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: studio.zielke.musubi
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
# 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
@ -75,8 +74,6 @@ targets:
product: DesignSystem
- package: MusubiKit
product: Remote
- package: MusubiKit
product: Intercom
- framework: Vendor/NDI/lib/libndi.dylib
embed: true
codeSign: true
@ -91,7 +88,6 @@ schemes:
- package: MusubiKit/MusubiCoreTests
- package: MusubiKit/AudioMatrixTests
- package: MusubiKit/RemoteTests
- package: MusubiKit/IntercomTests
run:
config: Debug
archive:

Loading…
Cancel
Save

Powered by TurnKey Linux.