Starting 3.00
This commit is contained in:
parent
34d61d957a
commit
20b76523c9
15 changed files with 700 additions and 76 deletions
41
CHANGELOG
41
CHANGELOG
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
INSTALL
2
INSTALL
|
|
@ -1,4 +1,4 @@
|
|||
SINGE 2.20
|
||||
SINGE 3.00
|
||||
==========
|
||||
|
||||
(For the latest version of this document, visit https://kangaroopunch.com!
|
||||
|
|
|
|||
3
LICENSES
3
LICENSES
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
128
docs/Manual.adoc
128
docs/Manual.adoc
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
40
src/main.c
40
src/main.c
|
|
@ -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,13 +291,22 @@ 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;
|
||||
char *existing = NULL;
|
||||
size_t bytes = 0;
|
||||
bool written = false;
|
||||
bool same = false;
|
||||
|
||||
if (utilFileExists(filename)) {
|
||||
existing = utilReadFile(filename, &bytes);
|
||||
same = (existing != NULL) && (bytes == length) && (memcmp(existing, data, length) == 0);
|
||||
free(existing);
|
||||
if (same) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_showHeader();
|
||||
out = fopen(filename, "wb");
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
155
src/singe.c
155
src/singe.c
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -31,11 +31,15 @@
|
|||
|
||||
|
||||
#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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue