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/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..c80abde32 --- /dev/null +++ b/crates/hypercolor-core/src/device/usb_backend/input.rs @@ -0,0 +1,97 @@ +//! 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]) { + 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) { + let mut lease = self.lock(); + let Lease::Live(publisher) = &mut *lease else { + return; + }; + // 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, + } + } + + 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..59f22b068 --- /dev/null +++ b/crates/hypercolor-core/src/device/usb_backend/input/tests.rs @@ -0,0 +1,273 @@ +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}; +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, 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)]); +} + +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/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-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..9729e508d 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. @@ -63,6 +66,7 @@ pub enum DeviceInputRegistryError { struct HeldTouch { position: TouchPosition, + segment: Option>, announced: bool, } @@ -388,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) { @@ -539,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 { @@ -638,14 +678,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 +704,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 +731,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 +776,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 +792,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 +809,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 +845,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/src/spatial/device_surface.rs b/crates/hypercolor-core/src/spatial/device_surface.rs index c7b4a9eb7..4dca2d794 100644 --- a/crates/hypercolor-core/src/spatial/device_surface.rs +++ b/crates/hypercolor-core/src/spatial/device_surface.rs @@ -5,12 +5,18 @@ //! 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, matched +//! the way LED routing binds an output's segment name to the device. use std::sync::Arc; +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, + Corner, EdgeBehavior, LedTopology, NormalizedPosition, SpatialLayout, StripDirection, }; use super::is_led_sampled_zone; @@ -18,11 +24,16 @@ 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: 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)] @@ -30,17 +41,24 @@ 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>, + /// 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, - 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 +67,111 @@ 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), + alias: segment.map(|segment| slot_alias_key(segment).into_boxed_str()), + sole, 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 - .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 }, - ); + 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)) } - /// Whether `device_id` has a placement in this map. + fn surface(&self, device_id: DeviceId, segment: Option<&str>) -> Option<&DeviceSurface> { + 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 + .alias + .as_deref() + .is_some_and(|alias| slot_alias_key_matches(alias, segment)) + }); + let alias = aliased.next()?; + aliased.next().is_none().then_some(alias) + } + + /// 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 +179,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 +196,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/alloc_contract_tests.rs b/crates/hypercolor-core/tests/alloc_contract_tests.rs index 7adc03661..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)] @@ -601,6 +605,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"), @@ -647,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(); @@ -800,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-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..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}; @@ -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 @@ -235,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); } @@ -414,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(); @@ -457,6 +594,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 +731,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 +755,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/device_surface_tests.rs b/crates/hypercolor-core/tests/device_surface_tests.rs index e866ac64c..60108c18d 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,215 @@ 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_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_eq!( + surfaces.segment_canvas_position(device, Some("Pads"), 0.25, 0.75), + None + ); + assert!(surfaces.canvas_position(device, 0.25, 0.75).is_some()); + + // Nor is a sole output bound to another segment. + 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()); +} + +#[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 + ); +} 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-core/tests/push2_touch_surface_tests.rs b/crates/hypercolor-core/tests/push2_touch_surface_tests.rs new file mode 100644 index 000000000..ed53a3955 --- /dev/null +++ b/crates/hypercolor-core/tests/push2_touch_surface_tests.rs @@ -0,0 +1,308 @@ +//! 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. 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::{ + 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]; + // 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 = + 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-driver-api/src/input.rs b/crates/hypercolor-driver-api/src/input.rs index 1879d40a8..6827bedec 100644 --- a/crates/hypercolor-driver-api/src/input.rs +++ b/crates/hypercolor-driver-api/src/input.rs @@ -24,5 +24,19 @@ 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; + + /// 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/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..a57a08e4f 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: 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..0d79e55e3 --- /dev/null +++ b/crates/hypercolor-hal/src/drivers/push2/input.rs @@ -0,0 +1,58 @@ +//! 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. 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); + 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..64e4c21d9 --- /dev/null +++ b/crates/hypercolor-hal/src/drivers/push2/protocol/input.rs @@ -0,0 +1,493 @@ +//! 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. 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. +//! - 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: [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, + /// 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: [None; 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.pad(first) { + self.aftertouch(pad, second, edges); + } + } + CHANNEL_PRESSURE => { + // The Push 2 reports its hardest-pressed pad; every held pad + // takes that aftertouch. + for pad in 0..PAD_GRID * PAD_GRID { + self.aftertouch(pad, first, edges); + } + } + 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)] = Some(HeldPad { + strike: velocity, + pressure: velocity, + }); + 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.pad(note) + && self.held_pads[usize::from(pad)].take().is_some() + { + 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)] + } + + /// 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 { + 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 = [None; 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/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); +} 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..1c661c90d --- /dev/null +++ b/crates/hypercolor-hal/tests/push2_input_tests.rs @@ -0,0 +1,515 @@ +//! 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 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, 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![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![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()); +} + +#[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); +} 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/src/device_input.rs b/crates/hypercolor-types/src/device_input.rs index 433927e8b..c8bd68592 100644 --- a/crates/hypercolor-types/src/device_input.rs +++ b/crates/hypercolor-types/src/device_input.rs @@ -10,11 +10,18 @@ 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. + /// 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, @@ -30,6 +37,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/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(); 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(); diff --git a/docs/specs/28-ableton-push2-driver.md b/docs/specs/28-ableton-push2-driver.md index b26312184..8f74b7bf3 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,82 @@ 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. 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); +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` 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 +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 +1365,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 +1404,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 +1423,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..c949bab9a 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 { @@ -111,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 { @@ -123,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`) @@ -144,7 +160,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. | @@ -197,13 +213,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 +311,18 @@ 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: 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. 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 +336,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 +365,39 @@ 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()` 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. 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 +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 +407,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 +435,26 @@ 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, 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 + segment-named touches per segment; `InputEvent::Touch` omits `segment` + when unset. +- USB seam: the bridge drops input before connect, attaches on activation, + renews on interruption, retires on close and stays retired, and never + disturbs a successor, even when an interruption reaches a superseded + 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. - 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 +484,17 @@ 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 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 + leaves the touch one step short of it until it moves, because autoreturn + reports the same value just before every lift. ## Open questions 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 }