Starting 3.00

This commit is contained in:
Scott Duensing 2026-09-03 19:56:45 -05:00
parent 34d61d957a
commit 20b76523c9
15 changed files with 700 additions and 76 deletions

View file

@ -1,4 +1,4 @@
SINGE 2.20
SINGE 3.00
==========
Unreleased
@ -57,8 +57,8 @@ API Changes
from a keyframe no longer stalls the game loop; the previous frame stays
on screen until the new one is ready. The laserdisc and framefile videos
are handed to the GPU as YUV and converted there instead of on the CPU,
and each video reports its keyframe spacing when loaded, with a warning
when seeks may be slow.
and each video reports its keyframe spacing to the program trace when
loaded, with a warning there when seeks may be slow.
- Constants that used to be duplicated in Framework.singe (SWITCH_*,
FONT_QUALITY_*, MODE_*, MOUSE_*, OVERLAY_*, RENDER_*, SOUND_ERROR_*) are
@ -66,6 +66,26 @@ API Changes
for discGetState(), SINGE_VERSION_MAJOR/MINOR/STRING, and the SINGE_*
input code layout that Framework.singe builds GAMEPAD_N and MOUSE_N from.
- Audio and video are kept in sync from the audio clock. The engine
measures the audio device's queue at startup instead of guessing, uses
small mixer buffers, and honours a container whose first video frame is
timestamped after its first audio sample. For delay the engine cannot
see (receivers, Bluetooth, DACs) there is singeSetAudioDelay(ms) /
singeGetAudioDelay(), the games.dat key AUDIO_DELAY, and
--audiodelay=MS; positive values mean the audio is heard late.
- The menu has an audio delay calibration screen for the machine's own
delay (DAC, receiver, Bluetooth, display). Press the service key on
the game list; a click plays once a second and the screen flashes.
Adjust with the stick until the flash and the click coincide, then
press button 1 to save. The value is kept in audio.cfg in the data
root and applied to every game on the machine, from the menu or the
command line, on top of any per-game AUDIO_DELAY. Scripts can read and
set it with singeGetAudioCalibration() / singeSetAudioCalibration(),
read the measured device queue with singeGetAudioLatency(), and now
have singeGetTicks() for a wall clock in milliseconds (os.clock() is
processor time and drifts).
Fixes
-----
@ -124,6 +144,21 @@ Fixes
- The Sinden gun arguments could overflow the configuration structure.
- Support files in the Singe directory are refreshed when the installed
copy differs from the running build. Upgrading the binary used to keep
the old Framework.singe and Menu.singe forever.
- The menu background video is encoded with a keyframe every second, so
the menu's jump to the game list is instant.
- Framefiles whose first line is a relative directory produced garbage
video paths (the directory string was freed before use); a lone "." now
means the framefile's own directory.
- Audio ran late by the device queue depth on most Linux audio servers:
the clock assumed one mixer buffer of latency where PulseAudio and
PipeWire hold several. See the audio sync entry above.
- Windows builds now carry the icon and version resource, with the version
number encoded correctly.

View file

@ -20,7 +20,7 @@
cmake_minimum_required(VERSION 3.22)
project(singe2 VERSION 2.20 LANGUAGES C)
project(singe2 VERSION 3.00 LANGUAGES C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
@ -144,12 +144,13 @@ singeEmbed(${CMAKE_SOURCE_DIR}/assets/controls.cfg ${GENERATED_DIR}/controls_cfg
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.singe ${GENERATED_DIR}/Menu_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/FreeSansBold.ttf ${GENERATED_DIR}/FreeSansBold_ttf.h "")
# Menu background video: two clips cropped to 4:3, scaled to 720x480, and joined.
# Menu background video: two clips cropped to 4:3, scaled to 720x480, keyframed every second so the
# menu's seeks are instant and the engine's keyframe warning stays quiet, and joined.
file(WRITE ${GENERATED_DIR}/menuBackground.txt "file ${GENERATED_DIR}/menuBackground1.mkv\nfile ${GENERATED_DIR}/menuBackground2.mkv\n")
add_custom_command(
OUTPUT ${GENERATED_DIR}/menuBackground.mkv
COMMAND ${FFMPEG_TOOL} -y -loglevel error -i "${CMAKE_SOURCE_DIR}/assets/Singe Engine Intro.mpg" -filter:v "crop=ih/3*4:ih,scale=720:480" -c:v libx264 -c:a aac -f matroska ${GENERATED_DIR}/menuBackground1.mkv
COMMAND ${FFMPEG_TOOL} -y -loglevel error -i ${CMAKE_SOURCE_DIR}/assets/180503_01_PurpleGrid.mp4 -filter:v "crop=ih/3*4:ih,scale=720:480" -c:v libx264 -c:a aac -f matroska ${GENERATED_DIR}/menuBackground2.mkv
COMMAND ${FFMPEG_TOOL} -y -loglevel error -i "${CMAKE_SOURCE_DIR}/assets/Singe Engine Intro.mpg" -filter:v "crop=ih/3*4:ih,scale=720:480" -c:v libx264 -force_key_frames "expr:gte(t,n_forced)" -c:a aac -f matroska ${GENERATED_DIR}/menuBackground1.mkv
COMMAND ${FFMPEG_TOOL} -y -loglevel error -i ${CMAKE_SOURCE_DIR}/assets/180503_01_PurpleGrid.mp4 -filter:v "crop=ih/3*4:ih,scale=720:480" -c:v libx264 -force_key_frames "expr:gte(t,n_forced)" -c:a aac -f matroska ${GENERATED_DIR}/menuBackground2.mkv
COMMAND ${FFMPEG_TOOL} -y -loglevel error -f concat -safe 0 -i ${GENERATED_DIR}/menuBackground.txt -c copy ${GENERATED_DIR}/menuBackground.mkv
DEPENDS "assets/Singe Engine Intro.mpg" assets/180503_01_PurpleGrid.mp4
COMMENT "Building menuBackground.mkv"
@ -157,6 +158,15 @@ add_custom_command(
)
singeEmbed(${GENERATED_DIR}/menuBackground.mkv ${GENERATED_DIR}/menuBackground_mkv.h "")
# Calibration click for the menu's audio delay screen: 20 ms of white noise with a fast fade.
add_custom_command(
OUTPUT ${GENERATED_DIR}/click.wav
COMMAND ${FFMPEG_TOOL} -y -loglevel error -f lavfi -i "anoisesrc=d=0.02:c=white:r=44100:a=0.6" -af "afade=t=out:st=0:d=0.02:curve=exp" -ac 1 -c:a pcm_s16le ${GENERATED_DIR}/click.wav
COMMENT "Building click.wav"
VERBATIM
)
singeEmbed(${GENERATED_DIR}/click.wav ${GENERATED_DIR}/click_wav.h "")
# Manual, rendered from the AsciiDoc source and shipped inside the binary.
add_custom_command(
OUTPUT ${GENERATED_DIR}/Manual.pdf
@ -350,7 +360,7 @@ if(WIN32)
target_sources(${CMAKE_PROJECT_NAME} PRIVATE ${GENERATED_DIR}/singe.rc ${GENERATED_DIR}/icon.ico)
endif()
# Output name matches the release artifact: Singe-v2.20-Linux-x86_64
# Output name matches the release artifact: Singe-v3.00-Linux-x86_64
string(SUBSTRING ${KANGAROO_OS} 0 1 osInitial)
string(SUBSTRING ${KANGAROO_OS} 1 -1 osRest)
string(TOUPPER ${osInitial} osInitial)
@ -397,9 +407,9 @@ target_link_directories(${CMAKE_PROJECT_NAME} PRIVATE
# Platform specific system libraries.
if(KANGAROO_OS STREQUAL "linux")
# ffmpeg's vdpau hardware context is always compiled in on X11 hosts.
set(SYSTEM_LIBS -lX11 -lvdpau)
set(SYSTEM_LIBS -lX11 -lvdpau -ldl)
elseif(KANGAROO_OS STREQUAL "pi")
set(SYSTEM_LIBS)
set(SYSTEM_LIBS -ldl)
elseif(KANGAROO_OS STREQUAL "macos")
set(SYSTEM_LIBS
-Wl,-framework,CoreVideo

View file

@ -1,4 +1,4 @@
SINGE 2.20
SINGE 3.00
==========
(For the latest version of this document, visit https://kangaroopunch.com!

View file

@ -44,4 +44,5 @@ Fonts
-----
FreeSansBold GPL-3.0 with font exception https://www.gnu.org/software/freefont
BreatheFire (license not recorded - used only by the manual's example)
BreatheFire (design-time only: text layer in singeLogo.xcf and indexing.xcf;
the font file is not shipped and its license is not recorded)

View file

@ -419,7 +419,7 @@ if singeSetPauseKeyEnabled ~= nil then
singeDisablePauseKey = function() singeSetPauseKeyEnabled(false) end
end
-- Singe 2.20 moved the sprite handle to the first argument. Games written for
-- Singe 3.00 moved the sprite handle to the first argument. Games written for
-- 2.10 can set SINGE_LEGACY_SPRITE_ARGS = true (or LEGACY_SPRITE_ARGS = true in
-- games.dat) to keep calling the old way.
if SINGE_LEGACY_SPRITE_ARGS and spriteDraw ~= nil then

View file

@ -27,6 +27,105 @@ dofile("Singe/Framework.singe")
local lfs = require("lfs")
-- Audio delay calibration: a click every second and a flash scheduled the engine's measured
-- device queue plus the candidate value after it. The click and the disc audio share one mixer
-- device, so when the player sees and hears them as one event the candidate is the machine's delay.
function calibrationBegin()
CALIBRATING = true
CAL_ORIGINAL = singeGetAudioCalibration()
CAL_VALUE = CAL_ORIGINAL
CAL_LATENCY = singeGetAudioLatency()
CAL_BEAT = singeGetTicks() - CAL_PERIOD
CAL_CLICK_DONE = true
CAL_FLASH_DONE = true
if VIDEO_ATTRACT then
videoPause(VIDEO_ATTRACT)
end
end
function calibrationEnd(save)
if save then
singeSetAudioCalibration(CAL_VALUE)
end
CALIBRATING = false
if VIDEO_ATTRACT then
videoPlay(VIDEO_ATTRACT)
end
end
function calibrationInput(what)
local delta = 0
if what == SWITCH_LEFT then
delta = -CAL_STEP_COARSE
elseif what == SWITCH_RIGHT then
delta = CAL_STEP_COARSE
elseif what == SWITCH_UP then
delta = CAL_STEP_FINE
elseif what == SWITCH_DOWN then
delta = -CAL_STEP_FINE
elseif what == SWITCH_START1 or what == SWITCH_START2 then
CAL_VALUE = 0
elseif what == SWITCH_BUTTON1 then
calibrationEnd(true)
elseif what == SWITCH_BUTTON2 or what == SWITCH_SERVICE then
calibrationEnd(false)
end
if delta ~= 0 then
CAL_VALUE = math.max(-CAL_LIMIT, math.min(CAL_LIMIT, CAL_VALUE + delta))
end
end
function calibrationUpdate()
local now = singeGetTicks()
local gap = CAL_LATENCY + CAL_VALUE -- flash this long after the click; negative means before
local flash = false
local y = MARGIN_Y
-- Start a new beat? Whichever of click and flash comes first goes on the beat.
if now - CAL_BEAT >= CAL_PERIOD then
CAL_BEAT = now
CAL_CLICK_AT = now + math.max(0, -gap)
CAL_FLASH_AT = now + math.max(0, gap)
CAL_CLICK_DONE = false
CAL_FLASH_DONE = false
end
if not CAL_CLICK_DONE and now >= CAL_CLICK_AT then
soundPlay(SND_CLICK)
CAL_CLICK_DONE = true
end
if not CAL_FLASH_DONE and now >= CAL_FLASH_AT then
flash = true
CAL_FLASH_DONE = true
end
if flash then
colorBackground(255, 255, 255, 255)
colorForeground(0, 0, 0, 255)
else
colorBackground(0, 0, 0, 255)
colorForeground(255, 255, 255, 255)
end
overlayClear()
fontPrint(MARGIN_X, y, "AUDIO DELAY CALIBRATION")
y = y + CAL_LINE_HEIGHT * 2
fontPrint(MARGIN_X, y, "Adjust until the flash and the click happen together.")
y = y + CAL_LINE_HEIGHT * 2
fontPrint(MARGIN_X, y, "Delay: " .. CAL_VALUE .. " ms")
y = y + CAL_LINE_HEIGHT
fontPrint(MARGIN_X, y, "Measured device queue: " .. CAL_LATENCY .. " ms")
y = y + CAL_LINE_HEIGHT * 2
fontPrint(MARGIN_X, y, "Left / Right: 10 ms Up / Down: 1 ms Start: reset to 0")
y = y + CAL_LINE_HEIGHT
fontPrint(MARGIN_X, y, "Button 1: save Button 2: cancel")
end
function cleanTitle(a)
local output = string.lower(a)
output = string.gsub(output, '%p', ' ')
@ -74,9 +173,19 @@ end
function onInputPressed(what)
if CALIBRATING then
calibrationInput(what)
return
end
-- Are we displaying the grid background?
if (discGetFrame() >= DISC_GRID_START) then
if what == SWITCH_SERVICE then
calibrationBegin()
return
end
if what == SWITCH_UP then
if TEXT_LINE_TOP > 1 then
TEXT_LINE_TOP = TEXT_LINE_TOP - 1
@ -125,6 +234,17 @@ function onOverlayUpdate()
local c = 0
local t = 0
-- Loop video?
if (discGetFrame() >= DISC_LAST_FRAME) then
discSkipToFrame(DISC_GRID_START)
end
if CALIBRATING then
calibrationUpdate()
return(OVERLAY_UPDATED)
end
colorBackground(0, 0, 0, 0)
overlayClear()
-- Are we displaying the grid background?
@ -173,11 +293,6 @@ function onOverlayUpdate()
end
end
-- Loop video?
if (discGetFrame() >= DISC_LAST_FRAME) then
discSkipToFrame(DISC_GRID_START)
end
return(OVERLAY_UPDATED)
end
@ -189,6 +304,9 @@ function onShutdown()
if freeSans18 then
fontUnload(freeSans18)
end
if SND_CLICK then
soundUnload(SND_CLICK)
end
end
@ -304,7 +422,7 @@ for dir in lfs.dir(".") do
GAMES = {}
dofile(dir .. "/games.dat")
for _, value in pairs(GAMES or {}) do
-- Since 2.20 a laserdisc game must say DISC = true; refuse the ambiguous cases here
-- Since 3.00 a laserdisc game must say DISC = true; refuse the ambiguous cases here
-- with a message rather than letting the engine stop the menu when it is picked.
local title = tostring(value.TITLE or value.SCRIPT or "?")
if value.VIDEO and not value.DISC then
@ -384,6 +502,15 @@ else
SHUTDOWN_FROM_PUSH = false
-- Audio delay calibration screen (SWITCH_SERVICE)
CALIBRATING = false
CAL_PERIOD = 1000
CAL_STEP_COARSE = 10
CAL_STEP_FINE = 1
CAL_LIMIT = 1000
CAL_LINE_HEIGHT = 24
SND_CLICK = soundLoad("Singe/click.wav")
-- Load configuration
SHOW_INTRO = true
CONFIG_FILE = singeGetDataPath() .. "menu.dat"

View file

@ -1,6 +1,6 @@
= Singe Manual
Scott Duensing <scott@kangaroopunch.com>
:revnumber: 2.20
:revnumber: 3.00
:revdate: 2026
:doctype: book
:toc: left
@ -114,6 +114,7 @@ name and any extension FFmpeg can demux, then for a `.txt` framefile.
[cols="1,2",options="header"]
|===
| Option | Purpose
| `-A`, `--audiodelay=MS` | Compensate for audio that is heard `MS` milliseconds later than the engine can measure (negative when it is heard early), `-1000` to `1000`. See <<audiosync,Audio Sync>>.
| `-a`, `--aspect=N:D` | Force the aspect ratio used to pick a window size (`4:3`, `16:9`, `16:10`).
| `-c`, `--showcalculated` | Print the frame ranges of every segment of a framefile, for debugging.
| `-C`, `--canvas=WxH` | World size for a game without a disc, default 720x480. Ignored when there is a disc.
@ -241,6 +242,12 @@ channel and additional documentation by searching the web.
----
dofile("Singe/Framework.singe")
----
* Keep your game self-contained. If you build on a third-party framework or
share code between your games, copy it into your game directory. Never
reference a directory beside your game; the only file outside your game a
script may load is `Singe/Framework.singe`. A future single-file game format
packs exactly one directory, and a game that reaches outside it cannot be
packed.
* Stay out of the `Singe/` folder. This is managed by Singe and anything added
or changed here is subject to future deletion.
* Include a `games.dat`. This is extremely important for new users. While
@ -262,6 +269,7 @@ Singe/ Support files extracted by the engine
Framework.singe Loaded by every game (dofile it)
Menu.singe The bundled game menu
controls.cfg.example Template for input mappings
click.wav Used by the menu's audio delay calibration
Manual.pdf This manual
ActionMax/ One game
games.dat Menu entries for the games in this directory
@ -562,8 +570,8 @@ GAMES = {
----
The keys `SCRIPT`, `DISC`, `VIDEO`, `CANVAS_X`, `CANVAS_Y`, `STRETCH`,
`NO_MOUSE`, `RESOLUTION_X`, `RESOLUTION_Y`, `SINDEN_GUN`, `AUDIO_TRACK`, and
`LEGACY_SPRITE_ARGS` are read by the engine when the menu (or your own
`NO_MOUSE`, `RESOLUTION_X`, `RESOLUTION_Y`, `SINDEN_GUN`, `AUDIO_TRACK`,
`AUDIO_DELAY`, and `LEGACY_SPRITE_ARGS` are read by the engine when the menu (or your own
script, through `scriptExecute` / `scriptPush`) launches the entry; they
override the command line. A laserdisc game must say `DISC = true` and name
its `VIDEO`; an entry with a `VIDEO` but no `DISC = true` is refused with a
@ -596,7 +604,7 @@ script which kind of game it is running as.
[#migrating]
=== Migrating from Singe 2.10
Singe 2.20 moved the sprite handle to the first argument of `spriteDraw`,
Singe 3.00 moved the sprite handle to the first argument of `spriteDraw`,
`spriteLoop`, `spriteQuality`, `spriteRotate`, `spriteRotateAndScale`,
`spriteScale`, and `spriteSetFrame`, so every sprite call now matches the
`video*` family. To update a game, move the last argument of each of those
@ -605,9 +613,9 @@ calls to the front:
[source,lua]
----
spriteDraw(x, y, cursor) -- 2.10
spriteDraw(cursor, x, y) -- 2.20
spriteDraw(cursor, x, y) -- 3.00
spriteRotate(angle, cursor) -- 2.10
spriteRotate(cursor, angle) -- 2.20
spriteRotate(cursor, angle) -- 3.00
----
A game you cannot edit can opt into the old order instead. Either set the
@ -661,7 +669,7 @@ available to `controls.cfg` and to `Framework.singe` alike:
| `RENDER_PIXELATED`, `RENDER_SMOOTH` | Arguments for `spriteQuality` / `videoQuality`.
| `DISC_STOPPED`, `DISC_PLAYING`, `DISC_PAUSED`, `DISC_EJECTED` | Return values of `discGetState`.
| `SOUND_ERROR_INVALID`, `SOUND_REMOVE_HANDLE` | `-1`, what `soundPlay` returns when no channel is free.
| `SINGE_VERSION_MAJOR`, `SINGE_VERSION_MINOR`, `SINGE_VERSION_STRING`, `SINGE_FRAMEWORK_VERSION` | The engine version, as integers, as a string (`"v2.20"`), and as the number `singeVersion()` returns.
| `SINGE_VERSION_MAJOR`, `SINGE_VERSION_MINOR`, `SINGE_VERSION_STRING`, `SINGE_FRAMEWORK_VERSION` | The engine version, as integers, as a string (`"v3.00"`), and as the number `singeVersion()` returns.
| `SINGE_DEAD_ZONE` | The `DEAD_ZONE` from `controls.cfg`.
| `SINGE_LEGACY_SPRITE_ARGS` | True when the game asked for the 2.10 sprite argument order.
| `SINGE_DISC` | True when the game has a laserdisc; false when the canvas is the world.
@ -693,6 +701,21 @@ install any additional software:
Their usage is beyond the scope of this document.
[#audiosync]
=== Audio Sync
Singe presents video against the audio clock: a frame is shown when the sound that belongs with it is heard. The engine measures its own audio path at startup. SDL cannot report how much audio is queued between the mixer and the speaker, so Singe drains the device once, before anything audible plays, and counts the burst of buffers SDL uses to refill it; that burst is the queue depth, and the clock subtracts it. Run with `--program` to see the measurement in the trace (`Audio device queue: ...`).
What the engine cannot see is anything downstream of the operating system's audio server: a DAC, an HDMI receiver, a Bluetooth link. Those add a fixed delay that varies by machine. If actions on screen visibly precede their sound, pass the missing milliseconds with `--audiodelay=MS`, put `AUDIO_DELAY = MS` in `games.dat`, or let the game set it from a service menu with <<singesetaudiodelay,singeSetAudioDelay>>. Positive values mean the audio is late; the video is held back to match. A negative value handles the rare case of audio arriving early.
==== Calibrating from the menu
The bundled menu has a calibration screen for that downstream delay. Press the key mapped to `INPUT_SERVICE` (the `9` key by default) on the game list. The menu then plays a click once a second and flashes the screen white; adjust with left and right (10 ms) and up and down (1 ms) until the flash and the click happen together, then press button 1 to save. Start resets to zero and button 2 cancels. The value is stored in `audio.cfg` in the data root and applied by the engine to every game on that machine, whether launched from the menu or from the command line; it is added to any per-game `AUDIO_DELAY`. Recalibrate after changing speakers, headphones, or displays.
The screen works because the click and the disc audio share one mixer device and one queue: the flash is scheduled the measured queue plus the candidate value after the click, so when the two coincide the candidate is exactly the delay the engine cannot see. Display lag is folded in for free. A game can read the values with <<singegetaudiocalibration,singeGetAudioCalibration>> and <<singegetaudiolatency,singeGetAudioLatency>>, or offer its own screen with <<singesetaudiocalibration,singeSetAudioCalibration>>.
Two properties of the video file itself also matter. AAC audio carries encoder priming samples that the container must tell decoders to skip; files muxed without that information (an MP4 with no edit list, a Matroska file with no codec delay) play their audio about 20 milliseconds late, which `AUDIO_DELAY` can absorb. And when the first video frame is timestamped later than the first audio sample, Singe honours the container's timing, so leading audio is not lost.
=== Video, Audio, and Container Formats
Singe decodes video with FFmpeg through FFMS2, so any container and codec the
@ -705,9 +728,9 @@ format, channel count, and rate.
The first time a video is opened, Singe indexes it and stores the index next
to the game's other data (`<name>.index`). Indexing takes a while for large
files and happens again if the video changes. When a video is loaded, Singe
reports its keyframe spacing in the program trace and prints a warning if
keyframes are more than two seconds apart, because a seek has to decode
forward from the previous keyframe. Decoding happens on a separate thread,
reports its keyframe spacing in the program trace (`--program`), with a
warning there if keyframes are more than two seconds apart, because a seek
has to decode forward from the previous keyframe. Decoding happens on a separate thread,
so a slow seek shows the previous frame a little longer instead of stalling
the game.
@ -1750,7 +1773,7 @@ Turns mouse event dispatch to your script on or off. When off, the cursor still
* `enabled` -- boolean.
*Since:* 2.20
*Since:* 3.00
*See also:* <<mouseenable,mouseEnable>>, <<mousedisable,mouseDisable>>, <<mousesetcaptured,mouseSetCaptured>>
[#mousesetmode]
@ -2040,6 +2063,45 @@ Legacy alias for `singeSetPauseKeyEnabled(true)`, defined in `Framework.singe`.
*Since:* 1.18 (RDG)
*See also:* <<singesetpausekeyenabled,singeSetPauseKeyEnabled>>
[#singegetaudiocalibration]
==== singeGetAudioCalibration
[source,text]
----
milliseconds = singeGetAudioCalibration()
----
Returns the per-machine audio delay saved by the menu's calibration screen (see <<audiosync,Audio Sync>>), in milliseconds. Zero when the machine has not been calibrated.
*Since:* 3.00
*See also:* <<singesetaudiocalibration,singeSetAudioCalibration>>, <<singegetaudiodelay,singeGetAudioDelay>>
[#singegetaudiodelay]
==== singeGetAudioDelay
[source,text]
----
milliseconds = singeGetAudioDelay()
----
Returns the audio delay compensation currently in effect, in milliseconds, as set by `--audiodelay`, the `AUDIO_DELAY` key in `games.dat`, or <<singesetaudiodelay,singeSetAudioDelay>>. Zero when none has been set.
*Since:* 3.00
*See also:* <<singesetaudiodelay,singeSetAudioDelay>>, <<audiosync,Audio Sync>>
[#singegetaudiolatency]
==== singeGetAudioLatency
[source,text]
----
milliseconds = singeGetAudioLatency()
----
Returns the audio device queue the engine measured at startup, in milliseconds: the time between handing audio to the mixer and hearing it, before any calibration or per-game delay. Useful for diagnostics and for calibration screens.
*Since:* 3.00
*See also:* <<audiosync,Audio Sync>>
[#singegetdatapath]
==== singeGetDataPath
@ -2107,6 +2169,18 @@ Returns the absolute path of the currently running script file. `Framework.singe
*Since:* 1.15 (RDG)
*See also:* <<singegetdatapath,singeGetDataPath>>, the `DIR` global
[#singegetticks]
==== singeGetTicks
[source,text]
----
milliseconds = singeGetTicks()
----
Returns the wall clock in milliseconds since the engine started. Unlike `os.clock()`, which measures processor time and drifts whenever the engine idles, this is the clock to use for timers, debounces, and animation.
*Since:* 3.00
[#singegetwidth]
==== singeGetWidth
@ -2156,6 +2230,36 @@ Captures a PNG screenshot of the currently composited frame (disc + overlay) and
*Since:* 1.x
[#singesetaudiocalibration]
==== singeSetAudioCalibration
[source,text]
----
singeSetAudioCalibration(milliseconds)
----
Sets the per-machine audio delay, applies it immediately, and saves it to `audio.cfg` in the data root so every game on the machine uses it. This is what the menu's calibration screen calls; a game with its own service menu may call it too. Values outside `-1000` to `1000` terminate the script.
* `milliseconds` -- integer, positive when the audio is heard late.
*Since:* 3.00
*See also:* <<singegetaudiocalibration,singeGetAudioCalibration>>, <<audiosync,Audio Sync>>
[#singesetaudiodelay]
==== singeSetAudioDelay
[source,text]
----
singeSetAudioDelay(milliseconds)
----
Tells the engine how much later (positive) or earlier (negative) the audio is heard than it can measure, so video presentation is shifted to match. Applies to the disc and to every video the script plays. Use it to offer a sync adjustment in a service menu and save the value with the game's other settings; a value outside `-1000` to `1000` terminates the script.
* `milliseconds` -- integer, positive when the audio is late.
*Since:* 3.00
*See also:* <<singegetaudiodelay,singeGetAudioDelay>>, <<audiosync,Audio Sync>>
[#singesetgamename]
==== singeSetGameName
@ -2203,7 +2307,7 @@ Chooses who owns the pause key mapped to `INPUT_PAUSE` in `controls.cfg`. While
* `enabled` -- boolean.
*Since:* 2.20
*Since:* 3.00
*See also:* <<singeenablepausekey,singeEnablePauseKey>>, <<singedisablepausekey,singeDisablePauseKey>>, <<singesetpauseflag,singeSetPauseFlag>>
[#singeversion]

View file

@ -8,13 +8,13 @@ inside an installed game. Package one as a ".patch" archive (see
ActionMax/Emulator.singe
Shared emulator script used by every ActionMax title. Fixes sprite
leaks in the original release and uses the Singe 2.20 sprite argument
leaks in the original release and uses the Singe 3.00 sprite argument
order. The per-game wrapper script must define gameID, backgroundFrame,
lengthIntro, lengthGame, lengthMenu, highThreshold, lowThreshold,
sensorX, sensorY, sensorLeft, and sensorTop before loading it.
The ActionMax games.dat is not part of this repository. Since Singe
2.20 every laserdisc entry in it needs one added line, next to VIDEO:
3.00 every laserdisc entry in it needs one added line, next to VIDEO:
DISC = true,

View file

@ -37,6 +37,7 @@
#include "generated/Menu_singe.h"
#include "generated/FreeSansBold_ttf.h"
#include "generated/menuBackground_mkv.h"
#include "generated/click_wav.h"
#include "generated/Manual_pdf.h"
// LuaSocket

View file

@ -149,6 +149,7 @@ int32_t frameFileLoad(const char *filename, const char *indexPath, SDL_Renderer
size_t bytes = 0;
size_t x = 0;
char *audio = NULL;
char *combined = NULL;
char *data = NULL;
char *path = NULL;
char *temp = NULL;
@ -173,12 +174,18 @@ int32_t frameFileLoad(const char *filename, const char *indexPath, SDL_Renderer
}
utilFixPathSeparators(&path, true);
// A lone "." means the framefile's own directory.
if ((path[0] == '.') && (path[1] == utilGetPathSeparator()) && (path[2] == 0)) {
path[0] = 0;
}
// If it's not an absolute path, pre-pend the path to the framefile
if ((path[0] != utilGetPathSeparator()) && (path[1] != ':')) {
temp = utilGetUpToLastPathComponent(filename);
free(path);
path = utilCreateString("%s%s", temp, path);
combined = utilCreateString("%s%s", temp, path);
free(temp);
free(path);
path = combined;
utilFixPathSeparators(&path, true);
}

View file

@ -59,7 +59,7 @@
#define PRIMARY_DISPLAY 0
#define MIXER_FREQUENCY 44100
#define MIXER_CHANNELS 2
#define MIXER_CHUNK_SAMPLES 4096
#define MIXER_CHUNK_SAMPLES 1024 // Small so the device queue, and any error in measuring it, stays small
#define MIXER_MIX_CHANNELS 16
#define MIXER_FORMATS (MIX_INIT_FLAC | MIX_INIT_MID | MIX_INIT_MOD | MIX_INIT_MP3 | MIX_INIT_OGG | MIX_INIT_OPUS | MIX_INIT_WAVPACK)
#define IMAGE_FORMATS (IMG_INIT_JPG | IMG_INIT_PNG | IMG_INIT_WEBP)
@ -122,6 +122,7 @@ static const char *const _badExtensions[] = { "exe", "sh", "bat", "cmd", "index"
// The overscan and Sinden options work but are hidden until the Sinden border scales mouse input.
static const OptionT _options[] = {
{ 'a', "aspect", ap_yes, "N:D", "force aspect ratio", false },
{ 'A', "audiodelay", ap_yes, "MS", "compensate for audio heard MS milliseconds late (negative if early)", false },
{ 'b', "scalefactor", ap_yes, "PERCENT", "reduce screen size for overscan compensation", true },
{ 'c', "showcalculated", ap_no, NULL, "show calculated framefile values for debugging", false },
{ 'C', "canvas", ap_yes, "WxH", "world size for games without a disc (default 720x480)", false },
@ -290,12 +291,21 @@ static bool _extractArchive(const char *filename) {
}
// Writes an embedded support file, or rewrites it when the installed copy differs from this build's.
static bool _extractFile(const char *filename, const uint8_t *data, size_t length) {
FILE *out = NULL;
bool written = false;
FILE *out = NULL;
char *existing = NULL;
size_t bytes = 0;
bool written = false;
bool same = false;
if (utilFileExists(filename)) {
return false;
existing = utilReadFile(filename, &bytes);
same = (existing != NULL) && (bytes == length) && (memcmp(existing, data, length) == 0);
free(existing);
if (same) {
return false;
}
}
_showHeader();
@ -310,7 +320,7 @@ static bool _extractFile(const char *filename, const uint8_t *data, size_t lengt
unlink(filename);
utilDie("Unable to write %s", filename);
}
utilSay(">>> Created File: %s", filename);
utilSay(">>> %s File: %s", existing ? "Updated" : "Created", filename);
return true;
}
@ -477,7 +487,7 @@ static void _launcher(const char *exeName, ConfigT *conf) {
// Start our video playback system
_mainTrace(conf, "Initializing laserdisc video");
videoInit(MIXER_CHUNK_SAMPLES);
videoInit();
// Finish our setup
_mainTrace(conf, "Disabling screen saver");
@ -640,6 +650,11 @@ static ConfigT *_parseArguments(const char *exeName, int32_t argc, char *argv[])
conf->fullScreen = true;
break;
// Audio Delay
case 'A':
target = &conf->audioDelayMs;
break;
// Sinden Light Gun
case 'g':
free(sindenString);
@ -738,9 +753,9 @@ static ConfigT *_parseArguments(const char *exeName, int32_t argc, char *argv[])
}
ap_free(&parser);
if (!conf->scriptFile) {
_showUsage(exeName, "No script file specified.");
}
// A missing script is reported by main() after the support files and
// any game archives have been dealt with: running with no arguments
// is the documented way to install.
// Do the full screen options make sense?
if (conf->fullScreen && conf->fullScreenWindow) {
@ -755,6 +770,11 @@ static ConfigT *_parseArguments(const char *exeName, int32_t argc, char *argv[])
_showUsage(exeName, "Effects volume must be between 0 and 100 percent.");
}
// Sane audio delay?
if ((conf->audioDelayMs < -VIDEO_AUDIO_DELAY_MAX) || (conf->audioDelayMs > VIDEO_AUDIO_DELAY_MAX)) {
_showUsage(exeName, "Audio delay must be between -1000 and 1000 milliseconds.");
}
// Sane scale factor?
if ((conf->scaleFactor < SCALE_FACTOR_MIN) || (conf->scaleFactor > SCALE_FACTOR_MAX)) {
_showUsage(exeName, "Display scale must be between 50 and 100 percent.");
@ -1000,6 +1020,7 @@ static void _unpackData(const char *name) {
{ "Menu.singe", Menu_singe, Menu_singe_len },
{ "FreeSansBold.ttf", FreeSansBold_ttf, FreeSansBold_ttf_len },
{ "menuBackground.mkv", menuBackground_mkv, menuBackground_mkv_len },
{ "click.wav", click_wav, click_wav_len },
{ "Manual.pdf", Manual_pdf, Manual_pdf_len }
};
int32_t x = 0;
@ -1007,7 +1028,7 @@ static void _unpackData(const char *name) {
char *data = NULL;
bool created = false;
// Extract any missing support files. We do this here so they're not generated if launched from a front end.
// Extract missing or outdated support files. We do this here so they are not generated if launched from a front end.
if (!utilMkDirP(SUPPORT_DIR, DIRECTORY_MODE)) {
utilDie("Unable to create %s directory.", SUPPORT_DIR);
}
@ -1325,6 +1346,11 @@ int main(int argc, char *argv[]) {
_unpackData(exeName);
_unpackGames();
// Nothing to run? Installing was the whole job.
if (!conf->scriptFile) {
_showUsage(exeName, "No script file specified.");
}
// Queue initial script
_resolveFiles(exeName, conf);
queueScript(conf);

View file

@ -59,6 +59,7 @@ LSEC_API int luaopen_ssl_config(lua_State *L);
// We have to do the embedding here so the Lua module
// definitions can find their length properly. They
// can't be external to this source file.
#define AUDIO_CALIBRATION_FILE "audio.cfg" // Per-machine audio delay, in the data root
#define EMBED_HERE
#include "embedded.h"
@ -446,6 +447,7 @@ static void _fireMouseMoved(int32_t device, int32_t x, int32_t y, int32_
static void _fontDestroy(FontT *font);
static void _freezeGame(bool freeze);
static void _heldListUpdate(HeldKeyT *list, int32_t *count, bool down, int32_t keysym, int32_t scancode);
static int32_t _loadAudioCalibration(void);
static void _loadControlsFile(const char *path);
static SDL_Surface *_loadEmbeddedPng(const unsigned char *data, unsigned int length);
static SDL_Texture *_loadEmbeddedTexture(const unsigned char *data, unsigned int length, SDL_Surface **surface);
@ -466,6 +468,7 @@ static void _putPixel(int32_t x, int32_t y, uint32_t pixel);
static void _readColor(lua_State *L, const char *method, SDL_Color *color, uint8_t defaultAlpha);
static void _releaseAxis(int32_t axisIndex);
static SDL_Surface *_renderText(lua_State *L, const char *method, const char *message);
static void _saveAudioCalibration(int32_t milliseconds);
static void _selectDefaultAudioTrack(int32_t handle);
static void _setMouseCaptured(bool captured);
static void _setPause(bool paused, bool fromKey);
@ -537,13 +540,19 @@ static int32_t apiOverlayPrint(lua_State *L);
static int32_t apiOverlaySetResolution(lua_State *L);
static int32_t apiScriptExecute(lua_State *L);
static int32_t apiScriptPush(lua_State *L);
static int32_t apiSingeGetAudioCalibration(lua_State *L);
static int32_t apiSingeGetAudioDelay(lua_State *L);
static int32_t apiSingeGetAudioLatency(lua_State *L);
static int32_t apiSingeGetDataPath(lua_State *L);
static int32_t apiSingeGetHeight(lua_State *L);
static int32_t apiSingeGetPauseFlag(lua_State *L);
static int32_t apiSingeGetScriptPath(lua_State *L);
static int32_t apiSingeGetTicks(lua_State *L);
static int32_t apiSingeGetWidth(lua_State *L);
static int32_t apiSingeQuit(lua_State *L);
static int32_t apiSingeScreenshot(lua_State *L);
static int32_t apiSingeSetAudioCalibration(lua_State *L);
static int32_t apiSingeSetAudioDelay(lua_State *L);
static int32_t apiSingeSetGameName(lua_State *L);
static int32_t apiSingeSetPauseFlag(lua_State *L);
static int32_t apiSingeSetPauseKeyEnabled(lua_State *L);
@ -789,6 +798,8 @@ static ConfigT *_buildConfFromTable(lua_State *L) {
}
} else if (strcmp(confKey, "AUDIO_TRACK") == 0) {
c->audioOutputTrack = (int32_t)valueNumber;
} else if (strcmp(confKey, "AUDIO_DELAY") == 0) {
c->audioDelayMs = (int32_t)valueNumber;
} else if (strcmp(confKey, "LEGACY_SPRITE_ARGS") == 0) {
c->legacySpriteArgs = valueBoolean;
} else if (strcmp(confKey, "DISC") == 0) {
@ -1314,6 +1325,28 @@ static void _heldListUpdate(HeldKeyT *list, int32_t *count, bool down, int32_t k
}
// The per-machine audio delay lives beside the data directories, since it is not a property of any game.
static int32_t _loadAudioCalibration(void) {
char *path = utilCreateString("%s%s", _global.conf->dataDirBase, AUDIO_CALIBRATION_FILE);
size_t bytes = 0;
char *data = utilReadFile(path, &bytes);
int32_t value = 0;
if (data) {
if (sscanf(data, "delay = %d", &value) != 1) {
value = 0;
}
free(data);
}
free(path);
if ((value < -VIDEO_AUDIO_DELAY_MAX) || (value > VIDEO_AUDIO_DELAY_MAX)) {
value = 0;
}
return value;
}
// Runs a controls.cfg if it exists.
static void _loadControlsFile(const char *path) {
if (utilFileExists(path)) {
@ -1737,6 +1770,20 @@ static SDL_Surface *_renderText(lua_State *L, const char *method, const char *me
}
static void _saveAudioCalibration(int32_t milliseconds) {
char *path = utilCreateString("%s%s", _global.conf->dataDirBase, AUDIO_CALIBRATION_FILE);
FILE *out = fopen(path, "w");
if (out) {
fprintf(out, "delay = %d\n", milliseconds);
fclose(out);
} else {
utilSay("Unable to write %s", path);
}
free(path);
}
// Applies the command line audio track to a freshly loaded video, when it has one.
static void _selectDefaultAudioTrack(int32_t handle) {
if (_global.conf->audioOutputTrack < videoGetAudioTracks(handle)) {
@ -2969,6 +3016,39 @@ static int32_t apiScriptPush(lua_State *L) {
}
// milliseconds = singeGetAudioCalibration() The per-machine value saved by the menu's calibration screen.
static int32_t apiSingeGetAudioCalibration(lua_State *L) {
int32_t value = videoGetAudioCalibration();
_luaTrace(L, "singeGetAudioCalibration", "%d", value);
lua_pushinteger(L, value);
return 1;
}
// milliseconds = singeGetAudioDelay()
static int32_t apiSingeGetAudioDelay(lua_State *L) {
int32_t delay = videoGetAudioDelay();
_luaTrace(L, "singeGetAudioDelay", "%d", delay);
lua_pushinteger(L, delay);
return 1;
}
// milliseconds = singeGetAudioLatency() The audio device queue measured at startup.
static int32_t apiSingeGetAudioLatency(lua_State *L) {
int32_t value = videoGetAudioLatency();
_luaTrace(L, "singeGetAudioLatency", "%d", value);
lua_pushinteger(L, value);
return 1;
}
// path = singeGetDataPath()
static int32_t apiSingeGetDataPath(lua_State *L) {
_luaTrace(L, "singeGetDataPath", "%s", _global.conf->dataDir);
@ -3008,6 +3088,17 @@ static int32_t apiSingeGetScriptPath(lua_State *L) {
}
// milliseconds = singeGetTicks() Wall clock since the engine started.
static int32_t apiSingeGetTicks(lua_State *L) {
uint32_t ticks = SDL_GetTicks();
_luaTrace(L, "singeGetTicks", "%" PRIu32, ticks);
lua_pushinteger(L, (lua_Integer)ticks);
return 1;
}
// width = singeGetWidth() Window width in pixels.
static int32_t apiSingeGetWidth(lua_State *L) {
int32_t x = 0;
@ -3039,6 +3130,23 @@ static int32_t apiSingeScreenshot(lua_State *L) {
}
// singeSetAudioCalibration(milliseconds) Applies now and is remembered for every game on this machine.
static int32_t apiSingeSetAudioCalibration(lua_State *L) {
int32_t value = 0;
_argCheck(L, "singeSetAudioCalibration", 1, 1);
value = _argInteger(L, "singeSetAudioCalibration", 1);
if ((value < -VIDEO_AUDIO_DELAY_MAX) || (value > VIDEO_AUDIO_DELAY_MAX)) {
_luaDie(L, "singeSetAudioCalibration", "Audio calibration must be between %d and %d milliseconds: %d", -VIDEO_AUDIO_DELAY_MAX, VIDEO_AUDIO_DELAY_MAX, value);
}
videoSetAudioCalibration(value);
_saveAudioCalibration(value);
_luaTrace(L, "singeSetAudioCalibration", "%d", value);
return 0;
}
// singeSetGameName(title)
static int32_t apiSingeSetGameName(lua_State *L) {
const char *title = NULL;
@ -3062,6 +3170,22 @@ static int32_t apiSingeSetPauseFlag(lua_State *L) {
}
// singeSetAudioDelay(milliseconds) Positive when the audio device is heard later than it reports.
static int32_t apiSingeSetAudioDelay(lua_State *L) {
int32_t delay = 0;
_argCheck(L, "singeSetAudioDelay", 1, 1);
delay = _argInteger(L, "singeSetAudioDelay", 1);
if ((delay < -VIDEO_AUDIO_DELAY_MAX) || (delay > VIDEO_AUDIO_DELAY_MAX)) {
_luaDie(L, "singeSetAudioDelay", "Audio delay must be between %d and %d milliseconds: %d", -VIDEO_AUDIO_DELAY_MAX, VIDEO_AUDIO_DELAY_MAX, delay);
}
videoSetAudioDelay(delay);
_luaTrace(L, "singeSetAudioDelay", "%d", delay);
return 0;
}
// singeSetPauseKeyEnabled(enabled) Framework.singe aliases singeEnablePauseKey()/singeDisablePauseKey().
static int32_t apiSingeSetPauseKeyEnabled(lua_State *L) {
_argCheck(L, "singeSetPauseKeyEnabled", 1, 1);
@ -4141,6 +4265,9 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, ConfigT *conf) {
// Local copy of config
_global.conf = cloneConf(conf);
videoSetAudioDelay(_global.conf->audioDelayMs);
videoSetAudioCalibration(_loadAudioCalibration());
utilTrace("Audio delay: device queue %d ms, calibration %d ms, game %d ms", videoGetAudioLatency(), videoGetAudioCalibration(), videoGetAudioDelay());
// Load controller mappings in a throwaway Lua context.
_progTrace("Creating Lua context for Singe setup");
@ -4266,7 +4393,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, ConfigT *conf) {
lua_register(_global.luaContext, "mouseGetPosition", apiMouseGetPosition); // 2.00
lua_register(_global.luaContext, "mouseHowMany", apiMouseHowMany); // 1.18 RDG
lua_register(_global.luaContext, "mouseSetCaptured", apiMouseSetCaptured); // 2.00
lua_register(_global.luaContext, "mouseSetEnabled", apiMouseSetEnabled); // 2.20 mouseEnable/mouseDisable are framework aliases.
lua_register(_global.luaContext, "mouseSetEnabled", apiMouseSetEnabled); // 3.00 mouseEnable/mouseDisable are framework aliases.
lua_register(_global.luaContext, "mouseSetMode", apiMouseSetMode); // 1.18 RDG
lua_register(_global.luaContext, "overlayBox", apiOverlayBox); // 2.00
@ -4283,16 +4410,22 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, ConfigT *conf) {
lua_register(_global.luaContext, "scriptExecute", apiScriptExecute); // 2.00
lua_register(_global.luaContext, "scriptPush", apiScriptPush); // 2.00
lua_register(_global.luaContext, "singeGetAudioCalibration", apiSingeGetAudioCalibration); // 3.00
lua_register(_global.luaContext, "singeGetAudioDelay", apiSingeGetAudioDelay); // 3.00
lua_register(_global.luaContext, "singeGetAudioLatency", apiSingeGetAudioLatency); // 3.00
lua_register(_global.luaContext, "singeGetDataPath", apiSingeGetDataPath); // 2.00
lua_register(_global.luaContext, "singeGetHeight", apiSingeGetHeight); // 1.xx
lua_register(_global.luaContext, "singeGetPauseFlag", apiSingeGetPauseFlag); // 1.xx RDG
lua_register(_global.luaContext, "singeGetScriptPath", apiSingeGetScriptPath); // 1.15 RDG
lua_register(_global.luaContext, "singeGetTicks", apiSingeGetTicks); // 3.00
lua_register(_global.luaContext, "singeGetWidth", apiSingeGetWidth); // 1.xx
lua_register(_global.luaContext, "singeQuit", apiSingeQuit); // 1.xx RDG
lua_register(_global.luaContext, "singeScreenshot", apiSingeScreenshot); // 1.xx
lua_register(_global.luaContext, "singeSetAudioCalibration", apiSingeSetAudioCalibration); // 3.00
lua_register(_global.luaContext, "singeSetAudioDelay", apiSingeSetAudioDelay); // 3.00
lua_register(_global.luaContext, "singeSetGameName", apiSingeSetGameName); // 1.15 RDG
lua_register(_global.luaContext, "singeSetPauseFlag", apiSingeSetPauseFlag); // 1.xx RDG
lua_register(_global.luaContext, "singeSetPauseKeyEnabled", apiSingeSetPauseKeyEnabled); // 2.20 singeEnablePauseKey/singeDisablePauseKey are framework aliases.
lua_register(_global.luaContext, "singeSetPauseKeyEnabled", apiSingeSetPauseKeyEnabled); // 3.00 singeEnablePauseKey/singeDisablePauseKey are framework aliases.
lua_register(_global.luaContext, "singeVersion", apiSingeVersion); // 1.xx RDG
lua_register(_global.luaContext, "singeWantsCrosshairs", apiSingeWantsCrosshairs); // 2.00
@ -4307,20 +4440,20 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, ConfigT *conf) {
lua_register(_global.luaContext, "soundStop", apiSoundStop); // 1.xx RDG
lua_register(_global.luaContext, "soundUnload", apiSoundUnload); // 2.00
lua_register(_global.luaContext, "spriteDraw", apiSpriteDraw); // 1.xx Handle first since 2.20.
lua_register(_global.luaContext, "spriteDraw", apiSpriteDraw); // 1.xx Handle first since 3.00.
lua_register(_global.luaContext, "spriteGetFrame", apiSpriteGetFrame); // 2.10
lua_register(_global.luaContext, "spriteGetHeight", apiSpriteGetHeight); // 2.00
lua_register(_global.luaContext, "spriteGetWidth", apiSpriteGetWidth); // 2.00
lua_register(_global.luaContext, "spriteIsPlaying", apiSpriteIsPlaying); // 2.10
lua_register(_global.luaContext, "spriteLoad", apiSpriteLoad); // 1.xx
lua_register(_global.luaContext, "spriteLoop", apiSpriteLoop); // 2.10 Handle first since 2.20.
lua_register(_global.luaContext, "spriteLoop", apiSpriteLoop); // 2.10 Handle first since 3.00.
lua_register(_global.luaContext, "spritePause", apiSpritePause); // 2.10
lua_register(_global.luaContext, "spritePlay", apiSpritePlay); // 2.10
lua_register(_global.luaContext, "spriteQuality", apiSpriteQuality); // 2.10 Handle first since 2.20.
lua_register(_global.luaContext, "spriteRotate", apiSpriteRotate); // 2.10 Handle first since 2.20.
lua_register(_global.luaContext, "spriteRotateAndScale", apiSpriteRotateAndScale); // 2.10 Handle first since 2.20.
lua_register(_global.luaContext, "spriteScale", apiSpriteScale); // 2.10 Handle first since 2.20.
lua_register(_global.luaContext, "spriteSetFrame", apiSpriteSetFrame); // 2.10 Handle first since 2.20.
lua_register(_global.luaContext, "spriteQuality", apiSpriteQuality); // 2.10 Handle first since 3.00.
lua_register(_global.luaContext, "spriteRotate", apiSpriteRotate); // 2.10 Handle first since 3.00.
lua_register(_global.luaContext, "spriteRotateAndScale", apiSpriteRotateAndScale); // 2.10 Handle first since 3.00.
lua_register(_global.luaContext, "spriteScale", apiSpriteScale); // 2.10 Handle first since 3.00.
lua_register(_global.luaContext, "spriteSetFrame", apiSpriteSetFrame); // 2.10 Handle first since 3.00.
lua_register(_global.luaContext, "spriteUnload", apiSpriteUnload); // 2.00
lua_register(_global.luaContext, "videoDraw", apiVideoDraw); // 2.00
@ -4720,11 +4853,11 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, ConfigT *conf) {
// Deliver sound completions on this thread. They wait out an engine pause.
if (!_global.frozen) {
SDL_LockAudio();
videoLockAudio();
finishedCount = _global.soundQueueCount;
memcpy(finished, _global.soundQueue, sizeof(int32_t) * (size_t)finishedCount);
_global.soundQueueCount = 0;
SDL_UnlockAudio();
videoUnlockAudio();
for (x = 0; x < finishedCount; x++) {
_callLua("onSoundCompleted", "i", finished[x]);
}

View file

@ -75,6 +75,7 @@ typedef struct ConfigS {
int32_t sindenArgc;
int32_t sindenArgv[SINDEN_ARG_MAX];
int32_t audioOutputTrack;
int32_t audioDelayMs; // Positive when the audio device is heard later than it reports
} ConfigT;

View file

@ -22,6 +22,9 @@
#include <string.h>
#ifdef __linux__
#include <dlfcn.h>
#endif
#include "include/SDL2/SDL_mixer.h"
#include "../thirdparty/ffms2/include/ffms.h"
@ -48,6 +51,10 @@ typedef struct iso639_lang_t iso639_lang_t;
#define AUDIO_SAMPLE_PREREAD 1024
#define AUDIO_SILENCE_SECONDS 2
#define AUDIO_CHANNELS_MAX 2
#define AUDIO_DEVICE_ID_MAX 16
#define AUDIO_DRAIN_MS 400 // Long enough for any sane device queue to empty
#define AUDIO_MEASURE_TIMEOUT_MS 2000
#define AUDIO_MEASURE_MAX_BUFFERS 64
#define BITS_PER_BYTE 8
#define BYTES_PER_PIXEL 4
#define ERROR_BUFFER_SIZE 1024
@ -93,6 +100,7 @@ typedef struct VideoPlayerS {
int32_t audioSourceCount;
int64_t frame;
int64_t audioBufferSize;
int64_t firstFrameTime; // Presentation time (ms) of frame 0; FFMS2 aligns audio sample 0 to it
int64_t startTime; // Video time (ms) at the last play/seek
int64_t samplesPlayed; // Mixer frames handed to the device since the last reset
uint32_t lastCallbackTicks;
@ -131,6 +139,7 @@ typedef struct VideoPlayerS {
static int64_t _audioClock(VideoPlayerT *v, uint32_t now);
static void _allocateFrameBuffer(VideoPlayerT *v, FrameBufferT *buffer);
static void _alsaSetQuiet(bool quiet);
static void _copyFrame(VideoPlayerT *v, FrameBufferT *buffer, const FFMS_Frame *frame);
static FFMS_Index *_createIndex(const char *filename, const char *indexPath, bool hasVideo, bool hasAudio, VideoPlayerT *v);
static int _decoderThread(void *data); // SDL thread entry. Not changing int.
@ -144,10 +153,21 @@ static void _reportKeyframes(VideoPlayerT *v, const char *filename);
static void _requestFrame(VideoPlayerT *v);
static bool _takeDecodedFrame(VideoPlayerT *v);
static void _uploadFrame(VideoPlayerT *v);
static void _measureDeviceQueue(void *udata, uint8_t *stream, int32_t bytes);
static int64_t _msToSamples(VideoPlayerT *v, int64_t ms);
static void _resetClock(VideoPlayerT *v, uint32_t now);
#ifdef __linux__
// libasound prints its own diagnostics to stderr. SDL loads it, so borrow its error hook.
typedef void (*AlsaErrorHandlerT)(const char *file, int line, const char *function, int err, const char *fmt, ...);
typedef int (*AlsaSetErrorHandlerT)(AlsaErrorHandlerT handler);
static void _alsaQuiet(const char *file, int line, const char *function, int err, const char *fmt, ...);
static void *_alsaLibrary = NULL;
#endif
static VideoIndexingCallbackT _indexingFunction = NULL;
static VideoPlayerT *_videoPlayerHash = NULL;
static int32_t _nextId = 0;
@ -155,7 +175,17 @@ static int32_t _mixRate = -1;
static uint8_t _mixChannels = 0;
static int32_t _mixFrameBytes = 0;
static int64_t _mixLatencyMs = 0; // Time between handing audio to the device and hearing it
static int32_t _audioDelayMs = 0; // Per-game correction on top of the measured latency
static int32_t _audioCalibrationMs = 0; // Per-machine correction, from the calibration screen
static SDL_AudioFormat _mixFormat = 0;
static SDL_AudioDeviceID _mixDevice = 0; // SDL_mixer's device; SDL_LockAudio() only knows the legacy device 1
// Startup measurement of the audio device queue (see videoInit).
static bool _measuring = false;
static int32_t _measureCount = 0;
static int64_t _measureFrames = 0;
static int64_t _measurePeriodMs = 0;
static uint32_t _measureLastTicks = 0;
static void _allocateFrameBuffer(VideoPlayerT *v, FrameBufferT *buffer) {
@ -203,24 +233,61 @@ static void _copyFrame(VideoPlayerT *v, FrameBufferT *buffer, const FFMS_Frame *
// Presentation time (ms) the listener is hearing right now. Audio is the master clock:
// the picture is fitted to what the device has actually consumed, so the two cannot drift.
#ifdef __linux__
static void _alsaQuiet(const char *file, int line, const char *function, int err, const char *fmt, ...) {
(void)file;
(void)line;
(void)function;
(void)err;
(void)fmt;
}
#endif
// Silences libasound while the device is drained on purpose, so the expected underrun is not reported.
static void _alsaSetQuiet(bool quiet) {
#ifdef __linux__
AlsaSetErrorHandlerT setHandler = NULL;
void *symbol = NULL;
if (quiet) {
_alsaLibrary = dlopen("libasound.so.2", RTLD_NOW);
}
if (_alsaLibrary) {
symbol = dlsym(_alsaLibrary, "snd_lib_error_set_handler");
if (symbol) {
memcpy(&setHandler, &symbol, sizeof(setHandler));
setHandler(quiet ? _alsaQuiet : NULL);
}
if (!quiet) {
dlclose(_alsaLibrary);
_alsaLibrary = NULL;
}
}
#else
(void)quiet;
#endif
}
static int64_t _audioClock(VideoPlayerT *v, uint32_t now) {
int64_t played = 0;
uint32_t last = 0;
bool valid = false;
int64_t clock = 0;
SDL_LockAudio();
SDL_LockAudioDevice(_mixDevice);
played = v->samplesPlayed;
last = v->lastCallbackTicks;
valid = v->audioClockValid;
SDL_UnlockAudio();
SDL_UnlockAudioDevice(_mixDevice);
if (valid) {
// Consumed samples, interpolated since the last callback, less the device buffer still queued.
clock = v->startTime + (played * (int64_t)MS_PER_SECOND / _mixRate) + (int64_t)(now - last) - _mixLatencyMs;
clock = v->startTime + (played * (int64_t)MS_PER_SECOND / _mixRate) + (int64_t)(now - last) - _mixLatencyMs - _audioDelayMs - _audioCalibrationMs;
} else {
// No callback yet: wall clock, offset the same way so the switch over is seamless.
clock = v->startTime + (int64_t)(now - v->startTicks) - _mixLatencyMs;
clock = v->startTime + (int64_t)(now - v->startTicks) - _mixLatencyMs - _audioDelayMs - _audioCalibrationMs;
}
if (clock < v->startTime) {
clock = v->startTime;
@ -307,7 +374,7 @@ static int _decoderThread(void *data) {
}
// Runs on the SDL_mixer audio thread. Everything it touches is guarded by SDL_LockAudio on the main thread.
// Runs on the SDL_mixer audio thread. Everything it touches is guarded by the device lock on the main thread.
static void _dequeueVideoAudio(int channel, void *stream, int bytes, void *udata) {
VideoPlayerT *v = (VideoPlayerT *)udata;
int32_t bytesToCopy = bytes;
@ -354,9 +421,9 @@ static void _feedAudio(VideoPlayerT *v) {
int64_t count = 0;
int32_t available = 0;
SDL_LockAudio();
SDL_LockAudioDevice(_mixDevice);
available = SDL_AudioStreamAvailable(v->audioStream);
SDL_UnlockAudio();
SDL_UnlockAudioDevice(_mixDevice);
while ((available < AUDIO_STREAM_LOW_WATERMARK) && (v->audioPosition < track->audioProps->NumSamples)) {
// Don't read past end of audio data
@ -369,12 +436,12 @@ static void _feedAudio(VideoPlayerT *v) {
utilDie("%s", v->errInfo.Buffer);
}
// Feed it to the mixer stream
SDL_LockAudio();
SDL_LockAudioDevice(_mixDevice);
if (SDL_AudioStreamPut(v->audioStream, v->audioBuffer, (int32_t)(count * v->audioSampleSize)) < 0) {
utilDie("%s", SDL_GetError());
}
available = SDL_AudioStreamAvailable(v->audioStream);
SDL_UnlockAudio();
SDL_UnlockAudioDevice(_mixDevice);
v->audioPosition += count;
}
}
@ -482,6 +549,32 @@ static void _loadAudio(VideoPlayerT *v, const char *filename, FFMS_Index *index)
}
// Post-mix hook used once at startup. After the mixer thread has been stalled long enough for the
// device to drain, SDL refills the device as fast as it accepts data: a burst of callbacks a few
// microseconds apart, then one per chunk period. The burst is the queue depth between our callback
// and the speaker, which is what the audio clock has to subtract.
static void _measureDeviceQueue(void *udata, uint8_t *stream, int32_t bytes) {
uint32_t now = SDL_GetTicks();
(void)udata;
(void)stream;
if (!_measuring) {
return;
}
if (_measureCount == 0) {
_measurePeriodMs = (int64_t)bytes / _mixFrameBytes * (int64_t)MS_PER_SECOND / _mixRate;
} else if ((int64_t)(now - _measureLastTicks) >= _measurePeriodMs / 2) {
// First callback paced by the device: the burst is over.
_measuring = false;
return;
}
_measureCount++;
_measureFrames += bytes / _mixFrameBytes;
_measureLastTicks = now;
}
static int64_t _msToSamples(VideoPlayerT *v, int64_t ms) {
return (int64_t)((double)ms / MS_PER_SECOND * (double)v->audio[v->currentAudioTrack].audioProps->SampleRate);
}
@ -512,7 +605,7 @@ static void _reportKeyframes(VideoPlayerT *v, const char *filename) {
}
utilTrace("%s: %d frames, %d keyframes, longest gap %d frames (%.1f seconds)", filename, v->videoProps->NumFrames, keyframes, longestGap, seconds);
if (seconds > KEYFRAME_WARN_SECONDS) {
utilSay("Warning: %s has keyframes up to %.1f seconds apart; seeking into that video may stall. Re-encode with a keyframe interval of two seconds or less.", filename, seconds);
utilTrace("Warning: %s has keyframes up to %.1f seconds apart; seeking into that video may stall. Re-encode with a keyframe interval of two seconds or less.", filename, seconds);
}
}
@ -535,13 +628,17 @@ static void _resetClock(VideoPlayerT *v, uint32_t now) {
v->startTime = _frameTime(v, v->frame);
v->resetTime = false;
if (v->audioSourceCount > 0) {
SDL_LockAudio();
SDL_LockAudioDevice(_mixDevice);
SDL_AudioStreamClear(v->audioStream);
v->samplesPlayed = 0;
v->lastCallbackTicks = now;
v->audioClockValid = false;
SDL_UnlockAudio();
v->audioPosition = _msToSamples(v, v->startTime);
SDL_UnlockAudioDevice(_mixDevice);
// FFMS2 shifted the audio so sample 0 lines up with frame 0, whatever that frame's timestamp is.
v->audioPosition = _msToSamples(v, v->startTime - v->firstFrameTime);
if (v->audioPosition < 0) {
v->audioPosition = 0;
}
}
}
@ -580,6 +677,22 @@ static void _uploadFrame(VideoPlayerT *v) {
}
int32_t videoGetAudioCalibration(void) {
return _audioCalibrationMs;
}
int32_t videoGetAudioDelay(void) {
return _audioDelayMs;
}
// The device queue measured at startup, in milliseconds.
int32_t videoGetAudioLatency(void) {
return (int32_t)_mixLatencyMs;
}
int32_t videoGetAudioTrack(int32_t playerHandle) {
return _getPlayer(playerHandle, "videoGetAudioTrack")->currentAudioTrack;
}
@ -701,8 +814,9 @@ int32_t videoGetWidth(int32_t playerHandle) {
}
void videoInit(int32_t mixerChunkFrames) {
int32_t channels = 0;
void videoInit(void) {
int32_t channels = 0;
uint32_t started = 0;
// Start FFMS
FFMS_Init(0, 0);
@ -713,7 +827,42 @@ void videoInit(int32_t mixerChunkFrames) {
}
_mixChannels = (uint8_t)channels;
_mixFrameBytes = SDL_AUDIO_BITSIZE(_mixFormat) / BITS_PER_BYTE * _mixChannels;
_mixLatencyMs = (int64_t)mixerChunkFrames * (int64_t)MS_PER_SECOND / _mixRate;
// SDL_mixer does not expose its device id and it is the only device open, so find it.
for (_mixDevice = 1; _mixDevice <= AUDIO_DEVICE_ID_MAX; _mixDevice++) {
if (SDL_GetAudioDeviceStatus(_mixDevice) != SDL_AUDIO_STOPPED) {
break;
}
}
if (_mixDevice > AUDIO_DEVICE_ID_MAX) {
utilDie("videoInit: Unable to find the mixer's audio device.");
}
// Measure the device queue. SDL cannot report how much audio sits between the mixer callback
// and the speaker, but it refills an empty device in a burst, and the burst is that amount.
// Nothing but silence is playing yet, so draining the device is inaudible.
_alsaSetQuiet(true);
Mix_SetPostMix(_measureDeviceQueue, NULL);
SDL_LockAudioDevice(_mixDevice);
_measuring = true;
_measureCount = 0;
_measureFrames = 0;
SDL_Delay(AUDIO_DRAIN_MS);
SDL_UnlockAudioDevice(_mixDevice);
started = SDL_GetTicks();
while (_measuring && ((SDL_GetTicks() - started) < AUDIO_MEASURE_TIMEOUT_MS)) {
SDL_Delay(1);
}
Mix_SetPostMix(NULL, NULL);
_alsaSetQuiet(false);
if (_measuring || (_measureCount < 1) || (_measureCount > AUDIO_MEASURE_MAX_BUFFERS)) {
// Unreadable result: assume the two buffers a double-buffered device holds.
_measuring = false;
_measureFrames = (int64_t)(2.0 * (double)_measurePeriodMs * (double)_mixRate / MS_PER_SECOND);
_measureCount = 2;
}
_mixLatencyMs = _measureFrames * (int64_t)MS_PER_SECOND / _mixRate;
utilTrace("Audio device queue: %d buffers, %" PRId64 " frames, %" PRId64 " ms at %d Hz", _measureCount, _measureFrames, _mixLatencyMs, _mixRate);
// Volume only works with MIX_DEFAULT_FORMAT
if (_mixFormat != MIX_DEFAULT_FORMAT) {
@ -732,6 +881,12 @@ bool videoIsPlaying(int32_t playerHandle) {
// audioFilename may be NULL when the audio lives in the video file. rgb players decode to BGRA so
// scripts can read the pixels; everything else stays YUV and is converted by the GPU.
// The mixer's audio thread runs the sound and video callbacks; hold this to read what they write.
void videoLockAudio(void) {
SDL_LockAudioDevice(_mixDevice);
}
int32_t videoLoad(const char *videoFilename, const char *audioFilename, const char *indexPath, SDL_Renderer *renderer, bool rgb) {
int32_t pixelFormats[2];
FFMS_Index *vIndex = NULL;
@ -786,6 +941,7 @@ int32_t videoLoad(const char *videoFilename, const char *audioFilename, const ch
v->videoProps = FFMS_GetVideoProperties(v->videoSource);
v->videoTrackHandle = FFMS_GetTrackFromVideo(v->videoSource);
v->videoTimeBase = FFMS_GetTimeBase(v->videoTrackHandle);
v->firstFrameTime = (v->videoProps->NumFrames > 0) ? _frameTime(v, 0) : 0;
frame = FFMS_GetFrame(v->videoSource, 0, &v->errInfo);
if (frame == NULL) {
utilDie("%s", v->errInfo.Buffer);
@ -959,6 +1115,16 @@ void videoSeek(int32_t playerHandle, int64_t seekFrame) {
}
void videoSetAudioCalibration(int32_t milliseconds) {
_audioCalibrationMs = milliseconds;
}
void videoSetAudioDelay(int32_t milliseconds) {
_audioDelayMs = milliseconds;
}
void videoSetAudioTrack(int32_t playerHandle, int32_t track) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoSetAudioTrack");
@ -982,10 +1148,15 @@ void videoSetVolume(int32_t playerHandle, int32_t leftPercent, int32_t rightPerc
VideoPlayerT *v = _getPlayer(playerHandle, "videoSetVolume");
// The mixer thread reads these.
SDL_LockAudio();
SDL_LockAudioDevice(_mixDevice);
v->volumeLeft = leftPercent;
v->volumeRight = rightPercent;
SDL_UnlockAudio();
SDL_UnlockAudioDevice(_mixDevice);
}
void videoUnlockAudio(void) {
SDL_UnlockAudioDevice(_mixDevice);
}

View file

@ -30,12 +30,16 @@
#include "common.h"
#define VIDEO_VOLUME_MAX 100
#define VIDEO_VOLUME_MAX 100
#define VIDEO_AUDIO_DELAY_MAX 1000 // Milliseconds either way
typedef void (*VideoIndexingCallbackT)(int32_t percent);
int32_t videoGetAudioCalibration(void);
int32_t videoGetAudioDelay(void);
int32_t videoGetAudioLatency(void);
int32_t videoGetAudioTrack(int32_t playerHandle);
int32_t videoGetAudioTracks(int32_t playerHandle);
int64_t videoGetFrame(int32_t playerHandle);
@ -47,16 +51,20 @@ bool videoGetPixel(int32_t playerHandle, int32_t x, int32_t y, uint8_t *r
bool videoGetPixels(int32_t playerHandle, const uint8_t **pixels, int32_t *pitch);
void videoGetVolume(int32_t playerHandle, int32_t *leftPercent, int32_t *rightPercent);
int32_t videoGetWidth(int32_t playerHandle);
void videoInit(int32_t mixerChunkFrames);
void videoInit(void);
bool videoIsPlaying(int32_t playerHandle);
void videoLockAudio(void);
int32_t videoLoad(const char *videoFilename, const char *audioFilename, const char *indexPath, SDL_Renderer *renderer, bool rgb);
void videoPause(int32_t playerHandle);
void videoPlay(int32_t playerHandle);
void videoQuit(void);
void videoSeek(int32_t playerHandle, int64_t seekFrame);
void videoSetAudioCalibration(int32_t milliseconds);
void videoSetAudioDelay(int32_t milliseconds);
void videoSetAudioTrack(int32_t playerHandle, int32_t track);
void videoSetIndexCallback(VideoIndexingCallbackT callback);
void videoSetVolume(int32_t playerHandle, int32_t leftPercent, int32_t rightPercent);
void videoUnlockAudio(void);
void videoUnload(int32_t playerHandle);
int64_t videoUpdate(int32_t playerHandle, SDL_Texture **texture);