Merge pull request #1999 from 1Jamie/audio-pitch-fix

This commit is contained in:
bryanthaboi
2026-08-30 19:05:56 -04:00
committed by GitHub
7 changed files with 71 additions and 37 deletions
+6 -2
View File
@@ -394,13 +394,17 @@ function Game:logicSpeed()
end
function Game:update(dt)
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
-- Give the accumulator room for one full frame at the current speed,
-- or the anti-spiral clamp quietly caps every level above ~15X.
local speed = self:logicSpeed()
local Sound = require("src.core.Sound")
if Sound.setRate then Sound.setRate(speed) end
FixedStep.maxAccum = FixedStep.catchupLimit(speed)
FixedStep:update(dt, speed)
-- Audio runs off real time at a fixed 60Hz regardless of game speed or
-- display refresh, so fades and chip synthesis keep their intended tempo
-- whether we are at 1X, 10X, or running with vsync disabled. One-shot
-- SFX stay at natural pitch too (#1990/#1991/#1997); WaitForSoundToFinish
-- gates still release early at high speed via their logic-frame budget.
local step = FixedStep.STEP
self.audioAccum = math.min((self.audioAccum or 0) + dt, 0.25)
while self.audioAccum >= step do
+4 -2
View File
@@ -1261,12 +1261,14 @@ function Game2:update(dt)
-- they are presentational, so fast-forward must not speed them up.
require("src.render.Pipelines").update(dt)
pcall(function() require("src.core.DiscordPresence").update(dt) end)
-- GAME SPEED scales the logic clock only, exactly as the Gen 1 path does:
-- audio runs off its own real-time accumulator, so music and sfx keep their
-- tempo at every multiplier (#1990/#1991/#1997). speedOverride is the
-- driver/CLI hook and wins over the saved option.
-- pokegold engine/menus/intro_menu.asm:848 IntroSequence: boot cinema runs on the same clock as the overworld
local speed = math.max(1,
tonumber(self.speedOverride) or tonumber(self.options and self.options.speed)
or 1)
local Sound = require("src.core.Sound")
if Sound.setRate then Sound.setRate(speed) end
if self.phase == "boot" then
FixedStep.maxAccum = FixedStep.catchupLimit(speed)
FixedStep:update(dt, speed)
+4 -1
View File
@@ -3,7 +3,10 @@
-- Speeding up means running the 1/60 fixed step N times per real frame
-- (Game:update), so everything driven by the step -- movement, text,
-- battle timing, scripts -- advances N times faster while staying
-- deterministic.
-- deterministic. Audio deliberately does NOT scale: Music.update drives
-- fade counters and ChipAudio synthesis off its own real-time 60Hz
-- accumulator in Game:update, so music and sfx play at normal pitch and
-- tempo at every speed (#1990/#1991/#1997).
--
-- Vsync still caps how much work a real frame can do, so 10X is a target
-- rather than a promise on a slow machine -- the logic simply runs as many
+9 -2
View File
@@ -159,7 +159,11 @@ local function deviceSuspended()
return ChipAudio ~= nil and ChipAudio.isSuspended()
end
-- home/vblank.asm:58-72
-- Optional one-shot playback rate (Source:setPitch multiplier), capped the
-- way the cart's audio engine clamps under frame-skip (home/vblank.asm:58-72).
-- GAME SPEED does NOT drive this: the port keeps SFX at natural pitch at
-- every multiplier (#1990/#1991/#1997). Callers may still set it for tests
-- or a future opt-in; SessionLifecycle resets it on teardown.
local FF_PITCH_MAX = 4
local rate = 1
@@ -180,7 +184,10 @@ local function applyRate(src, base)
return src
end
-- home/delay.asm:14
-- WaitForSoundToFinish budget in logic frames (home/delay.asm:14).
-- At N× GAME SPEED the same budget passes N× sooner in wall time, so a
-- gate releases early instead of stalling the battle/script on a full-length
-- jingle -- without pitching the SFX (#1952 vs #1990).
function Sound.waitFrames(src, fallback)
if not src then return 0 end
local okd, dur = pcall(src.getDuration, src)
@@ -1,5 +1,5 @@
-- home/vblank.asm:58-72
-- home/delay.asm:14
-- Wait gates must release early at high GAME SPEED (#1952) without
-- pitching one-shot SFX (#1990/#1991/#1997).
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots"
@@ -26,6 +26,10 @@ return function(game)
ow:pushBattle(battle)
U.log("logic speed", game:logicSpeed(), "sfx rate", Sound.rate())
if Sound.rate() ~= 1 then
error(("bug1952: Game:update pitched SFX off GAME SPEED (rate %s at 4X)")
:format(tostring(Sound.rate())))
end
for _ = 1, 240 do
if battle.phase == "menu" then break end
@@ -53,6 +57,10 @@ return function(game)
local okp, p = pcall(src.getPitch, src)
dur = okd and d or nil
pitch = okp and p or nil
if pitch and pitch ~= 1 then
error(("bug1952: the level-up fanfare was pitched with GAME SPEED (%s)")
:format(tostring(pitch)))
end
if not shot then
shot = U.shot(game, DIR .. "/bug1952_fanfare.png")
end
@@ -74,10 +82,6 @@ return function(game)
error(("bug1952: the 4X battle still held the full fanfare (%.3fs of %.3fs)")
:format(held, dur))
end
if dur and pitch and pitch > 0 and held < (dur / pitch) * 0.8 then
error(("bug1952: the fanfare was cut short (%.3fs of a %.3fs pitched jingle)")
:format(held, dur / pitch))
end
U.log("PASS the fanfare hold scaled with the logic clock")
U.log("PASS the fanfare kept natural pitch and the hold released early")
love.event.quit()
end
@@ -1,5 +1,6 @@
-- ../pokecrystal/home/vblank.asm:58-72
-- ../pokecrystal/home/delay.asm:14
-- GAME SPEED must not pitch one-shot SFX (#1990/#1991/#1997). Wait gates
-- still release early via their logic-frame budget (#1952); that path is
-- covered by the engine + Gen 1 battle drivers.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots"
@@ -22,8 +23,8 @@ return function(game)
game.speedOverride = 4
U.wait(2)
U.log("gen2 speed override", 4, "sfx rate", Sound.rate())
if Sound.rate() ~= 4 then
error(("bug1952: Game2:update never drove Sound.setRate (rate %s at 4X)")
if Sound.rate() ~= 1 then
error(("bug1952: Game2:update pitched SFX off GAME SPEED (rate %s at 4X)")
:format(tostring(Sound.rate())))
end
@@ -34,8 +35,8 @@ return function(game)
local okp, pitch = pcall(src.getPitch, src)
if not (okd and dur) then error("bug1952: no duration for " .. name) end
U.log(("%s duration %.3fs pitch %s"):format(name, dur, tostring(pitch)))
if not (okp and pitch == 4) then
error(("bug1952: the gen 2 jingle played at natural pitch (%s)")
if not (okp and pitch == 1) then
error(("bug1952: the gen 2 jingle was pitched with GAME SPEED (%s)")
:format(tostring(pitch)))
end
@@ -58,14 +59,16 @@ return function(game)
end
end
if not held then error("bug1952: the gen 2 jingle never ended") end
U.log(("held %.3fs of a %.3fs jingle pitched to %s"):format(held, dur, pitch))
if held > dur * 0.75 then
error(("bug1952: the 4X jingle still ran the full length (%.3fs of %.3fs)")
U.log(("held %.3fs of a %.3fs natural-pitch jingle"):format(held, dur))
-- Freely playing (no wait gate cutting it) must take roughly the full
-- duration -- proof the 4X logic clock did not chipmunk the source.
if held < dur * 0.7 then
error(("bug1952: the jingle finished early under 4X (%.3fs of %.3fs)")
:format(held, dur))
end
if held < (dur / pitch) * 0.7 then
error(("bug1952: the jingle was cut short (%.3fs of a %.3fs pitched jingle)")
:format(held, dur / pitch))
if held > dur * 1.4 then
error(("bug1952: the jingle dragged past its length (%.3fs of %.3fs)")
:format(held, dur))
end
game.speedOverride = nil
@@ -76,6 +79,6 @@ return function(game)
end
U.shot(game, DIR .. "/bug1952_gold_after.png")
U.log("PASS the gen 2 jingle scaled with the logic clock")
U.log("PASS the gen 2 jingle kept natural pitch under GAME SPEED")
love.event.quit()
end
+21 -10
View File
@@ -1,5 +1,5 @@
-- home/vblank.asm:58-72
-- home/delay.asm:14
-- WaitForSoundToFinish must not stall logic at high GAME SPEED (#1952),
-- but one-shot SFX stay at natural pitch (#1990/#1991/#1997).
package.path = "./?.lua;./?/init.lua;" .. package.path
@@ -45,9 +45,9 @@ eq(Sound.waitFrames(stub(2, 4)), 32, "and a quarter at the 4X clamp")
Sound.setRate(4)
eq(Sound.waitFrames(stub(2, 4)), 122,
"at 4X a 4X-pitched jingle still spans the same 120 logic frames")
"at rate 4 a 4X-pitched jingle still spans the same 120 logic frames")
eq(Sound.waitFrames(stub(2, 1)), 482,
"a source fast-forward could not pitch costs proportionally more frames")
"a source the rate could not pitch costs proportionally more frames")
Sound.setRate(1)
local broken = { getDuration = function() error("no duration") end }
@@ -85,9 +85,22 @@ check(st2:updateQueue() == false, "and releases the frame the sfx goes quiet")
eq(st2.waitSoundLeft, nil, "the budget is cleared with the source")
local Game = require("src.core.Game")
local Game2 = require("src.core.Game2")
require("src.core.FixedStep"):init(function() end)
local function gen1(speed)
return setmetatable({
speedOverride = speed,
audioAccum = 0,
stack = { top = function() return nil end },
logicSpeed = function(self)
return require("src.core.GameSpeed").clamp(self.speedOverride or 1)
end,
updateSync = function() end,
}, { __index = Game })
end
local function gen2(speed)
return setmetatable({
phase = "boot",
@@ -99,14 +112,12 @@ local function gen2(speed)
end
Sound.setRate(1)
gen1(4):update(0)
eq(Sound.rate(), 1, "a Gen 1 frame does not pitch SFX from GAME SPEED")
gen2(4):update(0)
eq(Sound.rate(), 4, "a Gen 2 frame drives the one-shot rate off its own speed")
gen2(2):update(0)
eq(Sound.rate(), 2, "and follows it down")
eq(Sound.rate(), 1, "and neither does a Gen 2 frame")
gen2(100):update(0)
eq(Sound.rate(), 4, "the Gen 2 path clamps through the same setRate")
gen2(1):update(0)
eq(Sound.rate(), 1, "1X is rate 1 in Gen 2 too")
eq(Sound.rate(), 1, "even at a clamped 200X logic multiplier")
Sound.setRate(4)
require("src.core.SessionLifecycle").endGameSession(nil)