Fix doubled cutscene dialogue: fold only the fronts while a cutscene plays

AC6's cutscene mixer submits its 5.1 premix to all six speaker slots as decorrelated near-copies of one mix; six real speakers separate them acoustically, but the port's 6→2 fold sums them, combing speech into the long-reported doubled dialogue. While the demo sequencer ticks, fold only the fronts (verified lossless). audio_cutscene_downmix (default on).
This commit is contained in:
Dipshet
2026-07-31 04:25:30 +02:00
parent 466cac7c00
commit edcb631442
3 changed files with 155 additions and 4 deletions
@@ -31,6 +31,7 @@
#include <native/audio/audio_client.h>
#include <native/audio/audio_system.h>
#include <native/audio/conversion.h>
#include <rex/cvar.h>
#include <rex/logging.h>
#include <rex/ppc.h>
@@ -121,6 +122,12 @@ void ExecWithResync(PPCContext& ctx, uint8_t* base,
}
g_session.last_exec_ms = now_ms;
// Stamp the cinematic-audio gate for the stereo fold-down (conversion.h):
// the demo wrappers tick only while an in-engine cutscene plays, so their
// freshness is the "cutscene audio active" signal. Unconditional - the
// stamp is independent of whether the resync behavior itself is enabled.
rex::audio::NotifyCinematicAudioTick(now_ms);
uint64_t audio_samples = 0;
const bool clock_ok = ReadAudioClockSamples(ctx, &audio_samples);
if (clock_ok && !g_session.clock_valid) {
+107 -4
View File
@@ -3,13 +3,40 @@
#pragma once
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <native/audio/render_driver_frame_layout.h>
#include <rex/cvar.h>
#include <rex/platform.h>
#include <rex/types.h>
REXCVAR_DECLARE(bool, audio_cutscene_downmix);
REXCVAR_DECLARE(double, audio_downmix_center_gain);
REXCVAR_DECLARE(double, audio_downmix_surround_gain);
REXCVAR_DECLARE(double, audio_downmix_lfe_gain);
REXCVAR_DECLARE(double, audio_downmix_cutscene_center_gain);
REXCVAR_DECLARE(double, audio_downmix_cutscene_surround_gain);
REXCVAR_DECLARE(double, audio_downmix_cutscene_lfe_gain);
REXCVAR_DECLARE(double, audio_downmix_cutscene_trim);
REXCVAR_DECLARE(double, audio_downmix_cutscene_ramp_ms);
namespace rex::audio {
// Wall-clock ms of the last in-engine cutscene sequencer tick, stamped by the
// demo-tick hook (ac6_cutscene_resync). Lets the stereo fold-down apply
// cutscene-specific gains without reaching into game code. INT64_MIN = never.
inline std::atomic<int64_t> g_last_cinematic_audio_tick_ms{INT64_MIN};
inline void NotifyCinematicAudioTick(int64_t now_ms) {
g_last_cinematic_audio_tick_ms.store(now_ms, std::memory_order_relaxed);
}
} // namespace rex::audio
namespace rex::audio::conversion {
inline constexpr float kStereoDownmixCenterGain = 0.70710678f;
@@ -20,6 +47,81 @@ inline constexpr float kStereoDownmixNormalize =
1.0f / (1.0f + kStereoDownmixCenterGain + kStereoDownmixSurroundGain +
kStereoDownmixLfeGain);
// Live fold-down gains for the path AC6 actually plays through (the AMD64
// planar fold below; the other variants keep the compile-time constants).
// The base gains default to those constants; the cutscene set applies while
// the demo sequencer is ticking, because AC6's cutscene mixer submits its premix
// spread across ALL six speaker slots as decorrelated near-copies of one mix
// (measured: equal RMS on every channel, inter-channel correlation 0.69-0.94
// at exactly lag 0, identical structure across scenes). Real speakers
// separate the copies acoustically; an electrical 6-to-2 sum combs them -
// heard as doubled dialogue. The fronts alone carry the complete mix, so the
// cutscene defaults fold only the fronts. Gameplay audio (discrete channels)
// is bit-identical to the old constants.
struct StereoDownmixGains {
float center;
float surround;
float lfe;
float normalize;
};
inline StereoDownmixGains GetStereoDownmixGains() {
// The cutscene gains engage while demo-wrapper ticks are fresh; f slews
// over ramp_ms so fold changes never step. Known cosmetic (accepted): the
// wrapper keeps ticking through the gallery's menu->scene transitions, so
// the gallery's front-weighted transition SFX plays through the
// fronts-only fold hot; campaign flows are unaffected.
const int64_t now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
const int64_t last_tick =
g_last_cinematic_audio_tick_ms.load(std::memory_order_relaxed);
const bool engaged = REXCVAR_GET(audio_cutscene_downmix) &&
last_tick != INT64_MIN && (now_ms - last_tick) <= 250;
static float f_state = 0.0f; // 0 = base fold, 1 = full cutscene gains
static int64_t f_last_ms = INT64_MIN;
const double ramp = std::max(1.0, REXCVAR_GET(audio_downmix_cutscene_ramp_ms));
float step = 1.0f;
if (f_last_ms != INT64_MIN && now_ms >= f_last_ms) {
step = float(std::min(1.0, double(now_ms - f_last_ms) / ramp));
}
f_last_ms = now_ms;
const float target = engaged ? 1.0f : 0.0f;
if (target > f_state) {
f_state = std::min(target, f_state + step);
} else {
f_state = std::max(target, f_state - step);
}
const float f = f_state;
auto clamp_gain = [](double v) {
return std::min(2.0f, std::max(0.0f, float(v)));
};
auto blend = [&](double cutscene_value, double base_value) {
const float base = clamp_gain(base_value);
const float cut =
cutscene_value >= 0.0 ? clamp_gain(cutscene_value) : base;
return base + (cut - base) * f;
};
StereoDownmixGains gains;
gains.center = blend(REXCVAR_GET(audio_downmix_cutscene_center_gain),
REXCVAR_GET(audio_downmix_center_gain));
gains.surround = blend(REXCVAR_GET(audio_downmix_cutscene_surround_gain),
REXCVAR_GET(audio_downmix_surround_gain));
gains.lfe = blend(REXCVAR_GET(audio_downmix_cutscene_lfe_gain),
REXCVAR_GET(audio_downmix_lfe_gain));
// Normalization follows the live gains so loudness stays consistent at any
// setting; at the stock base gains this equals the old fixed constant, so
// gameplay output is bit-identical. The near-copy cutscene mixes are
// self-correcting under it (fold of N unity-ish copies divided by the gain
// sum lands at the same level whichever channels fold); the measured
// residual vs the old fold is +0.6 dB, cancelled by the default trim.
gains.normalize = 1.0f / (1.0f + gains.center + gains.surround + gains.lfe);
const float trim = std::min(
2.0f, std::max(0.0f, float(REXCVAR_GET(audio_downmix_cutscene_trim))));
gains.normalize *= 1.0f + (trim - 1.0f) * f;
return gains;
}
inline float SanitizeGuestAudioSample(float sample) {
if (!std::isfinite(sample)) {
return 0.0f;
@@ -70,10 +172,11 @@ inline void sequential_6_BE_to_interleaved_2_LE(float* output, const float* inpu
const __m128i byte_swap_shuffle =
_mm_set_epi8(12, 13, 14, 15, 8, 9, 10, 11, 4, 5, 6, 7, 0, 1, 2, 3);
const __m128 center_gain = _mm_set1_ps(kStereoDownmixCenterGain);
const __m128 surround_gain = _mm_set1_ps(kStereoDownmixSurroundGain);
const __m128 lfe_gain = _mm_set1_ps(kStereoDownmixLfeGain);
const __m128 normalize = _mm_set1_ps(kStereoDownmixNormalize);
const StereoDownmixGains live_gains = GetStereoDownmixGains();
const __m128 center_gain = _mm_set1_ps(live_gains.center);
const __m128 surround_gain = _mm_set1_ps(live_gains.surround);
const __m128 lfe_gain = _mm_set1_ps(live_gains.lfe);
const __m128 normalize = _mm_set1_ps(live_gains.normalize);
const __m128 peak_headroom = _mm_set1_ps(kStereoDownmixPeakHeadroom);
const __m128 sign_mask = _mm_set1_ps(-0.0f);
@@ -19,6 +19,47 @@ REXCVAR_DEFINE_BOOL(audio_trace_render_driver_verbose, false, "Audio",
"Trace render-driver activity");
REXCVAR_DEFINE_BOOL(audio_deep_trace, false, "Audio",
"Enable verbose runtime audio tracing");
REXCVAR_DEFINE_BOOL(audio_cutscene_downmix, true, "Audio",
"Master switch for the cutscene-specific stereo fold-down "
"(fronts-only fold while the demo sequencer is active - "
"removes the doubled/combed dialogue caused by summing the "
"cutscene mixer's six decorrelated near-copies to stereo). "
"false = the original summing fold everywhere; the "
"audio_downmix_cutscene_* cvars then have no effect. "
"Gameplay audio is identical either way.");
REXCVAR_DEFINE_DOUBLE(audio_downmix_center_gain, 0.70710678, "Audio",
"Front-center gain in the 6ch-to-stereo fold-down (was a "
"compile-time constant; default unchanged). Normalization "
"follows the live gains. Read continuously.");
REXCVAR_DEFINE_DOUBLE(audio_downmix_surround_gain, 0.5, "Audio",
"Rear-channel gain in the 6ch-to-stereo fold-down. See "
"audio_downmix_center_gain.");
REXCVAR_DEFINE_DOUBLE(audio_downmix_lfe_gain, 0.0, "Audio",
"LFE gain in the 6ch-to-stereo fold-down. See "
"audio_downmix_center_gain.");
REXCVAR_DEFINE_DOUBLE(audio_downmix_cutscene_center_gain, 0.0, "Audio",
"Front-center fold-down gain used ONLY while an in-engine "
"cutscene is playing (demo sequencer active); negative = "
"inherit audio_downmix_center_gain. AC6's cutscene mixer "
"spreads the premix across ALL six speaker slots as "
"decorrelated near-copies of one mix; the fronts alone "
"carry the complete mix, so the cutscene fold takes only "
"them - center and surround default to 0 here.");
REXCVAR_DEFINE_DOUBLE(audio_downmix_cutscene_surround_gain, 0.0, "Audio",
"Rear-channel fold-down gain during in-engine cutscenes; "
"negative = inherit. See audio_downmix_cutscene_center_gain.");
REXCVAR_DEFINE_DOUBLE(audio_downmix_cutscene_lfe_gain, -1.0, "Audio",
"LFE fold-down gain during in-engine cutscenes; negative = "
"inherit. See audio_downmix_cutscene_center_gain.");
REXCVAR_DEFINE_DOUBLE(audio_downmix_cutscene_trim, 0.933, "Audio",
"Extra output gain on the cutscene fold (ramped in with "
"the cutscene gains). Default 0.933 = the measured RMS "
"ratio of the fronts-only fold vs the stock summing fold "
"(-0.6 dB), so cutscene loudness matches the original "
"baseline; 1.0 = no trim.");
REXCVAR_DEFINE_DOUBLE(audio_downmix_cutscene_ramp_ms, 250.0, "Audio",
"Slew time between the base fold and the cutscene gains "
"when the demo-sequencer signal engages or releases.");
REXCVAR_DEFINE_BOOL(audio_xma_loop_guard, true, "Audio",
"Require a real loop window (loop_start < loop_end, "
"read_offset >= loop_end) before engaging XMA loop "