diff --git a/docs/building.md b/docs/building.md index 36524f67..b49ee7d2 100644 --- a/docs/building.md +++ b/docs/building.md @@ -358,6 +358,14 @@ frame, uneven frame times one steady step, and game time follows the clock. It c sites do: at 144 fps each of 64 water objects gets its ripple steps and the day its seconds, and a step of one frame gives what the game computes. No game files. +A change to how the mixer applies volumes (`runtime/audio_mixer.c` `accumulate_ramp`, the voice and splitter paths +of `audio_mixer_render`) passes `python tests\audio_mixer_check.py`, which builds `audio_mixer.c` with the rest of +the runtime stubbed and mixes voices of one constant sample, so a mix buffer shows the gain sample by sample: a +volume stepped from 0 to 1 rises in equal steps across the next 5 ms frame and holds after it, for a voice into a mix, +a voice into a splitter destination and a submix into one; a voice that starts, was silent in the frame before or was +moved to another mix, a destination that is new or was moved, and the first frame after the renderer starts take the +new volume at once; `NX2NAVI_AUDIO_RAMP=0` applies every volume at once. No game files and no audio device. + A change to the mouse camera (`runtime/mouse_direct.c`, the mouse path of `runtime/input.c`, the set `mouse_direct` of `recomp/patches-v111.txt` or `patches-v143.txt`) passes `python tests\mouse_direct_check.py --cc --deps \include`, which builds `mouse_direct.c`, and `input.c` with `--deps`, with the rest of the runtime stubbed and diff --git a/nx2navi.ini.example b/nx2navi.ini.example index 6a1335f8..d39a68b3 100644 --- a/nx2navi.ini.example +++ b/nx2navi.ini.example @@ -713,6 +713,10 @@ # Host priority of the game's audio thread: 0 leaves it alone, 1 raises it to the highest while its audio update is # read (the audio render thread waits for that), 2 also keeps it above normal. Takes effect at the next start. # audio_game_prio = 0 +# When the game changes a sound's volume, go from the old volume to the new one across the 5 ms of sound that follow, +# as the console does (1, the default). 0 applies the new volume at once, which clicks when a volume moves quickly (a +# fade, a sound passing by). Takes effect at the next start. +# audio_ramp = 1 # Volume in percent (0 to 100), on top of the game's own. Changes while the game runs take effect at once. # volume = 100 # Speakers: stereo, 5.1 or auto (5.1 when the Windows speaker setup has six or more channels, stereo otherwise). The diff --git a/runtime/audio_mixer.c b/runtime/audio_mixer.c index 0708e01a..5a0b9ebf 100644 --- a/runtime/audio_mixer.c +++ b/runtime/audio_mixer.c @@ -5,7 +5,8 @@ // // Per 5 ms frame the render thread (audren.c, renderer lock held) decodes every started voice (PCM8/16/24/32, float, // DSP-ADPCM) along the sample path the voice clock consumes, resamples it linearly, runs the voice biquads, scales it -// into its destination mix, folds the submixes, runs the mix effects and writes the device sink's mix (stereo, or 5.1 +// into its destination mix (a volume the game changed is reached in equal steps across the frame's samples, +// accumulate_ramp), folds the submixes, runs the mix effects and writes the device sink's mix (stereo, or 5.1 // with NX2NAVI_AUDIO_CHANNELS, output_channels) into a single-producer single-consumer ring. The SDL audio thread drains // that ring without taking any lock: an empty ring // plays silence and never blocks the render thread or the guest. NX2NAVI_AUDIO=none (or NX2NAVI_AUDIO_OFF) mixes without a @@ -41,8 +42,15 @@ #define SPLITTER_UNUSED 0xffffffffu #define dm_guest_write_prepare(p, n) ((void)(p), (void)(n)) // guest memory is plain host memory on Windows -typedef struct { float vol[24]; int32_t dest; uint8_t used; AudrenBiquadFloat biquads[2]; float state[2][2]; } SplitDest; +typedef struct { + float vol[24]; int32_t dest; uint8_t used; AudrenBiquadFloat biquads[2]; float state[2][2]; + // volume ramps (audio_mixer_render): the volumes it had in the render pass before, the mix it went to and how many + // of that mix's buffers; prev_ok 0 when that pass did not use it + float prev[24]; int32_t prev_dest, prev_buffers; uint8_t prev_ok; +} SplitDest; typedef struct { int32_t ndest; int32_t *ids; } SplitInfo; +// volume ramps: a mix in the render pass before: its volume, the splitter it went to, its buffer count, whether in use +typedef struct { float volume; uint32_t splitter; int32_t buffers; uint8_t ok; } MixPrev; typedef struct { Reverb3d *reverb; Reverb3dParam param; int pending; float *delay, detector[6], compression[6], peak[6], minimum[6]; int position[6], delay_max, channels; @@ -73,6 +81,10 @@ typedef struct Mixer { double level_sum, level_peak; uint64_t level_count, level_frames; int first_sound_logged; double input_sum[6]; // NX2NAVI_TRACE=audio: the device sink's inputs, squared and summed int prefill; // render blocks + int ramp; // NX2NAVI_AUDIO_RAMP: volumes ramp across a frame (accumulate_ramp) + int snap; // the next pass has no pass before it to ramp from (open, start) + MixPrev *mix_prev; // each mix in the pass before + uint64_t ramps, ramp_passes; // ramps made; render passes with at least one (audio_mixer_ramp_stats) uint64_t trace_pass, trace_seen[1024]; // NX2NAVI_TRACE=audio: render passes; the pass each voice was last mixed in uint64_t trace_buf[1024]; // and the wave buffer address it read in that pass uint64_t trace_pair[1024]; // the last shared-buffer pair logged for each voice (other voice, address) @@ -571,7 +583,8 @@ void audio_mixer_open(AudioRenderer *r) { m->ndest = r->param.splitter_destination_count; m->split = calloc((size_t)(m->nsplit ? m->nsplit : 1), sizeof *m->split); m->dest = calloc((size_t)(m->ndest ? m->ndest : 1), sizeof *m->dest); - if (!m->buf || !m->offset || !m->split || !m->dest) rt_fatal("audio mixer tables"); + m->mix_prev = calloc((size_t)r->param.submix_count + 1, sizeof *m->mix_prev); + if (!m->buf || !m->offset || !m->split || !m->dest || !m->mix_prev) rt_fatal("audio mixer tables"); m->effect = calloc((size_t)(r->param.effect_count ? r->param.effect_count : 1), sizeof *m->effect); m->effect_order = calloc((size_t)(r->param.effect_count ? r->param.effect_count : 1), sizeof *m->effect_order); if (!m->effect || !m->effect_order) rt_fatal("audio effect tables"); @@ -590,6 +603,10 @@ void audio_mixer_open(AudioRenderer *r) { m->prefill = PREFILL_DEFAULT_MS / 5; } m->channels = output_channels(); + m->ramp = cfg_bool("NX2NAVI_AUDIO_RAMP"); + m->snap = 1; + rt_log("audio: NX2NAVI_AUDIO_RAMP=%d: a changed volume %s", m->ramp, + m->ramp ? "is reached in equal steps across the frame" : "applies to the whole frame at once"); r->mixer = m; atomic_store(&m->playing, r->state == 0); output_open(m, r->param.sample_rate); @@ -601,6 +618,8 @@ void audio_mixer_set_running(AudioRenderer *r, int running) { if (!running) { atomic_store_explicit(&m->flush_until, atomic_load_explicit(&m->wr, memory_order_acquire), memory_order_release); atomic_fetch_add_explicit(&m->stop_epoch, 1, memory_order_release); + } else { + m->snap = 1; // what was mixed before the stop is not the sample before the next pass's first } atomic_store_explicit(&m->playing, running, memory_order_release); } @@ -627,6 +646,7 @@ void audio_mixer_close(AudioRenderer *r) { free(m->effect_order); free(m->buf); free(m->offset); + free(m->mix_prev); free(m->split); free(m->dest); free(m); @@ -844,6 +864,29 @@ static void accumulate(Mixer *m, int buffer, const float *src, float vol, int fr if (vol == 0 || buffer < 0 || buffer >= m->nbuf) return; float *d = mixbuf(m, buffer); for (int k = 0; k < frames; k++) d[k] += src[k] * vol; } +// Volume ramps (NX2NAVI_AUDIO_RAMP, default 1). The game sets volumes once per audio update, and a volume applied to a +// whole frame as one constant makes the level jump at the frame's first sample whenever the game moves it (a fade, a +// pan, a sound going past the camera): a step in the waveform, heard as a click. The console's renderer goes from the +// volume of the frame before to the new one across the frame's samples, and so does this: `from` is the gain the same +// source went into this buffer with in the render pass before, sample k gets from + k (to - from) / frames, and the +// next pass, which starts at `to`, continues the line. Equal gains take accumulate's constant path, and so does a gain +// that is not a finite number (NaN also marks "no gain in the pass before"). +static void accumulate_ramp(Mixer *m, int buffer, const float *src, float from, float to, int frames) { + if (from == to || !isfinite(from) || !isfinite(to)) { accumulate(m, buffer, src, to, frames); return; } + if (buffer < 0 || buffer >= m->nbuf) return; + const float step = (to - from) / (float)frames; + float *d = mixbuf(m, buffer), gain = from; + for (int k = 0; k < frames; k++) { d[k] += src[k] * gain; gain += step; } + m->ramps++; +} +// A splitter destination ramps when the pass before used it and sent it to the same mix: one that is new or was moved +// starts at its new volumes. +static int dest_ramps(const SplitDest *d) { return d->prev_ok && d->prev_dest == d->dest; } +void audio_mixer_ramp_stats(const AudioRenderer *r, uint64_t *passes, uint64_t *ramps) { + const Mixer *m = r->mixer; + *passes = m ? m->ramp_passes : 0; + *ramps = m ? m->ramps : 0; +} static int mix_valid(AudioRenderer *r, int32_t id) { return id >= 0 && id <= r->param.submix_count && r->mixes[id].in_use; } static int mix_depth(AudioRenderer *r, int id) { int d = 0; @@ -1046,6 +1089,10 @@ void audio_mixer_render(AudioRenderer *r, double seconds) { float (*vs)[MAX_FRAME]=m->voice_scratch,*filtered=m->filter_scratch; m->trace_pass++; + // A pass ramps from the pass before it. The first one after the mixer opened or the renderer started has none. + const int ramp = m->ramp && !m->snap; + const uint64_t ramps_before = m->ramps; + m->snap = 0; for (int i = 0; i < r->param.voice_count; i++) { ServerVoice *v = &r->voices[i]; if (!v->in.in_use || v->state != SV_STARTED || !v->in.sample_rate || !v->valid[v->index]) continue; @@ -1089,21 +1136,50 @@ void audio_mixer_render(AudioRenderer *r, double seconds) { } } } + // The voice ramps when the pass before mixed it, with as many channels and into the same mix or splitter. One + // that has just started (voice_reset in audren.c; or stopped, paused or out of wave buffers in that pass) and + // one the game moved to another destination start at their new volumes. + const int vramp = ramp && v->ramp_pass == r->elapsed_frames && v->ramp_channels == (uint32_t)cc && + v->ramp_mix == v->in.mix_id && v->ramp_splitter == v->in.splitter_id; + int routed = 0; if (v->in.splitter_id != SPLITTER_UNUSED && v->in.splitter_id < (uint32_t)m->nsplit) { const SplitInfo *s = &m->split[v->in.splitter_id]; for (int c = 0; c < cc; c++) for (int k = c; k < s->ndest; k += cc) { SplitDest *d = &m->dest[s->ids[k]]; if (!d->used || !mix_valid(r, d->dest)) continue; const float *source=splitter_filter(d,vs[c],filtered,frames); - for (int b = 0; b < r->mixes[d->dest].buffer_count && b < 24; b++) accumulate(m, m->offset[d->dest] + b, source, d->vol[b] * v->in.volume, frames); + // from the destination's volumes and the voice's volume of the pass before, whose product is the gain + // that pass mixed with (the destination's are kept once per pass: other voices may feed it too) + const int from_prev = vramp && dest_ramps(d); + for (int b = 0; b < r->mixes[d->dest].buffer_count && b < 24; b++) { + const float to = d->vol[b] * v->in.volume; + accumulate_ramp(m, m->offset[d->dest] + b, source, from_prev && b < d->prev_buffers ? d->prev[b] * v->ramp_volume : to, to, frames); + } } + routed = 1; } else if (mix_valid(r, v->in.mix_id)) { + const int nb = r->mixes[v->in.mix_id].buffer_count < 24 ? r->mixes[v->in.mix_id].buffer_count : 24; for (int c = 0; c < cc; c++) { - int32_t id = v->in.channel_ids[c]; if (id < 0 || id >= r->param.voice_count * 6) continue; + int32_t id = v->in.channel_ids[c]; + if (id < 0 || id >= r->param.voice_count * 6) { // nothing goes in: no gain for the next pass to ramp from + for (int b = 0; b < nb; b++) v->ramp_gain[c][b] = NAN; + continue; + } const AudrenChannelIn *ch = &r->channels[id]; - for (int b = 0; b < r->mixes[v->in.mix_id].buffer_count && b < 24; b++) accumulate(m, m->offset[v->in.mix_id] + b, vs[c], ch->mix[b] * v->in.volume, frames); + for (int b = 0; b < nb; b++) { + const float to = ch->mix[b] * v->in.volume; + accumulate_ramp(m, m->offset[v->in.mix_id] + b, vs[c], vramp && b < v->ramp_buffers ? v->ramp_gain[c][b] : to, to, frames); + v->ramp_gain[c][b] = to; + } } + v->ramp_buffers = nb; + routed = 1; } + v->ramp_pass = routed ? r->elapsed_frames + 1 : 0; // audren.c render_thread counts the pass after this render + v->ramp_channels = (uint32_t)cc; + v->ramp_mix = v->in.mix_id; + v->ramp_splitter = v->in.splitter_id; + v->ramp_volume = v->in.volume; } if (rt_trace & RT_TRACE_AUDIO) { // Voices mixed in the last pass and not in this one, and why; then voices that read the same wave buffer memory @@ -1139,17 +1215,38 @@ void audio_mixer_render(AudioRenderer *r, double seconds) { const AudrenMixIn *x = &r->mixes[order[a]]; int src = m->offset[order[a]]; if (x->dest_splitter_id != SPLITTER_UNUSED && x->dest_splitter_id < (uint32_t)m->nsplit) { const SplitInfo *s = &m->split[x->dest_splitter_id]; + // a mix into a splitter ramps like a voice into one: from the destination's volumes and the mix's volume + // of the pass before, when that pass had the mix with as many buffers on the same splitter + const MixPrev *xp = &m->mix_prev[order[a]]; + const int xramp = ramp && xp->ok && xp->splitter == x->dest_splitter_id && xp->buffers == x->buffer_count; for (int c = 0; c < x->buffer_count && c < 24; c++) for (int k = c; k < s->ndest; k += x->buffer_count) { SplitDest *d = &m->dest[s->ids[k]]; if (!d->used || !mix_valid(r, d->dest)) continue; const float *source=splitter_filter(d,mixbuf(m,src+c),filtered,frames); - for (int b = 0; b < r->mixes[d->dest].buffer_count && b < 24; b++) accumulate(m, m->offset[d->dest] + b, source, d->vol[b] * x->volume, frames); + const int from_prev = xramp && dest_ramps(d); + for (int b = 0; b < r->mixes[d->dest].buffer_count && b < 24; b++) { + const float to = d->vol[b] * x->volume; + accumulate_ramp(m, m->offset[d->dest] + b, source, from_prev && b < d->prev_buffers ? d->prev[b] * xp->volume : to, to, frames); + } } } else if (mix_valid(r, x->dest_mix_id)) { for (int c = 0; c < x->buffer_count && c < 24; c++) for (int b = 0; b < r->mixes[x->dest_mix_id].buffer_count && b < 24; b++) accumulate(m, m->offset[x->dest_mix_id] + b, mixbuf(m, src + c), x->mix[c][b] * x->volume, frames); } } + // What every splitter destination and every mix went in with, for the next pass's ramps: kept here, once per pass, + // after every voice and mix that feeds a destination has read the values of the pass before. + for (int i = 0; i < m->ndest; i++) { + SplitDest *d = &m->dest[i]; + d->prev_ok = d->used && mix_valid(r, d->dest); + if (!d->prev_ok) continue; + memcpy(d->prev, d->vol, sizeof d->prev); + d->prev_dest = d->dest; + d->prev_buffers = r->mixes[d->dest].buffer_count; + } + for (int i = 1; i < mc; i++) + m->mix_prev[i] = (MixPrev){r->mixes[i].volume, r->mixes[i].dest_splitter_id, r->mixes[i].buffer_count, r->mixes[i].in_use != 0}; + if (m->ramps != ramps_before) m->ramp_passes++; if (r->mixes[0].in_use) mix_effects(r, 0); /* device sink */ const AudrenSinkIn *sink = NULL;int circular=0; diff --git a/runtime/audren.c b/runtime/audren.c index b2c5617b..ad1c7af9 100644 --- a/runtime/audren.c +++ b/runtime/audren.c @@ -47,6 +47,7 @@ typedef struct AudrenStats { upd_hold_max_ns; // voices (under the lock) uint64_t dry, late[3], late_max; // next buffer late by 1-2 passes, 3-8, 9-AS_LATE_PASSES + uint64_t ramp_passes, ramps; // the mixer's volume ramp counts at the window's start (audio_mixer_ramp_stats) int events; uint64_t voices_ns; // the game's update (under the lock): the last voice census line int voices_most; // the most voices in use a census found @@ -132,6 +133,7 @@ static void voice_reset(ServerVoice *v) { memset(&v->adpcm, 0, sizeof v->adpcm); memset(v->biquad_state, 0, sizeof v->biquad_state); v->dry_pass = 0; + v->ramp_pass = 0; // a new voice starts at its volumes: no ramp from what the slot's last voice was mixed with } static void update_voice(AudioRenderer *r, const AudrenVoiceIn *in) { @@ -471,7 +473,9 @@ static void stats_report(AudioRenderer *r, uint64_t now, uint64_t lock_ns, uint6 int dev = audio_mixer_out_stats(r, &o); char event[320] = ""; int window = 0; + uint64_t ramp_passes, ramps; plat_mutex_lock(&r->lock); + audio_mixer_ramp_stats(r, &ramp_passes, &ramps); if (dev && o.pull_gap_max_ns > st->pull_gap_max_ns) st->pull_gap_max_ns = o.pull_gap_max_ns; if (dev && o.underruns > st->seen_underruns) { if (st->events < AS_EVENTS) { @@ -490,6 +494,7 @@ static void stats_report(AudioRenderer *r, uint64_t now, uint64_t lock_ns, uint6 st->window_ns = now; st->out_underruns = o.underruns, st->out_missing = o.missing_frames, st->out_skipped = o.skipped_frames; st->out_overruns = o.overruns; + st->ramp_passes = ramp_passes, st->ramps = ramps; } if (now - st->window_ns >= AS_WINDOW_NS) { snap = *st; @@ -500,6 +505,7 @@ static void stats_report(AudioRenderer *r, uint64_t now, uint64_t lock_ns, uint6 st->queue_min = UINT64_MAX; st->out_underruns = o.underruns, st->out_missing = o.missing_frames, st->out_skipped = o.skipped_frames; st->out_overruns = o.overruns; + st->ramp_passes = ramp_passes, st->ramps = ramps; st->upd_n = 0; // upd_last_ns and upd_pass_last stay: the next gap is measured from the last update st->upd_gap_max_ns = st->upd_gap_frame = st->upd_gaps_over = st->upd_passes_max = st->upd_hold_max_ns = 0; st->dry = st->late[0] = st->late[1] = st->late[2] = st->late_max = 0; @@ -516,7 +522,8 @@ static void stats_report(AudioRenderer *r, uint64_t now, uint64_t lock_ns, uint6 "frames %llu, ring at least %.1f ms, longest device request gap %.1f ms; render passes %llu, gaps over %llu " "ms %llu (longest %.1f ms), lock waits over %llu ms %llu (longest %.2f ms); game updates %llu, gaps over " "%llu ms %llu (longest %.1f ms at game frame %llu, %llu passes), longest held the lock %.2f ms; voices ran " - "dry %llu, next buffer late by 1-2 passes %llu, 3-8 %llu, 9-%d %llu (most %llu)", + "dry %llu, next buffer late by 1-2 passes %llu, 3-8 %llu, 9-%d %llu (most %llu); render passes that ramped a " + "volume %llu (%llu ramps)", (now - snap.window_ns) / 1e9, (unsigned long long)game_frame(), (unsigned long long)under, (o.missing_frames - snap.out_missing) * 1000.0 / rate, (o.skipped_frames - snap.out_skipped) * 1000.0 / rate, (unsigned long long)(o.overruns - snap.out_overruns), @@ -527,7 +534,8 @@ static void stats_report(AudioRenderer *r, uint64_t now, uint64_t lock_ns, uint6 (unsigned long long)snap.upd_gaps_over, snap.upd_gap_max_ns / 1e6, (unsigned long long)snap.upd_gap_frame, (unsigned long long)snap.upd_passes_max, snap.upd_hold_max_ns / 1e6, (unsigned long long)snap.dry, (unsigned long long)snap.late[0], (unsigned long long)snap.late[1], AS_LATE_PASSES, - (unsigned long long)snap.late[2], (unsigned long long)snap.late_max); + (unsigned long long)snap.late[2], (unsigned long long)snap.late_max, + (unsigned long long)(ramp_passes - snap.ramp_passes), (unsigned long long)(ramps - snap.ramps)); } static void render_thread(void *p) { diff --git a/runtime/audren.h b/runtime/audren.h index 4f3d861e..56abf38c 100644 --- a/runtime/audren.h +++ b/runtime/audren.h @@ -147,6 +147,15 @@ typedef struct { float biquad_state[2][6][2]; // filter, channel: transposed direct-form II delays uint64_t dry_pass; // NX2NAVI_AUDIO_STATS: 1 + the render pass in which the voice ran out of queued // buffers before its end of stream (audren.c advance_voice), 0 otherwise + // Volume ramps (audio_mixer.c audio_mixer_render): what the voice was mixed with in its last render pass. The pass + // after it starts every gain there and reaches the new volumes at its end; voice_reset (audren.c) forgets it. + uint64_t ramp_pass; // 1 + the render pass the voice was last mixed in, 0 when it has none to ramp from + int32_t ramp_mix; // the mix and + uint32_t ramp_splitter; // the splitter it went to then, and + uint32_t ramp_channels; // its channel count: a pass with another of these starts at the new volumes + int32_t ramp_buffers; // into a mix: how many of the mix's buffers ramp_gain holds + float ramp_volume; // into a splitter: the voice's volume then (the destinations keep their own) + float ramp_gain[6][24]; // into a mix: the gain channel c went into buffer b with (NaN: nothing went in) } ServerVoice; enum { SV_STOPPED, SV_STARTED, SV_PAUSED, SV_STOPPING }; @@ -191,6 +200,9 @@ int64_t audio_mixer_pace_ns(AudioRenderer *r, uint64_t now); // the last call (which starts that measure again). False without a device. typedef struct { uint64_t underruns, missing_frames, skipped_frames, overruns, queued_frames, pull_gap_max_ns; } AudioOutStats; int audio_mixer_out_stats(AudioRenderer *r, AudioOutStats *o); +// NX2NAVI_AUDIO_STATS: the render passes in which at least one volume was ramped (NX2NAVI_AUDIO_RAMP), and the ramps (one +// source channel into one mix buffer), since the mixer opened. +void audio_mixer_ramp_stats(const AudioRenderer *r, uint64_t *passes, uint64_t *ramps); uint64_t audio_voice_consumed(const AudioRenderer *r, ServerVoice *v, int frames); // source samples over one frame; advances v->fraction int audio_buffer_empty(const AudrenWaveBuffer *b); // no samples to decode: played as soon as it is reached void audio_device_handler(IpcSession *s, const IpcRequest *rq, IpcResponse *rs); // IAudioDevice diff --git a/runtime/cfg.c b/runtime/cfg.c index 5204429b..c88680aa 100644 --- a/runtime/cfg.c +++ b/runtime/cfg.c @@ -35,7 +35,7 @@ static int save_ok = 1; // the last cfg_save_to wrote (o // logged as unknown. A new variable goes here too, or its ini key logs a harmless "unknown key" line. static const char *const known[] = { "CORES", "TM_DISPATCH", "NVN2_CS_TABLE", "SKIP_SHADERS", "MOVIE_DEBAND", "MOVIE_FPS", "MOVIE_SKIP", "MOVIE_OVERLAY", "MOVIES", "NPLN", "NVN2_PROGRAMS", "NVN_TRAP", "OCARINA_REALTIME", "ONESHOT_BUDGET", "ONESHOT_WAIT_MS", "OCARINA_STEP", "PHYSICS_STEPS", "PICKUP_COUNT", "STEP_STEADY", "VI_HZ", "VI_VRR", "TIME_DUMP", "SHADER_DUMP", "SHOT_AFTER", "SHOT_DIR", "SHOT_EVERY", // Switch 2 port: movie skip (romfs.c), NVN2 proc traps (nvn_procs.c) - "AA", "AA_LOG", "AB", "AMIIBO_DIR", "ANISO", "APP_POOL", "ASPECT", "ASPECT_PATCH", "ASSUME_FOCUS", "ASTC", "ASTC_BC", "ASTC_TIME", "ASYNC_PREEMPT", "AUDIO", "AUDIO_CHANNELS", "AUDIO_DEVICE", "AUDIO_GAME_PRIO", "AUDIO_OFF", "AUDIO_PREFILL_MS", "AUDIO_STATS", + "AA", "AA_LOG", "AB", "AMIIBO_DIR", "ANISO", "APP_POOL", "ASPECT", "ASPECT_PATCH", "ASSUME_FOCUS", "ASTC", "ASTC_BC", "ASTC_TIME", "ASYNC_PREEMPT", "AUDIO", "AUDIO_CHANNELS", "AUDIO_DEVICE", "AUDIO_GAME_PRIO", "AUDIO_OFF", "AUDIO_PREFILL_MS", "AUDIO_RAMP", "AUDIO_STATS", "BARRIER_GAME", "MESH_AHEAD", "MESH_FALLBACK", "MESH_FALLBACK_MB", "MESH_FALLBACK_WORKERS", "PROBE_MESHFB_DIR", "BACKGROUND_WINDOW", "BIND_A", "BIND_B", "BIND_DOWN", "BIND_L", "BIND_LCLICK", "BIND_LEFT", "BIND_LSTICK_DOWN", "BIND_LSTICK_LEFT", "BIND_LSTICK_RIGHT", "BIND_LSTICK_UP", "BIND_MINUS", @@ -121,6 +121,7 @@ static const struct { const char *key, *section; } sections[] = { {"MODS_DIR", "mods"}, {"MODS_ORDER", "mods"}, {"MODS_DISABLED", "mods"}, {"MODS_REBUILD", "mods"}, {"MENU_LANG", "language"}, {"LANG", "language"}, {"OPERATION_MODE", "language"}, {"VOLUME", "audio"}, {"AUDIO_CHANNELS", "audio"}, {"AUDIO_DEVICE", "audio"}, {"MICROPHONE", "audio"}, {"MENU_SOUND_VOLUME", "audio"}, + {"AUDIO_RAMP", "audio"}, {"MOTION", "input"}, {"RUMBLE_STRENGTH", "input"}, {"RUMBLE_CURVE", "input"}, {"RUMBLE_TRIGGERS", "input"}, {"DS3", "input"}, {"PAD_LAYOUT", "input"}, {"PROMPTS", "input"}, {"STICK_DEADZONE", "input"}, {"LSTICK_DEADZONE", "input"}, {"RSTICK_DEADZONE", "input"}, {"STICK_SENS", "input"}, {"DEBUG_MENU", "input"}, @@ -231,6 +232,7 @@ static const CfgMeta metas[] = { {"VOLUME", "100", "0..100", LIVE}, {"MENU_SOUND_VOLUME", "80", "0..100", LIVE}, {"AUDIO_CHANNELS", "stereo", "auto|stereo|5.1", LIVE}, // audio_mixer.c audio_channels_live {"AUDIO_DEVICE", "default", NULL, RESTART}, // default|<1-based index>| (audio_mixer.c resolve_audio_device) {"MICROPHONE", "1", "0|1", RESTART}, // ov_audio_input.c mic_on: 0 refuses the game's input streams + {"AUDIO_RAMP", "1", "0|1", RESTART}, // audio_mixer.c accumulate_ramp: a changed volume is reached across the frame {"MOTION", "1", "0|1", LIVE}, {"RUMBLE_STRENGTH", "130", "0..300", LIVE}, {"RUMBLE_CURVE", "0.6", "0.2..1.5", LIVE}, {"RUMBLE_TRIGGERS", "0", "0|1", LIVE}, {"PAD_LAYOUT", "nintendo", "auto|nintendo|xbox|playstation", LIVE}, {"DS3", "0", "0|1", LIVE}, // input_ds3_win.c ds3_read: read a DualShock 3 directly; off: no HID scan diff --git a/tests/audio_mixer_check.c b/tests/audio_mixer_check.c new file mode 100644 index 00000000..040f119e --- /dev/null +++ b/tests/audio_mixer_check.c @@ -0,0 +1,293 @@ +/* The mixer's volume ramps (runtime/audio_mixer.c accumulate_ramp, audio_mixer_render), with the rest of the runtime + stubbed. No game data and no audio device: NX2NAVI_AUDIO=none mixes without opening one. Each voice plays a wave + buffer of one constant sample, so a mix buffer holds the gain the voice went in with, sample by sample. */ +#include "../runtime/audio_mixer.c" + +#include + +static int fails; +#define CHECK(ok, ...) do { if (!(ok)) { fails++; printf("FAIL %s:%d: ", __func__, __LINE__); printf(__VA_ARGS__); printf("\n"); } } while (0) + +/* ---- the runtime around the mixer ---- */ +static int ramp_setting = 1; +unsigned rt_trace; +void rt_log(const char *fmt, ...) { (void)fmt; } +void rt_fatal(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + vprintf(fmt, ap); + va_end(ap); + printf("\n"); + exit(2); +} +uint64_t kern_now_ns(void) { return 1; } +uint32_t kern_event_new(void) { return 1; } +uint32_t kern_event_readable(uint32_t writable) { return writable; } +void kern_event_signal(uint32_t writable) { (void)writable; } +void *ipc_guest(uint64_t addr, uint64_t size) { (void)size; return (void *)(uintptr_t)addr; } +uint32_t ipc_session_new(const char *name, IpcHandler handler, void *state, void (*close)(void *state)) { + (void)name, (void)handler, (void)state, (void)close; + return 1; +} +void ipc_unimplemented(const IpcSession *s, const IpcRequest *rq) { (void)s, (void)rq; exit(2); } +void ipc_out(IpcResponse *rs, const void *p, uint32_t n) { (void)rs, (void)p, (void)n; } +const void *ipc_arg(const IpcRequest *rq, uint32_t off, uint32_t n) { (void)rq, (void)off, (void)n; exit(2); } +void menu_sound_mix(float *out, uint32_t frames, int channels, int rate) { (void)out, (void)frames, (void)channels, (void)rate; } +const char *cfg_get(const char *name) { return !strcmp(name, "NX2NAVI_AUDIO") ? "none" : NULL; } +const char *cfg_value(const char *name) { return !strcmp(name, "NX2NAVI_AUDIO_CHANNELS") ? "stereo" : "default"; } +int cfg_bool(const char *name) { + if (!strcmp(name, "NX2NAVI_AUDIO_RAMP")) return ramp_setting; + printf("unexpected setting %s\n", name); + exit(2); +} +double cfg_double(const char *name) { (void)name; return 100; } +unsigned cfg_version(void) { return 1; } +int audio_buffer_empty(const AudrenWaveBuffer *b) { return !b->address || !b->size || b->end <= b->start; } +/* no effects in these renderers */ +AudioEffectDsp *audio_effect_create(unsigned type, const void *param, unsigned revision) { (void)type, (void)param, (void)revision; return NULL; } +void audio_effect_free(AudioEffectDsp *s) { (void)s; } +void audio_effect_update(AudioEffectDsp *s, const void *param) { (void)s, (void)param; } +void audio_effect_process(AudioEffectDsp *s, float *const input[6], float *const output[6], int frames) { (void)s, (void)input, (void)output, (void)frames; } +void audio_effect_statistics(AudioEffectDsp *s, void *output, unsigned bytes) { (void)s, (void)output, (void)bytes; } +Reverb3d *audio_reverb_create(const Reverb3dParam *p) { (void)p; return NULL; } +void audio_reverb_free(Reverb3d *s) { (void)s; } +void audio_reverb_update(Reverb3d *s, const Reverb3dParam *p) { (void)s, (void)p; } +void audio_reverb_process(Reverb3d *s, float *const input[6], float *const output[6], int frames) { (void)s, (void)input, (void)output, (void)frames; } + +/* ---- a renderer: the final mix (4 buffers, the mixer's buffers 0-3), one submix (2 buffers, 4-5) that goes + nowhere, three voices, one splitter with one destination ---- */ +#define FRAME 240 +#define SUBMIX 4 /* the submix's first buffer */ +static int16_t pcm[4 * FRAME]; /* every sample 16384: 0.5 */ + +static AudioRenderer *renderer_open(void) { + AudioRenderer *r = calloc(1, sizeof *r); + r->param = (AudrenParameter){.sample_rate = 48000, .sample_count = FRAME, .mix_buffer_count = 8, .submix_count = 1, + .voice_count = 3, .sink_count = 1, .effect_count = 0, .splitter_count = 1, + .splitter_destination_count = 1, .revision = AUDREN_REV(11)}; + r->revision = r->param.revision; + r->voices = calloc(3, sizeof *r->voices); + r->mixes = calloc(2, sizeof *r->mixes); + r->sinks = calloc(1, sizeof *r->sinks); + r->effects = calloc(1, sizeof *r->effects); + r->channels = calloc(3 * 6, sizeof *r->channels); + r->mixes[0] = (AudrenMixIn){.volume = 1, .sample_rate = 48000, .buffer_count = 4, .in_use = 1, .mix_id = 0, + .dest_mix_id = -1, .dest_splitter_id = SPLITTER_UNUSED}; + r->mixes[1] = (AudrenMixIn){.volume = 1, .sample_rate = 48000, .buffer_count = 2, .in_use = 1, .mix_id = 1, + .dest_mix_id = 0, .dest_splitter_id = SPLITTER_UNUSED}; + r->sinks[0] = (AudrenSinkIn){.type = SINK_DEVICE, .in_use = 1, .input_count = 2, .inputs = {0, 1}}; + for (size_t i = 0; i < sizeof pcm / sizeof *pcm; i++) pcm[i] = 16384; + audio_mixer_open(r); + audio_mixer_set_running(r, 1); + return r; +} + +static void renderer_close(AudioRenderer *r) { + audio_mixer_close(r); + free(r->voices), free(r->mixes), free(r->sinks), free(r->effects), free(r->channels), free(r); +} + +/* One render pass, counted as audren.c render_thread counts it. The voices are not advanced: every sample is the same. */ +static void pass(AudioRenderer *r) { + audio_mixer_render(r, (double)FRAME / 48000); + r->elapsed_frames++; +} + +/* A voice as the game's update leaves a new one (audren.c update_voice with is_new: voice_reset, then started). Of + what the slot's last voice was mixed with, voice_reset clears only ramp_pass, so the rest is kept here too. */ +static ServerVoice *voice_new(AudioRenderer *r, int i, float volume, int32_t mix, uint32_t splitter) { + ServerVoice *v = &r->voices[i], was = *v; + memset(v, 0, sizeof *v); + v->ramp_mix = was.ramp_mix, v->ramp_splitter = was.ramp_splitter, v->ramp_channels = was.ramp_channels; + v->ramp_buffers = was.ramp_buffers, v->ramp_volume = was.ramp_volume; + memcpy(v->ramp_gain, was.ramp_gain, sizeof v->ramp_gain); + v->in.id = (uint32_t)i, v->in.in_use = 1, v->in.play_state = PLAY_STARTED, v->in.sample_format = FMT_PCM16; + v->in.sample_rate = 48000, v->in.channel_count = 1, v->in.pitch = 1, v->in.volume = volume; + v->in.mix_id = mix, v->in.splitter_id = splitter; + v->in.channel_ids[0] = i * 6; + v->state = SV_STARTED; + v->buffers[0] = (AudrenWaveBuffer){.address = (uintptr_t)pcm, .size = sizeof pcm, .start = 0, .end = 4 * FRAME}; + v->valid[0] = 1; + return v; +} + +/* The splitter section of an update (REV11 layout): splitter 0 with destination 0, which goes to `mix` with `volume` + on that mix's buffer `buffer`. */ +static void splitter_set(AudioRenderer *r, int used, int32_t mix, int buffer, float volume) { + uint8_t wire[0x20 + 16 + 4 + 12 + 0x70 + 12] = {0}; + uint32_t header[3] = {0x48444e53u, 1, 1}, info[4] = {0x49444e53u, 0, 0, 1}, dest[2] = {0x44444e53u, 0}; + int32_t id = 0; + float volumes[24] = {0}; + volumes[buffer] = volume; + memcpy(wire, header, sizeof header); + memcpy(wire + 0x20, info, sizeof info); + memcpy(wire + 0x30, &id, 4); + uint8_t *d = wire + 0x40; + memcpy(d, dest, sizeof dest); + memcpy(d + 8, volumes, sizeof volumes); + memcpy(d + 0x68, &mix, 4); + d[0x6c] = (uint8_t)used; + AudrenMixIn mixes[2] = {r->mixes[0], r->mixes[1]}; + audio_mixer_update_routing(r, wire, sizeof wire, (const uint8_t *)mixes, sizeof mixes, 2); +} + +static const float *buf(AudioRenderer *r, int b) { return mixbuf(r->mixer, b); } + +/* Every sample of the pass went in with the same gain. */ +static int flat(const float *d, float level) { + for (int k = 0; k < FRAME; k++) + if (fabsf(d[k] - level) > 1e-6f) return 0; + return 1; +} + +/* The pass went from `from` to `to` in equal steps: sample k on the line from + k (to - from) / FRAME, so the first + sample is the level before and the pass after continues at `to`. */ +static int ramped(const float *d, float from, float to) { + for (int k = 0; k < FRAME; k++) { + if (fabsf(d[k] - (from + (to - from) * (float)k / FRAME)) > 2e-5f) return 0; + if (k && to > from ? d[k] <= d[k - 1] : k && d[k] >= d[k - 1]) return 0; + } + return d[0] == from; +} + +static void voice_into_mix(void) { + AudioRenderer *r = renderer_open(); + uint64_t passes, ramps; + r->channels[0].mix[0] = 1, r->channels[0].mix[1] = 0.5f; + ServerVoice *v = voice_new(r, 0, 0, 0, SPLITTER_UNUSED); + pass(r); + CHECK(flat(buf(r, 0), 0) && flat(buf(r, 1), 0), "a voice at volume 0 is silent"); + v->in.volume = 1; + pass(r); + CHECK(ramped(buf(r, 0), 0, 0.5f), "volume 0 to 1: the pass rises in equal steps (first %g, middle %g, last %g)", + buf(r, 0)[0], buf(r, 0)[FRAME / 2], buf(r, 0)[FRAME - 1]); + CHECK(ramped(buf(r, 1), 0, 0.25f), "each buffer of the mix ramps to its own gain"); + CHECK(flat(buf(r, 2), 0) && flat(buf(r, 3), 0), "buffers the voice does not go into stay silent"); + audio_mixer_ramp_stats(r, &passes, &ramps); + CHECK(passes == 1 && ramps == 2, "one pass ramped, two buffers (%llu, %llu)", (unsigned long long)passes, (unsigned long long)ramps); + pass(r); + CHECK(flat(buf(r, 0), 0.5f) && flat(buf(r, 1), 0.25f), "the pass after the step holds the new volume"); + audio_mixer_ramp_stats(r, &passes, &ramps); + CHECK(passes == 1 && ramps == 2, "an unchanged volume is not counted as a ramp"); + r->channels[0].mix[0] = 0.5f; /* the channel's mix volume moves, the voice's does not */ + pass(r); + CHECK(ramped(buf(r, 0), 0.5f, 0.25f) && flat(buf(r, 1), 0.25f), "a changed mix volume ramps, the other buffer does not"); + v->in.volume = 0; + pass(r); + CHECK(ramped(buf(r, 0), 0.25f, 0) && ramped(buf(r, 1), 0.25f, 0), "a volume set to 0 fades across the pass"); + pass(r); + CHECK(flat(buf(r, 0), 0), "and is silent after it"); + + /* a voice that starts is not ramped, whatever the slot held before */ + r->channels[6].mix[2] = 1; + voice_new(r, 1, 1, 0, SPLITTER_UNUSED); + pass(r); + CHECK(flat(buf(r, 2), 0.5f), "a new voice starts at its volume (first sample %g)", buf(r, 2)[0]); + r->voices[1].in.volume = 0.5f; + pass(r); + CHECK(ramped(buf(r, 2), 0.5f, 0.25f), "and ramps from its second pass on"); + voice_new(r, 1, 1, 0, SPLITTER_UNUSED); + pass(r); + CHECK(flat(buf(r, 2), 0.5f), "a voice started again in the same slot starts at its volume"); + + /* not mixed in the pass before: paused, then played at another volume */ + r->voices[1].state = SV_PAUSED; + pass(r); + CHECK(flat(buf(r, 2), 0), "a paused voice is silent"); + r->voices[1].state = SV_STARTED, r->voices[1].in.volume = 0.25f; + pass(r); + CHECK(flat(buf(r, 2), 0.125f), "a voice that was silent in the pass before starts at its volume"); + + /* another destination: the submix */ + r->voices[1].in.mix_id = 1, r->voices[1].in.volume = 1; + r->channels[6].mix[0] = 1; + pass(r); + CHECK(flat(buf(r, SUBMIX), 0.5f) && flat(buf(r, 2), 0), "a voice moved to another mix starts at its volume there"); + r->voices[1].in.volume = 0.5f; + pass(r); + CHECK(ramped(buf(r, SUBMIX), 0.5f, 0.25f), "and ramps there afterwards"); + + /* the renderer stopped and started: the pass before is not the sound before */ + audio_mixer_set_running(r, 0); + audio_mixer_set_running(r, 1); + r->voices[1].in.volume = 1; + pass(r); + CHECK(flat(buf(r, SUBMIX), 0.5f), "the first pass after a start takes every volume as it is"); + renderer_close(r); +} + +static void voice_into_splitter(void) { + AudioRenderer *r = renderer_open(); + splitter_set(r, 1, 0, 3, 0); + ServerVoice *v = voice_new(r, 2, 1, 0, 0); + pass(r); + CHECK(flat(buf(r, 3), 0), "a destination at volume 0 is silent"); + splitter_set(r, 1, 0, 3, 1); + pass(r); + CHECK(ramped(buf(r, 3), 0, 0.5f), "destination volume 0 to 1: the pass rises in equal steps (first %g, last %g)", + buf(r, 3)[0], buf(r, 3)[FRAME - 1]); + pass(r); + CHECK(flat(buf(r, 3), 0.5f), "the pass after the step holds the new volume"); + v->in.volume = 0.5f; + pass(r); + CHECK(ramped(buf(r, 3), 0.5f, 0.25f), "the voice's own volume ramps through the destination"); + v->in.volume = 1; + splitter_set(r, 1, 0, 3, 0.5f); /* both move: from the product before to the product now, here the same gain */ + pass(r); + CHECK(flat(buf(r, 3), 0.25f), "voice volume doubled and destination volume halved: the gain does not move"); + + splitter_set(r, 1, 1, 0, 1); /* the destination goes to the submix */ + pass(r); + CHECK(flat(buf(r, SUBMIX), 0.5f) && flat(buf(r, 3), 0), "a destination moved to another mix starts at its volume there"); + splitter_set(r, 0, 1, 0, 1); + pass(r); + CHECK(flat(buf(r, SUBMIX), 0), "an unused destination is silent"); + splitter_set(r, 1, 1, 0, 0.5f); + pass(r); + CHECK(flat(buf(r, SUBMIX), 0.25f), "a destination used again starts at its volume"); + splitter_set(r, 1, 1, 0, 1); + voice_new(r, 2, 1, 0, 0); + pass(r); + CHECK(flat(buf(r, SUBMIX), 0.5f), "a new voice into a destination whose volume moved starts at the new gain"); + renderer_close(r); +} + +/* A submix into a splitter ramps the destination's volumes like a voice does. */ +static void mix_into_splitter(void) { + AudioRenderer *r = renderer_open(); + r->channels[0].mix[0] = 1; + r->mixes[1].dest_splitter_id = 0, r->mixes[1].buffer_count = 1; + splitter_set(r, 1, 0, 3, 0.5f); + voice_new(r, 0, 1, 1, SPLITTER_UNUSED); + pass(r); + CHECK(flat(buf(r, SUBMIX), 0.5f) && flat(buf(r, 3), 0.25f), "the submix reaches the final mix through the destination"); + splitter_set(r, 1, 0, 3, 1); + pass(r); + CHECK(ramped(buf(r, 3), 0.25f, 0.5f), "the destination's volume ramps for a mix"); + r->mixes[1].volume = 0.5f; + pass(r); + CHECK(ramped(buf(r, 3), 0.5f, 0.25f), "and so does the mix's own volume"); + renderer_close(r); +} + +static void ramps_off(void) { + ramp_setting = 0; + AudioRenderer *r = renderer_open(); + r->channels[0].mix[0] = 1; + ServerVoice *v = voice_new(r, 0, 0, 0, SPLITTER_UNUSED); + pass(r); + v->in.volume = 1; + pass(r); + CHECK(flat(buf(r, 0), 0.5f), "NX2NAVI_AUDIO_RAMP=0: the new volume applies to the whole pass"); + renderer_close(r); + ramp_setting = 1; +} + +int main(void) { + voice_into_mix(); + voice_into_splitter(); + mix_into_splitter(); + ramps_off(); + printf("audio_mixer_check: %s\n", fails ? "FAIL" : "ok"); + return fails ? 1 : 0; +} diff --git a/tests/audio_mixer_check.py b/tests/audio_mixer_check.py new file mode 100644 index 00000000..b8c9c45d --- /dev/null +++ b/tests/audio_mixer_check.py @@ -0,0 +1,42 @@ +"""Build and run tests/audio_mixer_check.c: runtime/audio_mixer.c with the rest of the runtime stubbed. No game files +and no audio device (the mixer runs as with NX2NAVI_AUDIO=none); SDL3 is only linked. + +Windows: python tests\\audio_mixer_check.py [--toolchain C:/msys64/ucrt64] +Linux: python tests/audio_mixer_check.py (clang and SDL3 development files via pkg-config) +""" +import argparse +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MSYS = Path(r'C:\msys64\ucrt64') + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('--toolchain', type=Path, default=MSYS if os.name == 'nt' and MSYS.exists() else None) + a = ap.parse_args() + work = ROOT / 'build' / 'test' / 'audio_mixer' + work.mkdir(parents=True, exist_ok=True) + env = dict(os.environ) + if a.toolchain: + env['PATH'] = str(a.toolchain / 'bin') + os.pathsep + env.get('PATH', '') + cc = os.environ.get('CC') or str(a.toolchain / 'bin' / 'clang.exe') + sdl = ['-L', str(a.toolchain / 'lib'), '-lSDL3'] + else: + cc = os.environ.get('CC') or shutil.which('clang') or 'cc' + sdl = shlex.split(subprocess.check_output(['pkg-config', '--cflags', '--libs', 'sdl3'], text=True)) + exe = work / ('audio_mixer_check.exe' if os.name == 'nt' else 'audio_mixer_check') + # the default build's target (x86-64-v3: AVX2 and FMA), so the mixing loops are compiled as they ship + subprocess.check_call([cc, '-std=gnu11', '-O2', '-fno-strict-aliasing', '-march=x86-64-v3', '-Wall', + '-Wno-unused-function', '-I', str(ROOT / 'runtime'), + str(ROOT / 'tests' / 'audio_mixer_check.c'), '-o', str(exe), *sdl, '-lm'], env=env) + return subprocess.run([str(exe)], env=env, timeout=60).returncode + + +if __name__ == '__main__': + sys.exit(main())