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
5 changes: 5 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}
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.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "textstep"
version = "0.1.0"
edition = "2021"
version = "1.0.0"
edition = "2024"

[dependencies]
ratatui = "0.29"
Expand Down
21 changes: 12 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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 |
|-----|--------|
Expand All @@ -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

Expand Down Expand Up @@ -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 │
Expand Down
2 changes: 2 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,8 @@ pub struct FaderDrag {
pub enum FaderKind {
Drum,
Synth,
SynthA,
SynthB,
}

/// State for synth knob drag.
Expand Down
160 changes: 92 additions & 68 deletions src/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -952,61 +985,49 @@ 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<FaderKind> {
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
}

// ── 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) {
Expand All @@ -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<FaderKind> {
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
}
}
14 changes: 7 additions & 7 deletions src/ui/help_overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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);
Expand Down
Loading
Loading