From abb463f51abb4be3eca0f8e6d31a975fa8ea3f34 Mon Sep 17 00:00:00 2001 From: Ali Alavi Date: Fri, 25 Sep 2026 20:28:00 +0000 Subject: [PATCH] feat: real-time 1s ping monitor to Soniox with dynamic orb color (green/yellow/orange/red) and millisecond HUD readout --- src/AppDelegate.swift | 11 ++++- src/HUDOverlay.swift | 63 +++++++++++++++++++++++------ src/PingMonitor.swift | 93 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 14 deletions(-) create mode 100644 src/PingMonitor.swift diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index ce774e6..6dcd405 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -61,10 +61,19 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { guard let self = self else { return } self.currentAudioLevel = level if self.audioRecorder.isRecording { - HUDOverlayController.shared.show(state: .recording(level: level, isLagging: self.isNetworkDegraded)) + HUDOverlayController.shared.show(state: .recording(level: level, isLagging: self.isNetworkDegraded, pingQuality: PingMonitor.shared.currentQuality)) } } + // Listen to live ping updates and push to HUD + PingMonitor.shared.onPingUpdate = { [weak self] quality in + guard let self = self else { return } + if self.audioRecorder.isRecording { + HUDOverlayController.shared.show(state: .recording(level: self.currentAudioLevel, isLagging: self.isNetworkDegraded, pingQuality: quality)) + } + } + PingMonitor.shared.start() + // Stream audio chunk live directly to active Soniox session in real-time audioRecorder.onAudioChunkAvailable = { [weak self] chunk in self?.activeSession?.sendAudioChunk(chunk) diff --git a/src/HUDOverlay.swift b/src/HUDOverlay.swift index 60d65d8..61e23bf 100644 --- a/src/HUDOverlay.swift +++ b/src/HUDOverlay.swift @@ -3,7 +3,7 @@ import QuartzCore public enum HUDState { case hidden - case recording(level: Float, isLagging: Bool = false) + case recording(level: Float, isLagging: Bool = false, pingQuality: PingQuality? = nil) case retrying case transcribing case success @@ -75,13 +75,13 @@ public final class HUDOverlayController { case .hidden: self.hide() - case .recording(let level, let isLagging): + case .recording(let level, let isLagging, let pingQuality): if !self.isVisible { window.orderFrontRegardless() window.animator().alphaValue = 1.0 self.isVisible = true } - orb.updateAudioLevel(level, isLagging: isLagging) + orb.updateAudioLevel(level, isLagging: isLagging, pingQuality: pingQuality) case .retrying: if !self.isVisible { @@ -130,6 +130,7 @@ public final class HUDOverlayController { final class OutwardExpandingOrbView: NSView { private let coreSolidLayer = CALayer() private let dynamicStrokeLayer = CALayer() + private let pingTextLayer = CATextLayer() private let baseDiameter: CGFloat = 48.0 private let minStrokeWidth: CGFloat = 2.0 @@ -153,13 +154,24 @@ final class OutwardExpandingOrbView: NSView { let center = CGPoint(x: bounds.midX, y: bounds.midY) - // 1. Solid Warm Yellow-Lime Inner Core (وسطش لیمویی مایل به زرد، کاملاً ثابت و مقتدر) + // 1. Solid Core Layer (رنگ بر اساس پینگ: سبز <450ms، زرد 450-750ms، نارنجی 750-1500ms، قرمز قطع/بالا) coreSolidLayer.bounds = CGRect(x: 0, y: 0, width: baseDiameter, height: baseDiameter) coreSolidLayer.position = center coreSolidLayer.cornerRadius = baseDiameter / 2.0 - coreSolidLayer.backgroundColor = NSColor(red: 0.94, green: 0.95, blue: 0.20, alpha: 1.0).cgColor // Warm Yellow-Lime (#F0F233) + coreSolidLayer.backgroundColor = NSColor(red: 0.94, green: 0.95, blue: 0.20, alpha: 1.0).cgColor // Initial Warm Yellow layer.addSublayer(coreSolidLayer) + // Ping Text Layer inside core + pingTextLayer.bounds = CGRect(x: 0, y: 0, width: baseDiameter, height: 16.0) + pingTextLayer.position = CGPoint(x: center.x, y: center.y - 1.0) + pingTextLayer.fontSize = 11.0 + pingTextLayer.font = NSFont.monospacedDigitSystemFont(ofSize: 11.0, weight: .bold) + pingTextLayer.alignmentMode = .center + pingTextLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 + pingTextLayer.foregroundColor = NSColor(red: 0.10, green: 0.12, blue: 0.15, alpha: 0.95).cgColor + pingTextLayer.string = "" + layer.addSublayer(pingTextLayer) + // 2. Outward Expanding Pastel Light Blue Stroke Layer (دورش آبی پاستیلی کمرنگ) let initialStrokeDiameter = baseDiameter + (minStrokeWidth * 2.0) dynamicStrokeLayer.bounds = CGRect(x: 0, y: 0, width: initialStrokeDiameter, height: initialStrokeDiameter) @@ -175,10 +187,13 @@ final class OutwardExpandingOrbView: NSView { layer.addSublayer(dynamicStrokeLayer) } - /// Updates audio response: - /// The inner black core stays completely fixed. - /// The stroke width expands purely OUTWARD from 2.0px up to 12.0px with voice volume. - func updateAudioLevel(_ level: Float, isLagging: Bool) { + /// Updates audio response and live ping quality: + /// Ping defines the Core Color: + /// - Green (<450ms): Excellent low latency + /// - Warm Yellow (450-750ms): Normal acceptable latency + /// - Orange (750-1500ms): Degraded high latency + /// - Red (>1500ms or Timeout/Error): Disconnected / severe lag + func updateAudioLevel(_ level: Float, isLagging: Bool, pingQuality: PingQuality?) { CATransaction.begin() CATransaction.setDisableActions(true) @@ -188,18 +203,41 @@ final class OutwardExpandingOrbView: NSView { let strokeWidth = minStrokeWidth + (dynamicFactor * (maxStrokeWidth - minStrokeWidth)) let strokeDiameter = baseDiameter + (strokeWidth * 2.0) - // Outward expansion geometry: inner edge = strokeDiameter - 2*strokeWidth = baseDiameter (48.0) dynamicStrokeLayer.bounds = CGRect(x: 0, y: 0, width: strokeDiameter, height: strokeDiameter) dynamicStrokeLayer.cornerRadius = strokeDiameter / 2.0 dynamicStrokeLayer.borderWidth = strokeWidth + // Update Core Color & Text based on live Ping + if let ping = pingQuality { + pingTextLayer.string = ping.labelText + switch ping { + case .good: + // Vibrant Emerald Green (#2DD4BF / #22C55E) + coreSolidLayer.backgroundColor = NSColor(red: 0.22, green: 0.82, blue: 0.44, alpha: 1.0).cgColor + pingTextLayer.foregroundColor = NSColor(red: 0.04, green: 0.20, blue: 0.08, alpha: 0.95).cgColor + + case .medium: + // Warm Yellow-Lime (#F0F233) + coreSolidLayer.backgroundColor = NSColor(red: 0.94, green: 0.95, blue: 0.20, alpha: 1.0).cgColor + pingTextLayer.foregroundColor = NSColor(red: 0.20, green: 0.18, blue: 0.02, alpha: 0.95).cgColor + + case .high: + // Warning Vibrant Orange (#FB923C / #F97316) + coreSolidLayer.backgroundColor = NSColor(red: 0.98, green: 0.55, blue: 0.14, alpha: 1.0).cgColor + pingTextLayer.foregroundColor = NSColor(red: 0.24, green: 0.08, blue: 0.01, alpha: 0.95).cgColor + + case .disconnected: + // Disconnected / Timeout Red (#EF4444) + coreSolidLayer.backgroundColor = NSColor(red: 0.94, green: 0.22, blue: 0.22, alpha: 1.0).cgColor + pingTextLayer.foregroundColor = NSColor.white.cgColor + } + } + if isLagging { - // Warm amber gold indicator for degraded network let amber = NSColor(red: 1.0, green: 0.68, blue: 0.15, alpha: 0.80 + (0.20 * dynamicFactor)).cgColor dynamicStrokeLayer.borderColor = amber dynamicStrokeLayer.shadowColor = amber } else { - // Soft Pastel Light Blue palette: brightens and blooms with volume let pastelBlue = NSColor( red: 0.65 + (0.15 * dynamicFactor), green: 0.86 + (0.10 * dynamicFactor), @@ -210,7 +248,6 @@ final class OutwardExpandingOrbView: NSView { dynamicStrokeLayer.shadowColor = NSColor(red: 0.58, green: 0.82, blue: 0.98, alpha: 0.85).cgColor } - // Dynamic bloom shadow expanding outward with voice dynamicStrokeLayer.shadowRadius = 4.0 + (dynamicFactor * 24.0) dynamicStrokeLayer.shadowOpacity = Float(0.50 + (0.45 * dynamicFactor)) diff --git a/src/PingMonitor.swift b/src/PingMonitor.swift new file mode 100644 index 0000000..7a4efb6 --- /dev/null +++ b/src/PingMonitor.swift @@ -0,0 +1,93 @@ +import Foundation + +public enum PingQuality { + case good(ms: Int) // < 450 ms -> Emerald Green + case medium(ms: Int) // 450 - 750 ms -> Warm Yellow/Lime + case high(ms: Int) // 750 - 1500 ms -> Amber Orange + case disconnected(ms: Int?) // > 1500 ms or Timeout/Error -> Crimson Red + + public var labelText: String { + switch self { + case .good(let ms): return "\(ms)ms" + case .medium(let ms): return "\(ms)ms" + case .high(let ms): return "\(ms)ms" + case .disconnected(let ms): + if let ms = ms { + return "\(ms)ms" + } else { + return "OFF" + } + } + } +} + +public final class PingMonitor { + public static let shared = PingMonitor() + + private let targetURL = URL(string: "https://translate.compare.soniox.com/")! + private var session: URLSession! + private var timer: Timer? + private var isRunning = false + + public var onPingUpdate: ((PingQuality) -> Void)? + public private(set) var currentQuality: PingQuality = .medium(ms: 380) + + private init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 2.5 + config.timeoutIntervalForResource = 2.5 + config.waitsForConnectivity = false + config.requestCachePolicy = .reloadIgnoringLocalCacheData + self.session = URLSession(configuration: config) + } + + public func start() { + guard !isRunning else { return } + isRunning = true + measurePing() + DispatchQueue.main.async { [weak self] in + self?.timer?.invalidate() + self?.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.measurePing() + } + } + } + + public func stop() { + isRunning = false + timer?.invalidate() + timer = nil + } + + private func measurePing() { + var req = URLRequest(url: targetURL) + req.httpMethod = "HEAD" + let t0 = Date() + + let task = session.dataTask(with: req) { [weak self] _, resp, _ in + let elapsed = Date().timeIntervalSince(t0) * 1000.0 + let ms = Int(elapsed) + + let quality: PingQuality + if let http = resp as? HTTPURLResponse, (200...399).contains(http.statusCode) { + if ms < 450 { + quality = .good(ms: ms) + } else if ms <= 750 { + quality = .medium(ms: ms) + } else if ms <= 1500 { + quality = .high(ms: ms) + } else { + quality = .disconnected(ms: ms) + } + } else { + quality = .disconnected(ms: nil) + } + + DispatchQueue.main.async { + self?.currentQuality = quality + self?.onPingUpdate?(quality) + } + } + task.resume() + } +}