diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9030888 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "frontend-design@claude-plugins-official": true + } +} diff --git a/Cargo.lock b/Cargo.lock index e057ef4..451aff4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1000,7 +1000,7 @@ dependencies = [ [[package]] name = "textstep" -version = "0.1.0" +version = "1.0.0" dependencies = [ "cpal", "crossbeam-channel", diff --git a/Cargo.toml b/Cargo.toml index e49d00d..4dee4b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "textstep" -version = "0.1.0" -edition = "2021" +version = "1.0.0" +edition = "2024" [dependencies] ratatui = "0.29" diff --git a/README.md b/README.md index d131ad8..c3dd472 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ All DSP from scratch — no samples, no external audio libraries. Just your term [![Audio: CoreAudio](https://img.shields.io/badge/Audio-CoreAudio-green)](https://developer.apple.com/documentation/coreaudio) [![TUI: ratatui](https://img.shields.io/badge/TUI-ratatui-purple)](https://ratatui.rs/) [![Lines of Code](https://img.shields.io/badge/Lines_of_Code-17k-informational)]() -[![Tests](https://img.shields.io/badge/Tests-23_passing-brightgreen)]() +[![Tests](https://img.shields.io/badge/Tests-31_passing-brightgreen)]() ![TextStep Demo](assets/demo.gif) @@ -23,12 +23,13 @@ All DSP from scratch — no samples, no external audio libraries. Just your term ## Features - **8 Drum Tracks** — Kick, Snare, Closed HiHat, Open HiHat, Ride, Clap, Cowbell, Tom — each fully synthesized with 8 tweakable sound parameters -- **Polyphonic Synth** — 2 oscillators + sub, 2 ADSR envelopes, resonant filter, LFO with 6 waveforms, collapsible UI section +- **Dual Polyphonic Synths** — Synth A + Synth B, each with 2 oscillators + sub, 2 ADSR envelopes, resonant filter, LFO with 6 waveforms, independent patterns and kits - **32-Step Sequencer** — 10 patterns and 8 kit slots with per-pattern BPM and swing - **Send Effects Chain** — Schroeder reverb, tempo-synced filtered delay, tube saturator, SSL-style glue compressor - **Live Performance** — drum pads, real-time recording, pattern queuing, per-pattern BPM - **Mouse Support** — click the grid, drag parameters Ableton-style, audition sounds from the activity bar - **Project System** — save/load `.tsp` files, standalone kit export, preset browser +- **Collapsible Panels** — minimize/expand synth, drum knobs, and waveform sections; auto-adapts to small terminals - **Spectrum Analyzer** — real-time FFT spectrum display and VU meter with 90s Hi-Fi LED aesthetic Ships with **10 demo patterns** ready to play: House, Chicago House, Brit House, French House, Dirty House, Trance, Techno, Drum & Bass, Trap, and Moombahton. @@ -63,7 +64,7 @@ sudo apt-get install libasound2-dev # Debian/Ubuntu Run the tests: ```bash -cargo test # 23 tests, runs in <1s +cargo test # 31 tests, runs in <1s ``` ### Pre-built Binaries @@ -99,7 +100,7 @@ xattr -d com.apple.quarantine ./textstep | Arrow keys | Move cursor in grid or controls | | `Enter` | Toggle step (and advance — hold to fill) | | `;` | Cycle parameter page: SYN → AMP → FX | -| `F2` | Collapse/expand synth section | +| `F2` | Collapse/expand all synth panels (A + B) | | `~` | Toggle spectrum analyzer / VU meter | | `?` | Help overlay | @@ -129,7 +130,7 @@ With record enabled and playback running, pad hits write steps at the playhead. ### Patterns & Kits -**Patterns** — 10 slots, each with its own step data and BPM: +**Patterns** — 10 slots per section (Synth A, Synth B, Drums), each with its own step data: | Key | Action | |-----|--------| @@ -144,9 +145,11 @@ With record enabled and playback running, pad hits write steps at the playhead. |-----|--------| | `1` through `8` | Switch to kit slot | -### Synth +### Dual Synths -The synth section (toggle visibility with `F2`) provides a polyphonic synthesizer with 2 main oscillators, a sub-oscillator, noise generator, two ADSR envelopes, a 24dB resonant filter, and an LFO with 6 waveforms. Synth notes are triggered with `z` `x` `c` `v` when the synth grid is focused, with `Up/Down` for pitch and `(` `)` for octave shifts. +TextStep features two independent polyphonic synthesizers (**Synth A** and **Synth B**), each with its own patterns, kits, 2 oscillators + sub, noise, 2 ADSR envelopes, 24dB resonant filter, and LFO with 6 waveforms. Toggle all synth panels with `F2`. Each synth has its own pattern/kit selectors shown in the transport bar. + +Synth notes are triggered with `z` `x` `c` `v` when a synth grid is focused, with `Up/Down` for pitch and `(` `)` for octave shifts. ### File Operations @@ -178,8 +181,8 @@ Projects are stored as JSON in `~/Library/Application Support/textstep/projects/ │ │ │ Audio Thread │ │ ┌────────────┐ ┌──────────┐ ┌──────────────┐ │ -│ │ Sequencer │ │ Voices │ │ Effects │ │ -│ │ Clock │ │ Drum+Syn │ │ Rev/Dly/Comp │ │ +│ │ Sequencer │ │ Voices │ Effects │ │ +│ │ Clock │ │Drum+SynAB│ │ Rev/Dly/Comp │ │ │ └────────────┘ └──────────┘ └──────────────┘ │ │ │ │ │ cpal/CoreAudio │ diff --git a/src/app.rs b/src/app.rs index 05b01da..7c2fefa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -393,6 +393,8 @@ pub struct FaderDrag { pub enum FaderKind { Drum, Synth, + SynthA, + SynthB, } /// State for synth knob drag. diff --git a/src/mouse.rs b/src/mouse.rs index 6504dc3..ebd0783 100644 --- a/src/mouse.rs +++ b/src/mouse.rs @@ -6,11 +6,12 @@ use std::time::Instant; use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use crate::app::{App, CompressorDrag, DragState, DrumControlField, FocusSection, KNOB_FIELDS, ModalState, SynthDrag, SynthNoteDrag}; +use crate::app::{App, CompressorDrag, DragState, DrumControlField, FaderDrag, FaderKind, FocusSection, KNOB_FIELDS, ModalState, SynthDrag, SynthNoteDrag}; use crate::messages::{SynthId, UiToAudio}; use crate::sequencer::drum_pattern::{NUM_DRUM_TRACKS, TRACK_IDS}; use crate::sequencer::project::{NUM_KITS, NUM_PATTERNS}; use crate::ui::layout::{compute_dual_layout, DualSynthLayout}; +use crate::ui::transport_bar::{STATUS_PAT_OFFSET, STATUS_KIT_OFFSET, STATUS_VOL_OFFSET, STATUS_VOL_WIDTH}; /// Threshold for double-click detection. const DOUBLE_CLICK_MS: u128 = 300; @@ -88,6 +89,24 @@ fn handle_scroll(app: &mut App, col: u16, row: u16, delta: f32, term_size: Rect) app.send_synth_pattern(SynthId::B); app.dirty = true; } + } else if let Some(kind) = hit_test_volume_slider(col, row, ly.transport) { + // Scroll over transport volume slider + match kind { + FaderKind::SynthA => { + app.synth_a_pattern.params.volume = (app.synth_a_pattern.params.volume + delta).clamp(0.0, 1.0); + app.send_synth_pattern(SynthId::A); + } + FaderKind::SynthB => { + app.synth_b_pattern.params.volume = (app.synth_b_pattern.params.volume + delta).clamp(0.0, 1.0); + app.send_synth_pattern(SynthId::B); + } + FaderKind::Drum => { + app.effect_params.drum_volume = (app.effect_params.drum_volume + delta).clamp(0.0, 1.0); + app.send_effect_params(); + } + _ => {} + } + app.dirty = true; } else if hit_test_compressor_gauge(col, row, ly.transport) { // Scroll over compressor gauge app.effect_params.compressor_amount = (app.effect_params.compressor_amount + delta).clamp(0.0, 1.0); @@ -143,10 +162,24 @@ fn handle_left_down(app: &mut App, col: u16, row: u16, term_size: Rect) { handle_pad_click(app, track); } else if hit_test_compressor_gauge(col, row, ly.transport) { handle_compressor_click(app, row); - } else if let Some((idx, is_synth)) = hit_test_pattern_selector(col, row, ly.transport) { - if is_synth { app.queue_synth_pattern(idx); } else { app.queue_pattern(idx); } - } else if let Some((idx, is_synth)) = hit_test_kit_selector(col, row, ly.transport) { - if is_synth { app.switch_synth_kit(idx); } else { app.switch_kit(idx); } + } else if let Some(kind) = hit_test_volume_slider(col, row, ly.transport) { + handle_volume_slider_click(app, kind, row); + } else if let Some((idx, section)) = hit_test_pattern_selector(col, row, ly.transport) { + use crate::app::FaderKind; + match section { + FaderKind::SynthA => app.queue_synth_pattern_for(SynthId::A, idx), + FaderKind::SynthB => app.queue_synth_pattern_for(SynthId::B, idx), + FaderKind::Drum => app.queue_pattern(idx), + _ => {} + } + } else if let Some((idx, section)) = hit_test_kit_selector(col, row, ly.transport) { + use crate::app::FaderKind; + match section { + FaderKind::SynthA => app.switch_synth_kit_for(SynthId::A, idx), + FaderKind::SynthB => app.switch_synth_kit_for(SynthId::B, idx), + FaderKind::Drum => app.switch_kit(idx), + _ => {} + } } else if hit_test_play_button(col, row, ly.transport) { use crate::sequencer::transport::PlayState; app.transport.state = match app.transport.state { @@ -952,31 +985,29 @@ fn hit_test_record_button(col: u16, row: u16, transport_area: Rect) -> bool { } /// Returns (pattern_index, is_synth_row) if the click is on a pattern selector key. -/// Transport lines 2-3 have two rows of machine selectors: -/// Line 2 (area.y + 2): Synth | Pattern: q w e ... | Kit: 1 2 ... -/// Line 3 (area.y + 3): Drum | Pattern: q w e ... | Kit: 1 2 ... -fn hit_test_pattern_selector(col: u16, row: u16, transport_area: Rect) -> Option<(usize, bool)> { - let synth_line_y = transport_area.y + 2; - let drum_line_y = transport_area.y + 3; - - let is_synth = if row == synth_line_y { - true - } else if row == drum_line_y { - false - } else { - return None; - }; +/// Identify which status line section a row belongs to. +/// Line 2 (area.y+2) = SA, Line 3 (area.y+3) = SB, Line 4 (area.y+4) = DR. +fn status_line_section(row: u16, transport_area: Rect) -> Option { + use crate::app::FaderKind; + match row { + r if r == transport_area.y + 2 => Some(FaderKind::SynthA), + r if r == transport_area.y + 3 => Some(FaderKind::SynthB), + r if r == transport_area.y + 4 => Some(FaderKind::Drum), + _ => None, + } +} - // Row layout: "Synth │ Pat: q w e r t y u i o p │ Kit: 1 2 ..." - // "Synth " (6) + "│" (1) + " Pat: " (7) = prefix 14 +/// Transport lines 2-4 have three rows of machine selectors (SA, SB, DR): +/// "SA Pattern: q w e r t y u i o p │ Kit: 1 2 3 4 5 6 7 8 ..." +fn hit_test_pattern_selector(col: u16, row: u16, transport_area: Rect) -> Option<(usize, FaderKind)> { + let section = status_line_section(row, transport_area)?; let inner_x = transport_area.x + 1; - let pat_start = inner_x + 14; + let pat_start = inner_x + STATUS_PAT_OFFSET; - // Each pattern key: 1 char key + 1 space = 2 chars if col >= pat_start && col < pat_start + (NUM_PATTERNS as u16) * 2 { let idx = ((col - pat_start) / 2) as usize; if idx < NUM_PATTERNS { - return Some((idx, is_synth)); + return Some((idx, section)); } } None @@ -984,29 +1015,19 @@ fn hit_test_pattern_selector(col: u16, row: u16, transport_area: Rect) -> Option // ── Volume fader hit testing ───────────────────────────────────────────────── -/// Check if click is within a fader area. -/// (Currently unused — faders not in DualSynthLayout yet, kept for future re-use.) -#[allow(dead_code)] -fn hit_test_fader(col: u16, row: u16, fader_area: Rect) -> bool { - col >= fader_area.x - && col < fader_area.x + fader_area.width - && row >= fader_area.y - && row < fader_area.y + fader_area.height -} - -/// Convert a click row to a volume value (0.0 at bottom, 1.0 at top). -/// (Currently unused — faders not in DualSynthLayout yet, kept for future re-use.) -#[allow(dead_code)] -fn fader_value_from_click(row: u16, fader_area: Rect) -> f32 { - // Inner area (inside border) - let inner_top = fader_area.y + 1; - let inner_height = fader_area.height.saturating_sub(2); - if inner_height == 0 { - return 0.5; - } - let rel = row.saturating_sub(inner_top) as f32; - // Invert: top = 1.0, bottom = 0.0 - (1.0 - rel / inner_height as f32).clamp(0.0, 1.0) +/// Handle click on a transport volume slider — start drag. +fn handle_volume_slider_click(app: &mut App, kind: FaderKind, row: u16) { + let current_value = match kind { + FaderKind::SynthA => app.synth_a_pattern.params.volume, + FaderKind::SynthB => app.synth_b_pattern.params.volume, + FaderKind::Drum => app.effect_params.drum_volume, + FaderKind::Synth => app.synth_a_pattern.params.volume, // legacy fallback + }; + app.ui.mouse.fader_drag = Some(crate::app::FaderDrag { + kind, + start_y: row, + start_value: current_value, + }); } fn handle_fader_drag(app: &mut App, row: u16) { @@ -1025,40 +1046,43 @@ fn handle_fader_drag(app: &mut App, row: u16) { app.effect_params.drum_volume = new_value; app.send_effect_params(); } - FaderKind::Synth => { + FaderKind::Synth | FaderKind::SynthA => { app.synth_a_pattern.params.volume = new_value; app.send_synth_pattern(SynthId::A); } + FaderKind::SynthB => { + app.synth_b_pattern.params.volume = new_value; + app.send_synth_pattern(SynthId::B); + } } app.dirty = true; } -/// Returns (kit_index, is_synth_row) if the click is on a kit selector number. -/// Transport lines 2-3 have two rows of machine selectors: -/// Line 2 (area.y + 2): Synth | Pattern: q w e ... | Kit: 1 2 ... -/// Line 3 (area.y + 3): Drum | Pattern: q w e ... | Kit: 1 2 ... -fn hit_test_kit_selector(col: u16, row: u16, transport_area: Rect) -> Option<(usize, bool)> { - let synth_line_y = transport_area.y + 2; - let drum_line_y = transport_area.y + 3; - - let is_synth = if row == synth_line_y { - true - } else if row == drum_line_y { - false - } else { - return None; - }; - +/// Returns (kit_index, section) if the click is on a kit selector number. +fn hit_test_kit_selector(col: u16, row: u16, transport_area: Rect) -> Option<(usize, FaderKind)> { + let section = status_line_section(row, transport_area)?; let inner_x = transport_area.x + 1; - // "Synth " (6) + "│" (1) + " Pat: " (7) + 10 patterns * 2 (20) + " │" (2) + " Kit: " (7) = 43 - let kit_start = inner_x + 14 + 20 + 9; + let kit_start = inner_x + STATUS_KIT_OFFSET; - // Each kit: 1 char + 1 space = 2 chars if col >= kit_start && col < kit_start + (NUM_KITS as u16) * 2 { let idx = ((col - kit_start) / 2) as usize; if idx < NUM_KITS { - return Some((idx, is_synth)); + return Some((idx, section)); } } None } + +/// Returns FaderKind if click is on a volume slider in a status line. +fn hit_test_volume_slider(col: u16, row: u16, transport_area: Rect) -> Option { + let section = status_line_section(row, transport_area)?; + let inner_x = transport_area.x + 1; + let vol_start = inner_x + STATUS_VOL_OFFSET; + let vol_end = vol_start + STATUS_VOL_WIDTH as u16; + + if col >= vol_start && col < vol_end { + Some(section) + } else { + None + } +} diff --git a/src/ui/help_overlay.rs b/src/ui/help_overlay.rs index e7038e1..f2b8a09 100644 --- a/src/ui/help_overlay.rs +++ b/src/ui/help_overlay.rs @@ -85,12 +85,12 @@ pub fn render_help(f: &mut Frame, area: Rect) { "Alt+R", "Randomize"), row3("Shift+L", "Loop length", ";", "Page SYN/AMP/FX", - "~", "Spectrum"), + "F2", "Toggle synths"), row3("< / >", "Swing ±5%", - "", "", + "~", "Spectrum/VU", "", ""), Line::from(Span::raw("")), - hdr("Patterns & Kits", "Synth Grid", "File / Project"), + hdr("Patterns & Kits", "Synth A + B Grid", "File / Project"), row3("q w e r t ..", "Pattern 1-10", "Up / Down", "Pitch ±semitone", "Ctrl+S", "Save project"), @@ -104,16 +104,16 @@ pub fn render_help(f: &mut Frame, area: Rect) { "z x c v ..", "Synth notes", "Ctrl+K", "Save kit"), Line::from(Span::raw("")), - hdr("Drum Pads", "Synth Knobs", ""), + hdr("Drum Pads", "Synth Knobs", "Dual Synth"), row3("z x c v b n m ,", "Trigger drums", "Arrows", "Navigate grid", - "Ctrl+J", "Load kit"), + "Tab to SA/SB", "Focus synth"), row3("(rec + play)", "Write at play", "Shift+Up/Dn", "Adjust value", - "Ctrl+P/L", "Sound/Pat preset"), + "Pat/Kit/Loop", "Per-synth"), row3("", "", "Alt+Up/Dn", "Adjust+audition", - "Ctrl+Q / C", "Quit"), + "Ctrl+P/L/J/Q", "File ops"), ]; let paragraph = Paragraph::new(lines).block(block); diff --git a/src/ui/transport_bar.rs b/src/ui/transport_bar.rs index 9c3104f..160011b 100644 --- a/src/ui/transport_bar.rs +++ b/src/ui/transport_bar.rs @@ -116,6 +116,7 @@ pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { synth_a_kit_name, synth_a_focused, &synth_a_loop_str, + app.synth_a_pattern.params.volume, ); // ── Line 3: Synth B status line ─────────────────────────────── @@ -135,6 +136,7 @@ pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { synth_b_kit_name, synth_b_focused, &synth_b_loop_str, + app.synth_b_pattern.params.volume, ); // ── Line 4: Drum status line ────────────────────────────────── @@ -153,6 +155,7 @@ pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { drum_kit_name, drum_focused, &drum_loop_str, + app.effect_params.drum_volume, ); // ── Line 5: Master gauges ──────────────────────────────────── @@ -204,8 +207,21 @@ fn gauge_spans<'a>(value: f32, width: usize, fill_style: Style, empty_style: Sty /// Pattern slot key labels (QWERTYUIOP = patterns 1-10). const PATTERN_KEYS: [&str; 10] = ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"]; -/// Build a status line with pattern/kit selectors: -/// SA | Pattern: q w e r t y u i o p | Kit: 1 2 3 4 5 6 7 8 +/// Width of the volume slider in the status line (in characters). +pub const STATUS_VOL_WIDTH: usize = 8; + +// ── Status line column offsets (from inner_x) for mouse hit-testing ────────── +// Format: "SA Pattern: q w e r t y u i o p │ Kit: 1 2 3 4 5 6 7 8 Loop[NN] Vol:████░░░░" +// 2 11 19 8 15 10 6 8 +/// Column offset of pattern keys from inner_x. +pub const STATUS_PAT_OFFSET: u16 = 13; // label(2) + " Pattern: "(11) +/// Column offset of kit numbers from inner_x. +pub const STATUS_KIT_OFFSET: u16 = 40; // PAT(13) + keys(19) + " │ Kit: "(8) +/// Column offset of volume slider bar from inner_x. +pub const STATUS_VOL_OFFSET: u16 = 71; // KIT(40) + nums(15) + " Loop[NN]"(10=2+8pad) + " Vol:"(6) + +/// Build a status line with pattern/kit selectors and volume slider: +/// SA Pattern: q w e r t y u i o p │ Kit: 1 2 3 4 5 6 7 8 Loop[32] Vol:████░░░░ fn status_line<'a>( label: &str, active_pattern: usize, @@ -214,6 +230,7 @@ fn status_line<'a>( _kit_name: &str, is_focused: bool, loop_info: &str, + volume: f32, ) -> Line<'a> { let mut spans: Vec> = Vec::new(); @@ -261,7 +278,20 @@ fn status_line<'a>( } else { Style::default().fg(theme::TEXT) }; - spans.push(Span::styled(format!(" {}", loop_info), loop_style)); + spans.push(Span::styled(format!(" {:<8}", loop_info), loop_style)); + + // Volume slider: amber fill (same theme as master gauges) + numeric value + spans.push(Span::styled(" Vol:", Style::default().fg(theme::DIM_TEXT))); + spans.push(gauge_spans( + volume, STATUS_VOL_WIDTH, + Style::default().fg(theme::AMBER), + Style::default().fg(theme::SURFACE), + )); + let pct = (volume.clamp(0.0, 1.0) * 100.0).round() as u8; + spans.push(Span::styled( + format!("{:>3}", pct), + Style::default().fg(theme::TEXT), + )); Line::from(spans) }