Ollin

Listening

the microphone read for what it is hearing: a live caption that corrects itself as more is heard, and named sounds (a clap, a knock, music) that appear as marks and fade. Both listeners share one microphone, and recognition runs on the Mac, so it asks for no permission of its own (SpeechListener, SoundClassifier, events())

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

/// Listening: the microphone read for *what* it is hearing rather than how loud
/// it is. Speak and the words appear as a caption that corrects itself; clap,
/// whistle, tap the desk, or play music and the sound is named.
///
/// Both listeners are bound to the same `AudioInput`, which is the point of the
/// tap hub: one microphone, several things listening to it.
///
/// The two reads are deliberately different shapes. A caption is a running
/// guess, so it is drawn. A sound event happens once, so it is drained and
/// leaves a mark. Recognition runs on this Mac and asks for no consent of its
/// own; the microphone asks for its own, the first time `start()` is called.
@main
final class Listening: Sketch {

    let mic = AudioInput()
    var speech: SpeechListener!
    var ears: SoundClassifier!

    /// One named sound, drawn where it landed and fading out.
    struct Mark {
        let label: String
        let confidence: Double
        let at: Vector2
        let born: Double
    }
    var marks: [Mark] = []

    let paper = Color(hex: 0x121316)
    let ink = Color(hex: 0xF2EFE9)
    let accent = Color(hex: 0xE4572E)

    override func setup() {
        speech = SpeechListener(of: mic)
        ears = SoundClassifier(of: mic, threshold: 0.55)
        try? mic.start()
        textFont(OutlineFont.system)
    }

    override func draw() {
        background(paper)

        drawLevel()
        drawNamedSounds()
        drawCaption()
        drawStatusLine()
    }

    /// A quiet ring of the live level, so the sketch is alive even in silence.
    private func drawLevel() {
        let radius = (120 + Double(mic.amplitude) * 900) * scale
        noFill()
        stroke(ink.withAlpha(0.12))
        strokeWeight(2 * scale)
        drawCircle(center: center, radius: radius)
    }

    /// Every sound the classifier names leaves a mark, placed on a ring by the
    /// first letter of its label so the same sound lands in the same place.
    private func drawNamedSounds() {
        for event in ears.events() {
            let hash = event.label.unicodeScalars.reduce(0) { ($0 &* 31 &+ Int($1.value)) & 0xFFFF }
            let angle = Double(hash) / 65535 * .tau
            let reach = (280 + Double(hash % 97) * 1.6) * scale
            marks.append(Mark(label: event.label, confidence: event.confidence,
                              at: center + Vector2(angle: angle) * reach,
                              born: time))
        }
        marks.removeAll { time - $0.born > 6 }

        textSize(15 * scale)
        textAlign(.center)
        for mark in marks {
            let age = (time - mark.born) / 6
            let fade = 1 - age * age
            noStroke()
            fill(accent.withAlpha(0.5 * fade))
            drawCircle(center: mark.at, radius: (6 + mark.confidence * 26) * scale * (1 + age))
            fill(ink.withAlpha(fade))
            drawText(mark.label.replacingOccurrences(of: "_", with: " "),
                     at: mark.at + Vector2(0, 34 * scale))
        }
    }

    /// The caption: the running guess, which is the read that is allowed to
    /// change its mind.
    private func drawCaption() {
        noStroke()
        fill(ink)
        textAlign(.center)
        textSize(34 * scale)
        let box = Rectangle(x: width * 0.1, y: height * 0.42, width: width * 0.8, height: height * 0.2)
        drawText(speech.caption, in: box)

        // What the classifier thinks it is hearing right now, under the words.
        if let top = ears.topClassification, top.confidence > 0.25 {
            fill(ink.withAlpha(0.4))
            textSize(17 * scale)
            drawText("sounds like \(top.label.replacingOccurrences(of: "_", with: " "))",
                     at: Vector2(width / 2, height * 0.66))
        }
    }

    private func drawStatusLine() {
        if let reason = speech.unavailableReason ?? ears.unavailableReason {
            return drawStatus(reason, style: .warning)
        }
        if !mic.isRunning { return drawCaption("Microphone: allow access to listen") }
        if !speech.isListening { return drawCaption("Starting the recognizer") }
        drawCaption("Say something, or clap. The caption corrects itself as it hears more.")
    }
}

↑↓ move ↩ open