Ollin

Sonification

numbers you can hear: a line across a landscape is drawn as a profile and read out as a tune from the same numbers, and the playhead is the note sounding (Sonification)

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

/// Numbers you can hear.
///
/// A line across a landscape is drawn as a profile and read out as a tune at
/// the same time, from the same numbers. The playhead is the note sounding, so
/// the picture and the sound are two views of one series rather than a chart
/// with a soundtrack bolted on.
///
/// Two things worth listening for. The pitches are snapped through a `Scale`,
/// so the reading stays in key however the ground moves: change the key with
/// the parameter and the same hills come out in a different mood. And the pale line
/// across the middle is a *reference*: it sounds its own note every bar, so you
/// hear each hill as above or below something instead of having to know what
/// any one note means. It is the grid line of an ordinary chart, in sound.
///
/// Press `S` to read the picture instead of the ground, which is the same call
/// with a different source.
@main
final class Sonify: Sketch {

    enum Source: String, ParamOption { case terrain, picture }

    // Named `mode` rather than `key`, which is the keyboard's on a `Sketch`.
    @Param(icon: "waveform", group: "Reading") var source = Source.terrain
    @Param(icon: "music.note", group: "Reading") var mode = Scale.Mode.minorPentatonic
    @Param(40 ... 160, icon: "metronome", group: "Reading") var tempo: Tempo = 96
    @Param(icon: "arrow.up.arrow.down", group: "Reading") var moreIsHigher = true

    let synth = Synth(.pluck, polyphony: 12)
    let marker = Synth(.sine, polyphony: 4)

    var land = Heightfield(columns: 2, rows: 2)
    var picture = Image(width: 1, height: 1, color: .black)
    var reading = Sonification([])
    var counter = StepCounter(perBeat: 2)
    var playhead = 0
    var lit: [Double] = []

    override func setup() {
        synth.gain = 0.5
        synth.reverb = Reverb(.hall, mix: 0.22)
        marker.gain = 0.16

        // The ground, and a picture made from it, so both readings are of the
        // same landscape and can be compared by ear.
        land = Heightfield(columns: 220, rows: 64) { u, v in
            fbm(u * 2.4, v * 2.4, octaves: 5)
        }.normalized()
        picture = Image(width: 220, height: 64, color: .black)
        for x in 0 ..< 220 {
            for y in 0 ..< 64 {
                picture[x, y] = Colormap.magma.color(at: land[x, y])
            }
        }
        rebuild()
    }

    /// The whole feature, in one call: a row of the landscape becomes a tune.
    private func rebuild() {
        built = Settings(source: source, mode: mode, moreIsHigher: moreIsHigher)
        let scale = Scale(mode, root: "A2")
        switch source {
        case .terrain:
            reading = Sonification(land, row: 32, in: scale,
                                   pitches: "A2"..."A5", noteLength: .eighth)
        case .picture:
            reading = Sonification(picture, row: 32, in: scale,
                                   pitches: "A2"..."A5", noteLength: .eighth)
        }
        if !moreIsHigher { reading = reading.inverted() }
        lit = [Double](repeating: 0, count: reading.count)
    }

    override func draw() {
        background(Color(hex: 0x0B0D12))

        // The parameters rebuild the reading, so a change is heard on the next note.
        if built != Settings(source: source, mode: mode, moreIsHigher: moreIsHigher) {
            rebuild()
        }

        for step in counter.steps(upTo: tempo.beats(at: time)) {
            let index = step % max(1, reading.count)
            playhead = index
            if index < lit.count { lit[index] = 1 }
            synth.play(reading, step: index, tempo: tempo)

            // The reference sounds once a bar, quietly, under the reading.
            if step % 8 == 0 {
                marker.play(reading.reference(at: referenceValue), tempo: tempo)
            }
        }

        drawProfile()
        drawCaption("A line across the landscape, drawn and read out at once. "
                    + "The pale line is the reference note.", edge: .top)
        drawCaption("S changes what is being read. The reading is snapped to a "
                    + "scale, so the ground always lands in key.")
    }

    /// The value the reference note sounds: the middle of the reading's range,
    /// which is the sea level of this landscape.
    private var referenceValue: Double {
        (reading.valueDomain.lowerBound + reading.valueDomain.upperBound) / 2
    }

    /// What the current reading was built from, so a parameter change rebuilds it and
    /// nothing else does.
    struct Settings: Equatable {
        var source: Source
        var mode: Scale.Mode
        var moreIsHigher: Bool
    }
    private var built = Settings(source: .terrain, mode: .major, moreIsHigher: true)

    private func drawProfile() {
        guard !reading.isEmpty else { return }
        let frame = Rectangle(x: width * 0.08, y: height * 0.28,
                              width: width * 0.84, height: height * 0.44)
        let right = frame.x + frame.width, bottom = frame.y + frame.height

        // The profile itself, as a filled area under the line.
        let low = reading.valueDomain.lowerBound, high = reading.valueDomain.upperBound
        let span = max(1e-9, high - low)
        func point(_ index: Int) -> Vector2 {
            let t = Double(index) / Double(max(1, reading.count - 1))
            let value = (reading.values[index] - low) / span
            return Vector2(frame.x + frame.width * t,
                           bottom - frame.height * value)
        }

        // Through the shape builder rather than drawPolygon: a profile is
        // concave wherever the ground dips, and a polygon fill is a fan from
        // the first point, which would bridge straight across every valley.
        noStroke()
        fill(Color(hex: 0x1B2A3A))
        drawShape { path in
            path.move(to: Vector2(frame.x, bottom))
            for index in 0 ..< reading.count { path.line(to: point(index)) }
            path.line(to: Vector2(right, bottom))
            path.close()
        }

        stroke(Color(hex: 0x6FB8E0))
        strokeWeight(2 * scale)
        noFill()
        drawPolyline((0 ..< reading.count).map(point))

        // The reference, drawn where it sounds.
        let referenceY = bottom - frame.height * ((referenceValue - low) / span)
        stroke(Color(white: 0.55, alpha: 0.5))
        strokeWeight(1 * scale)
        drawLine(frame.x, referenceY, right, referenceY)

        // Each reading lights as it is played and fades, so the playhead leaves
        // a trail of what has just been heard.
        noStroke()
        for index in lit.indices where lit[index] > 0.01 {
            lit[index] *= pow(0.02, deltaTime)
            let at = point(index)
            fill(Colormap.magma.color(at: 0.35 + reading.values[index] * 0.5)
                .withAlpha(lit[index]))
            drawCircle(at.x, at.y, (3 + lit[index] * 7) * scale)
        }

        // The note sounding now.
        let head = point(playhead)
        stroke(Color(white: 0.9, alpha: 0.7))
        strokeWeight(1.5 * scale)
        drawLine(head.x, frame.y, head.x, bottom)
        noStroke()
        fill(.white)
        drawCircle(head.x, head.y, 5 * scale)

        // What is sounding, named, so the mapping is legible.
        if let note = reading.note(at: playhead) {
            fill(Color(white: 0.75))
            textSize(20 * scale)
            textAlign(.center)
            drawText("\(note.pitch)", at: Vector2(width / 2, bottom + 46 * scale))
        }
    }

    override func keyPressed() {
        guard key == "s" || key == "S" else { return }
        source = source == .terrain ? .picture : .terrain
    }
}

↑↓ move ↩ open