Cliano: A Terminal Piano in Rust
Cliano is a fun, minimal terminal-based piano application built entirely in Rust. It lets you press keyboard keys to play piano notes in real time. This project was created to keep my Rust skills sharp and explore audio programming in a command-line interface.
Features
- Real-time audio playback with keypresses
- Loads WAV files into memory to reduce latency
- Terminal-based UI using Crossterm
- Built with Rodio, Crossterm, and Clap
File Structure
. ├── sounds/ # Contains WAV files of piano notes ├── src/ │ └── main.rs # Main application logic ├── Cargo.toml # Project configuration
Crate Dependencies
[dependencies] rodio = "0.17" crossterm = "0.27" clap = { version = "4.1", features = ["derive"] }
Step-by-Step Breakdown
1. Initialization
We start by initializing the Rodio audio output stream and sink:
let (stream, stream_handle) = OutputStream::try_default().unwrap(); let sink = Sink::try_new(&stream_handle).unwrap();
This sink handles audio playback. We also store a note_key_map
which maps keyboard characters to in-memory audio buffers.
2. Loading Audio Files
fn load_keys(&mut self, dir_name: &str) { let mut sounds = HashMap::new(); for entry in fs::read_dir(dir_name).unwrap() { let path = entry.unwrap().path(); let file = File::open(&path).unwrap(); let mut buffer = Vec::new(); BufReader::new(file).read_to_end(&mut buffer).unwrap(); // Mapping based on filename to key if let Some(&key) = file_to_key_map().get(path.file_name().unwrap().to_str().unwrap()) { sounds.insert(key, buffer); } } self.note_key_map = Some(sounds); }
We read WAV files from the sounds/
directory, store them in memory, and map them to keys like 'a', 's', 'd', etc.
3. Playing Notes
When a key is pressed, we decode the associated byte buffer and play it:
fn handle_keys(&mut self, key: char) { if let Some(ref map) = self.note_key_map { if let Some(data) = map.get(&key) { let cursor = Cursor::new(data.clone()); let source = Decoder::new(BufReader::new(cursor)).unwrap() .take_duration(Duration::from_secs(5)); self.audio_sink.stop(); self.audio_sink = Sink::try_new(&self.output_stream_handle).unwrap(); self.audio_sink.append(source); } } }
This keeps playback smooth by avoiding cold-loading audio on each press.
4. Handling Key Events
We use Crossterm to listen for keyboard input in real time:
if poll(Duration::from_millis(100)).unwrap() { if let Event::Key(event) = read().unwrap() { if event.kind == KeyEventKind::Press { if let KeyCode::Char(c) = event.code { self.handle_keys(c); } } } }
5. Key-to-File Mapping
fn file_to_key_map() -> HashMap<&'static str, char> { HashMap::from([ ("C4.wav", 'a'), ("Cb4.wav", 'w'), ("D4.wav", 's'), ("Db4.wav", 'e'), ("E4.wav", 'd'), ("F4.wav", 'f'), ("Fb4.wav", 't'), ("G4.wav", 'g'), ("Gb4.wav", 'y'), ("A4.wav", 'h'), ("Ab4.wav", 'u'), ("B4.wav", 'j'), ("C5.wav", 'k'), ]) }
You can modify this mapping to suit any custom layout.
Performance Note
Cliano uses about ~54MB of memory on Windows, mostly from storing all audio files in memory to avoid latency.
Final Thoughts
This was a blast to build. Sometimes the best way to learn (or remember) a language is to make something fun. You can find the full codebase on GitHub.
Future Improvements
- Visual UI using
ratatui
oregui
- Sustain key
- Recording/playback
- Octave switching
Thanks for reading!
If you want to see a live demo, check out the video:
https://youtu.be/Fppt16VJ108
Top comments (0)