diff --git a/Cargo.lock b/Cargo.lock
index 0138827..548f911 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -20,6 +20,11 @@ dependencies = [
  "pkg-config 0.3.17 (registry+https://github.com/rust-lang/crates.io-index)",
 ]
 
+[[package]]
+name = "autocfg"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+
 [[package]]
 name = "bitflags"
 version = "0.3.3"
@@ -40,6 +45,16 @@ name = "cfg-if"
 version = "0.1.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 
+[[package]]
+name = "chrono"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "num-integer 0.1.41 (registry+https://github.com/rust-lang/crates.io-index)",
+ "num-traits 0.2.10 (registry+https://github.com/rust-lang/crates.io-index)",
+ "time 0.1.42 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
 [[package]]
 name = "core-foundation"
 version = "0.2.3"
@@ -100,6 +115,8 @@ version = "0.1.0"
 dependencies = [
  "midir 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)",
  "pancurses 0.16.1 (registry+https://github.com/rust-lang/crates.io-index)",
+ "time 0.1.42 (registry+https://github.com/rust-lang/crates.io-index)",
+ "timer 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)",
 ]
 
 [[package]]
@@ -138,6 +155,23 @@ dependencies = [
  "void 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)",
 ]
 
+[[package]]
+name = "num-integer"
+version = "0.1.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "autocfg 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)",
+ "num-traits 0.2.10 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "autocfg 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
 [[package]]
 name = "pancurses"
 version = "0.16.1"
@@ -179,6 +213,14 @@ dependencies = [
  "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)",
 ]
 
+[[package]]
+name = "timer"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "chrono 0.4.10 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
 [[package]]
 name = "void"
 version = "1.0.2"
@@ -233,10 +275,12 @@ dependencies = [
 [metadata]
 "checksum alsa 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "b4a0d4ebc8b23041c5de9bc9aee13b4bad844a589479701f31a5934cfe4aeb32"
 "checksum alsa-sys 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "b0edcbbf9ef68f15ae1b620f722180b82a98b6f0628d30baa6b8d2a5abc87d58"
+"checksum autocfg 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)" = "1d49d90015b3c36167a20fe2810c5cd875ad504b39cff3d4eae7977e6b7c1cb2"
 "checksum bitflags 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "32866f4d103c4e438b1db1158aa1b1a80ee078e5d77a59a2f906fd62a577389c"
 "checksum bitflags 0.9.1 (registry+https://github.com/rust-lang/crates.io-index)" = "4efd02e230a02e18f92fc2735f44597385ed02ad8f831e7c1c1156ee5e1ab3a5"
 "checksum cc 1.0.48 (registry+https://github.com/rust-lang/crates.io-index)" = "f52a465a666ca3d838ebbf08b241383421412fe7ebb463527bba275526d89f76"
 "checksum cfg-if 0.1.10 (registry+https://github.com/rust-lang/crates.io-index)" = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
+"checksum chrono 0.4.10 (registry+https://github.com/rust-lang/crates.io-index)" = "31850b4a4d6bae316f7a09e691c944c28299298837edc0a03f755618c23cbc01"
 "checksum core-foundation 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "25bfd746d203017f7d5cbd31ee5d8e17f94b6521c7af77ece6c9e4b2d4b16c67"
 "checksum core-foundation-sys 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "065a5d7ffdcbc8fa145d6f0746f3555025b9097a9e9cda59f7467abae670c78d"
 "checksum coremidi 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "ae2b8d4679b3a2a92b843d4c8e4a721be038ce49fce701c46296b2713f24c814"
@@ -247,11 +291,14 @@ dependencies = [
 "checksum midir 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)" = "e653b919aca8b5f2697854f1819b33bfe49bcb3378abb0dbd834ce43ede9c7b1"
 "checksum ncurses 5.99.0 (registry+https://github.com/rust-lang/crates.io-index)" = "15699bee2f37e9f8828c7b35b2bc70d13846db453f2d507713b758fabe536b82"
 "checksum nix 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "a2c5afeb0198ec7be8569d666644b574345aad2e95a53baf3a532da3e0f3fb32"
+"checksum num-integer 0.1.41 (registry+https://github.com/rust-lang/crates.io-index)" = "b85e541ef8255f6cf42bbfe4ef361305c6c135d10919ecc26126c4e5ae94bc09"
+"checksum num-traits 0.2.10 (registry+https://github.com/rust-lang/crates.io-index)" = "d4c81ffc11c212fa327657cb19dd85eb7419e163b5b076bede2bdb5c974c07e4"
 "checksum pancurses 0.16.1 (registry+https://github.com/rust-lang/crates.io-index)" = "d3058bc37c433096b2ac7afef1c5cdfae49ede0a4ffec3dfc1df1df0959d0ff0"
 "checksum pdcurses-sys 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)" = "084dd22796ff60f1225d4eb6329f33afaf4c85419d51d440ab6b8c6f4529166b"
 "checksum pkg-config 0.3.17 (registry+https://github.com/rust-lang/crates.io-index)" = "05da548ad6865900e60eaba7f589cc0783590a92e940c26953ff81ddbab2d677"
 "checksum redox_syscall 0.1.56 (registry+https://github.com/rust-lang/crates.io-index)" = "2439c63f3f6139d1b57529d16bc3b8bb855230c8efcc5d3a896c8bea7c3b1e84"
 "checksum time 0.1.42 (registry+https://github.com/rust-lang/crates.io-index)" = "db8dcfca086c1143c9270ac42a2bbd8a7ee477b78ac8e45b19abfb0cbede4b6f"
+"checksum timer 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "31d42176308937165701f50638db1c31586f183f1aab416268216577aec7306b"
 "checksum void 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)" = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
 "checksum winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)" = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a"
 "checksum winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)" = "8093091eeb260906a183e6ae1abdba2ef5ef2257a21801128899c3fc699229c6"
diff --git a/Cargo.toml b/Cargo.toml
index ddb9e70..f7b925e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,3 +9,5 @@ edition = "2018"
 [dependencies]
 midir = "0.5"
 pancurses = "0.16"
+time = "0.1"
+timer = "0.2"
diff --git a/src/main.rs b/src/main.rs
index 51277fe..393a1d1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,16 +1,62 @@
 extern crate midir;
 extern crate pancurses;
+extern crate time;
+extern crate timer;
 
 use std::error::Error;
 use std::thread;
 use std::time::{Duration, Instant};
 use std::sync::{Arc, Mutex};
 use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::mpsc::channel;
 
 use midir::os::unix::{VirtualInput, VirtualOutput};
 
 use pancurses::{initscr, endwin, noecho, Input};
 
+#[derive(Clone, Copy, Debug)]
+enum MidiMessage {
+    NoteOn(u8, u8, u8),
+    NoteOff(u8, u8, u8),
+    PitchBend(u8, i16),
+}
+
+impl MidiMessage {
+    fn from_slice(s: &[u8]) -> Option<MidiMessage> {
+        if s.len() == 0 {
+            return None;
+        }
+        let channel = s[0] & 0x0f;
+        match s[0] & 0xf0 {
+            0x80 => {
+                if s.len() == 3 {
+                    Some(MidiMessage::NoteOff(channel, s[1], s[2]))
+                } else {
+                    None
+                }
+            },
+            0x90 => {
+                if s.len() == 3 {
+                    Some(MidiMessage::NoteOn(channel, s[1], s[2]))
+                } else {
+                    None
+                }
+            },
+            0xE0 => {
+                if s.len() == 3 {
+                    let lsb = s[2] as i16;
+                    let msb = s[1] as i16;
+                    Some(MidiMessage::PitchBend(channel, (msb << 8) | lsb))
+                } else {
+                    None
+                }
+            },
+            _ => None
+        }
+    }
+}
+
+
 #[derive(Clone, Copy, Debug)]
 enum LooperState {
     Idle,
@@ -24,6 +70,7 @@ struct LooperInputCallback {
     outer_start: Arc<Mutex<Instant>>,
     ts_offset: u64,
     msg_buf: Arc<Mutex<Vec<(u64, Vec<u8>)>>>,
+    log_msgs: Box<dyn FnMut(u64, Vec<u8>) + Send>,
 }
 
 struct Looper {
@@ -35,7 +82,10 @@ struct Looper {
     msg_buf: Arc<Mutex<Vec<(u64, Vec<u8>)>>>,
     // for either playback or recording
     start_time: Arc<Mutex<Instant>>,
-    playback_thread: Option<std::thread::Thread>,
+    recording_length: u64,
+    playback_structs: Option<timer::Guard>,
+    playback_timer: timer::Timer,
+    playback_cb: Arc<Mutex<dyn FnMut(u64, MidiMessage) + Send + 'static>>,
 }
 
 impl Looper {
@@ -43,7 +93,10 @@ impl Looper {
     const RECORDING: usize = 1;
     const PLAYBACK: usize = 2;
 
-    fn new() -> Result<Looper, Box<dyn Error>> {
+    fn new<F, G>(mut record_cb: F, playback_cb: G) -> Result<Looper, Box<dyn Error>> 
+        where F: 'static + FnMut(u64, MidiMessage) + Send,
+                G: 'static + FnMut(u64, MidiMessage) + Send
+    {
         let input = midir::MidiInput::new("midi looper")?;
         let output = midir::MidiOutput::new("midi looper")?;
 
@@ -59,11 +112,11 @@ impl Looper {
             |ts, data, me| {
                 {
                     let mut out = me.output.lock().unwrap();
-                    out.send(data);
+                    out.send(data).unwrap();
                 }
+                let ts_millis = ts/1000;
                 if me.state.load(Ordering::Acquire) == Looper::RECORDING {
                     let msg_time = Instant::now();
-                    let ts_millis = ts/1000;
 
                     let mut buf = me.msg_buf.lock().unwrap();
 
@@ -78,12 +131,18 @@ impl Looper {
                     }
                     buf.push((ts_millis - me.ts_offset, data.to_vec()));
                 }
+                (me.log_msgs)(ts_millis - me.ts_offset, data.to_vec());
             }, LooperInputCallback {
                 output: virtual_output.clone(),
                 state: cur_state.clone(),
                 outer_start: start_time.clone(),
                 ts_offset: 0,
                 msg_buf: msg_buf.clone(),
+                log_msgs: Box::new(move |ts, msg| {
+                    if let Some(msg) = MidiMessage::from_slice(msg.as_slice()) {
+                        record_cb(ts, msg);
+                    }
+                }),
             })?;
 
         Ok(Looper {
@@ -93,7 +152,10 @@ impl Looper {
             cur_state: cur_state.clone(),
             msg_buf: msg_buf.clone(),
             start_time: start_time.clone(),
-            playback_thread: None,
+            playback_structs: None,
+            playback_timer: timer::Timer::new(),
+            playback_cb: Arc::new(Mutex::new(playback_cb)),
+            recording_length: 0,
         })
     }
 
@@ -108,7 +170,60 @@ impl Looper {
                 *start_time = now;
             },
             Looper::PLAYBACK => {
-                // TODO
+                let now = Instant::now();
+                if self.msg_buf.lock().unwrap().len() == 0 {
+                    // empty buffer, no messages to send
+                    return Ok(());
+                }
+                let mut start = self.start_time.lock().unwrap();
+                self.recording_length = now.duration_since(*start).as_millis() as u64;
+                *start = now;
+
+                // setup/start playback thread
+                let mut cur_tick: u64 = 0;
+                let mut cur_idx = 0;
+                let playback_buf = self.msg_buf.clone();
+                let record_len = self.recording_length;
+                let output_mutex = self.virtual_output.clone();
+                let playback_cb = self.playback_cb.clone();
+                let playback_guard = self.playback_timer.schedule_repeating(
+                    time::Duration::milliseconds(1),
+                    move || {
+                        // TODO optimize this so locks happen after
+                        // the messages are sent; this would remove
+                        // timing jitter caused by lock contention
+                        let buf = playback_buf.lock().unwrap();
+                        let (ref ts1, ref msg1) = &buf[cur_idx];
+                        let mut ts = ts1;
+                        let mut msg = msg1;
+                        if *ts == cur_tick {
+                            let mut output = output_mutex.lock().unwrap();
+                            while *ts == cur_tick {
+                                output.send(msg.as_slice()).unwrap();
+                                if let Some(msg) = MidiMessage::from_slice(msg) {
+                                    let mut cb = playback_cb.lock().unwrap();
+                                    (&mut *cb)(*ts, msg);
+                                }
+                                cur_idx += 1;
+                                if cur_idx == buf.len() {
+                                    cur_idx = 0;
+                                    break;
+                                }
+                                let (ts_next, msg_next) = &buf[cur_idx];
+                                ts = ts_next;
+                                msg = msg_next;
+                            }
+                        }
+                        cur_tick += 1;
+                        if cur_tick >= record_len {
+                            cur_tick = 0;
+                            cur_idx = 0;
+                        }
+                    });
+                self.playback_structs = Some(playback_guard);
+            },
+            Looper::IDLE => {
+                self.playback_structs = None;
             },
             _ => ()
         }
@@ -128,9 +243,8 @@ impl Looper {
 }
 
 fn main() {
-    let mut looper = Looper::new().unwrap();
-
     let window = initscr();
+
     window.printw("============================\n");
     window.printw("======== MIDI LOOPER =======\n");
     window.printw("============================\n");
@@ -145,7 +259,20 @@ fn main() {
     window.printw("exit = Esc/Ctrl-C\n");
     window.refresh();
     window.keypad(true);
+    window.nodelay(true);
     noecho();
+
+    let (tx, rx) = channel();
+    let tx_record = tx.clone();
+    let tx_playback = tx.clone();
+    let mut looper = Looper::new(move |ts, msg| {
+        tx_record.send((ts, msg)).unwrap();
+    }, move |ts, msg| {
+        tx_playback.send((ts, msg)).unwrap();
+    }).unwrap();
+    // TODO turn off delay, and sleep for 1 millisecond between getch calls
+    // TODO call refresh once every 16 frames
+    let mut count = 0;
     loop {
         match window.getch() {
             Some(Input::Character('\x1b')) => break,
@@ -168,10 +295,19 @@ fn main() {
             },
             Some(input) => {
                 window.addstr(&format!("{:?}", input));
-                window.refresh();
             },
             None => ()
         }
+        while let Ok((ts, msg)) = rx.try_recv() {
+            window.mvprintw(5, 0, format!("{:?} {}.{:03}", msg, ts/1000, ts%1000));
+        }
+
+        count = (count + 1) % 16;
+        if count == 0 {
+            window.mv(12, 0);
+            window.refresh();
+        }
+        thread::sleep(Duration::from_millis(1));
     }
     endwin();
 }