Ollin

Tunings

twelve notes to the octave is a choice: one melody played in just, Pythagorean, quarter-tone, 19-tone, 31-tone, and Bohlen-Pierce tunings. Each degree is placed on a ladder by its cents and labeled with how far it is from the nearest equal-tempered note (Tuning, snap(_:))

Run
swift run --package-path Examples Example-Audio-Tunings
Imports
OllinAudio
Source
Examples/Audio/Tunings/Sketch.swift
import Ollin
import OllinAudio

/// Twelve is a choice.
///
/// A `Tuning` describes pitches by frequency ratios and the interval they
/// repeat over, which covers the ordinary keyboard as one case among many.
/// This sketch keeps a triad sounding and walks a melody up the whole tuning,
/// one degree per step, while the ladder shows where every degree actually
/// sits: each rung is a pitch, placed by its `cents` above the root, and the
/// label is how far it leans away from the equal-tempered grid on the left.
///
/// Switch the tuning with the parameter and listen for two things. In `.just` and
/// `.thirtyOne` the held triad locks and sits still where the equal one beats
/// slowly, because their thirds are whole number ratios or near them; the
/// ladder shows the same fact as rungs pulled 14 cents off the grid. And
/// `.bohlenPierce` repeats over a twelfth rather than an octave, so its ladder
/// is taller than everyone else's and almost nothing lines up.
///
/// The triad is found with `snap(_:)`: the sketch asks for an equal-tempered
/// root, third, and fifth, and each tuning answers with its own nearest
/// pitches, which is how a number that did not come from music joins a tuning.
@main
final class Tunings: Sketch {

    /// The named tunings, on a menu. A local enum rather than the type itself,
    /// because a menu needs a fixed roster of named values.
    enum Choice: String, CaseIterable, ParamOption {
        case equalTemperament, just, pythagorean, quarterTones
        case nineteen, thirtyOne, bohlenPierce

        var tuning: Tuning {
            switch self {
            case .equalTemperament: return .equalTemperament
            case .just:             return .just
            case .pythagorean:      return .pythagorean
            case .quarterTones:     return .quarterTones
            case .nineteen:         return .nineteen
            case .thirtyOne:        return .thirtyOne
            case .bohlenPierce:     return .bohlenPierce
            }
        }

        /// One line of what to listen for.
        var lesson: String {
            switch self {
            case .equalTemperament:
                return "Every key equally usable, every interval slightly wrong: the triad beats slowly."
            case .just:
                return "Whole number ratios: the third sits 14 cents low and the triad locks."
            case .pythagorean:
                return "Stacked fifths: a very pure fifth, and a third 8 cents wide of even the grid."
            case .quarterTones:
                return "The keyboard plus every pitch between its keys."
            case .nineteen:
                return "Nineteen equal steps: a sweeter third, and notes a piano spells the same come apart."
            case .thirtyOne:
                return "Thirty one steps, close enough to whole number thirds that chords stop beating."
            case .bohlenPierce:
                return "Thirteen steps of a twelfth: no octave anywhere, and odd harmonics still line up."
            }
        }
    }

    @Param(icon: "tuningfork", group: "Tuning") var choice = Choice.just
    @Param(60 ... 180, icon: "metronome", group: "Playing") var tempo: Tempo = 132

    let pad = Synth(.pad, polyphony: 12)
    let steps = Synth(.pluck, polyphony: 8)
    var counter = StepCounter(perBeat: 2)
    var chordCounter = StepCounter(perBeat: 0.125)

    var tuning = Tuning.just
    var triad: [Pitch] = []
    var triadDegrees: Set<Int> = []
    var lit: [Double] = []
    var sounding = 0
    var built = ""

    override func setup() {
        pad.gain = 0.3
        pad.reverb = Reverb(.hall, mix: 0.25)
        steps.gain = 0.5
        rebuild()
    }

    private var recipe: String { "\(choice)" }

    private func rebuild() {
        tuning = choice.tuning
        // An equal-tempered root, third, and fifth, and the tuning's own
        // nearest answer to each. This is what snap is for.
        let root = tuning.root
        triad = [root, tuning.snap(root.transposed(by: 4)),
                 tuning.snap(root.transposed(by: 7))]
        triadDegrees = Set(triad.compactMap { pitch in
            tuning.cents.indices.first { index in
                abs(tuning.pitch(index).midi - pitch.midi) < 0.001
            }
        })
        lit = [Double](repeating: 0, count: tuning.degreeCount + 1)
        built = recipe
    }

    override func draw() {
        background(Color(hex: 0x0C0E12))
        if built != recipe { rebuild() }

        // The melody climbs the whole tuning, one degree per step, top note
        // included, so every rung on the ladder gets heard.
        let lap = tuning.degreeCount + 1
        for step in counter.steps(upTo: tempo.beats(at: time)) {
            sounding = step % lap
            lit[sounding] = 1
            steps.play(tuning[sounding], velocity: 0.6, for: 0.5)
        }
        // The triad holds underneath, replayed every couple of bars.
        for _ in chordCounter.steps(upTo: tempo.beats(at: time)) {
            pad.play(chord: triad, velocity: 0.5, for: tempo.seconds(bars: 2))
        }

        drawLadder()
        drawCaption("Each rung is a pitch; its label is how far it leans off the equal grid, in cents.",
                    edge: .top)
        drawCaption(choice.lesson)
    }

    private func drawLadder() {
        let cents = tuning.cents + [1200 * log2(tuning.period)]
        let span = cents.last ?? 1200
        let top = height * 0.16
        let bottom = height * 0.86
        let railX = width * 0.36
        let rungX = width * 0.56

        func y(_ cent: Double) -> Double { bottom - cent / span * (bottom - top) }

        // The left rail: the equal-tempered grid, one mark per 100 cents,
        // which is the keyboard the deviations are measured against.
        stroke(Color(white: 0.3))
        strokeWeight(2 * scale)
        drawLine(railX, y(0), railX, y(span))
        var grid = 0.0
        while grid <= span + 0.5 {
            stroke(Color(white: 0.3))
            drawLine(railX - 14 * scale, y(grid), railX, y(grid))
            grid += 100
        }

        // The rungs: every degree at its true height, joined to the grid mark
        // it is nearest, so a leaning line is an interval the keyboard bends.
        for (index, cent) in cents.enumerated() {
            lit[index] *= pow(0.08, deltaTime)
            let glow = index == sounding % cents.count ? 1.0 : lit[index]
            let inTriad = triadDegrees.contains(index % tuning.degreeCount)
            let nearest = (cent / 100).rounded() * 100
            let deviation = Int((cent - nearest).rounded())

            let tone = inTriad ? Color(hex: 0xE4B34C)
                               : Colormap.viridis.color(at: 0.25 + 0.6 * cent / span)
            stroke(tone.withAlpha(0.35 + glow * 0.65))
            strokeWeight((1.5 + glow * 3) * scale)
            drawLine(railX, y(min(nearest, span)), rungX, y(cent))
            drawLine(rungX, y(cent), rungX + 44 * scale, y(cent))

            noStroke()
            fill(tone.withAlpha(0.55 + glow * 0.45))
            textSize(13 * scale)
            textAlign(.left, .middle)
            let sign = deviation > 0 ? "+" : ""
            drawText("\(tuning.pitch(index))  \(sign)\(deviation)",
                     at: Vector2(rungX + 54 * scale, y(cent)))
        }

        // What is sounding, written large.
        noStroke()
        fill(Color(white: 0.85))
        textSize(30 * scale)
        textAlign(.center)
        drawText("\(tuning[sounding])", at: Vector2(width * 0.18, height * 0.5))
        fill(Color(white: 0.5))
        textSize(15 * scale)
        drawText("\(tuning.degreeCount) degrees", at: Vector2(width * 0.18, height * 0.5 + 28 * scale))
    }
}

↑↓ move ↩ open