From 47ca946ed2cf89ad307fb820d20a86f9fbfae36a Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 19:20:28 -0700 Subject: [PATCH 01/13] feat(spatial): place device touches per layout segment A device with several LED outputs in a layout had no touch placement at all, because one unnamed touch could land on any of them. Touches can now name the device segment they landed on, and DeviceSurfaceMap resolves a named touch through the one LED output bound to that segment. A touch that names no segment keeps the single-output rule, so single-surface devices such as the Lightpad resolve exactly as before. A named touch on a device whose only output names no segment lands there, since that output stands for the whole device. Strip outputs now follow their direction the way matrix outputs follow their start corner: a strip's surface x axis runs from its first LED, so a touch at a strip position lands on the LED the sampler reads there in every direction. Before, a strip took its surface frame as the output's local frame, which only agreed with the LEDs for left-to-right strips; that direction maps exactly as it did. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/spatial/device_surface.rs | 222 ++++++++++++------ .../tests/device_surface_tests.rs | 155 +++++++++++- 2 files changed, 307 insertions(+), 70 deletions(-) diff --git a/crates/hypercolor-core/src/spatial/device_surface.rs b/crates/hypercolor-core/src/spatial/device_surface.rs index c7b4a9eb7..b9b0b82b0 100644 --- a/crates/hypercolor-core/src/spatial/device_surface.rs +++ b/crates/hypercolor-core/src/spatial/device_surface.rs @@ -5,12 +5,16 @@ //! spatial layout places that device on a canvas, so the affine chain that //! positions the device's LEDs also positions a contact: a touch maps to the //! canvas point that the LED under the finger samples. +//! +//! A device with several LED outputs, such as a controller with separate pad +//! and strip segments, is placed per segment: a touch that names the segment +//! it landed on resolves through the output bound to that segment. use std::sync::Arc; use hypercolor_types::device::DeviceId; use hypercolor_types::spatial::{ - Corner, EdgeBehavior, LedTopology, NormalizedPosition, SpatialLayout, + Corner, EdgeBehavior, LedTopology, NormalizedPosition, SpatialLayout, StripDirection, }; use super::is_led_sampled_zone; @@ -18,11 +22,14 @@ use super::sampler::{OutputPlacement, resolve_edge_behavior}; /// Canvas placement of the device surfaces one spatial layout resolves. /// -/// A device gets a placement when exactly one LED output in the layout -/// routes to it. A device with no output there, or with several, gets none, -/// and its contacts carry only their surface position: the layout says -/// nothing about where that surface sits on this canvas, or says several -/// things, so there is no single canvas point to report. +/// A touch that names no segment resolves through the device's only LED +/// output in the layout. A touch that names a segment resolves through the +/// one LED output bound to that segment, or, when the device has a single +/// output that names no segment, through that output, which stands for the +/// whole device. Anything else gets no placement, and the contact carries +/// only its surface position: the layout says nothing about where that +/// surface sits on this canvas, or says several things, so there is no +/// single canvas point to report. /// /// Cloning is cheap; the placements are shared. #[derive(Debug, Clone, Default, PartialEq)] @@ -30,17 +37,23 @@ pub struct DeviceSurfaceMap { surfaces: Arc<[DeviceSurface]>, } -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, PartialEq)] struct DeviceSurface { device_id: DeviceId, + /// The output's segment, kept only when no other LED output of the same + /// device names it, so a touch naming it has exactly one place to land. + segment: Option>, + /// Whether this is the device's only LED output in the layout. + sole: bool, + /// Whether the output names no segment at all. + whole_device: bool, placement: OutputPlacement, - mirror_x: bool, - mirror_y: bool, + frame: SurfaceFrame, edge: EdgeBehavior, } impl DeviceSurfaceMap { - /// Place every device that owns exactly one LED output in `layout`. + /// Place every device surface `layout` resolves without ambiguity. /// /// `resolve` maps an output's layout `device_id` to the physical device /// it currently routes to, or `None` when nothing is bound to it. @@ -49,75 +62,110 @@ impl DeviceSurfaceMap { layout: &SpatialLayout, mut resolve: impl FnMut(&str) -> Option, ) -> Self { - let mut placed: Vec<(DeviceId, usize)> = Vec::new(); - let mut ambiguous: Vec = Vec::new(); - for (index, output) in layout.zones.iter().enumerate() { - if !is_led_sampled_zone(output) { - continue; - } - let Some(device_id) = resolve(&output.device_id) else { - continue; - }; - if ambiguous.contains(&device_id) { - continue; - } - if let Some(existing) = placed.iter().position(|(id, _)| *id == device_id) { - placed.swap_remove(existing); - ambiguous.push(device_id); - continue; - } - placed.push((device_id, index)); - } + let outputs: Vec<(DeviceId, usize)> = layout + .zones + .iter() + .enumerate() + .filter(|(_, output)| is_led_sampled_zone(output)) + .filter_map(|(index, output)| Some((resolve(&output.device_id)?, index))) + .collect(); - let surfaces = placed - .into_iter() - .map(|(device_id, index)| { + let surfaces = outputs + .iter() + .filter_map(|&(device_id, index)| { let output = &layout.zones[index]; - let (mirror_x, mirror_y) = surface_orientation(&output.topology); - DeviceSurface { + let siblings = || outputs.iter().filter(|(id, _)| *id == device_id); + let sole = siblings().count() == 1; + let segment = output.zone_name.as_deref().filter(|segment| { + siblings() + .filter(|(_, other)| { + layout.zones[*other].zone_name.as_deref() == Some(*segment) + }) + .count() + == 1 + }); + if !sole && segment.is_none() { + return None; + } + Some(DeviceSurface { device_id, + segment: segment.map(Arc::from), + sole, + whole_device: output.zone_name.is_none(), placement: OutputPlacement::of(output), - mirror_x, - mirror_y, + frame: SurfaceFrame::of(&output.topology), edge: resolve_edge_behavior(output, layout), - } + }) }) .collect(); Self { surfaces } } - /// Normalized canvas position of a point on a device's surface. + /// Normalized canvas position of a point on a device's surface, for a + /// touch that names no segment. + /// + /// Equivalent to [`Self::segment_canvas_position`] with no segment. + #[must_use] + pub fn canvas_position( + &self, + device_id: DeviceId, + x: f32, + y: f32, + ) -> Option { + self.segment_canvas_position(device_id, None, x, y) + } + + /// Normalized canvas position of a point on one segment of a device's + /// surface. /// - /// `x` and `y` are device-surface coordinates in `[0, 1]`; values outside - /// that range are clamped first. Returns `None` when the device has no - /// placement in this map or the point is not finite. + /// `segment` is the device segment the touch landed on, matched against + /// the layout outputs' segment names, or `None` for a touch on a device + /// with one surface. `x` and `y` are device-surface coordinates in + /// `[0, 1]`; values outside that range are clamped first. Returns `None` + /// when the touched surface has no placement in this map or the point is + /// not finite. /// /// The result passes through the output's edge behavior exactly as its /// LED positions do, so it is the canvas point those LEDs would sample. #[must_use] - pub fn canvas_position( + pub fn segment_canvas_position( &self, device_id: DeviceId, + segment: Option<&str>, x: f32, y: f32, ) -> Option { if !x.is_finite() || !y.is_finite() { return None; } - let surface = self + let surface = self.surface(device_id, segment)?; + let local = surface.frame.to_local(x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)); + Some(surface.placement.local_to_canvas(local, surface.edge)) + } + + fn surface(&self, device_id: DeviceId, segment: Option<&str>) -> Option<&DeviceSurface> { + let mut surfaces = self .surfaces .iter() - .find(|surface| surface.device_id == device_id)?; - let x = x.clamp(0.0, 1.0); - let y = y.clamp(0.0, 1.0); - let local = NormalizedPosition::new( - if surface.mirror_x { 1.0 - x } else { x }, - if surface.mirror_y { 1.0 - y } else { y }, - ); - Some(surface.placement.local_to_canvas(local, surface.edge)) + .filter(|surface| surface.device_id == device_id); + match segment { + None => surfaces.find(|surface| surface.sole), + Some(segment) => { + let mut whole_device = None; + for surface in surfaces { + if surface.segment.as_deref() == Some(segment) { + return Some(surface); + } + if surface.sole && surface.whole_device { + whole_device = Some(surface); + } + } + whole_device + } + } } - /// Whether `device_id` has a placement in this map. + /// Whether any surface of `device_id` has a placement in this map. #[must_use] pub fn contains(&self, device_id: DeviceId) -> bool { self.surfaces @@ -125,13 +173,13 @@ impl DeviceSurfaceMap { .any(|surface| surface.device_id == device_id) } - /// Number of placed devices. + /// Number of placed surfaces. #[must_use] pub fn len(&self) -> usize { self.surfaces.len() } - /// Whether no device has a placement. + /// Whether no surface has a placement. #[must_use] pub fn is_empty(&self) -> bool { self.surfaces.is_empty() @@ -142,21 +190,57 @@ impl DeviceSurfaceMap { /// /// A matrix's LED index order is the device's own row-major order, so its /// start corner says which corner of the output holds the device's top-left -/// LED; a touch follows the same reflection to land on the LED under it. +/// LED. A strip's LED order runs along its surface's x axis from the first +/// LED, so its direction says which way the output lays that axis out. A +/// touch follows the same reflection, or turn, to land on the LED under it. /// Other topologies take the surface frame as the output's local frame. -const fn surface_orientation(topology: &LedTopology) -> (bool, bool) { - match topology { - LedTopology::Matrix { start_corner, .. } => match start_corner { - Corner::TopLeft => (false, false), - Corner::TopRight => (true, false), - Corner::BottomLeft => (false, true), - Corner::BottomRight => (true, true), - }, - LedTopology::Strip { .. } - | LedTopology::Ring { .. } - | LedTopology::ConcentricRings { .. } - | LedTopology::PerimeterLoop { .. } - | LedTopology::Point - | LedTopology::Custom { .. } => (false, false), +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SurfaceFrame { + /// Swap the surface axes before mirroring. + transpose: bool, + mirror_x: bool, + mirror_y: bool, +} + +impl SurfaceFrame { + const IDENTITY: Self = Self { + transpose: false, + mirror_x: false, + mirror_y: false, + }; + + const fn of(topology: &LedTopology) -> Self { + let (transpose, mirror_x, mirror_y) = match topology { + LedTopology::Matrix { start_corner, .. } => match start_corner { + Corner::TopLeft => (false, false, false), + Corner::TopRight => (false, true, false), + Corner::BottomLeft => (false, false, true), + Corner::BottomRight => (false, true, true), + }, + LedTopology::Strip { direction, .. } => match direction { + StripDirection::LeftToRight => (false, false, false), + StripDirection::RightToLeft => (false, true, false), + StripDirection::TopToBottom => (true, false, false), + StripDirection::BottomToTop => (true, false, true), + }, + LedTopology::Ring { .. } + | LedTopology::ConcentricRings { .. } + | LedTopology::PerimeterLoop { .. } + | LedTopology::Point + | LedTopology::Custom { .. } => return Self::IDENTITY, + }; + Self { + transpose, + mirror_x, + mirror_y, + } + } + + fn to_local(self, x: f32, y: f32) -> NormalizedPosition { + let (u, v) = if self.transpose { (y, x) } else { (x, y) }; + NormalizedPosition::new( + if self.mirror_x { 1.0 - u } else { u }, + if self.mirror_y { 1.0 - v } else { v }, + ) } } diff --git a/crates/hypercolor-core/tests/device_surface_tests.rs b/crates/hypercolor-core/tests/device_surface_tests.rs index e866ac64c..23ffec9c1 100644 --- a/crates/hypercolor-core/tests/device_surface_tests.rs +++ b/crates/hypercolor-core/tests/device_surface_tests.rs @@ -2,7 +2,9 @@ //! //! A touch at a device-surface position must land on the canvas point that //! the LED under the finger samples, through the same position, size, -//! rotation, scale, start corner, and edge behavior the sampler uses. +//! rotation, scale, start corner, strip direction, and edge behavior the +//! sampler uses, and on the segment the touch names when a device has +//! several. use std::f32::consts::FRAC_PI_2; @@ -11,6 +13,7 @@ use hypercolor_core::spatial::{DISPLAY_ZONE_NAME, DeviceSurfaceMap, SpatialEngin use hypercolor_types::device::DeviceId; use hypercolor_types::spatial::{ Corner, EdgeBehavior, LedTopology, NormalizedPosition, Output, SamplingMode, SpatialLayout, + StripDirection, }; const EPSILON: f32 = 1.0e-5; @@ -333,3 +336,153 @@ fn backend_manager_reports_layout_bindings_and_their_generation() { assert!(manager.routing_mapping_generation() > mapped); assert_eq!(manager.layout_device_bindings().count(), 0); } + +fn strip(count: u32, direction: StripDirection) -> LedTopology { + LedTopology::Strip { count, direction } +} + +fn segment_output(id: &str, segment: &str, topology: LedTopology) -> Output { + Output { + zone_name: Some(segment.to_owned()), + ..output(id, "blocks:lightpad", topology) + } +} + +#[test] +fn strip_touches_run_along_the_strip_in_every_direction() { + for direction in [ + StripDirection::LeftToRight, + StripDirection::RightToLeft, + StripDirection::TopToBottom, + StripDirection::BottomToTop, + ] { + let device = DeviceId::new(); + let output = placed((0.3, 0.6), (0.1, 0.5), 0.3, strip(31, direction)); + let layout = layout(vec![output]); + let engine = SpatialEngine::new(layout.clone()); + let plan = engine.sampling_plan(); + let sampled = &plan[0].sample_positions; + let surfaces = DeviceSurfaceMap::from_layout(&layout, single_device(device)); + + for (index, led) in sampled.iter().enumerate() { + // Along the strip from its first LED, on its center line. + let along = f32::from(u16::try_from(index).expect("small strip")) / 30.0; + let touch = surfaces + .canvas_position(device, along, 0.5) + .expect("placed strip should map"); + assert_close(touch, *led); + } + } +} + +#[test] +fn named_segments_of_one_device_resolve_through_their_own_outputs() { + let device = DeviceId::new(); + let pads = Output { + position: NormalizedPosition::new(0.25, 0.5), + size: NormalizedPosition::new(0.5, 1.0), + ..segment_output("pads", "Pads", matrix(8, 8, Corner::BottomLeft)) + }; + let touch_strip = Output { + position: NormalizedPosition::new(0.9, 0.5), + size: NormalizedPosition::new(0.1, 1.0), + ..segment_output( + "strip", + "Touch Strip", + strip(31, StripDirection::BottomToTop), + ) + }; + let layout = layout(vec![pads, touch_strip]); + let surfaces = DeviceSurfaceMap::from_layout(&layout, single_device(device)); + + assert_eq!(surfaces.len(), 2); + assert!(surfaces.contains(device)); + // The first pad row sits at the bottom of a bottom-left matrix. + assert_close( + surfaces + .segment_canvas_position(device, Some("Pads"), 0.0, 0.0) + .expect("pads are placed"), + NormalizedPosition::new(0.0, 1.0), + ); + // The strip's first LED sits at its bottom. + assert_close( + surfaces + .segment_canvas_position(device, Some("Touch Strip"), 0.0, 0.5) + .expect("strip is placed"), + NormalizedPosition::new(0.9, 1.0), + ); + assert_close( + surfaces + .segment_canvas_position(device, Some("Touch Strip"), 1.0, 0.5) + .expect("strip is placed"), + NormalizedPosition::new(0.9, 0.0), + ); + // A touch that names no segment cannot choose between them. + assert_eq!(surfaces.canvas_position(device, 0.5, 0.5), None); + // Nor can one that names a segment the layout does not place. + assert_eq!( + surfaces.segment_canvas_position(device, Some("Display"), 0.5, 0.5), + None + ); +} + +#[test] +fn a_segment_placed_twice_is_ambiguous_and_unplaced() { + let device = DeviceId::new(); + let layout = layout(vec![ + segment_output("left", "Pads", matrix(8, 8, Corner::TopLeft)), + segment_output("right", "Pads", matrix(8, 8, Corner::TopLeft)), + segment_output( + "strip", + "Touch Strip", + strip(31, StripDirection::BottomToTop), + ), + ]); + let surfaces = DeviceSurfaceMap::from_layout(&layout, single_device(device)); + + assert_eq!( + surfaces.segment_canvas_position(device, Some("Pads"), 0.5, 0.5), + None + ); + assert!( + surfaces + .segment_canvas_position(device, Some("Touch Strip"), 0.5, 0.5) + .is_some() + ); +} + +#[test] +fn a_named_touch_lands_on_a_sole_output_that_covers_the_whole_device() { + let device = DeviceId::new(); + let whole = layout(vec![output( + "pad", + "blocks:lightpad", + matrix(15, 15, Corner::TopLeft), + )]); + let surfaces = DeviceSurfaceMap::from_layout(&whole, single_device(device)); + assert_close( + surfaces + .segment_canvas_position(device, Some("Pads"), 0.25, 0.75) + .expect("the whole-device output stands for every segment"), + NormalizedPosition::new(0.25, 0.75), + ); + + // A sole output bound to another segment is not where this touch landed. + let other = layout(vec![segment_output( + "strip", + "Touch Strip", + strip(31, StripDirection::BottomToTop), + )]); + let surfaces = DeviceSurfaceMap::from_layout(&other, single_device(device)); + assert_eq!( + surfaces.segment_canvas_position(device, Some("Pads"), 0.5, 0.5), + None + ); + // The segment it names, and a touch naming none, both land on it. + assert!( + surfaces + .segment_canvas_position(device, Some("Touch Strip"), 0.5, 0.5) + .is_some() + ); + assert!(surfaces.canvas_position(device, 0.5, 0.5).is_some()); +} From 95542e8a5d405ebd231a3c3230c412d08d0724ac Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 19:20:34 -0700 Subject: [PATCH 02/13] feat(input): carry the touched segment from driver edge to effect Placing a touch per segment needs the segment to survive the trip from the driver to the effect payload. TouchBegan now carries an optional segment name, the device input fold keeps it with the held contact, and every Touch event that contact produces, its begin, end, and cancel, names it. Held TouchContact snapshots carry it as a shared Arc, so the router still rebuilds held output each frame without allocating, and the LightScript payload resolves canvas points through the segment the touch names. The segment rides as an optional field that serializes only when set, so a Lightpad touch keeps the event shape it always had. Blocks touches name no segment, since every block has one touch surface. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/device/blocks/input.rs | 7 +- .../src/device/blocks/input/tests.rs | 1 + .../src/effect/lightscript/payload.rs | 29 +++-- .../src/effect/servo/renderer/tests.rs | 1 + crates/hypercolor-core/src/input/device.rs | 41 ++++++- crates/hypercolor-core/src/input/routing.rs | 8 +- crates/hypercolor-core/src/input/traits.rs | 7 +- .../tests/alloc_contract_tests.rs | 2 + .../tests/blocks_input_tests.rs | 9 +- .../tests/device_input_tests.rs | 84 ++++++++++++- .../lightscript_interaction_payload_tests.rs | 115 ++++++++++++++++++ crates/hypercolor-types/src/device_input.rs | 9 ++ crates/hypercolor-types/src/event.rs | 5 +- crates/hypercolor-types/tests/event_tests.rs | 24 ++++ 14 files changed, 315 insertions(+), 27 deletions(-) diff --git a/crates/hypercolor-core/src/device/blocks/input.rs b/crates/hypercolor-core/src/device/blocks/input.rs index 855b86145..47ac24338 100644 --- a/crates/hypercolor-core/src/device/blocks/input.rs +++ b/crates/hypercolor-core/src/device/blocks/input.rs @@ -293,7 +293,12 @@ fn touch_edge(touch: &BlocksTouch) -> DeviceInputEdge { pressure: touch.z, }; match touch.action { - BlocksTouchAction::Start => DeviceInputEdge::TouchBegan { contact, position }, + // A block has one touch surface, so its touches name no segment. + BlocksTouchAction::Start => DeviceInputEdge::TouchBegan { + contact, + position, + segment: None, + }, BlocksTouchAction::Move => DeviceInputEdge::TouchMoved { contact, position }, BlocksTouchAction::End => DeviceInputEdge::TouchEnded { contact, position }, } diff --git a/crates/hypercolor-core/src/device/blocks/input/tests.rs b/crates/hypercolor-core/src/device/blocks/input/tests.rs index fd3049217..a0fda1386 100644 --- a/crates/hypercolor-core/src/device/blocks/input/tests.rs +++ b/crates/hypercolor-core/src/device/blocks/input/tests.rs @@ -135,6 +135,7 @@ async fn an_aborted_stream_cannot_publish_into_a_reconnected_device() { y: 0.5, pressure: 0.25, }, + segment: None, }, }), "the replacement stream still publishes" diff --git a/crates/hypercolor-core/src/effect/lightscript/payload.rs b/crates/hypercolor-core/src/effect/lightscript/payload.rs index a635d9336..48e1310a8 100644 --- a/crates/hypercolor-core/src/effect/lightscript/payload.rs +++ b/crates/hypercolor-core/src/effect/lightscript/payload.rs @@ -153,10 +153,19 @@ pub(super) struct TouchCanvas<'a> { } impl TouchCanvas<'_> { - /// Canvas point of a device-surface position, or `None` when the device - /// has no single output in the layout this canvas is sampled through. - fn point(self, device_id: DeviceId, x: f32, y: f32) -> Option { - let position = self.surfaces?.canvas_position(device_id, x, y)?; + /// Canvas point of a device-surface position, or `None` when the touched + /// surface has no single output in the layout this canvas is sampled + /// through. `segment` is the device segment the contact landed on. + fn point( + self, + device_id: DeviceId, + segment: Option<&str>, + x: f32, + y: f32, + ) -> Option { + let position = self + .surfaces? + .segment_canvas_position(device_id, segment, x, y)?; // The sampler reads pixel indices with pixel centers on integers; // Canvas 2D puts a pixel's center half a unit further in. let (px, py) = position.to_pixel(self.width, self.height); @@ -266,7 +275,12 @@ impl LightScriptDevicePayload { x: sanitize_norm(touch.x), y: sanitize_norm(touch.y), pressure: sanitize_norm(touch.pressure), - canvas: canvas.point(touch.device_id, touch.x, touch.y), + canvas: canvas.point( + touch.device_id, + touch.segment.as_deref(), + touch.x, + touch.y, + ), }) .collect(), buttons: device @@ -288,7 +302,7 @@ impl LightScriptDevicePayload { /// One contact on a device touch surface, held or at a begin/end edge. /// /// `x` and `y` are device-surface coordinates; `canvas` is `null` when the -/// device has no single output in this canvas's layout. +/// touched surface has no single output in this canvas's layout. #[derive(Debug, Clone, Copy, PartialEq, Serialize)] pub(super) struct LightScriptTouchPayload { pub(super) device: DeviceId, @@ -419,6 +433,7 @@ impl LightScriptInputEventPayload { x_q16_16, y_q16_16, pressure_q16_16, + segment, .. } => { let x = unit_from_q16_16(*x_q16_16); @@ -431,7 +446,7 @@ impl LightScriptInputEventPayload { x, y, pressure: unit_from_q16_16(*pressure_q16_16), - canvas: canvas.point(*device_id, x, y), + canvas: canvas.point(*device_id, segment.as_deref(), x, y), }); } InputEvent::DeviceButton { diff --git a/crates/hypercolor-core/src/effect/servo/renderer/tests.rs b/crates/hypercolor-core/src/effect/servo/renderer/tests.rs index a81740108..b9fff5c72 100644 --- a/crates/hypercolor-core/src/effect/servo/renderer/tests.rs +++ b/crates/hypercolor-core/src/effect/servo/renderer/tests.rs @@ -1621,6 +1621,7 @@ fn queued_frames_map_superseded_touches_with_the_newest_placement() { x_q16_16: 1 << 15, y_q16_16: 1 << 15, pressure_q16_16: 1 << 15, + segment: None, }, at_ms: 5, seq: 1, diff --git a/crates/hypercolor-core/src/input/device.rs b/crates/hypercolor-core/src/input/device.rs index b3a90d90d..682c98d25 100644 --- a/crates/hypercolor-core/src/input/device.rs +++ b/crates/hypercolor-core/src/input/device.rs @@ -63,6 +63,7 @@ pub enum DeviceInputRegistryError { struct HeldTouch { position: TouchPosition, + segment: Option>, announced: bool, } @@ -638,14 +639,23 @@ impl EdgeFold<'_> { fn apply(&mut self, state: &mut DeviceChildState, edge: &DeviceInputEdge) { let announce = state.publishing; match edge { - DeviceInputEdge::TouchBegan { contact, position } => { + DeviceInputEdge::TouchBegan { + contact, + position, + segment, + } => { let Some(position) = sanitize_position(*position, None) else { self.dropped = self.dropped.saturating_add(1); return; }; if let Some(previous) = state.touches.remove(contact) { if previous.announced { - self.touch(*contact, TouchPhase::Ended, previous.position); + self.touch( + *contact, + TouchPhase::Ended, + previous.position, + previous.segment.as_deref(), + ); } } else if state.touches.len() >= MAX_CONTACTS_PER_DEVICE { self.dropped = self.dropped.saturating_add(1); @@ -655,11 +665,12 @@ impl EdgeFold<'_> { *contact, HeldTouch { position, + segment: segment.clone(), announced: announce, }, ); if announce { - self.touch(*contact, TouchPhase::Began, position); + self.touch(*contact, TouchPhase::Began, position, segment.as_deref()); } } DeviceInputEdge::TouchMoved { contact, position } => { @@ -681,7 +692,12 @@ impl EdgeFold<'_> { { let position = sanitize_position(*position, Some(held.position)).unwrap_or(held.position); - self.touch(*contact, TouchPhase::Ended, position); + self.touch( + *contact, + TouchPhase::Ended, + position, + held.segment.as_deref(), + ); } } DeviceInputEdge::Button { @@ -721,7 +737,12 @@ impl EdgeFold<'_> { for (contact, held) in &mut state.touches { if held.announced { held.announced = false; - self.touch(*contact, TouchPhase::Cancelled, held.position); + self.touch( + *contact, + TouchPhase::Cancelled, + held.position, + held.segment.as_deref(), + ); } } for (button, announced) in &mut state.buttons { @@ -732,7 +753,13 @@ impl EdgeFold<'_> { } } - fn touch(&mut self, contact: u32, phase: TouchPhase, position: TouchPosition) { + fn touch( + &mut self, + contact: u32, + phase: TouchPhase, + position: TouchPosition, + segment: Option<&str>, + ) { self.changed = true; self.events.push(timed_event( InputEvent::Touch { @@ -743,6 +770,7 @@ impl EdgeFold<'_> { x_q16_16: unit_to_q16_16(position.x), y_q16_16: unit_to_q16_16(position.y), pressure_q16_16: unit_to_q16_16(position.pressure), + segment: segment.map(str::to_owned), }, self.at_ms, )); @@ -778,6 +806,7 @@ fn build_child_snapshot( x: held.position.x, y: held.position.y, pressure: held.position.pressure, + segment: held.segment.clone(), }) .collect(); data.device.buttons = state diff --git a/crates/hypercolor-core/src/input/routing.rs b/crates/hypercolor-core/src/input/routing.rs index 17e58dc74..49a18f640 100644 --- a/crates/hypercolor-core/src/input/routing.rs +++ b/crates/hypercolor-core/src/input/routing.rs @@ -1141,7 +1141,7 @@ impl ConsumerRouteState { // decides which holds this consumer observed being pressed. for touch in &snapshot.device.touches { if state.observed.contains_key(&touch_control(touch)) { - push_touch_reused(&mut interaction.device.touches, &mut touch_count, *touch); + push_touch_reused(&mut interaction.device.touches, &mut touch_count, touch); } } for hold in &snapshot.device.buttons { @@ -1514,7 +1514,7 @@ fn push_unique_reused(output: &mut Vec, length: &mut usize, value: &str) push_reused(output, length, value); } -fn push_touch_reused(output: &mut Vec, length: &mut usize, touch: TouchContact) { +fn push_touch_reused(output: &mut Vec, length: &mut usize, touch: &TouchContact) { if output[..*length] .iter() .any(|existing| existing.device_id == touch.device_id && existing.contact == touch.contact) @@ -1522,9 +1522,9 @@ fn push_touch_reused(output: &mut Vec, length: &mut usize, touch: return; } if let Some(slot) = output.get_mut(*length) { - *slot = touch; + slot.clone_from(touch); } else { - output.push(touch); + output.push(touch.clone()); } *length += 1; } diff --git a/crates/hypercolor-core/src/input/traits.rs b/crates/hypercolor-core/src/input/traits.rs index 07818a761..675218be1 100644 --- a/crates/hypercolor-core/src/input/traits.rs +++ b/crates/hypercolor-core/src/input/traits.rs @@ -431,7 +431,7 @@ impl DeviceInteractionData { .iter() .any(|held| held.device_id == touch.device_id && held.contact == touch.contact) { - self.touches.push(*touch); + self.touches.push(touch.clone()); } } for button in &other.buttons { @@ -449,7 +449,7 @@ impl DeviceInteractionData { } /// One contact held on a device touch surface. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub struct TouchContact { /// Device whose surface is being touched. pub device_id: DeviceId, @@ -461,6 +461,9 @@ pub struct TouchContact { pub y: f32, /// Contact pressure in `[0, 1]`. pub pressure: f32, + /// Device segment the contact landed on, when the device's touch + /// surfaces span several segments. + pub segment: Option>, } /// One control button held on a device. diff --git a/crates/hypercolor-core/tests/alloc_contract_tests.rs b/crates/hypercolor-core/tests/alloc_contract_tests.rs index 7adc03661..40f1bd2af 100644 --- a/crates/hypercolor-core/tests/alloc_contract_tests.rs +++ b/crates/hypercolor-core/tests/alloc_contract_tests.rs @@ -601,6 +601,8 @@ fn steady_router_resolution_control() -> (Stats, Stats) { y: 0.5, pressure: 0.5, }, + // A named segment keeps the held rebuild allocation free too. + segment: Some(Arc::from("Pads")), }, DeviceInputEdge::Button { button: Arc::from("mode"), diff --git a/crates/hypercolor-core/tests/blocks_input_tests.rs b/crates/hypercolor-core/tests/blocks_input_tests.rs index e2ec9821f..01421361a 100644 --- a/crates/hypercolor-core/tests/blocks_input_tests.rs +++ b/crates/hypercolor-core/tests/blocks_input_tests.rs @@ -260,7 +260,8 @@ async fn blocks_input_publishes_touches_and_buttons_for_adopted_devices() -> Tes 1, DeviceInputEdge::TouchBegan { contact: 1, - position: at(0.25) + position: at(0.25), + segment: None, } ), ( @@ -332,7 +333,8 @@ async fn blocks_input_cancels_holds_when_the_stream_or_device_is_lost() -> TestR 4, DeviceInputEdge::TouchBegan { contact: 0, - position: at(0.5) + position: at(0.5), + segment: None, } )], "input after reconnecting flows through the newest lease" @@ -379,7 +381,8 @@ async fn an_oversized_event_line_is_skipped_and_the_stream_stays_framed() -> Tes 2, DeviceInputEdge::TouchBegan { contact: 0, - position: at(0.5) + position: at(0.5), + segment: None, } )], "the line after the oversized one still decodes" diff --git a/crates/hypercolor-core/tests/device_input_tests.rs b/crates/hypercolor-core/tests/device_input_tests.rs index 0c7bc6baa..662605e55 100644 --- a/crates/hypercolor-core/tests/device_input_tests.rs +++ b/crates/hypercolor-core/tests/device_input_tests.rs @@ -22,7 +22,19 @@ fn at(x: f32, y: f32, pressure: f32) -> TouchPosition { } fn began(contact: u32, position: TouchPosition) -> DeviceInputEdge { - DeviceInputEdge::TouchBegan { contact, position } + DeviceInputEdge::TouchBegan { + contact, + position, + segment: None, + } +} + +fn began_on(contact: u32, position: TouchPosition, segment: &str) -> DeviceInputEdge { + DeviceInputEdge::TouchBegan { + contact, + position, + segment: Some(Arc::from(segment)), + } } fn moved(contact: u32, position: TouchPosition) -> DeviceInputEdge { @@ -140,7 +152,7 @@ fn touches_announce_begin_and_end_while_moves_update_holds() { let snapshot = held(&slot); assert!(snapshot.generation > generation); assert_eq!(snapshot.device.touches.len(), 1); - let touch = snapshot.device.touches[0]; + let touch = &snapshot.device.touches[0]; assert_eq!((touch.device_id, touch.contact), (device, 2)); assert_eq!((touch.x, touch.y, touch.pressure), (0.75, 0.25, 0.5)); @@ -176,6 +188,65 @@ fn repeated_begin_ends_the_previous_contact_first() { assert_eq!(held(&slot).device.touches[0].x, 0.9); } +/// Segment named by each touch event, in order. +fn touch_segments(events: &[TimedInputEvent]) -> Vec> { + events + .iter() + .filter_map(|timed| match &timed.event { + InputEvent::Touch { segment, .. } => Some(segment.clone()), + _ => None, + }) + .collect() +} + +#[test] +fn a_contact_keeps_the_segment_it_landed_on_until_it_ends() { + let handle = demanded_handle(); + let attachment = handle.attach(DeviceId::new(), "controller"); + let slot = attachment.slot(); + let mut cursor = 0; + let pads = Some("Pads".to_owned()); + let strip = Some("Touch Strip".to_owned()); + + attachment + .inject(&[ + began_on(0, at(0.0, 0.0, 0.5), "Pads"), + began_on(64, at(0.5, 0.5, 1.0), "Touch Strip"), + moved(64, at(0.75, 0.5, 1.0)), + ]) + .expect("inject"); + assert_eq!( + touch_segments(&events_since(&slot, &mut cursor)), + vec![pads.clone(), strip.clone()] + ); + let touches = held(&slot).device.touches; + assert_eq!(touches[0].segment.as_deref(), Some("Pads")); + assert_eq!(touches[1].segment.as_deref(), Some("Touch Strip")); + assert_eq!(touches[1].x, 0.75); + + // A contact that lands again, on another segment, ends on its old one. + attachment + .inject(&[began(0, at(0.5, 0.5, 0.5)), ended(64, at(0.75, 0.5, 0.0))]) + .expect("inject"); + assert_eq!( + touch_segments(&events_since(&slot, &mut cursor)), + vec![pads, None, strip] + ); + + // Cancels name the segment too, so a consumer can place them. + attachment + .inject(&[began_on(7, at(0.25, 0.25, 0.5), "Pads")]) + .expect("inject"); + events_since(&slot, &mut cursor); + handle.set_demanded(false); + let events = events_since(&slot, &mut cursor); + assert_eq!( + touch_edges(&events), + vec![(0, TouchPhase::Cancelled), (7, TouchPhase::Cancelled)] + ); + assert_eq!(touch_segments(&events), vec![None, Some("Pads".to_owned())]); +} + #[test] fn moves_and_ends_without_a_begin_are_ignored() { let handle = demanded_handle(); @@ -211,7 +282,7 @@ fn positions_clamp_and_non_finite_input_never_strands_a_contact() { moved(1, at(f32::INFINITY, 0.5, 0.5)), ]) .expect("inject"); - let touch = held(&slot).device.touches[0]; + let touch = held(&slot).device.touches[0].clone(); assert_eq!((touch.x, touch.y, touch.pressure), (1.0, 0.0, 1.0)); attachment @@ -457,6 +528,7 @@ fn merged_snapshots_union_device_holds_per_device() { x, y: 0.5, pressure: 0.5, + segment: None, }; let hold = |device_id| DeviceButtonHold { device_id, @@ -593,6 +665,7 @@ fn routed_touches_carry_begin_and_end_edges_and_live_positions() { x: 0.25, y: 0.5, pressure: 0.5, + segment: None, }] ); let began_generation = routed.generation; @@ -616,6 +689,11 @@ fn routed_touches_carry_begin_and_end_edges_and_live_positions() { vec![(3, TouchPhase::Ended)] ); assert!(routed.device.is_empty()); + + pad.inject(&[began_on(9, at(0.5, 0.5, 0.5), "Pads")]) + .expect("inject"); + let routed = route.host(); + assert_eq!(routed.device.touches[0].segment.as_deref(), Some("Pads")); } #[test] diff --git a/crates/hypercolor-core/tests/lightscript_interaction_payload_tests.rs b/crates/hypercolor-core/tests/lightscript_interaction_payload_tests.rs index 95a8b48af..31c5687ae 100644 --- a/crates/hypercolor-core/tests/lightscript_interaction_payload_tests.rs +++ b/crates/hypercolor-core/tests/lightscript_interaction_payload_tests.rs @@ -18,6 +18,7 @@ use hypercolor_types::event::{InputButtonState, InputEvent, TimedInputEvent, Tou use hypercolor_types::sensor::SystemSnapshot; use hypercolor_types::spatial::{ Corner, EdgeBehavior, LedTopology, NormalizedPosition, Output, SamplingMode, SpatialLayout, + StripDirection, }; const CANVAS_WIDTH: u32 = 321; @@ -253,6 +254,7 @@ fn touch(device_id: DeviceId, contact: u32, x: f32, y: f32, pressure: f32) -> To x, y, pressure, + segment: None, } } @@ -277,6 +279,7 @@ fn touch_edge( x_q16_16, y_q16_16, pressure_q16_16: Q / 2, + segment: None, }, at_ms: 200, seq, @@ -398,6 +401,118 @@ fn touches_and_buttons_reach_the_page_with_canvas_positions() { assert!(events[2].get("canvas").is_none()); } +/// A controller with an 8x8 pad matrix on the canvas's left half and a +/// bottom-to-top touch strip at its right edge, both bound to `device`. +fn segmented_surfaces(device: DeviceId) -> DeviceSurfaceMap { + let output = |id: &str, segment: &str, position, size, topology| Output { + id: id.into(), + name: id.into(), + device_id: "usb:controller".into(), + zone_name: Some(segment.to_owned()), + position, + size, + rotation: 0.0, + scale: 1.0, + orientation: None, + topology, + led_positions: Vec::new(), + led_mapping: None, + sampling_mode: None, + edge_behavior: None, + shape: None, + shape_preset: None, + display_order: 0, + attachment: None, + brightness: None, + }; + let layout = SpatialLayout { + id: "rig".into(), + name: "Rig".into(), + description: None, + canvas_width: CANVAS_WIDTH, + canvas_height: CANVAS_HEIGHT, + zones: vec![ + output( + "pads", + "Pads", + NormalizedPosition::new(0.25, 0.5), + NormalizedPosition::new(0.5, 1.0), + LedTopology::Matrix { + width: 8, + height: 8, + serpentine: false, + start_corner: Corner::BottomLeft, + }, + ), + output( + "strip", + "Touch Strip", + NormalizedPosition::new(0.875, 0.5), + NormalizedPosition::new(0.25, 1.0), + LedTopology::Strip { + count: 31, + direction: StripDirection::BottomToTop, + }, + ), + ], + default_sampling_mode: SamplingMode::Bilinear, + default_edge_behavior: EdgeBehavior::Clamp, + version: 1, + }; + DeviceSurfaceMap::from_layout(&layout, |layout_device_id| { + (layout_device_id == "usb:controller").then_some(device) + }) +} + +#[test] +fn touches_on_a_segmented_device_land_on_the_segment_they_name() { + let frame = Frame::new(); + let mut runtime = LightscriptRuntime::new(CANVAS_WIDTH, CANVAS_HEIGHT); + let controller = DeviceId::new(); + let surfaces = segmented_surfaces(controller); + + let mut interaction = InteractionData { + generation: 1, + ..InteractionData::default() + }; + interaction.device.touches = vec![ + TouchContact { + segment: Some(Arc::from("Pads")), + ..touch(controller, 0, 0.0, 0.0, 0.5) + }, + // A touch naming no segment cannot pick between the two outputs. + touch(controller, 1, 0.5, 0.5, 0.5), + ]; + let mut strip_end = touch_edge(controller, 64, TouchPhase::Ended, Q, Q / 2, 4); + if let InputEvent::Touch { segment, .. } = &mut strip_end.event { + *segment = Some("Touch Strip".to_owned()); + } + interaction.batch.events = vec![strip_end]; + + let payload = frame + .payload( + &mut runtime, + &interaction, + FrameDataSources { + device_surfaces: Some(&surfaces), + ..FrameDataSources::default() + }, + ) + .expect("device input emits"); + + // The first pad row sits at the bottom of the bottom-left matrix. + let pad = &payload["interaction"]["device"]["touches"][0]; + assert_number(&pad["canvas"]["nx"], 0.0); + assert_number(&pad["canvas"]["ny"], 1.0); + assert!(payload["interaction"]["device"]["touches"][1]["canvas"].is_null()); + + // The strip's far end is its top LED. + let strip = &payload["interaction"]["events"][0]; + assert_eq!(strip["phase"], "ended"); + assert_number(&strip["canvas"]["nx"], 0.875); + assert_number(&strip["canvas"]["ny"], 0.0); +} + #[test] fn touches_without_any_placement_carry_surface_positions_only() { let frame = Frame::new(); diff --git a/crates/hypercolor-types/src/device_input.rs b/crates/hypercolor-types/src/device_input.rs index 433927e8b..a428eacd9 100644 --- a/crates/hypercolor-types/src/device_input.rs +++ b/crates/hypercolor-types/src/device_input.rs @@ -10,6 +10,11 @@ use std::sync::Arc; use crate::event::InputButtonState; /// A normalized position and pressure on a device touch surface. +/// +/// A surface whose LEDs form a matrix or a strip uses the frame its LED order +/// defines, so a touch lands on the LED under it however a layout places the +/// device: a matrix's first LED is its top left, and a strip runs along `x` +/// from its first LED. #[derive(Debug, Clone, Copy, PartialEq)] pub struct TouchPosition { /// Horizontal position in `[0, 1]`, from the surface's left edge. @@ -30,6 +35,10 @@ pub enum DeviceInputEdge { TouchBegan { contact: u32, position: TouchPosition, + /// Device segment the contact landed on, by the segment's name, for + /// a device whose touch surfaces span several segments. `None` on a + /// device with one surface. The contact keeps it until it ends. + segment: Option>, }, /// A held contact moved or changed pressure. TouchMoved { diff --git a/crates/hypercolor-types/src/event.rs b/crates/hypercolor-types/src/event.rs index 2dfe4cf41..5e8541c72 100644 --- a/crates/hypercolor-types/src/event.rs +++ b/crates/hypercolor-types/src/event.rs @@ -321,7 +321,8 @@ pub enum InputEvent { /// /// Position and pressure are normalized to `[0, 1]` in Q16.16, so /// `1 << 16` is the far edge. Movement between these edges is held - /// interaction state, not an event. + /// interaction state, not an event. `segment` names the device segment + /// the contact landed on, for devices whose touch surfaces span several. Touch { source_id: String, device_id: DeviceId, @@ -330,6 +331,8 @@ pub enum InputEvent { x_q16_16: i64, y_q16_16: i64, pressure_q16_16: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + segment: Option, }, /// A control button on a device changed state. diff --git a/crates/hypercolor-types/tests/event_tests.rs b/crates/hypercolor-types/tests/event_tests.rs index 87f56a598..156298196 100644 --- a/crates/hypercolor-types/tests/event_tests.rs +++ b/crates/hypercolor-types/tests/event_tests.rs @@ -1311,6 +1311,7 @@ fn device_touch_round_trips_with_device_attribution() { x_q16_16: 32_768, y_q16_16: 65_536, pressure_q16_16: 16_384, + segment: None, }; let json = serde_json::to_value(&touch).expect("serialize touch"); @@ -1321,6 +1322,8 @@ fn device_touch_round_trips_with_device_attribution() { assert_eq!(json["x_q16_16"], 32_768); assert_eq!(json["y_q16_16"], 65_536); assert_eq!(json["pressure_q16_16"], 16_384); + // A single-surface device's touch keeps the wire shape it always had. + assert!(json.get("segment").is_none()); let restored: InputEvent = serde_json::from_value(json).expect("deserialize touch"); assert_eq!(restored, touch); @@ -1328,6 +1331,27 @@ fn device_touch_round_trips_with_device_attribution() { assert_eq!(restored.source_id(), format!("device:{device_id}")); } +#[test] +fn device_touch_on_a_named_segment_round_trips_the_segment() { + let device_id = DeviceId::new(); + let touch = InputEvent::Touch { + source_id: format!("device:{device_id}"), + device_id, + contact: 64, + phase: TouchPhase::Ended, + x_q16_16: 0, + y_q16_16: 32_768, + pressure_q16_16: 65_536, + segment: Some("Touch Strip".to_owned()), + }; + + let json = serde_json::to_value(&touch).expect("serialize touch"); + assert_eq!(json["segment"], "Touch Strip"); + + let restored: InputEvent = serde_json::from_value(json).expect("deserialize touch"); + assert_eq!(restored, touch); +} + #[test] fn device_button_round_trips_with_device_attribution() { let device_id = DeviceId::new(); From e359628c24201f9116570374e4a4c2785af277b7 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 19:25:31 -0700 Subject: [PATCH 03/13] feat(usb): let driver-owned USB transports publish device input Driver-owned USB transports had no way to report input from the devices they drive, and the HAL cannot reach the driver API's input sink without breaking the crate graph. The HAL now defines TransportInputSink, a callback a transport hands decoded DeviceInputEdges to, along with the two moments only the transport can see: input was interrupted, so a lift may have been lost, or input ended for good. A binding opts in with publishes_input, and the open request carries the sink. The core USB backend owns the lease. For a binding that publishes input, it passes the transport a bridge at open, attaches the host lease under the device's canonical DeviceId only once connect succeeds, and retires it when shutdown starts, so held touches cancel before the shutdown sequence runs. An interruption supersedes the lease and a close retires it, so the host cancels whatever the device held either way, the same way the Blocks backend handles a lost blocksd stream. Input that arrives before connect finishes is dropped, and a device that never finishes connecting never appears as an input source. The builtin USB module hands the host's device input sink to the backend. No binding sets publishes_input yet. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../hypercolor-core/src/device/usb_backend.rs | 69 +++++++- .../src/device/usb_backend/input.rs | 87 ++++++++++ .../src/device/usb_backend/input/tests.rs | 155 ++++++++++++++++++ .../src/device/usb_backend/tests.rs | 114 +++++++++++++ .../src/transport.rs | 10 +- .../src/drivers/lianli/devices.rs | 1 + .../src/drivers/push2/devices.rs | 1 + crates/hypercolor-hal/src/registry.rs | 17 +- crates/hypercolor-hal/src/transport.rs | 1 + crates/hypercolor-hal/src/transport/input.rs | 32 ++++ 10 files changed, 478 insertions(+), 9 deletions(-) create mode 100644 crates/hypercolor-core/src/device/usb_backend/input.rs create mode 100644 crates/hypercolor-core/src/device/usb_backend/input/tests.rs create mode 100644 crates/hypercolor-hal/src/transport/input.rs diff --git a/crates/hypercolor-core/src/device/usb_backend.rs b/crates/hypercolor-core/src/device/usb_backend.rs index 646564a2b..0c1884ede 100644 --- a/crates/hypercolor-core/src/device/usb_backend.rs +++ b/crates/hypercolor-core/src/device/usb_backend.rs @@ -1,6 +1,7 @@ //! USB backend that bridges HAL protocols to the core `DeviceBackend` trait. mod actor; +mod input; use std::cmp::min; use std::collections::HashMap; @@ -12,8 +13,8 @@ use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; use hypercolor_driver_api::{ BackendInfo, ConnectExecution, DeviceBackend, DeviceDeliveryAck, DeviceDeliveryId, - DeviceDeliveryObserver, DeviceDisplaySink, DeviceFrameSink, DeviceLifecyclePolicy, - DiscoveredDevice, + DeviceDeliveryObserver, DeviceDisplaySink, DeviceFrameSink, DeviceInputSink, + DeviceLifecyclePolicy, DiscoveredDevice, }; use hypercolor_hal::database::{DeviceDescriptor, TransportType}; use hypercolor_hal::protocol::Protocol; @@ -27,6 +28,7 @@ use hypercolor_hal::transport::bulk::UsbBulkTransport; use hypercolor_hal::transport::control::UsbControlTransport; use hypercolor_hal::transport::hid::UsbHidTransport; use hypercolor_hal::transport::hidapi::UsbHidApiTransport; +use hypercolor_hal::transport::input::TransportInputSink; use hypercolor_hal::transport::serial::UsbSerialTransport; use hypercolor_hal::transport::vendor::UsbVendorTransport; use hypercolor_hal::transport::{HidRawOpenRequest, Transport, TransportError}; @@ -38,6 +40,7 @@ use tokio::sync::{RwLock, mpsc, oneshot, watch}; use tokio::task::JoinHandle; use tracing::{debug, info, trace}; +use self::input::UsbDeviceInput; use super::transport_error::{DeviceTransportOperation, map_hal_transport_error}; use crate::attachment::ComponentRegistry; @@ -356,6 +359,8 @@ struct UsbDevice { info_template: DeviceInfo, frame_diagnostics_emitted: bool, non_black_frame_diagnostics_emitted: bool, + /// Input the transport decodes, published while the device is connected. + input: Option>, } impl UsbDevice { @@ -469,6 +474,11 @@ impl UsbDevice { } async fn shutdown(&mut self, device_id: DeviceId) -> std::result::Result<(), DeviceError> { + // Input stops counting the moment the device starts going away, so + // held touches cancel now rather than after the shutdown sequence. + if let Some(input) = &self.input { + input.close(); + } { let _gate = lock_lifecycle_gate(&self.lifecycle_gate); self.active.store(false, Ordering::Release); @@ -583,6 +593,9 @@ impl UsbDevice { impl Drop for UsbDevice { fn drop(&mut self) { + if let Some(input) = &self.input { + input.close(); + } let _gate = lock_lifecycle_gate(&self.lifecycle_gate); self.active.store(false, Ordering::Release); let device_id = self.info_template.id; @@ -863,6 +876,8 @@ pub struct UsbBackend { pending: StdRwLock>, connected: StdRwLock>>, protocol_configs: UsbProtocolConfigStore, + /// Host sink for input from transports that decode it. + device_input: Option>, } struct ConnectedUsbDevice { @@ -889,6 +904,37 @@ impl UsbBackend { } } + /// Publish input from connected devices whose transport decodes it. + /// + /// Each such device publishes under a lease attached with its canonical + /// `DeviceId` once connect succeeds and retired at disconnect. + #[must_use] + pub fn with_device_input(mut self, sink: Arc) -> Self { + self.device_input = Some(sink); + self + } + + /// Input bridge for a device whose transport decodes input, when the + /// host accepts device input. + fn device_input_for( + &self, + pending: &PendingUsbDevice, + device_id: DeviceId, + ) -> Option> { + let TransportType::DriverUsb { binding } = pending.descriptor.transport else { + return None; + }; + if !binding.publishes_input { + return None; + } + let sink = Arc::clone(self.device_input.as_ref()?); + Some(Arc::new(UsbDeviceInput::new( + sink, + device_id, + &pending.info_template.name, + ))) + } + async fn build_protocol( &self, pending: &PendingUsbDevice, @@ -907,6 +953,7 @@ impl UsbBackend { async fn build_transport( pending: &PendingUsbDevice, usb: &nusb::DeviceInfo, + input: Option>, ) -> Result> { match pending.descriptor.transport { TransportType::UsbHidApi { @@ -984,7 +1031,7 @@ impl UsbBackend { report_id, } => Self::open_bulk_transport(pending, usb, interface, report_id).await, TransportType::DriverUsb { binding } => { - Self::open_driver_usb_transport(pending, usb, binding).await + Self::open_driver_usb_transport(pending, usb, binding, input).await } TransportType::UsbSerial { baud_rate } => { Self::open_serial_transport(pending, baud_rate) @@ -1051,6 +1098,7 @@ impl UsbBackend { pending: &PendingUsbDevice, usb: &nusb::DeviceInfo, binding: UsbTransportBinding, + input: Option>, ) -> Result> { let device = Self::open_usb_device(pending, usb).await?; (binding.open)(UsbTransportOpenRequest { @@ -1059,6 +1107,7 @@ impl UsbBackend { product_id: pending.product_id, serial: pending.serial.clone(), usb_path: pending.usb_path.clone(), + input, }) .await .with_context(|| { @@ -1281,7 +1330,12 @@ impl DeviceBackend for UsbBackend { })?; let protocol: Arc = Arc::from(self.build_protocol(&pending, *id).await); - let transport: Arc = Arc::from(Self::build_transport(&pending, &usb).await?); + let input = self.device_input_for(&pending, *id); + let transport_input = input + .clone() + .map(|input| input as Arc); + let transport: Arc = + Arc::from(Self::build_transport(&pending, &usb, transport_input).await?); let init_sequence = protocol.init_sequence(); let first_init_packet = init_sequence.first().map_or_else( || "".to_owned(), @@ -1403,6 +1457,7 @@ impl DeviceBackend for UsbBackend { Arc::clone(&last_async_error), ); + let connected_input = input.clone(); let device = UsbDevice { protocol, transport_name, @@ -1419,6 +1474,7 @@ impl DeviceBackend for UsbBackend { info_template: pending.info_template, frame_diagnostics_emitted: false, non_black_frame_diagnostics_emitted: false, + input, }; let frame_sink = device.frame_sink(*id); let display_sink = device @@ -1437,6 +1493,11 @@ impl DeviceBackend for UsbBackend { .write() .unwrap_or_else(PoisonError::into_inner) .insert(*id, Arc::new(connected)); + // Input counts only once the device is fully connected, so a connect + // that fails partway never shows up as an input source. + if let Some(input) = connected_input { + input.activate(); + } Ok(()) } diff --git a/crates/hypercolor-core/src/device/usb_backend/input.rs b/crates/hypercolor-core/src/device/usb_backend/input.rs new file mode 100644 index 000000000..1b5bd7552 --- /dev/null +++ b/crates/hypercolor-core/src/device/usb_backend/input.rs @@ -0,0 +1,87 @@ +//! Input from driver-owned USB transports, published under the host's lease. +//! +//! A transport that decodes device input gets one [`UsbDeviceInput`] when it +//! opens. The backend attaches the host lease only once connect succeeds, so +//! a device that never finishes connecting never shows up as an input +//! source, and retires it at disconnect. Between those points the transport +//! decides when held input can no longer be trusted: an interruption +//! supersedes the lease, and closing input retires it, so the host cancels +//! every hold whose lift will never be seen. + +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use hypercolor_driver_api::{DeviceInputPublisher, DeviceInputSink}; +use hypercolor_hal::transport::input::TransportInputSink; +use hypercolor_types::device::DeviceId; +use hypercolor_types::device_input::DeviceInputEdge; + +/// Bridge from one transport's decoded input to the host's device lease. +pub(super) struct UsbDeviceInput { + sink: Arc, + device_id: DeviceId, + label: Arc, + lease: Mutex, +} + +enum Lease { + /// Connect has not finished, so input is dropped. + Pending, + Live(Box), + /// Input ended or the device disconnected; nothing attaches again. + Closed, +} + +impl UsbDeviceInput { + pub(super) fn new(sink: Arc, device_id: DeviceId, label: &str) -> Self { + Self { + sink, + device_id, + label: Arc::from(label), + lease: Mutex::new(Lease::Pending), + } + } + + /// Start publishing under a fresh lease once the device is connected. + /// + /// Does nothing after [`Self::close`], so a transport whose input + /// already ended cannot bring the lease back. + pub(super) fn activate(&self) { + let mut lease = self.lock(); + if matches!(*lease, Lease::Pending) { + *lease = Lease::Live(self.sink.attach(self.device_id, &self.label)); + } + } + + /// Retire the lease for good, cancelling whatever the device held. + pub(super) fn close(&self) { + let retired = std::mem::replace(&mut *self.lock(), Lease::Closed); + drop(retired); + } + + fn lock(&self) -> MutexGuard<'_, Lease> { + self.lease.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl TransportInputSink for UsbDeviceInput { + fn publish(&self, edges: &[DeviceInputEdge]) { + if let Lease::Live(publisher) = &*self.lock() { + publisher.publish(edges); + } + } + + fn interrupted(&self) { + // Attaching again supersedes the old lease, so the host cancels its + // holds before the replacement publishes anything. + if let Lease::Live(publisher) = &mut *self.lock() { + *publisher = self.sink.attach(self.device_id, &self.label); + } + } + + fn closed(&self) { + self.close(); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/hypercolor-core/src/device/usb_backend/input/tests.rs b/crates/hypercolor-core/src/device/usb_backend/input/tests.rs new file mode 100644 index 000000000..f7d2d1e02 --- /dev/null +++ b/crates/hypercolor-core/src/device/usb_backend/input/tests.rs @@ -0,0 +1,155 @@ +use std::sync::Arc; + +use hypercolor_hal::transport::input::TransportInputSink; +use hypercolor_types::device::DeviceId; +use hypercolor_types::device_input::{DeviceInputEdge, TouchPosition}; +use hypercolor_types::event::{InputEvent, TimedInputEvent, TouchPhase}; + +use super::UsbDeviceInput; +use crate::input::{DeviceInputChildSlot, DeviceInputHandle}; + +fn pad_press(contact: u32) -> DeviceInputEdge { + DeviceInputEdge::TouchBegan { + contact, + position: TouchPosition { + x: 0.5, + y: 0.5, + pressure: 1.0, + }, + segment: Some(Arc::from("Pads")), + } +} + +fn demanded_host() -> DeviceInputHandle { + let host = DeviceInputHandle::new(); + host.set_demanded(true); + host +} + +fn bridge(host: &DeviceInputHandle, device: DeviceId) -> UsbDeviceInput { + UsbDeviceInput::new(Arc::new(host.clone()), device, "Ableton Push 2") +} + +fn child(host: &DeviceInputHandle, device: DeviceId) -> Option { + host.registry().snapshot().child(device).cloned() +} + +fn touch_phases(slot: &DeviceInputChildSlot) -> Vec<(u32, TouchPhase)> { + let mut events: Vec = Vec::new(); + slot.read_events_since(0, &mut events); + events + .iter() + .filter_map(|timed| match &timed.event { + InputEvent::Touch { contact, phase, .. } => Some((*contact, *phase)), + _ => None, + }) + .collect() +} + +#[test] +fn input_before_connect_finishes_is_dropped_and_attaches_nothing() { + let host = demanded_host(); + let device = DeviceId::new(); + let input = bridge(&host, device); + + input.publish(&[pad_press(0)]); + input.interrupted(); + + assert!( + child(&host, device).is_none(), + "a device still connecting is not an input source" + ); +} + +#[test] +fn activation_attaches_under_the_device_id_and_publishes() { + let host = demanded_host(); + let device = DeviceId::new(); + let input = bridge(&host, device); + + input.activate(); + let slot = child(&host, device).expect("a connected device is an input source"); + assert_eq!(slot.label(), "Ableton Push 2"); + input.publish(&[pad_press(3)]); + + assert_eq!(touch_phases(&slot), vec![(3, TouchPhase::Began)]); + // Activating again keeps the same lease. + input.activate(); + assert_eq!( + child(&host, device).map(|slot| slot.publication_id()), + Some(slot.publication_id()) + ); +} + +#[test] +fn an_interruption_cancels_held_touches_and_keeps_input_flowing() { + let host = demanded_host(); + let device = DeviceId::new(); + let input = bridge(&host, device); + input.activate(); + let first = child(&host, device).expect("attached"); + input.publish(&[pad_press(3)]); + + input.interrupted(); + + // A retired child leaves the registry, which is what makes the router + // cancel every hold a consumer observed on it. + assert!(!first.is_active(), "the superseded lease retires"); + assert_eq!(touch_phases(&first), vec![(3, TouchPhase::Began)]); + let second = child(&host, device).expect("a fresh lease replaces it"); + assert_ne!(second.publication_id(), first.publication_id()); + input.publish(&[pad_press(5)]); + assert_eq!(touch_phases(&second), vec![(5, TouchPhase::Began)]); +} + +#[test] +fn closing_retires_the_lease_for_good() { + let host = demanded_host(); + let device = DeviceId::new(); + let input = bridge(&host, device); + input.activate(); + let slot = child(&host, device).expect("attached"); + + input.closed(); + + assert!(!slot.is_active()); + assert!(child(&host, device).is_none()); + // Nothing after the close brings the device back. + input.publish(&[pad_press(0)]); + input.interrupted(); + input.activate(); + input.close(); + assert!(child(&host, device).is_none()); +} + +#[test] +fn input_that_ends_before_connect_finishes_never_attaches() { + let host = demanded_host(); + let device = DeviceId::new(); + let input = bridge(&host, device); + + input.closed(); + input.activate(); + + assert!(child(&host, device).is_none()); +} + +#[test] +fn a_reconnect_supersedes_a_bridge_that_was_never_closed() { + let host = demanded_host(); + let device = DeviceId::new(); + let stale = bridge(&host, device); + stale.activate(); + let stale_slot = child(&host, device).expect("attached"); + + let fresh = bridge(&host, device); + fresh.activate(); + let fresh_slot = child(&host, device).expect("attached"); + assert!(!stale_slot.is_active()); + + // The stale bridge can neither publish into nor retire its successor. + stale.publish(&[pad_press(1)]); + stale.close(); + assert!(fresh_slot.is_active()); + assert!(touch_phases(&fresh_slot).is_empty()); +} diff --git a/crates/hypercolor-core/src/device/usb_backend/tests.rs b/crates/hypercolor-core/src/device/usb_backend/tests.rs index 857aa6dfc..1f8fae077 100644 --- a/crates/hypercolor-core/src/device/usb_backend/tests.rs +++ b/crates/hypercolor-core/src/device/usb_backend/tests.rs @@ -40,6 +40,7 @@ fn background_driver_transport() -> TransportType { connect_execution: TransportConnectExecution::Background, retry_on_connect_timeout: false, }, + publishes_input: false, open: unreachable_test_transport, }, } @@ -728,6 +729,7 @@ async fn brightness_actor_response_preserves_timeout_duration() { info_template: temporary_control_test_device(true, 1), frame_diagnostics_emitted: false, non_black_frame_diagnostics_emitted: false, + input: None, }; let error = device @@ -820,6 +822,7 @@ fn backend_with_stored_actor_error(device_id: DeviceId, error: DeviceError) -> U info_template: info.clone(), frame_diagnostics_emitted: false, non_black_frame_diagnostics_emitted: false, + input: None, }; let frame_sink = device.frame_sink(device_id); let display_sink = Some(device.display_sink(device_id)); @@ -887,6 +890,7 @@ fn backend_with_display_actor( info_template: info.clone(), frame_diagnostics_emitted: false, non_black_frame_diagnostics_emitted: false, + input: None, }; let frame_sink = device.frame_sink(device_id); let display_sink = Some(device.display_sink(device_id)); @@ -1606,6 +1610,7 @@ fn usb_device_with_actor_task( info_template: info, frame_diagnostics_emitted: false, non_black_frame_diagnostics_emitted: false, + input: None, } } @@ -4193,3 +4198,112 @@ async fn push2_midi_stall_never_blocks_frame_publication_or_tears_down_the_actor .expect("actor task should join") .expect("actor should exit cleanly"); } + +fn input_test_protocol() -> Box { + Box::new(FairnessProtocol) +} + +fn driver_usb_descriptor(publishes_input: bool) -> &'static DeviceDescriptor { + Box::leak(Box::new(DeviceDescriptor { + vendor_id: 0x1209, + product_id: 0x0001, + name: "Playable Test Controller", + family: DeviceFamily::new_static("test", "Test"), + transport: TransportType::DriverUsb { + binding: UsbTransportBinding { + id: "test/playable", + kind: UsbTransportKind::Midi, + lifecycle: TransportLifecycleHints::default(), + publishes_input, + open: unreachable_test_transport, + }, + }, + protocol: hypercolor_hal::registry::ProtocolBinding { + id: "test/playable", + build: input_test_protocol, + }, + firmware_predicate: None, + serial_quirk: None, + })) +} + +fn pending_for(descriptor: &'static DeviceDescriptor) -> PendingUsbDevice { + PendingUsbDevice { + vendor_id: descriptor.vendor_id, + product_id: descriptor.product_id, + serial: None, + usb_path: None, + descriptor, + info_template: temporary_control_test_device(true, 1), + } +} + +#[test] +fn only_transports_that_publish_input_get_an_input_bridge() { + let device_id = DeviceId::new(); + let host = crate::input::DeviceInputHandle::new(); + let with_input = UsbBackend::new().with_device_input(Arc::new(host)); + + assert!( + with_input + .device_input_for(&pending_for(driver_usb_descriptor(true)), device_id) + .is_some() + ); + assert!( + with_input + .device_input_for(&pending_for(driver_usb_descriptor(false)), device_id) + .is_none(), + "a transport that reads no input never becomes an input source" + ); + assert!( + UsbBackend::new() + .device_input_for(&pending_for(driver_usb_descriptor(true)), device_id) + .is_none(), + "a host without device input gets no bridge" + ); +} + +#[tokio::test] +async fn disconnect_retires_the_input_lease_before_shutting_down() { + let device_id = DeviceId::new(); + let host = crate::input::DeviceInputHandle::new(); + host.set_demanded(true); + let input = Arc::new(UsbDeviceInput::new( + Arc::new(host.clone()), + device_id, + "Playable Test Controller", + )); + input.activate(); + let (command_tx, mut command_rx) = mpsc::unbounded_channel(); + let lease_seen_at_shutdown = Arc::new(AtomicBool::new(true)); + let registry = host.registry(); + let actor_task = tokio::spawn({ + let lease_seen_at_shutdown = Arc::clone(&lease_seen_at_shutdown); + async move { + if let Some(UsbDeviceCommand::Shutdown { response_tx, .. }) = command_rx.recv().await { + lease_seen_at_shutdown.store( + registry.snapshot().child(device_id).is_some(), + Ordering::Release, + ); + let _ = response_tx.send(Ok(())); + } + } + }); + let mut device = usb_device_with_actor_task(device_id, command_tx, actor_task); + device.input = Some(Arc::clone(&input)); + assert!(host.registry().snapshot().child(device_id).is_some()); + + device + .shutdown(device_id) + .await + .expect("shutdown should complete"); + + assert!( + !lease_seen_at_shutdown.load(Ordering::Acquire), + "held input cancels before the shutdown sequence runs" + ); + assert!(host.registry().snapshot().child(device_id).is_none()); + // A transport callback that fires after disconnect changes nothing. + TransportInputSink::interrupted(input.as_ref()); + assert!(host.registry().snapshot().child(device_id).is_none()); +} diff --git a/crates/hypercolor-driver-builtin/src/transport.rs b/crates/hypercolor-driver-builtin/src/transport.rs index 3197be300..226b5ad23 100644 --- a/crates/hypercolor-driver-builtin/src/transport.rs +++ b/crates/hypercolor-driver-builtin/src/transport.rs @@ -88,12 +88,14 @@ impl DriverModule for UsbTransportDriverModule { impl DeviceBackendFactory for UsbTransportDriverModule { fn build( &self, - _host: &dyn DriverHost, + host: &dyn DriverHost, _config: DriverConfigView<'_>, ) -> Result, DriverError> { - Ok(Arc::new(UsbBackend::with_protocol_config_store( - self.protocol_configs.clone(), - ))) + let mut backend = UsbBackend::with_protocol_config_store(self.protocol_configs.clone()); + if let Some(sink) = host.device_input() { + backend = backend.with_device_input(sink); + } + Ok(Arc::new(backend)) } } diff --git a/crates/hypercolor-hal/src/drivers/lianli/devices.rs b/crates/hypercolor-hal/src/drivers/lianli/devices.rs index 2839f9a25..692616c2d 100644 --- a/crates/hypercolor-hal/src/drivers/lianli/devices.rs +++ b/crates/hypercolor-hal/src/drivers/lianli/devices.rs @@ -316,6 +316,7 @@ static LIANLI_DESCRIPTORS: LazyLock> = LazyLock::new(|| { id: "lianli/wireless", kind: UsbTransportKind::Usb, lifecycle: TransportLifecycleHints::default(), + publishes_input: false, open: open_wireless_controller, }, }, diff --git a/crates/hypercolor-hal/src/drivers/push2/devices.rs b/crates/hypercolor-hal/src/drivers/push2/devices.rs index ccf7461b2..fa5d93baa 100644 --- a/crates/hypercolor-hal/src/drivers/push2/devices.rs +++ b/crates/hypercolor-hal/src/drivers/push2/devices.rs @@ -45,6 +45,7 @@ static PUSH2_DESCRIPTORS: LazyLock> = LazyLock::new(|| { connect_execution: TransportConnectExecution::Background, retry_on_connect_timeout: false, }, + publishes_input: false, open: open_push2_transport, }, }, diff --git a/crates/hypercolor-hal/src/registry.rs b/crates/hypercolor-hal/src/registry.rs index 7b5b31d71..0e2fe8572 100644 --- a/crates/hypercolor-hal/src/registry.rs +++ b/crates/hypercolor-hal/src/registry.rs @@ -4,11 +4,13 @@ use std::borrow::Cow; use std::fmt; use std::future::Future; use std::pin::Pin; +use std::sync::Arc; use std::time::Duration; use hypercolor_types::device::DeviceFamily; use crate::protocol::Protocol; +use crate::transport::input::TransportInputSink; use crate::transport::{Transport, TransportError}; /// Function pointer used to construct a protocol instance. @@ -33,6 +35,10 @@ pub struct UsbTransportOpenRequest { pub serial: Option, /// Optional host USB topology path. pub usb_path: Option, + /// Where to publish the input the transport decodes, when its binding + /// sets [`UsbTransportBinding::publishes_input`] and the host accepts + /// device input. + pub input: Option>, } /// Inventory classification for a driver-owned USB transport. @@ -83,6 +89,11 @@ pub struct UsbTransportBinding { pub kind: UsbTransportKind, /// Connection lifecycle requirements. pub lifecycle: TransportLifecycleHints, + /// Whether the transport decodes device input and publishes it to + /// [`UsbTransportOpenRequest::input`]. Hosts attach input only for + /// transports that do, so a device that cannot be played never shows up + /// as an input source. + pub publishes_input: bool, /// Driver-owned transport factory. pub open: UsbTransportFactory, } @@ -93,13 +104,17 @@ impl fmt::Debug for UsbTransportBinding { .field("id", &self.id) .field("kind", &self.kind) .field("lifecycle", &self.lifecycle) + .field("publishes_input", &self.publishes_input) .finish_non_exhaustive() } } impl PartialEq for UsbTransportBinding { fn eq(&self, other: &Self) -> bool { - self.id == other.id && self.kind == other.kind && self.lifecycle == other.lifecycle + self.id == other.id + && self.kind == other.kind + && self.lifecycle == other.lifecycle + && self.publishes_input == other.publishes_input } } diff --git a/crates/hypercolor-hal/src/transport.rs b/crates/hypercolor-hal/src/transport.rs index cce525662..8cfe4d598 100644 --- a/crates/hypercolor-hal/src/transport.rs +++ b/crates/hypercolor-hal/src/transport.rs @@ -22,6 +22,7 @@ pub mod hid; pub mod hidapi; #[cfg(target_os = "linux")] pub mod hidraw; +pub mod input; pub mod midi; pub mod serial; pub mod smbus; diff --git a/crates/hypercolor-hal/src/transport/input.rs b/crates/hypercolor-hal/src/transport/input.rs new file mode 100644 index 000000000..d6e9260c8 --- /dev/null +++ b/crates/hypercolor-hal/src/transport/input.rs @@ -0,0 +1,32 @@ +//! Input a transport decodes from the device it drives. +//! +//! Some hardware a driver lights can also be played. A transport that reads +//! that input decodes it into [`DeviceInputEdge`]s and hands them to the +//! [`TransportInputSink`] its opener supplied. The sink belongs to the host, +//! which attributes the edges to the device and decides when they count; the +//! transport reports what it saw, and when its view of the device's input +//! broke or ended, so the host can cancel holds whose lift it will never see. + +use hypercolor_types::device_input::DeviceInputEdge; + +/// Host-side receiver for the input one transport decodes. +/// +/// Calls arrive on whatever thread the transport reads input on, often a +/// native callback thread, so implementations return promptly and never +/// block on device I/O. +pub trait TransportInputSink: Send + Sync { + /// Publish edges in the order the device reported them. + fn publish(&self, edges: &[DeviceInputEdge]); + + /// Some input may have been lost, so a lift may have gone unseen. + /// + /// The host cancels whatever the device held. Input keeps flowing, and + /// edges published afterward start from nothing held. + fn interrupted(&self); + + /// The transport stopped reading input for good. + /// + /// The host cancels whatever the device held and ignores anything + /// published afterward. + fn closed(&self); +} From 2733b432c6399fae0d0fb7101b4b4d13aed14dbf Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 19:34:46 -0700 Subject: [PATCH 04/13] feat(push2): publish pad, touch strip, and button input The Push 2 transport already held a midir input connection on the User port, but only for sysex replies; pad notes were discarded. Its callback now hands each message to the reply ingress first, so acknowledgement latency is unchanged, then to a relay that decodes it and publishes the edges through the transport input seam. Reading input sends no MIDI and changes no device mode, so it adds nothing to the output path that has wedged the firmware before, and the relay takes no lock the output or display paths share. The decoder follows Ableton's MIDI and Display Interface Manual. Pads (notes 36 to 99) begin on Note On with velocity as pressure, end on Note Off or zero velocity, and move on poly or channel pressure. Pads report in the matrix's LED frame and the strip along its LEDs from the bottom, so a press lands on the LED under it through the layout's start corner or strip direction; touches name the Pads or Touch Strip segment. The strip's autoreturn sends center just before every lift, so center only moves a touch once a later value confirms the finger, and a lift ends at the last real position. Buttons use stable names from the driver spec's tables, and relative encoder turns and encoder touches are ignored. A message cut short or a Set MIDI Mode reply announcing Live mode reports an interruption, and the relay's drop with the input connection reports that input ended, so the host cancels what the Push 2 held. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/push2_touch_surface_tests.rs | 300 +++++++++++ .../src/drivers/push2/devices.rs | 2 +- .../hypercolor-hal/src/drivers/push2/input.rs | 56 ++ .../hypercolor-hal/src/drivers/push2/mod.rs | 7 +- .../src/drivers/push2/protocol/input.rs | 470 ++++++++++++++++ .../src/drivers/push2/protocol/mod.rs | 14 +- .../src/drivers/push2/transport.rs | 20 +- .../hypercolor-hal/tests/push2_input_tests.rs | 507 ++++++++++++++++++ 8 files changed, 1370 insertions(+), 6 deletions(-) create mode 100644 crates/hypercolor-core/tests/push2_touch_surface_tests.rs create mode 100644 crates/hypercolor-hal/src/drivers/push2/input.rs create mode 100644 crates/hypercolor-hal/src/drivers/push2/protocol/input.rs create mode 100644 crates/hypercolor-hal/tests/push2_input_tests.rs diff --git a/crates/hypercolor-core/tests/push2_touch_surface_tests.rs b/crates/hypercolor-core/tests/push2_touch_surface_tests.rs new file mode 100644 index 000000000..77a027f74 --- /dev/null +++ b/crates/hypercolor-core/tests/push2_touch_surface_tests.rs @@ -0,0 +1,300 @@ +//! Push 2 touches land on the LEDs under them. +//! +//! Each case lights one LED through the real Push 2 frame encoder to learn +//! which pad note or strip LED drives it, presses that control through the +//! real input decoder, and checks the resulting touch maps to the exact +//! canvas point the sampler reads for that LED, through layouts as the UI +//! seeds them and as the daemon's auto layout builds them. + +use hypercolor_core::spatial::{DeviceSurfaceMap, SpatialEngine}; +use hypercolor_hal::drivers::push2::{ + PUSH2_PADS_SEGMENT, PUSH2_TOUCH_STRIP_SEGMENT, Push2InputDecode, Push2InputDecoder, + Push2Protocol, +}; +use hypercolor_hal::protocol::Protocol; +use hypercolor_types::device::{DeviceId, DeviceTopologyHint, SegmentInfo}; +use hypercolor_types::device_input::DeviceInputEdge; +use hypercolor_types::spatial::{ + Corner, EdgeBehavior, LedTopology, NormalizedPosition, Output, SamplingMode, SpatialLayout, + StripDirection, +}; + +const EPSILON: f32 = 1.0e-5; +const LAYOUT_DEVICE: &str = "usb:2982:1967:test"; +const PUSH2_FRAME_LEDS: usize = 160; +const SET_TOUCH_STRIP_LEDS: u8 = 0x19; +/// Pitch bend at the strip's top: eight significant bits in fourteen. +const STRIP_TOP: u32 = 16_320; + +/// The Push 2's own zone list, by segment name. +fn segment(name: &str) -> (usize, SegmentInfo) { + let mut start = 0; + for info in Push2Protocol::new().zones() { + let count = usize::try_from(info.led_count).expect("LED count fits"); + if info.name == name { + return (start, info); + } + start += count; + } + panic!("the Push 2 has no {name} zone"); +} + +/// Encode a frame with only zone LED `led` of `name` lit, on a fresh +/// protocol so the frame carries exactly that change. +fn frame_lighting(name: &str, led: usize) -> Vec> { + let (start, _) = segment(name); + let mut colors = vec![[0_u8; 3]; PUSH2_FRAME_LEDS]; + colors[start + led] = [255, 255, 255]; + Push2Protocol::new() + .encode_frame(&colors) + .into_iter() + .map(|command| command.data) + .collect() +} + +/// The pad note the frame encoder drives for Pads LED `led`. +fn pad_note(led: usize) -> u8 { + let notes: Vec = frame_lighting(PUSH2_PADS_SEGMENT, led) + .into_iter() + .filter(|message| message.len() == 3 && message[0] == 0x90 && message[2] != 0) + .map(|message| message[1]) + .collect(); + assert_eq!(notes.len(), 1, "lighting one pad sends one pad note"); + notes[0] +} + +/// The physical strip LED (0 at the bottom) the frame encoder drives for +/// Touch Strip LED `led`, read back from the Set Touch Strip LEDs sysex. +fn physical_strip_led(led: usize) -> usize { + let frame = frame_lighting(PUSH2_TOUCH_STRIP_SEGMENT, led); + let sysex = frame + .iter() + .find(|message| message.get(6) == Some(&SET_TOUCH_STRIP_LEDS)) + .expect("lighting a strip LED sends the strip sysex"); + let packed = &sysex[7..sysex.len() - 1]; + let lit: Vec = packed + .iter() + .enumerate() + .flat_map(|(index, byte)| { + [ + (index * 2, byte & 0x07), + (index * 2 + 1, (byte >> 3) & 0x07), + ] + }) + .filter(|(_, level)| *level != 0) + .map(|(physical, _)| physical) + .collect(); + assert_eq!(lit.len(), 1, "lighting one strip LED lights one"); + lit[0] +} + +fn decode(decoder: &mut Push2InputDecoder, bytes: &[u8]) -> Vec { + let mut edges = Vec::new(); + assert_eq!(decoder.decode(bytes, &mut edges), Push2InputDecode::Intact); + edges +} + +/// Surface position and segment of a touch's begin edge. +fn began(edges: &[DeviceInputEdge]) -> (f32, f32, String) { + match edges { + [ + DeviceInputEdge::TouchBegan { + position, segment, .. + }, + ] => ( + position.x, + position.y, + segment + .as_deref() + .expect("Push 2 touches name a segment") + .to_owned(), + ), + other => panic!("expected one touch begin, got {other:?}"), + } +} + +fn output(segment: &str, topology: LedTopology, center: (f32, f32), size: (f32, f32)) -> Output { + Output { + id: segment.to_owned(), + name: segment.to_owned(), + device_id: LAYOUT_DEVICE.to_owned(), + zone_name: Some(segment.to_owned()), + position: NormalizedPosition::new(center.0, center.1), + size: NormalizedPosition::new(size.0, size.1), + rotation: 0.0, + scale: 1.0, + orientation: None, + topology, + led_positions: Vec::new(), + led_mapping: None, + sampling_mode: Some(SamplingMode::Bilinear), + edge_behavior: Some(EdgeBehavior::Clamp), + shape: None, + shape_preset: None, + display_order: 0, + attachment: None, + brightness: None, + } +} + +/// A layout with the Push 2 pads and touch strip side by side, as two +/// outputs of one device; the strip is tilted so its frame is exercised +/// through a rotation too. +fn push2_layout(pad_corner: Corner, strip_direction: StripDirection) -> SpatialLayout { + let (_, pads) = segment(PUSH2_PADS_SEGMENT); + let (_, strip) = segment(PUSH2_TOUCH_STRIP_SEGMENT); + let DeviceTopologyHint::Matrix { rows, cols } = pads.topology else { + panic!("the pads are a matrix"); + }; + assert!(matches!(strip.topology, DeviceTopologyHint::Strip)); + let pads = output( + PUSH2_PADS_SEGMENT, + LedTopology::Matrix { + width: cols, + height: rows, + serpentine: false, + start_corner: pad_corner, + }, + (0.6, 0.55), + (0.5, 0.6), + ); + let mut touch_strip = output( + PUSH2_TOUCH_STRIP_SEGMENT, + LedTopology::Strip { + count: strip.led_count, + direction: strip_direction, + }, + (0.2, 0.55), + (0.05, 0.6), + ); + touch_strip.rotation = 0.2; + SpatialLayout { + id: "push2".into(), + name: "Push 2".into(), + description: None, + canvas_width: 640, + canvas_height: 480, + zones: vec![pads, touch_strip], + default_sampling_mode: SamplingMode::Bilinear, + default_edge_behavior: EdgeBehavior::Clamp, + version: 1, + } +} + +fn assert_close(actual: NormalizedPosition, expected: NormalizedPosition, what: &str) { + assert!( + (actual.x - expected.x).abs() < EPSILON && (actual.y - expected.y).abs() < EPSILON, + "{what}: touch at {actual:?}, LED samples {expected:?}" + ); +} + +/// Sampled canvas points per output, in layout order. +fn sampled(layout: &SpatialLayout) -> Vec> { + SpatialEngine::new(layout.clone()) + .sampling_plan() + .iter() + .map(|zone| zone.sample_positions.clone()) + .collect() +} + +#[test] +fn every_tested_pad_lands_on_its_own_led() { + // Four corners and an interior pad, in Pads LED order. + let pads = [0, 7, 56, 63, 27]; + // As the UI seeds the Push 2 (bottom left, its physical first LED) and + // as auto layout builds any 8x8 matrix (top left). + for corner in [Corner::BottomLeft, Corner::TopLeft] { + let layout = push2_layout(corner, StripDirection::BottomToTop); + let device = DeviceId::new(); + let surfaces = + DeviceSurfaceMap::from_layout(&layout, |id| (id == LAYOUT_DEVICE).then_some(device)); + let leds = sampled(&layout); + + for led in pads { + let note = pad_note(led); + let mut decoder = Push2InputDecoder::new(); + let (x, y, segment) = began(&decode(&mut decoder, &[0x90, note, 100])); + assert_eq!(segment, PUSH2_PADS_SEGMENT); + let touch = surfaces + .segment_canvas_position(device, Some(&segment), x, y) + .expect("the pads are placed"); + assert_close( + touch, + leds[0][led], + &format!("{corner:?} pad LED {led} (note {note})"), + ); + } + } +} + +#[test] +fn pads_land_where_they_sit_when_the_layout_matches_the_hardware() { + let layout = push2_layout(Corner::BottomLeft, StripDirection::BottomToTop); + let device = DeviceId::new(); + let surfaces = + DeviceSurfaceMap::from_layout(&layout, |id| (id == LAYOUT_DEVICE).then_some(device)); + let press = |note| { + let (x, y, segment) = began(&decode(&mut Push2InputDecoder::new(), &[0x90, note, 100])); + surfaces + .segment_canvas_position(device, Some(&segment), x, y) + .expect("the pads are placed") + }; + + // Note 36 is the bottom-left pad and note 99 the top-right one, per the + // interface manual; the pads output spans x 0.35 to 0.85, y 0.25 to 0.85. + assert_close( + press(36), + NormalizedPosition::new(0.35, 0.85), + "bottom-left pad", + ); + assert_close( + press(99), + NormalizedPosition::new(0.85, 0.25), + "top-right pad", + ); +} + +#[test] +fn strip_positions_land_on_the_led_at_that_height() { + // Strip LEDs whose pitch bend value is exact: LED p of 31 sits at + // p / 30 of the strip, and pitch bend runs from 0 at the bottom to the + // top value. + let strip_leds = [0, 10, 20, 30]; + // As the UI seeds it (bottom to top) and as auto layout builds strips + // (left to right), plus the remaining directions. + for direction in [ + StripDirection::BottomToTop, + StripDirection::LeftToRight, + StripDirection::TopToBottom, + StripDirection::RightToLeft, + ] { + let layout = push2_layout(Corner::BottomLeft, direction); + let device = DeviceId::new(); + let surfaces = + DeviceSurfaceMap::from_layout(&layout, |id| (id == LAYOUT_DEVICE).then_some(device)); + let leds = sampled(&layout); + + for led in strip_leds { + let physical = physical_strip_led(led); + let value = u32::try_from(physical).expect("small strip") * STRIP_TOP / 30; + let value = u16::try_from(value).expect("pitch bend fits"); + let mut decoder = Push2InputDecoder::new(); + assert!(decode(&mut decoder, &[0x90, 12, 127]).is_empty()); + let bend = [ + 0xE0, + u8::try_from(value & 0x7F).expect("seven bits"), + u8::try_from(value >> 7).expect("seven bits"), + ]; + let (x, y, segment) = began(&decode(&mut decoder, &bend)); + assert_eq!(segment, PUSH2_TOUCH_STRIP_SEGMENT); + let touch = surfaces + .segment_canvas_position(device, Some(&segment), x, y) + .expect("the strip is placed"); + assert_close( + touch, + leds[1][led], + &format!("{direction:?} strip LED {led} (physical {physical})"), + ); + } + } +} diff --git a/crates/hypercolor-hal/src/drivers/push2/devices.rs b/crates/hypercolor-hal/src/drivers/push2/devices.rs index fa5d93baa..a57a08e4f 100644 --- a/crates/hypercolor-hal/src/drivers/push2/devices.rs +++ b/crates/hypercolor-hal/src/drivers/push2/devices.rs @@ -45,7 +45,7 @@ static PUSH2_DESCRIPTORS: LazyLock> = LazyLock::new(|| { connect_execution: TransportConnectExecution::Background, retry_on_connect_timeout: false, }, - publishes_input: false, + publishes_input: true, open: open_push2_transport, }, }, diff --git a/crates/hypercolor-hal/src/drivers/push2/input.rs b/crates/hypercolor-hal/src/drivers/push2/input.rs new file mode 100644 index 000000000..13e93bd5d --- /dev/null +++ b/crates/hypercolor-hal/src/drivers/push2/input.rs @@ -0,0 +1,56 @@ +//! Relay from the Push 2 MIDI input callback to the host's input sink. + +use std::sync::Arc; + +use hypercolor_types::device_input::DeviceInputEdge; + +use super::protocol::{PUSH2_PAD_COUNT, Push2InputDecode, Push2InputDecoder}; +use crate::transport::input::TransportInputSink; + +/// Decodes each MIDI input message and publishes what it carried. +/// +/// The relay lives inside the native MIDI input callback, so dropping it is +/// the moment input from that connection ends: the sink hears +/// [`TransportInputSink::closed`] whenever the input connection closes, the +/// transport is dropped, or the connection never opened. +pub struct Push2InputRelay { + decoder: Push2InputDecoder, + edges: Vec, + sink: Arc, +} + +impl Push2InputRelay { + /// Relay input to `sink`. + #[must_use] + pub fn new(sink: Arc) -> Self { + Self { + decoder: Push2InputDecoder::new(), + // Channel pressure can move every pad at once, plus the strip. + edges: Vec::with_capacity(PUSH2_PAD_COUNT + 1), + sink, + } + } + + /// Decode one MIDI input message and publish its edges, then report an + /// interruption when input was lost. + /// + /// Runs on the native input thread for every message, sysex replies + /// included, so it only decodes and hands off: no allocation once warm, + /// and no locks shared with the output or display paths. + pub fn receive(&mut self, message: &[u8]) { + self.edges.clear(); + let decoded = self.decoder.decode(message, &mut self.edges); + if !self.edges.is_empty() { + self.sink.publish(&self.edges); + } + if decoded == Push2InputDecode::Lost { + self.sink.interrupted(); + } + } +} + +impl Drop for Push2InputRelay { + fn drop(&mut self) { + self.sink.closed(); + } +} diff --git a/crates/hypercolor-hal/src/drivers/push2/mod.rs b/crates/hypercolor-hal/src/drivers/push2/mod.rs index 88832a436..96040578a 100644 --- a/crates/hypercolor-hal/src/drivers/push2/mod.rs +++ b/crates/hypercolor-hal/src/drivers/push2/mod.rs @@ -1,6 +1,7 @@ //! Ableton Push 2 protocol driver family. pub mod devices; +pub mod input; pub mod protocol; pub mod transport; @@ -8,5 +9,9 @@ pub use devices::{ ABLETON_VENDOR_ID, PID_PUSH_2, PUSH2_DISPLAY_ENDPOINT, PUSH2_DISPLAY_INTERFACE, PUSH2_MIDI_INTERFACE, build_push2_protocol, descriptors, }; -pub use protocol::{Push2Clock, Push2Protocol}; +pub use input::Push2InputRelay; +pub use protocol::{ + PUSH2_PADS_SEGMENT, PUSH2_TOUCH_STRIP_SEGMENT, Push2Clock, Push2InputDecode, Push2InputDecoder, + Push2Protocol, +}; pub use transport::Push2Transport; diff --git a/crates/hypercolor-hal/src/drivers/push2/protocol/input.rs b/crates/hypercolor-hal/src/drivers/push2/protocol/input.rs new file mode 100644 index 000000000..0e3fb6fcc --- /dev/null +++ b/crates/hypercolor-hal/src/drivers/push2/protocol/input.rs @@ -0,0 +1,470 @@ +//! Push 2 MIDI input: pads, touch strip, and buttons. +//! +//! In User mode the Push 2 sends what its controls do on the User port, all +//! on MIDI channel 1 (status low nibble 0), per Ableton's Push 2 MIDI and +//! Display Interface Manual: +//! +//! - Pads are notes 36 (bottom left) to 99 (top right). A press is Note On +//! with its velocity; a release is Note Off. Aftertouch arrives as channel +//! pressure by default, or as polyphonic key pressure per pad. +//! - The touch strip touch is Note On 12 with velocity 127, released with +//! velocity 0. Its position is a pitch bend from 0 at the bottom to 16320 +//! at the top (eight significant bits), the mode the driver configures. +//! - Buttons are control changes, 127 pressed and 0 released. Encoders are +//! control changes too, but relative, so they are not buttons; their +//! capacitive touches are notes 0 to 10 and are skipped. +//! +//! Touch positions use each segment's LED frame, so a press lands on the LED +//! under it: pad `(column, row)` counts rows from the pad matrix's first LED +//! row, notes 36 to 43, which is the physical bottom row, and the strip runs +//! along `x` from its first LED, also at the bottom. + +use std::sync::Arc; + +use hypercolor_types::device_input::{DeviceInputEdge, TouchPosition}; +use hypercolor_types::event::InputButtonState; + +use super::{ + PAD_NOTE_MAP, PUSH2_CMD_SET_MIDI_MODE, PUSH2_MANUFACTURER_PREFIX, PUSH2_MIDI_MODE_LIVE, + PUSH2_PAD_COUNT, PUSH2_PADS_SEGMENT, PUSH2_TOUCH_STRIP_SEGMENT, +}; + +const NOTE_OFF: u8 = 0x80; +const NOTE_ON: u8 = 0x90; +const POLY_PRESSURE: u8 = 0xA0; +const CONTROL_CHANGE: u8 = 0xB0; +const CHANNEL_PRESSURE: u8 = 0xD0; +const PITCH_BEND: u8 = 0xE0; +const SYSEX_START: u8 = 0xF0; +const SYSEX_END: u8 = 0xF7; +const REALTIME_FIRST: u8 = 0xF8; + +const TOUCH_STRIP_NOTE: u8 = 12; +/// Contact for the touch strip; pads use their LED index, 0 to 63. +const TOUCH_STRIP_CONTACT: u32 = 64; +/// The strip's resting value, which autoreturn reports on every lift. +const PITCH_BEND_CENTER: u16 = 8192; +/// The strip's top: eight significant bits shifted into fourteen. +const PITCH_BEND_TOP: u16 = 16_320; +const PAD_GRID: u8 = 8; +const MIDI_DATA_MAX: f32 = 127.0; + +/// Momentary buttons by control change, named after the Push 2 driver spec's +/// button tables, which follow Ableton's MIDI mapping. +const BUTTON_NAMES: [(u8, &str); 65] = [ + (3, "tap_tempo"), + (9, "metronome"), + (20, "select_1"), + (21, "select_2"), + (22, "select_3"), + (23, "select_4"), + (24, "select_5"), + (25, "select_6"), + (26, "select_7"), + (27, "select_8"), + (28, "master"), + (29, "stop_clip"), + (30, "setup"), + (31, "layout"), + (35, "convert"), + (36, "scene_8"), + (37, "scene_7"), + (38, "scene_6"), + (39, "scene_5"), + (40, "scene_4"), + (41, "scene_3"), + (42, "scene_2"), + (43, "scene_1"), + (44, "arrow_left"), + (45, "arrow_right"), + (46, "arrow_up"), + (47, "arrow_down"), + (48, "select"), + (49, "shift"), + (50, "note"), + (51, "session"), + (52, "add_device"), + (53, "add_track"), + (54, "octave_down"), + (55, "octave_up"), + (56, "repeat"), + (57, "accent"), + (58, "scale"), + (59, "user"), + (60, "mute"), + (61, "solo"), + (62, "page_left"), + (63, "page_right"), + (85, "play"), + (86, "record"), + (87, "new"), + (88, "duplicate"), + (89, "automate"), + (90, "fixed_length"), + (102, "track_1"), + (103, "track_2"), + (104, "track_3"), + (105, "track_4"), + (106, "track_5"), + (107, "track_6"), + (108, "track_7"), + (109, "track_8"), + (110, "device"), + (111, "browse"), + (112, "mix"), + (113, "clip"), + (116, "quantize"), + (117, "double_loop"), + (118, "delete"), + (119, "undo"), +]; + +/// Outcome of decoding one MIDI input message. +#[must_use] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Push2InputDecode { + /// Everything decoded, or was not input. + Intact, + /// Input was lost: a message could not be decoded, or another host + /// moved the Push 2 out of User mode so its input stopped reaching this + /// port. A lift may have gone unseen. The decoder has forgotten every + /// hold, and the caller cancels the device's holds too. + Lost, +} + +/// Decodes Push 2 MIDI input into device input edges. +/// +/// Holds what the device reported pressed so aftertouch updates only held +/// pads and releases only end what began. +pub struct Push2InputDecoder { + pad_by_note: [Option; 128], + button_by_cc: [Option>; 128], + pads_segment: Arc, + strip_segment: Arc, + held_pads: [bool; PUSH2_PAD_COUNT], + strip: StripState, +} + +#[derive(Debug, Clone, Copy, Default)] +struct StripState { + touching: bool, + /// Whether the current touch has begun. A touch waits for its first + /// position when the strip still reports its resting value. + began: bool, + /// Position of the current touch along the strip. + position: f32, + /// The value the strip last reported, touched or not. + reported: Option, +} + +impl Push2InputDecoder { + /// Build a decoder that holds nothing. + #[must_use] + pub fn new() -> Self { + let mut pad_by_note = [None; 128]; + for (led, note) in (0_u8..).zip(PAD_NOTE_MAP) { + pad_by_note[usize::from(note)] = Some(led); + } + let mut button_by_cc: [Option>; 128] = std::array::from_fn(|_| None); + for (cc, name) in BUTTON_NAMES { + button_by_cc[usize::from(cc)] = Some(Arc::from(name)); + } + Self { + pad_by_note, + button_by_cc, + pads_segment: Arc::from(PUSH2_PADS_SEGMENT), + strip_segment: Arc::from(PUSH2_TOUCH_STRIP_SEGMENT), + held_pads: [false; PUSH2_PAD_COUNT], + strip: StripState::default(), + } + } + + /// Decode the bytes of one MIDI input message, appending edges in the + /// order the device reported them. + /// + /// Native MIDI inputs deliver one whole message per call. Several + /// messages, running status within them, and interleaved realtime bytes + /// decode too. A message cut short, or data with no status before it, + /// ends decoding with [`Push2InputDecode::Lost`]; edges appended before + /// that point stand. + pub fn decode(&mut self, bytes: &[u8], edges: &mut Vec) -> Push2InputDecode { + let mut running = None; + let mut index = 0; + while let Some(&byte) = bytes.get(index) { + if byte >= REALTIME_FIRST { + index += 1; + continue; + } + if byte == SYSEX_START { + let Some(length) = bytes[index..].iter().position(|&end| end == SYSEX_END) else { + // Sysex replies are whole messages; a fragment carries no input. + break; + }; + let message = &bytes[index..=index + length]; + index += length + 1; + running = None; + if leaves_user_mode(message) { + return self.lose(); + } + continue; + } + let status = if byte & 0x80 != 0 { + index += 1; + byte + } else if let Some(status) = running { + status + } else { + return self.lose(); + }; + let mut data = [0_u8; 2]; + let length = data_length(status); + let mut filled = 0; + while filled < length { + let Some(&next) = bytes.get(index) else { + return self.lose(); + }; + index += 1; + if next >= REALTIME_FIRST { + continue; + } + if next & 0x80 != 0 { + return self.lose(); + } + data[filled] = next; + filled += 1; + } + if status < SYSEX_START { + running = Some(status); + self.channel_message(status, data, edges); + } else { + running = None; + } + } + Push2InputDecode::Intact + } + + fn channel_message( + &mut self, + status: u8, + [first, second]: [u8; 2], + edges: &mut Vec, + ) { + // Every Push 2 control reports on the first channel. + if status & 0x0F != 0 { + return; + } + match status { + NOTE_ON if second > 0 => self.press(first, second, edges), + NOTE_ON | NOTE_OFF => self.release(first, edges), + POLY_PRESSURE => { + if let Some(pad) = self.held_pad(first) { + edges.push(self.pad_edge(pad, second, EdgeKind::Moved)); + } + } + CHANNEL_PRESSURE => { + // The Push 2 reports its hardest-pressed pad; every held pad + // takes that pressure. + for (pad, held) in (0_u8..).zip(self.held_pads) { + if held { + edges.push(self.pad_edge(pad, first, EdgeKind::Moved)); + } + } + } + CONTROL_CHANGE => { + if let Some(button) = &self.button_by_cc[usize::from(first)] { + edges.push(DeviceInputEdge::Button { + button: Arc::clone(button), + state: if second > 0 { + InputButtonState::Pressed + } else { + InputButtonState::Released + }, + }); + } + } + PITCH_BEND => self.strip_value((u16::from(second) << 7) | u16::from(first), edges), + _ => {} + } + } + + fn press(&mut self, note: u8, velocity: u8, edges: &mut Vec) { + if note == TOUCH_STRIP_NOTE { + if self.strip.touching { + return; + } + self.strip.touching = true; + self.strip.began = false; + // A value other than the resting one arrived ahead of the touch, + // so it is where the finger landed. + if let Some(value) = self.strip.reported + && value != PITCH_BEND_CENTER + { + self.begin_strip(strip_position(value), edges); + } + return; + } + if let Some(pad) = self.pad(note) { + self.held_pads[usize::from(pad)] = true; + edges.push(self.pad_edge(pad, velocity, EdgeKind::Began)); + } + } + + fn release(&mut self, note: u8, edges: &mut Vec) { + if note == TOUCH_STRIP_NOTE { + if !self.strip.touching { + return; + } + // A touch that never left the resting value lifts where it rests. + if !self.strip.began { + self.begin_strip(strip_position(PITCH_BEND_CENTER), edges); + } + // The autoreturn value reported just before this lift is not + // where the finger left, so the touch ends at its last position. + edges.push(DeviceInputEdge::TouchEnded { + contact: TOUCH_STRIP_CONTACT, + position: strip_touch(self.strip.position), + }); + self.strip.touching = false; + self.strip.began = false; + return; + } + if let Some(pad) = self.held_pad(note) { + self.held_pads[usize::from(pad)] = false; + edges.push(self.pad_edge(pad, 0, EdgeKind::Ended)); + } + } + + fn strip_value(&mut self, value: u16, edges: &mut Vec) { + self.strip.reported = Some(value); + // The resting value is ambiguous while touched: autoreturn sends it + // just before a lift, so it only moves the touch once a later value + // shows the finger is still down. + if !self.strip.touching || value == PITCH_BEND_CENTER { + return; + } + let position = strip_position(value); + if !self.strip.began { + self.begin_strip(position, edges); + } else if position != self.strip.position { + self.strip.position = position; + edges.push(DeviceInputEdge::TouchMoved { + contact: TOUCH_STRIP_CONTACT, + position: strip_touch(position), + }); + } + } + + fn begin_strip(&mut self, position: f32, edges: &mut Vec) { + self.strip.began = true; + self.strip.position = position; + edges.push(DeviceInputEdge::TouchBegan { + contact: TOUCH_STRIP_CONTACT, + position: strip_touch(position), + segment: Some(Arc::clone(&self.strip_segment)), + }); + } + + /// Pad LED index for a note. Data bytes are seven bits, so every note + /// indexes the table. + fn pad(&self, note: u8) -> Option { + self.pad_by_note[usize::from(note)] + } + + fn held_pad(&self, note: u8) -> Option { + self.pad(note) + .filter(|pad| self.held_pads[usize::from(*pad)]) + } + + fn pad_edge(&self, pad: u8, value: u8, kind: EdgeKind) -> DeviceInputEdge { + let contact = u32::from(pad); + let position = TouchPosition { + x: f32::from(pad % PAD_GRID) / f32::from(PAD_GRID - 1), + y: f32::from(pad / PAD_GRID) / f32::from(PAD_GRID - 1), + pressure: f32::from(value) / MIDI_DATA_MAX, + }; + match kind { + EdgeKind::Began => DeviceInputEdge::TouchBegan { + contact, + position, + segment: Some(Arc::clone(&self.pads_segment)), + }, + EdgeKind::Moved => DeviceInputEdge::TouchMoved { contact, position }, + EdgeKind::Ended => DeviceInputEdge::TouchEnded { contact, position }, + } + } + + fn lose(&mut self) -> Push2InputDecode { + self.held_pads = [false; PUSH2_PAD_COUNT]; + self.strip = StripState::default(); + Push2InputDecode::Lost + } +} + +impl Default for Push2InputDecoder { + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone, Copy)] +enum EdgeKind { + Began, + Moved, + Ended, +} + +/// Data bytes that follow a status byte. +const fn data_length(status: u8) -> usize { + match status { + 0x80..=0xBF | 0xE0..=0xEF | 0xF2 => 2, + 0xC0..=0xDF | 0xF1 | 0xF3 => 1, + _ => 0, + } +} + +/// Whether a sysex is the reply to a Set MIDI Mode that selected Live mode. +/// +/// The Push 2 sends that reply to both ports whoever asked, and in Live mode +/// its controls report on the Live port only, so this port stops seeing +/// lifts. +fn leaves_user_mode(message: &[u8]) -> bool { + message.len() == PUSH2_MANUFACTURER_PREFIX.len() + 3 + && message.starts_with(&PUSH2_MANUFACTURER_PREFIX) + && message[PUSH2_MANUFACTURER_PREFIX.len()] == PUSH2_CMD_SET_MIDI_MODE + && message[PUSH2_MANUFACTURER_PREFIX.len() + 1] == PUSH2_MIDI_MODE_LIVE +} + +fn strip_position(value: u16) -> f32 { + (f32::from(value) / f32::from(PITCH_BEND_TOP)).min(1.0) +} + +/// A strip touch at `position` along the strip, on its center line. The +/// strip senses no pressure, so a touch is a full press. +const fn strip_touch(position: f32) -> TouchPosition { + TouchPosition { + x: position, + y: 0.5, + pressure: 1.0, + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::super::{RGB_BUTTON_CC_MAP, WHITE_BUTTON_CC_MAP}; + use super::BUTTON_NAMES; + + #[test] + fn every_button_with_an_led_has_a_name_and_nothing_else_does() { + let lit: BTreeSet = RGB_BUTTON_CC_MAP + .into_iter() + .chain(WHITE_BUTTON_CC_MAP) + .collect(); + let named: BTreeSet = BUTTON_NAMES.iter().map(|(cc, _)| *cc).collect(); + assert_eq!(named.len(), BUTTON_NAMES.len(), "no CC is named twice"); + assert_eq!(named, lit); + let names: BTreeSet<&str> = BUTTON_NAMES.iter().map(|(_, name)| *name).collect(); + assert_eq!(names.len(), BUTTON_NAMES.len(), "no name is used twice"); + } +} diff --git a/crates/hypercolor-hal/src/drivers/push2/protocol/mod.rs b/crates/hypercolor-hal/src/drivers/push2/protocol/mod.rs index dd4ff3329..9139a816e 100644 --- a/crates/hypercolor-hal/src/drivers/push2/protocol/mod.rs +++ b/crates/hypercolor-hal/src/drivers/push2/protocol/mod.rs @@ -5,8 +5,11 @@ use hypercolor_types::device::SegmentInfo; mod diagnostics; mod display; mod flow; +mod input; mod led_palette; +pub use input::{Push2InputDecode, Push2InputDecoder}; + use std::borrow::Cow; use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant}; @@ -27,7 +30,7 @@ const PUSH2_RGB_LED_COUNT: usize = 92; const PUSH2_WHITE_BUTTON_COUNT: usize = 37; const PUSH2_MIDI_LED_COUNT: usize = PUSH2_RGB_LED_COUNT + PUSH2_WHITE_BUTTON_COUNT; const PUSH2_TOTAL_LEDS: usize = PUSH2_MIDI_LED_COUNT + PUSH2_TOUCH_STRIP_LED_COUNT; -const PUSH2_PAD_COUNT: usize = 64; +pub(super) const PUSH2_PAD_COUNT: usize = 64; const PUSH2_TOUCH_STRIP_LED_COUNT: usize = 31; const PUSH2_RGB_BUTTON_COUNT: usize = 28; const PUSH2_PALETTE_SIZE: usize = 128; @@ -73,6 +76,11 @@ const PUSH2_MIDI_MODE_USER: u8 = 0x01; const PUSH2_TOUCH_STRIP_HOST_CONFIG: u8 = 0x6B; const PUSH2_TOUCH_STRIP_DEFAULT_CONFIG: u8 = 0x68; +/// Segment name of the 8x8 pad matrix, as layouts bind it. +pub const PUSH2_PADS_SEGMENT: &str = "Pads"; +/// Segment name of the touch strip, as layouts bind it. +pub const PUSH2_TOUCH_STRIP_SEGMENT: &str = "Touch Strip"; + const PAD_NOTE_MAP: [u8; PUSH2_PAD_COUNT] = [ 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, @@ -435,7 +443,7 @@ impl Protocol for Push2Protocol { fn zones(&self) -> Vec { vec![ SegmentInfo { - name: "Pads".to_owned(), + name: PUSH2_PADS_SEGMENT.to_owned(), led_count: 64, topology: DeviceTopologyHint::Matrix { rows: 8, cols: 8 }, color_format: DeviceColorFormat::Rgb, @@ -477,7 +485,7 @@ impl Protocol for Push2Protocol { layout_hint: None, }, SegmentInfo { - name: "Touch Strip".to_owned(), + name: PUSH2_TOUCH_STRIP_SEGMENT.to_owned(), led_count: 31, topology: DeviceTopologyHint::Strip, color_format: DeviceColorFormat::Rgb, diff --git a/crates/hypercolor-hal/src/drivers/push2/transport.rs b/crates/hypercolor-hal/src/drivers/push2/transport.rs index 2aa729c20..427e9b7fb 100644 --- a/crates/hypercolor-hal/src/drivers/push2/transport.rs +++ b/crates/hypercolor-hal/src/drivers/push2/transport.rs @@ -22,6 +22,7 @@ use tracing::{debug, info, trace, warn}; use crate::protocol::TransferType; use crate::registry::{UsbTransportFuture, UsbTransportOpenRequest}; +use crate::transport::input::TransportInputSink; use crate::transport::midi::{ MidiResponseIngress, MidiResponseToken, MidiWorkerClient, NativeMidiSession, OpenedMidiSession, close_input_before_output, open_native_midi_worker, @@ -31,6 +32,7 @@ use crate::transport::{ }; use super::devices::{PUSH2_DISPLAY_ENDPOINT, PUSH2_DISPLAY_INTERFACE, PUSH2_MIDI_INTERFACE}; +use super::input::Push2InputRelay; const DEFAULT_IO_TIMEOUT: Duration = Duration::from_secs(1); const PUSH2_MIDI_SHORT_PACKET_SPACING: Duration = Duration::from_micros(500); @@ -65,6 +67,7 @@ pub fn open_push2_transport(request: UsbTransportOpenRequest) -> UsbTransportFut PUSH2_MIDI_INTERFACE, PUSH2_DISPLAY_INTERFACE, PUSH2_DISPLAY_ENDPOINT, + request.input, ) .await?; Ok(Box::new(transport) as Box) @@ -488,6 +491,10 @@ impl Push2Transport { /// Open the Push 2 transport, binding the MIDI user port and the display /// bulk endpoint. /// + /// Pad, touch strip, and button input arriving on the MIDI port goes to + /// `input`, when given, alongside the sysex replies the output path + /// waits on. Reading it sends nothing to the device. + /// /// # Errors /// /// Returns [`TransportError`] when the MIDI ports or bulk endpoint cannot @@ -505,6 +512,7 @@ impl Push2Transport { midi_interface: u8, display_interface: u8, display_endpoint: u8, + input: Option>, ) -> Result { let expected_role = match midi_interface { 1 => Push2MidiPortRole::Live, @@ -518,7 +526,7 @@ impl Push2Transport { serial: serial.map(ToOwned::to_owned), usb_path: usb_path.map(ToOwned::to_owned), }; - let midi = open_push2_midi_worker(identity.clone()).await?; + let midi = open_push2_midi_worker(identity.clone(), input).await?; #[cfg(target_os = "linux")] let display_interface_handle = device @@ -780,6 +788,7 @@ impl Transport for Push2Transport { async fn open_push2_midi_worker( identity: Push2MidiIdentity, + input: Option>, ) -> Result { let lease = Push2MidiLease::acquire(identity.clone())?; let worker_context = format!("Push 2 MIDI worker for {}", identity.describe()); @@ -794,6 +803,7 @@ async fn open_push2_midi_worker( let connections = open_push2_midi_connections( open_identity.role, ingress, + input, open_identity.vendor_id, open_identity.product_id, open_identity.serial.as_deref(), @@ -843,6 +853,7 @@ fn send_bulk_locked( fn open_push2_midi_connections( expected_role: Push2MidiPortRole, ingress: MidiResponseIngress, + input: Option>, vendor_id: u16, product_id: u16, serial: Option<&str>, @@ -876,12 +887,19 @@ fn open_push2_midi_connections( let output_name = midi_out .port_name(&output_port) .unwrap_or_else(|_| "".to_owned()); + // The relay lives in the callback, so input ends exactly when this + // connection closes. Replies go to the output path first, so decoding + // input never delays an acknowledgement. + let mut relay = input.map(Push2InputRelay::new); let midi_in = midi_in .connect( &input_port, "hypercolor-push2-sysex", move |_timestamp, message, _state| { ingress.forward(message); + if let Some(relay) = relay.as_mut() { + relay.receive(message); + } }, (), ) diff --git a/crates/hypercolor-hal/tests/push2_input_tests.rs b/crates/hypercolor-hal/tests/push2_input_tests.rs new file mode 100644 index 000000000..df61b14e5 --- /dev/null +++ b/crates/hypercolor-hal/tests/push2_input_tests.rs @@ -0,0 +1,507 @@ +//! Push 2 MIDI input decoding and its relay to a host input sink. +//! +//! Messages follow Ableton's Push 2 MIDI and Display Interface Manual: pads +//! are notes 36 to 99, the touch strip is note 12 plus pitch bend, buttons +//! and encoders are control changes, and aftertouch is channel or poly +//! pressure. + +use std::sync::{Arc, Mutex}; + +use hypercolor_hal::drivers::push2::{ + PUSH2_PADS_SEGMENT, PUSH2_TOUCH_STRIP_SEGMENT, Push2InputDecode, Push2InputDecoder, + Push2InputRelay, descriptors, +}; +use hypercolor_hal::registry::TransportType; +use hypercolor_hal::transport::input::TransportInputSink; +use hypercolor_types::device_input::{DeviceInputEdge, TouchPosition}; +use hypercolor_types::event::InputButtonState; + +const NOTE_ON: u8 = 0x90; +const NOTE_OFF: u8 = 0x80; +const CC: u8 = 0xB0; +const STRIP: u8 = 12; +const STRIP_CONTACT: u32 = 64; +/// Pitch bend data bytes (least significant first) for a 14-bit value. +fn bend(value: u16) -> [u8; 3] { + [ + 0xE0, + u8::try_from(value & 0x7F).expect("seven bits"), + u8::try_from(value >> 7).expect("seven bits"), + ] +} + +fn decode(decoder: &mut Push2InputDecoder, bytes: &[u8]) -> Vec { + let mut edges = Vec::new(); + assert_eq!( + decoder.decode(bytes, &mut edges), + Push2InputDecode::Intact, + "{bytes:02X?} should decode" + ); + edges +} + +fn pad_at(x: f32, y: f32, pressure: f32) -> TouchPosition { + TouchPosition { x, y, pressure } +} + +fn pad_began(contact: u32, position: TouchPosition) -> DeviceInputEdge { + DeviceInputEdge::TouchBegan { + contact, + position, + segment: Some(Arc::from(PUSH2_PADS_SEGMENT)), + } +} + +fn strip_at(along: f32) -> TouchPosition { + TouchPosition { + x: along, + y: 0.5, + pressure: 1.0, + } +} + +fn strip_began(along: f32) -> DeviceInputEdge { + DeviceInputEdge::TouchBegan { + contact: STRIP_CONTACT, + position: strip_at(along), + segment: Some(Arc::from(PUSH2_TOUCH_STRIP_SEGMENT)), + } +} + +fn button(name: &str, state: InputButtonState) -> DeviceInputEdge { + DeviceInputEdge::Button { + button: Arc::from(name), + state, + } +} + +#[test] +fn pads_press_in_their_led_frame_with_velocity_as_pressure() { + let mut decoder = Push2InputDecoder::new(); + + // Bottom left is the matrix's first LED, top right its last. + assert_eq!( + decode(&mut decoder, &[NOTE_ON, 36, 127]), + vec![pad_began(0, pad_at(0.0, 0.0, 1.0))] + ); + assert_eq!( + decode(&mut decoder, &[NOTE_ON, 43, 1]), + vec![pad_began(7, pad_at(1.0, 0.0, 1.0 / 127.0))] + ); + assert_eq!( + decode(&mut decoder, &[NOTE_ON, 92, 64]), + vec![pad_began(56, pad_at(0.0, 1.0, 64.0 / 127.0))] + ); + assert_eq!( + decode(&mut decoder, &[NOTE_ON, 99, 127]), + vec![pad_began(63, pad_at(1.0, 1.0, 1.0))] + ); + // Note 63 is row 3, column 3. + assert_eq!( + decode(&mut decoder, &[NOTE_ON, 63, 127]), + vec![pad_began(27, pad_at(3.0 / 7.0, 3.0 / 7.0, 1.0))] + ); +} + +#[test] +fn note_off_and_zero_velocity_note_on_both_release_a_pad() { + let mut decoder = Push2InputDecoder::new(); + decode(&mut decoder, &[NOTE_ON, 36, 100]); + decode(&mut decoder, &[NOTE_ON, 37, 100]); + + assert_eq!( + decode(&mut decoder, &[NOTE_OFF, 36, 0]), + vec![DeviceInputEdge::TouchEnded { + contact: 0, + position: pad_at(0.0, 0.0, 0.0), + }] + ); + assert_eq!( + decode(&mut decoder, &[NOTE_ON, 37, 0]), + vec![DeviceInputEdge::TouchEnded { + contact: 1, + position: pad_at(1.0 / 7.0, 0.0, 0.0), + }] + ); + // A pad already up, or never seen going down, has nothing to end. + assert!(decode(&mut decoder, &[NOTE_OFF, 36, 0]).is_empty()); + assert!(decode(&mut decoder, &[NOTE_OFF, 50, 64]).is_empty()); +} + +#[test] +fn poly_pressure_moves_one_held_pad_and_channel_pressure_moves_them_all() { + let mut decoder = Push2InputDecoder::new(); + decode(&mut decoder, &[NOTE_ON, 36, 100]); + decode(&mut decoder, &[NOTE_ON, 99, 100]); + + assert_eq!( + decode(&mut decoder, &[0xA0, 99, 127]), + vec![DeviceInputEdge::TouchMoved { + contact: 63, + position: pad_at(1.0, 1.0, 1.0), + }] + ); + assert!( + decode(&mut decoder, &[0xA0, 40, 127]).is_empty(), + "a pad that is not held has no pressure to report" + ); + assert_eq!( + decode(&mut decoder, &[0xD0, 0]), + vec![ + DeviceInputEdge::TouchMoved { + contact: 0, + position: pad_at(0.0, 0.0, 0.0), + }, + DeviceInputEdge::TouchMoved { + contact: 63, + position: pad_at(1.0, 1.0, 0.0), + }, + ] + ); + decode(&mut decoder, &[NOTE_OFF, 36, 0]); + decode(&mut decoder, &[NOTE_OFF, 99, 0]); + assert!(decode(&mut decoder, &[0xD0, 90]).is_empty()); +} + +#[test] +fn a_strip_touch_begins_at_its_first_position_and_ends_where_the_finger_left() { + let mut decoder = Push2InputDecoder::new(); + + // The strip still reports nothing, so the touch waits for a position. + assert!(decode(&mut decoder, &[NOTE_ON, STRIP, 127]).is_empty()); + assert_eq!(decode(&mut decoder, &bend(4_080)), vec![strip_began(0.25)]); + assert_eq!( + decode(&mut decoder, &bend(12_240)), + vec![DeviceInputEdge::TouchMoved { + contact: STRIP_CONTACT, + position: strip_at(0.75), + }] + ); + // Autoreturn reports the center just before the lift. + assert!(decode(&mut decoder, &bend(8_192)).is_empty()); + assert_eq!( + decode(&mut decoder, &[NOTE_ON, STRIP, 0]), + vec![DeviceInputEdge::TouchEnded { + contact: STRIP_CONTACT, + position: strip_at(0.75), + }] + ); + + // The next touch lands with the strip resting at center again, and the + // first real value places it. + assert!(decode(&mut decoder, &[NOTE_ON, STRIP, 127]).is_empty()); + assert_eq!(decode(&mut decoder, &bend(0)), vec![strip_began(0.0)]); + assert_eq!( + decode(&mut decoder, &bend(16_320)), + vec![DeviceInputEdge::TouchMoved { + contact: STRIP_CONTACT, + position: strip_at(1.0), + }] + ); + assert_eq!( + decode(&mut decoder, &[NOTE_OFF, STRIP, 0]), + vec![DeviceInputEdge::TouchEnded { + contact: STRIP_CONTACT, + position: strip_at(1.0), + }] + ); +} + +#[test] +fn a_strip_position_reported_ahead_of_the_touch_places_it_at_once() { + let mut decoder = Push2InputDecoder::new(); + + assert!(decode(&mut decoder, &bend(4_080)).is_empty()); + assert_eq!( + decode(&mut decoder, &[NOTE_ON, STRIP, 127]), + vec![strip_began(0.25)] + ); +} + +#[test] +fn a_strip_touch_that_never_leaves_the_center_lifts_there() { + let mut decoder = Push2InputDecoder::new(); + decode(&mut decoder, &bend(8_192)); + + assert!(decode(&mut decoder, &[NOTE_ON, STRIP, 127]).is_empty()); + assert!(decode(&mut decoder, &bend(8_192)).is_empty()); + let center = 8_192.0 / 16_320.0; + assert_eq!( + decode(&mut decoder, &[NOTE_ON, STRIP, 0]), + vec![ + strip_began(center), + DeviceInputEdge::TouchEnded { + contact: STRIP_CONTACT, + position: strip_at(center), + }, + ] + ); +} + +#[test] +fn a_strip_crossing_its_center_moves_on_to_the_next_value() { + let mut decoder = Push2InputDecoder::new(); + decode(&mut decoder, &[NOTE_ON, STRIP, 127]); + decode(&mut decoder, &bend(4_080)); + + assert!(decode(&mut decoder, &bend(8_192)).is_empty()); + assert_eq!( + decode(&mut decoder, &bend(12_240)), + vec![DeviceInputEdge::TouchMoved { + contact: STRIP_CONTACT, + position: strip_at(0.75), + }] + ); +} + +#[test] +fn buttons_press_and_release_by_name() { + let mut decoder = Push2InputDecoder::new(); + + assert_eq!( + decode(&mut decoder, &[CC, 85, 127]), + vec![button("play", InputButtonState::Pressed)] + ); + assert_eq!( + decode(&mut decoder, &[CC, 85, 0]), + vec![button("play", InputButtonState::Released)] + ); + for (cc, name) in [ + (3, "tap_tempo"), + (20, "select_1"), + (43, "scene_1"), + (36, "scene_8"), + (46, "arrow_up"), + (48, "select"), + (49, "shift"), + (59, "user"), + (86, "record"), + (102, "track_1"), + (109, "track_8"), + (119, "undo"), + ] { + assert_eq!( + decode(&mut decoder, &[CC, cc, 127]), + vec![button(name, InputButtonState::Pressed)], + "CC {cc}" + ); + } +} + +#[test] +fn encoders_mod_wheel_and_encoder_touches_are_not_input_edges() { + let mut decoder = Push2InputDecoder::new(); + + // Relative encoder turns: tempo, swing, the eight track encoders, and + // master, both directions. + for cc in [14, 15, 71, 72, 73, 74, 75, 76, 77, 78, 79] { + assert!(decode(&mut decoder, &[CC, cc, 1]).is_empty(), "CC {cc}"); + assert!(decode(&mut decoder, &[CC, cc, 127]).is_empty(), "CC {cc}"); + } + // The strip's mod wheel mode, which the driver does not configure. + assert!(decode(&mut decoder, &[CC, 1, 64]).is_empty()); + // Encoder capacitive touches. + for note in 0..=10 { + assert!(decode(&mut decoder, &[NOTE_ON, note, 127]).is_empty()); + assert!(decode(&mut decoder, &[NOTE_ON, note, 0]).is_empty()); + } +} + +#[test] +fn messages_on_other_channels_are_ignored() { + let mut decoder = Push2InputDecoder::new(); + + assert!(decode(&mut decoder, &[NOTE_ON | 1, 36, 127]).is_empty()); + assert!(decode(&mut decoder, &[CC | 15, 85, 127]).is_empty()); +} + +#[test] +fn running_status_concatenation_and_realtime_bytes_decode() { + let mut decoder = Push2InputDecoder::new(); + + assert_eq!( + decode( + &mut decoder, + &[0xF8, NOTE_ON, 0xFE, 36, 127, 37, 127, 36, 0] + ), + vec![ + pad_began(0, pad_at(0.0, 0.0, 1.0)), + pad_began(1, pad_at(1.0 / 7.0, 0.0, 1.0)), + DeviceInputEdge::TouchEnded { + contact: 0, + position: pad_at(0.0, 0.0, 0.0), + }, + ] + ); + assert_eq!( + decode(&mut decoder, &[CC, 85, 127, NOTE_OFF, 37, 0]), + vec![ + button("play", InputButtonState::Pressed), + DeviceInputEdge::TouchEnded { + contact: 1, + position: pad_at(1.0 / 7.0, 0.0, 0.0), + }, + ] + ); +} + +#[test] +fn sysex_replies_carry_no_input() { + let mut decoder = Push2InputDecoder::new(); + + // A palette entry reply and the reply confirming User mode. + assert!( + decode( + &mut decoder, + &[ + 0xF0, 0x00, 0x21, 0x1D, 0x01, 0x01, 0x04, 0x05, 0x7F, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xF7, + ], + ) + .is_empty() + ); + assert!( + decode( + &mut decoder, + &[0xF0, 0x00, 0x21, 0x1D, 0x01, 0x01, 0x0A, 0x01, 0xF7] + ) + .is_empty() + ); +} + +#[test] +fn a_message_cut_short_loses_input_and_forgets_every_hold() { + let mut decoder = Push2InputDecoder::new(); + decode(&mut decoder, &[NOTE_ON, 40, 100]); + decode(&mut decoder, &[NOTE_ON, STRIP, 127]); + decode(&mut decoder, &bend(4_080)); + + let mut edges = Vec::new(); + assert_eq!( + decoder.decode(&[NOTE_ON, 36, 100, NOTE_OFF, 40], &mut edges), + Push2InputDecode::Lost + ); + assert_eq!( + edges, + vec![pad_began(0, pad_at(0.0, 0.0, 100.0 / 127.0))], + "edges ahead of the loss stand" + ); + + // The host cancels the holds, so their later lifts end nothing. + assert!(decode(&mut decoder, &[NOTE_OFF, 40, 0]).is_empty()); + assert!(decode(&mut decoder, &[NOTE_OFF, 36, 0]).is_empty()); + assert!(decode(&mut decoder, &[NOTE_ON, STRIP, 0]).is_empty()); +} + +#[test] +fn data_without_a_status_or_a_status_cut_off_by_another_is_lost() { + let mut edges = Vec::new(); + assert_eq!( + Push2InputDecoder::new().decode(&[36, 100], &mut edges), + Push2InputDecode::Lost + ); + assert_eq!( + Push2InputDecoder::new().decode(&[NOTE_ON, 36, NOTE_OFF, 36, 0], &mut edges), + Push2InputDecode::Lost + ); + assert!(edges.is_empty()); +} + +#[test] +fn leaving_user_mode_loses_input() { + let mut decoder = Push2InputDecoder::new(); + decode(&mut decoder, &[NOTE_ON, 36, 100]); + + // Another host switched the Push 2 to Live mode; its pads now report on + // the Live port, so this one will not see the lift. + let mut edges = Vec::new(); + assert_eq!( + decoder.decode( + &[0xF0, 0x00, 0x21, 0x1D, 0x01, 0x01, 0x0A, 0x00, 0xF7], + &mut edges + ), + Push2InputDecode::Lost + ); + assert!(edges.is_empty()); + assert!(decode(&mut decoder, &[NOTE_OFF, 36, 0]).is_empty()); +} + +#[derive(Debug, Clone, PartialEq)] +enum SinkCall { + Publish(Vec), + Interrupted, + Closed, +} + +#[derive(Default)] +struct RecordingSink { + calls: Mutex>, +} + +impl RecordingSink { + fn calls(&self) -> Vec { + self.calls.lock().expect("sink log").clone() + } +} + +impl TransportInputSink for RecordingSink { + fn publish(&self, edges: &[DeviceInputEdge]) { + self.calls + .lock() + .expect("sink log") + .push(SinkCall::Publish(edges.to_vec())); + } + + fn interrupted(&self) { + self.calls + .lock() + .expect("sink log") + .push(SinkCall::Interrupted); + } + + fn closed(&self) { + self.calls.lock().expect("sink log").push(SinkCall::Closed); + } +} + +#[test] +fn the_relay_publishes_decoded_edges_and_reports_loss_after_them() { + let sink = Arc::new(RecordingSink::default()); + let mut relay = Push2InputRelay::new(Arc::clone(&sink) as Arc); + + relay.receive(&[NOTE_ON, 36, 127]); + // A sysex reply decodes to nothing and publishes nothing. + relay.receive(&[0xF0, 0x00, 0x21, 0x1D, 0x01, 0x01, 0x0A, 0x01, 0xF7]); + relay.receive(&[NOTE_OFF, 36, 0, NOTE_ON, 37]); + + assert_eq!( + sink.calls(), + vec![ + SinkCall::Publish(vec![pad_began(0, pad_at(0.0, 0.0, 1.0))]), + SinkCall::Publish(vec![DeviceInputEdge::TouchEnded { + contact: 0, + position: pad_at(0.0, 0.0, 0.0), + }]), + SinkCall::Interrupted, + ] + ); +} + +#[test] +fn dropping_the_relay_closes_input() { + let sink = Arc::new(RecordingSink::default()); + let relay = Push2InputRelay::new(Arc::clone(&sink) as Arc); + + drop(relay); + + assert_eq!(sink.calls(), vec![SinkCall::Closed]); +} + +#[test] +fn the_push2_transport_publishes_input() { + let push2 = &descriptors()[0]; + let TransportType::DriverUsb { binding } = push2.transport else { + panic!("the Push 2 uses a driver-owned USB transport"); + }; + assert!(binding.publishes_input); +} From 8f2a0660a684cb250aa1377d88a81c82d9418763 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 19:34:52 -0700 Subject: [PATCH 05/13] docs(specs): describe Push 2 input and segment-named touches Spec 28 listed input events as future work and spec 82 named Push 2 pads as the next producer. Spec 28 now documents the input path: the MIDI map and where it comes from, the LED frames touches report in, button names, strip autoreturn handling, and what counts as lost input. Spec 82 gains the segment field on touch edges, events, and held contacts, per-segment placement and the strip direction rule, the USB transport input seam and its lease lifecycle, and the new tests and known limits. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/28-ableton-push2-driver.md | 83 ++++++++++++++++++++++- docs/specs/82-device-input.md | 97 +++++++++++++++++++++++---- 2 files changed, 163 insertions(+), 17 deletions(-) diff --git a/docs/specs/28-ableton-push2-driver.md b/docs/specs/28-ableton-push2-driver.md index b26312184..9a0103798 100644 --- a/docs/specs/28-ableton-push2-driver.md +++ b/docs/specs/28-ableton-push2-driver.md @@ -4,7 +4,8 @@ **Status:** Implemented (status corrected 2026-08-25). The 960-pixel display path, palette-indexed pads, and MIDI transport ship in -`crates/hypercolor-hal/src/drivers/push2/`. +`crates/hypercolor-hal/src/drivers/push2/`. Pad, touch strip, and button input +(§11.4) shipped 2026-10-19. **Crate:** `hypercolor-hal` **Module path:** `hypercolor_hal::drivers::push2` **Author:** Nova @@ -1018,9 +1019,72 @@ impl Protocol for Push2Protocol { | Display | ✓ (960×160 RGB565) | | Display brightness | ✓ (0–255) | | Animations | Future (requires MIDI clock) | -| Input events | Future (pad pressure, encoders) | +| Input events | ✓ (pads, touch strip, buttons) | | Keepalive | ✓ (mode/touch-strip reassert) | +### 11.4 Input + +The Push 2 reports its controls on the User port the transport already opens +for sysex replies, so input costs no MIDI output: the driver sends no new +sysex and changes no device mode to read it. The midir input callback hands +every message to the sysex reply ingress first, then to a +`Push2InputRelay` (`drivers/push2/input.rs`) that decodes it with +`Push2InputDecoder` (`protocol/input.rs`) and publishes the edges through the +`TransportInputSink` the transport opener received (Spec 82 §9). The relay +owns no lock the output or display paths take, and allocates nothing once +warm. + +The map follows Ableton's MIDI and Display Interface Manual and its MIDI +mapping figure. Every control reports on channel 1 (status low nibble 0); +other channels are ignored. + +| Message | Decoded as | +| --- | --- | +| Note On 36 to 99, velocity > 0 | Pad `TouchBegan`, pressure = velocity / 127, segment `Pads` | +| Note Off, or Note On velocity 0, on a held pad | Pad `TouchEnded` | +| Poly key pressure on a held pad | That pad's `TouchMoved` with the new pressure | +| Channel pressure | `TouchMoved` for every held pad with that pressure (the device reports its hardest-pressed pad) | +| Note On 12, velocity 127 / 0 | Touch strip touched / released, contact 64, segment `Touch Strip` | +| Pitch bend (14 bit, 0 bottom to 16320 top) | Touch strip position | +| Control change, value > 0 / 0, on a button CC | `Button` pressed / released | +| Control change 14, 15, 71 to 79 | Ignored: relative encoder turns | +| Note On 0 to 10 | Ignored: encoder capacitive touches | + +Pad contacts are their LED index (`note - 36`). A pad touch reports +`x = column / 7` and `y = row / 7`, with row 0 the first LED row (notes 36 to +43, the physical bottom row), so a press projects onto the LED under it +through the layout's matrix start corner. The strip reports `x` along the +strip from its first LED at the bottom, on the center line `y = 0.5`, at full +pressure since it senses none. + +Buttons use stable snake_case names from the tables in §5.2 and §5.3: +`track_1` to `track_8` (CC 102 to 109), `select_1` to `select_8` (CC 20 to +27), `scene_1` (CC 43, top) to `scene_8` (CC 36), `play`, `record`, +`tap_tempo`, `metronome`, and the white buttons by their table names +(`master`, `stop_clip`, `arrow_up`, `shift`, `user`, `undo`, and so on). The +name table covers exactly the CCs that have LEDs. + +**Strip autoreturn.** The host touch strip configuration (§7.2) autoreturns +to center, and the manual says the strip sends that center value just before +the release note. The decoder therefore treats the center value as +ambiguous while the strip is touched: it moves the touch only once a later +value shows the finger is still down, and a release ends the touch at its +last real position. A touch begins at its first position, either a value +reported just ahead of the touch note or the first one after it; a touch +that never leaves the center begins and ends there on release. A finger +that stops exactly on center leaves the touch one step short until it moves +again. + +**Lost input.** A message cut short, data with no status before it, or a Set +MIDI Mode reply selecting Live mode (sent to both ports whoever asked, after +which the controls report on the Live port only) makes the relay report an +interruption, so the host cancels everything the Push 2 held. Closing the +input connection, which drops the relay, reports that input ended. midir +delivers whole messages with their status byte on every backend (its ALSA +decoder disables running status), so the decoder accepts running status +within one message buffer but treats a buffer that opens on a data byte as +lost. + --- ## 12. Render Pipeline @@ -1291,6 +1355,16 @@ Located in `crates/hypercolor-hal/tests/push2/`: | `init_sequence` | Init commands are well-formed and in correct order | | `shutdown_sequence` | Shutdown restores device to Live mode | +Input decoding and its relay are covered in +`crates/hypercolor-hal/tests/push2_input_tests.rs`: pads, zero-velocity and +Note Off releases, poly and channel pressure, strip touches with autoreturn, +named buttons, ignored encoders, running status, cut-short messages, and the +Live mode reply. `crates/hypercolor-core/tests/push2_touch_surface_tests.rs` +lights single LEDs through the real frame encoder, presses the matching +control through the real decoder, and checks the touch lands on the LED's +sampled canvas point for the four pad corners, an interior pad, and strip +positions in every strip direction. + ### 14.2 Integration Tests | Test | Validates | @@ -1320,6 +1394,9 @@ via `nusb`) requires direct USB device permissions. - Apply a solid color effect → all 64 pads light up uniformly - Apply a rainbow gradient → pads show smooth color gradient across 8×8 grid - Verify touch strip reflects effect intensity +- With Touch Ripple layered on the Push 2's zone, press each corner pad and + slide the touch strip: ripples start under the finger, and a strip lift + echoes where the finger left, not at the strip's center - Verify display renders effect visualization - Disconnect/reconnect → device recovers cleanly - Launch Ableton Live after shutdown → Live regains full control @@ -1336,7 +1413,7 @@ via `nusb`) requires direct USB device permissions. ## Appendix B: Open Questions -1. **Input events** — Push 2 sends pad velocity, aftertouch, encoder rotation, and button presses. These could feed into Hypercolor's (future) reactive effect system. Not in scope for the initial driver but worth designing the data path. +1. **Input events** (resolved): pads, the touch strip, and buttons publish device input (§11.4). Encoder turns and touches remain unread; they need a relative-control vocabulary the device input pipeline does not have yet. 2. **Conflict with Ableton Live** — if Live is running, it claims the User port. The driver should detect this (failed mode switch) and surface a clear error rather than silently failing. diff --git a/docs/specs/82-device-input.md b/docs/specs/82-device-input.md index ba49c31bf..1a43dda1c 100644 --- a/docs/specs/82-device-input.md +++ b/docs/specs/82-device-input.md @@ -4,7 +4,9 @@ Phase 1 implemented (2026-09-28, shipped in 0.6.3). Phase 2 implemented (2026-10-11, for 0.7.0): effects see touches and device buttons, with -canvas positions. Extends Spec 30 (ROLI Blocks backend), whose event +canvas positions. Phase 3 started (2026-10-19): the Ableton Push 2 publishes +pads, touch strip, and buttons through a USB transport input seam, and +touches name the device segment they land on. Extends Spec 30 (ROLI Blocks backend), whose event sections were never built, and Spec 71 (interactive input pipeline), which shipped differently from its text. Read `crates/hypercolor-core/src/input/` rather than Spec 71's design sections. @@ -58,7 +60,7 @@ publishes the same way. ```rust pub enum DeviceInputEdge { - TouchBegan { contact: u32, position: TouchPosition }, + TouchBegan { contact: u32, position: TouchPosition, segment: Option> }, TouchMoved { contact: u32, position: TouchPosition }, TouchEnded { contact: u32, position: TouchPosition }, Button { button: Arc, state: InputButtonState }, @@ -68,7 +70,13 @@ pub struct TouchPosition { pub x: f32, pub y: f32, pub pressure: f32 } ``` Positions are normalized device-surface coordinates in `[0, 1]`, origin at -the surface's top left as the device reports it. Pressure is `[0, 1]`. +the surface's top left as the device reports it. A surface whose LEDs form a +matrix or a strip uses the frame its LED order defines: a matrix's first LED +is its top left, and a strip runs along `x` from its first LED. Pressure is +`[0, 1]`. `segment` names the device segment a contact landed on, by the +segment name layouts bind (`Output::zone_name`), for a device whose touch +surfaces span several segments; single-surface devices leave it `None`. A +contact keeps its segment until it ends. `event.rs` gains two routed edges. `InputEvent` derives `Eq`, so positions use the same Q16.16 fixed point as `PointerScroll`: @@ -84,6 +92,7 @@ InputEvent::Touch { x_q16_16: i64, y_q16_16: i64, pressure_q16_16: i64, + segment: Option, // omitted from JSON when None } InputEvent::DeviceButton { @@ -197,13 +206,15 @@ pub struct TouchContact { pub x: f32, pub y: f32, pub pressure: f32, + pub segment: Option>, } pub struct DeviceButtonHold { pub device_id: DeviceId, pub button: Arc } ``` The router rebuilds held output every frame without allocating. Touch lookups -build their key from `Copy` fields, button lookups scan instead of building an +build their key from `Copy` fields, a held touch's segment is a shared +`Arc` cloned into reused slots, button lookups scan instead of building an owned key, and the output vectors are truncated and refilled in place. The allocation contract test holds a device touch and button through every measured frame. @@ -293,12 +304,16 @@ as input use, so an effect reading them must declare `input: true`. **Canvas positions.** Every render zone samples its canvas through its own spatial layout, so a touch has one canvas point per zone that places its device and none elsewhere. `DeviceSurfaceMap` -(`hypercolor-core::spatial`) places each device that owns exactly one LED -output in a layout, and maps a surface point through that output's +(`hypercolor-core::spatial`) resolves a touch that names no segment through +the device's only LED output in the layout, and a touch that names a +segment through the one LED output bound to that segment, or through the +device's only output when that output names no segment and so stands for +the whole device. It maps the surface point through that output's position, size, rotation, scale, and edge behavior, with the zone-local transform shared with the sampler. A matrix output also applies its start -corner, because its LED order is the device's own row-major order. The -result is the canvas point the LED under the finger samples. `canvas.nx` +corner, because its LED order is the device's own row-major order, and a +strip output applies its direction, because its surface `x` runs from its +first LED. The result is the canvas point the LED under the finger samples. `canvas.nx` and `canvas.ny` are normalized; `canvas.x` and `canvas.y` are Canvas 2D drawing coordinates of the sampled pixel's center (`n * (size - 1) + 0.5`), so an effect that draws at them lights exactly that point. @@ -312,7 +327,7 @@ the Servo frame queue keeps the newest one with a coalesced batch, so a superseded touch edge is mapped with the same placement as the held contacts beside it. -**Missing placement.** When the touched device has no LED output in the +**Missing placement.** When the touched surface has no LED output in the zone's layout, or has several, `canvas` is `null` and the surface coordinates still arrive. The platform does not guess between outputs or invent a position for an unplaced device; the effect decides what an @@ -341,6 +356,36 @@ contact's landing point, lights a pressure-scaled glow under every held finger, and steps its palette on device button presses. It skips touches whose `canvas` is `null` and deduplicates edges by `seq`. +### 9. USB transport producers and the Push 2 (phase 3) + +**Seam.** `hypercolor-hal` cannot depend on the driver API, so it defines +its own callback, `hypercolor_hal::transport::input::TransportInputSink`, +with `publish(&[DeviceInputEdge])`, `interrupted()` (input may have been +lost, so cancel what the device held), and `closed()` (input ended for +good). A driver-owned USB binding sets `publishes_input`, and +`UsbTransportOpenRequest::input` carries the sink to its opener. + +**Lease.** The builtin USB module hands `host.device_input()` to +`UsbBackend::with_device_input`. For a binding that publishes input, connect +passes the transport a `UsbDeviceInput` bridge at open, attaches the host +lease under the device's canonical `DeviceId` only once connect succeeds, +and retires it as shutdown begins, before the shutdown sequence runs. +`interrupted()` supersedes the lease and `closed()` retires it, so the host +cancels every hold either way, as the Blocks backend does for a lost +blocksd stream. Input before connect finishes is dropped, and a device that +never finishes connecting never becomes an input source. After a close, +nothing brings the lease back; a reconnect builds a new bridge. + +**Push 2.** Spec 28 §11.4 has the MIDI map. The transport's existing User +port input callback forwards each message to the sysex reply ingress, then +to a relay that decodes it and publishes the edges. Reading input sends no +MIDI and changes no device mode. Pads are contacts 0 to 63 on segment +`Pads`, in the matrix's LED frame; the touch strip is contact 64 on segment +`Touch Strip`, along the strip from its bottom LED; buttons use stable +snake_case names. The relay lives in the native callback, so dropping the +input connection reports `closed()`. A message cut short, or the Set MIDI +Mode reply announcing Live mode, reports `interrupted()`. + ## Phasing 1. **Pipeline (0.6.3).** Sections 1 through 7. Touches and buttons reach @@ -350,7 +395,9 @@ whose `canvas` is `null` and deduplicates edges by `seq`. bootstrap, and SDK types expose touches and device buttons. Touches gain canvas coordinates by resolving the device's output through each render zone's layout. The Touch Ripple effect proves the path on a Lightpad. -3. **More producers.** Push 2 pads; a generic MIDI source. +3. **More producers.** Push 2 pads, touch strip, and buttons (section 9, + started 2026-10-19), with segment-named touches so multi-output devices + place per segment; a generic MIDI source. ## Testing @@ -376,9 +423,22 @@ whose `canvas` is `null` and deduplicates edges by `seq`. checks it publishes nothing into the new lease. - Surfaces (`device_surface_tests.rs`): placement through position, size, rotation, scale, and edge behavior; every LED's sampled point equals the - touch over it, for each matrix start corner; unbound, ambiguous, and - display-only outputs place nothing; non-finite points; backend bindings - and their generation. + touch over it, for each matrix start corner and each strip direction; + named segments resolve through their own outputs, a segment placed twice + is ambiguous, and a named touch falls back only to a sole whole-device + output; unbound, ambiguous, and display-only outputs place nothing; + non-finite points; backend bindings and their generation. +- Segments: the registry keeps a contact's segment through begin, end, a + re-begin, and cancel, and routes it into held output; the payload places + segment-named touches per segment; `InputEvent::Touch` omits `segment` + when unset. +- USB seam: the bridge drops input before connect, attaches on activation, + supersedes on interruption, retires on close and stays retired, and never + disturbs a successor; only bindings that publish input get a bridge; a + disconnect retires the lease before the shutdown sequence runs. +- Push 2 (`push2_input_tests.rs`, `push2_touch_surface_tests.rs`): the MIDI + decode and relay (spec 28 §14.1), and touch-to-LED agreement through the + real frame encoder and decoder. - Payload (`lightscript_interaction_payload_tests.rs`): touches and buttons serialize with surface and canvas positions, an unplaced device carries `canvas: null`, a held touch re-sends when its placement or the canvas @@ -408,7 +468,16 @@ whose `canvas` is `null` and deduplicates edges by `seq`. the zone through that transform, and the transform is not inverted, so touches on such a layer land offset. - An attachment's LED remap (`led_mapping`) is not inverted, and outputs - other than matrices take the surface frame as the output's local frame. + other than matrices and strips take the surface frame as the output's + local frame. Strip outputs follow their direction since phase 3; before, + only left-to-right strips agreed with their LEDs. +- Push 2 channel pressure carries one value for the whole device, so every + held pad takes the hardest-pressed pad's pressure. The driver does not + switch the device to poly aftertouch, which would need a sysex the driver + avoids sending. +- A Push 2 touch strip finger that stops exactly on the strip's center + leaves the touch one step short of it until it moves, because autoreturn + reports the same value just before every lift. ## Open questions From f00ed25f533fb50269e6550a9e84812b5d27dc0e Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 19:52:21 -0700 Subject: [PATCH 06/13] fix(input): hold every Push 2 contact a device can report at once The device input fold capped held contacts at 32 per device, sized for touch hardware whose contact indices fit in five bits. The Push 2 can hold 65 at once, its 64 pads plus the touch strip, so a palm across the grid dropped every later press, the strip included. The cap now sits at 128, the range of seven-bit note numbers MIDI controllers count contacts by, so it bounds a misbehaving driver without dropping real presses. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/hypercolor-core/src/input/device.rs | 7 +++++-- crates/hypercolor-core/tests/device_input_tests.rs | 13 +++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/hypercolor-core/src/input/device.rs b/crates/hypercolor-core/src/input/device.rs index 682c98d25..348452e66 100644 --- a/crates/hypercolor-core/src/input/device.rs +++ b/crates/hypercolor-core/src/input/device.rs @@ -37,8 +37,11 @@ use crate::input::{ }; const DEFAULT_EVENT_LIMIT: usize = 256; -/// Protocol-level contact indices on current touch hardware fit in five bits. -const MAX_CONTACTS_PER_DEVICE: usize = 32; +/// Held contacts one device may report. MIDI controllers number contacts by +/// seven-bit note, and the Push 2 holds up to 65 at once (64 pads and its +/// touch strip), so the bound only stops a misbehaving driver from growing +/// held state without limit. +const MAX_CONTACTS_PER_DEVICE: usize = 128; const MAX_HELD_BUTTONS_PER_DEVICE: usize = 16; /// Opaque lifetime identity for one device child publication. diff --git a/crates/hypercolor-core/tests/device_input_tests.rs b/crates/hypercolor-core/tests/device_input_tests.rs index 662605e55..226ba1ee5 100644 --- a/crates/hypercolor-core/tests/device_input_tests.rs +++ b/crates/hypercolor-core/tests/device_input_tests.rs @@ -306,13 +306,22 @@ fn contacts_beyond_the_per_device_cap_are_dropped() { let attachment = handle.attach(DeviceId::new(), "pad"); let slot = attachment.slot(); - let edges: Vec<_> = (0..33) + // Every Push 2 pad and its touch strip, held at once, all fit. + let push2: Vec<_> = (0..65) + .map(|contact| began(contact, at(0.5, 0.5, 0.5))) + .collect(); + attachment.inject(&push2).expect("inject"); + let snapshot = held(&slot); + assert_eq!(snapshot.device.touches.len(), 65); + assert_eq!(snapshot.batch.dropped_events, 0); + + let edges: Vec<_> = (65..129) .map(|contact| began(contact, at(0.5, 0.5, 0.5))) .collect(); attachment.inject(&edges).expect("inject"); let snapshot = held(&slot); - assert_eq!(snapshot.device.touches.len(), 32); + assert_eq!(snapshot.device.touches.len(), 128); assert_eq!(snapshot.batch.dropped_events, 1); } From ea22c7ed40b43caa28019d2112c03068e45a62d5 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 19:52:21 -0700 Subject: [PATCH 07/13] fix(spatial): match touch segments the way LED routing does LED routing binds an output's segment name to the device by exact name, then by alias, ignoring case and slug punctuation, so a layout naming the pads "pads" still lights them. Touch placement compared names exactly, so the same layout lit the pads while every touch on them lost its canvas point. A named touch now resolves through the exact match, or else the only alias match, and two alias matches stay ambiguous. A named touch also no longer falls back to a device's single unnamed output. That output's LED order follows the whole device, not the touched segment, so projecting a pad touch through it would land on the wrong LED; an unplaced touch is the honest answer. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/spatial/device_surface.rs | 56 +++++++------- .../tests/device_surface_tests.rs | 76 +++++++++++++++++-- 2 files changed, 97 insertions(+), 35 deletions(-) diff --git a/crates/hypercolor-core/src/spatial/device_surface.rs b/crates/hypercolor-core/src/spatial/device_surface.rs index b9b0b82b0..10474b0fd 100644 --- a/crates/hypercolor-core/src/spatial/device_surface.rs +++ b/crates/hypercolor-core/src/spatial/device_surface.rs @@ -8,10 +8,12 @@ //! //! A device with several LED outputs, such as a controller with separate pad //! and strip segments, is placed per segment: a touch that names the segment -//! it landed on resolves through the output bound to that segment. +//! it landed on resolves through the output bound to that segment, matched +//! the way LED routing binds an output's segment name to the device. use std::sync::Arc; +use hypercolor_types::attachment::channel_name_matches_slot_alias; use hypercolor_types::device::DeviceId; use hypercolor_types::spatial::{ Corner, EdgeBehavior, LedTopology, NormalizedPosition, SpatialLayout, StripDirection, @@ -24,12 +26,14 @@ use super::sampler::{OutputPlacement, resolve_edge_behavior}; /// /// A touch that names no segment resolves through the device's only LED /// output in the layout. A touch that names a segment resolves through the -/// one LED output bound to that segment, or, when the device has a single -/// output that names no segment, through that output, which stands for the -/// whole device. Anything else gets no placement, and the contact carries -/// only its surface position: the layout says nothing about where that -/// surface sits on this canvas, or says several things, so there is no -/// single canvas point to report. +/// one LED output bound to that segment: the output whose segment name +/// matches exactly, or else the only one whose name matches it the way LED +/// routing does, ignoring case and slug punctuation. Anything else gets no +/// placement, and the contact carries only its surface position: the layout +/// says nothing about where that surface sits on this canvas, or says +/// several things, so there is no single canvas point to report. An output +/// that names no segment does not stand in for a named one, because its LED +/// order follows the whole device rather than the touched segment. /// /// Cloning is cheap; the placements are shared. #[derive(Debug, Clone, Default, PartialEq)] @@ -45,8 +49,6 @@ struct DeviceSurface { segment: Option>, /// Whether this is the device's only LED output in the layout. sole: bool, - /// Whether the output names no segment at all. - whole_device: bool, placement: OutputPlacement, frame: SurfaceFrame, edge: EdgeBehavior, @@ -91,7 +93,6 @@ impl DeviceSurfaceMap { device_id, segment: segment.map(Arc::from), sole, - whole_device: output.zone_name.is_none(), placement: OutputPlacement::of(output), frame: SurfaceFrame::of(&output.topology), edge: resolve_edge_behavior(output, layout), @@ -144,25 +145,24 @@ impl DeviceSurfaceMap { } fn surface(&self, device_id: DeviceId, segment: Option<&str>) -> Option<&DeviceSurface> { - let mut surfaces = self - .surfaces - .iter() - .filter(|surface| surface.device_id == device_id); - match segment { - None => surfaces.find(|surface| surface.sole), - Some(segment) => { - let mut whole_device = None; - for surface in surfaces { - if surface.segment.as_deref() == Some(segment) { - return Some(surface); - } - if surface.sole && surface.whole_device { - whole_device = Some(surface); - } - } - whole_device - } + let surfaces = || { + self.surfaces + .iter() + .filter(move |surface| surface.device_id == device_id) + }; + let Some(segment) = segment else { + return surfaces().find(|surface| surface.sole); + }; + if let Some(exact) = surfaces().find(|surface| surface.segment.as_deref() == Some(segment)) + { + return Some(exact); } + let mut aliased = surfaces().filter(|surface| { + surface.segment.is_some() + && channel_name_matches_slot_alias(surface.segment.as_deref(), Some(segment)) + }); + let alias = aliased.next()?; + aliased.next().is_none().then_some(alias) } /// Whether any surface of `device_id` has a placement in this map. diff --git a/crates/hypercolor-core/tests/device_surface_tests.rs b/crates/hypercolor-core/tests/device_surface_tests.rs index 23ffec9c1..60108c18d 100644 --- a/crates/hypercolor-core/tests/device_surface_tests.rs +++ b/crates/hypercolor-core/tests/device_surface_tests.rs @@ -452,22 +452,23 @@ fn a_segment_placed_twice_is_ambiguous_and_unplaced() { } #[test] -fn a_named_touch_lands_on_a_sole_output_that_covers_the_whole_device() { +fn a_named_touch_lands_only_on_the_output_bound_to_its_segment() { let device = DeviceId::new(); + // An output naming no segment follows the whole device's LED order, not + // the touched segment's, so it is not where a named touch landed. let whole = layout(vec![output( "pad", "blocks:lightpad", matrix(15, 15, Corner::TopLeft), )]); let surfaces = DeviceSurfaceMap::from_layout(&whole, single_device(device)); - assert_close( - surfaces - .segment_canvas_position(device, Some("Pads"), 0.25, 0.75) - .expect("the whole-device output stands for every segment"), - NormalizedPosition::new(0.25, 0.75), + assert_eq!( + surfaces.segment_canvas_position(device, Some("Pads"), 0.25, 0.75), + None ); + assert!(surfaces.canvas_position(device, 0.25, 0.75).is_some()); - // A sole output bound to another segment is not where this touch landed. + // Nor is a sole output bound to another segment. let other = layout(vec![segment_output( "strip", "Touch Strip", @@ -486,3 +487,64 @@ fn a_named_touch_lands_on_a_sole_output_that_covers_the_whole_device() { ); assert!(surfaces.canvas_position(device, 0.5, 0.5).is_some()); } + +#[test] +fn segment_names_match_the_way_led_routing_matches_them() { + let device = DeviceId::new(); + // A hand-written layout spells the segments the way routing tolerates. + let tolerant = layout(vec![ + segment_output("pads", "pads", matrix(8, 8, Corner::BottomLeft)), + segment_output( + "strip", + "touch-strip", + strip(31, StripDirection::BottomToTop), + ), + ]); + let surfaces = DeviceSurfaceMap::from_layout(&tolerant, single_device(device)); + + assert_eq!( + surfaces.segment_canvas_position(device, Some("Pads"), 0.0, 0.0), + surfaces.segment_canvas_position(device, Some("pads"), 0.0, 0.0) + ); + assert!( + surfaces + .segment_canvas_position(device, Some("Pads"), 0.0, 0.0) + .is_some() + ); + assert!( + surfaces + .segment_canvas_position(device, Some("Touch Strip"), 0.5, 0.5) + .is_some() + ); + + // An exact name wins over an alias, and two aliases are ambiguous. + let crowded = layout(vec![ + segment_output("exact", "Pads", matrix(8, 8, Corner::TopLeft)), + Output { + position: NormalizedPosition::new(0.1, 0.1), + size: NormalizedPosition::new(0.1, 0.1), + ..segment_output("alias", "pads", matrix(8, 8, Corner::TopLeft)) + }, + segment_output( + "left", + "Touch-Strip", + strip(31, StripDirection::BottomToTop), + ), + segment_output( + "right", + "touch strip", + strip(31, StripDirection::BottomToTop), + ), + ]); + let surfaces = DeviceSurfaceMap::from_layout(&crowded, single_device(device)); + assert_close( + surfaces + .segment_canvas_position(device, Some("Pads"), 0.0, 0.0) + .expect("the exact segment is placed"), + NormalizedPosition::new(0.0, 0.0), + ); + assert_eq!( + surfaces.segment_canvas_position(device, Some("Touch Strip"), 0.5, 0.5), + None + ); +} From b03033976e32194cff3758cab85f7901a7373d47 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 19:52:21 -0700 Subject: [PATCH 08/13] fix(usb): never let a superseded input bridge take a device back An interruption re-attached the lease without checking that it was still current. If a newer connection of the same device had attached since, the stale bridge would supersede it, stranding the live connection's input. The Push 2's MIDI lifecycle lease keeps two opens of one device apart today, but the bridge should not rely on that. It now asks its lease whether it is still current with an empty publish before re-attaching, and closes itself instead when it is not, as it also does when a publish reports the lease superseded. The driver API documents that publishing no edges changes nothing and reports whether a lease is current, which the bridge relies on. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/device/usb_backend/input.rs | 22 ++++++++++--- .../src/device/usb_backend/input/tests.rs | 31 ++++++++++++++++++- crates/hypercolor-driver-api/src/input.rs | 2 ++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/crates/hypercolor-core/src/device/usb_backend/input.rs b/crates/hypercolor-core/src/device/usb_backend/input.rs index 1b5bd7552..617dc407f 100644 --- a/crates/hypercolor-core/src/device/usb_backend/input.rs +++ b/crates/hypercolor-core/src/device/usb_backend/input.rs @@ -65,16 +65,28 @@ impl UsbDeviceInput { impl TransportInputSink for UsbDeviceInput { fn publish(&self, edges: &[DeviceInputEdge]) { - if let Lease::Live(publisher) = &*self.lock() { - publisher.publish(edges); + let mut lease = self.lock(); + if let Lease::Live(publisher) = &*lease + && !publisher.publish(edges) + { + // A newer connection of the same device owns its input now. + *lease = Lease::Closed; } } fn interrupted(&self) { - // Attaching again supersedes the old lease, so the host cancels its - // holds before the replacement publishes anything. - if let Lease::Live(publisher) = &mut *self.lock() { + let mut lease = self.lock(); + let Lease::Live(publisher) = &mut *lease else { + return; + }; + // An empty publish only asks whether the lease is still current. A + // lease a newer connection superseded must not take the device back. + if publisher.publish(&[]) { + // Attaching again supersedes this lease, so the host cancels its + // holds before the replacement publishes anything. *publisher = self.sink.attach(self.device_id, &self.label); + } else { + *lease = Lease::Closed; } } diff --git a/crates/hypercolor-core/src/device/usb_backend/input/tests.rs b/crates/hypercolor-core/src/device/usb_backend/input/tests.rs index f7d2d1e02..0904fe530 100644 --- a/crates/hypercolor-core/src/device/usb_backend/input/tests.rs +++ b/crates/hypercolor-core/src/device/usb_backend/input/tests.rs @@ -147,9 +147,38 @@ fn a_reconnect_supersedes_a_bridge_that_was_never_closed() { let fresh_slot = child(&host, device).expect("attached"); assert!(!stale_slot.is_active()); - // The stale bridge can neither publish into nor retire its successor. + // The stale bridge can neither publish into, take over, nor retire its + // successor. stale.publish(&[pad_press(1)]); + stale.interrupted(); stale.close(); assert!(fresh_slot.is_active()); + assert_eq!( + child(&host, device).map(|slot| slot.publication_id()), + Some(fresh_slot.publication_id()) + ); assert!(touch_phases(&fresh_slot).is_empty()); } + +#[test] +fn a_superseded_bridge_stops_before_an_interruption_can_take_the_device_back() { + let host = demanded_host(); + let device = DeviceId::new(); + let stale = bridge(&host, device); + stale.activate(); + let fresh = bridge(&host, device); + fresh.activate(); + let fresh_slot = child(&host, device).expect("attached"); + + // Only an interruption, with no publish first to notice the takeover. + stale.interrupted(); + stale.activate(); + + assert!(fresh_slot.is_active()); + assert_eq!( + child(&host, device).map(|slot| slot.publication_id()), + Some(fresh_slot.publication_id()) + ); + fresh.publish(&[pad_press(2)]); + assert_eq!(touch_phases(&fresh_slot), vec![(2, TouchPhase::Began)]); +} diff --git a/crates/hypercolor-driver-api/src/input.rs b/crates/hypercolor-driver-api/src/input.rs index 1879d40a8..9b5f3c490 100644 --- a/crates/hypercolor-driver-api/src/input.rs +++ b/crates/hypercolor-driver-api/src/input.rs @@ -24,5 +24,7 @@ pub trait DeviceInputPublisher: Send + Sync { /// Publish edges in the order the device reported them. /// /// Returns `false` once this lease has been superseded or detached. + /// Publishing no edges changes nothing, so it reports whether the lease + /// is still current. fn publish(&self, edges: &[DeviceInputEdge]) -> bool; } From 0052766f0e564f394edf5b67995b94e9f7a85d53 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 19:52:21 -0700 Subject: [PATCH 09/13] docs(input): correct the touch frame and Push 2 relay cost notes TouchPosition's field docs still said y counts from the surface's top edge, while a matrix surface counts from the row holding its first LED, which on the Push 2 is the physical bottom row. The relay claimed no allocation once warm, which holds for decoding but not for what the host sink does with published edges. The Push 2 touch surface test now covers all four matrix start corners and says plainly that it builds its layouts from the Push 2's zone list rather than through the UI seeding or auto layout code. Spec 82 records the raised contact cap, alias matching, and the lease fence. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/push2_touch_surface_tests.rs | 18 ++++++++++----- .../hypercolor-hal/src/drivers/push2/input.rs | 6 +++-- crates/hypercolor-types/src/device_input.rs | 6 +++-- docs/specs/82-device-input.md | 22 +++++++++++-------- 4 files changed, 34 insertions(+), 18 deletions(-) diff --git a/crates/hypercolor-core/tests/push2_touch_surface_tests.rs b/crates/hypercolor-core/tests/push2_touch_surface_tests.rs index 77a027f74..ed53a3955 100644 --- a/crates/hypercolor-core/tests/push2_touch_surface_tests.rs +++ b/crates/hypercolor-core/tests/push2_touch_surface_tests.rs @@ -3,8 +3,11 @@ //! Each case lights one LED through the real Push 2 frame encoder to learn //! which pad note or strip LED drives it, presses that control through the //! real input decoder, and checks the resulting touch maps to the exact -//! canvas point the sampler reads for that LED, through layouts as the UI -//! seeds them and as the daemon's auto layout builds them. +//! canvas point the sampler reads for that LED. The layouts are built here +//! from the Push 2's own zone list, in every matrix start corner and strip +//! direction, which covers the topologies the UI seeds (bottom-left pads, +//! bottom-to-top strip) and the ones auto layout picks (top-left matrices, +//! left-to-right strips). use hypercolor_core::spatial::{DeviceSurfaceMap, SpatialEngine}; use hypercolor_hal::drivers::push2::{ @@ -201,9 +204,14 @@ fn sampled(layout: &SpatialLayout) -> Vec> { fn every_tested_pad_lands_on_its_own_led() { // Four corners and an interior pad, in Pads LED order. let pads = [0, 7, 56, 63, 27]; - // As the UI seeds the Push 2 (bottom left, its physical first LED) and - // as auto layout builds any 8x8 matrix (top left). - for corner in [Corner::BottomLeft, Corner::TopLeft] { + // Bottom left is how the UI seeds the Push 2 (its physical first LED) + // and top left how auto layout builds any matrix. + for corner in [ + Corner::BottomLeft, + Corner::TopLeft, + Corner::TopRight, + Corner::BottomRight, + ] { let layout = push2_layout(corner, StripDirection::BottomToTop); let device = DeviceId::new(); let surfaces = diff --git a/crates/hypercolor-hal/src/drivers/push2/input.rs b/crates/hypercolor-hal/src/drivers/push2/input.rs index 13e93bd5d..0d79e55e3 100644 --- a/crates/hypercolor-hal/src/drivers/push2/input.rs +++ b/crates/hypercolor-hal/src/drivers/push2/input.rs @@ -35,8 +35,10 @@ impl Push2InputRelay { /// interruption when input was lost. /// /// Runs on the native input thread for every message, sysex replies - /// included, so it only decodes and hands off: no allocation once warm, - /// and no locks shared with the output or display paths. + /// included, so it only decodes and hands off. Decoding allocates + /// nothing once warm and takes no lock the output or display paths + /// share; publishing costs whatever the host's sink does with the edges, + /// and only messages that carry input publish. pub fn receive(&mut self, message: &[u8]) { self.edges.clear(); let decoded = self.decoder.decode(message, &mut self.edges); diff --git a/crates/hypercolor-types/src/device_input.rs b/crates/hypercolor-types/src/device_input.rs index a428eacd9..c8bd68592 100644 --- a/crates/hypercolor-types/src/device_input.rs +++ b/crates/hypercolor-types/src/device_input.rs @@ -17,9 +17,11 @@ use crate::event::InputButtonState; /// from its first LED. #[derive(Debug, Clone, Copy, PartialEq)] pub struct TouchPosition { - /// Horizontal position in `[0, 1]`, from the surface's left edge. + /// Horizontal position in `[0, 1]`, from the left edge of the surface's + /// frame. pub x: f32, - /// Vertical position in `[0, 1]`, from the surface's top edge. + /// Vertical position in `[0, 1]`, from the top edge of the surface's + /// frame, which for an LED matrix is the row holding its first LED. pub y: f32, /// Contact pressure in `[0, 1]`. pub pressure: f32, diff --git a/docs/specs/82-device-input.md b/docs/specs/82-device-input.md index 1a43dda1c..7ff800c3f 100644 --- a/docs/specs/82-device-input.md +++ b/docs/specs/82-device-input.md @@ -153,7 +153,7 @@ demanded: | Edge | Rule | | --- | --- | -| `TouchBegan` | Clamped. A non-finite position drops the edge and counts it. A begin for a contact already down ends the old contact first. More than 32 contacts per device drops the edge. | +| `TouchBegan` | Clamped. A non-finite position drops the edge and counts it. A begin for a contact already down ends the old contact first. More than 128 held contacts per device drops the edge (the Push 2 can hold 65). | | `TouchMoved` | Updates a held contact. A contact whose begin was never seen, such as one down when the device attached, is ignored. | | `TouchEnded` | Never dropped. A non-finite final position falls back to the last good one. | | `Button` | Pressed once until released; release without a press is ignored; autorepeat is ignored. At most 16 held buttons per device. | @@ -306,9 +306,11 @@ spatial layout, so a touch has one canvas point per zone that places its device and none elsewhere. `DeviceSurfaceMap` (`hypercolor-core::spatial`) resolves a touch that names no segment through the device's only LED output in the layout, and a touch that names a -segment through the one LED output bound to that segment, or through the -device's only output when that output names no segment and so stands for -the whole device. It maps the surface point through that output's +segment through the one LED output bound to that segment: the output whose +segment name matches exactly, or else the only one matching it the way LED +routing does (`channel_name_matches_slot_alias`, ignoring case and slug +punctuation). An output that names no segment never stands in for a named +one, since its LED order follows the whole device. It maps the surface point through that output's position, size, rotation, scale, and edge behavior, with the zone-local transform shared with the sampler. A matrix output also applies its start corner, because its LED order is the device's own row-major order, and a @@ -372,7 +374,8 @@ lease under the device's canonical `DeviceId` only once connect succeeds, and retires it as shutdown begins, before the shutdown sequence runs. `interrupted()` supersedes the lease and `closed()` retires it, so the host cancels every hold either way, as the Blocks backend does for a lost -blocksd stream. Input before connect finishes is dropped, and a device that +blocksd stream. A bridge whose lease a newer connection superseded closes +itself instead of re-attaching, so it can never take the device back. Input before connect finishes is dropped, and a device that never finishes connecting never becomes an input source. After a close, nothing brings the lease back; a reconnect builds a new bridge. @@ -424,9 +427,9 @@ Mode reply announcing Live mode, reports `interrupted()`. - Surfaces (`device_surface_tests.rs`): placement through position, size, rotation, scale, and edge behavior; every LED's sampled point equals the touch over it, for each matrix start corner and each strip direction; - named segments resolve through their own outputs, a segment placed twice - is ambiguous, and a named touch falls back only to a sole whole-device - output; unbound, ambiguous, and display-only outputs place nothing; + named segments resolve through their own outputs, match routing's + aliases with exact names first, and stay unplaced when placed twice, when + two aliases match, or when only an unnamed output exists; unbound, ambiguous, and display-only outputs place nothing; non-finite points; backend bindings and their generation. - Segments: the registry keeps a contact's segment through begin, end, a re-begin, and cancel, and routes it into held output; the payload places @@ -434,7 +437,8 @@ Mode reply announcing Live mode, reports `interrupted()`. when unset. - USB seam: the bridge drops input before connect, attaches on activation, supersedes on interruption, retires on close and stays retired, and never - disturbs a successor; only bindings that publish input get a bridge; a + disturbs a successor, even when an interruption reaches a superseded + bridge first; only bindings that publish input get a bridge; a disconnect retires the lease before the shutdown sequence runs. - Push 2 (`push2_input_tests.rs`, `push2_touch_surface_tests.rs`): the MIDI decode and relay (spec 28 §14.1), and touch-to-LED agreement through the From c74e9f2fa733387edbb69fde35733a69678b5c58 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 20:26:47 -0700 Subject: [PATCH 10/13] perf(spatial): resolve touch segment aliases without allocating A held touch whose segment has no exact match in a zone's layout went through channel_name_matches_slot_alias every frame, which builds several slug strings per comparison. A scene zone that places some of a device's segments but not the touched one takes that path for every held touch on every frame, as a Push 2 pad does in a zone that places only its white buttons and display. Two names alias exactly when their slot alias keys are equal, so the surface map now builds each output's key once, when the map is built, and slot_alias_key_matches walks the touched segment's name against it in place. A test checks the new comparison agrees with the old one over every pair of a set of names, and the allocation contract now places a held touch that misses its exact segment, and one that matches only by alias, with zero allocations. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/spatial/device_surface.rs | 12 ++- .../tests/alloc_contract_tests.rs | 90 ++++++++++++++++++- crates/hypercolor-types/src/attachment.rs | 46 +++++++++- .../tests/attachment_tests.rs | 43 ++++++++- 4 files changed, 185 insertions(+), 6 deletions(-) diff --git a/crates/hypercolor-core/src/spatial/device_surface.rs b/crates/hypercolor-core/src/spatial/device_surface.rs index 10474b0fd..4dca2d794 100644 --- a/crates/hypercolor-core/src/spatial/device_surface.rs +++ b/crates/hypercolor-core/src/spatial/device_surface.rs @@ -13,7 +13,7 @@ use std::sync::Arc; -use hypercolor_types::attachment::channel_name_matches_slot_alias; +use hypercolor_types::attachment::{slot_alias_key, slot_alias_key_matches}; use hypercolor_types::device::DeviceId; use hypercolor_types::spatial::{ Corner, EdgeBehavior, LedTopology, NormalizedPosition, SpatialLayout, StripDirection, @@ -47,6 +47,9 @@ struct DeviceSurface { /// The output's segment, kept only when no other LED output of the same /// device names it, so a touch naming it has exactly one place to land. segment: Option>, + /// The segment's alias key, built once so a touch that misses the exact + /// name resolves its alias every frame without allocating. + alias: Option>, /// Whether this is the device's only LED output in the layout. sole: bool, placement: OutputPlacement, @@ -92,6 +95,7 @@ impl DeviceSurfaceMap { Some(DeviceSurface { device_id, segment: segment.map(Arc::from), + alias: segment.map(|segment| slot_alias_key(segment).into_boxed_str()), sole, placement: OutputPlacement::of(output), frame: SurfaceFrame::of(&output.topology), @@ -158,8 +162,10 @@ impl DeviceSurfaceMap { return Some(exact); } let mut aliased = surfaces().filter(|surface| { - surface.segment.is_some() - && channel_name_matches_slot_alias(surface.segment.as_deref(), Some(segment)) + surface + .alias + .as_deref() + .is_some_and(|alias| slot_alias_key_matches(alias, segment)) }); let alias = aliased.next()?; aliased.next().is_none().then_some(alias) diff --git a/crates/hypercolor-core/tests/alloc_contract_tests.rs b/crates/hypercolor-core/tests/alloc_contract_tests.rs index 40f1bd2af..bf46e6a77 100644 --- a/crates/hypercolor-core/tests/alloc_contract_tests.rs +++ b/crates/hypercolor-core/tests/alloc_contract_tests.rs @@ -35,6 +35,7 @@ use hypercolor_core::input::{ ManagedSourceRole, MotionAggregate, ScreenData, ScreenSource, ScreenSourceRole, SourceKind, SourceRoleBinding, SourceSessionWriter, SourceStatusHandle, SourceStatusWriter, }; +use hypercolor_core::spatial::DeviceSurfaceMap; use hypercolor_types::audio::{AudioData, AudioPipelineConfig}; use hypercolor_types::control::ControlValue; use hypercolor_types::device::DeviceId; @@ -42,7 +43,10 @@ use hypercolor_types::device_input::{DeviceInputEdge, TouchPosition}; use hypercolor_types::event::{InputButtonState, TimedInputEvent}; use hypercolor_types::layer::{LayerSource, SceneLayer, SceneLayerId}; use hypercolor_types::scene::{Zone, ZoneId, ZoneRole}; -use hypercolor_types::spatial::{EdgeBehavior, SamplingMode, SpatialLayout}; +use hypercolor_types::spatial::{ + EdgeBehavior, LedTopology, NormalizedPosition, Output, SamplingMode, SpatialLayout, + StripDirection, +}; use stats_alloc::{INSTRUMENTED_SYSTEM, Region, Stats, StatsAlloc}; #[cfg_attr(not(feature = "servo"), global_allocator)] @@ -649,6 +653,74 @@ fn steady_router_resolution_control() -> (Stats, Stats) { (first, second) } +/// A controller whose zone layout places its white buttons and a strip it +/// spells `touch-strip`, but not its pads, as a scene zone may. +fn partial_controller_surfaces(device: DeviceId) -> DeviceSurfaceMap { + let strip_output = |id: &str, segment: &str| Output { + id: id.to_owned(), + name: id.to_owned(), + device_id: "usb:controller".to_owned(), + zone_name: Some(segment.to_owned()), + position: NormalizedPosition::new(0.5, 0.5), + size: NormalizedPosition::new(0.5, 0.1), + rotation: 0.0, + scale: 1.0, + orientation: None, + topology: LedTopology::Strip { + count: 31, + direction: StripDirection::BottomToTop, + }, + led_positions: Vec::new(), + led_mapping: None, + sampling_mode: None, + edge_behavior: None, + shape: None, + shape_preset: None, + display_order: 0, + attachment: None, + brightness: None, + }; + let layout = SpatialLayout { + id: "partial".to_owned(), + name: "Partial".to_owned(), + description: None, + canvas_width: 320, + canvas_height: 200, + zones: vec![ + strip_output("white", "White Buttons"), + strip_output("strip", "touch-strip"), + ], + default_sampling_mode: SamplingMode::Bilinear, + default_edge_behavior: EdgeBehavior::Clamp, + version: 1, + }; + DeviceSurfaceMap::from_layout(&layout, |layout_device_id| { + (layout_device_id == "usb:controller").then_some(device) + }) +} + +/// Place held named touches the way every frame's payload does: one whose +/// segment the layout does not place, and one it places only by alias. +fn named_touch_placement_round(surfaces: &DeviceSurfaceMap, device: DeviceId) -> Stats { + let mut region = Region::new(GLOBAL); + region.reset(); + for _ in 0..128 { + black_box(black_box(surfaces).segment_canvas_position( + black_box(device), + Some(black_box("Pads")), + 0.25, + 0.75, + )); + black_box(black_box(surfaces).segment_canvas_position( + black_box(device), + Some(black_box("Touch Strip")), + 0.5, + 0.5, + )); + } + region.change() +} + fn audio_input_construction_round(config: &AudioPipelineConfig) -> Stats { let mut region = Region::new(GLOBAL); region.reset(); @@ -802,6 +874,22 @@ fn counting_allocator_is_active_and_scoped() { assert_eq!(first_route, Stats::default()); assert_eq!(second_route, first_route); + let controller = DeviceId::new(); + let surfaces = partial_controller_surfaces(controller); + assert_eq!( + surfaces.segment_canvas_position(controller, Some("Pads"), 0.25, 0.75), + None + ); + assert!( + surfaces + .segment_canvas_position(controller, Some("Touch Strip"), 0.5, 0.5) + .is_some() + ); + assert_eq!( + named_touch_placement_round(&surfaces, controller), + Stats::default() + ); + let audio_config = AudioPipelineConfig::default(); let analyzer_construction = audio_input_construction_round(&audio_config); let first_audio_commit = prepared_audio_commit_round(&audio_config); diff --git a/crates/hypercolor-types/src/attachment.rs b/crates/hypercolor-types/src/attachment.rs index b40b56319..161f25798 100644 --- a/crates/hypercolor-types/src/attachment.rs +++ b/crates/hypercolor-types/src/attachment.rs @@ -69,6 +69,10 @@ pub fn slot_id_matches_channel_name(slot_id: &str, channel_name: &str) -> bool { } /// Symmetric comparison for channel names and attachment slot aliases. +/// +/// Two names match exactly when their [`slot_alias_key`]s are equal, so a +/// caller comparing one name against many can compute the keys once and use +/// [`slot_alias_key_matches`], which allocates nothing. #[must_use] pub fn channel_name_matches_slot_alias(left: Option<&str>, right: Option<&str>) -> bool { match (left, right) { @@ -82,7 +86,11 @@ pub fn channel_name_matches_slot_alias(left: Option<&str>, right: Option<&str>) } } -fn slot_alias_key(raw: &str) -> String { +/// Canonical alias key of a channel name or slot ID: its slug with numeric +/// parts stripped of leading zeros, so `"Channel 01"` and `"channel-1"` +/// share the key `"channel-1"`. +#[must_use] +pub fn slot_alias_key(raw: &str) -> String { let slug = slugify_slot_id(raw); let mut key = String::with_capacity(slug.len()); @@ -110,6 +118,42 @@ fn slot_alias_key(raw: &str) -> String { } } +/// Whether `raw`'s alias key equals `key`, a key [`slot_alias_key`] built. +/// +/// Walks `raw` in place instead of building its key, so matching one name +/// against precomputed keys allocates nothing. +#[must_use] +pub fn slot_alias_key_matches(key: &str, raw: &str) -> bool { + let mut parts = raw + .split(|ch: char| !ch.is_ascii_alphanumeric()) + .filter(|part| !part.is_empty()) + .peekable(); + if parts.peek().is_none() { + return key == "slot"; + } + let mut expected = key.bytes(); + for (index, part) in parts.enumerate() { + if index > 0 && expected.next() != Some(b'-') { + return false; + } + let canonical = if part.bytes().all(|byte| byte.is_ascii_digit()) { + match part.trim_start_matches('0') { + "" => "0", + number => number, + } + } else { + part + }; + if !canonical + .bytes() + .all(|byte| expected.next() == Some(byte.to_ascii_lowercase())) + { + return false; + } + } + expected.next().is_none() +} + /// Template category used for filtering and UI grouping. #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] diff --git a/crates/hypercolor-types/tests/attachment_tests.rs b/crates/hypercolor-types/tests/attachment_tests.rs index 8cb45771c..7d71ace16 100644 --- a/crates/hypercolor-types/tests/attachment_tests.rs +++ b/crates/hypercolor-types/tests/attachment_tests.rs @@ -1,7 +1,8 @@ use hypercolor_types::attachment::{ ComponentBinding, ComponentCanvasSize, ComponentCategory, ComponentCompatibility, ComponentOrigin, ComponentSlot, ComponentTemplate, ComponentTemplateManifest, - channel_name_matches_slot_alias, slot_id_matches_channel_name, slugify_slot_id, + channel_name_matches_slot_alias, slot_alias_key, slot_alias_key_matches, + slot_id_matches_channel_name, slugify_slot_id, }; use hypercolor_types::device::{ ConnectionType, DeviceCapabilities, DeviceColorFormat, DeviceFamily, DeviceFeatures, DeviceId, @@ -100,6 +101,46 @@ fn slot_alias_matching_uses_default_profile_slug_rules() { )); } +#[test] +fn precomputed_alias_keys_match_exactly_when_names_alias() { + let names = [ + "Pads", + "pads", + "PADS", + "Touch Strip", + "touch-strip", + "Touch_Strip", + "touch strip ", + "Touchstrip", + "Channel 1", + "channel-01", + "CHANNEL-001", + "Channel 10", + "channel 0", + "Channel 00", + "ATX Strimer", + "atx-strimer", + "gpu_strimer", + "GPU Strimer", + "!!!", + "slot", + "", + "Buttons Above", + "Ünicode Pads", + "-pads-", + ]; + for left in names { + let key = slot_alias_key(left); + for right in names { + assert_eq!( + slot_alias_key_matches(&key, right), + channel_name_matches_slot_alias(Some(left), Some(right)), + "{left:?} against {right:?}" + ); + } + } +} + #[test] fn attachment_template_toml_round_trip() { let manifest = sample_template(); From a91918265d7936f503d03d0cc4373118eb3d4b5f Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 20:31:26 -0700 Subject: [PATCH 11/13] fix(usb): renew an interrupted input lease atomically on the host An interrupted USB input bridge asked whether its lease was still current with an empty publish, then attached a replacement. The check and the attach were separate steps, so a fresh bridge for the same device could activate between them; the stale bridge then superseded it, took the device's input back, and the fresh bridge closed itself for good on its next publish. DeviceInputPublisher gains renew(), which replaces a lease with a fresh one only while it is still current. The core registry checks the publication incarnation and swaps the child under the writer lock, the same lock attach takes, so whichever bridge gets there second loses cleanly: a stale lease renews to None and its bridge closes. The trait method defaults to None, so publishers that cannot renew atomically retire the lease instead of racing. A regression test forces a fresh activation between a stale bridge deciding to renew and the renewal, and fails against the old probe-then-attach logic. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/device/usb_backend/input.rs | 14 ++- .../src/device/usb_backend/input/tests.rs | 91 ++++++++++++++++++- crates/hypercolor-core/src/input/device.rs | 38 +++++++- .../tests/device_input_tests.rs | 59 +++++++++++- crates/hypercolor-driver-api/src/input.rs | 12 +++ docs/specs/82-device-input.md | 36 +++++--- 6 files changed, 227 insertions(+), 23 deletions(-) diff --git a/crates/hypercolor-core/src/device/usb_backend/input.rs b/crates/hypercolor-core/src/device/usb_backend/input.rs index 617dc407f..c80abde32 100644 --- a/crates/hypercolor-core/src/device/usb_backend/input.rs +++ b/crates/hypercolor-core/src/device/usb_backend/input.rs @@ -79,14 +79,12 @@ impl TransportInputSink for UsbDeviceInput { let Lease::Live(publisher) = &mut *lease else { return; }; - // An empty publish only asks whether the lease is still current. A - // lease a newer connection superseded must not take the device back. - if publisher.publish(&[]) { - // Attaching again supersedes this lease, so the host cancels its - // holds before the replacement publishes anything. - *publisher = self.sink.attach(self.device_id, &self.label); - } else { - *lease = Lease::Closed; + // Renewing cancels this lease's holds and replaces it in one step on + // the host, and only while it is still current, so a lease a newer + // connection superseded can never take the device back. + match publisher.renew() { + Some(renewed) => *publisher = renewed, + None => *lease = Lease::Closed, } } diff --git a/crates/hypercolor-core/src/device/usb_backend/input/tests.rs b/crates/hypercolor-core/src/device/usb_backend/input/tests.rs index 0904fe530..59f22b068 100644 --- a/crates/hypercolor-core/src/device/usb_backend/input/tests.rs +++ b/crates/hypercolor-core/src/device/usb_backend/input/tests.rs @@ -1,5 +1,6 @@ -use std::sync::Arc; +use std::sync::{Arc, Mutex}; +use hypercolor_driver_api::{DeviceInputPublisher, DeviceInputSink}; use hypercolor_hal::transport::input::TransportInputSink; use hypercolor_types::device::DeviceId; use hypercolor_types::device_input::{DeviceInputEdge, TouchPosition}; @@ -182,3 +183,91 @@ fn a_superseded_bridge_stops_before_an_interruption_can_take_the_device_back() { fresh.publish(&[pad_press(2)]); assert_eq!(touch_phases(&fresh_slot), vec![(2, TouchPhase::Began)]); } + +type Hook = Box; + +/// Host sink whose leases run a hook the first time a bridge consults them +/// before replacing them, by renewing or by probing with an empty publish, +/// to force another bridge's activation into the instant between a stale +/// bridge deciding to replace its lease and the replacement. +struct InterleavingSink { + host: DeviceInputHandle, + before_renew: Arc>>, +} + +struct InterleavingPublisher { + inner: Box, + before_renew: Arc>>, +} + +impl DeviceInputSink for InterleavingSink { + fn attach(&self, device_id: DeviceId, label: &str) -> Box { + Box::new(InterleavingPublisher { + inner: DeviceInputSink::attach(&self.host, device_id, label), + before_renew: Arc::clone(&self.before_renew), + }) + } +} + +impl InterleavingPublisher { + fn run_hook(&self) { + let hook = self.before_renew.lock().expect("hook slot").take(); + if let Some(hook) = hook { + hook(); + } + } +} + +impl DeviceInputPublisher for InterleavingPublisher { + fn publish(&self, edges: &[DeviceInputEdge]) -> bool { + let current = self.inner.publish(edges); + if edges.is_empty() { + self.run_hook(); + } + current + } + + fn renew(&self) -> Option> { + self.run_hook(); + self.inner.renew() + } +} + +#[test] +fn a_reconnect_racing_an_interruption_keeps_the_device() { + let host = demanded_host(); + let device = DeviceId::new(); + let before_renew: Arc>> = Arc::new(Mutex::new(None)); + let stale = UsbDeviceInput::new( + Arc::new(InterleavingSink { + host: host.clone(), + before_renew: Arc::clone(&before_renew), + }), + device, + "Ableton Push 2", + ); + stale.activate(); + stale.publish(&[pad_press(4)]); + + // The fresh connection activates after the stale bridge has decided to + // renew but before the host renews it. + let fresh = Arc::new(bridge(&host, device)); + *before_renew.lock().expect("hook slot") = Some(Box::new({ + let fresh = Arc::clone(&fresh); + move || fresh.activate() + })); + stale.interrupted(); + + let fresh_slot = child(&host, device).expect("the fresh connection holds the device"); + assert!(fresh_slot.is_active()); + fresh.publish(&[pad_press(2)]); + assert_eq!(touch_phases(&fresh_slot), vec![(2, TouchPhase::Began)]); + // The stale bridge gave up rather than taking the device back. + stale.publish(&[pad_press(6)]); + stale.interrupted(); + assert_eq!( + child(&host, device).map(|slot| slot.publication_id()), + Some(fresh_slot.publication_id()) + ); + assert_eq!(touch_phases(&fresh_slot), vec![(2, TouchPhase::Began)]); +} diff --git a/crates/hypercolor-core/src/input/device.rs b/crates/hypercolor-core/src/input/device.rs index 348452e66..9729e508d 100644 --- a/crates/hypercolor-core/src/input/device.rs +++ b/crates/hypercolor-core/src/input/device.rs @@ -392,7 +392,34 @@ impl DeviceInputRegistryHandle { } fn attach(&self, device_id: DeviceId, label: Arc) -> DeviceInputAttachment { - let mut writer = lock_or_recover(&self.inner.writer); + let writer = lock_or_recover(&self.inner.writer); + self.attach_locked(writer, device_id, label) + } + + /// Replace the device's child with a fresh one, but only while + /// `publication_id` is still its current incarnation. The check and the + /// replacement share the writer lock, so a stale lease can never + /// supersede a newer attachment. + fn renew( + &self, + device_id: DeviceId, + publication_id: DeviceInputPublicationId, + ) -> Option { + let writer = lock_or_recover(&self.inner.writer); + let current = writer.children.get(&device_id)?; + if current.publication_id() != publication_id { + return None; + } + let label = Arc::clone(¤t.inner.label); + Some(self.attach_locked(writer, device_id, label)) + } + + fn attach_locked( + &self, + mut writer: MutexGuard<'_, DeviceInputRegistryWriter>, + device_id: DeviceId, + label: Arc, + ) -> DeviceInputAttachment { // A new attachment is a new session for the device. The previous // incarnation closes so the router cancels anything it still held. if let Some(previous) = writer.children.remove(&device_id) { @@ -543,6 +570,15 @@ impl DeviceInputPublisher for DeviceInputAttachment { fn publish(&self, edges: &[DeviceInputEdge]) -> bool { self.inject(edges).is_ok() } + + fn renew(&self) -> Option> { + if self.closed.load(Ordering::Acquire) { + return None; + } + self.registry + .renew(self.child.device_id(), self.child.publication_id()) + .map(|renewed| Box::new(renewed) as Box) + } } struct DeviceInputStatusControl { diff --git a/crates/hypercolor-core/tests/device_input_tests.rs b/crates/hypercolor-core/tests/device_input_tests.rs index 226ba1ee5..3ad6ec7e8 100644 --- a/crates/hypercolor-core/tests/device_input_tests.rs +++ b/crates/hypercolor-core/tests/device_input_tests.rs @@ -9,7 +9,7 @@ use hypercolor_core::input::{ BrowserInputHandle, DeviceButtonHold, DeviceInputChildSlot, DeviceInputHandle, DeviceInputRegistryError, InputData, InputManager, InteractionData, SourceState, TouchContact, }; -use hypercolor_driver_api::DeviceInputSink; +use hypercolor_driver_api::{DeviceInputPublisher, DeviceInputSink}; use hypercolor_types::config::InteractionRoutePolicy; use hypercolor_types::device::DeviceId; use hypercolor_types::device_input::{DeviceInputEdge, TouchPosition}; @@ -494,6 +494,63 @@ fn driver_sink_leases_report_supersession() { assert!(handle.registry().snapshot().child(device).is_some()); } +#[test] +fn renewing_replaces_only_a_lease_that_is_still_current() { + let handle = demanded_handle(); + let sink: Arc = Arc::new(handle.clone()); + let device = DeviceId::new(); + let registry = handle.registry(); + + let first = sink.attach(device, "pad"); + let first_slot = registry + .snapshot() + .child(device) + .cloned() + .expect("attached"); + assert!(first.publish(&[began(0, at(0.5, 0.5, 0.5))])); + + // A current lease renews into a fresh child under the same label, and + // the old child retires so the router cancels what it held. + let renewed = first.renew().expect("a current lease renews"); + assert!(!first_slot.is_active()); + let renewed_slot = registry + .snapshot() + .child(device) + .cloned() + .expect("attached"); + assert_ne!(renewed_slot.publication_id(), first_slot.publication_id()); + assert_eq!(renewed_slot.label(), "pad"); + assert!(held(&renewed_slot).device.touches.is_empty()); + assert!(!first.publish(&[])); + assert!(renewed.publish(&[])); + + // Once a newer attachment supersedes it, a lease cannot renew, and the + // newer attachment keeps the device. + let newer = sink.attach(device, "pad"); + let newer_slot = registry + .snapshot() + .child(device) + .cloned() + .expect("attached"); + assert!(renewed.renew().is_none()); + assert!(first.renew().is_none()); + assert!(newer_slot.is_active()); + assert_eq!( + registry + .snapshot() + .child(device) + .map(DeviceInputChildSlot::publication_id), + Some(newer_slot.publication_id()) + ); + + drop(newer); + + // A detached lease cannot renew either. + let detached = handle.attach(DeviceId::new(), "other"); + detached.close(); + assert!(DeviceInputPublisher::renew(&detached).is_none()); +} + #[test] fn source_status_follows_demand() { let handle = DeviceInputHandle::new(); diff --git a/crates/hypercolor-driver-api/src/input.rs b/crates/hypercolor-driver-api/src/input.rs index 9b5f3c490..6827bedec 100644 --- a/crates/hypercolor-driver-api/src/input.rs +++ b/crates/hypercolor-driver-api/src/input.rs @@ -27,4 +27,16 @@ pub trait DeviceInputPublisher: Send + Sync { /// Publishing no edges changes nothing, so it reports whether the lease /// is still current. fn publish(&self, edges: &[DeviceInputEdge]) -> bool; + + /// Replace this lease with a fresh one for the same device, cancelling + /// whatever it held, but only while this lease is still current. + /// + /// The host checks and replaces in one step, so a lease that a newer + /// attachment already superseded can never take the device back. + /// Returns `None` once this lease has been superseded or detached, and + /// the caller then holds nothing. Hosts that cannot replace atomically + /// keep this default, so callers retire the lease instead. + fn renew(&self) -> Option> { + None + } } diff --git a/docs/specs/82-device-input.md b/docs/specs/82-device-input.md index 7ff800c3f..8663bc017 100644 --- a/docs/specs/82-device-input.md +++ b/docs/specs/82-device-input.md @@ -120,6 +120,8 @@ pub trait DeviceInputSink: Send + Sync { pub trait DeviceInputPublisher: Send + Sync { /// `false` once this lease has been superseded or detached. fn publish(&self, edges: &[DeviceInputEdge]) -> bool; + /// A fresh lease replacing this one, only while this one is current. + fn renew(&self) -> Option> { None } } trait DriverHost { @@ -132,8 +134,13 @@ the router cancels anything the consumer observed. Attaching the same device again supersedes the earlier lease: the old source retires the same way, and the old lease's `publish` returns `false` and its drop does nothing, because close is fenced by publication incarnation. A stale lease can never retire -its successor. Hosts without device input return `None`, so every existing -host and test double keeps compiling. +its successor. `renew` replaces a lease with a fresh one, cancelling what it +held, and the host checks that the lease is still current and replaces it +under one lock, so a stale lease can never take the device back from a +newer attachment either; a superseded or detached lease renews to `None`. +Hosts without device input return `None`, so every existing host and test +double keeps compiling, and publishers that cannot renew atomically keep +the default `None`. ### 3. Device input registry (`hypercolor-core::input::device`) @@ -372,12 +379,14 @@ good). A driver-owned USB binding sets `publishes_input`, and passes the transport a `UsbDeviceInput` bridge at open, attaches the host lease under the device's canonical `DeviceId` only once connect succeeds, and retires it as shutdown begins, before the shutdown sequence runs. -`interrupted()` supersedes the lease and `closed()` retires it, so the host +`interrupted()` renews the lease and `closed()` retires it, so the host cancels every hold either way, as the Blocks backend does for a lost -blocksd stream. A bridge whose lease a newer connection superseded closes -itself instead of re-attaching, so it can never take the device back. Input before connect finishes is dropped, and a device that -never finishes connecting never becomes an input source. After a close, -nothing brings the lease back; a reconnect builds a new bridge. +blocksd stream. Renewal is atomic on the host, so a bridge whose lease a +newer connection superseded gets `None` and closes itself instead of +taking the device back. Input before connect finishes is dropped, and a +device that never finishes connecting never becomes an input source. +After a close, nothing brings the lease back; a reconnect builds a new +bridge. **Push 2.** Spec 28 §11.4 has the MIDI map. The transport's existing User port input callback forwards each message to the sysex reply ingress, then @@ -429,17 +438,20 @@ Mode reply announcing Live mode, reports `interrupted()`. touch over it, for each matrix start corner and each strip direction; named segments resolve through their own outputs, match routing's aliases with exact names first, and stay unplaced when placed twice, when - two aliases match, or when only an unnamed output exists; unbound, ambiguous, and display-only outputs place nothing; - non-finite points; backend bindings and their generation. + two aliases match, or when only an unnamed output exists; unbound, + ambiguous, and display-only outputs place nothing; non-finite points; + backend bindings and their generation. - Segments: the registry keeps a contact's segment through begin, end, a re-begin, and cancel, and routes it into held output; the payload places segment-named touches per segment; `InputEvent::Touch` omits `segment` when unset. - USB seam: the bridge drops input before connect, attaches on activation, - supersedes on interruption, retires on close and stays retired, and never + renews on interruption, retires on close and stays retired, and never disturbs a successor, even when an interruption reaches a superseded - bridge first; only bindings that publish input get a bridge; a - disconnect retires the lease before the shutdown sequence runs. + bridge first or a fresh connection activates between a stale bridge + deciding to renew and the renewal; only bindings that publish input get + a bridge; a disconnect retires the lease before the shutdown sequence + runs. The registry renews only a lease that is still current. - Push 2 (`push2_input_tests.rs`, `push2_touch_surface_tests.rs`): the MIDI decode and relay (spec 28 §14.1), and touch-to-LED agreement through the real frame encoder and decoder. From c1e4b3f8221c65466216ba3409b07c17b36a1b83 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 20:33:52 -0700 Subject: [PATCH 12/13] fix(push2): floor held pad pressure at its strike velocity Aftertouch replaced a held pad's pressure outright. The Push 2 reports aftertouch only above its pads' threshold and can send 0 once a strike settles, so a held pad's pressure collapsed right after the hit, and Touch Ripple's held glow shrank under a finger still on the pad. A held pad now keeps its strike velocity, and its pressure is the greater of that and its latest poly or channel aftertouch. Pressing in harder still swells it, easing off settles back to the strike rather than below it, and a pad moves only when that pressure changes. The decoder test and spec 28 state the rule. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/drivers/push2/protocol/input.rs | 57 +++++++++++++------ .../hypercolor-hal/tests/push2_input_tests.rs | 40 +++++++------ docs/specs/28-ableton-push2-driver.md | 12 +++- docs/specs/82-device-input.md | 3 +- 4 files changed, 76 insertions(+), 36 deletions(-) diff --git a/crates/hypercolor-hal/src/drivers/push2/protocol/input.rs b/crates/hypercolor-hal/src/drivers/push2/protocol/input.rs index 0e3fb6fcc..64e4c21d9 100644 --- a/crates/hypercolor-hal/src/drivers/push2/protocol/input.rs +++ b/crates/hypercolor-hal/src/drivers/push2/protocol/input.rs @@ -6,7 +6,11 @@ //! //! - Pads are notes 36 (bottom left) to 99 (top right). A press is Note On //! with its velocity; a release is Note Off. Aftertouch arrives as channel -//! pressure by default, or as polyphonic key pressure per pad. +//! pressure by default, or as polyphonic key pressure per pad. Aftertouch +//! reports only above the pads' threshold and can fall to 0 once a strike +//! settles, so a held pad's pressure is the greater of its strike velocity +//! and its latest aftertouch: pressing harder swells it, and the strike +//! sets its floor. //! - The touch strip touch is Note On 12 with velocity 127, released with //! velocity 0. Its position is a pitch bend from 0 at the bottom to 16320 //! at the top (eight significant bits), the mode the driver configures. @@ -141,10 +145,19 @@ pub struct Push2InputDecoder { button_by_cc: [Option>; 128], pads_segment: Arc, strip_segment: Arc, - held_pads: [bool; PUSH2_PAD_COUNT], + held_pads: [Option; PUSH2_PAD_COUNT], strip: StripState, } +/// A pad the device reported pressed. +#[derive(Debug, Clone, Copy)] +struct HeldPad { + /// Velocity of the strike, the floor for its pressure. + strike: u8, + /// Pressure last reported for it, as a seven-bit value. + pressure: u8, +} + #[derive(Debug, Clone, Copy, Default)] struct StripState { touching: bool, @@ -174,7 +187,7 @@ impl Push2InputDecoder { button_by_cc, pads_segment: Arc::from(PUSH2_PADS_SEGMENT), strip_segment: Arc::from(PUSH2_TOUCH_STRIP_SEGMENT), - held_pads: [false; PUSH2_PAD_COUNT], + held_pads: [None; PUSH2_PAD_COUNT], strip: StripState::default(), } } @@ -257,17 +270,15 @@ impl Push2InputDecoder { NOTE_ON if second > 0 => self.press(first, second, edges), NOTE_ON | NOTE_OFF => self.release(first, edges), POLY_PRESSURE => { - if let Some(pad) = self.held_pad(first) { - edges.push(self.pad_edge(pad, second, EdgeKind::Moved)); + if let Some(pad) = self.pad(first) { + self.aftertouch(pad, second, edges); } } CHANNEL_PRESSURE => { // The Push 2 reports its hardest-pressed pad; every held pad - // takes that pressure. - for (pad, held) in (0_u8..).zip(self.held_pads) { - if held { - edges.push(self.pad_edge(pad, first, EdgeKind::Moved)); - } + // takes that aftertouch. + for pad in 0..PAD_GRID * PAD_GRID { + self.aftertouch(pad, first, edges); } } CONTROL_CHANGE => { @@ -304,7 +315,10 @@ impl Push2InputDecoder { return; } if let Some(pad) = self.pad(note) { - self.held_pads[usize::from(pad)] = true; + self.held_pads[usize::from(pad)] = Some(HeldPad { + strike: velocity, + pressure: velocity, + }); edges.push(self.pad_edge(pad, velocity, EdgeKind::Began)); } } @@ -328,8 +342,9 @@ impl Push2InputDecoder { self.strip.began = false; return; } - if let Some(pad) = self.held_pad(note) { - self.held_pads[usize::from(pad)] = false; + if let Some(pad) = self.pad(note) + && self.held_pads[usize::from(pad)].take().is_some() + { edges.push(self.pad_edge(pad, 0, EdgeKind::Ended)); } } @@ -370,9 +385,17 @@ impl Push2InputDecoder { self.pad_by_note[usize::from(note)] } - fn held_pad(&self, note: u8) -> Option { - self.pad(note) - .filter(|pad| self.held_pads[usize::from(*pad)]) + /// Move a held pad to the greater of its strike and `aftertouch`, when + /// that changes its pressure. A pad that is not held has none to report. + fn aftertouch(&mut self, pad: u8, aftertouch: u8, edges: &mut Vec) { + let Some(held) = &mut self.held_pads[usize::from(pad)] else { + return; + }; + let pressure = held.strike.max(aftertouch); + if pressure != held.pressure { + held.pressure = pressure; + edges.push(self.pad_edge(pad, pressure, EdgeKind::Moved)); + } } fn pad_edge(&self, pad: u8, value: u8, kind: EdgeKind) -> DeviceInputEdge { @@ -394,7 +417,7 @@ impl Push2InputDecoder { } fn lose(&mut self) -> Push2InputDecode { - self.held_pads = [false; PUSH2_PAD_COUNT]; + self.held_pads = [None; PUSH2_PAD_COUNT]; self.strip = StripState::default(); Push2InputDecode::Lost } diff --git a/crates/hypercolor-hal/tests/push2_input_tests.rs b/crates/hypercolor-hal/tests/push2_input_tests.rs index df61b14e5..1c661c90d 100644 --- a/crates/hypercolor-hal/tests/push2_input_tests.rs +++ b/crates/hypercolor-hal/tests/push2_input_tests.rs @@ -129,35 +129,43 @@ fn note_off_and_zero_velocity_note_on_both_release_a_pad() { } #[test] -fn poly_pressure_moves_one_held_pad_and_channel_pressure_moves_them_all() { +fn aftertouch_swells_held_pads_above_their_strike_and_never_below_it() { let mut decoder = Push2InputDecoder::new(); decode(&mut decoder, &[NOTE_ON, 36, 100]); - decode(&mut decoder, &[NOTE_ON, 99, 100]); + decode(&mut decoder, &[NOTE_ON, 99, 40]); + let moved = |contact, x, y, value: f32| DeviceInputEdge::TouchMoved { + contact, + position: pad_at(x, y, value / 127.0), + }; + // Poly pressure moves only its own pad, and only while it is held. assert_eq!( decode(&mut decoder, &[0xA0, 99, 127]), - vec![DeviceInputEdge::TouchMoved { - contact: 63, - position: pad_at(1.0, 1.0, 1.0), - }] + vec![moved(63, 1.0, 1.0, 127.0)] ); + assert_eq!( + decode(&mut decoder, &[0xA0, 99, 20]), + vec![moved(63, 1.0, 1.0, 40.0)], + "easing off settles back to the strike, not below it" + ); + assert!(decode(&mut decoder, &[0xA0, 99, 10]).is_empty()); assert!( decode(&mut decoder, &[0xA0, 40, 127]).is_empty(), "a pad that is not held has no pressure to report" ); + + // Channel pressure reaches every held pad through the same floor, so + // the zero sent once a strike settles leaves both at their strikes. + assert!(decode(&mut decoder, &[0xD0, 0]).is_empty()); + assert_eq!( + decode(&mut decoder, &[0xD0, 110]), + vec![moved(0, 0.0, 0.0, 110.0), moved(63, 1.0, 1.0, 110.0),] + ); assert_eq!( decode(&mut decoder, &[0xD0, 0]), - vec![ - DeviceInputEdge::TouchMoved { - contact: 0, - position: pad_at(0.0, 0.0, 0.0), - }, - DeviceInputEdge::TouchMoved { - contact: 63, - position: pad_at(1.0, 1.0, 0.0), - }, - ] + vec![moved(0, 0.0, 0.0, 100.0), moved(63, 1.0, 1.0, 40.0)] ); + decode(&mut decoder, &[NOTE_OFF, 36, 0]); decode(&mut decoder, &[NOTE_OFF, 99, 0]); assert!(decode(&mut decoder, &[0xD0, 90]).is_empty()); diff --git a/docs/specs/28-ableton-push2-driver.md b/docs/specs/28-ableton-push2-driver.md index 9a0103798..3308629dd 100644 --- a/docs/specs/28-ableton-push2-driver.md +++ b/docs/specs/28-ableton-push2-driver.md @@ -1042,14 +1042,22 @@ other channels are ignored. | --- | --- | | Note On 36 to 99, velocity > 0 | Pad `TouchBegan`, pressure = velocity / 127, segment `Pads` | | Note Off, or Note On velocity 0, on a held pad | Pad `TouchEnded` | -| Poly key pressure on a held pad | That pad's `TouchMoved` with the new pressure | -| Channel pressure | `TouchMoved` for every held pad with that pressure (the device reports its hardest-pressed pad) | +| Poly key pressure on a held pad | That pad's `TouchMoved` when its pressure changes (see below) | +| Channel pressure | The same for every held pad (the device reports its hardest-pressed pad) | | Note On 12, velocity 127 / 0 | Touch strip touched / released, contact 64, segment `Touch Strip` | | Pitch bend (14 bit, 0 bottom to 16320 top) | Touch strip position | | Control change, value > 0 / 0, on a button CC | `Button` pressed / released | | Control change 14, 15, 71 to 79 | Ignored: relative encoder turns | | Note On 0 to 10 | Ignored: encoder capacitive touches | +**Held pressure.** A held pad's pressure is the greater of its strike +(Note On velocity / 127) and its latest aftertouch value / 127. The Push 2 +reports aftertouch only above its pads' threshold and can send 0 once a +strike settles, so taking the aftertouch alone would collapse a held pad's +pressure right after the hit. Pressing in harder still raises it; easing +off settles back to the strike, never below. A pad's `TouchMoved` goes out +only when that pressure changes. + Pad contacts are their LED index (`note - 36`). A pad touch reports `x = column / 7` and `y = row / 7`, with row 0 the first LED row (notes 36 to 43, the physical bottom row), so a press projects onto the LED under it diff --git a/docs/specs/82-device-input.md b/docs/specs/82-device-input.md index 8663bc017..c949bab9a 100644 --- a/docs/specs/82-device-input.md +++ b/docs/specs/82-device-input.md @@ -488,7 +488,8 @@ Mode reply announcing Live mode, reports `interrupted()`. local frame. Strip outputs follow their direction since phase 3; before, only left-to-right strips agreed with their LEDs. - Push 2 channel pressure carries one value for the whole device, so every - held pad takes the hardest-pressed pad's pressure. The driver does not + held pad takes the hardest-pressed pad's aftertouch, floored at its own + strike velocity. The driver does not switch the device to poly aftertouch, which would need a sysex the driver avoids sending. - A Push 2 touch strip finger that stops exactly on the strip's center From 6cbca73450b4fe87fcc7403fecf381210bd424ea Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 19 Oct 2026 20:34:36 -0700 Subject: [PATCH 13/13] docs(sdk): describe segment frames and per-segment touch placement The SDK's TouchContact doc still said x and y run from the surface's left and top edge, and that canvas is null when a device is placed more than once. Touches now report in the frame the touched segment's LED order defines, so a Push 2 pad's y of 0 is its bottom row, and a device with several segments is placed per segment, so a touch loses its canvas point only when its own segment is unplaced or placed twice. Spec 28 also claimed the Push 2 relay allocates nothing once warm. That holds for decoding, but publishing into the host sink allocates in the core fold, and the spec now says so. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/28-ableton-push2-driver.md | 6 ++++-- sdk/packages/core/src/input/types.ts | 26 ++++++++++++++++++++------ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/docs/specs/28-ableton-push2-driver.md b/docs/specs/28-ableton-push2-driver.md index 3308629dd..8f74b7bf3 100644 --- a/docs/specs/28-ableton-push2-driver.md +++ b/docs/specs/28-ableton-push2-driver.md @@ -1031,8 +1031,10 @@ every message to the sysex reply ingress first, then to a `Push2InputRelay` (`drivers/push2/input.rs`) that decodes it with `Push2InputDecoder` (`protocol/input.rs`) and publishes the edges through the `TransportInputSink` the transport opener received (Spec 82 §9). The relay -owns no lock the output or display paths take, and allocates nothing once -warm. +owns no lock the output or display paths take. Decoding allocates nothing +once warm; publishing costs what the host's sink does with the edges, which +allocates per batch in the core fold, and only messages that carry input +publish. The map follows Ableton's MIDI and Display Interface Manual and its MIDI mapping figure. Every control reports on channel 1 (status low nibble 0); diff --git a/sdk/packages/core/src/input/types.ts b/sdk/packages/core/src/input/types.ts index 0b573cc0c..7e6ea0563 100644 --- a/sdk/packages/core/src/input/types.ts +++ b/sdk/packages/core/src/input/types.ts @@ -137,22 +137,36 @@ export interface TouchCanvasPoint { ny: number } -/** One contact held on a device's touch surface, such as a ROLI Lightpad. */ +/** + * One contact held on a device's touch surface, such as a ROLI Lightpad or + * an Ableton Push 2 pad. + */ export interface TouchContact { /** Hypercolor device id of the touched surface. */ device: string /** Contact identity, stable from touch start to end on that device. */ contact: number - /** Device-surface x in [0, 1], from the surface's left edge. */ + /** + * Device-surface x in [0, 1], in the frame the touched segment's LED + * order defines: a matrix's first LED is its top left, and a strip runs + * along x from its first LED. + */ x: number - /** Device-surface y in [0, 1], from the surface's top edge. */ + /** + * Device-surface y in [0, 1], from the row holding the segment's first + * LED. That row need not be the physical top: on a Push 2 the first pad + * row, y = 0, is the bottom one. Draw at `canvas` to land where the + * finger is. + */ y: number /** Contact pressure in [0, 1]. */ pressure: number /** - * Canvas position of the contact, or `null` when the touched device has - * no single output in this canvas's layout (not placed, or placed more - * than once). Surface coordinates stay valid either way. + * Canvas position of the contact, or `null` when the touched surface has + * no single output in this canvas's layout. A device with several + * segments, such as Push 2 pads and its touch strip, is placed per + * segment, so a touch is `null` only when its own segment is not placed, + * or is placed more than once. Surface coordinates stay valid either way. */ canvas: TouchCanvasPoint | null }