diff --git a/clients/apple/Sources/PunktfunkClient/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/SessionModel.swift index 7527e0c..d980bec 100644 --- a/clients/apple/Sources/PunktfunkClient/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/SessionModel.swift @@ -77,6 +77,7 @@ final class SessionModel: ObservableObject { func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32, compositor: PunktfunkConnection.Compositor = .auto, gamepad: PunktfunkConnection.GamepadType = .auto, + bitrateKbps: UInt32 = 0, autoTrust: Bool = false) { guard phase == .idle else { return } phase = .connecting @@ -93,7 +94,7 @@ final class SessionModel: ObservableObject { host: host.address, port: host.port, width: width, height: height, refreshHz: hz, pinSHA256: pin, identity: identity, compositor: compositor, - gamepad: gamepad) } + gamepad: gamepad, bitrateKbps: bitrateKbps) } await MainActor.run { [weak self] in guard let self else { return } // The user may have abandoned this attempt (window closed, another host diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift index cd0887f..6421708 100644 --- a/clients/apple/Sources/PunktfunkClient/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/SettingsView.swift @@ -16,6 +16,7 @@ struct SettingsView: View { @AppStorage("punktfunk.hz") private var hz = 60 @AppStorage("punktfunk.compositor") private var compositor = 0 @AppStorage("punktfunk.gamepadType") private var gamepadType = 0 + @AppStorage("punktfunk.bitrateKbps") private var bitrateKbps = 0 @AppStorage("punktfunk.micEnabled") private var micEnabled = true @ObservedObject private var gamepads = GamepadManager.shared #if os(macOS) @@ -77,11 +78,19 @@ struct SettingsView: View { return ScrollView { VStack(spacing: 16) { TVSelectionRow(title: "Stream mode", options: options, selection: modeTag) + TVSelectionRow( + title: "Bitrate", options: bitrateOptions, selection: $bitrateKbps) + if bitrateKbps > 1_000_000 { + Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + .multilineTextAlignment(.center) + } TVSelectionRow( title: "Compositor", options: compositors, selection: $compositor) Text("The host creates a virtual output at exactly this mode — native " - + "resolution, no scaling. A specific compositor is honored only if " - + "available on the host.") + + "resolution, no scaling. \(Self.bitrateFooter) A specific compositor " + + "is honored only if available on the host.") .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -114,6 +123,77 @@ struct SettingsView: View { } #endif + // MARK: - Bitrate + + /// Slider domain, log-scale: the useful range spans three orders of magnitude + /// (a few Mbps … 3 Gbps) — linear would cram everything below 100 Mbps into the + /// first pixels. + private static let minSliderKbps = 2_000.0 + private static let maxSliderKbps = 3_000_000.0 + + private static let bitrateFooter = + "Automatic uses the host's default bitrate (20 Mbps); the host clamps any choice " + + "to its supported range. Run a speed test from a host card's context menu to " + + "pick an informed value. Applies from the next session." + + private static let gigabitWarning = + "Above 1 Gbps — test the network speed first (a host card's context menu → " + + "Test Network Speed…). A bitrate beyond what the link sustains causes loss " + + "and stutter." + + /// `bitrateKbps == 0` is Automatic; switching to manual lands on the host default. + private var automaticBitrate: Binding { + Binding( + get: { bitrateKbps == 0 }, + set: { bitrateKbps = $0 ? 0 : 20_000 }) + } + + /// Slider position 0...1 ↔ kbps on the log scale, snapped to two significant figures + /// so the readout shows round numbers instead of 47_322. + private var bitrateSlider: Binding { + Binding( + get: { + let v = Double(bitrateKbps).clamped(Self.minSliderKbps, Self.maxSliderKbps) + return log(v / Self.minSliderKbps) + / log(Self.maxSliderKbps / Self.minSliderKbps) + }, + set: { pos in + let raw = Self.minSliderKbps + * pow(Self.maxSliderKbps / Self.minSliderKbps, pos) + let mag = pow(10, floor(log10(raw)) - 1) + bitrateKbps = Int((raw / mag).rounded() * mag) + }) + } + + #if os(tvOS) + /// tvOS has no Slider — the focus-native control is the pushed picker (the same + /// pattern as the stream mode), so the rates are presets here, up to the same 3 Gbps + /// ceiling, plus a custom entry so a non-preset stored value stays visible. + private static let bitratePresets: [(label: String, tag: Int)] = [ + ("Automatic", 0), + ("10 Mbps", 10_000), + ("20 Mbps", 20_000), + ("40 Mbps", 40_000), + ("80 Mbps", 80_000), + ("150 Mbps", 150_000), + ("300 Mbps", 300_000), + ("500 Mbps", 500_000), + ("1 Gbps", 1_000_000), + ("1.5 Gbps", 1_500_000), + ("2 Gbps", 2_000_000), + ("3 Gbps", 3_000_000), + ] + + private var bitrateOptions: [(label: String, tag: Int)] { + var options = Self.bitratePresets + if !options.contains(where: { $0.tag == bitrateKbps }) { + options.insert( + (SpeedTestSheet.mbpsLabel(kbps: bitrateKbps) + " (custom)", bitrateKbps), at: 1) + } + return options + } + #endif + // MARK: - Controllers private static let padTypes: [(label: String, tag: Int)] = [ @@ -200,11 +280,32 @@ struct SettingsView: View { LabeledContent("") { Button("Use this display's mode") { fillFromMainScreen() } } + // (sharedBody is unused on tvOS — its body still compiles there, and + // Slider doesn't exist on tvOS; the tv path has its own preset picker.) + #if !os(tvOS) + Toggle("Automatic bitrate", isOn: automaticBitrate) + if bitrateKbps != 0 { + HStack(spacing: 12) { + Slider(value: bitrateSlider, in: 0...1) { + Text("Bitrate") + } + Text(SpeedTestSheet.mbpsLabel(kbps: bitrateKbps)) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(minWidth: 76, alignment: .trailing) + } + if bitrateKbps > 1_000_000 { + Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + } + } + #endif } header: { Text("Stream mode") } footer: { Text("The host creates a virtual output at exactly this mode — " - + "native resolution, no scaling.") + + "native resolution, no scaling. \(Self.bitrateFooter)") .font(.caption) .foregroundStyle(.secondary) } @@ -324,3 +425,10 @@ struct SettingsView: View { #endif } } + +extension Double { + /// The log-scale slider mapping needs a bounded input (Automatic stores 0). + fileprivate func clamped(_ lo: Double, _ hi: Double) -> Double { + Swift.min(Swift.max(self, lo), hi) + } +} diff --git a/clients/apple/Sources/PunktfunkClient/SpeedTestSheet.swift b/clients/apple/Sources/PunktfunkClient/SpeedTestSheet.swift new file mode 100644 index 0000000..9d952ec --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/SpeedTestSheet.swift @@ -0,0 +1,237 @@ +// Network speed-test sheet (roadmap §9): connect to the host, ask it to burst probe +// filler over the real data plane (FEC-encoded UDP, video paused — the measurement IS the +// streaming path), poll the measurement, and recommend a bitrate (~70% of the measured +// goodput, headroom for encoder burstiness). "Use N Mbps" writes the bitrate setting; it +// applies from the next session. +// +// Runs only while idle (the host serves one session at a time, so it can't share the wire +// with a live stream — the host-card grid is the idle UI anyway). Trust: a pinned host is +// verified as usual; an unpinned one is probed trust-on-first-use WITHOUT persisting +// anything — a bandwidth number doesn't justify a trust decision. + +import Foundation +import PunktfunkKit +import SwiftUI + +/// Dismissal must abandon the in-flight probe: the connect/poll loop runs detached and +/// checks this flag, closing the connection itself. Only the flag is shared; it is safe +/// to read/write from the loop and the main actor (single Bool, torn reads harmless). +private final class ProbeToken: @unchecked Sendable { + var cancelled = false +} + +/// What the host is asked to burst: the host's full probe ceiling (it clamps to ≤ 3 Gbps), +/// so the measurement surfaces the link's real ceiling instead of an artificial cap — +/// bursting ABOVE what the link can carry is how the probe finds where delivery falls off. +/// Two seconds rides out scheduler jitter. File-scope so the detached probe task reads them +/// without crossing into the view's main actor. +private let probeTargetKbps: UInt32 = 3_000_000 +private let probeDurationMs: UInt32 = 2_000 + +struct SpeedTestSheet: View { + @Environment(\.dismiss) private var dismiss + let host: StoredHost + + @AppStorage("punktfunk.width") private var width = 1920 + @AppStorage("punktfunk.height") private var height = 1080 + @AppStorage("punktfunk.hz") private var hz = 60 + @AppStorage("punktfunk.bitrateKbps") private var bitrateKbps = 0 + + private enum Phase: Equatable { + case connecting + case probing(partial: PunktfunkConnection.ProbeResult?) + case done(PunktfunkConnection.ProbeResult) + case failed(String) + } + + @State private var phase: Phase = .connecting + @State private var token = ProbeToken() + + var body: some View { + VStack(spacing: 20) { + Label("Speed test — \(host.displayName)", systemImage: "gauge.with.needle") + .font(.headline) + .foregroundStyle(.tint) + + switch phase { + case .connecting: + ProgressView("Connecting…") + .padding(.vertical, 12) + case .probing(let partial): + VStack(spacing: 8) { + ProgressView("Measuring — the host is bursting probe data…") + if let partial, partial.throughputKbps > 0 { + Text("~\(Self.mbpsLabel(kbps: Int(partial.throughputKbps))) so far") + .font(.callout.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 12) + case .done(let result): + resultView(result) + case .failed(let message): + Text(message) + .font(.callout) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + } + + HStack(spacing: 24) { + Button(phaseIsFinal ? "Close" : "Cancel", role: .cancel) { + token.cancelled = true + dismiss() + } + #if !os(tvOS) + .keyboardShortcut(.cancelAction) + #endif + if case .done(let result) = phase, let rec = Self.recommendedKbps(result) { + Button("Use \(Self.mbpsLabel(kbps: rec))") { + bitrateKbps = rec + dismiss() + } + .buttonStyle(.borderedProminent) + #if !os(tvOS) + .keyboardShortcut(.defaultAction) + #endif + } + if case .failed = phase { + Button("Retry") { run() } + .buttonStyle(.borderedProminent) + } + } + } + #if os(tvOS) + .frame(maxWidth: 1000) + .padding(60) + #else + .padding(24) + #endif + #if os(macOS) + .frame(width: 420) + .fixedSize(horizontal: false, vertical: true) + #endif + .onAppear { run() } + .onDisappear { token.cancelled = true } + } + + private var phaseIsFinal: Bool { + switch phase { + case .done, .failed: return true + case .connecting, .probing: return false + } + } + + private func resultView(_ result: PunktfunkConnection.ProbeResult) -> some View { + VStack(spacing: 10) { + Text(Self.mbpsLabel(kbps: Int(result.throughputKbps))) + .font(.system(.largeTitle, design: .rounded).weight(.semibold)) + .monospacedDigit() + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 4) { + GridRow { + Text("Loss").foregroundStyle(.secondary) + Text(String(format: "%.1f %%", result.lossPct)).monospacedDigit() + } + GridRow { + Text("Received").foregroundStyle(.secondary) + Text("\(ByteCountFormatter.string(fromByteCount: Int64(result.recvBytes), countStyle: .binary)) in \(result.elapsedMs) ms") + .monospacedDigit() + } + } + .font(.callout) + if let rec = Self.recommendedKbps(result) { + Text("Recommended bitrate: \(Self.mbpsLabel(kbps: rec)) " + + "(~70% of measured, headroom for encoder bursts).") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } else { + Text("Too little data made it through to recommend a bitrate — " + + "check the network and retry.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + } + + /// ~70% of the measured goodput, whole Mbps, clamped to the host's session bitrate + /// ceiling (2 Gbps — it clamps any session request above that, so recommending more is + /// pointless). nil when the measurement carried too little signal to recommend anything. + static func recommendedKbps(_ result: PunktfunkConnection.ProbeResult) -> Int? { + guard result.throughputKbps >= 2_000 else { return nil } + let raw = Int(result.throughputKbps) * 7 / 10 + let wholeMbps = max(raw / 1_000, 2) + return min(wholeMbps, 2_000) * 1_000 + } + + static func mbpsLabel(kbps: Int) -> String { + if kbps >= 1_000_000 { + let gbps = Double(kbps) / 1_000_000 + return gbps == gbps.rounded() + ? "\(Int(gbps)) Gbps" + : String(format: "%.1f Gbps", gbps) + } + return kbps % 1_000 == 0 + ? "\(kbps / 1_000) Mbps" + : String(format: "%.1f Mbps", Double(kbps) / 1_000) + } + + private func run() { + phase = .connecting + let token = token + let address = host.address + let port = host.port + let pin = host.pinnedSHA256 + let (w, h, fps) = (UInt32(clamping: width), UInt32(clamping: height), UInt32(clamping: hz)) + Task.detached(priority: .userInitiated) { + // Connect (blocking) — same identity/trust as a session, but TOFU results are + // NOT persisted from here. + let identity = (try? ClientIdentityStore.shared.load())?.identity + let conn: PunktfunkConnection + do { + conn = try PunktfunkConnection( + host: address, port: port, width: w, height: h, refreshHz: fps, + pinSHA256: pin, identity: identity) + } catch { + await MainActor.run { + guard !token.cancelled else { return } + phase = .failed( + "Could not connect to \(address):\(port) — is punktfunk-host " + + "running and not mid-session?") + } + return + } + defer { conn.close() } + + conn.startSpeedTest(targetKbps: probeTargetKbps, durationMs: probeDurationMs) + await MainActor.run { if !token.cancelled { phase = .probing(partial: nil) } } + + // Poll until the host's end-of-burst report lands (or a generous deadline — + // the host clamps the burst to ≤ 5 s). + let deadline = Date().addingTimeInterval(Double(probeDurationMs) / 1000 + 8) + var final: PunktfunkConnection.ProbeResult? + while !token.cancelled, Date() < deadline { + try? await Task.sleep(nanoseconds: 200_000_000) + guard let r = conn.probeResult() else { break } // closed underneath us + if r.done { + final = r + break + } + await MainActor.run { + if !token.cancelled { phase = .probing(partial: r) } + } + } + let result = final + await MainActor.run { + guard !token.cancelled else { return } + if let result { + phase = .done(result) + } else { + phase = .failed( + "The measurement never completed — the connection may have " + + "dropped mid-probe. Retry?") + } + } + } + } +} diff --git a/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift index 3d2a50d..0a4b919 100644 --- a/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift @@ -202,6 +202,11 @@ public final class PunktfunkConnection { /// machines. `0` = no correction (an older host that didn't answer, or synchronized clocks). public private(set) var clockOffsetNs: Int64 = 0 + /// The video encoder bitrate (kbps) the host actually configured — the requested + /// `bitrateKbps` clamped to the host's range ([500, 2 000 000] kbps), or its default + /// (20 000) when 0 was requested. `0` = an older host that didn't report it. + public private(set) var resolvedBitrateKbps: UInt32 = 0 + /// Connect and start a session at the requested mode (the host creates a native virtual /// output at exactly this size/refresh). Blocks up to `timeoutMs`. /// @@ -218,6 +223,10 @@ public final class PunktfunkConnection { /// /// `gamepad`: which virtual pad the host creates for this session's controllers (see /// `GamepadType`; `.auto` = host decides). Check `resolvedGamepad` afterwards. + /// + /// `bitrateKbps`: requested video encoder bitrate (0 = host default; the host clamps + /// to its supported range). Check `resolvedBitrateKbps` afterwards — a speed test + /// (`startSpeedTest`) is how a client picks an informed value. public init( host: String, port: UInt16 = 9777, width: UInt32, height: UInt32, refreshHz: UInt32, @@ -225,6 +234,7 @@ public final class PunktfunkConnection { identity: ClientIdentity? = nil, compositor: Compositor = .auto, gamepad: GamepadType = .auto, + bitrateKbps: UInt32 = 0, timeoutMs: UInt32 = 10_000 ) throws { if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin } @@ -234,16 +244,16 @@ public final class PunktfunkConnection { withOptionalCString(identity?.keyPEM) { key in if let pin = pinSHA256 { return pin.withUnsafeBytes { p in - punktfunk_connect_ex2( + punktfunk_connect_ex3( cs, port, width, height, refreshHz, compositor.rawValue, - gamepad.rawValue, + gamepad.rawValue, bitrateKbps, p.bindMemory(to: UInt8.self).baseAddress, &observed, cert, key, timeoutMs) } } - return punktfunk_connect_ex2( + return punktfunk_connect_ex3( cs, port, width, height, refreshHz, compositor.rawValue, - gamepad.rawValue, + gamepad.rawValue, bitrateKbps, nil, &observed, cert, key, timeoutMs) } } @@ -261,6 +271,54 @@ public final class PunktfunkConnection { var offset: Int64 = 0 _ = punktfunk_connection_clock_offset_ns(handle, &offset) clockOffsetNs = offset + var br: UInt32 = 0 + _ = punktfunk_connection_bitrate(handle, &br) + resolvedBitrateKbps = br + } + + /// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`. + public struct ProbeResult: Sendable, Equatable { + /// The host's end-of-burst report arrived — the numbers are final. + public let done: Bool + /// Probe payload bytes / packets the client received. + public let recvBytes: UInt64 + public let recvPackets: UInt32 + /// Probe payload bytes / packets the host reported sending. + public let hostBytes: UInt64 + public let hostPackets: UInt32 + /// Client-measured receive window (first→last probe AU), milliseconds. + public let elapsedMs: UInt32 + /// Measured goodput, kilobits per second. + public let throughputKbps: UInt32 + /// Delivery loss `(hostBytes − recvBytes) / hostBytes`, percent (0 if unknown). + public let lossPct: Float + } + + /// Start a bandwidth speed test: the host bursts filler over the data plane at + /// `targetKbps` of goodput for `durationMs` (clamped host-side to ≤ 3 Gbps / ≤ 5 s), + /// briefly pausing video. Non-blocking — poll `probeResult()` until `done`. Starting + /// a probe resets any prior measurement. Silently dropped after close. + public func startSpeedTest(targetKbps: UInt32, durationMs: UInt32) { + abiLock.lock() + defer { abiLock.unlock() } + guard let h = handle, !closeRequested else { return } + _ = punktfunk_connection_speed_test(h, targetKbps, durationMs) + } + + /// The current speed-test measurement (zeros before any probe; partial until `done`). + /// Safe to poll from any thread; nil after close. + public func probeResult() -> ProbeResult? { + abiLock.lock() + defer { abiLock.unlock() } + guard let h = handle, !closeRequested else { return nil } + var out = PunktfunkProbeResult() + guard punktfunk_connection_probe_result(h, &out) == statusOK else { return nil } + return ProbeResult( + done: out.done != 0, + recvBytes: out.recv_bytes, recvPackets: out.recv_packets, + hostBytes: out.host_bytes, hostPackets: out.host_packets, + elapsedMs: out.elapsed_ms, throughputKbps: out.throughput_kbps, + lossPct: out.loss_pct) } /// Ask the host to switch the live session to a new mode (window resized) — no @@ -509,6 +567,17 @@ public extension PunktfunkInputEvent { static func mouseMove(dx: Int32, dy: Int32) -> PunktfunkInputEvent { make(PUNKTFUNK_INPUT_KIND_MOUSE_MOVE.rawValue, code: 0, x: dx, y: dy) } + /// Absolute cursor position in client-surface pixels — the host places its cursor + /// there (same letterbox mapping and `flags` surface-dims packing as the touch events). + /// Used by the iPad pointer fallback when the scene can't pointer-lock and GCMouse's + /// relative deltas aren't available; the surface dimensions must each fit in 16 bits. + static func mouseMoveAbs( + x: Int32, y: Int32, surfaceWidth: UInt32, surfaceHeight: UInt32 + ) -> PunktfunkInputEvent { + make( + PUNKTFUNK_INPUT_KIND_MOUSE_MOVE_ABS.rawValue, code: 0, x: x, y: y, + flags: ((surfaceWidth & 0xFFFF) << 16) | (surfaceHeight & 0xFFFF)) + } /// GameStream button ids: 1=left 2=middle 3=right 4=X1 5=X2 (host maps to evdev BTN_*). static func mouseButton(_ button: UInt32, down: Bool) -> PunktfunkInputEvent { make( diff --git a/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift b/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift index bf32a47..0c07b3d 100644 --- a/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift @@ -15,10 +15,14 @@ final class LoopbackIntegrationTests: XCTestCase { } let conn = try PunktfunkConnection( - host: "127.0.0.1", port: port, width: 1280, height: 720, refreshHz: 60) + host: "127.0.0.1", port: port, width: 1280, height: 720, refreshHz: 60, + bitrateKbps: 50_000) XCTAssertEqual(conn.width, 1280) XCTAssertEqual(conn.height, 720) XCTAssertEqual(conn.refreshHz, 60) + // The Welcome echoes the negotiated encoder bitrate (50 Mbps is within the + // host's accepted range, so it comes back unclamped). + XCTAssertEqual(conn.resolvedBitrateKbps, 50_000) // Pull 25 synthetic frames and byte-verify the documented pattern: // u32 LE frame index, then data[i] = (idx as u8) &+ (i as u8). @@ -88,12 +92,34 @@ final class LoopbackIntegrationTests: XCTestCase { "missing the scripted trigger event: \(hidout)") } + // Speed test against the synthetic host: a short 20 Mbps burst over the real + // data plane. Probe filler is diverted from the frame queue (the 25-frame + // verification above stays byte-exact), the host's end-of-burst report flips + // `done`, and the measurement carries real numbers. + conn.startSpeedTest(targetKbps: 20_000, durationMs: 500) + var probe: PunktfunkConnection.ProbeResult? + let probeDeadline = Date().addingTimeInterval(10) + while Date() < probeDeadline { + if let r = conn.probeResult(), r.done { + probe = r + break + } + Thread.sleep(forTimeInterval: 0.1) + } + let result = try XCTUnwrap(probe, "the probe never completed") + XCTAssertGreaterThan(result.recvBytes, 0) + XCTAssertGreaterThan(result.hostBytes, 0) + XCTAssertGreaterThan(result.throughputKbps, 0) + XCTAssertGreaterThan(result.elapsedMs, 0) + XCTAssertGreaterThanOrEqual(result.lossPct, 0) + conn.close() XCTAssertThrowsError(try conn.nextAU(timeoutMs: 10)) { error in guard case PunktfunkClientError.closed = error else { return XCTFail("expected .closed, got \(error)") } } + XCTAssertNil(conn.probeResult()) } func testConnectFailureThrows() { diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 5fca6be..f3d13d6 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -1406,7 +1406,7 @@ pub struct PunktfunkProbeResult { } /// Start a bandwidth speed test: ask the host to burst filler over the data plane at -/// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 1 Gbps / ≤ 5 s), +/// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 3 Gbps / ≤ 5 s), /// *briefly pausing video*. Non-blocking — poll [`punktfunk_connection_probe_result`] until its /// `done` field is 1. Starting a probe resets any prior measurement. /// diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs index eb56da2..0962223 100644 --- a/crates/punktfunk-core/src/client.rs +++ b/crates/punktfunk-core/src/client.rs @@ -366,7 +366,7 @@ impl NativeClient { /// `target_kbps` of goodput for `duration_ms`, *briefly pausing video*. Non-blocking — the /// measurement accumulates in the background; poll [`NativeClient::probe_result`] until its /// `done` flag is set. Starting a probe resets any prior measurement. The host clamps both - /// fields (≤ 1 Gbps, ≤ 5 s). + /// fields (≤ 3 Gbps, ≤ 5 s). pub fn request_probe(&self, target_kbps: u32, duration_ms: u32) -> Result<()> { // Reset the accumulator so a fresh run doesn't blend into the previous one. *self.probe.lock().unwrap() = ProbeState { diff --git a/docs-site/content/docs/roadmap.md b/docs-site/content/docs/roadmap.md index 245f1e7..8ec5f29 100644 --- a/docs-site/content/docs/roadmap.md +++ b/docs-site/content/docs/roadmap.md @@ -175,20 +175,20 @@ client) is built and live. Two changes harden it from "works" to "secure by defa PIN pairing (§8a) stays the bootstrap — the first device, or when no approver is online. -## 9. Client→host network speed test + settable bitrate *(host side done — client UI remaining)* +## 9. Client→host network speed test + settable bitrate *(host + Apple client done — web console remaining)* Measure what the network actually sustains so the bitrate picker is informed (suggest/cap a safe value) instead of guesswork that ends in a stuttering stream. **Done & live (host + protocol + connector + C ABI, `74819b1`):** - **Bitrate negotiation**: `bitrate_kbps` rides Hello/Welcome (trailing-byte back-compat). The - client requests a rate; the host clamps to [500 kbps, 500 Mbps] (or its 20 Mbps default on 0), + client requests a rate; the host clamps to [500 kbps, 2 Gbps] (or its 20 Mbps default on 0), applies it to NVENC (replacing the old hardcoded 20 Mbps) on the initial mode + every reconfigure, and echoes the resolved value. C ABI: `punktfunk_connect_ex3(…, bitrate_kbps, …)` + `punktfunk_connection_bitrate()`. - **Bandwidth probe over the punktfunk/1 data path**: `ProbeRequest{target_kbps,duration_ms}` / `ProbeResult{bytes_sent,…}` control messages + a `FLAG_PROBE` packet flag. The host bursts - zero-filled FEC-encoded AUs at the target goodput for the duration (clamped ≤ 1 Gbps / ≤ 5 s, + zero-filled FEC-encoded AUs at the target goodput for the duration (clamped ≤ 3 Gbps / ≤ 5 s, video paused), reports what it sent; the connector measures received bytes/window → goodput + loss and exposes it (`punktfunk_connection_speed_test()` + `punktfunk_connection_probe_result()` → `PunktfunkProbeResult{throughput_kbps, loss_pct, …}`). Probe filler is diverted from the decoder. @@ -196,9 +196,17 @@ value) instead of guesswork that ends in a stuttering stream. interleaved probe AUs excluded from frame verification. `punktfunk-client-rs` gains `--bitrate` + `--speed-test KBPS:MS` as the reference/loopback driver. -**Remaining (client UI):** wire the C ABI into the Apple client — a "Test network" action -(`speed_test` → poll `probe_result` → "~XXX Mbps · recommended bitrate YYY") feeding a bitrate -control (`connect_ex3`), and surface both in the web console. +**Done (Apple client UI):** Settings grows a Bitrate control (Automatic = host default; manual is +a log-scale slider up to 3 Gbps with an above-1-Gbps "test the speed first" warning — tvOS keeps +a focus-native preset picker; rides `connect_ex3` on every connect, `PUNKTFUNK_BITRATE_KBPS` dev +override), and each host card's context menu gets +"Test Network Speed…" — a sheet that connects, runs `speed_test` (up to the host's 3 Gbps +probe ceiling for 2 s), polls `probe_result` with a live readout, and shows measured +goodput · loss · recommended bitrate (≈70% of measured, capped at the 2 Gbps session +ceiling) with a one-tap "Use N Mbps" writing the setting. Loopback-tested through the +xcframework: bitrate echo (50 000 → 50 000) + a 20 Mbps/500 ms probe completing with real numbers. + +**Remaining:** surface both in the web console. ## 10. HDR + 10-bit color *(parked — blocked upstream at the compositor producer)* diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 5256459..62cd6c5 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -830,7 +830,7 @@ PunktfunkStatus punktfunk_connection_request_mode(const PunktfunkConnection *c, #if defined(PUNKTFUNK_FEATURE_QUIC) // Start a bandwidth speed test: ask the host to burst filler over the data plane at -// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 1 Gbps / ≤ 5 s), +// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 3 Gbps / ≤ 5 s), // *briefly pausing video*. Non-blocking — poll [`punktfunk_connection_probe_result`] until its // `done` field is 1. Starting a probe resets any prior measurement. //