V/Oct Calibration
1V/octave calibration for musical pitch CV (Control Voltage) inputs.
This module provides accurate conversion from analog CV inputs to MIDI note numbers, enabling precise pitch tracking for Eurorack synthesizer applications.
What is V/Oct?
1V/octave is the standard CV pitch control used in modular synthesizers:
- 1V increase = +12 semitones (one octave higher)
- 1V decrease = -12 semitones (one octave lower)
- 83.33mV = +1 semitone (1/12 of a volt)
Example voltages:
- 0V = C0 (MIDI note 0)
- 1V = C1 (MIDI note 12)
- 2V = C2 (MIDI note 24)
- 3V = C3 (MIDI note 36)
- 4V = C4 (MIDI note 48, middle C)
Why Calibration is Needed
ADC (Analog-to-Digital Converter) readings vary due to:
- Input circuit tolerances (resistors, op-amps)
- ADC reference voltage drift
- Temperature effects
- Component aging
Without calibration, a 1V input might read as 0.23 or 0.25 instead of exactly the expected value, causing pitch errors (out-of-tune notes).
This module solves the problem by measuring two known voltages (1V and 3V) and calculating a correction formula (scale + offset).
Calibration Procedure
Step 1: Create calibration object
var voct: VoctCalibration
Step 2: Measure 1V reference
# User patches 1V CV source to input # (use precision voltage source or calibrated CV module) let reading1V = adc.get(0) # Read ADC value
Step 3: Measure 3V reference
# User patches 3V CV source to input let reading3V = adc.get(0) # Read ADC value
Step 4: Record calibration
if voct.record(reading1V, reading3V): echo "Calibration successful!"
Step 5: Save calibration (optional but recommended)
# Save to persistent storage let (scale, offset) = voct.getCalibrationData() settings.voctScale = scale settings.voctOffset = offset storage.save(settings)
Step 6: Restore calibration on boot
# Load from persistent storage voct.setData(settings.voctScale, settings.voctOffset)
Using Calibrated Input
Once calibrated, convert ADC readings to MIDI note numbers:
let cvInput = adc.get(0) # Raw ADC reading (0.0 - 1.0) let midiNote = voct.processInput(cvInput) echo "CV input: ", cvInput echo "MIDI note: ", midiNote echo "Note name: ", cvToNoteName(midiNote) # Use for pitch control let frequency = midiNoteToFreq(midiNote) oscillator.setFrequency(frequency)
Complete Example
Full calibration workflow with OLED display:
import nimphea_voct_calibration import nimphea_persistent_storage type Settings = object voctScale: float32 voctOffset: float32 var voct: VoctCalibration var storage: PersistentStorage[Settings] var settings: Settings # On first boot: calibrate if not storage.hasCalibration(): oled.setCursor(0, 0) oled.writeString("CALIBRATION", Font_7x10, true) oled.setCursor(0, 12) oled.writeString("Patch 1V", Font_7x10, true) oled.update() # Wait for user to press button waitForButtonPress() let val1V = adc.get(0) oled.fill(false) oled.setCursor(0, 0) oled.writeString("Patch 3V", Font_7x10, true) oled.update() waitForButtonPress() let val3V = adc.get(0) voct.record(val1V, val3V) let (scale, offset) = voct.getCalibrationData() settings.voctScale = scale settings.voctOffset = offset storage.save(settings) oled.fill(false) oled.setCursor(0, 0) oled.writeString("DONE!", Font_7x10, true) oled.update() else: # Restore calibration settings = storage.recall() voct.setData(settings.voctScale, settings.voctOffset) # Main loop: track pitch while true: let cvInput = adc.get(0) let midiNote = voct.processInput(cvInput) let freq = midiNoteToFreq(midiNote) osc.setFrequency(freq)
Buchla Standard (100mV/Semitone)
This module also supports Buchla-style CV calibration:
- 100mV/semitone instead of 83.33mV/semitone
- 1.2V/octave instead of 1V/octave
Calibration procedure for Buchla:
# Patch 1.2V (C1) to input let val1V2 = adc.get(0) # Patch 3.6V (C3) to input let val3V6 = adc.get(0) voct.record(val1V2, val3V6) # Same function works!
Helper Functions
This module provides additional Nim helper functions:
# Convert MIDI note to frequency (Hz) let freq = midiNoteToFreq(60.0) # 60 = middle C = 261.63 Hz # Convert MIDI note to note name let name = midiNoteToName(60) # "C4" let name2 = midiNoteToName(61) # "C#4" # Convert CV to note name (combines processInput + midiNoteToName) let name3 = cvToNoteName(voct, cvInput)
See Also
- nimphea_persistent_storage - Save calibration data
- examples/voct_tuning.nim - Complete OLED-guided calibration example
- per/adc - Read CV inputs
Types
VoctCalibration {.importcpp: "daisy::VoctCalibration".} = object
-
V/Oct calibration helper for musical pitch CV.
Converts ADC readings to MIDI note numbers using a two-point calibration (1V and 3V references).
Internal state:
- Scale factor (semitones per ADC unit)
- Offset (MIDI note number at 0V)
- Calibration valid flag
Usage pattern:
- Create object: var voct: VoctCalibration
- Record calibration: voct.record(val1V, val3V)
- Process inputs: let note = voct.processInput(cvReading)
Procs
proc cvToMidiNote(this: var VoctCalibration; cvInput: float32): float32 {. ...raises: [], tags: [], forbids: [].}
-
Convert CV input to MIDI note number (wrapper for processInput).
Identical to processInput() but with more descriptive name.
Parameters:
- cvInput - ADC reading (0.0 - 1.0)
Returns: MIDI note number (float32, can be fractional)
Example:
let note = voct.cvToMidiNote(adc.get(0)) let freq = midiNoteToFreq(note) oscillator.setFrequency(freq)
Source Edit proc cvToNoteName(this: var VoctCalibration; cvInput: float32): string {. ...raises: [], tags: [], forbids: [].}
-
Convert CV input to note name (combines processInput + midiNoteToName).
Parameters:
- cvInput - ADC reading (0.0 - 1.0)
Returns: Note name as string
Example:
let cvReading = adc.get(0) let noteName = voct.cvToNoteName(cvReading) oled.setCursor(0, 0) oled.writeString("Note: " & noteName, Font_7x10, true) oled.update()
Note: Rounds to nearest semitone (fractional notes are truncated).
Source Edit proc getCalibrationData(this: var VoctCalibration): tuple[ scale, offset: float32, valid: bool] {....raises: [], tags: [], forbids: [].}
-
Get calibration data as a Nim tuple (more idiomatic than getData).
Returns: Tuple with:
- scale - Scale factor (semitones per ADC unit)
- offset - Offset (MIDI note at 0V)
- valid - True if calibration has been performed
Example:
let (scale, offset, valid) = voct.getCalibrationData() if valid: echo "Scale: ", scale echo "Offset: ", offset # Save to persistent storage settings.voctScale = scale settings.voctOffset = offset storage.save(settings) else: echo "Not calibrated - run calibration procedure"
Source Edit proc getData(this: var VoctCalibration; scale, offset: var cfloat): bool {. importcpp: "#.GetData(@)", ...raises: [], tags: [], forbids: [].}
-
Get calibration scale and offset values (C++ style, pass-by-reference).
Parameters:
- scale - Output: scale factor (semitones per ADC unit)
- offset - Output: offset (MIDI note at 0V)
Returns: True if calibration has been recorded, false otherwise
Example:
var scale, offset: cfloat if voct.getData(scale, offset): echo "Scale: ", scale echo "Offset: ", offset else: echo "Not calibrated yet"
Note: Prefer the Nim-style getCalibrationData() which returns a tuple.
Source Edit proc isCalibrated(this: var VoctCalibration): bool {....raises: [], tags: [], forbids: [].}
-
Check if calibration has been performed.
Returns: True if record() or setData() has been called
Example:
if not voct.isCalibrated(): echo "WARNING: Calibration required!" runCalibrationWizard()
Source Edit proc midiNoteToFreq(midiNote: float32): float32 {....raises: [], tags: [], forbids: [].}
-
Convert MIDI note number to frequency in Hz.
Uses standard formula: freq = 440 * 2^((note - 69) / 12)
Parameters:
- midiNote - MIDI note number (can be fractional)
Returns: Frequency in Hz
Examples:
echo midiNoteToFreq(69.0) # A4 = 440.0 Hz echo midiNoteToFreq(60.0) # C4 = 261.63 Hz (middle C) echo midiNoteToFreq(48.0) # C3 = 130.81 Hz echo midiNoteToFreq(72.0) # C5 = 523.25 Hz echo midiNoteToFreq(60.5) # C4 + 50 cents = ~269.4 Hz
Note: A4 (MIDI note 69) = 440 Hz by definition.
Source Edit proc midiNoteToName(midiNote: int): string {....raises: [], tags: [], forbids: [].}
-
Convert integer MIDI note number to note name (e.g., "C4", "A#5").
Parameters:
- midiNote - Integer MIDI note number (0-127)
Returns: Note name as string (e.g., "C4", "F#3", "Bb5")
Examples:
echo midiNoteToName(60) # "C4" (middle C) echo midiNoteToName(69) # "A4" (440 Hz) echo midiNoteToName(61) # "C#4" echo midiNoteToName(70) # "Bb4" echo midiNoteToName(0) # "C0" echo midiNoteToName(127) # "G9"
Note naming:
- Uses sharps (#) for black keys (C#, D#, F#, G#, A#)
- Octave numbering: C0 = MIDI 0, C4 = MIDI 60 (middle C)
proc processInput(this: var VoctCalibration; inval: cfloat): cfloat {. importcpp: "#.ProcessInput(@)", ...raises: [], tags: [], forbids: [].}
-
Convert ADC reading to MIDI note number using calibration.
Formula: midiNote = offset + (scale * inval)
Parameters:
- inval - ADC reading (0.0 - 1.0 range)
Returns: MIDI note number (float, can be fractional for pitch bend)
Example:
let cvInput = adc.get(0) # e.g., 0.4567 let midiNote = voct.processInput(cvInput) echo "MIDI note: ", midiNote # e.g., 48.23 (slightly above middle C) # Use for pitch control let freq = midiNoteToFreq(midiNote) oscillator.setFrequency(freq)
Note: Returns fractional MIDI note numbers for sub-semitone accuracy. Round if you need integer note values.
Source Edit proc record(this: var VoctCalibration; val1V, val3V: cfloat): bool {. importcpp: "#.Record(@)", ...raises: [], tags: [], forbids: [].}
-
Record calibration data from 1V and 3V measurements.
Calculates scale and offset for converting ADC readings to MIDI notes.
Formula:
- delta = val3V - val1V
- scale = 24 / delta (24 semitones in 2 octaves)
- offset = 12 - scale * val1V (MIDI note 12 = C1 = 1V)
Parameters:
- val1V - ADC reading when 1V is applied (0.0 - 1.0 range)
- val3V - ADC reading when 3V is applied (0.0 - 1.0 range)
Returns: Always true (calibration always succeeds)
Example:
var voct: VoctCalibration # User patches 1V CV source let reading1V = adc.get(0) # e.g., 0.2345 # User patches 3V CV source let reading3V = adc.get(0) # e.g., 0.7012 if voct.record(reading1V, reading3V): echo "Calibration complete!"
Note: This function does not validate the input range. Ensure val3V > val1V to avoid division issues.
Source Edit proc setData(this: var VoctCalibration; scale, offset: cfloat) {. importcpp: "#.SetData(@)", ...raises: [], tags: [], forbids: [].}
-
Manually set calibration data (e.g., loaded from persistent storage).
Use this to restore calibration after power cycle without redoing the calibration procedure.
Parameters:
- scale - Scale factor from previous calibration
- offset - Offset from previous calibration
Example:
# Load from persistent storage let savedSettings = storage.recall() voct.setData(savedSettings.voctScale, savedSettings.voctOffset) echo "Calibration restored from storage"
Source Edit