You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
75 lines
3.2 KiB
75 lines
3.2 KiB
import CoreGraphics
|
|
import Foundation
|
|
import MusubiCore
|
|
import NDIKit
|
|
|
|
/// Appends one CSV line per output and minute to ~/Library/Logs/Musubi/stats-<date>.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] ?? 0
|
|
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)
|
|
}
|
|
}
|