aboutsummaryrefslogtreecommitdiff
path: root/Hotline/Library/NetSocket/TransferRateEstimator.swift
diff options
context:
space:
mode:
authorRuben Beltran del Rio <git@r.bdr.sh>2025-11-27 23:38:04 +0100
committerRuben Beltran del Rio <git@r.bdr.sh>2025-11-27 23:38:04 +0100
commit213710bf5bd6413c747bf126db50816ef5de5a6e (patch)
tree912f8cf87955a08077c92fea8ad934f50b7ab975 /Hotline/Library/NetSocket/TransferRateEstimator.swift
parentf466b21dc02f78c984ba6748e703f6780a7a0db4 (diff)
parent6a95b53616a4abfa306ddce43151cf4fefbd20ed (diff)
Merge remote-tracking branch 'upstream/main'
Diffstat (limited to 'Hotline/Library/NetSocket/TransferRateEstimator.swift')
-rw-r--r--Hotline/Library/NetSocket/TransferRateEstimator.swift138
1 files changed, 138 insertions, 0 deletions
diff --git a/Hotline/Library/NetSocket/TransferRateEstimator.swift b/Hotline/Library/NetSocket/TransferRateEstimator.swift
new file mode 100644
index 0000000..61383ff
--- /dev/null
+++ b/Hotline/Library/NetSocket/TransferRateEstimator.swift
@@ -0,0 +1,138 @@
+// NetSocket: TransferRateEstimator
+// Dustin Mierau • @mierau
+// MIT License
+
+import Foundation
+
+/// Transfer rate estimator using exponential moving average (EMA)
+///
+/// Tracks transfer speed and estimates time remaining. Designed to smooth out
+/// network jitter and provide stable estimates after collecting enough samples.
+///
+/// Example:
+/// ```swift
+/// var estimator = TransferRateEstimator(total: fileSize)
+///
+/// while transferring {
+/// let chunk = try await receiveData()
+/// let progress = estimator.update(bytes: chunk.count)
+/// print("Speed: \(progress.bytesPerSecond ?? 0) B/s, ETA: \(progress.estimatedTimeRemaining ?? 0)s")
+/// }
+/// ```
+public struct TransferRateEstimator {
+ /// Total bytes to transfer (nil if unknown)
+ public let total: Int?
+
+ /// Exponential moving average of transfer rate (bytes/second)
+ private var emaBytesPerSecond: Double = 0
+
+ /// Smoothing factor for EMA (0 < alpha ≤ 1)
+ /// Higher = more responsive to recent changes, lower = more smoothing
+ private let alpha: Double
+
+ /// Number of samples collected
+ private var sampleCount: Int = 0
+
+ /// Timestamp of first sample (for elapsed time calculation)
+ private var startTime: ContinuousClock.Instant?
+
+ /// Timestamp of last update (for calculating sample duration)
+ private var lastUpdateTime: ContinuousClock.Instant?
+
+ /// Minimum elapsed time before trusting estimates (seconds)
+ private let minElapsedTime: TimeInterval
+
+ /// Minimum number of samples before trusting estimates
+ private let minSamples: Int
+
+ /// Current number of bytes transferred
+ public private(set) var transferred: Int = 0
+
+ /// Create a new transfer rate estimator
+ ///
+ /// - Parameters:
+ /// - total: Total bytes to transfer (nil if unknown)
+ /// - alpha: EMA smoothing factor (default: 0.2). Range: 0.0-1.0
+ /// - minElapsedTime: Minimum elapsed time before estimates are reliable (default: 2.0s)
+ /// - minSamples: Minimum samples before estimates are reliable (default: 4)
+ public init(
+ total: Int? = nil,
+ alpha: Double = 0.2,
+ minElapsedTime: TimeInterval = 2.0,
+ minSamples: Int = 8
+ ) {
+ precondition(alpha > 0 && alpha <= 1, "alpha must be in range (0, 1]")
+ precondition(minSamples >= 0, "minSamples must be >= 0")
+
+ self.total = total
+ self.alpha = alpha
+ self.minElapsedTime = minElapsedTime
+ self.minSamples = minSamples
+ }
+
+ @discardableResult
+ public mutating func update(total: Int) -> NetSocket.FileProgress {
+ return self.update(bytes: max(0, total - self.transferred))
+ }
+
+ /// Update the estimator with a new data sample
+ ///
+ /// Automatically calculates the duration since the last update.
+ ///
+ /// - Parameter bytes: Number of bytes transferred in this sample
+ /// - Returns: Current progress with speed and ETA estimates
+ @discardableResult
+ public mutating func update(bytes: Int) -> NetSocket.FileProgress {
+ let clock = ContinuousClock()
+ let now = clock.now
+
+ // Record start time on first sample
+ if self.startTime == nil {
+ self.startTime = now
+ }
+
+ // Calculate duration since last update
+ let duration = self.lastUpdateTime.map { now - $0 } ?? .zero
+ self.lastUpdateTime = now
+
+ // Update transferred count
+ self.transferred += bytes
+
+ // Calculate instantaneous rate for this sample
+ let seconds: Double = duration / .seconds(1.0)
+ if seconds > 0 {
+ let instantRate = Double(bytes) / seconds
+ self.sampleCount += 1
+
+ // Update EMANetSocket
+ if self.emaBytesPerSecond == 0 {
+ self.emaBytesPerSecond = instantRate
+ } else {
+ self.emaBytesPerSecond += self.alpha * (instantRate - self.emaBytesPerSecond)
+ }
+ }
+
+ // Determine if we have enough data to trust the estimate
+ let elapsed = self.startTime.map { now - $0 } ?? .zero
+ let elapsedSeconds: Double = elapsed / .seconds(1.0)
+ let haveEstimate = (elapsedSeconds >= self.minElapsedTime || self.sampleCount >= self.minSamples) && self.emaBytesPerSecond > 0
+
+ // Calculate ETA if we have both an estimate and a known total
+ let eta: TimeInterval?
+ if haveEstimate, let total = self.total {
+ let remaining = total - self.transferred
+ eta = remaining > 0 ? TimeInterval(Double(remaining) / self.emaBytesPerSecond) : 0
+ } else {
+ eta = nil
+ }
+
+ return NetSocket.FileProgress(
+ sent: self.transferred,
+ total: self.total,
+ now: bytes,
+ bytesPerSecond: haveEstimate ? self.emaBytesPerSecond : nil,
+ estimatedTimeRemaining: eta
+ )
+ }
+}
+