Compare commits

...
1 Commits
Author SHA1 Message Date
Jean-André Santoni bd1ede2321 Pacing 2026-04-12 22:50:01 +02:00
7 changed files with 666 additions and 116 deletions

No files matched your search

+312 -55
View File
@@ -3,7 +3,9 @@
package audio
import (
"encoding/binary"
"log"
"math"
"path/filepath"
"time"
"unsafe"
@@ -14,16 +16,31 @@ import (
"golang.org/x/mobile/exp/audio/al"
)
const bufSize = 1024 * 8
const (
bufSize = 1024
bytesPerFrame = 4
framesPerBuffer = bufSize / bytesPerFrame
outputRate = 48000
targetLatencyMs = 96
minNumBuffers = 2
audioMaxTimingSkew = 0.05
maxPlaybackRateSkew = 0.005
)
var (
source al.Source
buffers []al.Buffer
rate int32
numBuffers int32
tmpBuf [bufSize]byte
tmpBufPtr int32
resPtr int32
source al.Source
buffers []al.Buffer
freeBufs []al.Buffer
rate int32
inputRate float64
videoSyncRate float64
numBuffers int32
queueBytes int32
inputBuf []int16
readPhase float64
tmpBuf [bufSize]byte
srcRatioOrig float64
srcRatioCurr float64
)
// Effects are sound effects
@@ -31,9 +48,17 @@ var Effects map[string]*Effect
// SetVolume sets the audio volume
func SetVolume(vol float32) {
if source == 0 {
return
}
source.SetGain(vol)
}
// Stop clears queued game audio without tearing down the OpenAL device.
func Stop() {
clearQueue()
}
// Init initializes the audio device
func Init() {
err := al.OpenDevice()
@@ -55,20 +80,76 @@ func Init() {
// Reconfigure initializes the audio package. It sets the number of buffers, the
// volume and the source for the games.
func Reconfigure(r int32) {
rate = r
numBuffers = 4
releaseSource()
log.Printf("[OpenAL]: Using %v buffers of %v bytes.\n", numBuffers, bufSize)
rate = outputRate
inputRate = float64(r)
numBuffers = queueBufferCount(rate)
queueBytes = numBuffers * bufSize
updateInputRate()
log.Printf(
"[OpenAL]: Using %v buffers of %v bytes (~%v ms queued).\n",
numBuffers,
bufSize,
queueLatencyMs(rate, queueBytes),
)
source = al.GenSources(1)[0]
buffers = al.GenBuffers(int(numBuffers))
resPtr = numBuffers
tmpBufPtr = 0
freeBufs = append(freeBufs[:0], buffers...)
inputBuf = inputBuf[:0]
readPhase = 0
tmpBuf = [bufSize]byte{}
source.SetGain(settings.Current.AudioVolume)
}
func releaseSource() {
if source == 0 {
return
}
clearQueue()
al.DeleteSources(source)
source = 0
if len(buffers) > 0 {
al.DeleteBuffers(buffers...)
}
buffers = nil
freeBufs = nil
queueBytes = 0
inputBuf = nil
readPhase = 0
tmpBuf = [bufSize]byte{}
inputRate = 0
videoSyncRate = 0
srcRatioOrig = 0
srcRatioCurr = 0
}
func clearQueue() {
if source == 0 {
return
}
al.StopSources(source)
queued := source.BuffersQueued()
if queued > 0 {
unqueued := make([]al.Buffer, queued)
source.UnqueueBuffers(unqueued...)
}
freeBufs = append(freeBufs[:0], buffers...)
inputBuf = inputBuf[:0]
readPhase = 0
tmpBuf = [bufSize]byte{}
srcRatioCurr = srcRatioOrig
}
func min(a, b int32) int32 {
if a < b {
return a
@@ -76,61 +157,237 @@ func min(a, b int32) int32 {
return b
}
func alUnqueueBuffers() bool {
func clampFloat64(v, low, high float64) float64 {
if v < low {
return low
}
if v > high {
return high
}
return v
}
func queueBufferCount(rate int32) int32 {
totalBytes := int32(math.Ceil(float64(rate*bytesPerFrame*targetLatencyMs) / 1000.0))
buffers := int32(math.Ceil(float64(totalBytes) / float64(bufSize)))
if buffers < minNumBuffers {
return minNumBuffers
}
return buffers
}
func adjustedInputRate(inputRate, inputFPS, targetVideoSyncRate float64) float64 {
if inputRate <= 0 {
return 0
}
if inputFPS <= 0 || targetVideoSyncRate <= 0 {
return inputRate
}
timingSkew := math.Abs(1.0 - inputFPS/targetVideoSyncRate)
if timingSkew <= audioMaxTimingSkew {
return inputRate * targetVideoSyncRate / inputFPS
}
return inputRate
}
func updateInputRate() {
if inputRate <= 0 || rate <= 0 {
srcRatioOrig = 0
srcRatioCurr = 0
return
}
syncInputRate := adjustedInputRate(inputRate, state.CoreFPS, videoSyncRate)
if syncInputRate <= 0 {
syncInputRate = inputRate
}
srcRatioOrig = float64(rate) / syncInputRate
srcRatioCurr = srcRatioOrig
}
// SetVideoTiming updates the current video timing used for audio rate control.
func SetVideoTiming(refreshHz float64, swapInterval int) {
syncRate := refreshHz
if swapInterval > 1 {
syncRate /= float64(swapInterval)
}
if math.Abs(syncRate-videoSyncRate) < 0.01 {
return
}
videoSyncRate = syncRate
updateInputRate()
}
func queueLatencyMs(rate, bytes int32) int32 {
if rate <= 0 {
return 0
}
return int32(math.Round(float64(bytes*1000) / float64(rate*bytesPerFrame)))
}
func pendingQueueBytes(queued, processed, offset int32) int32 {
active := queued - processed
if active <= 0 {
return 0
}
bytes := active * bufSize
if offset > 0 {
bytes -= min(offset, bufSize)
}
if bytes < 0 {
return 0
}
return bytes
}
func queuedWholeBufferBytes() int32 {
if source == 0 {
return 0
}
queued := source.BuffersQueued()
processed := source.BuffersProcessed()
return pendingQueueBytes(queued, processed, 0)
}
func sourceWriteAvail() int32 {
alUnqueueBuffers()
avail := queueBytes - queuedWholeBufferBytes()
if avail < 0 {
return 0
}
if avail > queueBytes {
return queueBytes
}
return avail
}
func currentResampleStep(writeAvail int32) float64 {
if rate <= 0 || queueBytes <= 0 || srcRatioOrig <= 0 {
return 1.0
}
halfSize := float64(queueBytes) / 2
direction := 0.0
if halfSize > 0 {
direction = (float64(writeAvail) - halfSize) / halfSize
}
adjust := 1.0 + maxPlaybackRateSkew*clampFloat64(direction, -1.0, 1.0)
srcRatioCurr = srcRatioOrig * adjust
return 1.0 / srcRatioCurr
}
func alUnqueueBuffers() int32 {
if source == 0 {
return 0
}
val := source.BuffersProcessed()
if val <= 0 {
return false
return 0
}
source.UnqueueBuffers(buffers[resPtr:val]...)
resPtr += val
return true
unqueued := make([]al.Buffer, val)
source.UnqueueBuffers(unqueued...)
freeBufs = append(freeBufs, unqueued...)
return val
}
func alGetBuffer() al.Buffer {
if resPtr == 0 {
for {
if alUnqueueBuffers() {
break
}
time.Sleep(time.Millisecond)
}
}
resPtr--
return buffers[resPtr]
}
func fillInternalBuf(buf []byte) int32 {
readSize := min(bufSize-tmpBufPtr, int32(len(buf)))
copy(tmpBuf[tmpBufPtr:], buf[:readSize])
tmpBufPtr += readSize
return readSize
}
func write(buf []byte, size int32) int32 {
written := int32(0)
if state.FastForward {
return size
}
for size > 0 {
rc := fillInternalBuf(buf[written:])
written += rc
size -= rc
if tmpBufPtr != bufSize {
for len(freeBufs) == 0 {
if alUnqueueBuffers() > 0 {
break
}
buffer := alGetBuffer()
// OpenAL does not provide a blocking dequeue API, so keep the queue
// short and sleep briefly while waiting for the next buffer to retire.
time.Sleep(time.Millisecond)
}
last := len(freeBufs) - 1
buffer := freeBufs[last]
freeBufs = freeBufs[:last]
return buffer
}
func appendInput(buf []byte) int32 {
readSize := int32(len(buf))
for i := 0; i+1 < len(buf); i += 2 {
inputBuf = append(inputBuf, int16(binary.LittleEndian.Uint16(buf[i:])))
}
return readSize
}
func availableInputFrames() int {
return len(inputBuf) / 2
}
func requiredInputFrames(step float64) int {
return int(math.Ceil(readPhase+step*float64(framesPerBuffer-1))) + 2
}
func renderOutputBuffer(step float64) {
for frame := 0; frame < framesPerBuffer; frame++ {
base := int(readPhase)
frac := readPhase - float64(base)
left0 := float64(inputBuf[base*2])
right0 := float64(inputBuf[base*2+1])
left1 := float64(inputBuf[(base+1)*2])
right1 := float64(inputBuf[(base+1)*2+1])
left := int16(math.Round(left0 + (left1-left0)*frac))
right := int16(math.Round(right0 + (right1-right0)*frac))
offset := frame * bytesPerFrame
binary.LittleEndian.PutUint16(tmpBuf[offset:], uint16(left))
binary.LittleEndian.PutUint16(tmpBuf[offset+2:], uint16(right))
readPhase += step
}
discardFrames := int(readPhase)
if discardFrames <= 0 {
return
}
discardSamples := discardFrames * 2
copy(inputBuf, inputBuf[discardSamples:])
inputBuf = inputBuf[:len(inputBuf)-discardSamples]
readPhase -= float64(discardFrames)
}
func write(buf []byte, size int32) int32 {
if state.FastForward {
clearQueue()
return size
}
if source == 0 || len(buffers) == 0 {
return size
}
written := appendInput(buf[:size])
for {
writeAvail := sourceWriteAvail()
step := currentResampleStep(writeAvail)
if availableInputFrames() < requiredInputFrames(step) {
break
}
buffer := alGetBuffer()
renderOutputBuffer(step)
buffer.BufferData(al.FormatStereo16, tmpBuf[:], rate)
tmpBufPtr = 0
source.QueueBuffers(buffer)
if source.State() != al.Playing {
+98 -39
View File
@@ -1,58 +1,117 @@
package audio
import (
"math"
"testing"
)
func Test_alUnqueueBuffers(t *testing.T) {
t.Run("Return false if no buffers were processed", func(t *testing.T) {
got := alUnqueueBuffers()
if got {
t.Errorf("alUnqueueBuffers() = %v, want %v", got, false)
}
})
}
func Test_Sample(t *testing.T) {
t.Run("Doesn't crash when called", func(t *testing.T) {
Sample(-30000, -30000)
Sample( 30000, 30000)
})
}
func Test_fillInternalBuf(t *testing.T) {
Reconfigure(48000)
type args struct {
buf []byte
size int32
}
func TestPendingQueueBytes(t *testing.T) {
tests := []struct {
name string
args args
want int32
name string
queued int32
processed int32
offset int32
want int32
}{
{
name: "Fill the buffer partially",
args: args{
buf: make([]byte, bufSize),
size: 6000,
},
want: 6000,
name: "Nothing queued",
queued: 0,
processed: 0,
offset: 0,
want: 0,
},
{
name: "Fill the buffer fully",
args: args{
buf: make([]byte, bufSize),
size: 6000,
},
want: 2192,
name: "Processed buffers do not count as pending",
queued: 4,
processed: 2,
offset: 0,
want: 2 * bufSize,
},
{
name: "Subtract current playback offset",
queued: 3,
processed: 1,
offset: 128,
want: 2*bufSize - 128,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := fillInternalBuf(tt.args.buf[:tt.args.size]); got != tt.want {
t.Errorf("fillInternalBuf() = %v, want %v", got, tt.want)
got := pendingQueueBytes(tt.queued, tt.processed, tt.offset)
if got != tt.want {
t.Errorf("pendingQueueBytes() = %v, want %v", got, tt.want)
}
})
}
}
func TestQueueBufferCount(t *testing.T) {
tests := []struct {
rate int32
want int32
}{
{rate: 32040, want: 13},
{rate: 48000, want: 18},
{rate: 96000, want: 36},
}
for _, tt := range tests {
if got := queueBufferCount(tt.rate); got != tt.want {
t.Errorf("queueBufferCount(%v) = %v, want %v", tt.rate, got, tt.want)
}
}
}
func TestCurrentPlaybackRate(t *testing.T) {
rate = outputRate
inputRate = 32768
videoSyncRate = 60.0
srcRatioOrig = 0
srcRatioCurr = 0
updateInputRate()
queueBytes = 5 * bufSize
nominalStep := 1.0 / srcRatioOrig
t.Run("Low write availability speeds playback up", func(t *testing.T) {
got := currentResampleStep(0)
if got <= nominalStep {
t.Errorf("currentResampleStep() = %v, want > %v", got, nominalStep)
}
})
t.Run("High write availability slows playback down", func(t *testing.T) {
got := currentResampleStep(queueBytes)
if got >= nominalStep {
t.Errorf("currentResampleStep() = %v, want < %v", got, nominalStep)
}
})
t.Run("Half-full queue stays at nominal rate", func(t *testing.T) {
got := currentResampleStep(queueBytes / 2)
if math.Abs(got-nominalStep) > 1e-9 {
t.Errorf("currentResampleStep() = %v, want %v", got, nominalStep)
}
})
}
func TestAdjustedInputRate(t *testing.T) {
got := adjustedInputRate(32768, 59.7275, 60.0)
want := 32768 * 60.0 / 59.7275
if math.Abs(got-want) > 1e-6 {
t.Fatalf("adjustedInputRate() = %v, want %v", got, want)
}
got = adjustedInputRate(32768, 50.0, 60.0)
if got != 32768 {
t.Fatalf("adjustedInputRate() = %v, want %v outside timing skew window", got, 32768)
}
}
func TestRequiredInputFrames(t *testing.T) {
readPhase = 0.25
got := requiredInputFrames(1.0)
if got != framesPerBuffer+2 {
t.Errorf("requiredInputFrames() = %v, want %v", got, framesPerBuffer+2)
}
}
+20 -6
View File
@@ -6,6 +6,7 @@ package core
import (
"errors"
"log"
"math"
"os"
"path/filepath"
"strings"
@@ -33,6 +34,21 @@ func Init(v *video.Video) {
vid = v
}
func applySystemAVInfo(avi libretro.SystemAVInfo) {
vid.Geom = avi.Geometry
if avi.Timing.FPS > 0 {
state.CoreFPS = avi.Timing.FPS
}
if avi.Timing.SampleRate > 0 {
audio.Reconfigure(int32(math.Round(avi.Timing.SampleRate)))
if state.Core != nil && state.Core.AudioCallback != nil {
state.Core.AudioCallback.SetState(true)
}
}
}
// Load loads a libretro core
func Load(sofile string) error {
// In case a core is already loaded, we need to close it properly
@@ -178,8 +194,7 @@ func LoadGame(gamePath string) error {
}
avi := state.Core.GetSystemAVInfo()
vid.Geom = avi.Geometry
applySystemAVInfo(avi)
// Append the library name to the window title.
if len(si.LibraryName) > 0 {
@@ -187,10 +202,6 @@ func LoadGame(gamePath string) error {
}
input.Init(vid)
audio.Reconfigure(int32(avi.Timing.SampleRate))
if state.Core.AudioCallback != nil {
state.Core.AudioCallback.SetState(true)
}
state.CoreRunning = true
state.FastForward = false
@@ -216,6 +227,7 @@ func Unload() {
state.CorePath = ""
state.Core = nil
Options = nil
state.CoreFPS = 0
}
}
@@ -223,9 +235,11 @@ func Unload() {
func UnloadGame() {
if state.CoreRunning {
savefiles.SaveSRAM()
audio.Stop()
state.Core.UnloadGame()
state.GamePath = ""
state.CoreRunning = false
state.CoreFPS = 0
vid.ResetPitch()
vid.ResetRot()
}
+1 -2
View File
@@ -171,8 +171,7 @@ func environment(cmd uint32, data unsafe.Pointer) bool {
case libretro.EnvironmentSetGeometry:
vid.Geom = libretro.GetGeometry(data)
case libretro.EnvironmentSetSystemAVInfo:
avi := libretro.GetSystemAVInfo(data)
vid.Geom = avi.Geometry
applySystemAVInfo(libretro.GetSystemAVInfo(data))
case libretro.EnvironmentGetFastforwarding:
libretro.SetBool(data, state.FastForward)
case libretro.EnvironmentGetLanguage:
+197 -14
View File
@@ -4,6 +4,7 @@ import (
"flag"
"fmt"
"log"
"math"
"os"
"runtime"
"time"
@@ -30,12 +31,148 @@ func init() {
var frame = 0
const (
defaultCoreFPS = 60.0
maxLoopDelta = 250 * time.Millisecond
maxPacingLagFrames = 2
maxAutoSwapInt = 4
pacingMatchSkew = 0.02
)
func coreFrameDuration() time.Duration {
fps := state.CoreFPS
if fps <= 0 {
fps = defaultCoreFPS
}
return time.Duration(float64(time.Second) / fps)
}
func frameTimeUsec(frameDuration time.Duration) int64 {
if state.Core != nil && state.Core.FrameTimeCallback != nil {
if state.FastForward && state.Core.FrameTimeCallback.Reference > 0 {
return state.Core.FrameTimeCallback.Reference
}
usec := frameDuration / time.Microsecond
if usec > 0 {
return int64(usec)
}
if state.Core.FrameTimeCallback.Reference > 0 {
return state.Core.FrameTimeCallback.Reference
}
}
return int64(coreFrameDuration() / time.Microsecond)
}
func runCoreFrame(frameDuration time.Duration) {
if state.Core == nil {
return
}
if state.Core.FrameTimeCallback != nil {
state.Core.FrameTimeCallback.Callback(frameTimeUsec(frameDuration))
}
state.Core.Run()
}
func updateSwapInterval(current *int, target int) {
if *current == target {
return
}
glfw.SwapInterval(target)
*current = target
}
func relativeSkew(a, b float64) float64 {
if a <= 0 || b <= 0 {
return math.Inf(1)
}
return math.Abs(a-b) / b
}
func displayRefreshHz(vid *video.Video) float64 {
if vid == nil || vid.Window == nil {
return 0
}
monitor := vid.Window.GetMonitor()
if monitor == nil {
monitors := glfw.GetMonitors()
if len(monitors) > 0 {
index := settings.Current.VideoMonitorIndex
if index < 0 || index >= len(monitors) {
index = 0
}
monitor = monitors[index]
}
}
if monitor == nil {
return 0
}
mode := monitor.GetVideoMode()
if mode == nil || mode.RefreshRate <= 0 {
return 0
}
return float64(mode.RefreshRate)
}
func autoSwapInterval(refreshHz, coreFPS float64) int {
if refreshHz <= 0 || coreFPS <= 0 {
return 1
}
bestInterval := 1
bestSkew := relativeSkew(refreshHz, coreFPS)
for interval := 2; interval <= maxAutoSwapInt; interval++ {
effectiveRefresh := refreshHz / float64(interval)
skew := relativeSkew(effectiveRefresh, coreFPS)
if skew <= pacingMatchSkew && skew < bestSkew {
bestInterval = interval
bestSkew = skew
}
}
return bestInterval
}
func effectiveRefreshHz(refreshHz float64, swapInterval int) float64 {
if refreshHz <= 0 {
return 0
}
if swapInterval <= 0 {
return refreshHz
}
return refreshHz / float64(swapInterval)
}
func blocksOnSwap(refreshHz, coreFPS float64) bool {
return relativeSkew(refreshHz, coreFPS) <= pacingMatchSkew
}
func runLoop(vid *video.Video, m *menu.Menu) {
var currTime time.Time
prevTime := time.Now()
accumulator := time.Duration(0)
swapInterval := -1
pacingPrimed := false
for !vid.Window.ShouldClose() {
currTime = time.Now()
dt := float32(currTime.Sub(prevTime)) / 1000000000
loopDelta := currTime.Sub(prevTime)
prevTime = currTime
if loopDelta > maxLoopDelta {
loopDelta = maxLoopDelta
}
dt := float32(loopDelta) / 1000000000
glfw.PollEvents()
m.ProcessHotkeys()
ntf.Process(dt)
@@ -43,33 +180,79 @@ func runLoop(vid *video.Video, m *menu.Menu) {
m.UpdatePalette()
input.Poll()
if !state.MenuActive {
ranFrames := 0
if state.CoreRunning {
state.Core.Run()
if state.Core.FrameTimeCallback != nil {
state.Core.FrameTimeCallback.Callback(state.Core.FrameTimeCallback.Reference)
if state.FastForward {
updateSwapInterval(&swapInterval, 0)
audio.SetVideoTiming(0, 0)
runCoreFrame(coreFrameDuration())
ranFrames = 1
accumulator = 0
pacingPrimed = false
} else {
frameDuration := coreFrameDuration()
refreshHz := displayRefreshHz(vid)
targetSwap := autoSwapInterval(refreshHz, state.CoreFPS)
updateSwapInterval(&swapInterval, targetSwap)
effectiveRefresh := effectiveRefreshHz(refreshHz, targetSwap)
audio.SetVideoTiming(refreshHz, targetSwap)
if blocksOnSwap(effectiveRefresh, state.CoreFPS) {
runCoreFrame(frameDuration)
ranFrames = 1
accumulator = 0
pacingPrimed = false
} else {
if !pacingPrimed {
accumulator = frameDuration
pacingPrimed = true
}
accumulator += loopDelta
maxAccumulator := frameDuration * maxPacingLagFrames
if accumulator > maxAccumulator {
accumulator = maxAccumulator
}
if accumulator >= frameDuration {
runCoreFrame(frameDuration)
accumulator -= frameDuration
if accumulator > frameDuration {
accumulator = frameDuration
}
ranFrames = 1
}
}
}
if state.Core.AudioCallback != nil {
if state.Core.AudioCallback != nil && ranFrames > 0 {
state.Core.AudioCallback.Callback()
}
} else {
updateSwapInterval(&swapInterval, 1)
audio.SetVideoTiming(0, 0)
accumulator = 0
pacingPrimed = false
}
vid.Render()
frame++
if frame%600 == 0 { // save sram about every 10 sec
savefiles.SaveSRAM()
for i := 0; i < ranFrames; i++ {
frame++
if frame%600 == 0 { // save sram about every 10 sec
savefiles.SaveSRAM()
}
}
} else {
updateSwapInterval(&swapInterval, 1)
audio.SetVideoTiming(0, 0)
accumulator = 0
pacingPrimed = false
m.Update(dt)
vid.Render()
m.Render(dt)
}
m.RenderNotifications()
if state.FastForward {
glfw.SwapInterval(0)
} else {
glfw.SwapInterval(1)
}
vid.Window.SwapBuffers()
prevTime = currTime
}
}
+35
View File
@@ -0,0 +1,35 @@
package main
import "testing"
func TestAutoSwapInterval(t *testing.T) {
tests := []struct {
name string
refreshHz float64
coreFPS float64
want int
}{
{name: "60Hz stays at swap 1", refreshHz: 60, coreFPS: 59.73, want: 1},
{name: "120Hz prefers swap 2 for 60fps", refreshHz: 120, coreFPS: 60, want: 2},
{name: "144Hz prefers swap 3 for 48fps", refreshHz: 144, coreFPS: 48, want: 3},
{name: "144Hz keeps swap 1 for 60fps", refreshHz: 144, coreFPS: 60, want: 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := autoSwapInterval(tt.refreshHz, tt.coreFPS); got != tt.want {
t.Fatalf("autoSwapInterval(%v, %v) = %v, want %v", tt.refreshHz, tt.coreFPS, got, tt.want)
}
})
}
}
func TestBlocksOnSwap(t *testing.T) {
if got := blocksOnSwap(60.0, 59.73); !got {
t.Fatalf("blocksOnSwap() = %v, want %v", got, true)
}
if got := blocksOnSwap(144.0, 60.0); got {
t.Fatalf("blocksOnSwap() = %v, want %v", got, false)
}
}
+3
View File
@@ -33,3 +33,6 @@ var LudOS bool
// FastForward will run the core as fast as possible
var FastForward bool
// CoreFPS is the current emulated video rate declared by the core.
var CoreFPS float64