DEV Community

Ojalla
Ojalla

Posted on

Building Cliano: A Terminal Piano in Rust

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 
Enter fullscreen mode Exit fullscreen mode

Crate Dependencies

[dependencies] rodio = "0.17" crossterm = "0.27" clap = { version = "4.1", features = ["derive"] } 
Enter fullscreen mode Exit fullscreen mode

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(); 
Enter fullscreen mode Exit fullscreen mode

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); } 
Enter fullscreen mode Exit fullscreen mode

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); } } } 
Enter fullscreen mode Exit fullscreen mode

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); } } } } 
Enter fullscreen mode Exit fullscreen mode

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'), ]) } 
Enter fullscreen mode Exit fullscreen mode

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 or egui
  • 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)