Ollin

Timecode

a sketch that chases a timeline: an internal deck sends MIDI Time Code quarter frames to itself through a virtual source, a TimecodeClock follows them, and cues flash under a scrolling timeline; point a real sender at the Mac and the same sketch chases that (TimecodeClock, Timecode, MIDIOutput.send(timecode:))

Run
swift run --package-path Examples Example-Integration-Timecode
Imports
OllinMIDI
Source
Examples/Integration/Timecode/Sketch.swift
import Foundation
import Ollin
import OllinMIDI

/// A sketch that chases a timeline. MIDI Time Code is how a video deck, a show
/// controller, a lighting desk, or a DAW locked to picture tells everything
/// else where it is, as hours, minutes, seconds, and frames, and a
/// `TimecodeClock` reads it back so the picture can land its cues on the
/// frame. A background timer here plays the part of the deck: it sends the
/// quarter-frame messages through a virtual source at four a frame, and a
/// full-frame message when it stops, and the clock follows them. Point any
/// timecode sender at this Mac and the sketch follows that instead.
///
///   swift run Example-Integration-Timecode
///
/// The readout is the position as the clock has it. The strip below it is the
/// timeline scrolling under a playhead, one tick a second, and the marks on
/// it are cues: each flashes as the playhead passes it. `rate` picks the
/// frame rate the internal deck counts at (a real sender declares its own),
/// `playing` rolls and stops it, and `cueEvery` spaces the cues. See
/// `Docs/Integration/MIDI.md`.
@main
final class TimecodeChase: Sketch {
    enum Rate: CaseIterable, ParamOption {
        case fps24, fps25, fps30Drop, fps30

        var frameRate: Timecode.FrameRate {
            switch self {
            case .fps24: .fps24
            case .fps25: .fps25
            case .fps30Drop: .fps30Drop
            case .fps30: .fps30
            }
        }

        var optionLabel: String { frameRate.name }
    }

    let midi = MIDIInput()
    lazy var clock = TimecodeClock(from: midi)
    var deck: InternalDeck?

    @Param var rate = Rate.fps25
    @Param var playing = true
    @Param(2 ... 30, icon: "flag") var cueEvery = 5.0

    var sentRate: Rate?
    var wasPlaying: Bool?
    /// The last cue passed, so each flashes once.
    var lastCue = -1
    var flashAt = -1.0

    override func setup() {
        try? midi.start()
        deck = InternalDeck()
        noStroke()
    }

    override func draw() {
        background(Color(white: 0.07))
        driveDeck()

        let accent = Color(red: 0.95, green: 0.62, blue: 0.25)
        let seconds = clock.seconds

        // The readout, the way the deck's own display would show it.
        fill(clock.isPlaying ? Color(white: 0.97) : Color(white: 0.6))
        textSize(118 * scale)
        textAlign(.center, .middle)
        drawText(clock.timecode.map { "\($0)" } ?? "--:--:--:--", width / 2, height * 0.36)

        // The timeline: one tick a second, numbers every ten, scrolling so the
        // playhead stays put.
        let baseline = height * 0.62
        let pixelsPerSecond = 90 * scale
        let cue = max(1.0, cueEvery)
        textSize(20 * scale)
        for tick in Int(seconds) - 8 ... Int(seconds) + 8 where tick >= 0 {
            let x = width / 2 + (Double(tick) - seconds) * pixelsPerSecond
            let isCue = tick % Int(cue) == 0
            let passed = Double(tick) <= seconds
            fill(isCue ? (passed ? accent : accent.withAlpha(0.5)) : Color(white: 0.4))
            let tall = (isCue ? 46 : 22) * scale
            drawRect(x - 1.5 * scale, baseline - tall, 3 * scale, tall)
            if tick % 10 == 0 {
                fill(Color(white: 0.6))
                drawText(String(format: "%d:%02d", tick / 60, tick % 60), x, baseline + 24 * scale)
            }
        }
        fill(Color(white: 0.25))
        drawRect(0, baseline, width, 2 * scale)

        // The playhead, and the flash when it passes a cue.
        let cueIndex = Int(seconds / cue)
        if cueIndex != lastCue, clock.isPlaying {
            lastCue = cueIndex
            flashAt = time
        }
        let flash = max(0, 1 - (time - flashAt) / 0.5)
        fill(accent.withAlpha(0.25 * flash))
        drawCircle(width / 2, baseline - 24 * scale, 120 * scale * (1 + flash))
        fill(Color(white: 0.97))
        drawRect(width / 2 - 2 * scale, baseline - 70 * scale, 4 * scale, 90 * scale)

        // The status line: the rate the sender declares, and whether it rolls.
        fill(Color(white: 0.55))
        textSize(22 * scale)
        textAlign(.left, .top)
        let status: String
        if let frameRate = clock.frameRate, clock.isReceiving {
            status = "\(frameRate.name) fps   " + (clock.isPlaying
                ? (clock.isReversed ? "rolling backward" : "rolling")
                : "stopped")
        } else {
            status = "waiting for timecode: press play, or send MIDI Time Code to this Mac"
        }
        drawText(status, 40 * scale, 40 * scale)
        fill(Color(white: 0.95))
        textSize(30 * scale)
        drawText("Timecode: chasing a timeline", 40 * scale, 70 * scale)
    }

    /// Applies the parameters to the internal deck, once per change.
    func driveDeck() {
        if rate != sentRate {
            sentRate = rate
            deck?.setRate(rate.frameRate)
        }
        if playing != wasPlaying {
            wasPlaying = playing
            if playing { deck?.play() } else { deck?.stop() }
        }
    }
}

/// The deck side of the loopback: a virtual MIDI source and a background timer
/// sending quarter frames at four a frame, so the sketch has a timeline to
/// chase with no gear attached. Stopping sends the position as a full frame,
/// the way a deck reports where it parked.
final class InternalDeck: @unchecked Sendable {

    private let output = MIDIOutput(name: "Ollin Timecode")
    private let queue = DispatchQueue(label: "Timecode.deck")
    // Touched only on `queue`.
    private var timer: DispatchSourceTimer?
    private var frameRate = Timecode.FrameRate.fps25
    private var quarter = 0          // the next piece to send, 0…7
    private var frame = 0            // the frame the current set spells
    private var rolling = false

    init?() {
        do { try output.openVirtual(named: "Ollin Timecode") } catch { return nil }
    }

    func play() {
        queue.async { [self] in
            rolling = true
            schedule()
        }
    }

    func stop() {
        queue.async { [self] in
            timer?.cancel()
            timer = nil
            rolling = false
            output.send(timecode: Timecode(frameNumber: frame, frameRate: frameRate))
        }
    }

    /// Changes the rate the deck counts at, keeping its place on the wall clock.
    func setRate(_ rate: Timecode.FrameRate) {
        queue.async { [self] in
            let seconds = Double(frame) * frameRate.secondsPerFrame
            frameRate = rate
            frame = Int(seconds / rate.secondsPerFrame)
            quarter = 0
            if rolling { schedule() }
        }
    }

    /// (On `queue`.) Replaces the timer with one firing four times a frame.
    private func schedule() {
        timer?.cancel()
        let tick = DispatchSource.makeTimerSource(queue: queue)
        tick.schedule(deadline: .now(), repeating: frameRate.secondsPerFrame / 4, leeway: .milliseconds(1))
        tick.setEventHandler { [self] in
            // Each set spells the frame it started on; the pieces go out over
            // the next two frames.
            let code = Timecode(frameNumber: frame, frameRate: frameRate)
            output.send(MIDIMessage(.timecodeQuarterFrame(piece: quarter, value: code.quarterFrameValue(piece: quarter))))
            quarter += 1
            if quarter == 8 {
                quarter = 0
                frame += 2
            }
        }
        tick.resume()
        timer = tick
    }

    deinit {
        timer?.cancel()
        output.close()
    }
}

↑↓ move ↩ open