nimphea/nimphea_voct_calibration

Source   Edit  

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:

  1. Input circuit tolerances (resistors, op-amps)
  2. ADC reference voltage drift
  3. Temperature effects
  4. 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

Compile-time examples (not executable without hardware)

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:

  1. Create object: var voct: VoctCalibration
  2. Record calibration: voct.record(val1V, val3V)
  3. Process inputs: let note = voct.processInput(cvReading)
Source   Edit  

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)
Source   Edit  
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