diff --git a/Cargo.lock b/Cargo.lock index 451aff4..97e02e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1000,7 +1000,7 @@ dependencies = [ [[package]] name = "textstep" -version = "1.0.0" +version = "1.2.3" dependencies = [ "cpal", "crossbeam-channel", diff --git a/Cargo.toml b/Cargo.toml index f5639ae..7b302db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "textstep" -version = "1.1.0" +version = "1.2.3" edition = "2024" [dependencies] diff --git a/README.md b/README.md index df1185c..49aabc5 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ 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 -- **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 +- **Dual Polyphonic Synths** — Synth A + Synth B with DJ-style crossfader, each with 2 oscillators + sub, 2 ADSR envelopes, resonant filter, dual LFOs 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 @@ -95,6 +95,9 @@ xattr -d com.apple.quarantine ./textstep | `Shift+T` | Cycle tube saturator: Off / Light / Medium / Heavy / Max | | `Shift+V` | Adjust master volume | | `<` / `>` | Swing ±5% | +| `(` / `)` | Crossfader toward A / B | +| Click `A` / `B` | Mute/unmute Synth A / B | +| Click `C` | Reset crossfader to center | ### Navigation diff --git a/assets/demo.gif b/assets/demo.gif index 2b829e1..d2a99aa 100644 Binary files a/assets/demo.gif and b/assets/demo.gif differ diff --git a/src/app.rs b/src/app.rs index 7c2fefa..85fae55 100644 --- a/src/app.rs +++ b/src/app.rs @@ -389,6 +389,13 @@ pub struct FaderDrag { pub start_value: f32, } +/// State for horizontal crossfader drag. +#[derive(Clone, Debug)] +pub struct CrossfaderDrag { + pub start_x: u16, + pub start_value: f32, +} + #[derive(Clone, Copy, Debug, PartialEq)] pub enum FaderKind { Drum, @@ -429,6 +436,10 @@ pub struct MouseState { pub synth_drag: Option, /// Active synth note length drag. pub synth_note_drag: Option, + /// Active crossfader drag (horizontal). + pub crossfader_drag: Option, + /// Last click on A/B label for double-click mute detection: (time, is_b_side). + pub last_xfade_label_click: Option<(Instant, bool)>, } impl Default for MouseState { @@ -440,6 +451,8 @@ impl Default for MouseState { compressor_drag: None, synth_drag: None, synth_note_drag: None, + crossfader_drag: None, + last_xfade_label_click: None, } } } diff --git a/src/audio/engine.rs b/src/audio/engine.rs index c529950..4732f72 100644 --- a/src/audio/engine.rs +++ b/src/audio/engine.rs @@ -124,6 +124,7 @@ pub struct AudioEngine { transport: Transport, drum_pattern: DrumPattern, master_volume: f32, + crossfader: f32, // 0.0=A, 0.5=center, 1.0=B // DSP drum_voices: [Box; 8], @@ -169,6 +170,7 @@ impl AudioEngine { transport: Transport::default(), drum_pattern: DrumPattern::default(), master_volume: 0.8, + crossfader: 0.5, drum_voices: create_drum_voices(sample_rate), synth_a: SynthInstance::new(sample_rate), synth_b: SynthInstance::new(sample_rate), @@ -243,6 +245,7 @@ impl AudioEngine { self.compressor .set_amount(ep.compressor_amount, self.sample_rate); self.master_volume = ep.master_volume; + self.crossfader = ep.crossfader; self.drum_saturator.set_drive(ep.drum_saturator_drive); self.synth_a.saturator.set_drive(ep.synth_saturator_drive); self.synth_b.saturator.set_drive(ep.synth_saturator_drive); @@ -554,9 +557,14 @@ impl AudioEngine { let reverb_out = self.drum_reverb.tick(reverb_send); let delay_out = self.drum_delay.tick(delay_send); + // Apply crossfader gain: center (0.5) = both full, extremes fade one out + let xf = self.crossfader; + let gain_a = if xf <= 0.5 { 1.0 } else { 2.0 * (1.0 - xf) }; + let gain_b = if xf >= 0.5 { 1.0 } else { 2.0 * xf }; + // Mix: per-instrument saturated signals + wet effects → headroom → master volume → compressor → clip // Both synths centered (mono to both channels) - let mono_wet = synth_a_out + synth_b_out + reverb_out + delay_out; + let mono_wet = synth_a_out * gain_a + synth_b_out * gain_b + reverb_out + delay_out; let mixed_l = (drum_sat_l + mono_wet) * 0.5 * self.master_volume; let mixed_r = (drum_sat_r + mono_wet) * 0.5 * self.master_volume; // Linked stereo compression: detect from mono sum, apply gain to both channels diff --git a/src/keys.rs b/src/keys.rs index eda26dc..e8c171d 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -273,6 +273,20 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { return; } + // Crossfader: ( / ) to move toward A / B + KeyCode::Char('(') => { + app.effect_params.crossfader = (app.effect_params.crossfader - PARAM_INCREMENT).clamp(0.0, 1.0); + app.send_effect_params(); + app.dirty = true; + return; + } + KeyCode::Char(')') => { + app.effect_params.crossfader = (app.effect_params.crossfader + PARAM_INCREMENT).clamp(0.0, 1.0); + app.send_effect_params(); + app.dirty = true; + return; + } + // Record mode toggle: backtick KeyCode::Char('`') => { app.transport.record_mode = match app.transport.record_mode { diff --git a/src/mouse.rs b/src/mouse.rs index abe7353..8788b01 100644 --- a/src/mouse.rs +++ b/src/mouse.rs @@ -6,12 +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, FaderDrag, FaderKind, FocusSection, KNOB_FIELDS, ModalState, SynthDrag, SynthNoteDrag}; +use crate::app::{App, CompressorDrag, CrossfaderDrag, 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}; +use crate::ui::transport_bar::{STATUS_PAT_OFFSET, STATUS_KIT_OFFSET, STATUS_VOL_OFFSET, STATUS_VOL_WIDTH, XFADE_OFFSET, XFADE_RAIL_WIDTH, XFADE_CENTER_COL}; /// Threshold for double-click detection. const DOUBLE_CLICK_MS: u128 = 300; @@ -51,6 +51,7 @@ pub fn handle_mouse(app: &mut App, event: MouseEvent, term_size: Rect) { app.ui.mouse.fader_drag = None; app.ui.mouse.synth_drag = None; app.ui.mouse.synth_note_drag = None; + app.ui.mouse.crossfader_drag = None; } MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { let delta: f32 = if matches!(event.kind, MouseEventKind::ScrollUp) { 0.01 } else { -0.01 }; @@ -112,6 +113,11 @@ fn handle_scroll(app: &mut App, col: u16, row: u16, delta: f32, term_size: Rect) app.effect_params.compressor_amount = (app.effect_params.compressor_amount + delta).clamp(0.0, 1.0); app.send_effect_params(); app.dirty = true; + } else if hit_test_crossfader_rail(col, row, ly.transport).is_some() { + // Scroll over crossfader rail + app.effect_params.crossfader = (app.effect_params.crossfader + delta).clamp(0.0, 1.0); + app.send_effect_params(); + app.dirty = true; } } @@ -160,6 +166,8 @@ fn handle_left_down(app: &mut App, col: u16, row: u16, term_size: Rect) { // ── Bottom / transport zones ──────────────────────────────────── } else if let Some(track) = hit_test_activity_pad(col, row, ly.activity_bar) { handle_pad_click(app, track); + } else if let Some(xfade_hit) = hit_test_crossfader(col, row, ly.transport) { + handle_crossfader_click(app, col, xfade_hit, ly.transport); } else if hit_test_compressor_gauge(col, row, ly.transport) { handle_compressor_click(app, row); } else if let Some(kind) = hit_test_volume_slider(col, row, ly.transport) { @@ -906,6 +914,16 @@ fn handle_drag(app: &mut App, col: u16, row: u16, _term_size: Rect) { return; } + // Check if dragging the crossfader (horizontal) + if let Some(ref xd) = app.ui.mouse.crossfader_drag { + let delta_x = col as f32 - xd.start_x as f32; + let new_value = (xd.start_value + delta_x / XFADE_RAIL_WIDTH as f32).clamp(0.0, 1.0); + app.effect_params.crossfader = new_value; + app.send_effect_params(); + app.dirty = true; + return; + } + // Check if dragging a synth knob if let Some(ref d) = app.ui.mouse.synth_drag { let d = d.clone(); @@ -1128,3 +1146,101 @@ fn hit_test_volume_slider(col: u16, row: u16, transport_area: Rect) -> Option u16 { + // inner_x + XFADE_OFFSET + 3(gap) + 3(" A ") + 1("├") + transport_area.x + 1 + XFADE_OFFSET + 7 +} + +/// Hit-test the crossfader on the SB status line (line 3 = transport_area.y + 3). +/// Also checks the "C" center-reset button on SA line (transport_area.y + 2). +fn hit_test_crossfader(col: u16, row: u16, transport_area: Rect) -> Option { + let sb_row = transport_area.y + 3; + let sa_row = transport_area.y + 2; + let inner_x = transport_area.x + 1; + let base = inner_x + XFADE_OFFSET; + + if row == sa_row { + // "C" button on SA line, aligned to center of rail + let c_col = inner_x + XFADE_CENTER_COL; + if col == c_col { + return Some(CrossfaderHit::CenterReset); + } + return None; + } + + if row != sb_row { + return None; + } + + // Layout on SB line: " " (3) + " A " (3) + "├" (1) + rail (28) + "┤" (1) + " B " (3) + let a_label_start = base + 3; + let rail_start = base + 7; + let rail_end = rail_start + XFADE_RAIL_WIDTH as u16; + let b_label_start = rail_end + 1; + + if col >= a_label_start && col < a_label_start + 3 { + Some(CrossfaderHit::LabelA) + } else if col >= b_label_start && col < b_label_start + 3 { + Some(CrossfaderHit::LabelB) + } else if col >= rail_start && col < rail_end { + Some(CrossfaderHit::Rail) + } else { + None + } +} + +/// Hit-test just the rail area (for scroll handling). +fn hit_test_crossfader_rail(col: u16, row: u16, transport_area: Rect) -> Option<()> { + match hit_test_crossfader(col, row, transport_area)? { + CrossfaderHit::Rail => Some(()), + _ => None, + } +} + +/// Handle click on the crossfader area. +fn handle_crossfader_click(app: &mut App, col: u16, hit: CrossfaderHit, transport_area: Rect) { + match hit { + CrossfaderHit::LabelA => { + // Single-click toggles mute + app.synth_a_pattern.params.mute = !app.synth_a_pattern.params.mute; + app.send_synth_pattern(SynthId::A); + } + CrossfaderHit::LabelB => { + app.synth_b_pattern.params.mute = !app.synth_b_pattern.params.mute; + app.send_synth_pattern(SynthId::B); + } + CrossfaderHit::CenterReset => { + // Reset crossfader to center + app.effect_params.crossfader = 0.5; + app.send_effect_params(); + app.dirty = true; + } + CrossfaderHit::Rail => { + // Click on rail: set crossfader to clicked position and start drag + let rail_start = xfade_rail_start(transport_area); + let rel = (col - rail_start) as f32 / (XFADE_RAIL_WIDTH - 1) as f32; + let new_value = rel.clamp(0.0, 1.0); + app.effect_params.crossfader = new_value; + app.send_effect_params(); + app.dirty = true; + // Start drag from this position (delta will be 0 initially) + app.ui.mouse.crossfader_drag = Some(CrossfaderDrag { + start_x: col, + start_value: new_value, + }); + } + } +} diff --git a/src/params.rs b/src/params.rs index 4732660..2c624d7 100644 --- a/src/params.rs +++ b/src/params.rs @@ -21,10 +21,13 @@ pub struct EffectParams { pub synth_saturator_drive: f32, // 0.0-1.0: synth tube saturator drive (0=off) #[serde(default = "default_drum_volume")] pub drum_volume: f32, // 0.0-1.0: drum bus output volume + #[serde(default = "default_crossfader")] + pub crossfader: f32, // 0.0-1.0: synth A/B crossfader (0=A, 0.5=center, 1=B) } fn default_master_volume() -> f32 { 0.8 } fn default_drum_volume() -> f32 { 0.8 } +fn default_crossfader() -> f32 { 0.5 } impl Default for EffectParams { fn default() -> Self { @@ -39,6 +42,7 @@ impl Default for EffectParams { drum_saturator_drive: 0.0, synth_saturator_drive: 0.0, drum_volume: 0.8, + crossfader: 0.5, } } } diff --git a/src/ui/help_overlay.rs b/src/ui/help_overlay.rs index f2b8a09..9dfefbc 100644 --- a/src/ui/help_overlay.rs +++ b/src/ui/help_overlay.rs @@ -88,7 +88,7 @@ pub fn render_help(f: &mut Frame, area: Rect) { "F2", "Toggle synths"), row3("< / >", "Swing ±5%", "~", "Spectrum/VU", - "", ""), + "( / )", "Crossfader A/B"), Line::from(Span::raw("")), hdr("Patterns & Kits", "Synth A + B Grid", "File / Project"), row3("q w e r t ..", "Pattern 1-10", @@ -113,7 +113,10 @@ pub fn render_help(f: &mut Frame, area: Rect) { "Pat/Kit/Loop", "Per-synth"), row3("", "", "Alt+Up/Dn", "Adjust+audition", - "Ctrl+P/L/J/Q", "File ops"), + "Click A/B", "Mute synth A/B"), + row3("", "", + "", "", + "Click C", "Center crossfader"), ]; let paragraph = Paragraph::new(lines).block(block); diff --git a/src/ui/splash.rs b/src/ui/splash.rs index 3aa5a22..c0b745e 100644 --- a/src/ui/splash.rs +++ b/src/ui/splash.rs @@ -28,7 +28,7 @@ const LOGO_WIDTH: u16 = (LETTER_WIDTH * 8 + LETTER_GAP * 7) as u16; // 54 const LOGO_HEIGHT: u16 = LOGO_ROWS as u16; const SUBTITLE: &str = "step sequencer + synthesizer"; -const VERSION: &str = "v0.1.0"; +const VERSION: &str = "v1.2.3"; // Step pattern displayed below the logo (16 steps) const STEP_PATTERN: [bool; 16] = [ diff --git a/src/ui/transport_bar.rs b/src/ui/transport_bar.rs index 160011b..56fc786 100644 --- a/src/ui/transport_bar.rs +++ b/src/ui/transport_bar.rs @@ -108,7 +108,7 @@ pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { }; let synth_a_kit_name = app.project.synth_kits.get(app.ui.synth_a.active_kit) .map(|k| k.name.as_str()).unwrap_or(""); - let synth_a_line = status_line( + let mut synth_a_spans = status_line( "SA", app.ui.synth_a.active_pattern, app.ui.synth_a.queued_pattern, @@ -117,7 +117,7 @@ pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { synth_a_focused, &synth_a_loop_str, app.synth_a_pattern.params.volume, - ); + ).spans; // ── Line 3: Synth B status line ─────────────────────────────── let synth_b_focused = matches!(app.ui.focus, FocusSection::SynthBGrid | FocusSection::SynthBControls); @@ -128,7 +128,7 @@ pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { }; let synth_b_kit_name = app.project.synth_kits.get(app.ui.synth_b.active_kit) .map(|k| k.name.as_str()).unwrap_or(""); - let synth_b_line = status_line( + let mut synth_b_spans = status_line( "SB", app.ui.synth_b.active_pattern, app.ui.synth_b.queued_pattern, @@ -137,7 +137,17 @@ pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { synth_b_focused, &synth_b_loop_str, app.synth_b_pattern.params.volume, - ); + ).spans; + + // Append crossfader to SB line and center-reset button to SA line + let xf = app.effect_params.crossfader; + let a_muted = app.synth_a_pattern.params.mute; + let b_muted = app.synth_b_pattern.params.mute; + synth_b_spans.extend(crossfader_spans(xf, a_muted, b_muted)); + synth_a_spans.extend(center_reset_spans()); + + let synth_a_line = Line::from(synth_a_spans); + let synth_b_line = Line::from(synth_b_spans); // ── Line 4: Drum status line ────────────────────────────────── let drum_focused = matches!(app.ui.focus, FocusSection::DrumGrid | FocusSection::Knobs); @@ -204,6 +214,96 @@ fn gauge_spans<'a>(value: f32, width: usize, fill_style: Style, empty_style: Sty } } +// ── Crossfader constants ───────────────────────────────────────────────────── + +/// Width of the crossfader rail in characters (between ├ and ┤). +pub const XFADE_RAIL_WIDTH: usize = 28; + +/// Column offset of the crossfader area from inner_x on the SB status line. +/// Status line content width: label(2) + " Pattern: "(11) + keys(19) + " │ Kit: "(8) +/// + nums(15) + " Loop[NN]"(10) + " Vol:"(6) + gauge(8) + pct(3) = 82 +pub const XFADE_OFFSET: u16 = 82; + +/// Center position of the rail (for the "C" button alignment on SA line). +/// XFADE_OFFSET + 3(gap) + 3(" A ") + 1("├") + rail_width/2 +pub const XFADE_CENTER_COL: u16 = XFADE_OFFSET + 7 + (XFADE_RAIL_WIDTH as u16) / 2; + +/// Interpolate an RGB color toward black by a factor (0.0 = full color, 1.0 = black). +fn fade_color(base: Color, fade: f32) -> Color { + let fade = fade.clamp(0.0, 1.0); + if let Color::Rgb(r, g, b) = base { + Color::Rgb( + (r as f32 * (1.0 - fade)) as u8, + (g as f32 * (1.0 - fade)) as u8, + (b as f32 * (1.0 - fade)) as u8, + ) + } else { + base + } +} + +/// Build crossfader spans: " A ├─────────────│⧫──────────────┤ B" +/// Center marker │ at the midpoint of the rail. +fn crossfader_spans(xf: f32, a_muted: bool, b_muted: bool) -> Vec> { + let xf = xf.clamp(0.0, 1.0); + + // Brightness for each label: center=1.0, opposite extreme=0.0 + let a_brightness = if xf <= 0.5 { 1.0 } else { 2.0 * (1.0 - xf) }; + let b_brightness = if xf >= 0.5 { 1.0 } else { 2.0 * xf }; + + // Amber theme: active = AMBER bg + dark text, muted = AMBER_DIM bg + dim text + let a_base_bg = if a_muted { theme::AMBER_DIM } else { theme::AMBER }; + let b_base_bg = if b_muted { theme::AMBER_DIM } else { theme::AMBER }; + let label_fg = theme::BG; // dark text on amber bg + + // Fade bg and fg toward black based on crossfader position + let a_bg = fade_color(a_base_bg, 1.0 - a_brightness); + let a_fg = fade_color(label_fg, 1.0 - a_brightness); + let b_bg = fade_color(b_base_bg, 1.0 - b_brightness); + let b_fg = fade_color(label_fg, 1.0 - b_brightness); + + // Cursor position on the rail + let cursor_pos = (xf * (XFADE_RAIL_WIDTH - 1) as f32).round() as usize; + let center_pos = XFADE_RAIL_WIDTH / 2; + + let rail_style = Style::default().fg(theme::DIM_TEXT); + let center_style = Style::default().fg(theme::BORDER); + let cursor_style = Style::default().fg(theme::AMBER).add_modifier(Modifier::BOLD); + + // Build rail as individual spans: each char styled separately for cursor + center marker + let mut rail_spans: Vec> = Vec::with_capacity(XFADE_RAIL_WIDTH); + for i in 0..XFADE_RAIL_WIDTH { + if i == cursor_pos { + rail_spans.push(Span::styled("\u{29EB}", cursor_style)); // ⧫ bowtie + } else if i == center_pos { + rail_spans.push(Span::styled("\u{2502}", center_style)); // │ center marker + } else { + rail_spans.push(Span::styled("\u{2500}", rail_style)); // ─ + } + } + + let mut spans = vec![ + Span::styled(" ", Style::default()), + Span::styled(" A ", Style::default().fg(a_fg).bg(a_bg).add_modifier(Modifier::BOLD)), + Span::styled("\u{251C}", rail_style), // ├ + ]; + spans.extend(rail_spans); + spans.push(Span::styled("\u{2524}", rail_style)); // ┤ + spans.push(Span::styled(" B ", Style::default().fg(b_fg).bg(b_bg).add_modifier(Modifier::BOLD))); + spans +} + +/// Build center-reset button spans for the SA line, padded to align with crossfader center. +/// Places "C" at the same column as the center marker on the rail below. +fn center_reset_spans() -> Vec> { + // Pad from end of status line content to XFADE_CENTER_COL - XFADE_OFFSET + let pad = (XFADE_CENTER_COL - XFADE_OFFSET) as usize; + vec![ + Span::styled(" ".repeat(pad), Style::default()), + Span::styled("C", Style::default().fg(theme::AMBER).add_modifier(Modifier::BOLD)), + ] +} + /// Pattern slot key labels (QWERTYUIOP = patterns 1-10). const PATTERN_KEYS: [&str; 10] = ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"];