diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StylusInput.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StylusInput.kt index f6220c20..e02042ea 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StylusInput.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StylusInput.kt @@ -14,7 +14,11 @@ private const val PEN_TOUCHING = 2f private const val PEN_BARREL1 = 4f private const val PEN_BARREL2 = 8f private const val STRIDE = 10 -private const val MAX_SAMPLES = 8 +// Ceiling on samples per emit, NOT the wire batch size: the JNI layer splits an over-8 run into +// consecutive wire batches (never truncates — a long historical run means the UI thread hitched, +// which is exactly when dropping its head would notch the stroke). 64 samples ≈ >250 ms of +// 240 Hz history; anything past that clamp is a pathological stall, not stroke geometry. +private const val MAX_SAMPLES = 64 /** * Android stylus → the state-full pen plane (design/pen-tablet-input.md §7): pressure, tilt @@ -117,7 +121,8 @@ internal class StylusStream(private val handle: Long) { NativeBridge.nativeSendPen(handle, last, 1) } - /** Historical (coalesced) samples oldest-first, then the current one — a single batch. */ + /** Historical (coalesced) samples oldest-first, then the current one — one emit; the JNI + * layer splits runs longer than the wire's 8-sample batch cap into consecutive sends. */ private fun emitSamples(me: MotionEvent, idx: Int, size: IntSize) { val history = minOf(me.historySize, MAX_SAMPLES - 1) var count = 0 diff --git a/clients/android/native/src/session/input.rs b/clients/android/native/src/session/input.rs index cbb66035..4a01acb6 100644 --- a/clients/android/native/src/session/input.rs +++ b/clients/android/native/src/session/input.rs @@ -185,7 +185,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostSupport /// Floats per sample in the `nativeSendPen` flat array. const PEN_JNI_STRIDE: usize = 10; -/// `NativeBridge.nativeSendPen(handle, samples, count)` — one stylus batch of STATE-FULL +/// Sample ceiling per `nativeSendPen` call: over-cap runs are SPLIT into consecutive ≤8-sample +/// `send_pen` batches (the send_pen contract — never truncated), so this only bounds the stack +/// buffer. 64 samples ≈ >250 ms of 240 Hz history = a pathological UI-thread stall. +const PEN_JNI_MAX_SAMPLES: usize = PEN_BATCH_MAX * 8; + +/// `NativeBridge.nativeSendPen(handle, samples, count)` — one stylus emit of STATE-FULL /// samples, `count` × [`PEN_JNI_STRIDE`] floats, oldest first: /// `[state, tool, x, y, pressure, distance, tilt_deg, azimuth_deg, roll_deg, dt_us]`. /// `state` = the wire `PEN_*` bits; `tool` 0=pen 1=eraser; `x`/`y`/`pressure`/`distance` @@ -203,53 +208,56 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen( if handle == 0 || count <= 0 { return; } - let count = (count as usize).min(PEN_BATCH_MAX); - let mut buf = [0f32; PEN_BATCH_MAX * PEN_JNI_STRIDE]; + let count = (count as usize).min(PEN_JNI_MAX_SAMPLES); + let mut buf = [0f32; PEN_JNI_MAX_SAMPLES * PEN_JNI_STRIDE]; let flat = &mut buf[..count * PEN_JNI_STRIDE]; if env.get_float_array_region(&samples, 0, flat).is_err() { return; // short array — a bridge bug, never worth a crash on the input path } - let mut batch = [PenSample::default(); PEN_BATCH_MAX]; - for (slot, s) in batch.iter_mut().zip(flat.chunks_exact(PEN_JNI_STRIDE)) { - if !s[2].is_finite() || !s[3].is_finite() { - return; // never forward a NaN coordinate - } - *slot = PenSample { - state: s[0] as u8, - tool: if s[1] as u8 == 1 { - PenTool::Eraser - } else { - PenTool::Pen - }, - x: s[2].clamp(0.0, 1.0), - y: s[3].clamp(0.0, 1.0), - pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16, - distance: if s[5] < 0.0 { - PEN_DISTANCE_UNKNOWN - } else { - (s[5].clamp(0.0, 1.0) * 65534.0) as u16 - }, - tilt_deg: if s[6] < 0.0 { - PEN_TILT_UNKNOWN - } else { - (s[6].clamp(0.0, 90.0)) as u8 - }, - azimuth_deg: if s[7] < 0.0 { - PEN_ANGLE_UNKNOWN - } else { - (s[7] as u16) % 360 - }, - roll_deg: if s[8] < 0.0 { - PEN_ANGLE_UNKNOWN - } else { - (s[8] as u16) % 360 - }, - dt_us: s[9].clamp(0.0, 65535.0) as u16, - }; - } // SAFETY: live handle per the nativeConnect/nativeClose contract; send_pen is &self. let h = unsafe { &*(handle as *const SessionHandle) }; - let _ = h.client.send_pen(&batch[..count]); + let mut batch = [PenSample::default(); PEN_BATCH_MAX]; + for run in flat.chunks(PEN_BATCH_MAX * PEN_JNI_STRIDE) { + let n = run.len() / PEN_JNI_STRIDE; + for (slot, s) in batch.iter_mut().zip(run.chunks_exact(PEN_JNI_STRIDE)) { + if !s[2].is_finite() || !s[3].is_finite() { + return; // never forward a NaN coordinate + } + *slot = PenSample { + state: s[0] as u8, + tool: if s[1] as u8 == 1 { + PenTool::Eraser + } else { + PenTool::Pen + }, + x: s[2].clamp(0.0, 1.0), + y: s[3].clamp(0.0, 1.0), + pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16, + distance: if s[5] < 0.0 { + PEN_DISTANCE_UNKNOWN + } else { + (s[5].clamp(0.0, 1.0) * 65534.0) as u16 + }, + tilt_deg: if s[6] < 0.0 { + PEN_TILT_UNKNOWN + } else { + (s[6].clamp(0.0, 90.0)) as u8 + }, + azimuth_deg: if s[7] < 0.0 { + PEN_ANGLE_UNKNOWN + } else { + (s[7] as u16) % 360 + }, + roll_deg: if s[8] < 0.0 { + PEN_ANGLE_UNKNOWN + } else { + (s[8] as u16) % 360 + }, + dt_us: s[9].clamp(0.0, 65535.0) as u16, + }; + } + let _ = h.client.send_pen(&batch[..n]); + } } /// `NativeBridge.nativeSendText(handle, text)` — committed IME text, one `TextInput` event per diff --git a/clients/apple/Sources/PunktfunkKit/Views/PencilStream.swift b/clients/apple/Sources/PunktfunkKit/Views/PencilStream.swift index 518cbc01..7458b6fb 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/PencilStream.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/PencilStream.swift @@ -40,6 +40,8 @@ final class PencilStream: NSObject, UIPencilInteractionDelegate { private(set) var hoverActive = false private var last = PencilStream.idleSample() private var heartbeat: Timer? + /// When the last batch (event or keepalive) went out — the heartbeat tick's idle test. + private var lastSendTs: TimeInterval = 0 // MARK: - Contact path (UITouch, `.pencil` only) @@ -52,11 +54,12 @@ final class PencilStream: NSObject, UIPencilInteractionDelegate { touching = true inRange = true // Coalesced samples restore the Pencil's full capture rate (UIKit delivers at - // display cadence); oldest first, `dt_us` preserving their spacing. + // display cadence); oldest first, `dt_us` preserving their spacing. The whole run + // is kept — emit() splits anything over the wire's batch cap. let raw = event?.coalescedTouches(for: touch) ?? [touch] var batch: [PunktfunkPenSample] = [] var prevTs: TimeInterval? - for t in raw.suffix(Int(PUNKTFUNK_PEN_BATCH_MAX)) { + for t in raw { guard let s = contactSample(t, in: view, prevTs: prevTs) else { continue } prevTs = t.timestamp batch.append(s) @@ -202,27 +205,46 @@ final class PencilStream: NSObject, UIPencilInteractionDelegate { guard !batch.isEmpty else { return } last = batch[batch.count - 1] last.dt_us = 0 - send?(batch) - armHeartbeat() + // A run longer than the wire's batch cap is SPLIT into consecutive sends, never + // truncated (the send_pen contract): an over-cap run means the main thread hitched + // past a frame of 240 Hz samples, and dropping its head would notch exactly the + // stroke geometry a drawing app can least afford to lose. + var start = 0 + while start < batch.count { + let end = min(start + Int(PUNKTFUNK_PEN_BATCH_MAX), batch.count) + send?(Array(batch[start..= 0.05 { + self.send?([self.last]) + self.lastSendTs = CACurrentMediaTime() + } } + heartbeat = timer + RunLoop.main.add(timer, forMode: .common) } // MARK: - Angle conversions diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index f275cb7d..37bf5dd5 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -320,12 +320,22 @@ public final class StreamViewController: StreamViewControllerBase { // prior session (stop() doesn't clear it). Otherwise a stale `true` could later // re-engage capture on a foreground that the new session never asked for. wasCapturedOnResign = false - // Read the LIVE mode per touch batch — an accepted requestMode() mid-stream - // changes the letterbox, and touches must follow it. + // The letterbox must follow an accepted requestMode() mid-stream, so this stays a live + // read — but behind a short TTL: the pencil path maps every coalesced sample through + // here (≤8 per event at panel rate, main thread), and a per-sample FFI read re-takes + // the ABI lock the batch send itself needs next. 250 ms staleness is invisible next to + // the video reconfigure a mode switch performs anyway. Main-thread-only closure. + var cachedMode = CGSize.zero + var cachedAt = CACurrentMediaTime() - 1 streamView.currentHostMode = { [weak connection] in - guard let connection else { return .zero } - let mode = connection.currentMode() - return CGSize(width: Double(mode.width), height: Double(mode.height)) + let now = CACurrentMediaTime() + if now - cachedAt > 0.25 { + guard let connection else { return .zero } + let mode = connection.currentMode() + cachedMode = CGSize(width: Double(mode.width), height: Double(mode.height)) + cachedAt = now + } + return cachedMode } streamView.onTouchEvent = { [weak self, weak connection] event in // Touch IS the intent during a trusted session, but must not leak to the host