diff --git a/marbles/calibrate.py b/marbles/calibrate.py
new file mode 100644
index 0000000..ef557b4
--- /dev/null
+++ b/marbles/calibrate.py
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+
+import subprocess, re, sys
+from pathlib import Path
+
+DEBUG = False
+
+current_dir = Path(__file__).absolute().parent
+
+make_command = ("make", "-f", "marbles/makefile", "upload")
+stdout = None if DEBUG else subprocess.DEVNULL
+
+test_mode_regex = re.compile("(output_test_mode_\s*= \s*)((true)|(false))(\s*;)")
+scale_regexs = [
+    re.compile(
+        "(persistent_data_\.calibration_data\.dac_scale\[{}\]\s*=\s*)[^;]*(\s*;)".format(i)
+    ) for i in range(4)
+]
+offset_regexs = [
+    re.compile(
+        "(persistent_data_\.calibration_data\.dac_offset\[{}\]\s*=\s*)[^;]*(\s*;)".format(i)
+    ) for i in range(4)
+]
+ui_poll_regex = re.compile("(//)?(ui\.Poll\(\);)")
+ui_do_events_regex = re.compile("(//)?(ui\.DoEvents\(\);)")
+
+def set_test_mode(value):
+    with open(str(current_dir / "ui.cc"), "r") as file:
+        content = file.read()
+
+    content = test_mode_regex.sub("\g<1>{}\g<5>".format(str(value).lower()), content)
+
+    with open(str(current_dir / "ui.cc"), "w") as file:
+        file.write(content)
+
+def set_calibration_data(scales, offsets):
+    with open(str(current_dir / "settings.cc"), "r") as file:
+        content = file.read()
+
+    for scale_regex, offset_regex, scale, offset in zip(scale_regexs, offset_regexs, scales, offsets):
+        content = scale_regex.sub("\g<1>{}f\g<2>".format(scale), content)
+        content = offset_regex.sub("\g<1>{}f\g<2>".format(offset), content)
+
+    with open(str(current_dir / "settings.cc"), "w") as file:
+        file.write(content)
+
+def set_ui(value):
+    with open(str(current_dir / "marbles.cc"), "r") as file:
+        content = file.read()
+
+    prefix = "" if value else "//"
+    content = ui_poll_regex.sub(prefix + "\g<2>", content)
+    content = ui_do_events_regex.sub(prefix + "\g<2>", content)
+
+    with open(str(current_dir / "marbles.cc"), "w") as file:
+        file.write(content)
+
+def inputVoltage(prompt="", default_whole=0):
+    while True:
+        value = input(prompt)
+
+        if value == "":
+            continue
+
+        try:
+            if "." in value:
+                value = float(value)
+            else:
+                value = default_whole + float("0." + value)
+
+            return value
+
+        except ValueError:
+            continue
+
+def make():
+    res = subprocess.call(make_command, cwd=str(current_dir.parent), stdout=stdout, stderr=stdout)
+    if res != 0:
+        print("error")
+        sys.exit()
+
+while True:
+    set_test_mode(True)
+    set_calibration_data([-6212.8] * 4, [32768.0] * 4)
+    set_ui(False)
+
+    print("flashing test firmware ... ", end="", flush=True)
+    make()
+    print("done")
+    print("check cv inputs, gate outputs, calibrate cv outputs:")
+
+    scales = []
+    offsets = []
+    for i in range(4):
+        voltage_1v = inputVoltage("J{} 1V: ".format(13 + i), 0)
+        voltage_3v = inputVoltage("J{} 3V: ".format(13 + i), 2)
+
+        scale = (14129 - 26555) / (voltage_3v - voltage_1v)
+        offset = 26555 - (voltage_1v * scale)
+
+        scales.append(scale)
+        offsets.append(offset)
+
+    set_calibration_data(scales, offsets)
+
+    print("flashing test firmware (calibrated) ... ", end="", flush=True)
+    make()
+    print("done")
+
+    if input("repeat? (y) ") != "y":
+        break
+
+calibration_name = input("save calibration? ")
+if calibration_name != "":
+    with open(calibration_name + ".txt", "w") as file:
+        file.write("scale\n")
+        for scale in scales:
+            file.write(str(scale) + "\n")
+
+        file.write("offset\n")
+        for offset in offsets:
+            file.write(str(offset) + "\n")
+
+
+set_test_mode(False)
+set_ui(True)
+
+print("flashing firmware (calibrated) ... ", end="", flush=True)
+make()
+print("done")
diff --git a/marbles/marbles.cc b/marbles/marbles.cc
index f120414..97ebb45 100755
--- a/marbles/marbles.cc
+++ b/marbles/marbles.cc
@@ -142,38 +142,54 @@ inline uint16_t DacCode(int index, float voltage) {
   return ClipU16(static_cast<int32_t>(voltage * scale + offset));
 }
 
+unsigned long tick;
+bool high_output;
+Leds leds_;
+bool first_call = true;
+
 void ProcessTest(IOBuffer::Block* block, size_t size) {
+  if (first_call) {
+    leds_.Init();
+    first_call = false;
+  }
+
   float parameters[kNumParameters];
   GateFlags hidden_gates[kNumParameters];
-  static float phase;
   cv_reader.Process(false, &block->adc_value[0], parameters, hidden_gates);
   for (size_t i = 0; i < size; ++i) {
-    phase += 100.0f / static_cast<float>(kSampleRate);
-    if (phase >= 1.0f) {
-      phase -= 1.0f;
-    }
-    block->cv_output[0][i] = DacCode(
-        0, 4.0 * Interpolate(lut_sine, phase, 256.0f));
-    block->cv_output[1][i] = DacCode(
-        1, -8.0f * phase + 4.0f);
-    block->cv_output[2][i] = DacCode(
-        2, (phase < 0.5f ? phase : 1.0f - phase) * 16.0f - 4.0f);
-    block->cv_output[3][i] = DacCode(
-        3, phase < 0.5f ? -4.0f : 4.0f);
-
-    for (int j = 0; j < 4; ++j) {
-      uint16_t dac_code = ui.output_test_forced_dac_code(j);
-      if (dac_code) {
-        block->cv_output[j][i] = dac_code;
-      }
-    }
-    
-    block->gate_output[0][i] = block->input_patched[0]
-        ? block->input[0][i]
-        : phase < 0.2f;
-    block->gate_output[1][i] = phase < 0.5f;
-    block->gate_output[2][i] = block->input_patched[1]
-        ? block->input[1][i]
-        : phase < 0.8f;
+    tick = (tick + 1) % 150000;
+    if (tick == 0) high_output = !high_output;
+
+    float output_voltage = high_output ? 3.0f : 1.0f;
+
+    block->cv_output[0][i] = DacCode(0, output_voltage);
+    block->cv_output[1][i] = DacCode(1, output_voltage);
+    block->cv_output[2][i] = DacCode(2, output_voltage);
+    block->cv_output[3][i] = DacCode(3, output_voltage);
+
+    block->gate_output[0][i] = high_output;
+    block->gate_output[1][i] = !high_output;
+    block->gate_output[2][i] = high_output;
+
+    bool green = false
+      || parameters[ADC_CHANNEL_T_BIAS] > 0.5f
+      || parameters[ADC_CHANNEL_T_JITTER] > 0.5f
+      || parameters[ADC_CHANNEL_X_STEPS] > 0.5f
+      || parameters[ADC_CHANNEL_X_BIAS] > 0.5f;
+
+    bool red = false
+      || parameters[ADC_CHANNEL_T_RATE] > -20.0f
+      || parameters[ADC_CHANNEL_DEJA_VU_AMOUNT] > 0.5f
+      || parameters[ADC_CHANNEL_X_SPREAD] > 0.5f;
+
+    marbles::LedColor color =
+      green && red ? LED_COLOR_YELLOW :
+      green ? LED_COLOR_GREEN :
+      red ? LED_COLOR_RED :
+      LED_COLOR_OFF;
+
+    leds_.set(LED_T_MODEL, color);
+    leds_.set(LED_X_CONTROL_MODE, color);
+    leds_.Write();
   }
 }
 
diff --git a/marbles/settings.cc b/marbles/settings.cc
index 0e7ca22..1bb0edb 100644
--- a/marbles/settings.cc
+++ b/marbles/settings.cc
@@ -174,6 +174,15 @@ void Settings::Init() {
   fill(&c.dac_offset[0], &c.dac_offset[DAC_CHANNEL_LAST], 32768.0f);
   c.adc_offset[ADC_CHANNEL_T_RATE] = 60.0f;
   c.adc_scale[ADC_CHANNEL_T_RATE] = -120.0f;
+
+  persistent_data_.calibration_data.dac_scale[0] = -6212.8f;
+  persistent_data_.calibration_data.dac_scale[1] = -6212.8f;
+  persistent_data_.calibration_data.dac_scale[2] = -6212.8f;
+  persistent_data_.calibration_data.dac_scale[3] = -6212.8f;
+  persistent_data_.calibration_data.dac_offset[0] = 32768.0f;
+  persistent_data_.calibration_data.dac_offset[1] = 32768.0f;
+  persistent_data_.calibration_data.dac_offset[2] = 32768.0f;
+  persistent_data_.calibration_data.dac_offset[3] = 32768.0f;
   
   for (size_t i = 0; i < kNumScales; ++i) {
     ResetScale(i);
