Files
SpeedBreakerProject--speedb…/runtime/apu/audio.cpp
T
2026-10-17 04:39:51 +00:00

514 lines
23 KiB
C++

// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// XAudio render driver. The game registers one client callback; the audio
// system calls it once per 256-sample frame (48 kHz, ~5.33 ms) and the
// callback submits the mixed frame with XAudioSubmitRenderDriverFrame
// (256 samples x 6 channels, planar big-endian float).
//
// Frames go to an SDL3 audio stream (5.1 float; SDL downmixes to whatever
// the device has). The callback runs like the hardware's: one frame every
// 5.33 ms, evenly (the game's voices hold only a couple of frames of
// decoded audio). Calling it in
// bursts of whatever the device pulls at once (1024+ samples on PipeWire)
// left every voice empty for half of each burst: gameplay audio came out
// chopped 50% (two frames on, two off), thin and "tinny". SDL's queue is
// kept near two device buffers to absorb the device's pulls, and the
// cadence gains or skips a frame to follow the device clock, but never stops
// (apu/audio_cadence.h): all of the game's sound, its sound commands
// included, runs on this callback, so a device that stops taking audio must
// not stop it (GitHub issue #20: the sound stopped for good, then the game
// hung after the race). What such a device can't take is dropped, and the
// log says when it stopped and started taking audio again. NFSMW_MUTE=1
// (or no audio device) keeps the same cadence with no output. The driver
// handle and wrapped argument follow Xenia.
//
// While the game is suspended (iOS: the app isn't the active one) the
// cadence stands still with guest time and the main thread pauses the
// device (apu::SuspendOutput), which keeps what is queued: on the resume
// that plays first and the cadence carries on at its old phase, with no
// frames owed.
#include <stdafx.h>
#include "audio.h"
#include "audio_cadence.h"
#include <kernel/function.h>
#include <cpu/guest_thread.h>
#include <cpu/guest_time.h>
#include <SDL3/SDL.h>
#ifdef __APPLE__
#include <pthread.h>
#endif
#include <user/settings.h>
#include <video/presenter.h>
#include <report/report.h>
namespace
{
constexpr uint32_t DRIVER_HANDLE_BASE = 0x41550000; // Xenia
constexpr uint32_t SAMPLES_PER_FRAME = apu::kSamplesPerFrame;
constexpr uint32_t SAMPLE_RATE = apu::kSampleRate;
std::atomic<uint32_t> s_callback{ 0 };
std::atomic<uint32_t> s_callbackArgPtr{ 0 }; // guest pointer to the argument
std::atomic<bool> s_running{ false };
std::atomic<uint64_t> s_framesSubmitted{ 0 };
std::atomic<uint64_t> s_rendered{ 0 }; // render callbacks (apu::RenderedFrames)
constexpr uint32_t CHANNELS = 6; // FL FR FC LFE SL SR
// Set once, under s_streamMutex, which also orders starting the device
// (OpenDevice, a guest thread) against pausing it (the main thread), so
// a suspension can't slip between the check and the start.
std::mutex s_streamMutex;
SDL_AudioStream* s_stream = nullptr;
constexpr int FRAME_BYTES = SAMPLES_PER_FRAME * CHANNELS * sizeof(float);
std::atomic<int> s_targetFrames{ 4 }; // SDL queue level to hold (frames of 256 samples)
int s_deviceFrames = 0; // the device's buffer at the stream's rate (FollowDevice)
// What the hang report says about the audio (apu::DescribeState): when
// the render callback last ran, when the game's mix last had sound, and
// since when the device hasn't taken audio (0: it has); steady-clock ns.
std::atomic<int64_t> s_lastRenderNs{ 0 }, s_lastSoundNs{ 0 }, s_deviceStalledNs{ 0 };
std::atomic<uint64_t> s_dropped{ 0 }; // frames the device didn't take (dropped)
// The cadence (the audio thread; the frames it renders are submitted on
// that thread too).
apu::AudioCadence s_cadence;
int64_t SteadyNs()
{
return std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
// The queue level for the stream's device as it is now: two device
// buffers (at the stream's rate), at least 4 frames. Logs it when it
// changes, as when iOS moves the output to Bluetooth headphones, whose
// buffer can be larger: a target sized for the speaker's then ran dry.
void FollowDevice(bool opening)
{
SDL_AudioSpec deviceSpec{};
int deviceFrames = 0;
SDL_AudioDeviceID device = SDL_GetAudioStreamDevice(s_stream);
if (!SDL_GetAudioDeviceFormat(device, &deviceSpec, &deviceFrames))
return;
if (deviceFrames > 0 && deviceSpec.freq > 0)
deviceFrames = int(int64_t(deviceFrames) * SAMPLE_RATE / deviceSpec.freq);
if (!opening && deviceFrames == s_deviceFrames)
return;
s_deviceFrames = deviceFrames;
s_targetFrames = std::max(4, (2 * deviceFrames + int(SAMPLES_PER_FRAME) - 1) / int(SAMPLES_PER_FRAME));
fprintf(stderr, "[audio] %s: %s, 5.1 float at %u Hz (device %d ch, %s at %d Hz; device buffer %d samples, queue target %.1f ms)\n",
opening ? "output" : "output now", SDL_GetAudioDeviceName(device), SAMPLE_RATE, deviceSpec.channels,
SDL_GetAudioFormatName(deviceSpec.format), deviceSpec.freq, deviceFrames,
1000.0 * s_targetFrames * SAMPLES_PER_FRAME / SAMPLE_RATE);
}
void OpenDevice()
{
// NFSMW_MUTE=1 (automated runs): no device at all. A mute from the
// menu keeps it open at gain 0, so unmuting works live.
if (settings::EnvOverride(settings::Id::Mute) && settings::GetBool(settings::Id::Mute))
return;
if (!SDL_InitSubSystem(SDL_INIT_AUDIO))
{
fprintf(stderr, "[audio] SDL audio init failed: %s (silent)\n", SDL_GetError());
return;
}
SDL_AudioSpec spec{ SDL_AUDIO_F32, int(CHANNELS), int(SAMPLE_RATE) };
SDL_AudioStream* stream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec, nullptr, nullptr);
if (!stream)
{
fprintf(stderr, "[audio] no playback device: %s (silent)\n", SDL_GetError());
return;
}
{
// It opens paused. Opened while the game is suspended, it stays
// so until apu::ResumeOutput starts it.
std::lock_guard lock(s_streamMutex);
s_stream = stream;
if (!guesttime::Suspended())
SDL_ResumeAudioStreamDevice(s_stream);
}
FollowDevice(true);
}
void RenderFrame(GuestThreadContext& ctx)
{
uint32_t callback = s_callback;
if (callback != 0)
{
ctx.ppcContext.r3.u64 = s_callbackArgPtr;
g_memory.FindFunction(callback)(ctx.ppcContext, g_memory.base);
}
}
void AudioThread()
{
using namespace std::chrono;
#ifdef __APPLE__
// Every 5.3 ms, or the queue runs dry and the output crackles. At the
// default QoS an iPhone (two performance cores, both busy with the
// game and the command processor) ran it late on an efficiency core.
pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0);
#endif
GuestThreadContext ctx(0);
SetHostThreadName("nfsmw-audio");
constexpr auto INTERVAL = nanoseconds(1000000000ull * SAMPLES_PER_FRAME / SAMPLE_RATE);
// The time suspended that `next` allows for. Both taken while running
// (XAudio can start this thread in a suspension): `next` is a host
// time at which `paused` held.
steady_clock::time_point next;
int64_t paused;
do
{
guesttime::WaitWhileSuspended();
paused = guesttime::PausedNs();
next = steady_clock::now();
} while (guesttime::Suspended() || guesttime::PausedNs() != paused);
// NFSMW_TEST_AUDIO_DEVICE_STALL=<s>[+<secs>] (tests): the output
// device stops taking audio <s> seconds after this thread starts
// (paused, as a device that stalls or that another app holds), for
// good or for <secs>.
double stallAt = -1, stallFor = 0;
if (const char* v = std::getenv("NFSMW_TEST_AUDIO_DEVICE_STALL"))
{
char* end = nullptr;
stallAt = std::strtod(v, &end);
if (end && *end == '+')
stallFor = std::strtod(end + 1, nullptr);
}
int stallState = 0; // 0 before, 1 stalled, 2 over
const auto started = steady_clock::now();
while (s_running)
{
std::this_thread::sleep_until(next);
if (stallAt >= 0 && stallState < 2 && s_stream)
{
const double t = duration<double>(steady_clock::now() - started).count();
if (stallState == 0 && t >= stallAt)
{
stallState = 1;
fprintf(stderr, "[test] NFSMW_TEST_AUDIO_DEVICE_STALL: the output device stops taking audio (%.1f s)\n", t);
std::lock_guard lock(s_streamMutex);
SDL_PauseAudioStreamDevice(s_stream);
}
else if (stallState == 1 && stallFor > 0 && t >= stallAt + stallFor)
{
stallState = 2;
fprintf(stderr, "[test] NFSMW_TEST_AUDIO_DEVICE_STALL: the output device takes audio again (%.1f s)\n", t);
std::lock_guard lock(s_streamMutex);
if (!guesttime::Suspended())
SDL_ResumeAudioStreamDevice(s_stream);
}
}
// No guest audio time passes while the game is suspended: render
// nothing (the main thread has paused the device). After it the
// cadence moves on by exactly the time suspended, keeping its
// phase in guest time (the movies' clock follows the audio
// played): no frame is owed, and the queue is still at its
// level, so there is no burst either.
guesttime::WaitWhileSuspended();
if (int64_t nowPaused = guesttime::PausedNs(); nowPaused != paused)
{
next += nanoseconds(nowPaused - paused);
paused = nowPaused;
continue;
}
next += INTERVAL;
auto now = steady_clock::now();
if (next < now - milliseconds(100))
next = now; // fell far behind (debugger, stall): don't burst
int frames = 1;
if (s_stream)
{
// The device can change under the stream (headphones on or
// off): its buffer sets the target, so check it now and then.
static uint32_t sinceCheck = 0;
if (++sinceCheck >= SAMPLE_RATE / SAMPLES_PER_FRAME) // ~1 s
{
sinceCheck = 0;
FollowDevice(false);
}
// Follow the device clock: one frame more or less when the
// queue drifts from its target, never stopping the game's
// sound (audio_cadence.h).
int queued = std::max(0, SDL_GetAudioStreamQueued(s_stream)) / FRAME_BYTES;
frames = s_cadence.Frames(queued, s_targetFrames);
if (s_cadence.Stalled() && !s_deviceStalledNs.load(std::memory_order_relaxed))
{
s_deviceStalledNs.store(SteadyNs(), std::memory_order_relaxed);
fprintf(stderr, "[audio] the output device has taken no audio for %.1f s (%.0f ms queued): the game's sound "
"carries on, unheard, until it does\n", apu::AudioCadence::StallTicks(s_targetFrames) * apu::kFrameNs / 1e9,
1000.0 * queued * SAMPLES_PER_FRAME / SAMPLE_RATE);
}
if (s_cadence.Resumed())
{
// What was queued before the stall is stale.
SDL_ClearAudioStream(s_stream);
const int64_t since = s_deviceStalledNs.exchange(0, std::memory_order_relaxed);
fprintf(stderr, "[audio] the output device takes audio again after %.1f s (%llu frames dropped in all)\n",
since ? double(SteadyNs() - since) / 1e9 : 0.0, (unsigned long long)s_dropped.load());
}
}
for (int i = 0; i < frames && !guesttime::Suspended(); i++)
{
RenderFrame(ctx);
s_rendered.fetch_add(1, std::memory_order_relaxed);
s_lastRenderNs.store(SteadyNs(), std::memory_order_relaxed);
}
}
}
}
namespace apu
{
void SuspendOutput()
{
std::lock_guard lock(s_streamMutex);
if (s_stream)
SDL_PauseAudioStreamDevice(s_stream);
}
void ResumeOutput()
{
std::lock_guard lock(s_streamMutex);
if (s_stream && !guesttime::Suspended())
SDL_ResumeAudioStreamDevice(s_stream);
}
uint64_t RenderedFrames()
{
return s_rendered.load(std::memory_order_relaxed);
}
std::string DescribeState()
{
const int64_t now = SteadyNs();
auto ago = [now](int64_t t) { return t ? std::format("{:.1f} s ago", double(now - t) / 1e9) : std::string("never"); };
std::string text = std::format("Render callbacks: {} (the last {}); frames from the game: {}; the game's mix last had sound {}.\n",
s_rendered.load(), ago(s_lastRenderNs.load()), s_framesSubmitted.load(), ago(s_lastSoundNs.load()));
if (!s_stream)
return text + "Output device: none (muted by NFSMW_MUTE, or no device).\n";
const int64_t stalled = s_deviceStalledNs.load();
text += std::format("Output device: {:.1f} ms queued (target {:.1f} ms); {}; {} frames dropped in all.\n", QueuedMs(),
1000.0 * s_targetFrames * SAMPLES_PER_FRAME / SAMPLE_RATE,
stalled ? std::format("not taking audio for {:.1f} s", double(now - stalled) / 1e9) : std::string("taking audio"),
s_dropped.load());
return text;
}
double QueuedMs()
{
std::lock_guard lock(s_streamMutex);
if (!s_stream)
return 0.0;
int queued = std::max(0, SDL_GetAudioStreamQueued(s_stream));
return 1000.0 * queued / (CHANNELS * sizeof(float) * SAMPLE_RATE);
}
}
uint32_t XAudioRegisterRenderDriverClient(be<uint32_t>* callback, be<uint32_t>* driver)
{
// callback[0] = function, callback[1] = argument. Like Xenia, the callback
// receives a pointer to a guest copy of the argument.
uint32_t function = callback[0];
uint32_t argument = callback[1];
auto* argCopy = static_cast<be<uint32_t>*>(g_userHeap.Alloc(sizeof(uint32_t)));
*argCopy = argument;
if (s_callback != 0)
{
fprintf(stderr, "[audio] a second render driver client is not supported yet\n");
assert(false && "multiple XAudio render clients");
}
s_callbackArgPtr = g_memory.MapVirtual(argCopy);
s_callback = function;
*driver = DRIVER_HANDLE_BASE;
fprintf(stderr, "[audio] render client %08X (arg %08X) registered\n", function, argument);
if (!s_running.exchange(true))
{
OpenDevice();
std::thread(AudioThread).detach();
}
return 0;
}
uint32_t XAudioUnregisterRenderDriverClient(uint32_t driver)
{
s_callback = 0;
return 0;
}
// 256 samples x 6 channels, planar, big-endian float.
uint32_t XAudioSubmitRenderDriverFrame(uint32_t driver, void* samples)
{
s_framesSubmitted++;
// When the game's mix last had sound (the hang report), and a line once it
// has been silent for 10 s in a row (its sound stopped, or a silent
// screen) and when it sounds again: a player's log shows when it dropped.
if (samples)
{
static uint64_t silentFrames = 0;
static double silentFrom = 0;
const auto* words = static_cast<const uint32_t*>(samples);
bool silent = true;
for (uint32_t i = 0; i < SAMPLES_PER_FRAME * CHANNELS && silent; i++)
silent = (words[i] & 0xFFFFFF7Fu) == 0; // +0.0 or -0.0, big-endian
constexpr uint64_t kSilentLog = 10 * SAMPLE_RATE / SAMPLES_PER_FRAME;
if (!silent)
{
s_lastSoundNs.store(SteadyNs(), std::memory_order_relaxed);
if (silentFrames >= kSilentLog)
fprintf(stderr, "[audio] the game's mix sounds again after %.1f s of silence\n",
double(silentFrames) * SAMPLES_PER_FRAME / SAMPLE_RATE);
silentFrames = 0;
}
else
{
if (silentFrames == 0)
silentFrom = report::SessionSeconds();
if (++silentFrames == kSilentLog)
fprintf(stderr, "[audio] the game's mix has been silent for 10 s (since %.1f s)\n", silentFrom);
}
}
if (!s_stream || !samples)
return 0;
// Volume, mute and mute-in-background, when they or the focus change.
{
static uint64_t generation = ~0ull;
static bool wasFocused = true;
bool focused = video::WindowFocused();
if (uint64_t g = settings::Generation(); g != generation || focused != wasFocused)
{
generation = g;
wasFocused = focused;
bool muted = settings::GetBool(settings::Id::Mute);
bool background = !focused && settings::GetBool(settings::Id::MuteInBackground);
float volume = float(settings::GetInt(settings::Id::MasterVolume)) / 100.0f;
float gain = muted || background ? 0.0f : volume * volume; // squared: closer to perceived loudness
SDL_SetAudioStreamGain(s_stream, gain);
static float logged = -1.0f;
if (gain != logged)
{
// With the output position, so a recording's level can be matched to it.
logged = gain;
fprintf(stderr, "[audio] gain %.4f (volume %d%%%s%s) from %.2f s of output\n", gain, int(volume * 100.0f + 0.5f),
muted ? ", muted" : "", background ? ", muted in the background" : "",
double(s_framesSubmitted) * SAMPLES_PER_FRAME / SAMPLE_RATE);
}
}
}
const auto* in = static_cast<const uint32_t*>(samples);
float out[SAMPLES_PER_FRAME * CHANNELS];
for (uint32_t c = 0; c < CHANNELS; c++)
for (uint32_t i = 0; i < SAMPLES_PER_FRAME; i++)
{
uint32_t bits = ByteSwap(in[c * SAMPLES_PER_FRAME + i]);
float v;
memcpy(&v, &bits, 4);
out[i * CHANNELS + c] = v;
}
// Cut-out detection: the device ran dry, or the game's frames stopped.
// Gaps in guest time, so a suspension (the device paused with it) isn't
// one, even for a frame that was under way as it began.
{
static int64_t lastFrame = guesttime::NowNs();
int64_t guestNow = guesttime::NowNs();
double gapMs = double(guestNow - lastFrame) / 1e6;
lastFrame = guestNow;
auto now = std::chrono::steady_clock::now();
int queued = SDL_GetAudioStreamQueued(s_stream);
static uint32_t underruns = 0, gaps = 0;
static double worstGap = 0;
if (queued == 0)
underruns++;
if (gapMs > 50)
gaps++;
worstGap = std::max(worstGap, gapMs);
// Every 2 s with NFSMW_AUDIO_LOG; otherwise only when it went wrong,
// so a player's log shows where a crackle came from.
static const bool logStats = std::getenv("NFSMW_AUDIO_LOG") != nullptr;
static auto nextLog = now + std::chrono::seconds(2);
if (now >= nextLog && (logStats || underruns || gaps))
{
fprintf(stderr, "[audio] frames: %u device-empty on submit, %u gaps > 50 ms (worst %.0f ms)\n", underruns, gaps, worstGap);
underruns = gaps = 0;
worstGap = 0;
nextLog = now + std::chrono::seconds(2);
}
}
// A device that isn't taking audio (audio_cadence.h): the frame is
// dropped, and the game's sound carries on, unheard.
if (s_cadence.Stalled())
{
s_dropped.fetch_add(1, std::memory_order_relaxed);
return 0;
}
SDL_PutAudioStreamData(s_stream, out, sizeof(out));
// NFSMW_AUDIO_DUMP=<file>: the final mix as raw interleaved 6-channel
// float (48 kHz), from NFSMW_AUDIO_DUMP_FROM seconds for
// NFSMW_AUDIO_DUMP_SECONDS (default 30).
{
static const char* dumpPath = std::getenv("NFSMW_AUDIO_DUMP");
static const double from = [] { const char* v = std::getenv("NFSMW_AUDIO_DUMP_FROM"); return v ? std::atof(v) : 0.0; }();
static const double seconds = [] { const char* v = std::getenv("NFSMW_AUDIO_DUMP_SECONDS"); return v ? std::atof(v) : 30.0; }();
static const auto start = std::chrono::steady_clock::now();
static FILE* dump = nullptr;
static uint64_t dumped = 0;
if (dumpPath && dumped < uint64_t(seconds * SAMPLE_RATE) &&
std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count() >= from)
{
if (!dump && !(dump = fopen(dumpPath, "wb")))
dumpPath = nullptr;
if (dump)
{
fwrite(out, sizeof(out), 1, dump);
dumped += SAMPLES_PER_FRAME;
if (dumped >= uint64_t(seconds * SAMPLE_RATE))
{
fclose(dump);
fprintf(stderr, "[audio] dump written: %s\n", dumpPath);
}
}
}
}
// NFSMW_AUDIO_LOG=1: level meter every ~2 s.
static const bool logLevels = std::getenv("NFSMW_AUDIO_LOG") != nullptr;
if (logLevels)
{
static double sumSquares = 0;
static float peak = 0;
static uint32_t frames = 0;
for (float v : out)
{
sumSquares += double(v) * v;
peak = std::max(peak, std::fabs(v));
}
if (++frames == 375)
{
fprintf(stderr, "[audio] level: peak %.3f, rms %.4f\n", peak,
std::sqrt(sumSquares / (375.0 * SAMPLES_PER_FRAME * CHANNELS)));
sumSquares = 0;
peak = 0;
frames = 0;
}
}
return 0;
}
uint32_t XAudioGetVoiceCategoryVolumeChangeMask(uint32_t driver, be<uint32_t>* mask)
{
*mask = 0;
return 0;
}
uint32_t XAudioGetVoiceCategoryVolume(uint32_t category, be<float>* volume)
{
*volume = 1.0f;
return 0;
}
GUEST_FUNCTION_HOOK(__imp__XAudioRegisterRenderDriverClient, XAudioRegisterRenderDriverClient);
GUEST_FUNCTION_HOOK(__imp__XAudioUnregisterRenderDriverClient, XAudioUnregisterRenderDriverClient);
GUEST_FUNCTION_HOOK(__imp__XAudioSubmitRenderDriverFrame, XAudioSubmitRenderDriverFrame);
GUEST_FUNCTION_HOOK(__imp__XAudioGetVoiceCategoryVolumeChangeMask, XAudioGetVoiceCategoryVolumeChangeMask);
GUEST_FUNCTION_HOOK(__imp__XAudioGetVoiceCategoryVolume, XAudioGetVoiceCategoryVolume);