oma-tank/audio/sounds.lua
2026-04-18 11:35:06 +01:00

183 lines
5.5 KiB
Lua

local Sounds = {}
local sources = {}
local SAMPLE_RATE = 44100
local function makeSoundData(duration, generator)
local samples = math.floor(SAMPLE_RATE * duration)
local sd = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 1)
for i = 0, samples - 1 do
local t = i / SAMPLE_RATE
local p = i / samples
sd:setSample(i, math.max(-1, math.min(1, generator(t, p))))
end
return sd
end
local function makeSource(sd)
return love.audio.newSource(sd, "static")
end
local function genFire(t, p)
local freq = 800 - p * 600
local env = (1 - p) ^ 2
return math.sin(2 * math.pi * freq * t) * env * 0.4
end
local function genEnemyFire(t, p)
local freq = 400 - p * 250
local env = (1 - p) ^ 2
return math.sin(2 * math.pi * freq * t) * env * 0.35
end
local function genHitEnemy(t, p)
local env = (1 - p) ^ 1.5
local sine = math.sin(2 * math.pi * (80 + 40*(1-p)) * t) * 0.5
local noise = (math.random() * 2 - 1) * 0.4
return (sine + noise) * env * 0.5
end
local function genHitPlayer(t, p)
local env = (1 - p) ^ 1.0
local sine = math.sin(2 * math.pi * (50 + 30*(1-p)) * t) * 0.5
local noise = (math.random() * 2 - 1) * 0.5
local pulse = 0.7 + math.sin(2 * math.pi * 4 * t) * 0.3
return (sine + noise) * env * pulse * 0.5
end
local function genEngine(t, p)
local noise = (math.random() * 2 - 1) * 0.2
local low = math.sin(2 * math.pi * 35 * t) * 0.15
return (noise + low) * 0.3
end
local function genEnemyAppear(t, p)
local freq = 200 + p * 300
local env = 0.6
if p < 0.1 then env = p / 0.1 * 0.6 end
if p > 0.8 then env = (1 - p) / 0.2 * 0.6 end
return math.sin(2 * math.pi * freq * t) * env * 0.3
end
local function genExtraLife(t, p)
local freq
if p < 0.33 then freq = 400
elseif p < 0.66 then freq = 600
else freq = 800 end
local env = 0.7
if p < 0.05 then env = p / 0.05 * 0.7 end
if p > 0.9 then env = (1-p) / 0.1 * 0.7 end
return math.sin(2 * math.pi * freq * t) * env * 0.35
end
local function genMissileLaunch(t, p)
-- Rising whistle: frequency ramps up
local freq = 300 + p * 900
local env
if p < 0.1 then env = p / 0.1
elseif p > 0.8 then env = (1 - p) / 0.2
else env = 1 end
local tone = math.sin(2 * math.pi * freq * t)
local noise = (math.random() * 2 - 1) * 0.1
return (tone + noise) * env * 0.25
end
local function genScreenCrack(t, p)
-- Glass break: noise burst + downward pitch sweep
local env = (1 - p) ^ 1.5
local noise = (math.random() * 2 - 1)
local tone = math.sin(2 * math.pi * (1400 - p * 1200) * t)
return (noise * 0.6 + tone * 0.2) * env * 0.45
end
local function genMotionBlocked(t, p)
local env = (1 - p) ^ 2
return math.sin(2 * math.pi * 160 * t) * env * 0.25
end
local function genSaucerHum(t, _)
-- Long looping wavering tone — distinctive UFO cue
local base = 220
local vibrato = math.sin(2 * math.pi * 5 * t) * 12
return math.sin(2 * math.pi * (base + vibrato) * t) * 0.22
end
function Sounds.init()
sources = {}
local defs = {
fire = {0.08, genFire},
enemy_fire = {0.1, genEnemyFire},
hit_enemy = {0.4, genHitEnemy},
hit_player = {0.8, genHitPlayer},
engine = {0.3, genEngine},
enemy_appear = {0.3, genEnemyAppear},
extra_life = {0.3, genExtraLife},
missile_launch = {0.6, genMissileLaunch},
screen_crack = {0.7, genScreenCrack},
motion_blocked = {0.12, genMotionBlocked},
}
for name, def in pairs(defs) do
sources[name] = makeSource(makeSoundData(def[1], def[2]))
end
-- Saucer hum is a looping source (not cloned per-play) so it can be panned live
local humData = makeSoundData(1.0, genSaucerHum)
sources.saucer_hum = love.audio.newSource(humData, "static")
sources.saucer_hum:setLooping(true)
sources.saucer_hum:setRelative(false)
sources.saucer_hum:setVolume(0.6)
-- playMusic() silently no-ops if the music asset isn't bundled
end
function Sounds.play(name)
local src = sources[name]
if not src then return end
src:clone():play()
end
function Sounds.startSaucerHum()
local src = sources.saucer_hum
if not src then return end
if not src:isPlaying() then
src:setPosition(0, 0, 0)
src:play()
end
end
function Sounds.stopSaucerHum()
local src = sources.saucer_hum
if src and src:isPlaying() then src:stop() end
end
function Sounds.updateSaucerHum(bearingRadians, distance)
-- bearingRadians: atan2(side, forward) in player frame; 0 = straight ahead
-- Map bearing sine to x pan, distance to attenuation via z distance
local src = sources.saucer_hum
if not src or not src:isPlaying() then return end
local x = math.sin(bearingRadians) * 5
local z = math.max(1, distance / 200)
src:setPosition(x, 0, z)
end
function Sounds.playMusic(name)
local path = "assets/music/" .. name .. ".ogg"
if love.filesystem.getInfo and love.filesystem.getInfo(path) then
local ok, src = pcall(love.audio.newSource, path, "stream")
if ok and src then
src:setLooping(false)
src:play()
sources["music_" .. name] = src
end
end
-- Silently no-op if the asset isn't bundled
end
function Sounds.stopMusic()
for k, src in pairs(sources) do
if k:sub(1, 6) == "music_" and src.isPlaying and src:isPlaying() then
src:stop()
end
end
end
return Sounds