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