diff --git a/CONTEXT.md b/CONTEXT.md index 5c48d4a2..465230a2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/Packages/ShowRecorderKit/Sources/Recording/ChannelNaming.swift b/Packages/ShowRecorderKit/Sources/Recording/ChannelNaming.swift index 8c31f8f7..5f9c5a2e 100644 --- a/Packages/ShowRecorderKit/Sources/Recording/ChannelNaming.swift +++ b/Packages/ShowRecorderKit/Sources/Recording/ChannelNaming.swift @@ -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 @@ -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 } @@ -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 @@ -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 diff --git a/Packages/ShowRecorderKit/Sources/Recording/Recorder.swift b/Packages/ShowRecorderKit/Sources/Recording/Recorder.swift index 9570506c..c1438e3e 100644 --- a/Packages/ShowRecorderKit/Sources/Recording/Recorder.swift +++ b/Packages/ShowRecorderKit/Sources/Recording/Recorder.swift @@ -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? @@ -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) @@ -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() } @@ -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 } @@ -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 @@ -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() @@ -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. @@ -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 } diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/ChannelNamingTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/ChannelNamingTests.swift index 06391fa8..584b4926 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/ChannelNamingTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/ChannelNamingTests.swift @@ -83,6 +83,108 @@ struct ChannelNamingTests { #expect(next.names == [2: "Snare"], "late names are used once") } + static func routed(_ name: String, from route: String?) -> Source { + var source = Source(name: name, color: .off) + source.routedFrom = route + return source + } + + @Test("A name the Mixer changes during a Take on the same routing is a rename, once (#557)") + func renamedDuringATake() { + var naming = ChannelNaming() + naming.takeStarted(with: [Self.routed("Kick", from: "In 1"), Self.routed("Snare", from: "In 2")]) + + let events = naming.mixerOffered([Self.routed("Kick In", from: "In 1"), Self.routed("Snare", from: "In 2")]) + + #expect(events == [.renamed(channel: 1, from: "Kick", to: "Kick In")]) + #expect(naming.mixerOffered([Self.routed("Kick In", from: "In 1"), Self.routed("Snare", from: "In 2")]).isEmpty, "a repeat is nothing") + } + + @Test("A channel the Mixer re-patches is a re-patch, not a rename; a sweep of the source selector is a re-patch each time (#557)") + func repatchedDuringATake() { + var naming = ChannelNaming() + naming.takeStarted(with: [Self.routed("Kick", from: "In 1")]) + + let first = naming.mixerOffered([Self.routed("Vox", from: "In 5")]) + let second = naming.mixerOffered([Self.routed("Bass", from: "In 6")]) + + #expect(first == [.repatched(channel: 1, from: "Kick", to: "Vox")]) + #expect(second == [.repatched(channel: 1, from: "Vox", to: "Bass")]) + } + + @Test("A re-patch and a rename in one offer come back re-patch first, even when the re-patched channel is the higher one (#557)") + func repatchBeforeRename() { + var naming = ChannelNaming() + naming.takeStarted(with: [Self.routed("Kick", from: "In 1"), Self.routed("Snare", from: "In 2")]) + + let events = naming.mixerOffered([Self.routed("Kick In", from: "In 1"), Self.routed("Vox", from: "In 9")]) + + #expect(events == [.repatched(channel: 2, from: "Snare", to: "Vox"), .renamed(channel: 1, from: "Kick", to: "Kick In")]) + #expect(naming.mixerOffered([Self.routed("Kick In", from: "In 1"), Self.routed("Vox", from: "In 9")]).isEmpty, "a repeat is nothing") + } + + @Test("Names offered late are kept for the Take's end, and their first offer is not a rename of the USB names (#557)") + func lateNamesAndChanges() { + var naming = ChannelNaming() + naming.takeStarted(with: [.fallback(usbChannel: 1)]) + + let events = naming.mixerOffered([Source(name: "Kick", color: .off)]) + let ended = naming.takeEnded(allowance: nil, onFiles: [1: "USB 01"]) + + #expect(events == [.renamed(channel: 1, from: "USB 01", to: "Kick")], "logged on the Take, as before") + #expect(ended.names == [1: "Kick"], "and put on the files at its end") + } + + @Test("The first routing known for a channel is only noted; a re-patch to a Source with no routing stays a rename (#557)") + func routingFirstKnownAndUnknown() { + var naming = ChannelNaming() + naming.takeStarted(with: [Self.routed("Kick", from: nil)]) + + #expect(naming.mixerOffered([Self.routed("Kick", from: "In 1")]).isEmpty, "routing arrives late: noted, no event") + #expect(naming.mixerOffered([Self.routed("Vox", from: nil)]) == [.renamed(channel: 1, from: "Kick", to: "Vox")]) + } + + @Test("A channel past the Take's start Sources compares with its USB name (#557)") + func renamedFromTheUSBName() { + var naming = ChannelNaming() + naming.takeStarted(with: []) + + #expect(naming.mixerOffered([Self.routed("Kick", from: nil)]) == [.renamed(channel: 1, from: "USB 01", to: "Kick")]) + } + + @Test("Typed names are remembered as chips, newest first, one per name whatever the case; the built-in shortcut names are not (#557)") + func typedNamesBecomeChips() { + var naming = ChannelNaming() + for (channel, name) in [(1, "Pastor Mike"), (2, "Choir mic"), (3, "Kick"), (4, "Vox 2"), (5, "pastor mike")] { naming.type(name, for: channel) } + + #expect(naming.saved == ["pastor mike", "Choir mic"]) + naming.type(" ", for: 6) + #expect(naming.saved == ["pastor mike", "Choir mic"], "a refused name is not remembered") + } + + @Test("Copying a Show's names replaces the typed ones and remembers them, the lowest channel's name newest; it says how many (#557)") + func copyingNames() { + var naming = ChannelNaming(typed: [9: "Old"], saved: ["Old"]) + + let count = naming.copy([1: "Pastor", 2: "Choir"]) + + #expect(count == 2) + #expect(naming.typed == [1: "Pastor", 2: "Choir"]) + #expect(naming.saved == ["Pastor", "Choir", "Old"]) + } + + @Test("The chips are kept on the device and come back (#557)") + func chipsPersist() throws { + let suite = "ChannelNamingTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + var naming = ChannelNaming() + naming.type("Choir mic", for: 1) + naming.persist(to: defaults) + + #expect(ChannelNaming(typed: [:], saved: SavedChannelNames.load(from: defaults)).saved == ["Choir mic"]) + } + @Test("A Take that started with Mixer names takes no late ones, and Free puts no typed names on files") func noLateNamesAndFree() { var naming = ChannelNaming(typed: [1: "Kick In"])