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.
This commit is contained in:
libretroadmin
2026-06-14 19:01:05 +00:00
committed by U-DESKTOP-SPFP6AQ\twistedtechre
parent fef3de8bd9
commit cf6080af28
12 changed files with 341 additions and 19 deletions

View File

@@ -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++) {

View File

@@ -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))) {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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",

View File

@@ -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;

View File

@@ -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. */

View File

@@ -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);
}
}

View File

@@ -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" },

View File

@@ -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. */