diff --git a/ASFW/ASFWDriverConnector.swift b/ASFW/ASFWDriverConnector.swift index be052687..056d49c6 100644 --- a/ASFW/ASFWDriverConnector.swift +++ b/ASFW/ASFWDriverConnector.swift @@ -53,6 +53,9 @@ final class ASFWDriverConnector: ObservableObject { case setIsochVerbosity = 40 case asyncBlockRead = 44 case asyncBlockWrite = 45 + // DV capture (raw DIF stream via shared ring, memory type 1) + case startDVCapture = 50 + case stopDVCapture = 51 } // MARK: - Re-exported Models diff --git a/ASFW/DriverConnector+DVCapture.swift b/ASFW/DriverConnector+DVCapture.swift new file mode 100644 index 00000000..02b3f32f --- /dev/null +++ b/ASFW/DriverConnector+DVCapture.swift @@ -0,0 +1,196 @@ +// +// DriverConnector+DVCapture.swift +// ASFW +// +// DV (IEC 61883-2) capture control and shared DIF ring access. +// +// The driver fills a shared-memory SPSC ring with raw 480-byte DIF chunks +// (one DV source packet each). This file maps the ring (memory type 1) and +// consumes records; the view layer concatenates them into a .dv file. +// +// Ring layout is ABI with ASFWDriver/Isoch/Receive/DVCaptureSink.hpp: +// +0 magic 'ASDV' (0x41534456) +// +4 version (u16) = 1 +// +8 numRecords (u32) +// +12 recordBytes (u32) = 480 +// +16 dataOffsetBytes (u32) = 192 +// +24 packetsSeen (u32) +// +28 dvSourcePackets (u32) +// +32 nonDvPackets (u32) +// +36 overruns (u32) +// +64 writeIndex (u32, driver-owned, free-running records) +// +128 readIndex (u32, app-owned, free-running records) +// +192 record data (numRecords × 480 bytes) +// + +import Foundation +import IOKit + +// MARK: - DV Ring Stats + +struct DVCaptureStats { + var packetsSeen: UInt32 = 0 + var dvSourcePackets: UInt32 = 0 + var nonDvPackets: UInt32 = 0 + var overruns: UInt32 = 0 + var lastRejectLen: UInt32 = 0 + var lastRejectQ0: UInt32 = 0 + var lastRejectQ1: UInt32 = 0 + var lastXferStatus: UInt32 = 0 +} + +// MARK: - Mapped Ring + +/// Consumer-side view of the shared DV ring. Single consumer only. +final class DVCaptureRing { + static let magic: UInt32 = 0x41534456 // 'ASDV' + static let recordBytes = 480 + + private let base: UnsafeMutableRawPointer + private let mappedAddress: mach_vm_address_t + private let connection: io_connect_t + private let numRecords: UInt32 + private let dataOffset: Int + + private init(base: UnsafeMutableRawPointer, + mappedAddress: mach_vm_address_t, + connection: io_connect_t, + numRecords: UInt32, + dataOffset: Int) { + self.base = base + self.mappedAddress = mappedAddress + self.connection = connection + self.numRecords = numRecords + self.dataOffset = dataOffset + } + + static func map(connection: io_connect_t) -> DVCaptureRing? { + var address: mach_vm_address_t = 0 + var length: mach_vm_size_t = 0 + let options = UInt32(kIOMapAnywhere | kIOMapDefaultCache) + let kr = IOConnectMapMemory64(connection, 1, mach_task_self_, &address, &length, options) + guard kr == KERN_SUCCESS, let pointer = UnsafeMutableRawPointer(bitPattern: UInt(address)) else { + return nil + } + + let magic = pointer.load(fromByteOffset: 0, as: UInt32.self) + let numRecords = pointer.load(fromByteOffset: 8, as: UInt32.self) + let recBytes = pointer.load(fromByteOffset: 12, as: UInt32.self) + let dataOffset = pointer.load(fromByteOffset: 16, as: UInt32.self) + + guard magic == DVCaptureRing.magic, + recBytes == UInt32(DVCaptureRing.recordBytes), + numRecords > 0, + UInt64(dataOffset) + UInt64(numRecords) * UInt64(recBytes) <= UInt64(length) else { + IOConnectUnmapMemory64(connection, 1, mach_task_self_, address) + return nil + } + + return DVCaptureRing(base: pointer, + mappedAddress: address, + connection: connection, + numRecords: numRecords, + dataOffset: Int(dataOffset)) + } + + func unmap() { + IOConnectUnmapMemory64(connection, 1, mach_task_self_, mappedAddress) + } + + /// Drain all available records, invoking the handler with each 480-byte chunk. + /// Returns the number of records consumed. + /// + /// Note on ordering: the driver release-stores writeIndex after the memcpy. + /// We poll at tens of milliseconds, so observed records were published long + /// before we read them; explicit acquire barriers are intentionally omitted. + @discardableResult + func drain(_ handler: (UnsafeRawBufferPointer) -> Void) -> Int { + let w = base.load(fromByteOffset: 64, as: UInt32.self) + var r = base.load(fromByteOffset: 128, as: UInt32.self) + var consumed = 0 + + while r != w { + let idx = Int(r % numRecords) + let chunk = UnsafeRawBufferPointer( + start: base + dataOffset + idx * DVCaptureRing.recordBytes, + count: DVCaptureRing.recordBytes + ) + handler(chunk) + r &+= 1 + consumed += 1 + } + + if consumed > 0 { + base.storeBytes(of: r, toByteOffset: 128, as: UInt32.self) + } + return consumed + } + + var stats: DVCaptureStats { + DVCaptureStats( + packetsSeen: base.load(fromByteOffset: 24, as: UInt32.self), + dvSourcePackets: base.load(fromByteOffset: 28, as: UInt32.self), + nonDvPackets: base.load(fromByteOffset: 32, as: UInt32.self), + overruns: base.load(fromByteOffset: 36, as: UInt32.self), + lastRejectLen: base.load(fromByteOffset: 40, as: UInt32.self), + lastRejectQ0: base.load(fromByteOffset: 44, as: UInt32.self), + lastRejectQ1: base.load(fromByteOffset: 48, as: UInt32.self), + lastXferStatus: base.load(fromByteOffset: 52, as: UInt32.self) + ) + } +} + +// MARK: - Driver Connector Extension + +extension ASFWDriverConnector { + + /// Start DV capture on the given isoch channel (camcorders broadcast on 63). + func startDVCapture(channel: UInt8) -> Bool { + guard isConnected, connection != 0 else { return false } + + var input: [UInt64] = [UInt64(channel)] + let kr = IOConnectCallScalarMethod( + connection, + Method.startDVCapture.rawValue, + &input, 1, + nil, nil + ) + + if kr != KERN_SUCCESS { + log("startDVCapture failed: \(interpretIOReturn(kr))", level: .error) + return false + } + log("Started DV capture on channel \(channel)", level: .info) + return true + } + + /// Stop DV capture. + func stopDVCapture() -> Bool { + guard isConnected, connection != 0 else { return false } + + let kr = IOConnectCallScalarMethod( + connection, + Method.stopDVCapture.rawValue, + nil, 0, + nil, nil + ) + + if kr != KERN_SUCCESS { + log("stopDVCapture failed: \(interpretIOReturn(kr))", level: .error) + return false + } + log("Stopped DV capture", level: .info) + return true + } + + /// Map the shared DV ring. Call after startDVCapture succeeds. + func mapDVCaptureRing() -> DVCaptureRing? { + guard isConnected, connection != 0 else { return nil } + guard let ring = DVCaptureRing.map(connection: connection) else { + log("mapDVCaptureRing: mapping failed", level: .error) + return nil + } + log("Mapped DV capture ring", level: .info) + return ring + } +} diff --git a/ASFW/Views/DVCaptureView.swift b/ASFW/Views/DVCaptureView.swift new file mode 100644 index 00000000..73a0f5b5 --- /dev/null +++ b/ASFW/Views/DVCaptureView.swift @@ -0,0 +1,367 @@ +// +// DVCaptureView.swift +// ASFW +// +// Capture DV (MiniDV camcorder) video to a raw .dv file. +// +// Flow: AV/C tape transport (PLAY) via raw FCP → driver receives the isoch +// stream and fills the shared DIF ring → this view drains the ring and +// appends chunks to a .dv file. Raw DV files open directly in QuickTime, +// iMovie, FFmpeg, DaVinci Resolve, etc. +// + +import SwiftUI +import AppKit +import Combine + +// MARK: - Capture Controller + +@MainActor +final class DVCaptureController: ObservableObject { + @Published var isCapturing = false + @Published var bytesWritten: UInt64 = 0 + @Published var framesSeen: Int = 0 + @Published var droppedFrames: Int = 0 + @Published var stats = DVCaptureStats() + @Published var systemLabel = "—" + @Published var lastError: String? + + private var ring: DVCaptureRing? + private var fileHandle: FileHandle? + private var captureTask: Task? + + // Frame accumulator: only exact-size frames are written, so a duplicated + // or lost packet corrupts one frame instead of misaligning the whole file + // (DV demuxers read fixed 120000/144000-byte records with no resync). + private var currentFrame = Data() + private var inFrame = false + private var expectedFrameBytes = 120_000 + + func start(connector: ASFWDriverConnector, channel: UInt8, url: URL) { + guard !isCapturing else { return } + lastError = nil + + guard FileManager.default.createFile(atPath: url.path, contents: nil), + let handle = try? FileHandle(forWritingTo: url) else { + lastError = "Could not create \(url.lastPathComponent)" + return + } + + guard connector.startDVCapture(channel: channel) else { + lastError = connector.lastError ?? "startDVCapture failed (is audio receive running?)" + try? handle.close() + return + } + + guard let mappedRing = connector.mapDVCaptureRing() else { + lastError = "Failed to map DV ring" + _ = connector.stopDVCapture() + try? handle.close() + return + } + + fileHandle = handle + ring = mappedRing + bytesWritten = 0 + framesSeen = 0 + droppedFrames = 0 + currentFrame.removeAll() + inFrame = false + systemLabel = "—" + isCapturing = true + + captureTask = Task { [weak self] in + while !Task.isCancelled { + self?.tick() + try? await Task.sleep(nanoseconds: 33_000_000) // ~30 Hz + } + } + } + + func stop(connector: ASFWDriverConnector) { + guard isCapturing else { return } + + captureTask?.cancel() + captureTask = nil + + // Final drain so the tail of the stream lands in the file. + tick() + flushCurrentFrame() + + _ = connector.stopDVCapture() + ring?.unmap() + ring = nil + try? fileHandle?.close() + fileHandle = nil + isCapturing = false + } + + private func tick() { + guard let ring else { return } + + ring.drain { chunk in + // Frame start: first DIF block is a header-section block (SCT=0) + // with sequence number 0. Masked comparison per Apple's + // AVCVideoServices DVReceiver ((u16 & 0xE0FC) == 0x0004) - the + // unmasked bits are reserved/arbitrary and vary between devices. + let isFrameStart = (chunk[0] & 0xE0) == 0x00 && (chunk[1] & 0xFC) == 0x04 + if isFrameStart { + flushCurrentFrame() + inFrame = true + let isPAL = (chunk[3] & 0x80) != 0 + expectedFrameBytes = isPAL ? 144_000 : 120_000 + if systemLabel == "—" { + systemLabel = isPAL ? "PAL (625/50)" : "NTSC (525/60)" + } + currentFrame.removeAll(keepingCapacity: true) + } + guard inFrame else { return } + currentFrame.append(contentsOf: chunk) + } + + stats = ring.stats + } + + private func flushCurrentFrame() { + guard inFrame, !currentFrame.isEmpty else { return } + if currentFrame.count == expectedFrameBytes, let fileHandle { + fileHandle.write(currentFrame) + bytesWritten += UInt64(currentFrame.count) + framesSeen += 1 + } else { + droppedFrames += 1 + } + } +} + +// MARK: - View + +struct DVCaptureView: View { + @ObservedObject var viewModel: DebugViewModel + @StateObject private var controller = DVCaptureController() + + @State private var avcUnits: [ASFWDriverConnector.AVCUnitInfo] = [] + @State private var selectedUnitGUID: UInt64? + @State private var channelText = "63" + @State private var outputURL: URL? + @State private var transportBusy = false + @State private var transportStatus: String? + + var body: some View { + Form { + statusSection + camcorderSection + captureSection + } + .formStyle(.grouped) + .navigationTitle("DV Capture") + .onAppear { refreshUnits() } + } + + // MARK: Sections + + private var statusSection: some View { + Section("Status") { + if viewModel.isConnected { + Label("Connected to driver", systemImage: "checkmark.circle.fill") + .foregroundColor(.green) + } else { + Label("Driver not connected", systemImage: "xmark.circle.fill") + .foregroundColor(.red) + } + if let error = controller.lastError { + Label(error, systemImage: "exclamationmark.triangle.fill") + .foregroundColor(.red) + } + } + } + + private var camcorderSection: some View { + Section("Camcorder Transport (AV/C Tape Subunit)") { + if avcUnits.isEmpty { + HStack { + Text("No AV/C units found") + .foregroundColor(.secondary) + Button("Refresh") { refreshUnits() } + } + } else { + Picker("Unit", selection: $selectedUnitGUID) { + ForEach(avcUnits) { unit in + Text("GUID: \(unit.guidHex) (Node: \(unit.nodeIDHex))") + .tag(unit.guid as UInt64?) + } + } + } + + HStack(spacing: 12) { + Button { + sendTransport(opcode: 0xC4, operand: 0x65, label: "Rewind") + } label: { + Label("Rewind", systemImage: "backward.fill") + } + + Button { + sendTransport(opcode: 0xC3, operand: 0x75, label: "Play") + } label: { + Label("Play", systemImage: "play.fill") + } + + Button { + sendTransport(opcode: 0xC4, operand: 0x60, label: "Stop") + } label: { + Label("Stop", systemImage: "stop.fill") + } + } + .disabled(transportBusy || selectedUnitGUID == nil || !viewModel.isConnected) + + if let status = transportStatus { + Text(status) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + private var captureSection: some View { + Section("Capture") { + HStack { + TextField("Channel", text: $channelText) + .frame(width: 60) + Text("Isoch channel (camcorders broadcast on 63)") + .font(.caption) + .foregroundColor(.secondary) + } + .disabled(controller.isCapturing) + + HStack { + Button("Choose Output File…") { chooseOutputFile() } + .disabled(controller.isCapturing) + Text(outputURL?.lastPathComponent ?? "No file selected") + .foregroundColor(.secondary) + } + + if controller.isCapturing { + Button { + controller.stop(connector: viewModel.connector) + } label: { + Label("Stop Capture", systemImage: "stop.circle.fill") + } + .tint(.red) + } else { + Button { + startCapture() + } label: { + Label("Start Capture", systemImage: "record.circle") + } + .disabled(!viewModel.isConnected || outputURL == nil) + } + + captureStats + } + } + + private var captureStats: some View { + Group { + LabeledContent("System", value: controller.systemLabel) + LabeledContent("Frames", value: "\(controller.framesSeen)") + LabeledContent("Dropped frames", value: "\(controller.droppedFrames)") + .foregroundColor(controller.droppedFrames > 0 ? .orange : .primary) + LabeledContent("Written", value: formatBytes(controller.bytesWritten)) + LabeledContent("Isoch packets seen", value: "\(controller.stats.packetsSeen)") + LabeledContent("DV source packets", value: "\(controller.stats.dvSourcePackets)") + LabeledContent("Non-DV packets", value: "\(controller.stats.nonDvPackets)") + LabeledContent("Ring overruns", value: "\(controller.stats.overruns)") + .foregroundColor(controller.stats.overruns > 0 ? .orange : .primary) + if controller.stats.nonDvPackets > 0 { + LabeledContent("Last rejected", + value: String(format: "len=%u q0=%08X q1=%08X", + controller.stats.lastRejectLen, + controller.stats.lastRejectQ0, + controller.stats.lastRejectQ1)) + .foregroundColor(.orange) + LabeledContent("Last xferStatus", + value: String(format: "%04X (evt=0x%02X)", + controller.stats.lastXferStatus, + controller.stats.lastXferStatus & 0x1F)) + .foregroundColor(.orange) + } + } + .font(.callout) + } + + // MARK: Actions + + private func refreshUnits() { + guard viewModel.isConnected else { return } + + DispatchQueue.global(qos: .userInitiated).async { + let units = viewModel.connector.getAVCUnits() ?? [] + DispatchQueue.main.async { + self.avcUnits = units + if self.selectedUnitGUID == nil, let first = units.first { + self.selectedUnitGUID = first.guid + } + } + } + } + + private func sendTransport(opcode: UInt8, operand: UInt8, label: String) { + guard let guid = selectedUnitGUID else { return } + + // CONTROL, tape subunit (type 0x04, id 0), opcode, operand. + // Exactly 4 bytes - transport opcodes take a single operand and some + // camcorders reject frames with extra padding operands. + let frame = Data([0x00, 0x20, opcode, operand]) + + transportBusy = true + transportStatus = "Sending \(label)…" + + DispatchQueue.global(qos: .userInitiated).async { + let response = viewModel.connector.sendRawFCPCommand(guid: guid, frame: frame) + DispatchQueue.main.async { + self.transportBusy = false + if let response, response.count >= 1 { + let code = response[0] + // AV/C response codes: 0x09 ACCEPTED, 0x08 NOT IMPLEMENTED, 0x0A REJECTED + let codeLabel: String + switch code { + case 0x09: codeLabel = "ACCEPTED" + case 0x0A: codeLabel = "REJECTED" + case 0x08: codeLabel = "NOT IMPLEMENTED" + case 0x0C: codeLabel = "IMPLEMENTED/STABLE" + default: codeLabel = String(format: "0x%02X", code) + } + self.transportStatus = "\(label): \(codeLabel)" + } else { + // Timeout is common (some camcorders act on the command but + // the response path misses it) - check if the tape moved. + self.transportStatus = "\(label): no FCP response (command may still have worked)" + } + } + } + } + + private func chooseOutputFile() { + let panel = NSSavePanel() + panel.nameFieldStringValue = "capture.dv" + panel.canCreateDirectories = true + if panel.runModal() == .OK { + outputURL = panel.url + } + } + + private func startCapture() { + guard let url = outputURL else { return } + let channel = UInt8(channelText) ?? 63 + controller.start(connector: viewModel.connector, + channel: min(channel, 63), + url: url) + } + + private func formatBytes(_ bytes: UInt64) -> String { + if bytes < 1_000_000 { + return String(format: "%.0f KB", Double(bytes) / 1000) + } + return String(format: "%.1f MB", Double(bytes) / 1_000_000) + } +} diff --git a/ASFW/Views/ModernContentView.swift b/ASFW/Views/ModernContentView.swift index b622e364..c7d02b9c 100644 --- a/ASFW/Views/ModernContentView.swift +++ b/ASFW/Views/ModernContentView.swift @@ -42,6 +42,7 @@ struct ModernContentView: View { case topology = "Topology & Self-ID" case romExplorer = "ROM Explorer" case metrics = "Isoch Metrics" + case dvCapture = "DV Capture" case busReset = "Bus Reset History" case logs = "System Logs" case loggingSettings = "Logging Settings" @@ -64,6 +65,7 @@ struct ModernContentView: View { case .topology: return "network" case .romExplorer: return "memorychip" case .metrics: return "chart.bar.xaxis" + case .dvCapture: return "video.fill" case .busReset: return "bolt.horizontal.circle" case .logs: return "doc.text" case .loggingSettings: return "slider.horizontal.3" @@ -111,6 +113,8 @@ struct ModernContentView: View { ROMExplorerView(viewModel: romExplorerVM) case .metrics: MetricsView(connector: debugVM.connector) + case .dvCapture: + DVCaptureView(viewModel: debugVM) case .busReset: BusResetHistoryView(viewModel: debugVM) case .logs: diff --git a/ASFWDriver/ASFWDriver.cpp b/ASFWDriver/ASFWDriver.cpp index e0f5d756..5fc167d1 100644 --- a/ASFWDriver/ASFWDriver.cpp +++ b/ASFWDriver/ASFWDriver.cpp @@ -762,6 +762,36 @@ void* ASFWDriver::GetIsochReceiveContext() const { return ivars->context->isoch.ReceiveContext(); } +// ============================================================================= +// MARK: - DV Capture (no audio nub required) +// ============================================================================= + +kern_return_t ASFWDriver::StartDVCapture(uint8_t channel) { + if (!ivars || !ivars->context) { + return kIOReturnNotReady; + } + auto& ctx = *ivars->context; + if (!ctx.deps.hardware) { + ASFW_LOG(Controller, "[Isoch] ❌ StartDVCapture: hardware not ready"); + return kIOReturnNotReady; + } + return ctx.isoch.StartDVCapture(channel, *ctx.deps.hardware); +} + +kern_return_t ASFWDriver::StopDVCapture() { + if (!ivars || !ivars->context) { + return kIOReturnNotReady; + } + return ivars->context->isoch.StopDVCapture(); +} + +kern_return_t ASFWDriver::CopyDVCaptureMemory(uint64_t* options, IOMemoryDescriptor** memory) const { + if (!ivars || !ivars->context) { + return kIOReturnNotReady; + } + return ivars->context->isoch.CopyDVCaptureMemory(options, memory); +} + // ============================================================================= // MARK: - Isochronous Transmit // ============================================================================= diff --git a/ASFWDriver/ASFWDriver.iig b/ASFWDriver/ASFWDriver.iig index 091c617c..af94954b 100644 --- a/ASFWDriver/ASFWDriver.iig +++ b/ASFWDriver/ASFWDriver.iig @@ -73,6 +73,12 @@ public: kern_return_t StartIsochReceive(uint8_t channel, uint32_t wireFormatRaw, uint32_t am824Slots) LOCALONLY; kern_return_t StopIsochReceive() LOCALONLY; void* GetIsochReceiveContext() const LOCALONLY; + + // DV capture (raw DIF stream to shared ring, no audio nub required) + kern_return_t StartDVCapture(uint8_t channel) LOCALONLY; + kern_return_t StopDVCapture() LOCALONLY; + kern_return_t CopyDVCaptureMemory(uint64_t* options, + IOMemoryDescriptor** memory) const LOCALONLY; // Isochronous Transmit Control kern_return_t StartIsochTransmit(uint8_t channel) LOCALONLY; diff --git a/ASFWDriver/Isoch/IsochService.cpp b/ASFWDriver/Isoch/IsochService.cpp index 18343b92..820a1ebf 100644 --- a/ASFWDriver/Isoch/IsochService.cpp +++ b/ASFWDriver/Isoch/IsochService.cpp @@ -18,7 +18,8 @@ kern_return_t IsochService::StartReceive(uint8_t channel, HardwareInterface& hardware, ASFW::Audio::Runtime::IDirectAudioBindingSource* bindingSource, ASFW::Encoding::AudioWireFormat wireFormat, - uint32_t am824Slots) { + uint32_t am824Slots, + ASFW::Isoch::IsochReceiveCallback packetCallback) { if (!isochReceiveContext_) { ASFW::Isoch::Memory::IsochMemoryConfig config; config.numDescriptors = ASFW::Isoch::IsochReceiveContext::kNumDescriptors; @@ -55,6 +56,10 @@ kern_return_t IsochService::StartReceive(uint8_t channel, return kr; } + // Install (or clear) the per-packet callback before Start so Poll never + // races a std::function assignment. + isochReceiveContext_->SetCallback(std::move(packetCallback)); + ASFW_LOG(Isoch, "IsochService: Starting IR on channel %u (Direct-Only)", channel); const kern_return_t startKr = isochReceiveContext_->Start(); return startKr; @@ -64,7 +69,107 @@ kern_return_t IsochService::StopReceive() { if (isochReceiveContext_) { isochReceiveContext_->Stop(); isochReceiveContext_->SetDirectAudioBindingSource(nullptr); + // Safe after Stop(): Poll no longer runs, so no callback is in flight. + isochReceiveContext_->SetCallback(nullptr); + } + + if (dvCaptureActive_) { + dvSink_.Detach(); + dvRing_.Reset(); + dvCaptureActive_ = false; + ASFW_LOG(Isoch, "IsochService: DV capture stopped"); + } + + return kIOReturnSuccess; +} + +// ============================================================================ +// DV capture (minimal IEC 61883-2 tap; see Receive/DVCaptureSink.hpp) +// ============================================================================ + +kern_return_t IsochService::StartDVCapture(uint8_t channel, HardwareInterface& hardware) { + if (dvCaptureActive_) { + ASFW_LOG(Isoch, "IsochService: DV capture already active; StartDVCapture is idempotent"); + return kIOReturnSuccess; + } + + if (isochReceiveContext_ && + isochReceiveContext_->GetState() == ASFW::Isoch::IRPolicy::State::Running) { + ASFW_LOG(Isoch, "IsochService: StartDVCapture blocked: IR context busy (audio receive running)"); + return kIOReturnBusy; + } + + // ~3.9MB ring = 8192 DIF chunks ≈ 1.1s of DV at 3.6MB/s. + constexpr uint32_t kDVRingRecords = 8192; + const uint64_t ringBytes = ASFW::Isoch::Rx::DVCaptureSink::RequiredBytes(kDVRingRecords); + + IOBufferMemoryDescriptor* memRaw = nullptr; + kern_return_t kr = IOBufferMemoryDescriptor::Create(kIOMemoryDirectionOutIn, + ringBytes, 64, &memRaw); + if (kr != kIOReturnSuccess || !memRaw) { + ASFW_LOG(Isoch, "IsochService: StartDVCapture ring allocation failed: 0x%x", kr); + return kr ? kr : kIOReturnNoMemory; + } + kr = memRaw->SetLength(ringBytes); + if (kr != kIOReturnSuccess) { + memRaw->release(); + return kr; + } + + dvRing_.Reset(); + dvRing_.memory = Common::AdoptRetained(memRaw); + dvRing_.bytes = ringBytes; + + kr = Common::CreateSharedMapping(dvRing_.memory, dvRing_.map); + if (kr != kIOReturnSuccess) { + dvRing_.Reset(); + return kr; + } + + if (!dvSink_.InitializeAndAttach(dvRing_.BaseAddress(), ringBytes, kDVRingRecords)) { + ASFW_LOG(Isoch, "IsochService: StartDVCapture sink init failed"); + dvRing_.Reset(); + return kIOReturnInternalError; + } + + auto* sink = &dvSink_; + kr = StartReceive(channel, hardware, /*bindingSource=*/nullptr, + ASFW::Encoding::AudioWireFormat::kAM824, /*am824Slots=*/0, + [sink](std::span data, uint32_t status, + uint64_t /*timestamp*/) { + sink->OnPacket(data.data(), data.size(), status); + }); + if (kr != kIOReturnSuccess) { + dvSink_.Detach(); + dvRing_.Reset(); + return kr; + } + + dvCaptureActive_ = true; + ASFW_LOG(Isoch, "IsochService: DV capture started on channel %u (ring=%llu bytes)", + channel, ringBytes); + return kIOReturnSuccess; +} + +kern_return_t IsochService::StopDVCapture() { + if (!dvCaptureActive_) { + return kIOReturnSuccess; + } + return StopReceive(); +} + +kern_return_t IsochService::CopyDVCaptureMemory(uint64_t* options, IOMemoryDescriptor** memory) const { + if (!memory) { + return kIOReturnBadArgument; + } + if (!dvCaptureActive_ || !dvRing_.memory) { + return kIOReturnNotReady; + } + if (options) { + *options = 0; } + dvRing_.memory->retain(); + *memory = dvRing_.memory.get(); return kIOReturnSuccess; } diff --git a/ASFWDriver/Isoch/IsochService.hpp b/ASFWDriver/Isoch/IsochService.hpp index 321ef534..a3457cca 100644 --- a/ASFWDriver/Isoch/IsochService.hpp +++ b/ASFWDriver/Isoch/IsochService.hpp @@ -13,6 +13,7 @@ #endif #include "IsochReceiveContext.hpp" +#include "Receive/DVCaptureSink.hpp" #include "Transmit/IsochTransmitContext.hpp" #include "../Common/DriverKitOwnership.hpp" @@ -41,10 +42,18 @@ class IsochService { HardwareInterface& hardware, ASFW::Audio::Runtime::IDirectAudioBindingSource* bindingSource, ASFW::Encoding::AudioWireFormat wireFormat = ASFW::Encoding::AudioWireFormat::kAM824, - uint32_t am824Slots = 0); + uint32_t am824Slots = 0, + ASFW::Isoch::IsochReceiveCallback packetCallback = nullptr); kern_return_t StopReceive(); + // Minimal DV (IEC 61883-2) capture tap: starts IR on the given channel with + // no audio binding and streams raw DIF chunks into a shared ring the app + // maps via CopyClientMemoryForType(type=1). + kern_return_t StartDVCapture(uint8_t channel, HardwareInterface& hardware); + kern_return_t StopDVCapture(); + kern_return_t CopyDVCaptureMemory(uint64_t* options, IOMemoryDescriptor** memory) const; + kern_return_t StartTransmit(uint8_t channel, HardwareInterface& hardware, uint8_t sid); @@ -107,6 +116,27 @@ class IsochService { OSSharedPtr isochReceiveContext_; std::unique_ptr isochTransmitContext_; + // DV capture shared ring (see Receive/DVCaptureSink.hpp) + struct DVRingMapping { + OSSharedPtr memory{}; + OSSharedPtr map{}; + uint64_t bytes{0}; + + void Reset() noexcept { + map.reset(); + memory.reset(); + bytes = 0; + } + + [[nodiscard]] void* BaseAddress() const noexcept { + return map ? reinterpret_cast(static_cast(map->GetAddress())) : nullptr; + } + }; + + DVRingMapping dvRing_{}; + ASFW::Isoch::Rx::DVCaptureSink dvSink_{}; + bool dvCaptureActive_{false}; + OSSharedPtr txPayloadSlab_{nullptr}; OSSharedPtr txMetadataRing_{nullptr}; OSSharedPtr txControlBlock_{nullptr}; diff --git a/ASFWDriver/Isoch/Receive/DVCaptureSink.hpp b/ASFWDriver/Isoch/Receive/DVCaptureSink.hpp new file mode 100644 index 00000000..3ea19ded --- /dev/null +++ b/ASFWDriver/Isoch/Receive/DVCaptureSink.hpp @@ -0,0 +1,199 @@ +// DVCaptureSink.hpp +// ASFW - Minimal DV (IEC 61883-2) capture tap for the IR context. +// +// Filters isoch packets for CIP FMT=0x00 (DVCR), strips the 8-byte driver +// prefix, 8-byte CIP header and 4-byte source packet header, and writes raw +// 480-byte DIF chunks into a shared-memory SPSC ring consumed by the ASFW app +// (which concatenates them into a .dv file). +// +// Producer: IsochReceiveContext::Poll() packet callback (driver process) +// Consumer: ASFW app via CopyClientMemoryForType(type=1) + IOConnectMapMemory64 +// +// NOTE: This is a pragmatic capture path, not a full DV pipeline. There is no +// frame reassembly here; the app gates output on the first DIF frame-header +// chunk (0x1F 0x07 0x00) and appends chunks in arrival order. Packet loss +// shows up as a visual glitch in the captured frame, which DV players tolerate. + +#pragma once + +#include +#include +#include +#include + +#include "../../Audio/Wire/CIP/CIPHeader.hpp" + +namespace ASFW::Isoch::Rx { + +// Shared-memory layout. Offsets are fixed and mirrored in the Swift app +// (DriverConnector+DVCapture.swift) - do not reorder fields. +struct DVRingHeader { + uint32_t magic; // +0 'ASDV' + uint16_t version; // +4 + uint16_t reserved0; // +6 + uint32_t numRecords; // +8 capacity in 480-byte records + uint32_t recordBytes; // +12 always 480 + uint32_t dataOffsetBytes; // +16 offset of record 0 from base + uint32_t reserved1; // +20 + + // Diagnostics (single writer: driver Poll thread; app reads racily, fine) + uint32_t packetsSeen; // +24 all isoch packets observed + uint32_t dvSourcePackets; // +28 DV source packets extracted + uint32_t nonDvPackets; // +32 packets rejected (bad CIP / wrong FMT) + uint32_t overruns; // +36 records dropped because ring was full + + // Last rejected packet snapshot (raw bytes as read from the buffer, so the + // app can show what the wire actually looks like when the filter misses). + uint32_t lastRejectLen; // +40 + uint32_t lastRejectQ0; // +44 raw quadlet at payload+8 (no byteswap) + uint32_t lastRejectQ1; // +48 raw quadlet at payload+12 (no byteswap) + uint32_t lastXferStatus; // +52 descriptor xferStatus (event code in bits 4:0) + + uint8_t pad0[8]; // +56..63 + + // Free-running SPSC indices in records (not bytes). + std::atomic writeIndex; // +64 driver-owned + uint8_t pad1[60]; + std::atomic readIndex; // +128 app-owned + uint8_t pad2[60]; // ..191 +}; + +static_assert(sizeof(DVRingHeader) == 192, "DVRingHeader layout is ABI with the app"); +static_assert(offsetof(DVRingHeader, writeIndex) == 64, "writeIndex offset is ABI"); +static_assert(offsetof(DVRingHeader, readIndex) == 128, "readIndex offset is ABI"); + +class DVCaptureSink { +public: + static constexpr uint32_t kMagic = 0x41534456; // 'ASDV' + static constexpr uint16_t kVersion = 1; + static constexpr uint32_t kRecordBytes = 480; // one DV source packet (6 DIF blocks) + static constexpr size_t kDriverPrefixBytes = 8; // timestamp + isoch header quadlets + static constexpr size_t kCipHeaderBytes = 8; + static constexpr size_t kSphBytes = 4; + static constexpr size_t kWireBlockBytes = kSphBytes + kRecordBytes; // 484 + + [[nodiscard]] static uint64_t RequiredBytes(uint32_t numRecords) noexcept { + return sizeof(DVRingHeader) + uint64_t(numRecords) * kRecordBytes; + } + + // Creator-side: initialize the ring header in shared memory and attach. + [[nodiscard]] bool InitializeAndAttach(void* base, uint64_t bytes, uint32_t numRecords) noexcept { + Detach(); + if (!base || numRecords == 0 || bytes < RequiredBytes(numRecords)) { + return false; + } + + std::memset(base, 0, size_t(RequiredBytes(numRecords))); + + auto* hdr = reinterpret_cast(base); + hdr->magic = kMagic; + hdr->version = kVersion; + hdr->numRecords = numRecords; + hdr->recordBytes = kRecordBytes; + hdr->dataOffsetBytes = sizeof(DVRingHeader); + hdr->writeIndex.store(0, std::memory_order_relaxed); + hdr->readIndex.store(0, std::memory_order_relaxed); + std::atomic_thread_fence(std::memory_order_release); + + hdr_ = hdr; + data_ = reinterpret_cast(base) + hdr->dataOffsetBytes; + numRecords_ = numRecords; + return true; + } + + void Detach() noexcept { + hdr_ = nullptr; + data_ = nullptr; + numRecords_ = 0; + } + + [[nodiscard]] bool IsAttached() const noexcept { return hdr_ != nullptr; } + + // Called per completed isoch packet. payload includes the 8-byte driver + // prefix ([0-3] timestamp quadlet, [4-7] isoch header) ahead of the CIP. + void OnPacket(const uint8_t* payload, size_t length, uint32_t xferStatus) noexcept { + if (!hdr_ || !payload) { + return; + } + hdr_->packetsSeen++; + hdr_->lastXferStatus = xferStatus; + + if (length < kDriverPrefixBytes + kCipHeaderBytes) { + hdr_->nonDvPackets++; + hdr_->lastRejectLen = static_cast(length); + return; // runt / no CIP - not even an empty packet + } + + uint32_t q0 = 0; + uint32_t q1 = 0; + std::memcpy(&q0, payload + kDriverPrefixBytes, sizeof(q0)); + std::memcpy(&q1, payload + kDriverPrefixBytes + 4, sizeof(q1)); + + const auto cip = CIPHeader::Decode(q0, q1); + if (!cip || cip->format != 0x00) { + hdr_->nonDvPackets++; + hdr_->lastRejectLen = static_cast(length); + hdr_->lastRejectQ0 = q0; + hdr_->lastRejectQ1 = q1; + return; + } + + const size_t dataBytes = length - kDriverPrefixBytes - kCipHeaderBytes; + + // SD-DVCR carries DBS=120 quadlets (480 bytes per block); the ring + // records are fixed at that size. Reject other DV variants (e.g. + // DVCPRO50) instead of mis-slicing them. Empty (CIP-only) packets + // pass through with zero blocks regardless. + if (dataBytes > 0 && cip->dataBlockSize != kRecordBytes / 4) { + hdr_->nonDvPackets++; + hdr_->lastRejectLen = static_cast(length); + hdr_->lastRejectQ0 = q0; + hdr_->lastRejectQ1 = q1; + return; + } + + // Consumer DV camcorders (Linux dv1394-compatible, matching Apple's + // AVCVideoServices DVReceiver) send SPH=0: the payload after the CIP + // is plain 480-byte DIF blocks, timestamp lives in CIP.SYT. Devices + // that set SPH=1 prefix each block with a 4-byte source packet header. + const size_t skip = cip->sourcePacketHeader ? kSphBytes : 0; + const size_t blockBytes = kRecordBytes + skip; + + // Sanity: payload must be a whole number of blocks (Apple validates + // expected packet size the same way; truncated DMA = corrupt packet). + if (dataBytes % blockBytes != 0) { + hdr_->nonDvPackets++; + hdr_->lastRejectLen = static_cast(length); + hdr_->lastRejectQ0 = q0; + hdr_->lastRejectQ1 = q1; + return; + } + + const size_t blocks = dataBytes / blockBytes; // 0 for empty (CIP-only) packets + + for (size_t i = 0; i < blocks; ++i) { + const uint8_t* dif = payload + kDriverPrefixBytes + kCipHeaderBytes + + (i * blockBytes) + skip; + WriteRecord(dif); + } + hdr_->dvSourcePackets += static_cast(blocks); + } + +private: + void WriteRecord(const uint8_t* src) noexcept { + const uint32_t w = hdr_->writeIndex.load(std::memory_order_relaxed); + const uint32_t r = hdr_->readIndex.load(std::memory_order_acquire); + if (uint32_t(w - r) >= numRecords_) { + hdr_->overruns++; + return; + } + std::memcpy(data_ + size_t(w % numRecords_) * kRecordBytes, src, kRecordBytes); + hdr_->writeIndex.store(w + 1, std::memory_order_release); + } + + DVRingHeader* hdr_{nullptr}; + uint8_t* data_{nullptr}; + uint32_t numRecords_{0}; +}; + +} // namespace ASFW::Isoch::Rx diff --git a/ASFWDriver/Service/LocalRequestWiring.cpp b/ASFWDriver/Service/LocalRequestWiring.cpp index 1b88ae08..885ecfcb 100644 --- a/ASFWDriver/Service/LocalRequestWiring.cpp +++ b/ASFWDriver/Service/LocalRequestWiring.cpp @@ -26,7 +26,9 @@ #include "../Protocols/Ports/FireWireRxPort.hpp" #include "../Protocols/SBP2/AddressSpaceManager.hpp" +#include #include +#include namespace ASFW::Service { @@ -79,20 +81,51 @@ class CSRLocalHandler final : public ILocalAddressHandler { ASFW::Bus::CSRResponder* csr_; }; -// --- FCP: AV/C command/response block writes ----------------------------------- +// --- FCP: AV/C command/response writes ------------------------------------------ +// Both block writes and quadlet writes must be handled: short AV/C responses +// (e.g. the 4-byte ACCEPTED a camcorder returns for a tape transport command) +// arrive as quadlet writes (tCode 0x0) to the FCP response register. Dropping +// them makes every short AV/C command time out on the response path. class FcpLocalHandler final : public ILocalAddressHandler { public: explicit FcpLocalHandler(ASFW::Protocols::AVC::FCPResponseRouter* fcp) noexcept : fcp_(fcp) {} [[nodiscard]] const char* Name() const noexcept override { return "FCP"; } [[nodiscard]] LocalRequestResult HandleLocalRequest(const LocalRequestContext& ctx) override { - if (fcp_ == nullptr || ctx.tCode != AReq::kTcodeWriteBlock || ctx.writePayload.empty()) { + if (fcp_ == nullptr) { return LocalRequestResult::NotMine(); } + + if (ctx.tCode == AReq::kTcodeWriteBlock) { + if (ctx.writePayload.empty()) { + return LocalRequestResult::NotMine(); + } + return Route(ctx, ctx.writePayload); + } + + if (ctx.tCode == AReq::kTcodeWriteQuad) { + // ctx.writePayload points at the little-endian AR header storage; + // rebuild wire (big-endian) byte order from the host-order value + // so the FCP frame reads as [ctype, subunit, opcode, operand]. + const std::array frame{ + static_cast(ctx.quadletData >> 24), + static_cast(ctx.quadletData >> 16), + static_cast(ctx.quadletData >> 8), + static_cast(ctx.quadletData), + }; + return Route(ctx, std::span(frame.data(), frame.size())); + } + + return LocalRequestResult::NotMine(); + } + +private: + [[nodiscard]] LocalRequestResult Route(const LocalRequestContext& ctx, + std::span payload) { const ASFW::Protocols::Ports::BlockWriteRequestView request{ .sourceID = ctx.sourceID, .destOffset = ctx.destOffset, - .payload = ctx.writePayload, + .payload = payload, }; const auto disposition = fcp_->RouteBlockWrite(request); if (disposition == ASFW::Protocols::Ports::BlockWriteDisposition::kAddressError) { @@ -101,7 +134,6 @@ class FcpLocalHandler final : public ILocalAddressHandler { return LocalRequestResult::Write(ResponseCode::Complete); } -private: ASFW::Protocols::AVC::FCPResponseRouter* fcp_; }; diff --git a/ASFWDriver/UserClient/Core/ASFWDriverUserClient.cpp b/ASFWDriver/UserClient/Core/ASFWDriverUserClient.cpp index 2f9458f6..533b17bb 100644 --- a/ASFWDriver/UserClient/Core/ASFWDriverUserClient.cpp +++ b/ASFWDriver/UserClient/Core/ASFWDriverUserClient.cpp @@ -81,6 +81,10 @@ enum { // Isoch Transmit Control (IT DMA allocation only - no CMP) kMethodStartIsochTransmit = 36, kMethodStopIsochTransmit = 37, + + // DV capture (raw DIF stream via shared ring, memory type 1) + kMethodStartDVCapture = 50, + kMethodStopDVCapture = 51, }; namespace { @@ -349,6 +353,10 @@ MethodDispatchResult DispatchIsochMethods(ASFW::UserClient::UserClientRuntimeSta return runtimeState.Isoch().StartIsochTransmit(arguments); case kMethodStopIsochTransmit: return runtimeState.Isoch().StopIsochTransmit(arguments); + case kMethodStartDVCapture: + return runtimeState.Isoch().StartDVCapture(arguments); + case kMethodStopDVCapture: + return runtimeState.Isoch().StopDVCapture(arguments); default: return std::nullopt; } @@ -712,12 +720,14 @@ kern_return_t IMPL(ASFWDriverUserClient, CopyClientMemoryForType) { return kIOReturnNotReady; } - // Only support kSharedStatusMemoryType = 0 - if (type != 0) { - return kIOReturnUnsupported; + // Type 0: shared status memory. Type 1: DV capture ring. + if (type == 0) { + return ivars->driver->CopySharedStatusMemory(options, memory); } - - return ivars->driver->CopySharedStatusMemory(options, memory); + if (type == 1) { + return ivars->driver->CopyDVCaptureMemory(options, memory); + } + return kIOReturnUnsupported; } // Note: GetDiscoveredDevices is handled in ExternalMethod (selector 16), no stub needed diff --git a/ASFWDriver/UserClient/Core/ASFWDriverUserClient.iig b/ASFWDriver/UserClient/Core/ASFWDriverUserClient.iig index 4c24a9e3..fd8c2d4b 100644 --- a/ASFWDriver/UserClient/Core/ASFWDriverUserClient.iig +++ b/ASFWDriver/UserClient/Core/ASFWDriverUserClient.iig @@ -71,6 +71,10 @@ public: // Isocho Stream Control kMethodStartIsochReceive = 32, kMethodStopIsochReceive = 33, + + // DV capture (raw DIF stream via shared ring, memory type 1) + kMethodStartDVCapture = 50, + kMethodStopDVCapture = 51, }; kern_return_t diff --git a/ASFWDriver/UserClient/Handlers/IsochHandler.cpp b/ASFWDriver/UserClient/Handlers/IsochHandler.cpp index 48649469..89b66e58 100644 --- a/ASFWDriver/UserClient/Handlers/IsochHandler.cpp +++ b/ASFWDriver/UserClient/Handlers/IsochHandler.cpp @@ -297,6 +297,27 @@ kern_return_t IsochHandler::StopIsochReceive(IOUserClientMethodArguments* args) return driver_->StopIsochReceive(); } +// ============================================================================ +// DV Capture Control +// ============================================================================ + +kern_return_t IsochHandler::StartDVCapture(IOUserClientMethodArguments* args) { + // Arguments: [0] = channel (DV camcorders broadcast on 63 by default) + if (args->scalarInputCount < 1) + return kIOReturnBadArgument; + const uint64_t channel = args->scalarInput[0]; + if (channel > 63) + return kIOReturnBadArgument; + + ASFW_LOG(UserClient, "StartDVCapture called for channel %llu", channel); + return driver_->StartDVCapture(static_cast(channel)); +} + +kern_return_t IsochHandler::StopDVCapture(IOUserClientMethodArguments* args) { + ASFW_LOG(UserClient, "StopDVCapture called"); + return driver_->StopDVCapture(); +} + // ============================================================================ // Isoch Metrics // ============================================================================ diff --git a/ASFWDriver/UserClient/Handlers/IsochHandler.hpp b/ASFWDriver/UserClient/Handlers/IsochHandler.hpp index 2dd19667..f9b109a9 100644 --- a/ASFWDriver/UserClient/Handlers/IsochHandler.hpp +++ b/ASFWDriver/UserClient/Handlers/IsochHandler.hpp @@ -33,6 +33,10 @@ class IsochHandler { // Isoch Streaming Control kern_return_t StartIsochReceive(IOUserClientMethodArguments* args); kern_return_t StopIsochReceive(IOUserClientMethodArguments* args); + + // DV Capture Control + kern_return_t StartDVCapture(IOUserClientMethodArguments* args); + kern_return_t StopDVCapture(IOUserClientMethodArguments* args); // Isoch Metrics kern_return_t GetIsochRxMetrics(IOUserClientMethodArguments* args);