Flight of a fly

Listen to a buzzing fly wander around a stationary listener. Headphones are recommended: HRTF rendering turns the fly's changing 3D position into clear left, right, front and rear movement.


    
main.lua
--[[ Flight of a fly

     A small spatial-audio demo. The listener stays still while a buzzing fly
     wanders around the listener. Best experienced with
     headphones.
]]

local natiny = require("natiny")
local demo = require("common.modules.demo")

-- ── The head ────────────────────────────────────────────────────────────
local HEAD_X, HEAD_Y, HEAD_Z = 0.0, 1.6, 0.0

-- ── The flight ──────────────────────────────────────────────────────────
local FLIGHT_SPEED = 2.0

-- ── The drone ───────────────────────────────────────────────────────────
-- Full volume within SOUND_NEAR - about a hand's width, so a fly at the ear
-- is loud - and quiet but never silent by SOUND_FAR.
local SOUND_NEAR, SOUND_FAR, SOUND_ROLLOFF = 0.25, 20.0, 1.4

local PITCH = 1.0

local BG = { 21 / 255, 30 / 255, 44 / 255 } -- #151E2C

-- Nothing on the command line means AUTO: Direct3D 12 on Windows, Vulkan on
-- Linux, WebGPU on the web.
if not natiny.backend.init(arg and arg[1] or natiny.backend.AUTO) then
    os.exit(1)
end
local _, backend_name = natiny.backend.get_current()
print("[fly] backend: " .. backend_name)

local win = natiny.window.create("Natiny - flight of a fly", 1280, 720)
natiny.window.set_scale_mode(win, natiny.window.SCALE_MODE_STRETCH)

-- ── Sound ────────────────────────────────────────────────────────────
local audio = natiny.audio.init()
local source, clip

-- ── Random flight ──────────────────────────────────
math.randomseed(os.time())

local function random_range(minimum, maximum)
    return minimum + math.random() * (maximum - minimum)
end

-- Random frequencies and phases make the combined waves non-repeating in
-- practice, while the clock itself always advances at the same rate.
local turn_rate = random_range(0.55, 0.95)
local sway_rate = random_range(0.35, 0.75)
local jitter_rate = random_range(1.1, 1.8)
local radius_rate = random_range(0.25, 0.55)
local radius_jitter_rate = random_range(0.8, 1.4)
local height_rate = random_range(0.4, 0.9)
local height_jitter_rate = random_range(1.0, 1.7)

local phase_a = random_range(0.0, math.pi * 2.0)
local phase_b = random_range(0.0, math.pi * 2.0)
local phase_c = random_range(0.0, math.pi * 2.0)
local phase_d = random_range(0.0, math.pi * 2.0)
local flight_clock = 0.0

local function update_fly(dt)
    flight_clock = flight_clock + dt * FLIGHT_SPEED

    local angle = flight_clock * turn_rate
                + math.sin(flight_clock * sway_rate + phase_a) * 1.1
                + math.sin(flight_clock * jitter_rate + phase_b) * 0.35
    local radius = 1.65
                 + math.sin(flight_clock * radius_rate + phase_c) * 0.75
                 + math.cos(flight_clock * radius_jitter_rate + phase_a) * 0.30
    local height = math.sin(flight_clock * height_rate + phase_d) * 0.65
                   + math.sin(flight_clock * height_jitter_rate + phase_b) * 0.22

    if source then
        natiny.entity.set_position(source, natiny.entity.SPACE_WORLD,
                                   HEAD_X + math.sin(angle) * radius,
                                   HEAD_Y + height,
                                   HEAD_Z + math.cos(angle) * radius)
    end
end
-- ── Frame ────────────────────────────────────────────────────────────
-- Nothing to draw but the plate: this demo is what you hear.
local function frame(dt)
    natiny.window.bind(win, function()
        update_fly(dt)
        natiny.render.clear(BG[1], BG[2], BG[3], 1.0)
    end)
end

-- The Ogg has landed by the time on_loaded runs, so the clip is only a matter
-- of asking the shell for it.
local function start_sound()
    if audio then
        local ear = natiny.audio.create_listener()
        natiny.entity.set_position(ear, natiny.entity.SPACE_WORLD,
                                   HEAD_X, HEAD_Y, HEAD_Z)

        -- The clip is the decoded audio; the shell still holds the Ogg it
        -- was decoded from and lets those bytes go after this runs.  One
        -- clip feeds any number of sources; a swarm would ask for it here
        -- exactly like this.
        local ok, err = pcall(function()
            clip = natiny.clip.load(demo:resource("data/examples/flight_of_a_fly/audio/fly.ogg"))
            source = natiny.audio.create_source(clip)
        end)
        if not ok then
            print("[fly] no sound: " .. tostring(err))
            source = nil
        end
    end

    -- Keep binaural rendering enabled for the whole demo.
    natiny.audio.set_hrtf(true)

    if source then
        natiny.audio.source_set_spatial(source, true)
        natiny.audio.source_set_attenuation(source, natiny.audio.ATTENUATION_INVERSE,
                                            SOUND_NEAR, SOUND_FAR, SOUND_ROLLOFF)
        natiny.audio.source_set_loop(source, true)
        natiny.audio.source_set_pitch(source, PITCH)
        natiny.audio.source_play(source)
        print(string.format("[fly] drone: %.1f s, looping, from the fly · HRTF on",
                            natiny.clip.get_duration(clip)))
    elseif audio then
        print("[fly] the device is open, but there is nothing to play")
    else
        print("[fly] no audio output on this machine")
    end

    demo:set_user_text(source and "Flight of a Fly - headphones recommended"
                              or "Flight of a Fly - sound unavailable")
end

demo:run({
    window = win,
    title  = "Flight of a fly",
    resources = { "data/examples/flight_of_a_fly/audio/fly.ogg" },

    on_loaded = start_sound,
    loop = frame,
})

natiny.backend.shutdown()