diff --git a/components/badge23/CMakeLists.txt b/components/badge23/CMakeLists.txt
index 4c394395e8db8e5069bb810c3e3a7325e826d30c..23ad0f3837614658c140b5f3e78d7d0b7c312884 100644
--- a/components/badge23/CMakeLists.txt
+++ b/components/badge23/CMakeLists.txt
@@ -5,7 +5,6 @@ idf_component_register(
         captouch.c
         espan.c
         leds.c
-        scope.c
         synth.c
         spio.c
     INCLUDE_DIRS
diff --git a/components/badge23/audio.c b/components/badge23/audio.c
index c721c8756152e8d87cf4131b3b1acbce85b26bc5..9e0852547da218cc389fc746207d4c07b30b5e1c 100644
--- a/components/badge23/audio.c
+++ b/components/badge23/audio.c
@@ -1,8 +1,9 @@
 #include "badge23/audio.h"
 #include "badge23/synth.h" 
-#include "badge23/scope.h"
 #include "badge23/lock.h"
 
+#include "st3m_scope.h"
+
 #include "driver/i2s.h"
 #include "driver/i2c.h"
 
@@ -584,8 +585,9 @@ uint16_t count_audio_sources(){
 }
 
 static void _audio_init(void) {
+    st3m_scope_init();
+
     // TODO: this assumes I2C is already initialized
-    init_scope(241);
     i2s_init();
     audio_update_jacksense();
     //ESP_ERROR_CHECK(i2s_channel_enable(tx_chan));
@@ -606,7 +608,7 @@ static void audio_player_task(void* arg) {
                 sample += (*(audio_source->render_function))(audio_source->render_data);
                 audio_source = audio_source->next;
             }
-            write_to_scope((int16_t) (1600. * sample));
+            st3m_scope_write((int16_t) (1600. * sample));
             sample = software_volume * (sample/10);
             if(sample > 32767) sample = 32767;
             if(sample < -32767) sample = -32767;
diff --git a/components/badge23/include/badge23/scope.h b/components/badge23/include/badge23/scope.h
deleted file mode 100644
index fe21205194e6939af6d740bea809d0e7c05d3799..0000000000000000000000000000000000000000
--- a/components/badge23/include/badge23/scope.h
+++ /dev/null
@@ -1,16 +0,0 @@
-#pragma once
-#include <stdint.h>
-
-typedef struct {
-    int16_t * buffer;
-    int16_t buffer_size;
-    int16_t write_head_position; // last written value
-    // atomic value used to prevent simultaneous reads/writes (not a mutex!).
-    volatile uint32_t is_being_read;
-} scope_t;
-
-void init_scope(uint16_t size);
-void write_to_scope(int16_t value);
-void begin_scope_read();
-void end_scope_read();
-void read_line_from_scope(uint16_t * line, int16_t point);
diff --git a/components/badge23/scope.c b/components/badge23/scope.c
deleted file mode 100644
index ac78a0f6aa79787c78d3b8d8f21e99bc6370e65b..0000000000000000000000000000000000000000
--- a/components/badge23/scope.c
+++ /dev/null
@@ -1,88 +0,0 @@
-#include "badge23/scope.h"
-#include "esp_log.h"
-#include <string.h>
-#include <freertos/FreeRTOS.h>
-#include <freertos/atomic.h>
-
-scope_t * scope = NULL;
-static const char* TAG = "scope";
-
-void init_scope(uint16_t size){
-    if (scope != NULL) {
-        return;
-    }
-
-    scope_t * scp = malloc(sizeof(scope_t));
-    if(scp == NULL) {
-        ESP_LOGE(TAG, "scope allocation failed, out of memory");
-        return;
-    }
-    scp->buffer_size = size;
-    scp->buffer = malloc(sizeof(int16_t) * scp->buffer_size);
-    if(scp->buffer == NULL){
-        free(scp);
-        ESP_LOGE(TAG, "scope buffer allocation failed, out of memory");
-        return;
-    }
-    memset(scp->buffer, 0, sizeof(int16_t) * scp->buffer_size);
-    scope = scp;
-    ESP_LOGI(TAG, "initialized");
-}
-
-static inline bool scope_being_read(void) {
-    return Atomic_CompareAndSwap_u32(&scope->is_being_read, 0, 0) == ATOMIC_COMPARE_AND_SWAP_FAILURE;
-}
-
-void write_to_scope(int16_t value){
-    if(scope == NULL || scope_being_read()) {
-        return;
-    }
-
-    scope->write_head_position++;
-    if(scope->write_head_position >= scope->buffer_size) scope->write_head_position = 0;
-    scope->buffer[scope->write_head_position] = value;
-}
-
-void begin_scope_read(void) {
-    if (scope == NULL) {
-        return;
-    }
-    Atomic_Increment_u32(&scope->is_being_read);
-}
-
-void read_line_from_scope(uint16_t * line, int16_t point){
-    if (scope == NULL) {
-        return;
-    }
-    memset(line, 0, 480);
-    int16_t startpoint = scope->write_head_position - point;
-    while(startpoint < 0){
-        startpoint += scope->buffer_size;
-    }
-    int16_t stoppoint = startpoint - 1;
-    if(stoppoint < 0){
-        stoppoint += scope->buffer_size;
-    }
-    int16_t start = (scope->buffer[point]/32 + 120);
-    int16_t stop = (scope->buffer[point+1]/32 + 120);
-    if(start>240)   start = 240;
-    if(start<0)     start = 0;
-    if(stop>240)    stop = 240;
-    if(stop<0)     stop = 0;
-
-    if(start > stop){
-        int16_t inter = start;
-        start = stop;
-        stop = inter;
-    }
-    for(int16_t i = start; i <= stop; i++){
-        line[i] = 255;
-    }
-}
-
-void end_scope_read(void) {
-    if (scope == NULL) {
-        return;
-    }
-    Atomic_Decrement_u32(&scope->is_being_read);
-}
diff --git a/components/st3m/CMakeLists.txt b/components/st3m/CMakeLists.txt
index 2504ce806a0d2273bc21defa403e4debcdafd25b..ec395c2c44e78dcbe08be49fc97eab37061bc60f 100644
--- a/components/st3m/CMakeLists.txt
+++ b/components/st3m/CMakeLists.txt
@@ -3,6 +3,7 @@ idf_component_register(
         st3m_gfx.c
         st3m_counter.c
         st3m_fs.c
+        st3m_scope.c
     INCLUDE_DIRS
         .
     REQUIRES
diff --git a/components/st3m/st3m_scope.c b/components/st3m/st3m_scope.c
new file mode 100644
index 0000000000000000000000000000000000000000..b9a21284c178370b7d44c69eb261b66eeac24044
--- /dev/null
+++ b/components/st3m/st3m_scope.c
@@ -0,0 +1,119 @@
+#include "st3m_scope.h"
+
+#include <string.h>
+
+#include "esp_log.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/atomic.h"
+
+#include "ctx_config.h"
+#include "ctx.h"
+
+st3m_scope_t scope = { 0, };
+static const char* TAG = "st3m-scope";
+
+void st3m_scope_init(void){
+    if (scope.write_buffer != NULL) {
+        return;
+    }
+
+    scope.buffer_size = 240;
+    scope.write_buffer = malloc(sizeof(int16_t) * scope.buffer_size);
+    scope.exchange_buffer = malloc(sizeof(int16_t) * scope.buffer_size);
+    scope.read_buffer = malloc(sizeof(int16_t) * scope.buffer_size);
+
+    if (scope.write_buffer == NULL || scope.exchange_buffer == NULL || scope.read_bufer == NULL) {
+        if (scope.write_buffer != NULL) {
+            free(scope.write_buffer);
+            scope.write_buffer = NULL;
+        }
+        if (scope.exchange_buffer != NULL) {
+            free(scope.exchange_buffer);
+            scope.exchange_buffer = NULL;
+        }
+        if (scope.read_buffer != NULL) {
+            free(scope.read_buffer);
+            scope.read_buffer = NULL;
+        }
+        ESP_LOGE(TAG, "out of memory");
+        return;
+    }
+
+    memset(scope.write_buffer, 0, sizeof(int16_t) * scope.buffer_size);
+    memset(scope.exchange_buffer, 0, sizeof(int16_t) * scope.buffer_size);
+    memset(scope.read_buffer, 0, sizeof(int16_t) * scope.buffer_size);
+
+
+	scope.write_head_position = 0;
+    scope.prev_write_attempt = 0;
+    ESP_LOGI(TAG, "initialized");
+}
+
+void st3m_scope_write(int16_t value){
+    if(scope.write_buffer == NULL) {
+        return;
+    }
+
+    int16_t prev_write_attempt = scope.prev_write_attempt;
+    scope.prev_write_attempt = value;
+
+    // If we're about to write the first sample, make sure we do so at a
+    // positive zero crossing.
+    if (scope.write_head_position == 0) {
+        // Calculate 'positivity' sign of this value and previous value.
+        int16_t this = value > 0;
+        int16_t prev = prev_write_attempt > 0;
+
+        if (this != 1 || prev != 0) {
+            return;
+        }
+    }
+
+    if (scope.write_head_position >= scope.buffer_size) {
+        scope.write_buffer = Atomic_SwapPointers_p32(&scope.exchange_buffer, scope.write_buffer);
+        scope.write_head_position = 0;
+    } else {
+        scope.write_buffer[scope.write_head_position] = value;
+    	scope.write_head_position++;
+    }
+}
+
+void st3m_scope_draw(Ctx *ctx){
+    if (scope.write_buffer == NULL) {
+        return;
+    }
+
+    scope.read_buffer = Atomic_SwapPointers_p32(&scope.exchange_buffer, scope.read_buffer);
+
+
+	// How much to divide the values persisted in the buffer to scale to
+	// -120+120.
+    //
+    // shift == 5 -> division by 2^5 -> division by 32. This seems to work for
+    // the value currently emitted by the audio stack.
+    int16_t shift = 5;
+
+	// How many samples to skip for each drawn line segment.
+    //
+    // decimate == 1 works, but is a waste of CPU cycles both at draw and
+    // rasterization time.
+    //
+    // decimate == 2 -> every second sample is drawn (240/2 == 120 line segments
+    // are drawn). Looks good enough.
+    size_t decimate = 2;
+
+    int x = -120;
+    int y = scope.read_buffer[0] >> shift;
+	ctx_move_to(ctx, x, y);
+    for (size_t i = 1; i < scope.buffer_size; i += decimate) {
+        x += decimate;
+        int y = scope.read_buffer[i] >> shift;
+        ctx_line_to(ctx, x, y);
+    }
+
+    ctx_line_to(ctx, 130, 0);
+    ctx_line_to(ctx, 130, -130);
+    ctx_line_to(ctx, -130, -130);
+    ctx_line_to(ctx, -130, 0);
+
+}
diff --git a/components/st3m/st3m_scope.h b/components/st3m/st3m_scope.h
new file mode 100644
index 0000000000000000000000000000000000000000..fc8d4074e6d773b59d76676cf1288c43eb7e1923
--- /dev/null
+++ b/components/st3m/st3m_scope.h
@@ -0,0 +1,51 @@
+#pragma once
+
+// st3m_scope implements an oscilloscope-style music visualizer.
+//
+// The audio subsystem will continuously send the global mixing output result
+// into the oscilloscope. User code can decide when to draw said scope.
+
+#include <stdint.h>
+
+#include "flow3r_bsp.h"
+#include "st3m_gfx.h"
+
+typedef struct {
+	// Scope buffer size, in samples. Currently always 240 (same as screen
+	// width).
+    size_t buffer_size;
+
+	// Triple-buffering for lockless exhange between free-running writer and
+	// reader. The exchange buffer is swapped to/from by the reader/writer
+	// whenever they're done with a whole sample buffer.
+	int16_t *write_buffer;
+	int16_t *exchange_buffer;
+	int16_t *read_buffer;
+
+	// Offset where the write handler should write the next sample.
+    uint32_t write_head_position;
+	// Previous sample that was attempted to be written. Used for
+	// zero-detection.
+	int16_t prev_write_attempt;
+} st3m_scope_t;
+
+// Initialize global scope. Must be performed before any other access to scope
+// is attempted.
+//
+// If initialization failes (eg. due to lack of memory) an error will be
+// printed.
+void st3m_scope_init(void);
+
+// Write a sound sample to the scope.
+void st3m_scope_write(int16_t value);
+
+// Draw the scope at bounding box -120/-120 +120/+120.
+//
+// The scope will be drawn as a closable line segment starting at x:-120
+// y:sample[0], through x:120 y:sample[239], then going through x:130 y:130,
+// x:-130 y:130.
+//
+// The user is responsible for setting a color and running a fill/stroke
+// afterwards.
+void st3m_scope_draw(Ctx *ctx);
+
diff --git a/usermodule/mp_hardware.c b/usermodule/mp_hardware.c
index b7b62c4dffbf7dc45da65376ef91210ab40cebca..7f40a738bb02129bb60a11d1cdfc5b6c16619a93 100644
--- a/usermodule/mp_hardware.c
+++ b/usermodule/mp_hardware.c
@@ -18,6 +18,7 @@
 
 #include "flow3r_bsp.h"
 #include "st3m_gfx.h"
+#include "st3m_scope.h"
 
 #include "ctx_config.h"
 #include "ctx.h"
@@ -217,6 +218,16 @@ STATIC mp_obj_t mp_display_pipe_flush(void) {
 }
 STATIC MP_DEFINE_CONST_FUN_OBJ_0(mp_display_pipe_flush_obj, mp_display_pipe_flush);
 
+STATIC mp_obj_t mp_scope_draw(mp_obj_t ctx_in) {
+    // TODO(q3k): check in_ctx? Or just drop from API?
+
+    if (gfx_last_desc != NULL) {
+        st3m_scope_draw(gfx_last_desc->ctx);
+    }
+    return mp_const_none;
+}
+STATIC MP_DEFINE_CONST_FUN_OBJ_1(mp_scope_draw_obj, mp_scope_draw);
+
 STATIC const mp_rom_map_elem_t mp_module_hardware_globals_table[] = {
     { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_badge_audio) },
     { MP_ROM_QSTR(MP_QSTR_init_done), MP_ROM_PTR(&mp_init_done_obj) },
@@ -253,6 +264,8 @@ STATIC const mp_rom_map_elem_t mp_module_hardware_globals_table[] = {
     { MP_ROM_QSTR(MP_QSTR_BUTTON_PRESSED_RIGHT), MP_ROM_INT(BUTTON_PRESSED_RIGHT) },
     { MP_ROM_QSTR(MP_QSTR_BUTTON_PRESSED_DOWN), MP_ROM_INT(BUTTON_PRESSED_DOWN) },
     { MP_ROM_QSTR(MP_QSTR_BUTTON_NOT_PRESSED), MP_ROM_INT(BUTTON_NOT_PRESSED) },
+
+    { MP_ROM_QSTR(MP_QSTR_scope_draw), MP_ROM_PTR(&mp_scope_draw_obj) },
 };
 
 STATIC MP_DEFINE_CONST_DICT(mp_module_hardware_globals, mp_module_hardware_globals_table);