Ollin

Spatial

sound that comes from somewhere: three chimes stand around you and one walks a circle past them. The camera is the listener (place(at:heardFrom:))

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

/// Sound that comes from somewhere.
///
/// Three chimes stand around you and one walks a circle past them. Each is a
/// `Synth` placed in the scene, and the camera is what hears: `place(at:heardFrom:)`
/// takes both facts at once, because a position says nothing until something is
/// listening and where the sketch is looking from is where it hears from.
///
/// Wear headphones. The placing is done the way the ear works it out, so a
/// sound behind you is behind you rather than merely quiet. On speakers it
/// falls back to a plain left and right.
///
/// Drag to move the camera, and the whole scene turns around your head.
///
/// It exports placed, too. Where each chime is and where it is being heard from
/// are written down as the frames are drawn, so the walk past your left ear is
/// in the file:
///
/// ```sh
/// swift run --package-path Examples Example-Audio-Spatial \
///     --export-video spatial.mp4 --frames 480
/// ```
@main
final class Spatial: Sketch {

    @Param(2 ... 30, icon: "waveform.path", group: "Room") var range = 12.0
    @Param(0.1 ... 2, icon: "figure.walk", group: "Room") var walkSpeed = 0.55

    struct Post {
        var synth: Synth
        var at: Vector3
        var pitch: Pitch
        var tone: Double
        var lastRang = -10.0
    }

    var posts: [Post] = []
    let walker = Synth(.nylon, polyphony: 6)
    var walkerPitch = 0
    var lastStep = -10.0
    var pings: [(at: Vector3, start: Double, tone: Double)] = []

    let tuning = Scale(.minorPentatonic, root: "A3")

    override func setup() {
        noStroke()
        lightingPreset(.studio)
        for (index, angle) in [0.0, 2.09, 4.19].enumerated() {
            let synth = Synth(.chime, polyphony: 6)
            synth.gain = 0.32
            synth.reverb = Reverb(.hall, mix: 0.3)
            posts.append(Post(synth: synth,
                              at: Vector3(cos(angle) * 5, 0, sin(angle) * 5),
                              pitch: tuning[index * 2],
                              tone: Double(index) / 3))
        }
        walker.gain = 0.4
    }

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

        // The camera is the listener, so it is worked out first and handed to
        // everything that makes a sound.
        cameraShowcase(.autoOrbit(period: 26), target: Vector3(0, 0.8, 0),
                       radius: 13, elevation: 0.34)
        groundGrid()
        // Whatever the rig settled on this frame is what the sketch hears from.
        guard let eye = activeCamera else { return }

        let walkerAt = Vector3(cos(time * walkSpeed) * 3.2, 0, sin(time * walkSpeed) * 3.2)
        walker.place(at: walkerAt, heardFrom: eye)
        walker.hearingDistance = 1...range

        // The walker plays as it goes, so there is always something moving.
        if time - lastStep > 0.42 {
            lastStep = time
            walkerPitch = (walkerPitch + Int(random(1, 4))) % 10
            walker.play(tuning[walkerPitch + 5], velocity: 0.75, for: 0.9)
            pings.append((at: walkerAt, start: time, tone: 0.85))
        }

        for index in posts.indices {
            posts[index].synth.place(at: posts[index].at, heardFrom: eye)
            posts[index].synth.hearingDistance = 1...range

            // A post answers when the walker comes near it.
            let apart = (walkerAt - posts[index].at).length
            if apart < 2.4, time - posts[index].lastRang > 1.6 {
                posts[index].lastRang = time
                posts[index].synth.play(posts[index].pitch, velocity: 0.9, for: 3)
                pings.append((at: posts[index].at, start: time, tone: posts[index].tone))
            }
        }

        drawScene(walkerAt: walkerAt)

        drawCaption("Wear headphones. Each sound comes from where its shape is.", edge: .top)
        drawCaption("Three chimes standing still and one walking past them, "
                    + "heard from wherever the camera is.")
    }

    private func drawScene(walkerAt: Vector3) {
        lights()
        for post in posts {
            withState {
                translate(post.at.x, post.at.y + 1.1, post.at.z)
                fill(Colormap.magma.color(at: 0.3 + post.tone * 0.5))
                material(.metal(roughness: 0.3))
                drawCylinder(radius: 0.28, height: 2.2)
            }
        }

        withState {
            translate(walkerAt.x, walkerAt.y + 0.5, walkerAt.z)
            fill(Color(white: 0.92))
            material(.plastic)
            drawIcosphere(radius: 0.42)
        }

        // Where a sound came from, spreading out from it. Drawn as flat rings
        // facing the camera rather than as spheres: a mesh takes its color from
        // the fill, so an outlined sphere is a solid one.
        pings.removeAll { time - $0.start > 2.2 }
        blendMode(.add)
        for ping in pings {
            let age = (time - ping.start) / 2.2
            withBillboard(at: Vector3(ping.at.x, ping.at.y + 0.6, ping.at.z)) {
                noFill()
                stroke(Colormap.magma.color(at: 0.35 + ping.tone * 0.45)
                    .withAlpha(pow(1 - age, 1.8) * 0.75))
                strokeWeight(2.5 * scale)
                drawCircle(0, 0, (14 + age * 150) * scale)
            }
        }
        blendMode(.normal)
        noStroke()
    }
}

↑↓ move ↩ open