diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index ee2ca62..ca21996 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -976,6 +976,14 @@ struct EditorApp { mobile_state: mobile::MobileState, /// Active mobile long-press context menu (set by a pane, rendered by the shell). mobile_context_menu: Option, + /// Shared keyboard octave offset (C4-relative) for the mobile Virtual Piano + Piano Roll. + keyboard_octave: i8, + /// Shared horizontal keyboard pan (px) for the mobile keyboard + roll. + keyboard_pan_x: f32, + /// Mobile: request to open the instrument Preset Browser (set by the music pane header). + open_instrument_browser: bool, + /// Mobile: request to toggle recording (set by the music pane REC button). + pending_record_toggle: bool, icon_cache: IconCache, tool_icon_cache: ToolIconCache, focus_icon_cache: FocusIconCache, // Focus card icons (start screen) @@ -1338,6 +1346,10 @@ impl EditorApp { mobile_ui_override: None, mobile_state: mobile::MobileState::default(), mobile_context_menu: None, + keyboard_octave: 0, + keyboard_pan_x: 0.0, + open_instrument_browser: false, + pending_record_toggle: false, icon_cache: IconCache::new(), tool_icon_cache: ToolIconCache::new(), focus_icon_cache: FocusIconCache::new(), @@ -6608,7 +6620,16 @@ impl eframe::App for EditorApp { { scratch.synthetic_input = test_mode_replay.synthetic_input; } - egui::CentralPanel::default().show(ctx, |ui| { + // On mobile the shell is full-bleed to the window edges (it paints its own background), so + // drop the central panel's default inner margin — otherwise every pane is inset by it while + // the unclipped keyboard overdraws into it, leaving an inconsistent gutter. + let central = egui::CentralPanel::default(); + let central = if self.mobile_active() { + central.frame(egui::Frame::default()) + } else { + central + }; + central.show(ctx, |ui| { let available_rect = ui.available_rect_before_wrap(); // Reset hovered divider each frame @@ -6668,6 +6689,27 @@ impl eframe::App for EditorApp { &mut bundle.rc, bundle.mobile_state, ); + + // Drive recording from the application, not the Timeline pane's render pass, so a + // REC request (from the music pane header) and count-in progression work regardless + // of whether the Timeline is currently visible. The Timeline pane still owns the + // recording *state*; we just tick it here each frame. + let rc = &mut bundle.rc; + for pane in rc.pane_instances.values_mut() { + if let PaneInstance::Timeline(t) = pane { + t.check_pending_recording_start(&mut rc.shared); + if *rc.shared.pending_record_toggle { + *rc.shared.pending_record_toggle = false; + t.toggle_recording(&mut rc.shared); + } + // Stopping playback stops recording (stop_recording clears is_recording, + // so this fires once). + if *rc.shared.is_recording && !*rc.shared.is_playing { + t.stop_recording(&mut rc.shared); + } + break; + } + } } else { render_layout_node( ui, @@ -7221,6 +7263,11 @@ impl EditorApp { rc: RenderContext { shared: panes::SharedPaneState { is_mobile, + keyboard_octave: &mut self.keyboard_octave, + keyboard_pan_x: &mut self.keyboard_pan_x, + instrument_show_roll: false, + open_instrument_browser: &mut self.open_instrument_browser, + pending_record_toggle: &mut self.pending_record_toggle, mobile_context_menu: &mut self.mobile_context_menu, container_path: self.current_file_path.clone(), onion: { diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs index 911dd07..590bdf4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs @@ -19,18 +19,24 @@ struct Intent { focus: usize, /// Initial mobile stack window: (window_top, window_count). window: (usize, usize), + /// Initial pane weights (relative heights) for the window. + weights: [f32; 3], } fn intents(pal: &Palette) -> [Intent; 6] { let [coral, cyan, amber, pink, violet] = pal.accents; + let even = [1.0, 1.0, 1.0]; + // Compose/Record: compressed Timeline ribbon on top; the tall Piano Roll (which now hosts the + // instrument header + keyboard as one surface) fills the rest. + let music = [0.4, 1.6, 1.0]; [ // Stage indices (see super::STACK): Stage=2, Timeline=3, PianoRoll=4, VirtualPiano=5. - Intent { label: "Draw", icon: icons::BRUSH, accent: coral, focus: 5, window: (2, 1) }, - Intent { label: "Animate", icon: icons::FILM, accent: cyan, focus: 0, window: (2, 2) }, - Intent { label: "Compose", icon: icons::MUSIC, accent: amber, focus: 2, window: (3, 3) }, - Intent { label: "Record", icon: icons::MIC, accent: pink, focus: 2, window: (3, 3) }, - Intent { label: "Edit video", icon: icons::CLAPPERBOARD, accent: violet, focus: 1, window: (2, 2) }, - Intent { label: "Blank", icon: icons::SQUARE_DASHED, accent: pal.text_dim, focus: 0, window: (2, 2) }, + Intent { label: "Draw", icon: icons::BRUSH, accent: coral, focus: 5, window: (2, 1), weights: even }, + Intent { label: "Animate", icon: icons::FILM, accent: cyan, focus: 0, window: (2, 2), weights: even }, + Intent { label: "Compose", icon: icons::MUSIC, accent: amber, focus: 2, window: (3, 2), weights: music }, + Intent { label: "Record", icon: icons::MIC, accent: pink, focus: 2, window: (3, 2), weights: music }, + Intent { label: "Edit video", icon: icons::CLAPPERBOARD, accent: violet, focus: 1, window: (2, 2), weights: even }, + Intent { label: "Blank", icon: icons::SQUARE_DASHED, accent: pal.text_dim, focus: 0, window: (2, 2), weights: even }, ] } @@ -92,7 +98,7 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) { app.create_new_project_with_focus(intent.focus); app.mobile_state.window_top = intent.window.0; app.mobile_state.window_count = intent.window.1; - app.mobile_state.weights = [1.0, 1.0, 1.0]; + app.mobile_state.weights = intent.weights; } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs index 69c2d3a..56cc7e7 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs @@ -89,21 +89,21 @@ pub enum StackPane { AssetLibrary, Stage, Timeline, + /// The instrument surface: keyboard-primary on mobile, hosting the falling-notes roll above the + /// keys (the standalone Virtual Piano is embedded here, so it isn't a separate stack slot). PianoRoll, - VirtualPiano, /// Node editor, or (toggled) the instrument/preset browser. NodeInstrument, ScriptEditor, } /// The stack order. Index into this is a pane's stable slot id. -pub const STACK: [StackPane; 8] = [ +pub const STACK: [StackPane; 7] = [ StackPane::Outliner, StackPane::AssetLibrary, StackPane::Stage, StackPane::Timeline, StackPane::PianoRoll, - StackPane::VirtualPiano, StackPane::NodeInstrument, StackPane::ScriptEditor, ]; @@ -118,7 +118,6 @@ impl StackPane { StackPane::Stage => PaneType::Stage, StackPane::Timeline => PaneType::Timeline, StackPane::PianoRoll => PaneType::PianoRoll, - StackPane::VirtualPiano => PaneType::VirtualPiano, StackPane::NodeInstrument => { if show_instruments { PaneType::PresetBrowser @@ -136,8 +135,7 @@ impl StackPane { StackPane::AssetLibrary => "Assets", StackPane::Stage => "Stage", StackPane::Timeline => "Timeline", - StackPane::PianoRoll => "Piano Roll", - StackPane::VirtualPiano => "Keys", + StackPane::PianoRoll => "Instrument", StackPane::NodeInstrument => { if show_instruments { "Instruments" @@ -332,6 +330,20 @@ pub fn render_mobile_shell( region }; + // Decide whether the instrument pane (PianoRoll = STACK index 4) reveals the roll, from the + // *committed* (snapped) window weights — so the keyboard↔roll transition lands on a stack snap + // rather than at an arbitrary mid-drag height. Roll shows once the pane is past the smallest snap. + rc.shared.instrument_show_roll = { + let idx = 4i32 - state.window_top as i32; + if idx >= 0 && (idx as usize) < state.window_count { + let sum: f32 = state.weights[..state.window_count].iter().map(|w| w.max(0.0)).sum(); + let w = if sum > 0.0 { state.weights[idx as usize].max(0.0) / sum } else { 0.0 }; + w > 0.30 // 0.25 preset ⇒ keyboard only; 0.33/0.5/0.75 ⇒ keyboard + roll + } else { + true + } + }; + stack::render(ui, stack_rect, rc, state, &pal); if inspector_shown { @@ -345,6 +357,16 @@ pub fn render_mobile_shell( // Omnibutton FAB (radial tool menu) — drawn above the stack region, on top of everything else. omni::render(ui, region, rc, state, &pal); + // Instrument-browser request from the music pane's header → show the Preset Browser fullscreen. + if *rc.shared.open_instrument_browser { + *rc.shared.open_instrument_browser = false; + state.show_instruments = true; + state.window_top = 5; // Node/Instrument band (PresetBrowser when show_instruments) + state.window_count = 1; + state.weights = [1.0, 1.0, 1.0]; + state.anim = None; + } + // Top bar (filename + ⌕ palette + ⋯ commands). Its menus overlay the whole shell, so it's last. topbar::render(ui, topbar_rect, available_rect, rc, state, &pal); diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/transport.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/transport.rs index 87f0e5a..8b4bc1b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/transport.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/transport.rs @@ -40,9 +40,24 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState, } } - // --- Timecode (MM:SS:FF) --- - let fps = shared.action_executor.document().framerate.max(1.0); - let tc = format_timecode(*shared.playback_time, fps); + // --- Playhead readout: format follows the project's TimelineMode (set at creation by type). --- + let t = *shared.playback_time; + let tc = { + use lightningbeam_core::document::TimelineMode; + let doc = shared.action_executor.document(); + match doc.timeline_mode { + TimelineMode::Measures => { + let pos = lightningbeam_core::beat_time::time_to_measure(t, doc.tempo_map(), &doc.time_signature); + format!("{}.{}.{:02}", pos.measure, pos.beat, pos.tick / 10) + } + TimelineMode::Frames => format_timecode(t, doc.framerate.max(1.0)), + TimelineMode::Seconds => { + let total = t.max(0.0); + let m = (total / 60.0).floor() as u32; + format!("{:02}:{:06.3}", m, total % 60.0) + } + } + }; let tc_left = btn_rect.right() + 12.0; painter.text( egui::pos2(tc_left, cy), diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/keyboard_layout.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/keyboard_layout.rs new file mode 100644 index 0000000..038665d --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/keyboard_layout.rs @@ -0,0 +1,108 @@ +//! Shared horizontal keyboard geometry (pitch → x), used by both the Virtual Piano and the mobile +//! portrait "Synthesia" Piano Roll so their columns line up with the keys. It is **width-driven** +//! (key width from the pane width, not its height) and supports a smooth horizontal **pan** (pixels) +//! so the roll and keyboard scroll together and stay aligned. + +/// Number of white keys strictly before each pitch-class within its octave. +const WHITES_BEFORE: [i32; 12] = [0, 1, 1, 2, 2, 3, 4, 4, 5, 5, 6, 6]; +/// White pitch-classes, indexed by white-key-within-octave. +const WHITE_PC: [i32; 7] = [0, 2, 4, 5, 7, 9, 11]; +/// Target on-screen white-key width (px); the visible white-key count approximates it. +const TARGET_WHITE_KEY_W: f32 = 40.0; + +/// Absolute white-key index of a note, counting white keys from MIDI 0. +fn awi(note: u8) -> i32 { + (note as i32 / 12) * 7 + WHITES_BEFORE[(note % 12) as usize] +} + +/// The white note at a given absolute white-key index. +fn white_note_from_awi(a: i32) -> u8 { + let oct = a.div_euclid(7); + let rem = a.rem_euclid(7) as usize; + (oct * 12 + WHITE_PC[rem]).clamp(0, 127) as u8 +} + +/// A horizontal piano key layout for a pane width, octave center, and horizontal pan. +#[derive(Clone, Copy)] +pub struct KeyboardLayout { + pub white_key_width: f32, + pub black_key_width: f32, + /// `note_x(white n) == base_x + awi(n) * white_key_width`. + base_x: f32, + rect_left: f32, + rect_width: f32, +} + +impl KeyboardLayout { + pub fn is_black_key(note: u8) -> bool { + matches!(note % 12, 1 | 3 | 6 | 8 | 10) + } + + /// Build a layout. `pan_x` shifts everything right (drag-right reveals lower keys); at `pan_x == 0` + /// the octave-center key sits in the middle of the pane. + pub fn from_width(origin_x: f32, width: f32, octave: i8, pan_x: f32) -> Self { + let vis = ((width / TARGET_WHITE_KEY_W).round() as u32).clamp(7, 24) as f32; + let white_key_width = width / vis; + let black_key_width = white_key_width * 0.6; + let center = (60 + octave as i32 * 12).clamp(0, 127) as u8; + let base_x = origin_x + width / 2.0 - (awi(center) as f32 + 0.5) * white_key_width + pan_x; + Self { white_key_width, black_key_width, base_x, rect_left: origin_x, rect_width: width } + } + + /// Snap a pan offset to the nearest whole key (for release-snap). + pub fn snap_pan(&self, pan_x: f32) -> f32 { + (pan_x / self.white_key_width).round() * self.white_key_width + } + + /// Left x of a key's rect (black keys straddle the white boundary). + pub fn note_x(&self, note: u8) -> f32 { + let x = self.base_x + awi(note) as f32 * self.white_key_width; + if Self::is_black_key(note) { + x - self.black_key_width / 2.0 + } else { + x + } + } + + pub fn note_width(&self, note: u8) -> f32 { + if Self::is_black_key(note) { + self.black_key_width + } else { + self.white_key_width + } + } + + pub fn note_center_x(&self, note: u8) -> f32 { + self.note_x(note) + self.note_width(note) / 2.0 + } + + /// Leftmost visible white key (with one key of margin). + pub fn first_visible_white(&self) -> u8 { + let a = ((self.rect_left - self.base_x) / self.white_key_width).floor() as i32 - 1; + white_note_from_awi(a.max(0)) + } + + /// Rightmost visible note (with one key of margin). + pub fn last_visible_note(&self) -> u8 { + let a = ((self.rect_left + self.rect_width - self.base_x) / self.white_key_width).ceil() as i32 + 1; + white_note_from_awi(a).min(127) + } + + pub fn visible_notes(&self) -> std::ops::RangeInclusive { + self.first_visible_white()..=self.last_visible_note() + } + + /// Which key is at screen x (black keys, drawn on top, take precedence). + pub fn x_to_note(&self, x: f32) -> u8 { + for note in self.visible_notes() { + if Self::is_black_key(note) { + let c = self.note_center_x(note); + if (x - c).abs() <= self.black_key_width / 2.0 { + return note; + } + } + } + let a = ((x - self.base_x) / self.white_key_width).floor() as i32; + white_note_from_awi(a.max(0)).min(127) + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index a345def..fdca5ce 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -72,6 +72,7 @@ pub mod gradient_editor; pub mod timeline; pub mod infopanel; pub mod outliner; +pub mod keyboard_layout; pub mod piano_roll; pub mod virtual_piano; pub mod node_editor; @@ -363,6 +364,19 @@ pub struct SharedPaneState<'a> { pub brush_preview_pixels: &'a std::sync::Arc)>>>, /// True when rendering the phone/mobile shell (panes can render more compactly). pub is_mobile: bool, + /// Shared keyboard octave offset (C4-relative), so the mobile Virtual Piano and the portrait + /// Piano Roll agree on which keys are visible and stay column-aligned. + pub keyboard_octave: &'a mut i8, + /// Shared horizontal keyboard pan (px) for smooth left/right scroll of the mobile keyboard+roll. + pub keyboard_pan_x: &'a mut f32, + /// Whether the mobile instrument pane should show the falling-notes roll above the keys. Driven + /// by the shell from the *snapped* pane size-class so the reveal happens at a stack snap point. + pub instrument_show_roll: bool, + /// Set by the mobile instrument header's "Presets" button; the shell opens the Preset Browser. + pub open_instrument_browser: &'a mut bool, + /// Set by the mobile instrument header's REC button; the Timeline pane picks it up and toggles + /// recording (reusing its full count-in / clip-creation flow). + pub pending_record_toggle: &'a mut bool, /// Mobile long-press context menu request. A pane sets this on `response.secondary_clicked()` /// (which fires on long-press) with the items relevant to what was pressed; the mobile shell /// renders one persistent popup and dispatches the chosen `MenuAction`. `None` = no menu. diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index 64e5390..19ddd0b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -169,6 +169,18 @@ pub struct PianoRollPane { snap_value: SnapValue, last_snap_selection: HashSet, snap_user_changed: bool, // set in render_header, consumed before handle_input + + // Mobile portrait "Synthesia" mode: the playable keyboard is drawn as a strip at the bottom of + // this pane (one unified surface), reusing the Virtual Piano's rendering + MIDI logic. + keyboard: super::virtual_piano::VirtualPianoPane, + // Manual long-press detection for the vertical roll (works with mouse-hold and touch): time the + // press started, and where. `None` = disarmed (no fresh press, or the press became a pan). + lp_press_time: Option, + lp_press_pos: egui::Pos2, + // When the long-press landed on an existing note, we resize it instead of creating: its index in + // the resolved-note list and its original duration (to compute the resize delta on commit). + editing_index: Option, + editing_orig_dur: f64, } impl PianoRollPane { @@ -205,6 +217,11 @@ impl PianoRollPane { snap_value: SnapValue::None, last_snap_selection: HashSet::new(), snap_user_changed: false, + keyboard: super::virtual_piano::VirtualPianoPane::new(), + lp_press_time: None, + lp_press_pos: egui::Pos2::ZERO, + editing_index: None, + editing_orig_dur: 0.0, } } @@ -444,6 +461,13 @@ impl PianoRollPane { } } + // Mobile: keyboard-primary "Synthesia" instrument surface (keys + falling-notes roll). Always + // used on mobile (device is portrait; the landscape/conventional flip is P7). + if shared.is_mobile { + self.render_vertical_mode(ui, rect, shared, &clip_data); + return; + } + // Apply quantize if the user changed the snap dropdown (must happen before handle_input // which may clear the selection when the ComboBox click propagates to the grid). if self.snap_user_changed { @@ -556,6 +580,336 @@ impl PianoRollPane { self.render_keyboard(&painter, keyboard_rect); } + /// Portrait mobile "Synthesia" view — ONE unified instrument surface: an instrument header on + /// top, then falling notes as vertical columns (pitch on X, aligned to the keys via the shared + /// `KeyboardLayout`, time falling toward the "now" line), then the playable keyboard strip at the + /// bottom (reusing the Virtual Piano). Tapping a column adds a note. + fn render_vertical_mode( + &mut self, + ui: &mut egui::Ui, + rect: Rect, + shared: &mut SharedPaneState, + clip_data: &[(u32, f64, f64, f64, Uuid)], + ) { + use super::keyboard_layout::KeyboardLayout; + + // Keyboard-primary layout (portrait, per wireframe Plate 08): the playable keyboard is the + // base surface, capped at an ergonomic height. Whether the falling-notes roll appears above + // is decided by the shell from the *snapped* pane size-class, so the reveal lands on a stack + // snap point (not mid-drag). Short/smallest snap ⇒ keyboard only. + const KEY_CAP: f32 = 170.0; + let header_h = 32.0; + let header_rect = Rect::from_min_max(rect.min, pos2(rect.right(), rect.top() + header_h)); + let content_top = header_rect.bottom(); + let content_h = (rect.bottom() - content_top).max(0.0); + let show_roll = shared.instrument_show_roll && content_h > KEY_CAP + 40.0; + let kb_h = if show_roll { content_h.min(KEY_CAP) } else { content_h }; + let kb_rect = Rect::from_min_max(pos2(rect.left(), rect.bottom() - kb_h), rect.max); + let roll_rect = Rect::from_min_max(pos2(rect.left(), content_top), pos2(rect.right(), kb_rect.top())); + + let pps = self.pixels_per_second; // pixels per second (vertical waterfall) + let now_y = roll_rect.bottom(); // a note reaches the keys at its onset time + let now = ui.input(|i| i.time); + + // Auto-release a long-press-added preview note once its short audition elapses (the normal + // release check lives in handle_input, which the vertical mode bypasses). + if let Some(dur) = self.preview_duration { + if self.preview_note_sounding && now - self.preview_start_time >= dur { + self.preview_note_off(shared); + } + } + + // Handle roll gestures BEFORE building the layout / drawing, so the roll and the keyboard both + // use the updated pan + playhead this same frame (no 1-frame follow lag between them). + let resp = if show_roll { + let r = ui.interact(roll_rect, ui.id().with("pr_vertical"), egui::Sense::click_and_drag()); + if self.creating_note.is_none() && r.dragged() { + let d = r.drag_delta(); + if d.y.abs() > 0.0 { + // Drag down ⇒ advance time (content follows the finger). + let nt = (*shared.playback_time + (d.y / pps) as f64).max(0.0); + *shared.playback_time = nt; + if let Some(ctrl) = shared.audio_controller.as_ref() { + if let Ok(mut c) = ctrl.lock() { + c.seek(nt); + } + } + } + *shared.keyboard_pan_x += d.x; + } + Some(r) + } else { + None + }; + + let layout = KeyboardLayout::from_width(rect.min.x, rect.width(), *shared.keyboard_octave, *shared.keyboard_pan_x); + // Note times are in beats; the playhead is in seconds — convert via the tempo map so the + // waterfall lines up with real playback (onset crosses the now line exactly when it sounds). + let tempo_map = shared.action_executor.document().tempo_map().clone(); + let playhead = *shared.playback_time; // seconds + + if let Some(resp) = resp { + let painter = ui.painter_at(roll_rect); + let bg = shared.theme.bg_color(&["#piano-roll", ".pane-content"], ui.ctx(), Color32::from_rgb(30, 30, 35)); + painter.rect_filled(roll_rect, 0.0, bg); + + // Column guides aligned to the keys: black-key columns tinted, white-key boundaries lined. + for note in layout.visible_notes() { + let x = layout.note_x(note); + if KeyboardLayout::is_black_key(note) { + let w = layout.note_width(note); + painter.rect_filled( + Rect::from_min_size(pos2(x, roll_rect.min.y), vec2(w, roll_rect.height())), + 0.0, + Color32::from_rgba_unmultiplied(0, 0, 0, 40), + ); + } else { + painter.line_segment([pos2(x, roll_rect.min.y), pos2(x, roll_rect.max.y)], Stroke::new(1.0, Color32::from_gray(55))); + } + } + + // Notes of the selected clip, falling toward the now line. + if let Some(clip_id) = self.selected_clip_id { + if let Some(&(_, timeline_start, trim_start, duration, _)) = + clip_data.iter().find(|c| c.0 == clip_id) + { + if let Some(events) = shared.midi_event_cache.get(&clip_id) { + let resolved = Self::resolve_notes(events); + for (ni, note) in resolved.iter().enumerate() { + // The note being resized is drawn separately (live) as the temp note. + if Some(ni) == self.editing_index { + continue; + } + if note.start_time + note.duration <= trim_start + || note.start_time >= trim_start + duration + { + continue; + } + let global = timeline_start + (note.start_time - trim_start); + let y_on = now_y - ((tempo_map.transform(global) - playhead) as f32) * pps; + let y_off = now_y - ((tempo_map.transform(global + note.duration) - playhead) as f32) * pps; + let (top, bot) = (y_on.min(y_off), y_on.max(y_off)); + if bot < roll_rect.min.y || top > roll_rect.max.y { + continue; + } + let x = layout.note_x(note.note); + let w = layout.note_width(note.note).max(3.0); + let bright = 0.35 + (note.velocity as f32 / 127.0) * 0.65; + let col = Color32::from_rgb( + (111.0 * bright) as u8, + (220.0 * bright) as u8, + (111.0 * bright) as u8, + ); + painter.rect_filled( + Rect::from_min_max( + pos2(x + 1.0, top.max(roll_rect.min.y)), + pos2(x + w - 1.0, bot.min(roll_rect.max.y)), + ), + 2.0, + col, + ); + } + } + } + } + + // The note currently being created (long-press to start, drag along Y to size). + if let Some(temp) = self.creating_note.as_ref() { + if let Some(&(_, ts, trim, _d, _)) = + clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id) + { + let g = ts + (temp.start_time - trim); + let y_on = now_y - ((tempo_map.transform(g) - playhead) as f32) * pps; + let y_off = now_y - ((tempo_map.transform(g + temp.duration) - playhead) as f32) * pps; + let x = layout.note_x(temp.note); + let w = layout.note_width(temp.note).max(3.0); + painter.rect_filled( + Rect::from_min_max(pos2(x + 1.0, y_on.min(y_off)), pos2(x + w - 1.0, y_on.max(y_off))), + 2.0, + Color32::from_rgb(150, 255, 150), + ); + } + } + + // The amber "now" line at the boundary with the keyboard. + painter.line_segment( + [pos2(roll_rect.min.x, now_y), pos2(roll_rect.max.x, now_y)], + Stroke::new(2.0, Color32::from_rgb(0xf4, 0xa3, 0x40)), + ); + + const LONG_PRESS_SECS: f64 = 0.4; + let pressed = ui.input(|i| i.pointer.primary_pressed()); + let down = ui.input(|i| i.pointer.any_down()); + let ptr = ui.input(|i| i.pointer.latest_pos()); + + if self.creating_note.is_some() { + // Sizing the note (new or existing): drag along Y sets its duration; release commits. + if down { + if let (Some(p), Some(&(_, ts, trim, _d, _))) = ( + ptr, + clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id), + ) { + let end_sec = playhead + ((now_y - p.y) / pps) as f64; + let end_beats = tempo_map.inverse_transform(end_sec).max(0.0); + let end_local = trim + (end_beats - ts); + if let Some(t) = self.creating_note.as_mut() { + t.duration = (end_local - t.start_time).max(0.25); + } + } + } else if let (Some(temp), Some(clip_id)) = + (self.creating_note.take(), self.selected_clip_id) + { + if let Some(idx) = self.editing_index.take() { + let dt = temp.duration - self.editing_orig_dur; + self.commit_resize_note(clip_id, idx, dt, shared, clip_data); + } else { + self.commit_create_note(clip_id, temp, shared, clip_data); + } + } + } else { + // Snap the pan to the nearest key on release. + if resp.drag_stopped() { + *shared.keyboard_pan_x = layout.snap_pan(*shared.keyboard_pan_x); + } + // Manual long-press (mouse-hold or touch): armed only on a *fresh* press, and cancelled + // for the rest of that press once it moves (→ pan), so pausing mid-pan won't fire. + if pressed { + match ptr { + Some(p) if roll_rect.contains(p) => { + self.lp_press_time = Some(now); + self.lp_press_pos = p; + } + _ => self.lp_press_time = None, + } + } + if !down { + self.lp_press_time = None; + } + if let Some(t0) = self.lp_press_time { + let moved = ptr.map_or(0.0, |p| (p - self.lp_press_pos).length()); + if moved > 8.0 { + self.lp_press_time = None; // became a pan → suppress for this press + } else if now - t0 >= LONG_PRESS_SECS { + self.lp_press_time = None; + let pos = self.lp_press_pos; + if let Some(clip_id) = self.selected_clip_id { + if let Some(&(_, timeline_start, trim_start, clip_dur, _)) = + clip_data.iter().find(|c| c.0 == clip_id) + { + let pitch = layout.x_to_note(pos.x); + // Hit-test an existing note in this column → resize it; else create. + let mut hit = None; + if let Some(events) = shared.midi_event_cache.get(&clip_id) { + for (i, n) in Self::resolve_notes(events).iter().enumerate() { + if n.note != pitch + || n.start_time < trim_start + || n.start_time >= trim_start + clip_dur + { + continue; + } + let g = timeline_start + (n.start_time - trim_start); + let y_on = now_y - ((tempo_map.transform(g) - playhead) as f32) * pps; + let y_off = now_y - ((tempo_map.transform(g + n.duration) - playhead) as f32) * pps; + if pos.y >= y_on.min(y_off) && pos.y <= y_on.max(y_off) { + hit = Some((i, n.start_time, n.duration, n.velocity)); + break; + } + } + } + if let Some((idx, onset, dur, vel)) = hit { + self.editing_index = Some(idx); + self.editing_orig_dur = dur; + self.creating_note = Some(TempNote { note: pitch, start_time: onset, duration: dur, velocity: vel }); + } else { + let onset_sec = playhead + ((now_y - pos.y) / pps) as f64; + let onset_beats = tempo_map.inverse_transform(onset_sec).max(0.0); + let onset_local = snap_to_value((trim_start + (onset_beats - timeline_start)).max(0.0), self.snap_value, &tempo_map); + self.editing_index = None; + self.creating_note = Some(TempNote { note: pitch, start_time: onset_local, duration: 0.5, velocity: DEFAULT_VELOCITY }); + } + self.preview_note_on(pitch, DEFAULT_VELOCITY, Some(0.4), now, shared); + } + } + } + } + } + } + + // Notes sounding at the playhead → drive the keyboard's highlights so it colors them exactly + // like pressed keys (full key, correct white/black layering) rather than an after-overlay. + let mut sounding = std::collections::HashSet::new(); + if let Some(clip_id) = self.selected_clip_id { + if let Some(&(_, timeline_start, trim_start, duration, _)) = + clip_data.iter().find(|c| c.0 == clip_id) + { + if let Some(events) = shared.midi_event_cache.get(&clip_id) { + for note in Self::resolve_notes(events) { + if note.start_time < trim_start || note.start_time >= trim_start + duration { + continue; + } + let global = timeline_start + (note.start_time - trim_start); + let s = tempo_map.transform(global); + let e = tempo_map.transform(global + note.duration); + if playhead >= s && playhead < e { + sounding.insert(note.note); + } + } + } + } + } + self.keyboard.playback_notes = sounding; + + // Instrument header (in this pane's own header) + the always-present playable keyboard strip. + self.render_instrument_header(ui, header_rect, shared); + let kb_path: NodePath = Vec::new(); + self.keyboard.render_content(ui, kb_rect, &kb_path, shared); + } + + /// The in-pane instrument header: active track name + Presets + REC. + fn render_instrument_header(&mut self, ui: &mut egui::Ui, rect: Rect, shared: &mut SharedPaneState) { + let painter = ui.painter_at(rect); + let hdr = shared.theme.bg_color(&[".pane-header"], ui.ctx(), Color32::from_rgb(24, 24, 28)); + painter.rect_filled(rect, 0.0, hdr); + painter.line_segment( + [pos2(rect.left(), rect.bottom()), pos2(rect.right(), rect.bottom())], + Stroke::new(1.0, Color32::from_gray(60)), + ); + + let name = shared + .active_layer_id + .and_then(|id| shared.action_executor.document().get_layer(&id)) + .map(|l| l.name().to_string()) + .unwrap_or_else(|| "No instrument".to_string()); + painter.text( + pos2(rect.left() + 12.0, rect.center().y), + egui::Align2::LEFT_CENTER, + name, + egui::FontId::proportional(14.0), + Color32::from_gray(220), + ); + + // REC (rightmost). + let rec = Rect::from_min_max(pos2(rect.right() - 64.0, rect.top() + 4.0), pos2(rect.right() - 8.0, rect.bottom() - 4.0)); + let rec_resp = ui.interact(rec, ui.id().with("pr_rec"), egui::Sense::click()); + let recording = *shared.is_recording; + let rec_bg = if recording { Color32::from_rgb(0xe0, 0x3a, 0x3a) } else { Color32::from_gray(66) }; + painter.rect_filled(rec, 6.0, rec_bg); + painter.circle_filled(pos2(rec.left() + 13.0, rec.center().y), 4.0, Color32::from_rgb(255, 96, 96)); + painter.text(pos2(rec.left() + 23.0, rec.center().y), egui::Align2::LEFT_CENTER, "REC", egui::FontId::proportional(12.0), Color32::WHITE); + if rec_resp.clicked() { + *shared.pending_record_toggle = true; + } + + // Presets (left of REC). + let pre = Rect::from_min_max(pos2(rec.left() - 84.0, rect.top() + 4.0), pos2(rec.left() - 8.0, rect.bottom() - 4.0)); + let pre_resp = ui.interact(pre, ui.id().with("pr_presets"), egui::Sense::click()); + painter.rect_filled(pre, 6.0, if pre_resp.hovered() { Color32::from_gray(56) } else { Color32::from_gray(42) }); + painter.text(pre.center(), egui::Align2::CENTER_CENTER, "Presets", egui::FontId::proportional(12.0), Color32::from_gray(220)); + if pre_resp.clicked() { + *shared.open_instrument_browser = true; + } + } + fn render_keyboard(&self, painter: &egui::Painter, rect: Rect) { // Background painter.rect_filled(rect, 0.0, Color32::from_rgb(40, 40, 45)); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index d04028a..663678b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -951,7 +951,7 @@ impl TimelinePane { /// Toggle recording on/off /// In Auto mode, records to the active layer (audio or video with camera) - fn toggle_recording(&mut self, shared: &mut SharedPaneState) { + pub(crate) fn toggle_recording(&mut self, shared: &mut SharedPaneState) { if *shared.is_recording { // Stop recording self.stop_recording(shared); @@ -1212,7 +1212,7 @@ impl TimelinePane { /// Stop all active recordings /// Called every frame; fires deferred recording commands once the count-in pre-roll ends. - fn check_pending_recording_start(&mut self, shared: &mut SharedPaneState) { + pub(crate) fn check_pending_recording_start(&mut self, shared: &mut SharedPaneState) { let Some(ref pending) = self.pending_recording_start else { return }; if !*shared.is_playing || *shared.playback_time < pending.trigger_time { return; @@ -1232,7 +1232,7 @@ impl TimelinePane { *shared.count_in_enabled = saved_count_in; } - fn stop_recording(&mut self, shared: &mut SharedPaneState) { + pub(crate) fn stop_recording(&mut self, shared: &mut SharedPaneState) { // Cancel any in-progress count-in self.pending_recording_start = None; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs index 3ab982c..1bcba15 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs @@ -33,6 +33,8 @@ pub struct VirtualPianoPane { sustain_active: bool, /// Notes being held by sustain pedal (not by active key/mouse press) sustained_notes: HashSet, + /// Externally-driven highlights (e.g. notes sounding during playback), colored like pressed keys. + pub playback_notes: HashSet, } impl Default for VirtualPianoPane { @@ -83,6 +85,7 @@ impl VirtualPianoPane { note_to_key_map, sustain_active: false, sustained_notes: HashSet::new(), + playback_notes: HashSet::new(), } } @@ -216,9 +219,17 @@ impl VirtualPianoPane { /// Render the piano keyboard fn render_keyboard(&mut self, ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) { - // Calculate visible range and key dimensions based on pane size - let (visible_start, visible_end, white_key_width, offset_x) = - self.calculate_visible_range(rect.width(), rect.height()); + // Calculate visible range and key dimensions based on pane size. On mobile use the shared + // width-driven layout (so the portrait Piano Roll's columns line up with these keys). + let (visible_start, visible_end, white_key_width, offset_x) = if shared.is_mobile { + let layout = super::keyboard_layout::KeyboardLayout::from_width( + rect.min.x, rect.width(), self.octave_offset, *shared.keyboard_pan_x, + ); + let vs = layout.first_visible_white(); + (vs, layout.last_visible_note(), layout.white_key_width, layout.note_x(vs) - rect.min.x) + } else { + self.calculate_visible_range(rect.width(), rect.height()) + }; let white_key_height = rect.height(); let black_key_width = white_key_width * self.black_key_width_ratio; @@ -238,6 +249,11 @@ impl VirtualPianoPane { return; } + // Only play notes when the press STARTED on the keyboard, so dragging in from the roll above + // (mobile) doesn't trigger notes. On desktop the whole pane is the keyboard, so a click here + // always qualifies. + let started_here = ui.input(|i| i.pointer.press_origin().map_or(false, |p| rect.contains(p))); + // Count white keys before each note for positioning let mut white_key_positions: std::collections::HashMap = std::collections::HashMap::new(); let mut white_count = 0; @@ -303,7 +319,8 @@ impl VirtualPianoPane { }); let pointer_down = ui.input(|i| i.pointer.primary_down()); let is_pressed = self.pressed_notes.contains(¬e) || - (!black_key_interacted && pointer_over_key && pointer_down); + self.playback_notes.contains(¬e) || + (started_here && !black_key_interacted && pointer_over_key && pointer_down); let color = if is_pressed { shared.theme.bg_color(&[".piano-white-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255)) } else { @@ -320,8 +337,9 @@ impl VirtualPianoPane { ); if !black_key_interacted { - // Mouse down starts note (detect primary button pressed on this key) - if pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) { + // Mouse down starts note (detect primary button pressed on this key). Gated on + // `started_here` so a drag that began on the roll above doesn't start notes. + if started_here && pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) { // Calculate velocity based on mouse Y position let mouse_y = ui.input(|i| i.pointer.hover_pos()).unwrap().y; let velocity = self.calculate_velocity_from_mouse_y(mouse_y, key_rect); @@ -330,7 +348,8 @@ impl VirtualPianoPane { self.dragging_note = Some(note); } - // Mouse up stops note (detect primary button released) + // Mouse up stops note (detect primary button released). NEVER gated — a held note + // must always release. if ui.input(|i| i.pointer.primary_released()) { if self.dragging_note == Some(note) { self.send_note_off(note, shared); @@ -339,7 +358,7 @@ impl VirtualPianoPane { } // Dragging over a new key (pointer is down and over a different key) - if pointer_over_key && pointer_down { + if started_here && pointer_over_key && pointer_down { if self.dragging_note != Some(note) { // Stop previous note if let Some(prev_note) = self.dragging_note { @@ -387,7 +406,8 @@ impl VirtualPianoPane { }); let pointer_down = ui.input(|i| i.pointer.primary_down()); let is_pressed = self.pressed_notes.contains(¬e) || - (pointer_over_key && pointer_down); + self.playback_notes.contains(¬e) || + (started_here && pointer_over_key && pointer_down); let color = if is_pressed { shared.theme.bg_color(&[".piano-black-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(50, 100, 200)) } else { @@ -397,7 +417,7 @@ impl VirtualPianoPane { ui.painter().rect_filled(key_rect, 2.0, color); // Mouse down starts note - if pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) { + if started_here && pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) { // Calculate velocity based on mouse Y position let mouse_y = ui.input(|i| i.pointer.hover_pos()).unwrap().y; let velocity = self.calculate_velocity_from_mouse_y(mouse_y, key_rect); @@ -415,7 +435,7 @@ impl VirtualPianoPane { } // Dragging over a new key - if pointer_over_key && pointer_down { + if started_here && pointer_over_key && pointer_down { if self.dragging_note != Some(note) { if let Some(prev_note) = self.dragging_note { self.send_note_off(prev_note, shared); @@ -824,6 +844,11 @@ impl PaneRenderer for VirtualPianoPane { _path: &NodePath, shared: &mut SharedPaneState, ) { + // On mobile, the octave is shared with the Piano Roll so they stay aligned. + if shared.is_mobile { + self.octave_offset = *shared.keyboard_octave; + } + // Check if there's an active MIDI layer let has_active_midi_layer = if let Some(active_layer_id) = *shared.active_layer_id { shared.layer_to_track_map.contains_key(&active_layer_id) @@ -869,14 +894,28 @@ impl PaneRenderer for VirtualPianoPane { } // Calculate visible range (needed for both rendering and labels) - let (visible_start, visible_end, white_key_width, offset_x) = - self.calculate_visible_range(rect.width(), rect.height()); + let (visible_start, visible_end, white_key_width, offset_x) = if shared.is_mobile { + let layout = super::keyboard_layout::KeyboardLayout::from_width( + rect.min.x, rect.width(), self.octave_offset, *shared.keyboard_pan_x, + ); + let vs = layout.first_visible_white(); + (vs, layout.last_visible_note(), layout.white_key_width, layout.note_x(vs) - rect.min.x) + } else { + self.calculate_visible_range(rect.width(), rect.height()) + }; // Render the keyboard self.render_keyboard(ui, rect, shared); - // Render keyboard labels on top - self.render_key_labels(ui, rect, shared, visible_start, visible_end, white_key_width, offset_x); + // Render keyboard labels on top — but not on mobile (no physical keyboard to hint at). + if !shared.is_mobile { + self.render_key_labels(ui, rect, shared, visible_start, visible_end, white_key_width, offset_x); + } + + // Publish the octave so the portrait Piano Roll aligns to these keys. + if shared.is_mobile { + *shared.keyboard_octave = self.octave_offset; + } } fn name(&self) -> &str { diff --git a/phone-ui-sketches.html b/phone-ui-sketches.html new file mode 100644 index 0000000..313a8fc --- /dev/null +++ b/phone-ui-sketches.html @@ -0,0 +1,842 @@ + + + + + +Phone UI — Wireframe Plates + + + + +
+

Mobile port · concept plates

+
+
+

Unified-timeline studio — phone layout

+

One hero surface at a time, surface tabs along the top, and time controls grouped at the bottom: a fixed transport bar with the resizable timeline ribbon riding above it. Every desktop control stays reachable; almost none stay visible.

+
+
+
CONSTRAINTPhone · single-pane
+
TABSTop · surfaces
+
SPINEBottom · time
+
+
+
+ +
+ Time / playhead / transport + Selection + Annotation +
+ + +
+
+ PLATE 01 +

The spine & the resizable ribbon

+ Tabs top · time bottom · 3 snaps +
+

Surface tabs sit along the top. At the bottom, the transport bar is fixed — the irreducible spine — and the timeline ribbon expands upward above it through three snaps, trading stage for tracks by degrees. It never hits zero on its own; the transport is always the floor.

+ +
+
+
+
9:41●●●
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
+
+
V1
+
00:12:04
+ 1 + 2 + 3 +
+
Peek — ribbon collapsed to a sliver; transport still anchors the bottom.
+
+ +
+
+
9:41●●●
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
+
+
+
+
RASTER
+
VEC
+
AUD
+
AUD
+
+
00:12:04
+
+
Half — arrange & trim across tracks while the stage stays in view.
+
+ +
+
1
+

Tabs own the top

+

Stage / Time / Nodes / Mixer / Tree. The active surface is home; the rest are one tap away. Moving them up clears the bottom for the controls your thumb actually lives on.

+
+
2
+

Ribbon expands upward

+

Drag the grabber up for more tracks, down for more stage. A continuous reveal, not a modal toggle — there's always a sliver left.

+
+
3
+

Transport = the fixed floor

+

Play, playhead time and a zoomed-out project scrub never move and never disappear. It's the irreducible spine; the ribbon is detail layered on top of it.

+
+
+
+
+ + +
+
+ PLATE 02 +

Tweak mode — the pull model

+ Selection → inspector +
+

You arrive with a full project and a vague target, so controls come to what you point at. Tap anything and its properties rise in a sheet above the transport. The header carries jump-to chips that reframe to another surface with the same object still selected.

+ +
+
+
+
9:41⌕ ●●●
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
+
+
+
+
+
+
+
+
Ellipse · Vector layer
+
PropertiesTimelineNodesAutomation
+
Fill
#9A6BC0
+
Opacity
+
Position
x 108
y 54
+
Z-order
Front ▾
+
+
00:12:04
+ 1 + 2 + 3 +
+
Half tier shown — drag up for every advanced parameter, down to a 3-control peek. Transport stays pinned below.
+
+ +
+
1
+

Selection-driven

+

Tap selects and summons the sheet. Once selected, dragging the object moves it; dragging empty space pans. One chain: tap → inspect → move → long-press.

+
+
2
+

Jump-to chips = parity move

+

Timeline · Nodes · Automation reframe to that surface with the object still held — replacing the desktop trick of seeing panes side-by-side.

+
+
3
+

Sheet floats over the floor

+

The inspector rises above the transport, never under it — so even mid-tweak you can scrub and see the playhead. Object actions like send to back live on the .

+
+
+
+

Two ways to find the thing

+

For "I don't know what I want to tweak": the command palette and an outliner surface (the Tree tab) — a browsable map of the whole project.

+
+
+
+
+ + +
+
+ PLATE 03 +

Sketch mode — the push model

+ New file · intent seeds +
+

An empty project with a clearish intent, so a tool defines the surface and gets out of the way. "New" offers intents — the same non-binding menu as desktop. Picking one sets the hero surface and a minimal toolset; switching intent mid-session just swaps the hero while a real project quietly accretes underneath.

+ +
+
+
+
9:41●●●
+
+
Start something
+
Sets your first surface — nothing is locked.
+
+
DrawFull canvas · stylus · radial brushes
+
AnimateStage + keyframe scrub strip
+
ComposeInstrument + arrangement ribbon
+
RecordBig button · waveform fills screen
+
Edit videoClip bin + trim timeline
+
BlankEmpty unified timeline
+
Open recent…
+
+
+ 1 +
+
Intent picker — mirrors the desktop new-file flow; the seed steers nothing once content exists.
+
+ +
+
1
+

Intent seeds, state steers

+

At creation there's nothing else to go on, so intent is the right call. The moment content exists, the original type stops driving anything.

+
+
2
+

Home-on-open ≠ birth type

+

Re-opening lands on the file's last-focused surface + last selection — not the type it was born as. A music file that grew an animation layer shouldn't open music-shaped.

+
+
3
+

Lossless underneath

+

Every intent writes to the same document model. Whatever you rough out on the train reconstitutes as a full pane layout back on desktop.

+
+
+
+
+ + +
+
+ PLATE 04 +

Music surface — the GarageBand fix

+ Expression + context, together +
+

GarageBand denies you the when while you play. Here the instrument lives in the thumb zone with a compressed arrangement ribbon pinned above it — loop boundaries, the playhead sweeping toward the wrap, and squashed lanes of what you're playing against. The transport anchors the bottom as everywhere else.

+ +
+
+
+
9:41●●●
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
DRUMS
+
BASS
+
KEYS ●
+
+
Grand Piano ▾Loop ⟳ 2 bars● REC
+
+ LIVE TAKE · KEYS +
+
+
+
+
+
+
+
+
+
+
+
2.1.03
120bpm
+ 1 + 2 + 3 +
+
Record-against-context — see the loop wrap and the other tracks while your thumbs are on the keys.
+
+ +
+
1
+

The loop boundary is visible

+

Green markers show where the 2-bar loop wraps; the amber playhead sweeps toward it — the exact when GarageBand's modal switch hides.

+
+
2
+

Playing against the mix

+

Squashed drum/bass lanes stay on screen so you hear and see what your take lands over. The live KEYS lane fills as you record.

+
+
3
+

Same spine, same floor

+

Transport with bars-beats + tempo anchors the bottom. Drag the ribbon down for full arrangement; the Mixer is a portrait-native surface one tab away.

+
+
+
+
+ + +
+
+ PLATE 05 +

Orientation is a per-surface reflow

+ Not an app-global mode +
+

No surface forces a rotation — that's the user's wrist, not the app's call. But the same timeline data emphasises a different axis per orientation: landscape spends pixels on time resolution, portrait spends them on track count. Rotation preserves selection, playhead and zoom-center; it only reflows.

+ +
+
+
+
9:41●●●
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
V1
+
V2
+
RAS
+
A1
+
A2
+
A3
+
A4
+
FX
+
+
00:12:04
+
+
Portrait — vertical room buys more tracks. Best for arranging, reordering, seeing structure.
+
+ +
+
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
V1
+
A1
+
A2
+
+
00:12:04
+
+
Landscape — wide axis buys time resolution. Best for scrubbing, precise trims.
+
+ +
+
+

Reflow, don't reset

+

Same selection, playhead and zoom-center — just re-emphasised. A rotate that throws away where you were is its own GarageBand-tier annoyance.

+
+
+

Per-surface character

+

Stage follows the project's aspect; instrument prefers landscape for key width; mixer is portrait-native. The ribbon absorbs whatever the hero leaves over.

+
+
🔒
+

Manual lock

+

People work lying down and propped at angles. The device suggests; the user can pin. Auto-rotate never fights a drawing gesture.

+
+
+
+
+ + +
+
+ PLATE 06 +

Omnibutton & the gesture map

+ Tools vs commands · 7 + 1 meanings +
+

The omnibutton produces tools/objects; the global menu holds commands/destinations — a clean tool-vs-command line. Below, the resolved stage gestures: seven meanings, no collisions, each reinforcing a desktop idiom rather than inventing a phone-only convention.

+ +
+
+
+
9:41⌕ ⋯ ●●●
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
+
+
+
+
+
Shape
+
+
+
+
V1
+
00:12:04
+ 1 + 2 +
+
Radial fans away from the finger to dodge occlusion. Global commands live in the top .
+
+ +
+
1
+

Omnibutton = tools/objects

+

Brushes, shapes, instruments, nodes — things you place or use. Long-press on empty space can summon this same create menu at the point you pressed.

+
+
2
+

Global menu = commands

+

Export, render, project settings, and the command palette itself live in the top /. If an item feels like either, it's usually a selection action in disguise → inspector.

+
+
+

Palette is the safety net

+

Every menu-bar verb has one contextual home plus the palette. That's what lets contextual homes hide rare commands without breaking parity.

+
+
+
+ +
+
+
tap object
select  + summon inspector
+
tap empty
deselect
+
drag selected
move the object / clip
+
drag empty
pan the view (matches timeline scroll)
+
two-finger drag
pan — always, everywhere (escape valve)
+
pinch
zoom — time-zoom on timeline, spatial on stage
+
long-press object
actions menu for that object
+
long-press empty
create-here radial (proposed)
+
double-tap object
enter / edit — go a level deeper
+
double-tap empty
zoom-to-fit
+
double-tap-drag empty
marquee select — fast transient path
+
+
+
+

Two tiers for marquee

+

The double-tap-drag gesture is the fast, transient path. A sustained marquee mode covers heavy vector multi-select — and with the gesture in hand, it may not even need a dedicated button.

+
+
+

Origin disambiguates

+

Marquee is strictly empty-space-initiated. Double-tap-drag starting on an object never marquees — same spatial-zones logic as the ruler vs lanes.

+
+
+

Crisp double-tap window

+

Empty space is where both zoom-to-fit and marquee branch off the same tap-tap, so tighten the detection window there specifically.

+
+
+
+
+ + +
+
+ PLATE 07 +

Node editor — focus & patch

+ Audio synthesis · port-to-port +
+

For synthesis the graph is patch-heavy: several cables of one type running between modules. So ports are identified by name — gate, velocity, pitch — not just type; type only decides what may connect, the name decides which is which. Connections are wire-level, parallel cables between a single pair are normal, and one output can fan out. Focus mode tunes a module's parameters; patch mode runs the cables.

+ +
+
+
+
9:41⌕ ⋯ ●●●
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
+ + + + + + +
+
+
+
+
◂ gateMIDI→CV.gate
+
◂ velocityMIDI→CV.vel
+
+
+
ADSRENV · CV
+
+
Attack
+
Decay
120 ms
+
Sustain
+
+
+
env ▸VCA.in
+
+
+ Add node  ·  ⌕ Search
+
+
+
00:12:04
+ 1 + 2 +
+
Focus — one module's parameters; its named ports are travel chips. Input chips name the remote port, so two cables from the same node never collide.
+
+ +
+
+
9:41⌕ ⋯ ●●●
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
PatchMIDI→CV ▸ ADSRCV
+
pitch
→ Osc 1
+
velocity
velocity
+
gate
gate
+
tap a source port, then a target — both are CV, so the name decides the match
+
+
+
00:12:04
+ 3 +
+
Patch — two modules as port lists, each cable a row. The CV pair (gate, velocity) is just two rows; pitch fans off to the oscillator.
+
+ +
+
1
+

Ports are named, not just typed

+

Input chips read the remote endpoint as node.portMIDI→CV.gate, MIDI→CV.vel — so two cables from one node stay distinct even though both are CV. Type gates compatibility; the name carries identity.

+
+
2
+

Focus still travels & renders live

+

Tap a chip to jump to that endpoint; the minimap keeps you placed. The audio graph evaluates at the playhead, so transport stays — scrub and the envelope previews where you are.

+
+
3
+

Patch mode for cabling

+

A pair (or small cluster) shown as two port lists; each cable is a row. Parallel cables between the same pair are normal, and an output fans out (pitch → Osc). Tap a source port then a target — compatible ports light up, incompatible dim. This is the modular workflow, not one-node-at-a-time.

+
+
!
+

Honest scope

+

Phone is tune-a-module and patch-a-few; large graph restructuring stays a desktop job. Reachable for parity via the palette and these two views — without faking a tiny pannable canvas of boxes.

+
+
+
+
+ + +
+
+ PLATE 08 +

Keyboard cap & the reclaimed space

+ Portrait · keys ≤ ⅓ +
+

Key width governs playability, and width is set by the screen's short axis — so past about a third of a portrait screen, taller keys add nothing. The resize catches at that cap; pulling further reveals an instrument workspace above the keys instead. By default it's a note view of the current part, pitch-aligned to the keys; toggle it to performance controllers. Landscape has no room for this, so it's a portrait-only affordance.

+ +
+
+
+
9:41●●●
+
Stage
Time
Nodes
Mixer
Tree
+ +
+
+
DRUMS
+
BASS
+
+ +
+ Grand Piano ▾ +
NotesControllers
+ ● REC +
+ +
+ CURRENT PART · KEYS +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
2.1.03
120bpm
+ 1 + 2 + 3 +
+
Macro → micro → input: arrangement context up top, note workspace in the middle, capped keys in the thumb zone.
+
+ +
+
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
DRUMS
+
+
Grand Piano ▾
KeysNotes
● REC
+
+ PIANO ROLL · time ▸ +
+
+
+
+
+
+
+
+
2.1.03
120bpm
+ 4 +
+
Landscape · Notes — standalone, the editor flips to a conventional piano roll (pitch ↕, time ▸) to spend the wide axis on time. The context ribbon still rides on top.
+
+ +
+
1
+

The cap, and what fills it

+

Keys grow to ~⅓ then stop; continued upward drag reveals the workspace. The keyboard holds its ergonomic height — the new room opens above it. Same continuous-reveal as the timeline ribbon.

+
+
2
+

Default: see what you play

+

A note view of the current part, each column sitting over its key below (a waterfall toward the amber now line). The see-what-you're-doing principle the GarageBand port breaks — and editable in place.

+
+
3
+

Or perform: Controllers

+

Toggle the zone to pitch/mod strips, an XY pad, an arpeggiator — the other natural use of space above keys. The same drag, a different occupant.

+
+
4
+

Landscape splits them — and reflows

+

No room to stack, so keys and notes become a Keys / Notes toggle, with the context ribbon persisting in both — you lose seeing keys-and-notes together, never the when. And the note view itself reflows: Synthesia-style (pitch ↔, aligned to keys) when it sits above the keyboard in portrait; a conventional piano roll (pitch ↕, time ▸) when it's standalone in landscape and the wide axis is better spent on time. Rotate back and your part, playhead and selection carry across.

+
+
+
+
+ +
+ UNIFIED-TIMELINE STUDIO · PHONE CONCEPT PLATES 01–08 + WIREFRAME — LAYOUT & GESTURE INTENT, NOT FINAL VISUAL +
+ + +