diff --git a/Cargo.toml b/Cargo.toml
index 1291649..9660a52 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,5 +5,7 @@ edition = "2021"
 
 [dependencies]
 anyhow = "1.0.86"
+hex = "0.4.3"
 midir = "0.10.0"
+nom = "7.1.3"
 thiserror = "1.0.63"
diff --git a/src/main.rs b/src/bin/main.rs
similarity index 78%
rename from src/main.rs
rename to src/bin/main.rs
index a6f105b..1934176 100644
--- a/src/main.rs
+++ b/src/bin/main.rs
@@ -1,5 +1,4 @@
 use std::io::stdin;
-use std::io::Read;
 
 use anyhow::{anyhow, Result};
 use midir::{MidiInput, MidiInputPort};
@@ -32,7 +31,8 @@ fn run() -> Result<()> {
 }
 
 fn handle_midi_event(timestamp: u64, message: &[u8], _extra_data: &mut ()) {
-    println!("{timestamp} > {message:?}")
+    let hex_msg = hex::encode(message);
+    println!("{timestamp} > {hex_msg}");
 }
 
 /// Finds the first MIDI input port which corresponds to a physical MIDI device
@@ -40,6 +40,11 @@ fn handle_midi_event(timestamp: u64, message: &[u8], _extra_data: &mut ()) {
 fn find_first_midi_device(midi_in: &MidiInput) -> Result<MidiInputPort> {
     let ports = midi_in.ports();
 
+    // "Midi Through" is automatically created by the snd-seq-dummy kernel module.
+    // We can ignore it, since it's not a physical MIDI device, which is what we
+    // are looking for.
+    //
+    // Source: https://www.reddit.com/r/linuxaudio/comments/jsrl31/comment/gc16qwu/
     let mut physical_ports = ports.iter().filter(|p| {
         midi_in
             .port_name(p)
@@ -51,9 +56,3 @@ fn find_first_midi_device(midi_in: &MidiInput) -> Result<MidiInputPort> {
         .cloned()
         .ok_or(anyhow!("no physical MIDI devices found"))
 }
-
-// "Midi Through" is automatically created bya the snd-seq-dummy kernel module.
-// We can ignore it, since it's not a physical MIDI device, which is what we
-// are looking for.
-//
-// Source: https://www.reddit.com/r/linuxaudio/comments/jsrl31/comment/gc16qwu/
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..66200c2
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,2 @@
+pub mod midi;
+pub mod parser;
diff --git a/src/midi.rs b/src/midi.rs
new file mode 100644
index 0000000..233d310
--- /dev/null
+++ b/src/midi.rs
@@ -0,0 +1,33 @@
+
+
+#[derive(PartialEq, Eq, Debug, Clone)]
+pub enum Message {
+    Voice(VoiceMessage),
+    System(SystemMessage),
+}
+
+#[derive(PartialEq, Eq, Debug, Clone)]
+pub struct VoiceMessage {
+    pub category: VoiceCategory,
+    pub channel: u8,
+}
+
+impl VoiceMessage {
+    pub fn new(category: VoiceCategory, channel: u8) -> VoiceMessage {
+        VoiceMessage { category, channel }
+    }
+}
+
+#[derive(PartialEq, Eq, Debug, Clone)]
+pub enum VoiceCategory {
+    NoteOff { note: u8, velocity: u8 },
+    NoteOn { note: u8, velocity: u8 },
+    AfterTouch,
+    ControlChange,
+    ProgramChange,
+    ChannelPressure,
+    PitchWheel,
+}
+
+#[derive(PartialEq, Eq, Debug, Clone)]
+pub enum SystemMessage {}
diff --git a/src/parser.rs b/src/parser.rs
new file mode 100644
index 0000000..7cfebe4
--- /dev/null
+++ b/src/parser.rs
@@ -0,0 +1,65 @@
+use nom::{bytes::complete::take, IResult};
+
+use crate::midi::{Message, VoiceCategory, VoiceMessage};
+
+pub fn parse_message(bytes: &[u8]) -> IResult<&[u8], Message> {
+    let (bytes, status_byte) = take(1usize)(bytes)?;
+    let status_byte = status_byte[0];
+
+    if status_byte < 0xF0 {
+        let (bytes, vm) = parse_voice_message(status_byte, bytes)?;
+        Ok((bytes, Message::Voice(vm)))
+    } else {
+        todo!()
+    }
+}
+
+pub fn parse_voice_message(status_byte: u8, remainder: &[u8]) -> IResult<&[u8], VoiceMessage> {
+    let category_nibble = 0xf0 & status_byte;
+    let channel = 0x0f & status_byte;
+
+    let (remainder, category) = match category_nibble {
+        0x80 => parse_voice_note(remainder, true)?,
+        0x90 => parse_voice_note(remainder, false)?,
+        _ => todo!(),
+    };
+
+    Ok((remainder, VoiceMessage::new(category, channel)))
+}
+
+pub fn parse_voice_note(bytes: &[u8], off: bool) -> IResult<&[u8], VoiceCategory> {
+    let (remainder, data) = take(2usize)(bytes)?;
+
+    let note = data[0];
+    let velocity = data[1];
+
+    let category = if velocity == 0 || off {
+        VoiceCategory::NoteOff { note, velocity }
+    } else {
+        VoiceCategory::NoteOn { note, velocity }
+    };
+
+    Ok((remainder, category))
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::midi::VoiceMessage;
+
+    use super::*;
+
+    #[test]
+    fn parses_note_on_message() {
+        let msg = [0x90, 0x24, 0x51];
+        let (remainder, parsed) = parse_message(&msg).unwrap();
+
+        let note = 0x24;
+        let velocity = 0x51;
+        let expected = Message::Voice(VoiceMessage::new(
+            VoiceCategory::NoteOn { note, velocity },
+            0,
+        ));
+        assert_eq!(parsed, expected,);
+        assert_eq!(remainder.len(), 0);
+    }
+}