Browse Source

feat: real-time 1s ping monitor to Soniox with dynamic orb color (green/yellow/orange/red) and millisecond HUD readout

main
Ali Alavi 2 weeks ago
parent
commit
abb463f51a
  1. 11
      src/AppDelegate.swift
  2. 63
      src/HUDOverlay.swift
  3. 93
      src/PingMonitor.swift

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

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

93
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()
}
}
Loading…
Cancel
Save