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.
Musubi/App/Sources/StatsRecorder.swift

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)
}
}

Powered by TurnKey Linux.