import CoreGraphics import Foundation import MusubiCore import NDIKit /// Appends one CSV line per output and minute to ~/Library/Logs/Musubi/stats-.csv (for long-run tests). @MainActor final class StatsRecorder { private let interval: TimeInterval = 60 private var lastWrite = Date() private var lastCPU = StatsRecorder.cpuTime() private var lastReceived: [CGDirectDisplayID: Int64] = [:] private let directory = FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Logs/Musubi") private static let header = "time,display,refresh_hz,source,state,width,height,source_fps,shown_fps,received,missing,dropped_by_ndi,loss_rate,cpu_percent\n" func recordIfDue(outputs: [ActiveOutput], screens: [ScreenInfo]) { let now = Date() let elapsed = now.timeIntervalSince(lastWrite) guard elapsed >= interval else { return } let cpu = Self.cpuTime() let cpuPercent = (cpu - lastCPU) / elapsed * 100 lastCPU = cpu lastWrite = now guard !outputs.isEmpty else { return } let time = now.ISO8601Format() var lines = "" for output in outputs { let s = output.snapshot let previous = lastReceived[output.displayID] ?? s.stats.received lastReceived[output.displayID] = s.stats.received let shownFPS = Double(s.stats.received - previous) / elapsed let refresh = screens.first { $0.id == output.displayID }?.refreshRate ?? 0 let state: String = switch s.state { case .connecting: "connecting" case .live: "live" case .stalled: "stalled" } let name = output.source.ndiName.replacingOccurrences(of: "\"", with: "'") lines += "\(time),\(output.displayID),\(refresh),\"\(name)\",\(state),\(s.width),\(s.height)," lines += String(format: "%.2f,%.2f,%lld,%lld,%lld,%.6f,%.1f\n", s.frameRate, shownFPS, s.stats.received, s.stats.missing, s.stats.droppedByReceiver, s.stats.lossRate, cpuPercent) } append(lines, date: now) } private func append(_ text: String, date: Date) { let day = date.formatted(.iso8601.year().month().day()) let file = directory.appending(path: "stats-\(day).csv") do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) if !FileManager.default.fileExists(atPath: file.path) { try Self.header.write(to: file, atomically: true, encoding: .utf8) } let handle = try FileHandle(forWritingTo: file) defer { try? handle.close() } try handle.seekToEnd() try handle.write(contentsOf: Data(text.utf8)) } catch { Log.ndi.error("Stats CSV: \(error.localizedDescription, privacy: .public)") } } /// User + system CPU seconds of this process. private static func cpuTime() -> Double { var usage = rusage() getrusage(RUSAGE_SELF, &usage) func seconds(_ t: timeval) -> Double { Double(t.tv_sec) + Double(t.tv_usec) / 1_000_000 } return seconds(usage.ru_utime) + seconds(usage.ru_stime) } }