From cf6080af286c66d11fff7f9d97fd8bf480f4cecb Mon Sep 17 00:00:00 2001 From: libretroadmin Date: Sun, 14 Jun 2026 19:01:05 +0000 Subject: [PATCH] sound: per-channel expansion-audio volume controls (#512) Adds six new core options for individually scaling the output of each emulated Famicom expansion-audio chip: - fceumm_apu_fds - Famicom Disk System 2C33 wavetable - fceumm_apu_s5b - Sunsoft 5B (YM2149F clone) - fceumm_apu_n163 - Namco 163 - fceumm_apu_vrc6 - Konami VRC6 - fceumm_apu_vrc7 - Konami VRC7 (YM2413-clone) - fceumm_apu_mmc5 - Nintendo MMC5 PCM + square Each is a 0..100 range in steps of 5: 100 (default) is the unscaled output and is bit-identical to pre-patch builds; 0 silences the chip; intermediate values do (sample * vol) / 256 with vol in the internal 0..256 range used by the existing per-NES-APU-channel TriangleVolume / SquareVolume / NoiseVolume / PCMVolume fields. The original report (#512) asked for on/off toggles; the followup asked for volume. This implements volume - on/off falls out as the endpoints (0/100) of the same control, matching what fceumm_next exposes. The three rare PCM/speech chips mentioned in the original report (NEC uPD7755C/56C Jaleco, Mitsubishi M50805 Bandai) are not included; they're outside fceumm_next's scope as well and not all of those chip emulations have a clean output stage to wrap. Implementation: - One new int[6] in FSettings (fceu.h) and an init loop in fceu.c that sets all six slots to 256 on startup. Matches the existing per-NES-APU per-channel volume convention so the default mixing path stays unchanged. - One enum (SND_FDS..SND_MMC5, sound.h) and one helper, GetExpOutput (sound.c), that wraps the per-channel scale. Fast path on the default value 256 returns the input unchanged with no multiply, so the hot mixing loops keep the same instruction sequence as before. - Each of the six chip's audio module wraps its output amplitude through GetExpOutput(SND_XXX, amp) at the point where the computed sample is written into Wave[] / WaveHi[]. For VRC7 the wrap is a small dispatch helper (VRC7Mix) that defers to plain OPLL_fillbuf at vol=256 and falls back to a local mix loop calling OPLL_calc when scaling is needed - this keeps the default path identical to before the patch. For the FDS module the wrap is at the cached sample-out point, reusing the same site (and reinstating the GetOutput-style scaling that the recent MDFN backport had to drop for lack of the API). - libretro.c's check_variables polls the six new options at the same point it polls the existing fceumm_apu_1..5 bitmask, via a small {SND_XXX, "fceumm_apu_xxx"} table. Validation on Vs. Excitebike (Japan) FDS, 9000-frame headless capture: - default (no opts set): md5 9c62be1651... - --opt fceumm_apu_fds=100: md5 9c62be1651... (bit-identical) - --opt fceumm_apu_s5b=0 ... mmc5=0: md5 9c62be1651... (no-op on FDS-only game) - --opt fceumm_apu_fds=50: race-window amplitude std 7662 -> 5623, FDS contribution scales linearly within integer rounding (RMS error 30 of 5547) - --opt fceumm_apu_fds=0: race-window std 7662 -> 4457, FDS muted A non-FDS test ROM (mapper 64, Skull & Crossbones) runs cleanly with arbitrary expansion-chip options set - those values are no-op for the chip the game doesn't use. Performance: GetExpOutput is one bounds check, one array load, one compare, and on the default value an immediate return. The mixing loops compile to the same hot path as before for default settings. Non-default values add one multiply + one shift per sample, which is the same cost the per-NES-APU per-channel scaling already pays. --- src/boards/69.c | 2 + src/boards/mmc5.c | 16 +- src/boards/n106.c | 8 +- src/boards/vrc6.c | 12 +- src/boards/vrc7.c | 32 ++- src/drivers/libretro/libretro.c | 33 ++++ src/drivers/libretro/libretro_core_options.h | 198 +++++++++++++++++++ src/fceu.c | 9 + src/fceu.h | 6 + src/fds_apu.c | 2 +- src/sound.c | 16 ++ src/sound.h | 26 +++ 12 files changed, 341 insertions(+), 19 deletions(-) diff --git a/src/boards/69.c b/src/boards/69.c index 6bc8748..04f28b6 100644 --- a/src/boards/69.c +++ b/src/boards/69.c @@ -162,6 +162,7 @@ static void DoAYSQ(int x) { int V; amp += amp >> 1; + amp = GetExpOutput(SND_S5B, amp); start = CAYBC[x]; end = (SOUNDTS << 16) / soundtsinc; @@ -186,6 +187,7 @@ static void DoAYSQHQ(int x) { int32_t amp = (sreg[0x8 + x] & 15) << 6; amp += amp >> 1; + amp = GetExpOutput(SND_S5B, amp); if (!(sreg[0x7] & (1 << x))) { for (V = CAYBC[x]; V < SOUNDTS; V++) { diff --git a/src/boards/mmc5.c b/src/boards/mmc5.c index 4792385..d4c212f 100644 --- a/src/boards/mmc5.c +++ b/src/boards/mmc5.c @@ -489,16 +489,20 @@ static void Do5PCM(void) { if (end <= start) return; MMC5Sound.BC[2] = end; - if (!(MMC5Sound.rawcontrol & 0x40) && MMC5Sound.raw) + if (!(MMC5Sound.rawcontrol & 0x40) && MMC5Sound.raw) { + int32_t amp = GetExpOutput(SND_MMC5, MMC5Sound.raw << 1); for (V = start; V < end; V++) - Wave[V >> 4] += MMC5Sound.raw << 1; + Wave[V >> 4] += amp; + } } static void Do5PCMHQ(void) { uint32_t V; - if (!(MMC5Sound.rawcontrol & 0x40) && MMC5Sound.raw) + if (!(MMC5Sound.rawcontrol & 0x40) && MMC5Sound.raw) { + int32_t amp = GetExpOutput(SND_MMC5, MMC5Sound.raw << 5); for (V = MMC5Sound.BC[2]; V < SOUNDTS; V++) - WaveHi[V] += MMC5Sound.raw << 5; + WaveHi[V] += amp; + } MMC5Sound.BC[2] = SOUNDTS; } @@ -552,7 +556,7 @@ static void Do5SQ(int P) { MMC5Sound.BC[P] = end; wl = MMC5Sound.wl[P] + 1; - amp = (MMC5Sound.env[P] & 0xF) << 4; + amp = GetExpOutput(SND_MMC5, (MMC5Sound.env[P] & 0xF) << 4); rthresh = tal[(MMC5Sound.env[P] & 0xC0) >> 6]; if (wl >= 8 && (MMC5Sound.running & (P + 1))) { @@ -582,7 +586,7 @@ static void Do5SQHQ(int P) { int32_t amp, rthresh, wl; wl = MMC5Sound.wl[P] + 1; - amp = ((MMC5Sound.env[P] & 0xF) << 8); + amp = GetExpOutput(SND_MMC5, ((MMC5Sound.env[P] & 0xF) << 8)); rthresh = tal[(MMC5Sound.env[P] & 0xC0) >> 6]; if (wl >= 8 && (MMC5Sound.running & (P + 1))) { diff --git a/src/boards/n106.c b/src/boards/n106.c index f4c57c1..5727332 100644 --- a/src/boards/n106.c +++ b/src/boards/n106.c @@ -301,13 +301,13 @@ static void DoNamcoSoundHQ(void) { envelope = EnvCache[P]; lengo = LengthCache[P]; - duff2 = FetchDuff(P, envelope); + duff2 = GetExpOutput(SND_N163, FetchDuff(P, envelope)); for (V = CVBC << 1; V < (int)SOUNDTS << 1; V++) { WaveHi[V >> 1] += duff2; if (!vco) { PlayIndex[P] += freq; while ((PlayIndex[P] >> TOINDEX) >= lengo) PlayIndex[P] -= lengo << TOINDEX; - duff2 = FetchDuff(P, envelope); + duff2 = GetExpOutput(SND_N163, FetchDuff(P, envelope)); vco = cyclesuck; } vco--; @@ -349,7 +349,7 @@ static void DoNamcoSound(int32_t *WaveBuf, int Count) { if ((IRAM[0x46 + (P << 3)] + PlayIndex[P]) & 1) duff >>= 4; duff &= 0xF; - duff2 = (duff * envelope) >> 19; + duff2 = GetExpOutput(SND_N163, (duff * envelope) >> 19); for (V = 0; V < Count * 16; V++) { if (vco >= inc) { PlayIndex[P]++; @@ -360,7 +360,7 @@ static void DoNamcoSound(int32_t *WaveBuf, int Count) { if ((IRAM[0x46 + (P << 3)] + PlayIndex[P]) & 1) duff >>= 4; duff &= 0xF; - duff2 = (duff * envelope) >> 19; + duff2 = GetExpOutput(SND_N163, (duff * envelope) >> 19); } WaveBuf[V >> 4] += duff2; vco += 0x8000; diff --git a/src/boards/vrc6.c b/src/boards/vrc6.c index 60de52f..3224bc5 100644 --- a/src/boards/vrc6.c +++ b/src/boards/vrc6.c @@ -179,7 +179,8 @@ static void DoSawV(void); static INLINE void DoSQV(int x) { int32_t V; - int32_t amp = (((vpsg1[x << 2] & 15) << 8) * 6 / 8) >> 4; + int32_t amp = GetExpOutput(SND_VRC6, + (((vpsg1[x << 2] & 15) << 8) * 6 / 8) >> 4); int32_t start, end; start = cvbc[x]; @@ -251,7 +252,8 @@ static void DoSawV(void) { } if (vcount[2] <= 0) goto rea; - duff = (((phaseacc >> 3) & 0x1f) << 4) * 6 / 8; + duff = GetExpOutput(SND_VRC6, + (((phaseacc >> 3) & 0x1f) << 4) * 6 / 8); } Wave[V >> 4] += duff; } @@ -260,7 +262,8 @@ static void DoSawV(void) { static INLINE void DoSQVHQ(int x) { int32_t V; - int32_t amp = ((vpsg1[x << 2] & 15) << 8) * 6 / 8; + int32_t amp = GetExpOutput(SND_VRC6, + ((vpsg1[x << 2] & 15) << 8) * 6 / 8); if (vpsg1[(x << 2) | 0x2] & 0x80) { if (vpsg1[x << 2] & 0x80) { @@ -300,7 +303,8 @@ static void DoSawVHQ(void) { if (vpsg2[2] & 0x80) { for (V = cvbc[2]; V < (int)SOUNDTS; V++) { - WaveHi[V] += (((phaseacc >> 3) & 0x1f) << 8) * 6 / 8; + WaveHi[V] += GetExpOutput(SND_VRC6, + (((phaseacc >> 3) & 0x1f) << 8) * 6 / 8); vcount[2]--; if (vcount[2] <= 0) { vcount[2] = (vpsg2[1] + ((vpsg2[2] & 15) << 8) + 1) << 1; diff --git a/src/boards/vrc7.c b/src/boards/vrc7.c index 6b7e10a..cdeedd5 100644 --- a/src/boards/vrc7.c +++ b/src/boards/vrc7.c @@ -45,26 +45,50 @@ static SFORMAT StateRegs[] = /* VRC7 Sound */ +/* Apply per-channel volume (#512) around OPLL_fillbuf. Default volume + * 256 dispatches to the plain OPLL_fillbuf for a bit-identical + * pre-#512 path; volume 0 drops the chip silently; intermediate + * values run a local mix loop that calls OPLL_calc (equivalent to + * the internal calc() when quality=0, which is the VRC7 default). + * The (calc + 32768) << shift formulation matches OPLL_fillbuf's + * inner expression. */ +static void VRC7Mix(int32_t *buf, int32_t len, int shift) { + int v; + if (!VRC7Sound || len <= 0) + return; + v = FSettings.ExpVolume[SND_VRC7]; + if (v == 0) + return; + if (v == 256) { + OPLL_fillbuf(VRC7Sound, buf, len, shift); + } else { + int32_t i; + for (i = 0; i < len; i++) { + int32_t s = (OPLL_calc(VRC7Sound) + 32768); + buf[i] += ((s * v) / 256) << shift; + } + } +} + static void DoVRC7Sound(void) { int32_t z, a; if (FSettings.soundq >= 1) return; z = ((SOUNDTS << 16) / soundtsinc) >> 4; a = z - dwave; - OPLL_fillbuf(VRC7Sound, &Wave[dwave], a, 1); + VRC7Mix(&Wave[dwave], a, 1); dwave += a; } static void UpdateOPLNEO(int32_t *WaveBuf, int Count) { - OPLL_fillbuf(VRC7Sound, WaveBuf, Count, 4); + VRC7Mix(WaveBuf, Count, 4); } static void UpdateOPL(int Count) { int32_t z, a; z = ((SOUNDTS << 16) / soundtsinc) >> 4; a = z - dwave; - if (VRC7Sound && a) - OPLL_fillbuf(VRC7Sound, &Wave[dwave], a, 1); + VRC7Mix(&Wave[dwave], a, 1); dwave = 0; } diff --git a/src/drivers/libretro/libretro.c b/src/drivers/libretro/libretro.c index a48a42c..5275b8a 100644 --- a/src/drivers/libretro/libretro.c +++ b/src/drivers/libretro/libretro.c @@ -2493,6 +2493,39 @@ static void check_variables(bool startup) } set_apu_channels(enable_apu); + /* Per-channel expansion-audio volume controls (#512). Six options + * map 1:1 to SND_FDS..SND_MMC5 indexing FSettings.ExpVolume[]. UI + * values are 0..100 in steps of 5; the internal scale is 0..256 + * (matching the convention used for FSettings.SquareVolume[] etc). + * A value of 256 (i.e. UI "100") is the default and leaves the + * mixing path bit-identical to pre-#512 builds. */ + { + static const struct { int channel; const char *key; } expvol_opts[] = { + { SND_FDS, "fceumm_apu_fds" }, + { SND_S5B, "fceumm_apu_s5b" }, + { SND_N163, "fceumm_apu_n163" }, + { SND_VRC6, "fceumm_apu_vrc6" }, + { SND_VRC7, "fceumm_apu_vrc7" }, + { SND_MMC5, "fceumm_apu_mmc5" }, + }; + size_t j; + for (j = 0; j < sizeof(expvol_opts) / sizeof(expvol_opts[0]); j++) + { + struct retro_variable expv = { 0 }; + expv.key = expvol_opts[j].key; + if (environ_cb(RETRO_ENVIRONMENT_GET_VARIABLE, &expv) && expv.value) + { + int pct = atoi(expv.value); + int newval; + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + newval = (256 * pct) / 100; + if (FSettings.ExpVolume[expvol_opts[j].channel] != newval) + FSettings.ExpVolume[expvol_opts[j].channel] = newval; + } + } + } + update_dipswitch(); update_option_visibility(); diff --git a/src/drivers/libretro/libretro_core_options.h b/src/drivers/libretro/libretro_core_options.h index e17ca26..3affc04 100644 --- a/src/drivers/libretro/libretro_core_options.h +++ b/src/drivers/libretro/libretro_core_options.h @@ -501,6 +501,204 @@ struct retro_core_option_v2_definition option_defs[] = { }, "enabled", }, + { + "fceumm_apu_fds", + "Channel Volume (FDS)", + NULL, + "Scale Famicom Disk System 2C33 wavetable expansion-audio output (0 = mute, 100 = unscaled).", + NULL, + "audio", + { + { "0", NULL }, + { "5", NULL }, + { "10", NULL }, + { "15", NULL }, + { "20", NULL }, + { "25", NULL }, + { "30", NULL }, + { "35", NULL }, + { "40", NULL }, + { "45", NULL }, + { "50", NULL }, + { "55", NULL }, + { "60", NULL }, + { "65", NULL }, + { "70", NULL }, + { "75", NULL }, + { "80", NULL }, + { "85", NULL }, + { "90", NULL }, + { "95", NULL }, + { "100", NULL }, + { NULL, NULL }, + }, + "100", + }, + { + "fceumm_apu_s5b", + "Channel Volume (S5B)", + NULL, + "Scale Sunsoft 5B (YM2149F) expansion-audio output (0 = mute, 100 = unscaled).", + NULL, + "audio", + { + { "0", NULL }, + { "5", NULL }, + { "10", NULL }, + { "15", NULL }, + { "20", NULL }, + { "25", NULL }, + { "30", NULL }, + { "35", NULL }, + { "40", NULL }, + { "45", NULL }, + { "50", NULL }, + { "55", NULL }, + { "60", NULL }, + { "65", NULL }, + { "70", NULL }, + { "75", NULL }, + { "80", NULL }, + { "85", NULL }, + { "90", NULL }, + { "95", NULL }, + { "100", NULL }, + { NULL, NULL }, + }, + "100", + }, + { + "fceumm_apu_n163", + "Channel Volume (N163)", + NULL, + "Scale Namco 163 expansion-audio output (0 = mute, 100 = unscaled).", + NULL, + "audio", + { + { "0", NULL }, + { "5", NULL }, + { "10", NULL }, + { "15", NULL }, + { "20", NULL }, + { "25", NULL }, + { "30", NULL }, + { "35", NULL }, + { "40", NULL }, + { "45", NULL }, + { "50", NULL }, + { "55", NULL }, + { "60", NULL }, + { "65", NULL }, + { "70", NULL }, + { "75", NULL }, + { "80", NULL }, + { "85", NULL }, + { "90", NULL }, + { "95", NULL }, + { "100", NULL }, + { NULL, NULL }, + }, + "100", + }, + { + "fceumm_apu_vrc6", + "Channel Volume (VRC6)", + NULL, + "Scale Konami VRC6 expansion-audio output (0 = mute, 100 = unscaled).", + NULL, + "audio", + { + { "0", NULL }, + { "5", NULL }, + { "10", NULL }, + { "15", NULL }, + { "20", NULL }, + { "25", NULL }, + { "30", NULL }, + { "35", NULL }, + { "40", NULL }, + { "45", NULL }, + { "50", NULL }, + { "55", NULL }, + { "60", NULL }, + { "65", NULL }, + { "70", NULL }, + { "75", NULL }, + { "80", NULL }, + { "85", NULL }, + { "90", NULL }, + { "95", NULL }, + { "100", NULL }, + { NULL, NULL }, + }, + "100", + }, + { + "fceumm_apu_vrc7", + "Channel Volume (VRC7)", + NULL, + "Scale Konami VRC7 (YM2413-clone) expansion-audio output (0 = mute, 100 = unscaled).", + NULL, + "audio", + { + { "0", NULL }, + { "5", NULL }, + { "10", NULL }, + { "15", NULL }, + { "20", NULL }, + { "25", NULL }, + { "30", NULL }, + { "35", NULL }, + { "40", NULL }, + { "45", NULL }, + { "50", NULL }, + { "55", NULL }, + { "60", NULL }, + { "65", NULL }, + { "70", NULL }, + { "75", NULL }, + { "80", NULL }, + { "85", NULL }, + { "90", NULL }, + { "95", NULL }, + { "100", NULL }, + { NULL, NULL }, + }, + "100", + }, + { + "fceumm_apu_mmc5", + "Channel Volume (MMC5)", + NULL, + "Scale Nintendo MMC5 PCM + square expansion-audio output (0 = mute, 100 = unscaled).", + NULL, + "audio", + { + { "0", NULL }, + { "5", NULL }, + { "10", NULL }, + { "15", NULL }, + { "20", NULL }, + { "25", NULL }, + { "30", NULL }, + { "35", NULL }, + { "40", NULL }, + { "45", NULL }, + { "50", NULL }, + { "55", NULL }, + { "60", NULL }, + { "65", NULL }, + { "70", NULL }, + { "75", NULL }, + { "80", NULL }, + { "85", NULL }, + { "90", NULL }, + { "95", NULL }, + { "100", NULL }, + { NULL, NULL }, + }, + "100", + }, { "fceumm_turbo_enable", "Turbo Enable", diff --git a/src/fceu.c b/src/fceu.c index ba1706a..4925662 100644 --- a/src/fceu.c +++ b/src/fceu.c @@ -324,6 +324,15 @@ int FCEUI_Initialize(void) { FSettings.UsrLastSLine[0] = 231; FSettings.UsrLastSLine[1] = 239; FSettings.SoundVolume = 100; + /* Default expansion-audio channel volumes to 256 (unscaled). Mirrors + * the convention used for the NES APU per-channel volume fields: + * a value of 256 in any of these slots leaves the corresponding + * mixing path bit-identical to pre-#512 builds. */ + { + int i; + for (i = 0; i < (int)(sizeof(FSettings.ExpVolume) / sizeof(FSettings.ExpVolume[0])); i++) + FSettings.ExpVolume[i] = 256; + } FCEUPPU_Init(); X6502_Init(); return 1; diff --git a/src/fceu.h b/src/fceu.h index 4534588..04ae4ab 100644 --- a/src/fceu.h +++ b/src/fceu.h @@ -80,6 +80,12 @@ typedef struct { int SquareVolume[2]; int NoiseVolume; int PCMVolume; + int ExpVolume[6]; /* Per-channel expansion-audio volume, + * indexed by SND_FDS..SND_MMC5 (see + * src/sound.h). Scale is 0-256 where 256 = + * unscaled output and 0 = silence. Default + * 256 leaves output bit-identical to builds + * predating issue #512. */ int GameGenie; /* Current first and last rendered scanlines. */ diff --git a/src/fds_apu.c b/src/fds_apu.c index 0425aa3..a148e16 100644 --- a/src/fds_apu.c +++ b/src/fds_apu.c @@ -379,7 +379,7 @@ static void FDSDoSound(void) { sample = fdso.cwave[(fdso.cwave_pos >> fdso.cwave_pos_shift) & 0x3F] * k * 4; sample = (int32_t)((uint32_t)sample * fds_div_lut[idx]) >> 16; - fdso.sample_out_cache = sample; + fdso.sample_out_cache = GetExpOutput(SND_FDS, sample); } } diff --git a/src/sound.c b/src/sound.c index 4544dd4..926e9ff 100644 --- a/src/sound.c +++ b/src/sound.c @@ -1315,6 +1315,22 @@ void FCEUI_SetSoundVolume(uint32_t volume) { FSettings.SoundVolume = volume; } +/* Per-channel expansion-audio volume scaling. See sound.h for context. + * Hot-path consideration: the common case (vol == 256, the default) + * returns immediately with no multiply, keeping the existing + * bit-identical behaviour on builds where the new options haven't + * been touched. When vol is 0 the channel is silenced cleanly + * regardless of the input sample. */ +int32_t GetExpOutput(int channel, int32_t in) { + int v; + if ((unsigned)channel >= (unsigned)SND_EXP_LAST) + return in; + v = FSettings.ExpVolume[channel]; + if (v == 256) return in; + if (v == 0) return 0; + return (in * v) / 256; +} + SFORMAT FCEUSND_STATEINFO[] = { { &fhcnt, 4 | FCEUSTATE_RLSB, "FHCN" }, diff --git a/src/sound.h b/src/sound.h index c1f6dbf..ecd8493 100644 --- a/src/sound.h +++ b/src/sound.h @@ -21,6 +21,32 @@ #ifndef _FCEU_SOUND_H #define _FCEU_SOUND_H +#include "fceu-types.h" + +/* Identifiers for the expansion-audio channels exposed to the + * per-channel volume API (#512). Indices into FSettings.ExpVolume[]. + * Numbering is internal-only - the savestate format and core options + * key names do not depend on these values - but the count must match + * FSettings.ExpVolume[]'s declared length in fceu.h. */ +enum { + SND_FDS = 0, /* Famicom Disk System 2C33 wavetable */ + SND_S5B = 1, /* Sunsoft 5B / YM2149F (mapper 69) */ + SND_N163 = 2, /* Namco 163 (mapper 19 / "n106") */ + SND_VRC6 = 3, /* Konami VRC6 (mappers 24, 26) */ + SND_VRC7 = 4, /* Konami VRC7 / YM2413-clone (mapper 85) */ + SND_MMC5 = 5, /* Nintendo MMC5 PCM + square (mapper 5) */ + SND_EXP_LAST = 6 +}; + +/* Apply the per-channel volume modifier from FSettings.ExpVolume[]. + * Scale is 0..256: 256 (default) returns the sample unchanged so the + * hot path stays free of multiplies on builds where the user has not + * touched the new options; 0 returns silence; intermediate values + * scale the sample by (in * vol) / 256. Used by the six expansion- + * audio modules (see fds_apu.c, boards/vrc6.c, boards/vrc7.c, + * boards/n106.c, boards/mmc5.c, boards/69.c). */ +int32_t GetExpOutput(int channel, int32_t in); + typedef struct { void (*Fill)(int Count); /* Low quality ext sound. */