diff --git a/src/frontend/qt_sdl/AudioLowPass.h b/src/frontend/qt_sdl/AudioLowPass.h new file mode 100644 index 0000000000..bf19f31490 --- /dev/null +++ b/src/frontend/qt_sdl/AudioLowPass.h @@ -0,0 +1,208 @@ +/* + Copyright 2016-2026 melonDS team + + This file is part of melonDS. + + melonDS is free software: you can redistribute it and/or modify it under + the terms of the GNU General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) + any later version. + + melonDS is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with melonDS. If not, see http://www.gnu.org/licenses/. +*/ + +#ifndef AUDIOLOWPASS_H +#define AUDIOLOWPASS_H + +#include +#include +#include + +// fourth-order Butterworth low-pass: two cascaded RBJ biquads, stereo with +// independent state per channel. the cutoff is smoothed rather than jumped, +// since stepping the coefficients would click. wide open leaves the output alone. +class AudioLowPass +{ +public: + static constexpr double kSmoothingTau = 0.05; // cutoff smoother, seconds + static constexpr double kBypassThreshold = 0.995; // fraction of wide-open + // section Q's for a fourth-order Butterworth cascade + static constexpr double kSectionQ[2] = {0.54119610014619698, 1.3065629648763766}; + static constexpr double kMinCutoff = 20.0; + + void Init(double sampleRate) + { + SampleRate = sampleRate; + WideOpen = std::max(0.45 * sampleRate, kMinCutoff); + for (int s = 0; s < 2; s++) + { + Stages[s].z1[0] = Stages[s].z1[1] = 0.0; + Stages[s].z2[0] = Stages[s].z2[1] = 0.0; + } + SetCutoffNow(WideOpen); + } + + double WideOpenCutoff() const { return WideOpen; } + double Cutoff() const { return CurCutoff; } + bool Bypassed() const { return CurCutoff >= (WideOpen * kBypassThreshold); } + + // advance the smoothed cutoff by one block, then filter in place. + // the coefficients are interpolated across the block rather than replaced + // in one go: a transposed direct-form biquad's state encodes its past under + // the coefficients that produced it, so a step leaves the two inconsistent + // and the filter rings, once per block. + void Process(int16_t* samples, int numFrames, double targetHz, double blockSeconds) + { + if (numFrames < 1) return; + + double from[2][5], to[2][5]; + BeginBlock(targetHz, blockSeconds, from, to); + bool bypass = Bypassed(); + + for (int i = 0; i < numFrames; i++) + { + StepCoefficients(from, to, (double)(i + 1) / numFrames); + for (int ch = 0; ch < 2; ch++) + { + // runs even when bypassed: the state must stay in step with + // the signal, or re-engaging would click + double y = ProcessSample(samples[(i*2)+ch], ch); + if (!bypass) samples[(i*2)+ch] = Saturate(y); + } + } + + EndBlock(to); + } + + // advance the cutoff and the filter state over silence, writing nothing. + // used while muted, so unmuting neither steps the coefficients nor dumps + // whatever was still ringing in the biquads into a buffer meant to be quiet. + void ProcessMuted(int numFrames, double targetHz, double blockSeconds) + { + if (numFrames < 1) return; + + double from[2][5], to[2][5]; + BeginBlock(targetHz, blockSeconds, from, to); + + for (int i = 0; i < numFrames; i++) + { + StepCoefficients(from, to, (double)(i + 1) / numFrames); + for (int ch = 0; ch < 2; ch++) + ProcessSample(0.0, ch); + } + + EndBlock(to); + } + + void Smooth(double targetHz, double blockSeconds) + { + targetHz = std::clamp(targetHz, kMinCutoff, WideOpen); + double a = 1.0 - std::exp(-blockSeconds / kSmoothingTau); + SetCutoffNow(CurCutoff + ((targetHz - CurCutoff) * a)); + } + + void SetCutoffNow(double cutoffHz) + { + CurCutoff = std::clamp(cutoffHz, kMinCutoff, WideOpen); + for (int s = 0; s < 2; s++) + Stages[s].Design(CurCutoff, SampleRate, kSectionQ[s]); + } + + double ProcessSample(double x, int ch) + { + double y = x; + for (int s = 0; s < 2; s++) + y = Stages[s].Run(y, ch); + return y; + } + +private: + static int16_t Saturate(double y) + { + long v = std::lround(y); + if (v > 32767) v = 32767; + if (v < -32768) v = -32768; + return (int16_t)v; + } + + // coefficients as they stand, then as designed for this block's cutoff + void BeginBlock(double targetHz, double blockSeconds, double from[2][5], double to[2][5]) + { + for (int s = 0; s < 2; s++) Stages[s].Snapshot(from[s]); + Smooth(targetHz, blockSeconds); + for (int s = 0; s < 2; s++) Stages[s].Snapshot(to[s]); + } + + void StepCoefficients(const double from[2][5], const double to[2][5], double t) + { + for (int s = 0; s < 2; s++) Stages[s].Lerp(from[s], to[s], t); + } + + // land exactly on the designed set, so rounding in the interpolation + // cannot accumulate across blocks + void EndBlock(const double to[2][5]) + { + for (int s = 0; s < 2; s++) Stages[s].Restore(to[s]); + } + + struct Biquad + { + double b0 = 1.0, b1 = 0.0, b2 = 0.0, a1 = 0.0, a2 = 0.0; + double z1[2] = {0.0, 0.0}; + double z2[2] = {0.0, 0.0}; + + void Snapshot(double out[5]) const + { + out[0] = b0; out[1] = b1; out[2] = b2; out[3] = a1; out[4] = a2; + } + + void Restore(const double in[5]) + { + b0 = in[0]; b1 = in[1]; b2 = in[2]; a1 = in[3]; a2 = in[4]; + } + + void Lerp(const double from[5], const double to[5], double t) + { + b0 = from[0] + ((to[0] - from[0]) * t); + b1 = from[1] + ((to[1] - from[1]) * t); + b2 = from[2] + ((to[2] - from[2]) * t); + a1 = from[3] + ((to[3] - from[3]) * t); + a2 = from[4] + ((to[4] - from[4]) * t); + } + + void Design(double cutoffHz, double sampleRate, double q) + { + double w0 = 2.0 * M_PI * (cutoffHz / sampleRate); + double cw = std::cos(w0); + double alpha = std::sin(w0) / (2.0 * q); + double a0 = 1.0 + alpha; + + b0 = ((1.0 - cw) * 0.5) / a0; + b1 = (1.0 - cw) / a0; + b2 = b0; + a1 = (-2.0 * cw) / a0; + a2 = (1.0 - alpha) / a0; + } + + // transposed direct form II + double Run(double x, int ch) + { + double y = (b0 * x) + z1[ch]; + z1[ch] = (b1 * x) - (a1 * y) + z2[ch]; + z2[ch] = (b2 * x) - (a2 * y); + return y; + } + }; + + double SampleRate = 48000.0; + double WideOpen = 21600.0; + double CurCutoff = 21600.0; + Biquad Stages[2]; +}; + +#endif // AUDIOLOWPASS_H diff --git a/src/frontend/qt_sdl/AudioSettingsDialog.cpp b/src/frontend/qt_sdl/AudioSettingsDialog.cpp index 6f4b547592..53673e37c1 100644 --- a/src/frontend/qt_sdl/AudioSettingsDialog.cpp +++ b/src/frontend/qt_sdl/AudioSettingsDialog.cpp @@ -47,6 +47,9 @@ AudioSettingsDialog::AudioSettingsDialog(QWidget* parent) : QDialog(parent), ui( oldBitDepth = cfg.GetInt("Audio.BitDepth"); oldVolume = instcfg.GetInt("Audio.Volume"); oldDSiSync = instcfg.GetBool("Audio.DSiVolumeSync"); + oldTimeStretch = cfg.GetBool("SpeedUpTimeStretch"); + oldLowPassEnable = cfg.GetBool("SpeedUpLowPassEnable"); + oldLowPass = cfg.GetInt("SpeedUpLowPass"); volume = oldVolume; dsiSync = oldDSiSync; @@ -69,6 +72,13 @@ AudioSettingsDialog::AudioSettingsDialog(QWidget* parent) : QDialog(parent), ui( ui->chkSyncDSiVolume->setChecked(oldDSiSync); + ui->cbSpeedUpTimeStretch->setChecked(oldTimeStretch); + ui->cbSpeedUpLowPass->setChecked(oldLowPassEnable); + state = ui->spinSpeedUpLowPass->blockSignals(true); + ui->spinSpeedUpLowPass->setValue(oldLowPass); + ui->spinSpeedUpLowPass->blockSignals(state); + ui->spinSpeedUpLowPass->setEnabled(oldLowPassEnable); + // Setup volume slider accordingly if (emuActive && emuInstance->getNDS()->ConsoleType == 1) { @@ -182,6 +192,9 @@ void AudioSettingsDialog::on_AudioSettingsDialog_rejected() cfg.SetInt("Audio.BitDepth", oldBitDepth); instcfg.SetInt("Audio.Volume", oldVolume); instcfg.SetBool("Audio.DSiVolumeSync", oldDSiSync); + cfg.SetBool("SpeedUpTimeStretch", oldTimeStretch); + cfg.SetBool("SpeedUpLowPassEnable", oldLowPassEnable); + cfg.SetInt("SpeedUpLowPass", oldLowPass); emit updateAudioVolume(oldVolume, oldDSiSync); emit updateAudioSettings(); @@ -227,6 +240,25 @@ void AudioSettingsDialog::on_slVolume_valueChanged(int val) emit updateAudioVolume(val, dsiSync); } +void AudioSettingsDialog::on_cbSpeedUpTimeStretch_clicked(bool checked) +{ + emuInstance->getGlobalConfig().SetBool("SpeedUpTimeStretch", checked); + emit updateAudioSettings(); +} + +void AudioSettingsDialog::on_cbSpeedUpLowPass_clicked(bool checked) +{ + ui->spinSpeedUpLowPass->setEnabled(checked); + emuInstance->getGlobalConfig().SetBool("SpeedUpLowPassEnable", checked); + emit updateAudioSettings(); +} + +void AudioSettingsDialog::on_spinSpeedUpLowPass_valueChanged(int val) +{ + emuInstance->getGlobalConfig().SetInt("SpeedUpLowPass", val); + emit updateAudioSettings(); +} + void AudioSettingsDialog::on_chkSyncDSiVolume_clicked(bool checked) { dsiSync = checked; diff --git a/src/frontend/qt_sdl/AudioSettingsDialog.h b/src/frontend/qt_sdl/AudioSettingsDialog.h index d4d1a6ebc1..b77c0d471e 100644 --- a/src/frontend/qt_sdl/AudioSettingsDialog.h +++ b/src/frontend/qt_sdl/AudioSettingsDialog.h @@ -68,6 +68,9 @@ private slots: void on_cbBitDepth_currentIndexChanged(int idx); void on_slVolume_valueChanged(int val); void on_chkSyncDSiVolume_clicked(bool checked); + void on_cbSpeedUpTimeStretch_clicked(bool checked); + void on_cbSpeedUpLowPass_clicked(bool checked); + void on_spinSpeedUpLowPass_valueChanged(int val); void onChangeMicMode(int mode); void on_btnMicWavBrowse_clicked(); @@ -80,6 +83,9 @@ private slots: int oldBitDepth; int oldVolume; bool oldDSiSync; + bool oldTimeStretch; + bool oldLowPassEnable; + int oldLowPass; QButtonGroup* grpMicMode; int volume; diff --git a/src/frontend/qt_sdl/AudioSettingsDialog.ui b/src/frontend/qt_sdl/AudioSettingsDialog.ui index 380197ec4c..e64a554bf3 100644 --- a/src/frontend/qt_sdl/AudioSettingsDialog.ui +++ b/src/frontend/qt_sdl/AudioSettingsDialog.ui @@ -100,6 +100,57 @@ + + + + Fast-forward and slow-mo + + + + + + Time-stretch fast-forward and slow-mo audio so it keeps its pitch instead of being chopped up. + + + Time-stretch fast-forward/slow-mo + + + + + + + Roll the treble off fast-forward audio, which turns harsh when sped up. + + + Low-pass fast-forward + + + + + + + Cutoff for fast-forward audio only, divided by the emulation speed. Use the highest value that still sounds good sped up; 48000 Hz is the same as unchecking the box. + + + Hz + + + 1000 + + + 48000 + + + 500 + + + 48000 + + + + + + diff --git a/src/frontend/qt_sdl/AudioSpeed.h b/src/frontend/qt_sdl/AudioSpeed.h new file mode 100644 index 0000000000..74e0522225 --- /dev/null +++ b/src/frontend/qt_sdl/AudioSpeed.h @@ -0,0 +1,98 @@ +/* + Copyright 2016-2026 melonDS team + + This file is part of melonDS. + + melonDS is free software: you can redistribute it and/or modify it under + the terms of the GNU General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) + any later version. + + melonDS is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with melonDS. If not, see http://www.gnu.org/licenses/. +*/ + +#ifndef AUDIOSPEED_H +#define AUDIOSPEED_H + +#include +#include + +// the DS's true framerate; a skew of 1.0 emits at the hardware's rate +constexpr double INTERNAL_FRAME_RATE = 59.8260982880808; + +// sentinel value: don't filter at all. the reference is divided by speed, so +// the top of the range bounds how gentle the filter can be; well past any +// device's Nyquist on purpose. +constexpr int kSpeedUpLowPassOff = 48000; + +constexpr double kStretchTrimGain = 0.25; + +// below 1 the stretcher expands (slow-mo), above it compresses (fast-forward) +constexpr double kMinStretchRatio = 1.0 / 32.0; +constexpr double kMaxStretchRatio = 32.0; + +// SPU::BufferAudio flushes every 512*128 SPU cycles, accumulating +// 65536*48000 / (16756991*skew) ~= 188/skew samples, and blip_new(512) asserts +// avail <= 512. below ~0.37 that overflows the allocation. +constexpr double kMinOutputSkew = 0.4; + +// emulated speed relative to normal +inline double audioSpeedRatio(double curFPS, double targetFPS) +{ + if (targetFPS <= 0.0) return 1.0; + return curFPS / targetFPS; +} + +inline bool audioIsOffSpeed(double curFPS, double targetFPS) +{ + return std::fabs(curFPS - targetFPS) > 0.01; +} + +// independent of emulation speed: deriving this from curFPS would resample, +// shifting pitch. fitting off-speed audio to the output is the stretcher's job. +inline double audioComputeOutputSkew(double targetFPS) +{ + return std::max(targetFPS / INTERNAL_FRAME_RATE, kMinOutputSkew); +} + +// how much input each output frame consumes. driven by measured arrival rather +// than the requested speed, which the host may not achieve. the FIFO-fill term +// only steers the level: a larger ratio drains faster. +inline double audioComputeStretchRatio(double arrivalPerCallback, int outputPerCallback, + int inputFill, int targetFill) +{ + if (outputPerCallback <= 0) return 1.0; + + double ratio = arrivalPerCallback / (double)outputPerCallback; + + if (targetFill > 0) + { + double err = (inputFill - (double)targetFill) / (double)targetFill; + err = std::clamp(err, -1.0, 1.0); + ratio *= 1.0 + (kStretchTrimGain * err); + } + + return std::clamp(ratio, kMinStretchRatio, kMaxStretchRatio); +} + +// low-pass cutoff in Hz. wideOpen means transparent; the filter only engages +// above normal speed, and gets duller the faster the emulator runs. +inline double audioComputeLowPassCutoff(double curFPS, double targetFPS, + int reference, double wideOpen) +{ + if (wideOpen <= 200.0) return wideOpen; + + double speed = audioSpeedRatio(curFPS, targetFPS); + if (speed <= 1.0) return wideOpen; + // zero is off too, rather than clamping to the strongest filter + if (reference <= 0 || reference >= kSpeedUpLowPassOff) return wideOpen; + + return std::clamp(reference / speed, 200.0, wideOpen); +} + +#endif // AUDIOSPEED_H diff --git a/src/frontend/qt_sdl/AudioStreamRamp.h b/src/frontend/qt_sdl/AudioStreamRamp.h new file mode 100644 index 0000000000..aef83db16b --- /dev/null +++ b/src/frontend/qt_sdl/AudioStreamRamp.h @@ -0,0 +1,325 @@ +/* + Copyright 2016-2026 melonDS team + + This file is part of melonDS. + + melonDS is free software: you can redistribute it and/or modify it under + the terms of the GNU General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) + any later version. + + melonDS is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with melonDS. If not, see http://www.gnu.org/licenses/. +*/ + +#ifndef AUDIOSTREAMRAMP_H +#define AUDIOSTREAMRAMP_H + +#include +#include +#include +#include + +// conceals the ends of the output stream. a pause or a state jump ends the +// audio wherever the waveform happened to be, and brings it back the same way: +// a step at both ends, heard as a click. keep a copy of the last frames +// played; on End() repeat the period that best matches them under a raised +// cosine, as a packet-loss concealer does, and on Begin() ramp the first +// frames back up. fading the last sample as a constant instead is a decaying +// DC offset rather than a decaying sound, and is heard as a thump. +// +// audio thread only. frames are interleaved stereo s16, kept as normalised +// float inside. +class AudioStreamRamp +{ +public: + static constexpr int kHistFrames = 2048; // must hold kMaxPeriod + kCorrFrames + static constexpr int kTailFrames = 512; + // period the tail repeats, and the window it is matched over. 96..1024 is + // roughly 47 Hz to 500 Hz at 48 kHz. + static constexpr int kMinPeriod = 96; + static constexpr int kMaxPeriod = 1024; + static constexpr int kCorrFrames = 128; + // frames over which the tail's join to what was playing is eased in + static constexpr int kJoinFrames = 128; + // source frames dropped after the tail, to swallow whatever the pipeline + // still held when the stream went down + static constexpr int kMuteFrames = 512; + static constexpr int kRampInFrames = 512; + + AudioStreamRamp() { Reset(); } + + void Reset() + { + std::memset(Hist, 0, sizeof(Hist)); + HistPos = 0; + HistFill = 0; + LastOut[0] = LastOut[1] = 0.0f; + TailFrames = 0; + TailPeriod = 0; + TailJoin[0] = TailJoin[1] = 0.0f; + TailFrom[0] = TailFrom[1] = 0.0f; + MuteFrames = 0; + FadeInFrames = 0; + RampInLatched = false; + } + + // the stream is down: a tail is playing, or the mute after it is pending + bool Ended() const { return TailFrames > 0 || MuteFrames > 0; } + bool TailSpent() const { return TailFrames == 0; } + + // take the stream down from where it is. nothing playing means no step to + // smooth, so nothing is armed. + void End() + { + if (LastOut[0] == 0.0f && LastOut[1] == 0.0f) return; + + TailPeriod = FindPeriod(); + + // the tail reads history as it stood here, so keep the frames it will + // repeat: ages 1..period+1 + if (TailPeriod) + { + for (int k = 0; k <= TailPeriod; k++) + { + const float* f = HistAt(k + 1); + TailSrc[k][0] = f[0]; + TailSrc[k][1] = f[1]; + } + // however good the match, the repeat starts at its own value rather + // than the one the stream stopped on. the frame matching the last + // one played is a whole period before it; age period is its + // successor, the frame the tail opens on. + TailJoin[0] = LastOut[0] - TailSrc[TailPeriod][0]; + TailJoin[1] = LastOut[1] - TailSrc[TailPeriod][1]; + } + else + { + TailJoin[0] = TailJoin[1] = 0.0f; + } + + TailFrom[0] = LastOut[0]; + TailFrom[1] = LastOut[1]; + TailFrames = kTailFrames; + FadeInFrames = 0; + } + + // produce numFrames in place of source audio: the tail, then silence. + // what is written is recorded as played. + void FillTail(int16_t* samples, int numFrames) + { + for (int i = 0; i < numFrames; i++) + { + float l = 0.0f, r = 0.0f; + if (TailFrames > 0) TailFrame(l, r); + samples[(i*2)+0] = Saturate(l); + samples[(i*2)+1] = Saturate(r); + Push(l, r); + } + } + + // the stream is coming back: ramp its first frames in. while a tail is + // still playing the ramp waits for it and the mute after it. + void Begin() + { + if (Ended()) + { + RampInLatched = true; + return; + } + FadeInFrames = kRampInFrames; + } + + // source frames about to be played: finish a tail the last buffer could + // not, drop what the mute still owes, apply the ramp in, and record them + void Track(int16_t* samples, int numFrames) + { + int start = 0; + // a short read ends the stream mid-buffer and the next buffer is full + // again: the tail still has to play out, over frames that would + // otherwise splice in at full level behind it + if (TailFrames > 0) + { + int n = std::min(TailFrames, numFrames); + for (int j = 0; j < n; j++) + { + float l, r; + TailFrame(l, r); + samples[(j*2)+0] = Saturate(l); + samples[(j*2)+1] = Saturate(r); + } + start = n; + } + + if (MuteFrames > 0 && start < numFrames) + { + int m = std::min(MuteFrames, numFrames - start); + std::memset(samples + (start * 2), 0, m * 2 * sizeof(int16_t)); + MuteFrames -= m; + start += m; + } + + if (RampInLatched && !Ended()) + { + RampInLatched = false; + FadeInFrames = kRampInFrames; + } + + if (FadeInFrames > 0 && start < numFrames) + { + int n = std::min(FadeInFrames, numFrames - start); + for (int j = 0; j < n; j++) + { + // progress against the whole ramp, not what is left of it: the + // counter falls as the ramp is spent, so measuring from it + // would restart the gain near zero at the head of every buffer + int done = kRampInFrames - FadeInFrames + j + 1; + float g = 0.5f * (1.0f - std::cos((float)M_PI * done / kRampInFrames)); + int16_t* f = samples + ((start + j) * 2); + f[0] = Saturate((f[0] / 32768.0f) * g); + f[1] = Saturate((f[1] / 32768.0f) * g); + } + FadeInFrames -= n; + } + + for (int i = 0; i < numFrames; i++) + Push(samples[(i*2)+0] / 32768.0f, samples[(i*2)+1] / 32768.0f); + } + +private: + // the next frame of the tail; arms the mute once the tail is spent + void TailFrame(float& l, float& r) + { + int idx = kTailFrames - TailFrames; + // raised cosine: a linear ramp still corners at both ends + float g = 0.5f * (1.0f + std::cos((float)M_PI * (idx + 1) / kTailFrames)); + if (TailPeriod) + { + int u = idx % TailPeriod; + // age period continues the waveform from where it stopped; + // wrapping back to it repeats that cycle under the envelope + const float* f = TailSrc[TailPeriod - u - 1]; + // the correction must be spent by the time the repeat wraps, or + // the remainder recurs every cycle as a sawtooth + int jn = std::min(kJoinFrames, TailPeriod - 1); + float jw = (u < jn) ? (1.0f - (float)u / jn) : 0.0f; + l = (f[0] + (TailJoin[0] * jw)) * g; + r = (f[1] + (TailJoin[1] * jw)) * g; + } + else + { + l = TailFrom[0] * g; + r = TailFrom[1] * g; + } + TailFrames--; + if (TailFrames == 0) MuteFrames = kMuteFrames; + } + + static int16_t Saturate(float v) + { + long s = std::lround(v * 32768.0f); + if (s > 32767) s = 32767; + if (s < -32768) s = -32768; + return (int16_t)s; + } + + void Push(float l, float r) + { + Hist[HistPos][0] = l; + Hist[HistPos][1] = r; + HistPos = (HistPos + 1) % kHistFrames; + if (HistFill < kHistFrames) HistFill++; + LastOut[0] = l; + LastOut[1] = r; + } + + int HistIdx(int age) const + { + return (HistPos + kHistFrames - age) % kHistFrames; + } + + // the frame 'age' frames back from the most recently played one; age 1 is + // that frame itself. the caller checks age against HistFill. + const float* HistAt(int age) const + { + return Hist[HistIdx(age)]; + } + + // one frame older, wrapping without a modulo + static int HistPrev(int idx) + { + return idx ? (idx - 1) : (kHistFrames - 1); + } + + // the lag whose copy of the last kCorrFrames frames matches them best. + // scored by distance rather than correlation: a normalised correlation is + // blind to level, so the same phrase at half the volume scores a perfect + // match and repeating it steps the waveform. matched on the channel sum, + // so a quiet channel's noise cannot choose the period for a loud one. + // 0 when there is not enough history, leaving the caller on the DC ramp. + int FindPeriod() const + { + if (HistFill < kMinPeriod + kCorrFrames) return 0; + int maxP = std::min(kMaxPeriod, HistFill - kCorrFrames); + + // the reference window is the same for every candidate, so sum its + // channels once. the energy only decides whether there is anything + // here worth matching + float ref[kCorrFrames]; + float eRef = 0.0f; + int ia = HistIdx(1); + for (int k = 0; k < kCorrFrames; k++) + { + const float* f = Hist[ia]; + ref[k] = f[0] + f[1]; + eRef += ref[k] * ref[k]; + ia = HistPrev(ia); + } + if (eRef <= 0.0f) return 0; + + int best = 0; + float bestDiff = -1.0f; + for (int p = kMinPeriod; p <= maxP; p++) + { + float diff = 0.0f; + int ib = HistIdx(1 + p); + for (int k = 0; k < kCorrFrames; k++) + { + const float* b = Hist[ib]; + float d = ref[k] - (b[0] + b[1]); + diff += d * d; + ib = HistPrev(ib); + } + if (bestDiff < 0.0f || diff < bestDiff) + { + bestDiff = diff; + best = p; + } + } + // no threshold on the score: content with no periodicity has no right + // answer, and repeating the closest recent stretch of it still hands + // back the right spectrum at the right level + return best; + } + + float Hist[kHistFrames][2]; + int HistPos; + int HistFill; + float LastOut[2]; + + float TailSrc[kMaxPeriod + 1][2]; + int TailFrames; + int TailPeriod; + float TailJoin[2]; + float TailFrom[2]; + + int MuteFrames; + int FadeInFrames; + bool RampInLatched; +}; + +#endif // AUDIOSTREAMRAMP_H diff --git a/src/frontend/qt_sdl/AudioTimeStretch.h b/src/frontend/qt_sdl/AudioTimeStretch.h new file mode 100644 index 0000000000..3bd87ebf2c --- /dev/null +++ b/src/frontend/qt_sdl/AudioTimeStretch.h @@ -0,0 +1,368 @@ +/* + Copyright 2016-2026 melonDS team + + This file is part of melonDS. + + melonDS is free software: you can redistribute it and/or modify it under + the terms of the GNU General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) + any later version. + + melonDS is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with melonDS. If not, see http://www.gnu.org/licenses/. +*/ + +#ifndef AUDIOTIMESTRETCH_H +#define AUDIOTIMESTRETCH_H + +#include +#include +#include +#include +#include + +// WSOLA time-stretcher: changes playback rate while preserving pitch, by +// overlap-adding windowed frames picked for waveform similarity, so consecutive +// frames splice on matching phase. +// +// single producer/single consumer. the emu thread calls Write and BeginSession; +// the audio thread calls Read, InputFill, OutputFill and TotalWritten. Reset is +// neither, and needs both stopped. +class AudioTimeStretch +{ +public: + static constexpr int kFrameSize = 256; // analysis window, frames + static constexpr int kSynthesisHop = kFrameSize / 2; // periodic Hann at 50% sums to unity + static constexpr int kSearchRadius = 1024; // frames either side of nominal + static constexpr int kCoarseStride = 4; + static constexpr int kFineRadius = 3; + static constexpr int kInputCapacity = 32768; // must be a power of two + static constexpr int kOutputCapacity = 8192; // must be a power of two + + // floor on how much input the ratio control keeps buffered + static constexpr int kMinTargetInputFill = 4096; + + // how much input to keep buffered. a fixed figure starves the stretcher at + // high speed, where one callback consumes more than the target. + static int TargetInputFill(double arrivalPerCallback) + { + // leave half the ring free, or a large hop squeezes Write down to nothing + const double cap = kInputCapacity / 2; + + double need = (2.0 * arrivalPerCallback) + kSearchRadius + kFrameSize; + // clamped as a double: a TargetFPS of 0.0001 seeds an arrival estimate + // around 2.5e9, and casting that to int is UB + if (!(need > kMinTargetInputFill)) need = kMinTargetInputFill; + if (need > cap) need = cap; + return (int)need; + } + + AudioTimeStretch() + { + for (int i = 0; i < kFrameSize; i++) + Window[i] = (float)(0.5 * (1.0 - std::cos((2.0 * M_PI * i) / kFrameSize))); + Reset(); + } + + // not thread-safe: call only with both sides stopped + void Reset() + { + WritePos.store(0, std::memory_order_relaxed); + Generation.store(0, std::memory_order_relaxed); + ConsumerFloor.store(0, std::memory_order_relaxed); + SeenWrite = 0; + SeenGeneration = 0; + AnalysisPos = 0; + NaturalPos = 0; + OutReadPos = 0; + OutWritePos = 0; + Primed = false; + std::memset(AccL, 0, sizeof(AccL)); + std::memset(AccR, 0, sizeof(AccR)); + + // the search is already bounded to written frames; this only degrades + // a future bound slip to silence rather than noise + std::memset(InL, 0, sizeof(InL)); + std::memset(InR, 0, sizeof(InR)); + std::memset(InMono, 0, sizeof(InMono)); + } + + int InputFill() const + { + int64_t pending = WritePos.load(std::memory_order_acquire) - AnalysisPos; + if (pending < 0) return 0; + if (pending > kInputCapacity) return kInputCapacity; + return (int)pending; + } + + int OutputFill() const { return (int)(OutWritePos - OutReadPos); } + + // append interleaved stereo frames, returning how many were accepted. + // writing is refused rather than allowed to lap the consumer. + int Write(const int16_t* samples, int numFrames) + { + int64_t w = WritePos.load(std::memory_order_relaxed); + + int64_t floor = ConsumerFloor.load(std::memory_order_acquire); + int64_t space = kInputCapacity - (w - floor); + if (space < 0) space = 0; + if (numFrames > space) numFrames = (int)space; + if (numFrames <= 0) return 0; + + for (int i = 0; i < numFrames; i++) + { + int idx = (int)((w + i) & (kInputCapacity - 1)); + int16_t l = samples[(i*2)+0]; + int16_t r = samples[(i*2)+1]; + InL[idx] = l; + InR[idx] = r; + InMono[idx] = 0.5f * ((float)l + (float)r); + } + + // release: the frames must be visible before the count advertising them + WritePos.store(w + numFrames, std::memory_order_release); + return numFrames; + } + + // marks a discontinuity: the consumer resyncs rather than splicing across it + void BeginSession() + { + Generation.fetch_add(1, std::memory_order_release); + } + + int64_t TotalWritten() const { return WritePos.load(std::memory_order_acquire); } + + // emit up to numFrames of interleaved stereo, synthesising as needed. works + // off one snapshot of the producer's count, so the bounds can't shift. + int Read(int16_t* samples, int numFrames, double ratio) + { + // generation first, then WritePos. the other order lets a session that + // starts between the two loads resync against a stale SeenWrite, and + // then never resync again. + uint32_t gen = Generation.load(std::memory_order_acquire); + SeenWrite = WritePos.load(std::memory_order_acquire); + + if (gen != SeenGeneration) + { + SeenGeneration = gen; + ResyncToLive(); + } + + // the producer outran us: skip forward rather than read what landed + // on top of the frames we wanted + int64_t floor = (SeenWrite - kInputCapacity) + kSearchRadius + kFrameSize; + if (AnalysisPos < floor) + { + AnalysisPos = floor; + NaturalPos = floor; + Primed = false; + } + + // overlap-add only reaches full amplitude once the accumulator carries a + // whole window, so the first hop after a reposition would ramp up from + // silence. charge the accumulator with one hop and drop its output. + if (!Primed && CanSynthesise()) + SynthesiseHop(ratio, false); + + while ((OutputFill() < numFrames) && CanSynthesise()) + SynthesiseHop(ratio, true); + + int n = std::min(numFrames, OutputFill()); + for (int i = 0; i < n; i++) + { + int idx = (int)(OutReadPos & (kOutputCapacity - 1)); + samples[(i*2)+0] = OutL[idx]; + samples[(i*2)+1] = OutR[idx]; + OutReadPos++; + } + + PublishFloor(); + return n; + } + +private: + static int16_t Saturate(float v) + { + long s = std::lround(v); + if (s > 32767) s = 32767; + if (s < -32768) s = -32768; + return (int16_t)s; + } + + // publish the oldest frame we might still look at, so the producer stops + // short of it. NaturalPos can sit well below AnalysisPos - kSearchRadius. + void PublishFloor() + { + int64_t floorNow = std::min(AnalysisPos - kSearchRadius, NaturalPos); + if (floorNow < 0) floorNow = 0; + ConsumerFloor.store(floorNow, std::memory_order_release); + } + + // drop whatever is stale and pick up at live data. the published floor can + // decrease here, but the dip is bounded by kFrameSize + kSearchRadius, far + // short of kInputCapacity, so the producer can't reach the re-exposed region. + void ResyncToLive() + { + AnalysisPos = std::max(0, SeenWrite - kFrameSize); + NaturalPos = AnalysisPos; + Primed = false; + PublishFloor(); + OutReadPos = 0; + OutWritePos = 0; + std::memset(AccL, 0, sizeof(AccL)); + std::memset(AccR, 0, sizeof(AccR)); + } + + bool CanSynthesise() const + { + if ((kOutputCapacity - OutputFill()) < kSynthesisHop) return false; + + // only the frame and the natural-continuation reference need to be + // present; demanding the full search radius would starve a short FIFO + int64_t frameEnd = AnalysisPos + kFrameSize; + int64_t naturalEnd = NaturalPos + kSynthesisHop; + return (SeenWrite >= frameEnd) && (SeenWrite >= naturalEnd); + } + + // output=false charges the accumulator and advances without writing out. + // not named emit: Qt defines that away. + void SynthesiseHop(double ratio, bool output) + { + int hop = (int)std::lround(kSynthesisHop * ratio); + if (hop < 1) hop = 1; + + int64_t chosen = Primed ? FindBestOffset() : AnalysisPos; + Primed = true; + + for (int i = 0; i < kFrameSize; i++) + { + int idx = (int)((chosen + i) & (kInputCapacity - 1)); + float w = Window[i]; + AccL[i] += w * (float)InL[idx]; + AccR[i] += w * (float)InR[idx]; + } + + if (output) + { + for (int i = 0; i < kSynthesisHop; i++) + { + int idx = (int)(OutWritePos & (kOutputCapacity - 1)); + OutL[idx] = Saturate(AccL[i]); + OutR[idx] = Saturate(AccR[i]); + OutWritePos++; + } + } + + std::memmove(AccL, AccL + kSynthesisHop, kSynthesisHop * sizeof(float)); + std::memmove(AccR, AccR + kSynthesisHop, kSynthesisHop * sizeof(float)); + std::memset(AccL + kSynthesisHop, 0, kSynthesisHop * sizeof(float)); + std::memset(AccR + kSynthesisHop, 0, kSynthesisHop * sizeof(float)); + + // the nominal pointer advances by hop alone; advancing from chosen would + // make consumption hop + E[bestK], which the ratio control can't see + NaturalPos = chosen + kSynthesisHop; + AnalysisPos += hop; + } + + int64_t FindBestOffset() const + { + int64_t oldest = std::max(0, SeenWrite - kInputCapacity); + + // at a high ratio on a near-full ring NaturalPos can fall off the back. + // the reference would then be overwritten frames, so don't search. + if (NaturalPos < oldest) return AnalysisPos; + + // NaturalPos is fixed for the whole search: copy the reference window + // out once rather than paying the masked load per candidate, and take + // its energy in the same pass + double ref[kSynthesisHop]; + double refEnergy = 0.0; + for (int i = 0; i < kSynthesisHop; i++) + { + double v = InMono[(int)((NaturalPos + i) & (kInputCapacity - 1))]; + ref[i] = v; + refEnergy += v * v; + } + + // full radius regardless of hop: expansion needs the reach + const int radius = kSearchRadius; + + // masking a negative position wraps it into frames we never wrote + int lowestK = -radius; + if ((AnalysisPos + lowestK) < oldest) + lowestK = (int)(oldest - AnalysisPos); + + // clamp to what has arrived, so a short FIFO narrows the search + int highestK = radius; + int64_t latest = SeenWrite - kFrameSize; + if ((AnalysisPos + highestK) > latest) + highestK = (int)(latest - AnalysisPos); + if (highestK < lowestK) highestK = lowestK; + + // seeded from the nominal offset, so ties keep it rather than sliding + // to the bottom of the window (every score is 0 on digital silence) + int bestK = std::min(std::max(lowestK, 0), highestK); + double bestScore = Score(AnalysisPos + bestK, ref, refEnergy); + + for (int k = lowestK; k <= highestK; k += kCoarseStride) + { + double s = Score(AnalysisPos + k, ref, refEnergy); + if (s > bestScore) { bestScore = s; bestK = k; } + } + + int lo = std::max(lowestK, bestK - kFineRadius); + int hi = std::min(highestK, bestK + kFineRadius); + for (int k = lo; k <= hi; k++) + { + double s = Score(AnalysisPos + k, ref, refEnergy); + if (s > bestScore) { bestScore = s; bestK = k; } + } + + return AnalysisPos + bestK; + } + + + // normalised, so the search doesn't just latch onto the loudest candidate. + // ref is the natural-continuation window as a linear copy + double Score(int64_t pos, const double* ref, double refEnergy) const + { + double dot = 0.0; + double energy = 0.0; + for (int i = 0; i < kSynthesisHop; i++) + { + double a = InMono[(int)((pos + i) & (kInputCapacity - 1))]; + dot += a * ref[i]; + energy += a * a; + } + return dot / std::sqrt((energy * refEnergy) + 1.0e-9); + } + + float Window[kFrameSize]; + + int16_t InL[kInputCapacity]; + int16_t InR[kInputCapacity]; + float InMono[kInputCapacity]; + std::atomic WritePos{0}; // producer writes, consumer reads + std::atomic Generation{0}; // producer writes, consumer reads + std::atomic ConsumerFloor{0}; // consumer writes, producer reads + + int64_t SeenWrite = 0; // consumer's snapshot of WritePos + uint32_t SeenGeneration = 0; + int64_t AnalysisPos = 0; + int64_t NaturalPos = 0; + bool Primed = false; + + float AccL[kFrameSize]; + float AccR[kFrameSize]; + + int16_t OutL[kOutputCapacity]; + int16_t OutR[kOutputCapacity]; + int64_t OutReadPos = 0; + int64_t OutWritePos = 0; +}; + +#endif // AUDIOTIMESTRETCH_H diff --git a/src/frontend/qt_sdl/Config.cpp b/src/frontend/qt_sdl/Config.cpp index 0d48ee1646..b222b1ca49 100644 --- a/src/frontend/qt_sdl/Config.cpp +++ b/src/frontend/qt_sdl/Config.cpp @@ -66,6 +66,7 @@ DefaultList DefaultInts = {"Instance*.Audio.Volume", 256}, {"Mic.InputType", 1}, {"Mouse.HideSeconds", 5}, + {"SpeedUpLowPass", 48000}, {"Instance*.DSi.Battery.Level", 0xF}, #ifdef GDBSTUB_ENABLED {"Instance*.Gdb.ARM7.Port", 3334}, @@ -90,6 +91,7 @@ RangeList IntRanges = {"Instance*.Window*.ScreenAspectTop", {0, AspectRatiosNum-1}}, {"Instance*.Window*.ScreenAspectBot", {0, AspectRatiosNum-1}}, {"MP.AudioMode", {0, 2}}, + {"SpeedUpLowPass", {1000, 48000}}, {"LAN.HostNumPlayers", {2, 16}}, }; @@ -111,6 +113,8 @@ DefaultList DefaultBools = #endif #endif {"DSi.DSP.HLE", true}, + {"SpeedUpTimeStretch", true}, + {"SpeedUpLowPassEnable", true}, {"Instance*.RTC.SyncToHost", true}, }; diff --git a/src/frontend/qt_sdl/EmuInstance.h b/src/frontend/qt_sdl/EmuInstance.h index a1eab0efc2..add842ec5a 100755 --- a/src/frontend/qt_sdl/EmuInstance.h +++ b/src/frontend/qt_sdl/EmuInstance.h @@ -21,6 +21,8 @@ #include +#include + #include "Platform.h" #include "main.h" #include "NDS.h" @@ -28,9 +30,26 @@ #include "Window.h" #include "Config.h" #include "SaveManager.h" +#include "AudioSpeed.h" +#include "AudioLowPass.h" +#include "AudioTimeStretch.h" +#include "AudioStreamRamp.h" const int kMaxWindows = 4; +// upper bound on frames drained from the SPU FIFO per emulated frame, and the +// size of the staging buffer that receives them. tied to kMinOutputSkew: at a +// skew of 0.4 the SPU emits 802.3/0.4 ~= 2006 frames per emulated frame, so +// lowering that floor means raising this too. +const int kAudioDrainMax = 2048; + +// cross-fade across a stretcher handover, in output frames. kept short: both +// sides are the same material at different points, so a long overlap is heard +// for itself. the dip at the midpoint attenuates the discontinuity the +// handover carries, at the cost of a shallow notch. +const unsigned kAudioHandoverFadeFrames = 256; +const float kAudioHandoverDipDepth = 0.15f; + enum { HK_Lid = 0, @@ -167,6 +186,21 @@ class EmuInstance void micStop(); int micReadInput(melonDS::s16* data, int maxlength); + // called from the emu thread every frame: it, not the audio callback, + // drains the SPU while off-speed + void audioDrainSPU(); + bool audioOffSpeed() const; + // emu thread. call when the stream jumps - savestate load, undo + void audioMarkDiscontinuity(); + // emu thread. bracket a jump in the game's state - a load, an undo, a + // reset - with the pause tail and the resume ramp, so the splice lands in + // silence. a stream already down is left alone: begin returns false and + // end then does nothing. + bool audioJumpBegin(); + void audioJumpEnd(bool ramped); + // emu thread. the achieved frame rate, for the low-pass only + void audioSetMeasuredFPS(double fps); + QMutex renderLock; private: @@ -229,6 +263,10 @@ class EmuInstance void updateFastForwardMute(bool fastForward); void audioSync(); void audioUpdateSettings(); + void audioUpdateSpeedUpSettings(); + void audioUpdateOutputSkew(); + bool audioStreamEnd(); + void audioDrainRing(); void micOpen(); void micClose(); @@ -236,7 +274,12 @@ class EmuInstance void setupMicInputData(); int audioGetNumSamplesOut(int outlen); + double audioLowPassCutoff() const; static void audioCallback(void* data, Uint8* stream, int len); + void audioDirectRead(melonDS::s16* stream, int len); + void audioStretchedRead(melonDS::s16* stream, int len); + void audioArmHandoverFade(); + void audioFinishBuffer(melonDS::s16* stream, int len, int num_in); int micGetNumSamplesIn(int inlen); void micResample(melonDS::s16* inbuf, int inlen); @@ -298,7 +341,8 @@ class EmuInstance std::unique_ptr firmwareSave; bool doLimitFPS; - double curFPS; + // the emu thread writes this, the audio thread reads it + std::atomic curFPS; double targetFPS; double fastForwardFPS; double slowmoFPS; @@ -316,12 +360,49 @@ class EmuInstance SDL_AudioDeviceID audioDevice; int audioFreq; int audioBufSize; - float audioSampleFrac; + // written from the Qt thread with the emu thread and the audio device + // paused, read on both while running. atomic so a slip in that pausing + // degrades to a stale read; they are independent and need no ordering. + std::atomic audioSpeedUpLowPass{kSpeedUpLowPassOff}; + std::atomic audioTimeStretchEnable{false}; + std::atomic audioLowPassEnable{false}; + // the rate actually achieved, published by the emu thread. curFPS is only + // the frame limiter's setpoint, and with an uncapped fast-forward target it + // is nowhere near the truth. + std::atomic audioMeasuredFPS{0.0}; + // frames handed to the stretcher, accepted or not. the audio thread derives + // the arrival rate from this: counting only accepted frames would read a + // saturated ring as silence and stretch the wrong way. + std::atomic audioOfferedFrames{0}; + AudioLowPass audioLowPass; + AudioTimeStretch audioTimeStretch; + bool audioStretchEngaged; + melonDS::u32 audioLastFrame; + double audioArrivalAvg; + // speed the last fast-forward hold actually reached, banked at release + // and seeding the next engage; 0 until one has happened + double audioSpeedAchieved; + melonDS::s64 audioLastOffered; + double audioSampleFrac; + melonDS::s16 audioDrainTemp[kAudioDrainMax * 2]; + bool audioDrainEngaged; + // audio thread. audioStretchEngaged as of the last buffer, so the + // handover is seen as an edge; the cross-fade still to run, and the + // level it started from + bool audioFadePrevEngaged; + unsigned audioFadeOutFrames; + float audioFadeOutFrom[2]; bool audioMutedToggle; bool audioMutedByFastForward; bool audioMutedByWindowFocus; SDL_cond* audioSyncCond; SDL_mutex* audioSyncLock; + // conceals the ends of the stream. the emu thread asks for the tail + // through audioTailRequested and waits on audioTailSpent before pausing + // the device; everything else is the audio thread's. + AudioStreamRamp audioRamp; + std::atomic audioTailRequested{false}; + std::atomic audioTailSpent{false}; int mpAudioMode; diff --git a/src/frontend/qt_sdl/EmuInstanceAudio.cpp b/src/frontend/qt_sdl/EmuInstanceAudio.cpp index 7df4d9f4f4..44517cde98 100644 --- a/src/frontend/qt_sdl/EmuInstanceAudio.cpp +++ b/src/frontend/qt_sdl/EmuInstanceAudio.cpp @@ -22,12 +22,12 @@ #include "Platform.h" #include "main.h" +#include "AudioSpeed.h" + #include "mic_blow.h" using namespace melonDS; -#define INTERNAL_FRAME_RATE 59.8260982880808f - // --- AUDIO OUTPUT ----------------------------------------------------------- @@ -43,7 +43,9 @@ void EmuInstance::audioInit() audioSyncLock = SDL_CreateMutex(); audioFreq = 48000; // TODO: make both of these configurable? - audioBufSize = 512; + // 256 rather than 512, for the halved output latency. this used to also cap + // emulation speed, back when the callback drained the SPU ring. + audioBufSize = 256; SDL_AudioSpec whatIwant, whatIget; memset(&whatIwant, 0, sizeof(SDL_AudioSpec)); @@ -67,7 +69,24 @@ void EmuInstance::audioInit() SDL_PauseAudioDevice(audioDevice, 1); } - audioSampleFrac = 0; + audioRamp.Reset(); + audioTailRequested.store(false, std::memory_order_relaxed); + audioTailSpent.store(false, std::memory_order_relaxed); + audioUpdateSpeedUpSettings(); + audioLowPass.Init(audioFreq); + audioTimeStretch.Reset(); + audioStretchEngaged = false; + audioLastFrame = 0; + audioArrivalAvg = 0.0; + audioSpeedAchieved = 0.0; + audioLastOffered = 0; + audioOfferedFrames.store(0, std::memory_order_relaxed); + audioMeasuredFPS.store(0.0, std::memory_order_relaxed); + audioSampleFrac = 0.0; + audioDrainEngaged = false; + audioFadePrevEngaged = false; + audioFadeOutFrames = 0; + audioFadeOutFrom[0] = audioFadeOutFrom[1] = 0.0f; micStarted = false; micDevice = 0; @@ -135,9 +154,101 @@ void EmuInstance::updateFastForwardMute(bool fastForward) audioMutedByFastForward = fastForward && globalCfg.GetBool("MuteFastForward"); } +bool EmuInstance::audioOffSpeed() const +{ + // both threads decide through here which of them owns the SPU FIFO. they + // can disagree for one buffer across a speed change; it self-corrects. + if (!audioTimeStretchEnable.load(std::memory_order_relaxed)) return false; + return audioIsOffSpeed(curFPS, targetFPS); +} + +void EmuInstance::audioDrainSPU() +{ + // draining here rather than from the audio callback is what removes the + // speed ceiling: the callback could only take one ring's worth per period + if (!audioDevice || !audioOffSpeed()) + { + audioDrainEngaged = false; + return; + } + + if (!audioDrainEngaged) + { + audioDrainEngaged = true; + audioTimeStretch.BeginSession(); + } + + SDL_LockMutex(audioSyncLock); + int avail = nds->SPU.GetOutputSize(); + if (avail > kAudioDrainMax) avail = kAudioDrainMax; + int got = (avail > 0) ? nds->SPU.ReadOutput(audioDrainTemp, avail) : 0; + SDL_CondSignal(audioSyncCond); + SDL_UnlockMutex(audioSyncLock); + + if (got > 0) + { + audioOfferedFrames.fetch_add(got, std::memory_order_release); + + // frames are already out of the SPU FIFO, so a short accept loses them. + // mark the discontinuity so it resyncs rather than splicing. + if (audioTimeStretch.Write(audioDrainTemp, got) < got) + audioTimeStretch.BeginSession(); + } +} + +void EmuInstance::audioMarkDiscontinuity() +{ + audioTimeStretch.BeginSession(); +} + +bool EmuInstance::audioJumpBegin() +{ + return audioStreamEnd(); +} + +// emu thread. bounded: the ring is finite and nothing feeds it here. +void EmuInstance::audioDrainRing() +{ + if (nds == nullptr) return; + + SDL_LockMutex(audioSyncLock); + for (int i = 0; i < 8 && nds->SPU.GetOutputSize() > 0; i++) + nds->SPU.ReadOutput(audioDrainTemp, kAudioDrainMax); + SDL_UnlockMutex(audioSyncLock); +} + +void EmuInstance::audioJumpEnd(bool ramped) +{ + // the SPU output ring is not part of a savestate, so it still holds + // audio from before the jump; that would come back spliced behind the + // tail, or behind the resume ramp if the jump was made while paused. + audioDrainRing(); + + if (!ramped) return; + + // the device kept running through the jump, so the ramp is armed under + // its lock rather than with it paused + if (audioDevice) SDL_LockAudioDevice(audioDevice); + audioTailRequested.store(false, std::memory_order_relaxed); + audioTailSpent.store(false, std::memory_order_relaxed); + audioRamp.Begin(); + // a cross-fade still running would re-inject the pre-jump level on top of + // the frames the ramp is bringing back + audioFadeOutFrames = 0; + audioFadeOutFrom[0] = audioFadeOutFrom[1] = 0.0f; + if (audioDevice) SDL_UnlockAudioDevice(audioDevice); +} + +void EmuInstance::audioSetMeasuredFPS(double fps) +{ + audioMeasuredFPS.store(fps, std::memory_order_relaxed); +} + void EmuInstance::audioSync() { - if (audioDevice) + // a paused device drains nothing, and neither the callback nor audioDrainSPU + // can signal from there, so the wait would only spend its timeout + if (audioDevice && SDL_GetAudioDeviceStatus(audioDevice) == SDL_AUDIO_PLAYING) { SDL_LockMutex(audioSyncLock); while (nds->SPU.GetOutputSize() >= audioBufSize) @@ -149,51 +260,222 @@ void EmuInstance::audioSync() } } +void EmuInstance::audioCallback(void* data, Uint8* stream, int len) +{ + EmuInstance* inst = (EmuInstance*)data; + len /= (sizeof(s16) * 2); + + if (!inst->audioOffSpeed()) + { + // bank the speed the hold reached before the average is thrown away + if (inst->audioStretchEngaged && len > 0) + { + double reached = inst->audioArrivalAvg / len; + if (std::isfinite(reached) && reached > 1.05) + inst->audioSpeedAchieved = reached; + } + + // no Reset() here: this is the audio thread, and the emu thread can + // still be inside Write. re-engaging resyncs via BeginSession anyway. + inst->audioStretchEngaged = false; + inst->audioDirectRead((s16*) stream, len); + return; + } + + if (!inst->audioStretchEngaged) + { + inst->audioStretchEngaged = true; + + // the requested speed is a ceiling the host may not reach, and the + // average takes most of a short hold to walk down from it. seed from + // what the last hold actually reached instead, capped by the request + // only where something enforces it: curFPS is 1000 for uncapped + // fast-forward, and nothing paces the loop with the limiter off. + double requested = audioSpeedRatio(inst->curFPS, inst->targetFPS); + double anchor = requested; + if (requested > 1.05 && inst->audioSpeedAchieved > 1.05) + { + anchor = inst->audioSpeedAchieved; + if (inst->doLimitFPS && inst->curFPS < 999.0 && anchor > requested) + anchor = requested; + } + inst->audioArrivalAvg = len * anchor; + } + inst->audioStretchedRead((s16*) stream, len); +} + +// how much input one output buffer is worth at the current speed. without this +// the direct path consumes faster than the SPU fills and gates to silence. int EmuInstance::audioGetNumSamplesOut(int outlen) { - float f_len_in = outlen * (curFPS/targetFPS); - f_len_in += audioSampleFrac; - int len_in = (int)floor(f_len_in); - audioSampleFrac = f_len_in - len_in; + double f_len_in = (outlen * audioSpeedRatio(curFPS, targetFPS)) + audioSampleFrac; + + // capped before the cast: targetFPS goes down to 0.0001, which would + // otherwise make this far too large for an int + if (f_len_in > outlen) f_len_in = outlen; + if (f_len_in < 0.0) f_len_in = 0.0; + int len_in = (int)f_len_in; + audioSampleFrac = f_len_in - len_in; return len_in; } -void EmuInstance::audioCallback(void* data, Uint8* stream, int len) +void EmuInstance::audioDirectRead(s16* stream, int len) { - EmuInstance* inst = (EmuInstance*)data; - len /= (sizeof(s16) * 2); + int len_in = audioGetNumSamplesOut(len); + + SDL_LockMutex(audioSyncLock); + int num_in = nds->SPU.ReadOutput(stream, len_in); + SDL_CondSignal(audioSyncCond); + SDL_UnlockMutex(audioSyncLock); + + audioFinishBuffer(stream, len, num_in); +} - double skew = std::max(inst->targetFPS / INTERNAL_FRAME_RATE, 0.5); - inst->nds->SPU.SetOutputSkew(skew); +void EmuInstance::audioStretchedRead(s16* stream, int len) +{ + // the emu thread fills the stretcher; what it has offered gives us the + // arrival rate, which is lumpy, so smooth it before using it as a rate + melonDS::s64 offered = audioOfferedFrames.load(std::memory_order_acquire); + melonDS::s64 delta = offered - audioLastOffered; + if (delta < 0) delta = 0; + audioLastOffered = offered; + + audioArrivalAvg += (delta - audioArrivalAvg) * 0.05; - int len_in = inst->audioGetNumSamplesOut(len); - if (len_in > inst->audioBufSize) len_in = inst->audioBufSize; + double ratio = audioComputeStretchRatio(audioArrivalAvg, len, + audioTimeStretch.InputFill(), + AudioTimeStretch::TargetInputFill(audioArrivalAvg)); + int num_in = audioTimeStretch.Read(stream, len, ratio); - SDL_LockMutex(inst->audioSyncLock); - int num_in = inst->nds->SPU.ReadOutput((s16*) stream, len_in); - SDL_CondSignal(inst->audioSyncCond); - SDL_UnlockMutex(inst->audioSyncLock); + audioFinishBuffer(stream, len, num_in); +} - if ((num_in < 1) || inst->audioMutedByWindowFocus || inst->audioMutedToggle || inst->audioMutedByFastForward) +double EmuInstance::audioLowPassCutoff() const +{ + int ref = audioLowPassEnable.load(std::memory_order_relaxed) + ? audioSpeedUpLowPass.load(std::memory_order_relaxed) + : kSpeedUpLowPassOff; + + // the setpoint decides whether we are off-speed at all, so that normal + // speed stays exactly transparent and measurement jitter can't engage the + // filter. how far off-speed comes from the achieved rate: curFPS is 1000 + // for an uncapped fast-forward and would over-filter by the shortfall. + double fps = curFPS; + if (audioIsOffSpeed(curFPS, targetFPS)) { - memset(stream, 0, len*sizeof(s16)*2); - return; + double measured = audioMeasuredFPS.load(std::memory_order_relaxed); + if (measured > 0.0) fps = measured; } - if (inst->audioVolume < 256) + return audioComputeLowPassCutoff(fps, targetFPS, ref, audioLowPass.WideOpenCutoff()); +} + +// switching between the stretcher and the raw FIFO jumps the output by the +// stretcher's reserve in one direction or the other. the two sides cannot be +// joined seamlessly, and a short equal-power cross-fade from the level the +// stream stopped at is less audible than a splice. +void EmuInstance::audioArmHandoverFade() +{ + // not while one is running: the edge can arrive twice in quick succession + if (audioFadeOutFrames != 0) return; + + audioFadeOutFrames = kAudioHandoverFadeFrames; + audioFadeOutFrom[0] = (s16)(audioLastFrame & 0xFFFF) / 32768.0f; + audioFadeOutFrom[1] = (s16)(audioLastFrame >> 16) / 32768.0f; +} + +void EmuInstance::audioFinishBuffer(s16* stream, int len, int num_in) +{ + // the stream is being taken down: play the tail in place of the source, + // which was still read so the ring drains + bool tailRequested = audioTailRequested.load(std::memory_order_acquire); + if (tailRequested) num_in = 0; + + if (audioMutedByWindowFocus || audioMutedToggle || audioMutedByFastForward) { - s16* samples = (s16*) stream; - for (int i = 0; i < num_in * 2; i++) - samples[i] = ((s32) samples[i] * inst->audioVolume) >> 8; + memset(stream, 0, len*sizeof(s16)*2); + audioLastFrame = 0; + // keep the cutoff smoother moving while muted, or re-engaging clicks. + // with MuteFastForward on, this path is the fast-forward case. + audioLowPass.ProcessMuted(len, audioLowPassCutoff(), len / (double)audioFreq); + // the ramp's history follows the silence, so a pause while muted has + // no tail to play + audioRamp.Track(stream, len); + if (tailRequested && audioRamp.TailSpent()) audioTailSpent.store(true, std::memory_order_release); + // follow the handover edge without arming a fade: silence needs none, + // and an unmute next to one would otherwise fade from a zeroed level + audioFadePrevEngaged = audioStretchEngaged; + return; } - if (num_in < len_in) + // slow-mo with the stretcher off is untreated by design: pad the buffer by + // holding the last frame, as it always was + if (num_in >= 1 && num_in < len && !audioStretchEngaged && audioIsOffSpeed(curFPS, targetFPS)) { int last = num_in-1; - for (int i = num_in; i < len_in; i++) + for (int i = num_in; i < len; i++) ((u32*)stream)[i] = ((u32*)stream)[last]; + num_in = len; + } + + // frames came back after the stream ended: bring them in on the ramp + if (num_in >= 1 && audioRamp.Ended()) audioRamp.Begin(); + audioRamp.Track(stream, num_in); + + if (num_in < len) + { + // the source ran out, or the stream is being taken down: end it on a + // tail from where it stopped rather than a step. the low-pass below + // runs over the tail too, so its state stays in step with what plays. + if (!audioRamp.Ended()) audioRamp.End(); + audioRamp.FillTail(stream + (num_in * 2), len - num_in); + if (tailRequested && audioRamp.TailSpent()) + audioTailSpent.store(true, std::memory_order_release); + } + + audioLowPass.Process((int16_t*) stream, len, audioLowPassCutoff(), + len / (double)audioFreq); + + // either edge of the handover, seen on the buffer that first crosses it + if (audioStretchEngaged != audioFadePrevEngaged) + { + audioFadePrevEngaged = audioStretchEngaged; + audioArmHandoverFade(); + } + + if (audioFadeOutFrames > 0) + { + // smoothstep the progress so the gain leaves and arrives with zero + // slope; a step in rate of change is heard as a blip at each end + int n = std::min(audioFadeOutFrames, len); + for (int j = 0; j < n; j++) + { + unsigned done = kAudioHandoverFadeFrames - audioFadeOutFrames + j + 1; + float u = (float)done / kAudioHandoverFadeFrames; + float th = 0.5f * (float)M_PI * (u * u * (3.0f - 2.0f * u)); + float g = std::cos(th); + float gn = std::sin(th); + float env = 1.0f - ((1.0f - kAudioHandoverDipDepth) + * std::sin((float)M_PI * done / kAudioHandoverFadeFrames)); + for (int ch = 0; ch < 2; ch++) + { + float v = ((audioFadeOutFrom[ch] * g) + ((stream[(j*2)+ch] / 32768.0f) * gn)) * env; + stream[(j*2)+ch] = (s16)std::clamp(std::lround(v * 32768.0f), -32768L, 32767L); + } + } + audioFadeOutFrames -= n; + } + + // pre-volume: what continues from this frame is scaled once, by the + // volume below + if (len > 0) audioLastFrame = ((u32*)stream)[len-1]; + + if (audioVolume < 256) + { + for (int i = 0; i < len * 2; i++) + stream[i] = ((s32) stream[i] * audioVolume) >> 8; } } @@ -494,6 +776,16 @@ void EmuInstance::micCallback(void* data, Uint8* stream, int len) +// atomics, so the audio settings dialog can apply these live with everything +// running - which is what lets the cutoff be set by ear during fast-forward +void EmuInstance::audioUpdateSpeedUpSettings() +{ + audioSpeedUpLowPass.store(globalCfg.GetInt("SpeedUpLowPass"), std::memory_order_relaxed); + audioTimeStretchEnable.store(globalCfg.GetBool("SpeedUpTimeStretch"), std::memory_order_relaxed); + audioLowPassEnable.store(globalCfg.GetBool("SpeedUpLowPassEnable"), std::memory_order_relaxed); +} + +// from the audio settings dialog, which does not pause the emu thread void EmuInstance::audioUpdateSettings() { if (micStarted) micClose(); @@ -504,18 +796,81 @@ void EmuInstance::audioUpdateSettings() nds->SPU.SetInterpolation(static_cast(audiointerp)); } + audioUpdateSpeedUpSettings(); + setupMicInputData(); if (micStarted) micOpen(); } +// from the interface settings dialog, and it has to stay there: SetOutputSkew +// reaches blip_set_rates, which the emu thread reads with no lock, and that +// dialog is the only settings path that pauses. +void EmuInstance::audioUpdateOutputSkew() +{ + if (nds != nullptr) nds->SPU.SetOutputSkew(audioComputeOutputSkew(targetFPS)); +} + void EmuInstance::audioEnable() { + // whatever the ring still holds was never played: the tail replaced it + // while the stream went down. a frame step is the loud case - it runs + // frames with the device paused, and they would all play at the resume. + audioDrainRing(); + + // covers emulator reset, which brackets with audioDisable/audioEnable, + // else pre-reset audio splices into post. savestate load does not come + // through here at all - see audioMarkDiscontinuity. Reset clears state the + // audio thread owns, so hold the device lock. + if (audioDevice) SDL_LockAudioDevice(audioDevice); + // the stream is coming back: ramp its first frames in. the ramp's own + // state is not reset, a tail's mute still applies to what comes back. + audioTailRequested.store(false, std::memory_order_relaxed); + audioTailSpent.store(false, std::memory_order_relaxed); + audioRamp.Begin(); + audioTimeStretch.Reset(); + audioStretchEngaged = false; + audioLastFrame = 0; + audioArrivalAvg = 0.0; + audioSpeedAchieved = 0.0; + audioLastOffered = 0; + audioOfferedFrames.store(0, std::memory_order_relaxed); + audioSampleFrac = 0.0; + audioDrainEngaged = false; + audioFadePrevEngaged = false; + audioFadeOutFrames = 0; + audioFadeOutFrom[0] = audioFadeOutFrom[1] = 0.0f; + if (audioDevice) SDL_UnlockAudioDevice(audioDevice); + + // the skew depends only on targetFPS, so it doesn't belong in the audio + // callback, where it raced SPU::Reset through blip_set_rates. here and in + // audioUpdateOutputSkew covers every time it can change. + if (nds != nullptr) nds->SPU.SetOutputSkew(audioComputeOutputSkew(targetFPS)); + if (audioDevice) SDL_PauseAudioDevice(audioDevice, 0); if (micStarted) micOpen(); } +// emu thread. end the stream on its tail: the callback plays it in place of +// the source from the next buffer on. the tail is two callbacks long, so wait +// for the callback to report it spent, bounded so a device that has stopped +// calling back cannot hold the emu thread. false if the stream was not up. +bool EmuInstance::audioStreamEnd() +{ + if (!audioDevice || SDL_GetAudioDeviceStatus(audioDevice) != SDL_AUDIO_PLAYING) return false; + if (audioTailRequested.load(std::memory_order_acquire)) return false; + + audioTailSpent.store(false, std::memory_order_relaxed); + audioTailRequested.store(true, std::memory_order_release); + int waitMs = (4 * 1000 * audioBufSize) / audioFreq + 1; + for (int i = 0; i < waitMs && !audioTailSpent.load(std::memory_order_acquire); i++) + SDL_Delay(1); + return true; +} + void EmuInstance::audioDisable() { + audioStreamEnd(); + if (audioDevice) SDL_PauseAudioDevice(audioDevice, 1); if (micStarted) micClose(); } diff --git a/src/frontend/qt_sdl/EmuThread.cpp b/src/frontend/qt_sdl/EmuThread.cpp index c3fbdfe603..e2ef2226c0 100755 --- a/src/frontend/qt_sdl/EmuThread.cpp +++ b/src/frontend/qt_sdl/EmuThread.cpp @@ -376,6 +376,8 @@ void EmuThread::run() emuInstance->audioVolume = volumeLevel * (256.0 / 31.0); } + emuInstance->audioDrainSPU(); + if (emuInstance->doAudioSync && !(fastforward || slowmo)) emuInstance->audioSync(); @@ -412,6 +414,7 @@ void EmuThread::run() lastMeasureTime = time; u32 fps = round(nframes / dt); + if (dt > 0.0) emuInstance->audioSetMeasuredFPS(nframes / dt); nframes = 0; float fpstarget = 1.0/frametimeStep; @@ -428,6 +431,7 @@ void EmuThread::run() else { // paused + emuInstance->audioSetMeasuredFPS(0.0); nframes = 0; lastTime = SDL_GetPerformanceCounter() * perfCountsSec; lastMeasureTime = lastTime; @@ -539,6 +543,8 @@ void EmuThread::handleMessages() break; case msg_EmuReset: + // audioEnable below brings the stream back + emuInstance->audioJumpBegin(); emuInstance->reset(); emuStatus = emuStatus_Running; @@ -568,8 +574,15 @@ void EmuThread::handleMessages() case msg_BootROM: msgResult = 0; + // the console is rebuilt under a callback that dereferences nds, + // so stop the device rather than only ending the stream. msg_EmuRun + // brings it back; a failed boot leaves the old one running instead + emuInstance->audioDisable(); if (!emuInstance->loadROM(msg.param.value(), true, msgError)) + { + if (emuStatus == emuStatus_Running) emuInstance->audioEnable(); break; + } assert(emuInstance->nds != nullptr); emuInstance->nds->Start(); @@ -578,8 +591,12 @@ void EmuThread::handleMessages() case msg_BootFirmware: msgResult = 0; + emuInstance->audioDisable(); if (!emuInstance->bootToMenu(msgError)) + { + if (emuStatus == emuStatus_Running) emuInstance->audioEnable(); break; + } assert(emuInstance->nds != nullptr); emuInstance->nds->Start(); @@ -621,11 +638,21 @@ void EmuThread::handleMessages() break; case msg_LoadState: - msgResult = emuInstance->loadState(msg.param.value().toStdString()); + { + bool ramped = emuInstance->audioJumpBegin(); + msgResult = emuInstance->loadState(msg.param.value().toStdString()); + emuInstance->audioMarkDiscontinuity(); + emuInstance->audioJumpEnd(ramped); + } break; case msg_UndoStateLoad: - emuInstance->undoStateLoad(); + { + bool ramped = emuInstance->audioJumpBegin(); + emuInstance->undoStateLoad(); + emuInstance->audioMarkDiscontinuity(); + emuInstance->audioJumpEnd(ramped); + } msgResult = 1; break; diff --git a/src/frontend/qt_sdl/Window.cpp b/src/frontend/qt_sdl/Window.cpp index 92189a5684..8861692bac 100755 --- a/src/frontend/qt_sdl/Window.cpp +++ b/src/frontend/qt_sdl/Window.cpp @@ -751,7 +751,7 @@ MainWindow::MainWindow(int id, EmuInstance* inst, QWidget* parent) : } QObject::connect(qApp, &QApplication::applicationStateChanged, this, &MainWindow::onAppStateChanged); - onUpdateInterfaceSettings(); + applyInterfaceSettings(); updateMPInterface(MPInterface::GetType()); } @@ -1930,6 +1930,9 @@ void MainWindow::onUpdateAudioVolume(int vol, int dsisync) void MainWindow::onUpdateAudioSettings() { + // before the emuIsActive check: these are atomics and need no console + emuInstance->audioUpdateSpeedUpSettings(); + if (!emuThread->emuIsActive()) return; assert(emuInstance->nds != nullptr); @@ -1990,6 +1993,16 @@ void MainWindow::onOpenInterfaceSettings() } void MainWindow::onUpdateInterfaceSettings() +{ + applyInterfaceSettings(); + + // only from the settings dialog, which pauses first: this reaches + // blip_set_rates, which is not safe against a running emu thread. opening + // a new window must not come through here. + emuInstance->audioUpdateOutputSkew(); +} + +void MainWindow::applyInterfaceSettings() { pauseOnLostFocus = globalCfg.GetBool("PauseLostFocus"); emuInstance->targetFPS = globalCfg.GetDouble("TargetFPS"); diff --git a/src/frontend/qt_sdl/Window.h b/src/frontend/qt_sdl/Window.h index 8b763407d6..16857e76bc 100755 --- a/src/frontend/qt_sdl/Window.h +++ b/src/frontend/qt_sdl/Window.h @@ -188,6 +188,10 @@ private slots: private: virtual void closeEvent(QCloseEvent* event) override; + // the part of the interface settings a new window has to pick up. kept out + // of the slot because that one also touches audio, which needs a pause. + void applyInterfaceSettings(); + QStringList currentROM; QStringList currentGBAROM; QList recentFileList; diff --git a/tools/audiobench.cpp b/tools/audiobench.cpp new file mode 100644 index 0000000000..c07b16f267 --- /dev/null +++ b/tools/audiobench.cpp @@ -0,0 +1,314 @@ +/* + Copyright 2016-2026 melonDS team + + This file is part of melonDS. + + melonDS is free software: you can redistribute it and/or modify it under + the terms of the GNU General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) + any later version. + + melonDS is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with melonDS. If not, see http://www.gnu.org/licenses/. +*/ + +// measurement tool for the off-speed audio path. not part of the build. +// +// g++ -std=c++17 -O2 -pthread -I src/frontend/qt_sdl -o build/audiobench tools/audiobench.cpp +// ./build/audiobench +// +// rebuild with -O1 -g -fsanitize=thread for the race check; the stress section +// is what exercises the producer/consumer boundary. +// +// three sections: +// pipeline where audio is lost, and to what, at each speed and drain mode +// cost CPU the stretcher and filter actually consume +// stress two real threads across the SPSC ring, with a tearing detector +// +// the tearing detector compares L against R, so it catches a torn read of +// InL/InR. it is blind to InMono, which only steers which offset gets picked. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "AudioTimeStretch.h" +#include "AudioSpeed.h" +#include "AudioLowPass.h" + +static constexpr double kFs = 48000.0; + +// mirrors of constants owned elsewhere, so this tool stays standalone +static constexpr int kSpuFifo = 2048; // SPU::InitOutput -> OutputBufferSize +static constexpr int kDrainMax = 2048; // kAudioDrainMax in EmuInstance.h +static constexpr double kInternalFps = 59.8260982880808; + +// ---------------------------------------------------------------- pipeline -- + +struct PipeResult +{ + long long callbacks, underruns, silent; + double meanFill, realFrac, survived; +}; + +// drainPerEmuFrame: the emu thread drains after every emulated frame. otherwise +// only the callback drains, capping speed at roughly kSpuFifo / audioBufSize. +static PipeResult pipeline(double speed, int audioBufSize, bool drainPerEmuFrame, + double seconds) +{ + AudioTimeStretch ts; + ts.Reset(); + + const int len = audioBufSize; + const double callbackPeriod = len / kFs; + const double emuFramePeriod = 1.0 / (kInternalFps * speed); + const double framesPerEmuFrame = kFs / kInternalFps; + + std::vector in(kDrainMax * 2), out(len * 2); + double carry = 0.0; + int spuPending = 0; + long long totalMade = 0, totalGot = 0, realFrames = 0; + double fillSum = 0.0; + double arrivalAvg = len * speed; + long long lastWritten = 0; + double tEmu = 0.0, tCb = 0.0; + PipeResult r{0, 0, 0, 0.0, 0.0, 0.0}; + + auto ingest = [&](int n) { + if (n <= 0) return; + for (int i = 0; i < n; i++) { in[(i*2)+0] = (int16_t)i; in[(i*2)+1] = (int16_t)i; } + ts.Write(in.data(), n); + }; + + auto drain = [&]() { + int got = spuPending; + if (got > kDrainMax) got = kDrainMax; + spuPending -= got; + totalGot += got; + ingest(got); + }; + + while (tCb < seconds) + { + // advance emulated frames up to the next callback + while (tEmu + emuFramePeriod <= tCb + callbackPeriod) + { + tEmu += emuFramePeriod; + carry += framesPerEmuFrame; + int made = (int)carry; carry -= made; + spuPending += made; + totalMade += made; + if (spuPending > kSpuFifo) spuPending = kSpuFifo; // BufferAudio discards + if (drainPerEmuFrame) drain(); + } + + tCb += callbackPeriod; + if (!drainPerEmuFrame) drain(); + + long long written = ts.TotalWritten(); + long long delta = written - lastWritten; + if (delta < 0) delta = 0; + lastWritten = written; + arrivalAvg += (delta - arrivalAvg) * 0.05; + + int fill = ts.InputFill(); + double ratio = audioComputeStretchRatio(arrivalAvg, len, fill, + AudioTimeStretch::TargetInputFill(arrivalAvg)); + int n = ts.Read(out.data(), len, ratio); + + r.callbacks++; + if (n < len) r.underruns++; + if (n < 1) r.silent++; + fillSum += fill; + realFrames += n; + } + + r.meanFill = fillSum / r.callbacks; + r.realFrac = realFrames / (double)(r.callbacks * len); + r.survived = totalMade ? (totalGot / (double)totalMade) : 1.0; + return r; +} + +static void runPipeline() +{ + printf("== pipeline ==\n"); + printf("output%% is how much of each buffer was real audio rather than padding.\n" + "input%% is how much of what the SPU generated survived its ring.\n\n"); + + const double speeds[] = {0.5, 1.5, 2.0, 3.0, 4.0, 8.0, 16.7}; + for (int bufSize : {512, 256}) + { + for (int mode = 0; mode < 2; mode++) + { + bool emuDrain = (mode == 1); + printf("audioBufSize %3d, drained by %s:\n", bufSize, + emuDrain ? "emu thread " : "callback "); + for (double s : speeds) + { + PipeResult r = pipeline(s, bufSize, emuDrain, 8.0); + printf(" %5.1fx underruns %5.1f%% output %5.1f%% input %5.1f%%\n", + s, 100.0 * r.underruns / r.callbacks, + 100.0 * r.realFrac, 100.0 * r.survived); + } + printf("\n"); + } + } +} + +// -------------------------------------------------------------------- cost -- + +static volatile long long g_sink = 0; + +static void runCost() +{ + printf("== cost ==\n"); + printf("Noise input, the worst case for the similarity search.\n\n"); + + const int len = 256; + const double seconds = 15.0; + const int callbacks = (int)(seconds * kFs / len); + const int poolFrames = 1 << 20; + + std::vector pool(poolFrames * 2); + srand(7); + double y = 0.0; + for (int i = 0; i < poolFrames; i++) + { + double n = ((rand() / (double)RAND_MAX) * 2.0 - 1.0) * 9000.0; + y = (0.6 * y) + (0.4 * n); + int16_t q = (int16_t)std::lround(y); + pool[(i*2)+0] = q; pool[(i*2)+1] = q; + } + + for (double ratio : {1.0, 2.0, 3.0, 4.0, 8.0}) + { + AudioTimeStretch ts; ts.Reset(); + AudioLowPass lp; lp.Init(kFs); + std::vector out(len * 2); + double carry = 0.0; + int pos = 0; + + auto t0 = std::chrono::steady_clock::now(); + for (int c = 0; c < callbacks; c++) + { + carry += len * ratio; + int got = (int)carry; carry -= got; + if (got > kDrainMax) got = kDrainMax; + if (pos + got > poolFrames) pos = 0; + ts.Write(&pool[pos*2], got); + pos += got; + int n = ts.Read(out.data(), len, ratio); + lp.Process(out.data(), len, 6000.0, len / kFs); + g_sink += n + out[0]; + } + auto t1 = std::chrono::steady_clock::now(); + + double ms = std::chrono::duration(t1 - t0).count(); + printf(" ratio %4.1f : %6.1f ms per %.0f s audio = %.2f%% of one core\n", + ratio, ms, seconds, 100.0 * (ms / 1000.0) / seconds); + } + printf("\n"); +} + +// ------------------------------------------------------------------ stress -- + +static AudioTimeStretch g_ts; +static std::atomic g_stop{false}; +static std::atomic g_written{0}, g_read{0}, g_refused{0}; +static std::atomic g_torn{0}, g_outOfRange{0}, g_sessions{0}; +static constexpr int kAmp = 9000; + +// per-thread state: rand() is neither reentrant nor reproducible when two +// threads share it, and an unreplayable stress test is worth less +static uint32_t nextRand(uint32_t& st) +{ + st = (st * 1664525u) + 1013904223u; + return st >> 16; +} + +static void producer() +{ + std::vector buf(kDrainMax * 2); + uint32_t rng = 0xA5A5A5A5u; + long long t = 0; + int spin = 0; + while (!g_stop.load(std::memory_order_relaxed)) + { + int n = 64 + (nextRand(rng) % 1900); + for (int i = 0; i < n; i++) + { + double v = kAmp * std::sin(2.0 * M_PI * 440.0 * ((t + i) / kFs)); + int16_t s = (int16_t)std::lround(v); + buf[(i*2)+0] = s; + buf[(i*2)+1] = s; // L == R, always + } + t += n; + int took = g_ts.Write(buf.data(), n); + g_written.fetch_add(took, std::memory_order_relaxed); + if (took < n) g_refused.fetch_add(n - took, std::memory_order_relaxed); + if (++spin % 500 == 0) { g_ts.BeginSession(); g_sessions.fetch_add(1, std::memory_order_relaxed); } + if ((spin % 7) == 0) std::this_thread::yield(); + } +} + +static void consumer() +{ + const int len = 256; + uint32_t rng = 0x5A5A5A5Au; + std::vector out(len * 2); + while (!g_stop.load(std::memory_order_relaxed)) + { + double ratio = 0.5 + ((nextRand(rng) % 700) / 100.0); + int n = g_ts.Read(out.data(), len, ratio); + g_read.fetch_add(n, std::memory_order_relaxed); + for (int i = 0; i < n; i++) + { + // input frames all have L == R and both channels get the same + // window and offset, so a difference means a torn read + if (out[(i*2)+0] != out[(i*2)+1]) g_torn.fetch_add(1, std::memory_order_relaxed); + if (std::abs((int)out[(i*2)]) > kAmp + 2000) g_outOfRange.fetch_add(1, std::memory_order_relaxed); + } + std::this_thread::yield(); + } +} + +static int runStress(int seconds) +{ + printf("== stress ==\n"); + srand(12345); + g_ts.Reset(); + + std::thread p(producer), c(consumer); + std::this_thread::sleep_for(std::chrono::seconds(seconds)); + g_stop.store(true, std::memory_order_relaxed); + p.join(); c.join(); + + printf(" %ds: written %lld, read %lld, refused %lld, sessions %lld\n", + seconds, g_written.load(), g_read.load(), g_refused.load(), g_sessions.load()); + printf(" torn frames (L != R): %lld\n", g_torn.load()); + printf(" out-of-range samples: %lld\n", g_outOfRange.load()); + + bool ok = (g_torn.load() == 0) && (g_outOfRange.load() == 0) + && (g_written.load() > 0) && (g_read.load() > 0); + printf(" %s\n\n", ok ? "PASS" : "FAIL"); + return ok ? 0 : 1; +} + +int main(int argc, char** argv) +{ + int stressSeconds = (argc > 1) ? atoi(argv[1]) : 5; + runPipeline(); + runCost(); + int rc = runStress(stressSeconds); + if (g_sink == 0x7fffffff) printf("unreachable\n"); + return rc; +}