diff --git a/assets/windsynth.log b/assets/windsynth.log
new file mode 100644
index 0000000..2df753f
Binary files /dev/null and b/assets/windsynth.log differ
diff --git a/src/bin/main.rs b/src/bin/main.rs
index 7d67708..a90e2b4 100644
--- a/src/bin/main.rs
+++ b/src/bin/main.rs
@@ -1,13 +1,17 @@
-use std::io::stdin;
+use std::{
+    fs::File,
+    io::{stdin, Read, Write},
+};
 
 use anyhow::{anyhow, Result};
-use enigo::{Direction, Enigo, Key, Keyboard, Settings};
+use midi_keys::{log::load_raw_log, parser::parse_message};
+//use enigo::{Direction, Enigo, Key, Keyboard, Settings};
 use midir::{MidiInput, MidiInputPort};
 
 fn main() {
-    let mut enigo = Enigo::new(&Settings::default()).unwrap();
-    enigo.text("echo \"hello world\"").unwrap();
-    enigo.key(Key::Return, Direction::Press).unwrap();
+    //let mut enigo = Enigo::new(&Settings::default()).unwrap();
+    //enigo.text("echo \"hello world\"").unwrap();
+    //enigo.key(Key::Return, Direction::Press).unwrap();
 
     match run() {
         Ok(_) => {}
@@ -20,10 +24,23 @@ fn main() {
 fn run() -> Result<()> {
     let midi_in = MidiInput::new("keyboard")?;
 
-    let midi_device = find_first_midi_device(&midi_in)?;
+    let midi_device = match find_first_midi_device(&midi_in) {
+        Ok(m) => m,
+        Err(e) => {
+            println!("error: {}", e);
+            return replay_file("assets/windsynth.log");
+        }
+    };
     let port_name = midi_in.port_name(&midi_device)?;
 
-    let _midi_connection = match midi_in.connect(&midi_device, &port_name, handle_midi_event, ()) {
+    let output_file = File::options().append(true).create(true).open("midi.log")?;
+
+    let _midi_connection = match midi_in.connect(
+        &midi_device,
+        &port_name,
+        handle_midi_event,
+        Some(output_file),
+    ) {
         Ok(m) => m,
         Err(err) => return Err(anyhow!("failed to connect to device: {}", err)),
     };
@@ -35,9 +52,31 @@ fn run() -> Result<()> {
     Ok(())
 }
 
-fn handle_midi_event(timestamp: u64, message: &[u8], _extra_data: &mut ()) {
+fn replay_file(filename: &str) -> Result<()> {
+    let entries = load_raw_log(filename)?;
+    for entry in entries {
+        handle_midi_event(entry.ts, &entry.message, &mut None);
+    }
+    Ok(())
+}
+
+fn handle_midi_event(timestamp: u64, message: &[u8], file: &mut Option<File>) {
+    if let Some(file) = file {
+        let ts_buf = timestamp.to_be_bytes();
+        let len_buf = message.len().to_be_bytes();
+
+        file.write(&ts_buf).unwrap();
+        file.write(&len_buf).unwrap();
+        file.write(message).unwrap();
+    }
+
     let hex_msg = hex::encode(message);
-    println!("{timestamp} > {hex_msg}");
+    let msg = match parse_message(message) {
+        Ok((_n, msg)) => format!("{:?}", msg),
+        Err(_) => "failed to parse message".to_string(),
+    };
+
+    println!("{timestamp} > {hex_msg} - {msg}");
 }
 
 /// Finds the first MIDI input port which corresponds to a physical MIDI device
diff --git a/src/lib.rs b/src/lib.rs
index 66200c2..535cd1e 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,2 +1,3 @@
+pub mod log;
 pub mod midi;
 pub mod parser;
diff --git a/src/log.rs b/src/log.rs
new file mode 100644
index 0000000..fea66b2
--- /dev/null
+++ b/src/log.rs
@@ -0,0 +1,51 @@
+use anyhow::{anyhow, Result};
+use std::{fs::File, io::Read};
+
+pub struct LogEntry {
+    pub ts: u64,
+    pub message: Vec<u8>,
+}
+
+pub fn load_raw_log(filename: &str) -> Result<Vec<LogEntry>> {
+    let mut file = File::options()
+        .append(true)
+        .create(true)
+        .read(true)
+        .open(filename)?;
+
+    let mut ts_buf: [u8; 8] = [0_u8; 8];
+    let mut len_buf: [u8; 8] = [0_u8; 8];
+
+    let mut entries = vec![];
+
+    loop {
+        match file.read(&mut ts_buf) {
+            Ok(0) => return Ok(entries),
+            Ok(8) => {}
+            Ok(n) => return Err(anyhow!("got {} of expected 8 bytes for timestamp", n)),
+            Err(e) => return Err(e.into()),
+        };
+        file.read_exact(&mut len_buf)?;
+
+        let ts = u64::from_be_bytes(ts_buf);
+        let len = usize::from_be_bytes(len_buf);
+
+        let mut message = vec![0_u8; len];
+        file.read_exact(message.as_mut_slice())?;
+
+        entries.push(LogEntry { ts, message });
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    pub fn reads_entries_from_windsynth_log() {
+        let filename = "assets/windsynth.log";
+        let entries = load_raw_log(filename).unwrap();
+
+        assert_eq!(1260, entries.len());
+    }
+}
diff --git a/src/midi.rs b/src/midi.rs
index 233d310..d7a0e9e 100644
--- a/src/midi.rs
+++ b/src/midi.rs
@@ -1,5 +1,3 @@
-
-
 #[derive(PartialEq, Eq, Debug, Clone)]
 pub enum Message {
     Voice(VoiceMessage),
@@ -23,10 +21,10 @@ pub enum VoiceCategory {
     NoteOff { note: u8, velocity: u8 },
     NoteOn { note: u8, velocity: u8 },
     AfterTouch,
-    ControlChange,
-    ProgramChange,
+    ControlChange { controller: u8, value: u8 },
+    ProgramChange { value: u8 },
     ChannelPressure,
-    PitchWheel,
+    PitchWheel { value: u16 },
 }
 
 #[derive(PartialEq, Eq, Debug, Clone)]
diff --git a/src/parser.rs b/src/parser.rs
index 7cfebe4..c7f040a 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -1,6 +1,6 @@
 use nom::{bytes::complete::take, IResult};
 
-use crate::midi::{Message, VoiceCategory, VoiceMessage};
+use crate::midi::{Message, SystemMessage, VoiceCategory, VoiceMessage};
 
 pub fn parse_message(bytes: &[u8]) -> IResult<&[u8], Message> {
     let (bytes, status_byte) = take(1usize)(bytes)?;
@@ -10,18 +10,35 @@ pub fn parse_message(bytes: &[u8]) -> IResult<&[u8], Message> {
         let (bytes, vm) = parse_voice_message(status_byte, bytes)?;
         Ok((bytes, Message::Voice(vm)))
     } else {
-        todo!()
+        let (bytes, sm) = parse_status_message(status_byte, bytes)?;
+        Ok((bytes, Message::System(sm)))
     }
 }
 
+fn parse_status_message(_status_byte: u8, bytes: &[u8]) -> IResult<&[u8], SystemMessage> {
+    return Err(nom::Err::Error(nom::error::Error {
+        input: bytes,
+        code: nom::error::ErrorKind::Fail,
+    }));
+}
+
 pub fn parse_voice_message(status_byte: u8, remainder: &[u8]) -> IResult<&[u8], VoiceMessage> {
     let category_nibble = 0xf0 & status_byte;
     let channel = 0x0f & status_byte;
 
+    println!("category_nibble = {:#x}", category_nibble);
     let (remainder, category) = match category_nibble {
         0x80 => parse_voice_note(remainder, true)?,
         0x90 => parse_voice_note(remainder, false)?,
-        _ => todo!(),
+        0xb0 => parse_control_change(remainder)?,
+        0xc0 => parse_program_change(remainder)?,
+        0xe0 => parse_pitch_wheel(remainder)?,
+        _ => {
+            return Err(nom::Err::Error(nom::error::Error {
+                input: remainder,
+                code: nom::error::ErrorKind::Fail,
+            }))
+        }
     };
 
     Ok((remainder, VoiceMessage::new(category, channel)))
@@ -42,9 +59,50 @@ pub fn parse_voice_note(bytes: &[u8], off: bool) -> IResult<&[u8], VoiceCategory
     Ok((remainder, category))
 }
 
+pub fn generic_error(bytes: &[u8]) -> nom::Err<nom::error::Error<&[u8]>> {
+    nom::Err::Error(nom::error::Error {
+        input: bytes,
+        code: nom::error::ErrorKind::Fail,
+    })
+}
+
+pub fn parse_pitch_wheel(bytes: &[u8]) -> IResult<&[u8], VoiceCategory> {
+    if bytes.len() < 2 {
+        return Err(generic_error(bytes));
+    }
+
+    let (db1, db2) = (bytes[0], bytes[1]);
+    let value = ((db1 as u16) << 7) | db2 as u16;
+
+    Ok((&bytes[2..], VoiceCategory::PitchWheel { value }))
+}
+
+pub fn parse_control_change(bytes: &[u8]) -> IResult<&[u8], VoiceCategory> {
+    if bytes.len() < 2 {
+        return Err(generic_error(bytes));
+    }
+
+    let controller = bytes[0];
+    let value = bytes[1];
+
+    Ok((
+        &bytes[2..],
+        VoiceCategory::ControlChange { controller, value },
+    ))
+}
+
+pub fn parse_program_change(bytes: &[u8]) -> IResult<&[u8], VoiceCategory> {
+    if bytes.len() < 1 {
+        return Err(generic_error(bytes));
+    }
+
+    let value = bytes[0];
+
+    Ok((&bytes[1..], VoiceCategory::ProgramChange { value }))
+}
 #[cfg(test)]
 mod tests {
-    use crate::midi::VoiceMessage;
+    use crate::{log::load_raw_log, midi::VoiceMessage};
 
     use super::*;
 
@@ -62,4 +120,26 @@ mod tests {
         assert_eq!(parsed, expected,);
         assert_eq!(remainder.len(), 0);
     }
+
+    #[test]
+    fn parse_log_from_windsynth() {
+        // I played a few notes on my Roland AE-20 and saved it to a log file.
+        // This test just reads that in and ensures that everything parses. It
+        // doesn't check for *correct* parsing, but this is sufficient for much
+        // of our needs.
+
+        let filename = "assets/windsynth.log";
+        let entries = load_raw_log(filename).unwrap();
+
+        assert_eq!(1260, entries.len());
+        for entry in entries {
+            let parsed = parse_message(&entry.message);
+            assert!(
+                parsed.is_ok(),
+                "failed to parse message: {:?}; {:?}",
+                &entry.message,
+                parsed
+            );
+        }
+    }
 }