Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "textstep"
version = "1.1.0"
version = "1.2.3"
edition = "2024"

[dependencies]
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Binary file modified assets/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -429,6 +436,10 @@ pub struct MouseState {
pub synth_drag: Option<SynthDrag>,
/// Active synth note length drag.
pub synth_note_drag: Option<SynthNoteDrag>,
/// Active crossfader drag (horizontal).
pub crossfader_drag: Option<CrossfaderDrag>,
/// 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 {
Expand All @@ -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,
}
}
}
Expand Down
10 changes: 9 additions & 1 deletion src/audio/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn DrumVoiceDsp>; 8],
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
120 changes: 118 additions & 2 deletions src/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1128,3 +1146,101 @@ fn hit_test_volume_slider(col: u16, row: u16, transport_area: Rect) -> Option<Fa
None
}
}

// ── Crossfader hit testing ──────────────────────────────────────────────────

/// Hit result for crossfader clicks.
#[derive(Clone, Copy, Debug)]
enum CrossfaderHit {
LabelA,
LabelB,
Rail,
CenterReset,
}

/// Compute the rail start column for the crossfader.
fn xfade_rail_start(transport_area: Rect) -> 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<CrossfaderHit> {
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,
});
}
}
}
4 changes: 4 additions & 0 deletions src/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
}
}
}
7 changes: 5 additions & 2 deletions src/ui/help_overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/ui/splash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
Expand Down
Loading
Loading