diff --git a/App/RecordScreen.swift b/App/RecordScreen.swift index 47ce3b28..946acc09 100644 --- a/App/RecordScreen.swift +++ b/App/RecordScreen.swift @@ -632,7 +632,7 @@ final class RecordScreenModel { let store = drive.store self.drive = drive recorder = Recorder(driveFolder: { - store.beginAccess().map { access in DestinationAccess(folder: access.folder, release: { access.end() }) } + store.beginAccess() }, preRollSeconds: PreRollSetting.load()) mixerAddress = UserDefaults.standard.string(forKey: Self.mixerAddressKey) ?? "" devices = Self.availableDevices() @@ -648,7 +648,7 @@ final class RecordScreenModel { } var takeTitle: String { - String(format: "Take %02d", recorder.currentShow?.takeCount ?? 0) + TakeMetadata.name(take: recorder.currentShow?.takeCount ?? 0) } func record() { diff --git a/App/ShowDetailView.swift b/App/ShowDetailView.swift index 5d0e3745..490b6f4b 100644 --- a/App/ShowDetailView.swift +++ b/App/ShowDetailView.swift @@ -43,7 +43,7 @@ struct ShowDetailView: View { } } header: { HStack { - Text(String(format: "Take %02d", take.number)) + Text(TakeMetadata.name(take: take.number)) Spacer() Button("Delete…", role: .destructive) { deletingTake = take.number } .buttonStyle(.borderless) diff --git a/Packages/ShowRecorderKit/Sources/Destinations/Destinations.swift b/Packages/ShowRecorderKit/Sources/Destinations/Destinations.swift index 1fbfdc7d..12445d78 100644 --- a/Packages/ShowRecorderKit/Sources/Destinations/Destinations.swift +++ b/Packages/ShowRecorderKit/Sources/Destinations/Destinations.swift @@ -17,6 +17,14 @@ public enum DestinationKind: String, Codable, Sendable, Hashable, CaseIterable { public init(index: Int) { self = index == 0 ? .device : .drive } + + /// "Device" or "Drive", as the operator reads it. + public var name: String { + switch self { + case .device: "Device" + case .drive: "Drive" + } + } } /// Where the Drive folder's bookmark is kept between launches. One per device. @@ -197,12 +205,13 @@ public struct DriveFolderStore: Sendable { } } -/// The Drive folder opened for one Take. Call `end()` when the Take is finalized. +/// The Drive folder opened for one Take, held until the Take is finalized and Repaired. `end()` lets it go +/// (for a bookmarked folder, its security scope). public struct DriveAccess: Sendable { public let folder: URL private let stop: @Sendable () -> Void - init(folder: URL, stop: @escaping @Sendable () -> Void) { + public init(folder: URL, stop: @escaping @Sendable () -> Void = {}) { self.folder = folder self.stop = stop } diff --git a/Packages/ShowRecorderKit/Sources/Recording/CopyCheck.swift b/Packages/ShowRecorderKit/Sources/Recording/CopyCheck.swift index cb0c49f9..fa7b4993 100644 --- a/Packages/ShowRecorderKit/Sources/Recording/CopyCheck.swift +++ b/Packages/ShowRecorderKit/Sources/Recording/CopyCheck.swift @@ -24,7 +24,7 @@ public enum CopyCheck { let deviceTakes = ShowList.takeMetadata(in: deviceShow).sorted { $0.take.take < $1.take.take } if deviceTakes.isEmpty { return .failed(["The Device copy has no Takes to compare."]) } for (deviceFolder, deviceTake) in deviceTakes { - let name = String(format: "Take %02d", deviceTake.take) + let name = TakeMetadata.name(take: deviceTake.take) let driveFolder = driveShow.appending(path: deviceFolder.lastPathComponent, directoryHint: .isDirectory) guard let driveTake = try? TakeMetadata.read(fromTakeFolder: driveFolder) else { problems.append("\(name) is missing on the Drive.") @@ -32,7 +32,7 @@ public enum CopyCheck { } let outcome = driveTake.outcome(ofCopy: .drive) if outcome == .hasGaps || outcome == .repairFailed { - problems.append("\(name) on the Drive still has Gaps.") + problems.append("\(name) on the \(DestinationKind.drive.name) still \(CopyOutcome.hasGaps.word).") } for channel in deviceTake.usbChannels { guard let match = driveTake.usbChannels.first(where: { $0.usbChannel == channel.usbChannel }) else { diff --git a/Packages/ShowRecorderKit/Sources/Recording/CopySummary.swift b/Packages/ShowRecorderKit/Sources/Recording/CopySummary.swift index 4ab6f033..7b5c1baa 100644 --- a/Packages/ShowRecorderKit/Sources/Recording/CopySummary.swift +++ b/Packages/ShowRecorderKit/Sources/Recording/CopySummary.swift @@ -7,16 +7,8 @@ public struct CopySummary: Equatable, Sendable { public let isProblem: Bool public init(outcomes: [DestinationKind: CopyOutcome], dropoutCount: Int) { - func word(_ outcome: CopyOutcome) -> String { - switch outcome { - case .complete: "complete" - case .hasGaps: "has Gaps" - case .repaired: "Repaired" - case .repairFailed: "Repair failed" - } - } - var parts = [(DestinationKind.device, "Device"), (.drive, "Drive")].compactMap { kind, name in - outcomes[kind].map { "\(name) copy: \(word($0))" } + var parts = DestinationKind.allCases.compactMap { kind in + outcomes[kind].map { "\(kind.name) copy: \($0.word)" } } if outcomes.count == 1 { parts[0] += outcomes[.device] == nil ? " (no device copy)" : " (no Drive)" diff --git a/Packages/ShowRecorderKit/Sources/Recording/Recorder.swift b/Packages/ShowRecorderKit/Sources/Recording/Recorder.swift index c7c691fa..7ffa2f9e 100644 --- a/Packages/ShowRecorderKit/Sources/Recording/Recorder.swift +++ b/Packages/ShowRecorderKit/Sources/Recording/Recorder.swift @@ -65,7 +65,7 @@ public final class Recorder { } @ObservationIgnored private let deviceFolder: URL - @ObservationIgnored private let driveFolder: () -> DestinationAccess? + @ObservationIgnored private let driveFolder: () -> DriveAccess? @ObservationIgnored private let now: () -> Date @ObservationIgnored private let freeSpace: (URL) -> Int64 /// How often a running Take's Destinations are checked (`checkDestinations()`). Nil leaves it to the caller. @@ -84,7 +84,7 @@ public final class Recorder { @ObservationIgnored private var session: TakeSession? @ObservationIgnored private var lastTake: LastTake? /// Repairs ended Takes; its result is published as `lastTakeOutcomes` and `isRepairing`. - @ObservationIgnored private let repairQueue: RepairQueue + @ObservationIgnored private let repair: TakeRepair @ObservationIgnored private var finishedCopies: [DestinationKind: CopyStatus] = [:] /// - Parameters: @@ -92,7 +92,7 @@ public final class Recorder { /// - driveFolder: asked at each record press for the Drive folder; nil writes to the Device only. public init( deviceFolder: URL = Recorder.defaultDeviceFolder, - driveFolder: @escaping () -> DestinationAccess? = { nil }, + driveFolder: @escaping () -> DriveAccess? = { nil }, now: @escaping () -> Date = Date.init, freeSpace: @escaping (URL) -> Int64 = { DriveFolderStore.availableBytes(at: $0) }, preRollSeconds: Double = 0, @@ -105,16 +105,16 @@ public final class Recorder { savedChannelNames = SavedChannelNames.load(from: savedNames) self.freeSpace = freeSpace self.preRollSeconds = preRollSeconds - repairQueue = RepairQueue(freeSpace: freeSpace) + repair = TakeRepair(freeSpace: freeSpace) self.makeStem = makeStem self.deviceFolder = deviceFolder self.driveFolder = driveFolder self.now = now - repairQueue.onChange = { [unowned self] in - lastTakeOutcomes = repairQueue.outcomes - isRepairing = repairQueue.isRunning + repair.onChange = { [unowned self] in + lastTakeOutcomes = repair.outcomes + isRepairing = repair.isRunning } - repairQueue.jobDone = { [unowned self] in regenerateReports() } + repair.jobDone = { [unowned self] in regenerateReports() } // A Show left open by a crash or an earlier launch is the open Show again. currentShow = Show.findOpen(in: deviceFolder) channelNames = currentShow?.channelNames ?? [:] @@ -323,14 +323,14 @@ public final class Recorder { let kinds = copies.sorted { $0.index < $1.index } guard !isRecording else { return .init(deleted: [], failed: kinds.map { .init(copy: $0, reason: "A Take is running.") }) } let access = copies.contains(.drive) ? driveFolder() : nil - defer { access?.release() } + defer { access?.end() } return ShowDeleter.delete(show: name, from: copies, device: deviceFolder, drive: access?.folder, openShow: currentShow?.name) } /// Checks that the Drive Copy of Show `show` is whole (see `CopyCheck`). Reads every Stem, off the main thread. public func checkDriveCopy(of show: String) async -> CopyCheck.Result { guard let access = driveFolder() else { return .failed(["The Drive isn't available."]) } - defer { access.release() } + defer { access.end() } let device = deviceFolder, drive = access.folder return await Task.detached { CopyCheck.verify(show: show, device: device, drive: drive) }.value } @@ -355,7 +355,7 @@ public final class Recorder { let kinds = copies.sorted { $0.index < $1.index } guard !isRecording else { return .init(deleted: [], failed: kinds.map { .init(copy: $0, reason: "A Take is running.") }) } let access = copies.contains(.drive) ? driveFolder() : nil - defer { access?.release() } + defer { access?.end() } return TakeDeleter.delete(take: take, ofShow: show, from: copies, device: deviceFolder, drive: access?.folder, openShow: currentShow?.name) } @@ -365,7 +365,7 @@ public final class Recorder { let kinds = copies.sorted { $0.index < $1.index } guard !isRecording else { return .init(deleted: [], failed: kinds.map { .init(copy: $0, reason: "A Take is running.") }) } let access = copies.contains(.drive) ? driveFolder() : nil - defer { access?.release() } + defer { access?.end() } return StemDeleter.delete(channel: channel, inTake: take, ofShow: show, from: copies, device: deviceFolder, drive: access?.folder, openShow: currentShow?.name) } @@ -393,7 +393,7 @@ public final class Recorder { nextShow = nil } } catch { - drive?.release() + drive?.end() throw error } if show.channelNames != channelNames { show.setChannelNames(channelNames) } @@ -431,7 +431,7 @@ public final class Recorder { show: show, folders: takeFolders, drive: takeFolders.count > 1 ? drive : nil, metadata: metadata, stems: specs, info: info, capture: capture, preRollFrames: preRollFrames, makeStem: makeStem, freeSpace: freeSpace) session.onCopyFailed = { [weak self, weak session] in self?.copyFailed(in: session) } - if takeFolders.count == 1 { drive?.release() } + if takeFolders.count == 1 { drive?.end() } self.session = session finishedCopies = [:] takeMarkers = [] @@ -514,15 +514,15 @@ public final class Recorder { /// The Drive is reached again for the rename and let go after. The outcome names any Copy that wasn't /// updated. Waits for Repair first, which would otherwise write the old name back. public func renameLastTakeMarker(at index: Int, to name: String) async -> MarkerRenamer.Outcome { - await repairQueue.wait() + await repair.wait() guard !isRecording, let take = lastTake else { return MarkerRenamer.Outcome(result: .notRecording, renamed: [], failed: []) } var copies: [DestinationKind: URL] = [.device: take.deviceFolder] - var access: DestinationAccess? + var access: DriveAccess? if take.hadDrive { access = driveFolder() - if let access { copies[.drive] = access.folder.appending(path: take.showName).appending(path: String(format: "Take %02d", take.takeNumber)) } + if let access { copies[.drive] = access.folder.appending(path: take.showName).appending(path: TakeMetadata.name(take: take.takeNumber)) } } - defer { access?.release() } + defer { access?.end() } var outcome = MarkerRenamer.rename(markerAt: index, to: name, in: copies) if take.hadDrive, access == nil { outcome.failed.append(.init(copy: .drive, reason: "The Drive isn't available, so its Copy still has the old name.")) @@ -612,7 +612,7 @@ public final class Recorder { currentShow?.noteTakeEnded(at: now()) // The Takes are safe either way; the report and project are regenerated next time. regenerateReports() - repairQueue.enqueue(finished.metadata, folders: finished.folders, holding: finished.access) + repair.enqueue(finished.metadata, folders: finished.folders, holding: finished.access) nameChannels(of: finished.metadata) } @@ -630,14 +630,14 @@ public final class Recorder { let previous = namingTask namingTask = Task { @MainActor in await previous?.value - await repairQueue.wait() + await repair.wait() var copies: [DestinationKind: URL] = [.device: take.deviceFolder] - var access: DestinationAccess? + var access: DriveAccess? if take.hadDrive { access = driveFolder() - if let access { copies[.drive] = access.folder.appending(path: take.showName).appending(path: String(format: "Take %02d", take.takeNumber)) } + if let access { copies[.drive] = access.folder.appending(path: take.showName).appending(path: TakeMetadata.name(take: take.takeNumber)) } } - defer { access?.release() } + defer { access?.end() } _ = ChannelRenamer.rename(names: names, lateChannels: Set(late.keys), in: copies) } } @@ -654,7 +654,7 @@ public final class Recorder { /// Waits for Repair of the last Take to finish. public func waitForRepair() async { - await repairQueue.wait() + await repair.wait() await namingTask?.value } } @@ -674,18 +674,6 @@ public enum RecorderError: Error, Equatable { case takeRunning } -/// Access to a Destination folder for the length of a Take. `release` ends it (for a Drive folder, -/// the security scope). -public struct DestinationAccess { - public let folder: URL - public let release: @MainActor () -> Void - - public init(folder: URL, release: @escaping @MainActor () -> Void = {}) { - self.folder = folder - self.release = release - } -} - /// Everything the real-time callback touches, allocated when the recorder is Armed. final class Capture: Sendable { let meters: PeakMeters diff --git a/Packages/ShowRecorderKit/Sources/Recording/RepairQueue.swift b/Packages/ShowRecorderKit/Sources/Recording/RepairQueue.swift deleted file mode 100644 index 81d6cf8b..00000000 --- a/Packages/ShowRecorderKit/Sources/Recording/RepairQueue.swift +++ /dev/null @@ -1,85 +0,0 @@ -import Destinations -import Foundation - -/// Runs Repair for ended Takes, one at a time in the order they ended, and says how the latest Take's -/// Copies turned out. `Recorder` publishes `outcomes` and `isRunning`; it does not schedule anything. -@MainActor -final class RepairQueue { - /// Fills the Gaps of `copies` from each other and returns what happened. `TakeRepair.run` in the app. - typealias Run = @Sendable (_ copies: [TakeRepair.Copy], _ metadata: TakeMetadata, _ skip: Set) -> [TakeMetadata.Repair] - - /// How each Copy of the latest Take to be enqueued ended up. Once its Repair has run, with the result. - private(set) var outcomes: [DestinationKind: CopyOutcome] = [:] - /// Whether any Repair is still queued or running. - private(set) var isRunning = false - /// Called after `outcomes` or `isRunning` changes. - var onChange: () -> Void = {} - /// Called after each Repair has written its result into the Take's folders. - var jobDone: () -> Void = {} - - private let freeSpace: (URL) -> Int64 - private let run: Run - private var task: Task? - /// Counts enqueued Takes, so only the latest one's Repair sets `outcomes`. - private var generation = 0 - private var pending = 0 - - /// - Parameters: - /// - freeSpace: free bytes on a Destination, read when a Take is enqueued. - /// - run: does the Repair; replaced in tests. - init(freeSpace: @escaping (URL) -> Int64, run: @escaping Run = { TakeRepair.run(copies: $0, metadata: $1, skip: $2) }) { - self.freeSpace = freeSpace - self.run = run - } - - /// Queues Repair of an ended Take: fills its Gaps from the other Copy, off the main thread, and - /// records the result in `Take.json`. `access` (the Drive's folder) is held until it's done. - /// A Take with no Gaps has nothing to repair; its outcomes are complete and `access` is released. - func enqueue(_ metadata: TakeMetadata, folders: [URL], holding access: DestinationAccess?) { - func outcomes(_ metadata: TakeMetadata) -> [DestinationKind: CopyOutcome] { - Dictionary(uniqueKeysWithValues: folders.indices.map { (DestinationKind(index: $0), metadata.outcome(ofCopy: DestinationKind(index: $0))) }) - } - generation += 1 - let mine = generation - self.outcomes = outcomes(metadata) - onChange() - guard !metadata.gaps.isEmpty else { - access?.release() - return - } - let copies = folders.indices.map { TakeRepair.Copy(kind: DestinationKind(index: $0), folder: folders[$0]) } - // Repair must not fill a Destination past the space reserve: a Copy stopped for being nearly - // full stays as it is, with its Gaps. - let reserve = SpaceReserve(channelCount: metadata.usbChannels.count, sampleRate: metadata.sampleRate) - let skip = Set(copies.filter { copy in - reserve.wouldBreach(free: freeSpace(copy.folder), adding: TakeRepair.bytesToExtend(copy, in: copies, metadata: metadata)) - }.map(\.kind)) - pending += 1 - isRunning = true - onChange() - let previous = task - let run = run - task = Task { [weak self] in - await previous?.value - let repairs = await Task.detached { run(copies, metadata, skip) }.value - var done = metadata - done.repairs = repairs - for folder in folders { try? done.write(to: folder) } - guard let self else { - access?.release() - return - } - if mine == generation { self.outcomes = outcomes(done) } - jobDone() - access?.release() - pending -= 1 - isRunning = pending > 0 - onChange() - } - } - - /// Waits for the latest enqueued Repair to finish (earlier ones finish first). - func wait() async { - await task?.value - } -} diff --git a/Packages/ShowRecorderKit/Sources/Recording/Show.swift b/Packages/ShowRecorderKit/Sources/Recording/Show.swift index a6e77cda..2cd1d60c 100644 --- a/Packages/ShowRecorderKit/Sources/Recording/Show.swift +++ b/Packages/ShowRecorderKit/Sources/Recording/Show.swift @@ -174,7 +174,7 @@ public struct Show: Sendable, Equatable { /// the same name) and a folder for the current Take. Returns that Take folder. mutating func joinDrive(_ parent: URL) throws -> URL { try useDrive(parent) - let takeFolder = driveFolder!.appending(path: String(format: "Take %02d", takeCount), directoryHint: .isDirectory) + let takeFolder = driveFolder!.appending(path: TakeMetadata.name(take: takeCount), directoryHint: .isDirectory) try FileManager.default.createDirectory(at: takeFolder, withIntermediateDirectories: false) return takeFolder } @@ -182,7 +182,7 @@ public struct Show: Sendable, Equatable { /// Creates "Take NN" for the next Take in every Copy and returns the folders, the Device first. mutating func createNextTakeFolder() throws -> [URL] { takeCount += 1 - let takeName = String(format: "Take %02d", takeCount) + let takeName = TakeMetadata.name(take: takeCount) return try ([folder] + (driveFolder.map { [$0] } ?? [])).map { showFolder in let takeFolder = showFolder.appending(path: takeName, directoryHint: .isDirectory) try FileManager.default.createDirectory(at: takeFolder, withIntermediateDirectories: false) diff --git a/Packages/ShowRecorderKit/Sources/Recording/ShowDetail.swift b/Packages/ShowRecorderKit/Sources/Recording/ShowDetail.swift index 01b4a4e1..aea94b5e 100644 --- a/Packages/ShowRecorderKit/Sources/Recording/ShowDetail.swift +++ b/Packages/ShowRecorderKit/Sources/Recording/ShowDetail.swift @@ -51,7 +51,7 @@ public enum ShowDetail { public static func renameMarker( at index: Int, to name: String, inTake take: Int, ofShow show: String, device: URL, drive: URL? ) -> MarkerRenamer.Outcome { - let takeFolder = String(format: "Take %02d", take) + let takeFolder = TakeMetadata.name(take: take) var copies: [DestinationKind: URL] = [.device: device.appending(path: show).appending(path: takeFolder)] if let drive { let folder = drive.appending(path: show).appending(path: takeFolder) diff --git a/Packages/ShowRecorderKit/Sources/Recording/ShowList.swift b/Packages/ShowRecorderKit/Sources/Recording/ShowList.swift index 315442f2..18552906 100644 --- a/Packages/ShowRecorderKit/Sources/Recording/ShowList.swift +++ b/Packages/ShowRecorderKit/Sources/Recording/ShowList.swift @@ -35,19 +35,19 @@ extension ShowSummary { /// What to say about the Copies, or nil when both are complete (or Repaired). Words as in CONTEXT.md. public var copyNote: CopyNote? { var parts: [(String, CopyNote.Severity)] = [] - func add(_ copy: String, _ outcome: CopyOutcome?, missing: String) { + func add(_ copy: DestinationKind, _ outcome: CopyOutcome?, missing: String) { switch outcome { case nil: parts.append((missing, .warning)) case .complete, .repaired: break - case .hasGaps: parts.append(("\(copy) copy has Gaps", .problem)) - case .repairFailed: parts.append(("\(copy) Repair failed", .problem)) + case .hasGaps: parts.append(("\(copy.name) copy \(CopyOutcome.hasGaps.word)", .problem)) + case .repairFailed: parts.append(("\(copy.name) \(CopyOutcome.repairFailed.word)", .problem)) } } - add("Device", deviceCopy, missing: "No Device copy") + add(.device, deviceCopy, missing: "No Device copy") let driveMissing: String if driveCopyWritten == true { driveMissing = driveConnected ? "Drive copy missing" : "Drive not connected now" } else { driveMissing = "No Drive copy" } - add("Drive", driveCopy, missing: driveMissing) + add(.drive, driveCopy, missing: driveMissing) guard !parts.isEmpty else { return nil } return CopyNote(text: parts.map(\.0).joined(separator: " · "), severity: parts.contains { $0.1 == .problem } ? .problem : .warning) } diff --git a/Packages/ShowRecorderKit/Sources/Recording/StemDeleter.swift b/Packages/ShowRecorderKit/Sources/Recording/StemDeleter.swift index 8dc00709..9b4d83db 100644 --- a/Packages/ShowRecorderKit/Sources/Recording/StemDeleter.swift +++ b/Packages/ShowRecorderKit/Sources/Recording/StemDeleter.swift @@ -22,7 +22,7 @@ public enum StemDeleter { continue } let showFolder = parent.appending(path: show, directoryHint: .isDirectory) - let takeFolder = showFolder.appending(path: String(format: "Take %02d", take), directoryHint: .isDirectory) + let takeFolder = showFolder.appending(path: TakeMetadata.name(take: take), directoryHint: .isDirectory) do { try delete(channel: channel, in: takeFolder, showFolder: showFolder) deleted.append(kind) diff --git a/Packages/ShowRecorderKit/Sources/Recording/TakeDeleter.swift b/Packages/ShowRecorderKit/Sources/Recording/TakeDeleter.swift index 5816ff5c..d607ace4 100644 --- a/Packages/ShowRecorderKit/Sources/Recording/TakeDeleter.swift +++ b/Packages/ShowRecorderKit/Sources/Recording/TakeDeleter.swift @@ -21,7 +21,7 @@ public enum TakeDeleter { continue } let showFolder = parent.appending(path: show, directoryHint: .isDirectory) - let folder = showFolder.appending(path: String(format: "Take %02d", take), directoryHint: .isDirectory) + let folder = showFolder.appending(path: TakeMetadata.name(take: take), directoryHint: .isDirectory) guard ShowDeleter.isRealFolder(showFolder), ShowDeleter.isRealFolder(folder), FileManager.default.fileExists(atPath: folder.appending(path: TakeMetadata.fileName).path) else { failed.append(.init(copy: kind, reason: "There is no such Take in this Copy.")) diff --git a/Packages/ShowRecorderKit/Sources/Recording/TakeRepair.swift b/Packages/ShowRecorderKit/Sources/Recording/TakeRepair.swift index dffc1126..666c0859 100644 --- a/Packages/ShowRecorderKit/Sources/Recording/TakeRepair.swift +++ b/Packages/ShowRecorderKit/Sources/Recording/TakeRepair.swift @@ -2,15 +2,98 @@ import BroadcastWave import Destinations import Foundation -/// Repair (#14): after a Take ends, fills each Copy's Gaps from the other Copy. -enum TakeRepair { +/// Repair (#14): after a Take ends, fills each Copy's Gaps from the other Copy. Takes are repaired one at a +/// time in the order they ended, and the latest Take's Copy outcomes are published. `Recorder` publishes +/// `outcomes` and `isRunning`; it does not schedule anything. +@MainActor +final class TakeRepair { + /// Fills the Gaps of `copies` from each other and returns what happened. `TakeRepair.fill` in the app. + typealias Run = @Sendable (_ copies: [Copy], _ metadata: TakeMetadata, _ skip: Set) -> [TakeMetadata.Repair] + + /// How each Copy of the latest Take to be enqueued ended up. Once its Repair has run, with the result. + private(set) var outcomes: [DestinationKind: CopyOutcome] = [:] + /// Whether any Repair is still queued or running. + private(set) var isRunning = false + /// Called after `outcomes` or `isRunning` changes. + var onChange: () -> Void = {} + /// Called after each Repair has written its result into the Take's folders. + var jobDone: () -> Void = {} + + private let freeSpace: (URL) -> Int64 + private let run: Run + private var task: Task? + /// Counts enqueued Takes, so only the latest one's Repair sets `outcomes`. + private var generation = 0 + private var pending = 0 + + /// - Parameters: + /// - freeSpace: free bytes on a Destination, read when a Take is enqueued. + /// - run: does the Repair; replaced in tests. + init(freeSpace: @escaping (URL) -> Int64, run: @escaping Run = { TakeRepair.fill(copies: $0, metadata: $1, skip: $2) }) { + self.freeSpace = freeSpace + self.run = run + } + + /// Queues Repair of an ended Take: fills its Gaps from the other Copy, off the main thread, and + /// records the result in `Take.json`. `access` (the Drive's folder) is held until it's done. + /// A Take with no Gaps has nothing to repair; its outcomes are complete and `access` is released. + func enqueue(_ metadata: TakeMetadata, folders: [URL], holding access: DriveAccess?) { + func outcomes(_ metadata: TakeMetadata) -> [DestinationKind: CopyOutcome] { + Dictionary(uniqueKeysWithValues: folders.indices.map { (DestinationKind(index: $0), metadata.outcome(ofCopy: DestinationKind(index: $0))) }) + } + generation += 1 + let mine = generation + self.outcomes = outcomes(metadata) + onChange() + guard !metadata.gaps.isEmpty else { + access?.end() + return + } + let copies = folders.indices.map { Copy(kind: DestinationKind(index: $0), folder: folders[$0]) } + // Repair must not fill a Destination past the space reserve: a Copy stopped for being nearly + // full stays as it is, with its Gaps. + let reserve = SpaceReserve(channelCount: metadata.usbChannels.count, sampleRate: metadata.sampleRate) + let skip = Set(copies.filter { copy in + reserve.wouldBreach(free: freeSpace(copy.folder), adding: Self.bytesToExtend(copy, in: copies, metadata: metadata)) + }.map(\.kind)) + pending += 1 + isRunning = true + onChange() + let previous = task + let run = run + task = Task { [weak self] in + await previous?.value + let repairs = await Task.detached { run(copies, metadata, skip) }.value + var done = metadata + done.repairs = repairs + for folder in folders { try? done.write(to: folder) } + guard let self else { + access?.end() + return + } + if mine == generation { self.outcomes = outcomes(done) } + jobDone() + access?.end() + pending -= 1 + isRunning = pending > 0 + onChange() + } + } + + /// Waits for the latest enqueued Repair to finish (earlier ones finish first). + func wait() async { + await task?.value + } + + // MARK: - Filling Gaps + struct Copy: Sendable { var kind: DestinationKind var folder: URL } /// The Take's length in frames: the longest Stem in any Copy, or the end of the last Gap. - static func length(copies: [Copy], metadata: TakeMetadata) -> Int { + nonisolated private static func length(copies: [Copy], metadata: TakeMetadata) -> Int { let stems = metadata.usbChannels.map(\.stemFile) return max( metadata.gaps.map(\.end).max() ?? 0, @@ -19,7 +102,7 @@ enum TakeRepair { /// Bytes Repair would add to `copy`'s Stems to bring them to the Take's length. Filling a Gap in the /// middle needs no space; only a Copy that stopped early grows. - static func bytesToExtend(_ copy: Copy, in copies: [Copy], metadata: TakeMetadata) -> Int64 { + nonisolated private static func bytesToExtend(_ copy: Copy, in copies: [Copy], metadata: TakeMetadata) -> Int64 { let length = length(copies: copies, metadata: metadata) return metadata.usbChannels.reduce(into: Int64(0)) { total, channel in let frames = Int(StemWriter.frameCount(of: copy.folder.appending(path: channel.stemFile)) ?? 0) @@ -31,7 +114,7 @@ enum TakeRepair { /// A Gap is repaired only if the other Copy has the samples; where both Copies are missing the /// same stretch it stays silence and is reported as failed. Copies named in `skip` are left alone /// and get no entry: they stay as they were, with their Gaps. - static func run(copies: [Copy], metadata: TakeMetadata, skip: Set = []) -> [TakeMetadata.Repair] { + nonisolated static func fill(copies: [Copy], metadata: TakeMetadata, skip: Set = []) -> [TakeMetadata.Repair] { let markers = metadata.markers.map { StemMarker(position: UInt32(clamping: $0.position), label: $0.name) } let stems = metadata.usbChannels.map(\.stemFile) let length = length(copies: copies, metadata: metadata) diff --git a/Packages/ShowRecorderKit/Sources/Recording/TakeSession.swift b/Packages/ShowRecorderKit/Sources/Recording/TakeSession.swift index 2f4f9197..8cd9ebb9 100644 --- a/Packages/ShowRecorderKit/Sources/Recording/TakeSession.swift +++ b/Packages/ShowRecorderKit/Sources/Recording/TakeSession.swift @@ -39,7 +39,7 @@ final class TakeSession { let ring: SampleRing var writer: TakeWriter /// Held until the Take is finished and Repaired. Only the Drive has one. - var access: DestinationAccess? + var access: DriveAccess? } /// What `checkDestinations` found. @@ -53,7 +53,7 @@ final class TakeSession { struct Finished { var metadata: TakeMetadata var folders: [URL] - var access: DestinationAccess? + var access: DriveAccess? var statuses: [DestinationKind: CopyStatus] var signal: TakeSignal } @@ -75,7 +75,7 @@ final class TakeSession { /// Opens the Stems in each of `folders` (one per Copy, the Device first), writes `Take.json` there /// and starts capturing. `drive` is the access held for the Drive Copy, when there is one. init( - show: Show, folders: [URL], drive: DestinationAccess?, metadata: TakeMetadata, stems: [StemSpec], + show: Show, folders: [URL], drive: DriveAccess?, metadata: TakeMetadata, stems: [StemSpec], info: StemWriter.Info, capture: Capture, preRollFrames: Int = 0, makeStem: @escaping (URL, StemWriter.Info, _ resumingAt: UInt64?) throws -> any StemSink, freeSpace: @escaping (URL) -> Int64 @@ -185,7 +185,7 @@ final class TakeSession { /// /// `drive` is asked only when a Drive could join or rejoin, and each access it returns is either /// kept for the Take or released here. - func checkDestinations(drive: () -> DestinationAccess?) -> Check { + func checkDestinations(drive: () -> DriveAccess?) -> Check { // Keep 60 s of audio free on each Destination. A Copy that's short stops cleanly while another // has room; when the last healthy one is short the Take finalizes, before anything fills. let reserve = SpaceReserve(channelCount: metadata.usbChannels.count, sampleRate: info.sampleRate) @@ -244,21 +244,21 @@ final class TakeSession { /// Starts the Drive Copy, or restarts it over `old`. Both start new Stems in a Take folder under /// `access` and bring the Copy in through the same handshake; they differ in where the folder /// comes from, whether the Stems resume `old`'s and what happens to `old`'s access. - private func attachDrive(_ access: DestinationAccess, replacing old: CopyRecord?, reserve: SpaceReserve) { + private func attachDrive(_ access: DriveAccess, replacing old: CopyRecord?, reserve: SpaceReserve) { var show = show do { let folder: URL if old == nil { - guard !reserve.isLow(free: freeSpace(access.folder)) else { access.release(); return } + guard !reserve.isLow(free: freeSpace(access.folder)) else { access.end(); return } folder = try show.joinDrive(access.folder) } else { // The Drive may have come back at a different path (a remount), so find the Take folder // under the folder we were just given. folder = access.folder .appending(path: show.name, directoryHint: .isDirectory) - .appending(path: String(format: "Take %02d", show.takeCount), directoryHint: .isDirectory) + .appending(path: TakeMetadata.name(take: show.takeCount), directoryHint: .isDirectory) guard FileManager.default.fileExists(atPath: folder.path), !reserve.isLow(free: freeSpace(folder)) else { - access.release() + access.end() return } try show.useDrive(access.folder) @@ -280,10 +280,10 @@ final class TakeSession { let record = CopyRecord(kind: .drive, folder: folder, ring: ring, writer: writer, access: access) if old == nil { copies.append(record) } else { copies[1] = record } - old?.access?.release() + old?.access?.end() self.show = show } catch { - access.release() + access.end() } } diff --git a/Packages/ShowRecorderKit/Sources/ShowReport/ShowReport.swift b/Packages/ShowRecorderKit/Sources/ShowReport/ShowReport.swift index 70fdb089..393f871b 100644 --- a/Packages/ShowRecorderKit/Sources/ShowReport/ShowReport.swift +++ b/Packages/ShowRecorderKit/Sources/ShowReport/ShowReport.swift @@ -229,14 +229,14 @@ public struct ShowReport: Equatable, Sendable { if !take.gaps.isEmpty { out += "

Gaps

\n

Missing from that Copy and held as silence until repaired from the other Copy.

\n
\n\n\n" for gap in take.gaps { - out += "\n" + out += "\n" } out += "\n
CopyFromToResult
\(Self.name(of: gap.copy))\(Self.markerClock(gap.startSeconds))\(Self.markerClock(gap.endSeconds))\(Self.result(of: gap.repair))
\(gap.copy.name)\(Self.markerClock(gap.startSeconds))\(Self.markerClock(gap.endSeconds))\(Self.result(of: gap.repair))
\n" } if !take.dropouts.isEmpty { out += "

Dropouts

\n

Audio the recorder couldn't keep up with. It is silence of the right length in that Copy's Stems.

\n
\n\n\n" for dropout in take.dropouts { - out += "\n" + out += "\n" } out += "\n
CopyFromToLength
\(Self.name(of: dropout.copy))\(Self.markerClock(dropout.startSeconds))\(Self.markerClock(dropout.endSeconds))\(Self.markerClock(dropout.lengthSeconds))
\(dropout.copy.name)\(Self.markerClock(dropout.startSeconds))\(Self.markerClock(dropout.endSeconds))\(Self.markerClock(dropout.lengthSeconds))
\n" } @@ -244,21 +244,9 @@ public struct ShowReport: Equatable, Sendable { return out } - /// "Device" or "Drive". - static func name(of copy: DestinationKind) -> String { - switch copy { - case .device: "Device" - case .drive: "Drive" - } - } - /// What Repair did about a Gap, in words. static func result(of repair: TakeMetadata.Repair.Outcome?) -> String { - switch repair { - case .repaired: "Repaired" - case .failed: "Repair failed" - case nil: "Not repaired" - } + repair?.word ?? "Not repaired" } /// A level in dBFS to a tenth, with "silent" for a channel that had no signal. diff --git a/Packages/ShowRecorderKit/Sources/TakeFile/TakeMetadata.swift b/Packages/ShowRecorderKit/Sources/TakeFile/TakeMetadata.swift index 37e0d2a2..12386e3d 100644 --- a/Packages/ShowRecorderKit/Sources/TakeFile/TakeMetadata.swift +++ b/Packages/ShowRecorderKit/Sources/TakeFile/TakeMetadata.swift @@ -124,6 +124,14 @@ public struct TakeMetadata: Codable, Equatable, Sendable { case repaired /// The other Copy didn't have those samples either, or they couldn't be copied. case failed + + /// "Repaired" or "Repair failed", as the operator reads it. + public var word: String { + switch self { + case .repaired: "Repaired" + case .failed: "Repair failed" + } + } } public var copy: DestinationKind @@ -168,6 +176,11 @@ public struct TakeMetadata: Codable, Equatable, Sendable { public static let fileName = "Take.json" + /// "Take 01": a Take's name, and the name of its folder in every Copy. + public static func name(take: Int) -> String { + String(format: "Take %02d", take) + } + enum CodingKeys: String, CodingKey { case show, take, startedAt, sampleRate, timeReference, usbChannels, markers, gaps, repairs, preRollFrames, dropouts, renames } @@ -220,4 +233,14 @@ public enum CopyOutcome: String, Sendable, Equatable { case hasGaps case repaired case repairFailed + + /// The outcome as the operator reads it: "complete", "has Gaps", "Repaired" or "Repair failed". + public var word: String { + switch self { + case .complete: "complete" + case .hasGaps: "has Gaps" + case .repaired: TakeMetadata.Repair.Outcome.repaired.word + case .repairFailed: TakeMetadata.Repair.Outcome.failed.word + } + } } diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/BothDestinationsTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/BothDestinationsTests.swift index 980f33c3..e23b3b5e 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/BothDestinationsTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/BothDestinationsTests.swift @@ -1,6 +1,7 @@ import AudioIO import Foundation import MixerLink +import Destinations @testable import Recording import Testing @@ -31,7 +32,7 @@ struct BothDestinationsTests { func copiesAreByteIdentical() async throws { let audio = FakeAudioDevice(inputChannelCount: 4) let drive = drive - let recorder = Recorder(deviceFolder: device, driveFolder: { DestinationAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) + let recorder = Recorder(deviceFolder: device, driveFolder: { DriveAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) try recorder.arm(audio) for take in 0..<2 { @@ -71,7 +72,7 @@ struct BothDestinationsTests { func driveAppearingMidShowJoinsNextTake() throws { let audio = FakeAudioDevice(inputChannelCount: 1) let available = DriveSwitch() - let recorder = Recorder(deviceFolder: device, driveFolder: { available.folder.map { DestinationAccess(folder: $0) } }, now: { RecordingTakeTests.showDay }) + let recorder = Recorder(deviceFolder: device, driveFolder: { available.folder.map { DriveAccess(folder: $0) } }, now: { RecordingTakeTests.showDay }) try recorder.arm(audio) try recorder.startTake() @@ -92,7 +93,7 @@ struct BothDestinationsTests { try FileManager.default.createDirectory(at: drive.appending(path: "2026-10-06 Show"), withIntermediateDirectories: true) let audio = FakeAudioDevice(inputChannelCount: 1) let drive = drive - let recorder = Recorder(deviceFolder: device, driveFolder: { DestinationAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) + let recorder = Recorder(deviceFolder: device, driveFolder: { DriveAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) try recorder.arm(audio) try recorder.startTake() @@ -108,7 +109,7 @@ struct BothDestinationsTests { let audio = FakeAudioDevice(inputChannelCount: 1) let released = ReleaseCounter() let drive = drive - let recorder = Recorder(deviceFolder: device, driveFolder: { DestinationAccess(folder: drive, release: { released.count += 1 }) }, now: { RecordingTakeTests.showDay }) + let recorder = Recorder(deviceFolder: device, driveFolder: { DriveAccess(folder: drive, stop: { released.increment() }) }, now: { RecordingTakeTests.showDay }) try recorder.arm(audio) try recorder.startTake() @@ -123,8 +124,3 @@ struct BothDestinationsTests { final class DriveSwitch { var folder: URL? } - -@MainActor -final class ReleaseCounter { - var count = 0 -} diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/ChannelRenamerTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/ChannelRenamerTests.swift index 85df514a..7f6ddd44 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/ChannelRenamerTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/ChannelRenamerTests.swift @@ -23,7 +23,7 @@ struct ChannelRenamerTests { func recordTake() throws { let audio = FakeAudioDevice(inputChannelCount: 2) let drive = drive - let recorder = Recorder(deviceFolder: device, driveFolder: { DestinationAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) + let recorder = Recorder(deviceFolder: device, driveFolder: { DriveAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) try recorder.arm(audio) try recorder.startTake() audio.deliver((0..<2).map { channel in (0..<4_800).map { Float($0 % 1000 + channel * 1000) / 8_388_608 } }) @@ -96,7 +96,7 @@ struct ChannelRenamerTests { func nameDuringTake() async throws { let audio = FakeAudioDevice(inputChannelCount: 2) let drive = drive - let recorder = Recorder(deviceFolder: device, driveFolder: { DestinationAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) + let recorder = Recorder(deviceFolder: device, driveFolder: { DriveAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) try recorder.arm(audio) try recorder.startTake() audio.deliver([[0.1], [0.1]]) @@ -122,7 +122,7 @@ struct ChannelRenamerTests { func recorder() -> Recorder { let drive = drive - return Recorder(deviceFolder: device, driveFolder: { DestinationAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) + return Recorder(deviceFolder: device, driveFolder: { DriveAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) } @Test("Names typed for the Show come back after a relaunch, and the next Take starts with them") diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/CopyCheckTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/CopyCheckTests.swift index 2bd3acdc..4e92e22d 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/CopyCheckTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/CopyCheckTests.swift @@ -23,7 +23,7 @@ struct CopyCheckTests { /// Two Takes of 2 channels in both Copies, the Show ended. func record() throws { let drive = drive - let recorder = Recorder(deviceFolder: device, driveFolder: { DestinationAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) + let recorder = Recorder(deviceFolder: device, driveFolder: { DriveAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) let audio = FakeAudioDevice(inputChannelCount: 2) try recorder.arm(audio) for _ in 1...2 { @@ -102,7 +102,7 @@ struct CopyCheckTests { func recorderWithDrive(_ connected: Bool = true) -> Recorder { let drive = drive - return Recorder(deviceFolder: device, driveFolder: { connected ? DestinationAccess(folder: drive) : nil }, now: { RecordingTakeTests.showDay }) + return Recorder(deviceFolder: device, driveFolder: { connected ? DriveAccess(folder: drive) : nil }, now: { RecordingTakeTests.showDay }) } @Test("Deleting the Device copy after the check removes only the Device copy; a failed check, a missing Drive or an open Show deletes nothing") diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/CopySummaryTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/CopySummaryTests.swift index dcad53d4..84f873c6 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/CopySummaryTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/CopySummaryTests.swift @@ -4,6 +4,12 @@ import Testing /// The record screen's line about how the last Take's Copies ended up. @Suite("Copy summary") struct CopySummaryTests { + @Test("Each outcome has its word: complete, has Gaps, Repaired, Repair failed") + func everyOutcomeWord() { + #expect(CopySummary(outcomes: [.device: .hasGaps, .drive: .repaired], dropoutCount: 0).text == "Device copy: has Gaps · Drive copy: Repaired") + #expect(CopySummary(outcomes: [.device: .complete, .drive: .repairFailed], dropoutCount: 0).text == "Device copy: complete · Drive copy: Repair failed") + } + @Test("Two complete Copies and no Dropouts read as before") func twoCopies() { let summary = CopySummary(outcomes: [.device: .complete, .drive: .complete], dropoutCount: 0) diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/FreeTakeTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/FreeTakeTests.swift index 2c35aede..d51093fe 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/FreeTakeTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/FreeTakeTests.swift @@ -47,7 +47,7 @@ struct FreeTakeTests { let drive = root.appending(path: "Drive") try FileManager.default.createDirectory(at: drive, withIntermediateDirectories: true) let device = root.appending(path: "Device") - let recorder = Recorder(deviceFolder: device, driveFolder: { DestinationAccess(folder: drive) }, now: { RecordingTakeTests.showDay }, destinationCheckInterval: nil) + let recorder = Recorder(deviceFolder: device, driveFolder: { DriveAccess(folder: drive) }, now: { RecordingTakeTests.showDay }, destinationCheckInterval: nil) let audio = FakeAudioDevice(inputChannelCount: 3) try recorder.arm(audio) // Two recorded channels with the report and project on (the report itself is a Pro extra, see ProExtrasTests). @@ -73,7 +73,7 @@ struct FreeTakeTests { let device = root.appending(path: "Device") final class Plug: @unchecked Sendable { var connected = false } let plug = Plug() - let recorder = Recorder(deviceFolder: device, driveFolder: { plug.connected ? DestinationAccess(folder: drive) : nil }, now: { RecordingTakeTests.showDay }, destinationCheckInterval: nil) + let recorder = Recorder(deviceFolder: device, driveFolder: { plug.connected ? DriveAccess(folder: drive) : nil }, now: { RecordingTakeTests.showDay }, destinationCheckInterval: nil) let audio = FakeAudioDevice(inputChannelCount: 3) try recorder.arm(audio) try recorder.startTake(allowance: allowance([2, 3])) diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/GapTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/GapTests.swift index 030ef6b1..cedc68a4 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/GapTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/GapTests.swift @@ -49,7 +49,7 @@ struct GapTests { let drive = drive return Recorder( deviceFolder: device, - driveFolder: { DestinationAccess(folder: drive) }, + driveFolder: { DriveAccess(folder: drive) }, now: { RecordingTakeTests.showDay }, makeStem: { url, info, resuming in let real = try StemWriter.open(url: url, info: info, resumingAt: resuming) @@ -104,7 +104,7 @@ struct GapTests { let drive = drive let recorder = Recorder( deviceFolder: device, - driveFolder: { DestinationAccess(folder: drive) }, + driveFolder: { DriveAccess(folder: drive) }, now: { RecordingTakeTests.showDay }, makeStem: { url, info, resuming in let real = try StemWriter.open(url: url, info: info, resumingAt: resuming) @@ -153,7 +153,7 @@ struct GapTests { func driveJoinsMidTake() async throws { let audio = FakeAudioDevice(inputChannelCount: 1) let available = DriveSwitch() - let recorder = Recorder(deviceFolder: device, driveFolder: { available.folder.map { DestinationAccess(folder: $0) } }, now: { RecordingTakeTests.showDay }, destinationCheckInterval: nil) + let recorder = Recorder(deviceFolder: device, driveFolder: { available.folder.map { DriveAccess(folder: $0) } }, now: { RecordingTakeTests.showDay }, destinationCheckInterval: nil) try recorder.arm(audio) try recorder.startTake() @@ -262,7 +262,7 @@ struct GapTests { func joiningDriveGetsEarlierMarkers() async throws { let audio = FakeAudioDevice(inputChannelCount: 1) let available = DriveSwitch() - let recorder = Recorder(deviceFolder: device, driveFolder: { available.folder.map { DestinationAccess(folder: $0) } }, now: { RecordingTakeTests.showDay }, destinationCheckInterval: nil) + let recorder = Recorder(deviceFolder: device, driveFolder: { available.folder.map { DriveAccess(folder: $0) } }, now: { RecordingTakeTests.showDay }, destinationCheckInterval: nil) try recorder.arm(audio) try recorder.startTake() @@ -287,7 +287,7 @@ struct GapTests { let remounted = root.appending(path: "Drive 2") let recorder = Recorder( deviceFolder: device, - driveFolder: { available.folder.map { DestinationAccess(folder: $0) } }, + driveFolder: { available.folder.map { DriveAccess(folder: $0) } }, now: { RecordingTakeTests.showDay }, makeStem: { url, info, resuming in let real = try StemWriter.open(url: url, info: info, resumingAt: resuming) @@ -316,7 +316,7 @@ struct GapTests { let free = FreeSpace(device: 100_000_000, drive: 100_000_000) let recorder = Recorder( deviceFolder: device, - driveFolder: { DestinationAccess(folder: drive) }, + driveFolder: { DriveAccess(folder: drive) }, now: { RecordingTakeTests.showDay }, freeSpace: { free.bytes(at: $0) }, destinationCheckInterval: nil) try recorder.arm(audio) diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/MarkerListTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/MarkerListTests.swift index cd33be2a..978d902c 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/MarkerListTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/MarkerListTests.swift @@ -17,15 +17,14 @@ struct MarkerListTests { try FileManager.default.createDirectory(at: root.appending(path: "Drive"), withIntermediateDirectories: true) } - func recorder(drive available: Bool = true, released: Counter? = nil) -> Recorder { + func recorder(drive available: Bool = true, released: ReleaseCounter? = nil) -> Recorder { let drive = drive return Recorder( deviceFolder: device, - driveFolder: { available ? DestinationAccess(folder: drive, release: { released?.count += 1 }) : nil }, + driveFolder: { available ? DriveAccess(folder: drive, stop: { released?.increment() }) : nil }, now: { RecordingTakeTests.showDay }, destinationCheckInterval: nil) } - final class Counter { var count = 0 } @Test("Each Marker is listed with its time in the Take, its name and its index among the operator's Markers") func listsMarkers() throws { @@ -111,7 +110,7 @@ struct MarkerListTests { @Test("After the Take a Marker can be renamed in both Copies, with the Drive reached again for the rename and let go after") func renameAfterTheTake() async throws { let audio = FakeAudioDevice(inputChannelCount: 1) - let released = Counter() + let released = ReleaseCounter() let recorder = recorder(released: released) try recorder.arm(audio) try recorder.startTake() @@ -140,7 +139,7 @@ struct MarkerListTests { let drive = drive let audio = FakeAudioDevice(inputChannelCount: 1) let recorder = Recorder( - deviceFolder: device, driveFolder: { flag.available ? DestinationAccess(folder: drive) : nil }, + deviceFolder: device, driveFolder: { flag.available ? DriveAccess(folder: drive) : nil }, now: { RecordingTakeTests.showDay }, destinationCheckInterval: nil) try recorder.arm(audio) try recorder.startTake() @@ -179,7 +178,7 @@ struct MarkerListTests { let audio = FakeAudioDevice(inputChannelCount: 1) let drive = drive let recorder = Recorder( - deviceFolder: device, driveFolder: { DestinationAccess(folder: drive) }, now: { RecordingTakeTests.showDay }, + deviceFolder: device, driveFolder: { DriveAccess(folder: drive) }, now: { RecordingTakeTests.showDay }, preRollSeconds: 1, destinationCheckInterval: nil) try recorder.arm(audio) audio.deliver([Array(repeating: 0, count: 48_000)]) // 1 s Armed: the Pre-roll diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/MarkerRenameTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/MarkerRenameTests.swift index 1af78237..5b360be0 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/MarkerRenameTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/MarkerRenameTests.swift @@ -22,7 +22,7 @@ struct MarkerRenameTests { func recorder() -> Recorder { let drive = drive - return Recorder(deviceFolder: device, driveFolder: { DestinationAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) + return Recorder(deviceFolder: device, driveFolder: { DriveAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) } func names(inTakeJSONOf copy: URL) throws -> [String] { diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/MarkerRenamerTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/MarkerRenamerTests.swift index e0bf4528..f0e8e01c 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/MarkerRenamerTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/MarkerRenamerTests.swift @@ -23,7 +23,7 @@ struct MarkerRenamerTests { func recordTake() throws { let audio = FakeAudioDevice(inputChannelCount: 2) let drive = drive - let recorder = Recorder(deviceFolder: device, driveFolder: { DestinationAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) + let recorder = Recorder(deviceFolder: device, driveFolder: { DriveAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) try recorder.arm(audio) try recorder.startTake() func deliver(_ frames: Int) { @@ -126,7 +126,7 @@ struct MarkerRenamerTests { func dropoutCuesSurvive() throws { let audio = FakeAudioDevice(inputChannelCount: 1) let drive = drive - let recorder = Recorder(deviceFolder: device, driveFolder: { DestinationAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) + let recorder = Recorder(deviceFolder: device, driveFolder: { DriveAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) try recorder.arm(audio) try recorder.startTake() audio.deliver([Array(repeating: 0, count: 4_800)]) diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/MarkerTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/MarkerTests.swift index 82d0d91b..4d238dc7 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/MarkerTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/MarkerTests.swift @@ -1,5 +1,6 @@ import AudioIO import Foundation +import Destinations @testable import Recording import Testing @@ -23,7 +24,7 @@ struct MarkerTests { func markerAtCurrentPosition() throws { let audio = FakeAudioDevice(inputChannelCount: 3) let drive = drive - let recorder = Recorder(deviceFolder: device, driveFolder: { DestinationAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) + let recorder = Recorder(deviceFolder: device, driveFolder: { DriveAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) try recorder.arm(audio) try recorder.startTake() diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/NewShowTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/NewShowTests.swift index c2a89a78..f56ca7a9 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/NewShowTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/NewShowTests.swift @@ -22,7 +22,7 @@ struct NewShowTests { let drive = drive return Recorder( deviceFolder: device, - driveFolder: { withDrive ? DestinationAccess(folder: drive) : nil }, + driveFolder: { withDrive ? DriveAccess(folder: drive) : nil }, now: { RecordingTakeTests.showDay }) } diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/PreRollTakeTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/PreRollTakeTests.swift index 766b8a74..58e94fe8 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/PreRollTakeTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/PreRollTakeTests.swift @@ -47,7 +47,7 @@ struct PreRollTakeTests { let drive = drive return Recorder( deviceFolder: device, - driveFolder: { available?.value == true ? DestinationAccess(folder: drive) : nil }, + driveFolder: { available?.value == true ? DriveAccess(folder: drive) : nil }, now: { RecordingTakeTests.showDay }, preRollSeconds: seconds, destinationCheckInterval: nil) } diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/RepairTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/RepairTests.swift index 69af3998..ab9a7d45 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/RepairTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/RepairTests.swift @@ -72,7 +72,7 @@ extension GapTests { func repairFillsJoinGap() async throws { let audio = FakeAudioDevice(inputChannelCount: 1) let available = DriveSwitch() - let recorder = Recorder(deviceFolder: device, driveFolder: { available.folder.map { DestinationAccess(folder: $0) } }, now: { RecordingTakeTests.showDay }, destinationCheckInterval: nil) + let recorder = Recorder(deviceFolder: device, driveFolder: { available.folder.map { DriveAccess(folder: $0) } }, now: { RecordingTakeTests.showDay }, destinationCheckInterval: nil) try recorder.arm(audio) try recorder.startTake() @@ -128,7 +128,7 @@ extension GapTests { let free = FreeSpace(device: 100_000_000, drive: 100_000_000) let recorder = Recorder( deviceFolder: device, - driveFolder: { DestinationAccess(folder: drive) }, + driveFolder: { DriveAccess(folder: drive) }, now: { RecordingTakeTests.showDay }, freeSpace: { free.bytes(at: $0) }, destinationCheckInterval: nil) try recorder.arm(audio) diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/ShowDeleterTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/ShowDeleterTests.swift index f35220a1..e0f8aad8 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/ShowDeleterTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/ShowDeleterTests.swift @@ -23,7 +23,7 @@ struct ShowDeleterTests { func recorder() -> Recorder { let clock = clock, drive = drive - return Recorder(deviceFolder: device, driveFolder: { DestinationAccess(folder: drive) }, now: { MainActor.assumeIsolated { clock.now } }) + return Recorder(deviceFolder: device, driveFolder: { DriveAccess(folder: drive) }, now: { MainActor.assumeIsolated { clock.now } }) } /// Records one Take in "2026-10-06 Show", ends it, then records another Show a day later (left open). diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/ShowDetailTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/ShowDetailTests.swift index 8df0e57b..6d3a0890 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/ShowDetailTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/ShowDetailTests.swift @@ -36,7 +36,7 @@ struct ShowDetailTests { func recorder() -> Recorder { let clock = clock, drive = drive - return Recorder(deviceFolder: device, driveFolder: { DestinationAccess(folder: drive) }, now: { MainActor.assumeIsolated { clock.now } }) + return Recorder(deviceFolder: device, driveFolder: { DriveAccess(folder: drive) }, now: { MainActor.assumeIsolated { clock.now } }) } @Test("Markers are listed by Take with their time in the Take; a Take with none lists none; a Dropout Marker has no rename index") diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/ShowListTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/ShowListTests.swift index 961f9dff..fd46d60f 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/ShowListTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/ShowListTests.swift @@ -24,7 +24,7 @@ struct ShowListTests { let clock = clock, drive = drive return Recorder( deviceFolder: device, - driveFolder: { withDrive ? DestinationAccess(folder: drive) : nil }, + driveFolder: { withDrive ? DriveAccess(folder: drive) : nil }, now: { MainActor.assumeIsolated { clock.now } }) } diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/ShowPersistenceTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/ShowPersistenceTests.swift index 495f2916..e6ef8a40 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/ShowPersistenceTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/ShowPersistenceTests.swift @@ -23,7 +23,7 @@ struct ShowPersistenceTests { let drive = drive return Recorder( deviceFolder: device, - driveFolder: { withDrive ? DestinationAccess(folder: drive) : nil }, + driveFolder: { withDrive ? DriveAccess(folder: drive) : nil }, now: { now }) } diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/StemDeleterTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/StemDeleterTests.swift index bbd34c1f..dcdf66a2 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/StemDeleterTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/StemDeleterTests.swift @@ -22,7 +22,7 @@ struct StemDeleterTests { /// One Take of 3 channels in an ended Show, in both Copies. func record() throws -> Recorder { let drive = drive - let recorder = Recorder(deviceFolder: device, driveFolder: { DestinationAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) + let recorder = Recorder(deviceFolder: device, driveFolder: { DriveAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) let audio = FakeAudioDevice(inputChannelCount: 3) try recorder.arm(audio) try recorder.startTake() diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/Support/ReleaseCounter.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/Support/ReleaseCounter.swift new file mode 100644 index 00000000..eef02c3b --- /dev/null +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/Support/ReleaseCounter.swift @@ -0,0 +1,8 @@ +import Synchronization + +/// Counts how often a Drive's access was ended. The app may end it from any thread, so this counts safely. +final class ReleaseCounter: Sendable { + private let value = Mutex(0) + var count: Int { value.withLock { $0 } } + func increment() { value.withLock { $0 += 1 } } +} diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/TakeDeleterTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/TakeDeleterTests.swift index 3d0894dc..384ddca2 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/TakeDeleterTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/TakeDeleterTests.swift @@ -22,7 +22,7 @@ struct TakeDeleterTests { /// Three Takes in an ended Show, in both Copies. func threeTakes() throws -> Recorder { let drive = drive - let recorder = Recorder(deviceFolder: device, driveFolder: { DestinationAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) + let recorder = Recorder(deviceFolder: device, driveFolder: { DriveAccess(folder: drive) }, now: { RecordingTakeTests.showDay }) let audio = FakeAudioDevice(inputChannelCount: 1) try recorder.arm(audio) for _ in 1...3 { diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/TakeEndTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/TakeEndTests.swift index 1f9c91e8..4f0123b0 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/TakeEndTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/TakeEndTests.swift @@ -36,7 +36,7 @@ struct TakeEndTests { let free = FreeSpace(device: 100_000_000, drive: 100_000_000) let recorder = Recorder( deviceFolder: device, - driveFolder: { DestinationAccess(folder: drive) }, + driveFolder: { DriveAccess(folder: drive) }, now: { RecordingTakeTests.showDay }, freeSpace: { free.bytes(at: $0) }, destinationCheckInterval: .milliseconds(10)) @@ -77,7 +77,7 @@ struct TakeEndTests { let available = DriveAvailability() let recorder = Recorder( deviceFolder: device, - driveFolder: { available.isOn ? DestinationAccess(folder: drive) : nil }, + driveFolder: { available.isOn ? DriveAccess(folder: drive) : nil }, now: { RecordingTakeTests.showDay }, destinationCheckInterval: .milliseconds(10)) try recorder.arm(audio) diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/TakeNameTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/TakeNameTests.swift new file mode 100644 index 00000000..ded37efe --- /dev/null +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/TakeNameTests.swift @@ -0,0 +1,12 @@ +@testable import Recording +import Testing + +@Suite("Take names") +struct TakeNameTests { + @Test("A Take's name, which is also its folder's, has its number in two digits or more") + func name() { + #expect(TakeMetadata.name(take: 1) == "Take 01") + #expect(TakeMetadata.name(take: 12) == "Take 12") + #expect(TakeMetadata.name(take: 100) == "Take 100") + } +} diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/RepairQueueTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/TakeRepairTests.swift similarity index 85% rename from Packages/ShowRecorderKit/Tests/RecordingTests/RepairQueueTests.swift rename to Packages/ShowRecorderKit/Tests/RecordingTests/TakeRepairTests.swift index 91deba74..2ac17384 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/RepairQueueTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/TakeRepairTests.swift @@ -5,14 +5,14 @@ import MixerLink @testable import Recording import Testing -/// The Repair queue on its own: no recorder, no recorded Takes. +/// Repair on its own: no recorder, no recorded Takes. @MainActor -@Suite("Repair queue") -struct RepairQueueTests { +@Suite("Repair") +struct TakeRepairTests { let root: URL init() throws { - root = FileManager.default.temporaryDirectory.appending(path: "RepairQueueTests-\(UUID().uuidString)") + root = FileManager.default.temporaryDirectory.appending(path: "TakeRepairTests-\(UUID().uuidString)") } /// What the stand-in Repair was asked, and a way to hold it until a test lets it go. @@ -58,33 +58,33 @@ struct RepairQueueTests { @Test("A Take with no Gaps is complete at once, releases its access and runs nothing") func noGaps() throws { let runner = Runner() - let queue = RepairQueue(freeSpace: { _ in .max }, run: runner.run) - var released = 0 - queue.enqueue(metadata(take: 1, gaps: []), folders: try folders("a"), holding: DestinationAccess(folder: root, release: { released += 1 })) + let queue = TakeRepair(freeSpace: { _ in .max }, run: runner.run) + let released = ReleaseCounter() + queue.enqueue(metadata(take: 1, gaps: []), folders: try folders("a"), holding: DriveAccess(folder: root, stop: { released.increment() })) #expect(queue.outcomes == [.device: .complete, .drive: .complete]) #expect(!queue.isRunning) - #expect(released == 1) + #expect(released.count == 1) #expect(runner.takes.isEmpty) } @Test("A Take with Gaps shows them as unrepaired until Repair has run, then holds its access to the end") func repairsAndReleases() async throws { let runner = Runner() - let queue = RepairQueue(freeSpace: { _ in .max }, run: runner.run) - var released = 0 + let queue = TakeRepair(freeSpace: { _ in .max }, run: runner.run) + let released = ReleaseCounter() var changes = 0 queue.onChange = { changes += 1 } - queue.enqueue(metadata(take: 1, gaps: [driveGap]), folders: try folders("a", deviceFrames: 4800, driveFrames: 480), holding: DestinationAccess(folder: root, release: { released += 1 })) + queue.enqueue(metadata(take: 1, gaps: [driveGap]), folders: try folders("a", deviceFrames: 4800, driveFrames: 480), holding: DriveAccess(folder: root, stop: { released.increment() })) #expect(queue.outcomes == [.device: .complete, .drive: .hasGaps]) #expect(queue.isRunning) - #expect(released == 0) + #expect(released.count == 0) await queue.wait() #expect(queue.outcomes == [.device: .complete, .drive: .repaired]) #expect(!queue.isRunning) - #expect(released == 1) + #expect(released.count == 1) #expect(changes >= 3) } @@ -92,7 +92,7 @@ struct RepairQueueTests { func latestTakeWins() async throws { let runner = Runner() runner.held = true - let queue = RepairQueue(freeSpace: { _ in .max }, run: runner.run) + let queue = TakeRepair(freeSpace: { _ in .max }, run: runner.run) var seenWhenFirstDone: [DestinationKind: CopyOutcome]? queue.jobDone = { if seenWhenFirstDone == nil { seenWhenFirstDone = queue.outcomes } } @@ -115,7 +115,7 @@ struct RepairQueueTests { let drive = root.appending(path: "a/Drive").path // The Drive Copy stopped at 480 frames and would grow by 4320 frames of 24-bit audio. let extra = Int64(4320 * StemWriter.bytesPerSample) - let queue = RepairQueue(freeSpace: { $0.path == drive ? reserve.bytes + extra - 1 : .max }, run: runner.run) + let queue = TakeRepair(freeSpace: { $0.path == drive ? reserve.bytes + extra - 1 : .max }, run: runner.run) queue.enqueue(metadata(take: 1, gaps: [driveGap]), folders: try folders("a", deviceFrames: 4800, driveFrames: 480), holding: nil) await queue.wait() @@ -123,7 +123,7 @@ struct RepairQueueTests { #expect(queue.outcomes[.drive] == .hasGaps) // One byte more room and it is repaired. - let roomy = RepairQueue(freeSpace: { $0.path == drive ? reserve.bytes + extra : .max }, run: runner.run) + let roomy = TakeRepair(freeSpace: { $0.path == drive ? reserve.bytes + extra : .max }, run: runner.run) roomy.enqueue(metadata(take: 2, gaps: [driveGap]), folders: try folders("b", deviceFrames: 4800, driveFrames: 480), holding: nil) await roomy.wait() #expect(runner.skips.last == []) diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/TakeSessionTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/TakeSessionTests.swift index b544c79d..1d595996 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/TakeSessionTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/TakeSessionTests.swift @@ -69,7 +69,7 @@ struct TakeSessionTests { let session = try session() try await deliver(480) let drive = drive - let check = session.checkDestinations(drive: { DestinationAccess(folder: drive) }) + let check = session.checkDestinations(drive: { DriveAccess(folder: drive) }) #expect(check == .carryOn) #expect(session.copies.map(\.kind) == [.device, .drive]) @@ -89,7 +89,7 @@ struct TakeSessionTests { let session = try session() var asked = 0 let drive = drive - _ = session.checkDestinations(drive: { asked += 1; return DestinationAccess(folder: drive) }) + _ = session.checkDestinations(drive: { asked += 1; return DriveAccess(folder: drive) }) #expect(asked == 1) // The Drive Copy is healthy: nothing to join or rejoin, so it isn't asked again. _ = session.checkDestinations(drive: { asked += 1; return nil }) @@ -101,10 +101,10 @@ struct TakeSessionTests { func fullDriveIsReleased() async throws { let drive = drive let session = try session(free: { $0.path.hasPrefix(drive.path) ? 0 : .max }) - var released = 0 - let check = session.checkDestinations(drive: { DestinationAccess(folder: drive, release: { released += 1 }) }) + let released = ReleaseCounter() + let check = session.checkDestinations(drive: { DriveAccess(folder: drive, stop: { released.increment() }) }) #expect(check == .carryOn) - #expect(released == 1) + #expect(released.count == 1) #expect(session.copies.count == 1) _ = session.finish() } @@ -117,25 +117,25 @@ struct TakeSessionTests { return fresh ? FailingStem(real, healthyFrames: 480) : real }) let drive = drive - var firstReleased = 0 - var secondReleased = 0 + let firstReleased = ReleaseCounter() + let secondReleased = ReleaseCounter() try await deliver(480) - _ = session.checkDestinations(drive: { DestinationAccess(folder: drive, release: { firstReleased += 1 }) }) + _ = session.checkDestinations(drive: { DriveAccess(folder: drive, stop: { firstReleased.increment() }) }) for _ in 0..<4 { try await deliver(480) } try await waitUntil { session.status(.drive) == .interrupted && session.copies[1].writer.isFinished } // It joined at 480 and failed on its first audio: it has the silence and nothing more. let failedAt = Int(session.copies[1].writer.stemFrameCounts[0]) #expect(failedAt == 480) - let check = session.checkDestinations(drive: { DestinationAccess(folder: drive, release: { secondReleased += 1 }) }) + let check = session.checkDestinations(drive: { DriveAccess(folder: drive, stop: { secondReleased.increment() }) }) #expect(check == .carryOn) #expect(opened.resumes.last == UInt64(failedAt)) #expect(session.status(.drive) == .recording) #expect(session.copies.count == 2) // The earlier access is let go; the new one is held to the end. - #expect(firstReleased == 1) - #expect(secondReleased == 0) + #expect(firstReleased.count == 1) + #expect(secondReleased.count == 0) let rejoinFrame = capture.takeFrameCount try await deliver(480) @@ -143,7 +143,7 @@ struct TakeSessionTests { #expect(finished.metadata.gaps == [.init(copy: .drive, start: 0, end: 480), .init(copy: .drive, start: failedAt, end: rejoinFrame)]) #expect(finished.statuses[.drive] == .recording) - #expect(secondReleased == 0) + #expect(secondReleased.count == 0) } @Test("The last healthy Copy running low ends the Take; a Copy running low while another has room retires") @@ -154,7 +154,7 @@ struct TakeSessionTests { let session = try session(free: { url in (url.path.hasPrefix(drive.path) ? driveLow : deviceLow) ? 0 : .max }) - _ = session.checkDestinations(drive: { DestinationAccess(folder: drive) }) + _ = session.checkDestinations(drive: { DriveAccess(folder: drive) }) try await deliver(480) driveLow = true @@ -188,7 +188,7 @@ struct TakeSessionTests { let session = try session() try await deliver(480) let drive = drive - _ = session.checkDestinations(drive: { DestinationAccess(folder: drive) }) + _ = session.checkDestinations(drive: { DriveAccess(folder: drive) }) // No block arrives, so the real-time side never picks the Copy's join frame. let takeEnd = capture.takeFrameCount let finished = session.finish() @@ -207,12 +207,12 @@ struct TakeSessionTests { }) let drive = drive try await deliver(480) - _ = session.checkDestinations(drive: { DestinationAccess(folder: drive) }) + _ = session.checkDestinations(drive: { DriveAccess(folder: drive) }) for _ in 0..<4 { try await deliver(480) } try await waitUntil { session.status(.drive) == .interrupted && session.copies[1].writer.isFinished } let failedAt = Int(session.copies[1].writer.stemFrameCounts[0]) - _ = session.checkDestinations(drive: { DestinationAccess(folder: drive) }) + _ = session.checkDestinations(drive: { DriveAccess(folder: drive) }) let takeEnd = capture.takeFrameCount let finished = session.finish() @@ -227,7 +227,7 @@ struct TakeSessionTests { #expect(marker.name == "Marker 1") #expect(marker.position == 480) let drive = drive - _ = session.checkDestinations(drive: { DestinationAccess(folder: drive) }) + _ = session.checkDestinations(drive: { DriveAccess(folder: drive) }) try await deliver(480) let finished = session.finish() @@ -276,7 +276,7 @@ struct TakeSessionTests { let session = try session() try await deliver(480) let drive = drive - _ = session.checkDestinations(drive: { DestinationAccess(folder: drive) }) + _ = session.checkDestinations(drive: { DriveAccess(folder: drive) }) try await deliver(480) // the Drive joins try await deliver(200_000) // both rings drop it try await deliver(480) @@ -309,7 +309,7 @@ struct TakeSessionTests { try await deliver(480) session.addMarker() let drive = drive - _ = session.checkDestinations(drive: { DestinationAccess(folder: drive) }) + _ = session.checkDestinations(drive: { DriveAccess(folder: drive) }) try await deliver(480) // the Drive joins #expect(session.renameMarker(at: 0, to: "Chorus") == .renamed) try await deliver(480)