Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,14 @@ A Copy that Stop couldn't finish writing in time: the Take ends without waiting
How widely a feature that ships hidden is offered: Debug only, Debug + TestFlight, or everyone. Each feature's availability rule names one as its `level`, so moving a feature up is that one line, after Eric has tried it on a device (#557).
_Avoid_: Feature flag, gate

**Re-patch**:
The Mixer routes something else to a USB Channel during a Take, so its Stem holds another Source from there on. It is logged with a Marker, unlike a rename, where the Mixer only gives the same routing a new name. `ChannelNaming` tells them apart and reports each to the Take (#21, #557).
_Avoid_: Routing change (the logged record is a routing change; the event is a re-patch)

**Saved name**:
A channel name the operator typed before, kept on the device and offered as a chip on the Channel names page. Newest first, one per name whatever its case; the built-in shortcut names are not saved.
_Avoid_: Recent name, history

**Mixer family**:
Mixers one driver reads, such as the X Air (XR18, MR18) or the X32 (X32, M32). The family is recognized from the Audio interface's name (for Mixer packs) and from the Mixer's own model (for the Mixer Link), and each family knows the port it is asked on and makes its driver. In Debug and sandbox builds (TestFlight, App Review) the Demo signal counts as an X Air for Mixer packs and the license, so App Review can reach the pack.

Expand Down
73 changes: 65 additions & 8 deletions Packages/ShowRecorderKit/Sources/Recording/ChannelNaming.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,29 @@ public struct ChannelNameRow: Equatable, Sendable, Identifiable {
public struct ChannelNaming: Equatable, Sendable {
/// The names the operator typed, by USB Channel.
public private(set) var typed: [Int: String]
/// The names typed before on this device, newest first, offered as chips (`SavedChannelNames`).
public private(set) var saved: [String]
/// True while a Take that started with no Mixer names runs: the first Mixer names offered go on its files.
private var acceptsLateNames = false
private var lateMixerNames: [Int: String] = [:]
/// The name the Mixer last gave each USB Channel during this Take (or, from the start, the Source the Take began with),
/// to tell a rename from a repeat.
private var mixerNames: [Int: String] = [:]
/// What the Mixer routed to each USB Channel: from the Take's start, or from when the routing was first known (a Mixer Link
/// that arrived after the press), and after each re-patch.
private var takeRouting: [Int: String] = [:]

/// What the Mixer changed on a USB Channel during a Take (#21): the caller logs each on the Take.
public enum MixerChange: Equatable, Sendable {
/// The Mixer renamed the channel on the same routing.
case renamed(channel: Int, from: String, to: String)
/// The Mixer routed something else to the channel (a re-patch); its Stem holds another Source from here on.
case repatched(channel: Int, from: String, to: String)
}

public init(typed: [Int: String] = [:]) {
public init(typed: [Int: String] = [:], saved: [String] = []) {
self.typed = typed
self.saved = saved
}

// MARK: Typing
Expand All @@ -34,6 +51,7 @@ public struct ChannelNaming: Equatable, Sendable {
let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return false }
typed[channel] = name
saved = SavedChannelNames.adding(name, to: saved)
return true
}

Expand All @@ -43,6 +61,20 @@ public struct ChannelNaming: Equatable, Sendable {
typed.removeValue(forKey: channel) != nil
}

/// Fills the names from the Show before: replaces every typed name and remembers them, the lowest channel's name newest.
/// Returns how many names were copied.
@discardableResult
public mutating func copy(_ names: [Int: String]) -> Int {
replace(with: names)
for name in names.sorted(by: { $0.key > $1.key }).map(\.value) { saved = SavedChannelNames.adding(name, to: saved) }
return names.count
}

/// Keeps the chips on the device, for the next launch.
public func persist(to defaults: UserDefaults) {
SavedChannelNames.save(saved, to: defaults)
}

/// Replaces every typed name: a new Show, a Template or the names copied from the Show before.
public mutating func replace(with names: [Int: String]) {
typed = names
Expand Down Expand Up @@ -103,15 +135,40 @@ public struct ChannelNaming: Equatable, Sendable {
public mutating func takeStarted(with sources: [Source]) {
acceptsLateNames = !sources.contains { $0.hasMixerName }
lateMixerNames = [:]
mixerNames = Dictionary(uniqueKeysWithValues: sources.enumerated().map { ($0.offset + 1, $0.element.name) })
takeRouting = Dictionary(uniqueKeysWithValues: sources.enumerated().map { ($0.offset + 1, $0.element.routedFrom) }).compactMapValues { $0 }
}

/// The Mixer offered names during the Take; the first real ones are kept for its end.
public mutating func mixerOffered(_ sources: [Source]) {
guard acceptsLateNames else { return }
let names = Dictionary(uniqueKeysWithValues: sources.enumerated().filter { $0.element.hasMixerName }.map { ($0.offset + 1, $0.element.name) })
guard !names.isEmpty else { return }
lateMixerNames = names
acceptsLateNames = false
/// The Mixer offered `sources` during the Take; the first real names are kept for its end. Returns what changed since the
/// last offer: re-patches first, then renames. A channel whose routing changed (#21) is a re-patch, not a rename. The first
/// routing known for a channel is only noted. A re-patch to something the Mixer Link can't name (no `routedFrom`) isn't told
/// apart from not knowing: it stays a rename.
public mutating func mixerOffered(_ sources: [Source]) -> [MixerChange] {
var changes: [MixerChange] = []
for (index, source) in sources.enumerated() {
let number = index + 1
guard let now = source.routedFrom else { continue }
guard let known = takeRouting[number] else { takeRouting[number] = now; continue }
guard now != known else { continue }
changes.append(.repatched(channel: number, from: mixerNames[number] ?? Source.fallback(usbChannel: number).name, to: source.name))
takeRouting[number] = now
mixerNames[number] = source.name
}
for (index, source) in sources.enumerated() {
let number = index + 1
let known = mixerNames[number] ?? Source.fallback(usbChannel: number).name
guard source.name != known else { continue }
changes.append(.renamed(channel: number, from: known, to: source.name))
mixerNames[number] = source.name
}
if acceptsLateNames {
let names = Dictionary(uniqueKeysWithValues: sources.enumerated().filter { $0.element.hasMixerName }.map { ($0.offset + 1, $0.element.name) })
if !names.isEmpty {
lateMixerNames = names
acceptsLateNames = false
}
}
return changes
}

/// The names to put on a Take's files when it ends: typed ones (when the Take may use them) win over the Mixer's
Expand Down
75 changes: 19 additions & 56 deletions Packages/ShowRecorderKit/Sources/Recording/Recorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public final class Recorder {
/// Who names each USB Channel: typed names, and the Mixer's that arrive late in a Take (#443).
public private(set) var naming = ChannelNaming()
/// Names typed before on this device, newest first, offered as chips (`SavedChannelNames`).
public private(set) var savedChannelNames: [String] = []
public var savedChannelNames: [String] { naming.saved }
@ObservationIgnored private let savedNames: UserDefaults
/// What the current or last Take was allowed, frozen at its record press. Nil: everything (no allowance given).
@ObservationIgnored private var lastAllowance: TakeAllowance?
Expand Down Expand Up @@ -207,7 +207,7 @@ public final class Recorder {
self.hostClock = hostClock
self.writerStopTimeout = writerStopTimeout
self.savedNames = savedNames
savedChannelNames = SavedChannelNames.load(from: savedNames)
naming = ChannelNaming(saved: SavedChannelNames.load(from: savedNames))
self.freeSpace = freeSpace
self.preRollSeconds = preRollSeconds
repair = TakeRepair(freeSpace: freeSpace, writeAbandoned: writeAbandoned)
Expand All @@ -228,7 +228,7 @@ public final class Recorder {
repair.jobDone = { [unowned self] in regenerateReportsOffMain(skipping: $0) }
// A Show left open by a crash or an earlier launch is the open Show again.
currentShow = Show.findOpen(in: deviceFolder)
naming = ChannelNaming(typed: currentShow?.channelNames ?? [:])
naming = ChannelNaming(typed: currentShow?.channelNames ?? [:], saved: naming.saved)
finishCutOffTake()
}

Expand Down Expand Up @@ -496,8 +496,7 @@ public final class Recorder {
if !isRecording, !isFinishing, !isStarting { endIdleShow(at: now()) }
naming.type(name, for: usbChannel)
currentShow?.setChannelNames(channelNames)
savedChannelNames = SavedChannelNames.adding(name, to: savedChannelNames)
SavedChannelNames.save(savedChannelNames, to: savedNames)
naming.persist(to: savedNames)
return true
}

Expand All @@ -507,13 +506,10 @@ public final class Recorder {
public func copyChannelNamesFromLastShow() -> Int? {
guard let current = currentShow, let previous = Show.lastShow(before: current, in: deviceFolder),
!previous.channelNames.isEmpty else { return nil }
naming.replace(with: previous.channelNames)
let count = naming.copy(previous.channelNames)
currentShow?.setChannelNames(channelNames)
for name in previous.channelNames.sorted(by: { $0.key > $1.key }).map(\.value) {
savedChannelNames = SavedChannelNames.adding(name, to: savedChannelNames)
}
SavedChannelNames.save(savedChannelNames, to: savedNames)
return channelNames.count
naming.persist(to: savedNames)
return count
}

/// What the naming box and the Channel names page do with what was typed: text names the channel, an empty or
Expand Down Expand Up @@ -766,9 +762,6 @@ public final class Recorder {
// The levels of a Take count from the press: drop what the meters gathered before it.
_ = capture.meters.takeLevels()
naming.takeStarted(with: sources)
mixerNames = Dictionary(uniqueKeysWithValues: sources.enumerated().map { ($0.offset + 1, $0.element.name) })
takeRouting = Dictionary(uniqueKeysWithValues: sources.enumerated().map { ($0.offset + 1, $0.element.routedFrom) })
.compactMapValues { $0 }
routingChanges = []
isRecording = true
moveStop.takeStarted()
Expand Down Expand Up @@ -1094,20 +1087,22 @@ public final class Recorder {
/// started with real names stays frozen. Touches no audio and no files while recording.
public func offerSources(_ sources: [Source]) {
guard isRecording else { return }
logRoutingChanges(in: sources)
logRenames(in: sources)
naming.mixerOffered(sources)
var repatched = false
for change in naming.mixerOffered(sources) {
switch change {
case .repatched(let channel, let from, let to):
session?.logRoutingChange(usbChannel: channel, from: from, to: to)
routingChanges = session?.metadata.routingChanges ?? routingChanges
repatched = true
case .renamed(let channel, let from, let to):
session?.logRename(usbChannel: channel, from: from, to: to)
}
}
if repatched, let session { refreshMarkers(from: session.metadata) }
}

/// The name the Mixer last gave each USB Channel during this Take (or, from the start, the Source the
/// Take began with), to tell a rename from a repeat.
private var mixerNames: [Int: String] = [:]

/// The USB routing changes of the running Take, for the record screen's warning (#21). Cleared when a Take starts.
public private(set) var routingChanges: [TakeMetadata.RoutingChange] = []
/// What the Mixer routed to each USB Channel: from the Take's start, or from when the routing was first known
/// (a Mixer Link that arrived after the press), and after each re-patch.
private var takeRouting: [Int: String] = [:]

/// What the record screen says about the running Take's routing changes (#21), naming each Stem as it was
/// named at the start. Nil when there are none.
Expand All @@ -1118,38 +1113,6 @@ public final class Recorder {
return RoutingChangeNotice.message(for: routingChanges, stemNames: stems)
}

/// A USB Channel whose routing changed (#21) is logged as a routing change with a Marker, not as a rename: from
/// here on its Stem holds another Source. The first routing known for a USB Channel is only noted. A re-patch to
/// something the Mixer Link can't name (no `routedFrom`) isn't told apart from not knowing: it stays a rename.
private func logRoutingChanges(in sources: [Source]) {
guard let session else { return }
var logged = false
for (index, source) in sources.enumerated() {
let number = index + 1
guard let now = source.routedFrom else { continue }
guard let known = takeRouting[number] else { takeRouting[number] = now; continue }
guard now != known else { continue }
let from = mixerNames[number] ?? Source.fallback(usbChannel: number).name
session.logRoutingChange(usbChannel: number, from: from, to: source.name)
routingChanges = session.metadata.routingChanges ?? []
takeRouting[number] = now
mixerNames[number] = source.name
logged = true
}
if logged { refreshMarkers(from: session.metadata) }
}

private func logRenames(in sources: [Source]) {
guard let session else { return }
for (index, source) in sources.enumerated() {
let number = index + 1
let known = mixerNames[number] ?? Source.fallback(usbChannel: number).name
guard source.name != known else { continue }
session.logRename(usbChannel: number, from: known, to: source.name)
mixerNames[number] = source.name
}
}

/// What the running Take was allowed at its press (#705), for what follows the Take, such as clip protection.
/// Nil between Takes, and for a Take with no allowance (everything allowed).
public var takeAllowance: TakeAllowance? { isRecording ? lastAllowance : nil }
Expand Down
Loading
Loading