SDL_mixer: api: Added MIX_PROP_PLAY_FADE_IN_START_GAIN_FLOAT, to fade from not-silence.

From 7b5b902968686c6606fc82eff33bd755aee2b96a Mon Sep 17 00:00:00 2001
From: "Ryan C. Gordon" <[EMAIL REDACTED]>
Date: Tue, 20 Jan 2026 00:00:41 -0500
Subject: [PATCH] api: Added MIX_PROP_PLAY_FADE_IN_START_GAIN_FLOAT, to fade
 from not-silence.

Fixes #746.
---
 include/SDL3_mixer/SDL_mixer.h |  5 +++++
 src/SDL_mixer.c                | 13 +++++++++++--
 src/SDL_mixer_internal.h       |  1 +
 test/testmixer.c               |  1 +
 4 files changed, 18 insertions(+), 2 deletions(-)

diff --git a/include/SDL3_mixer/SDL_mixer.h b/include/SDL3_mixer/SDL_mixer.h
index b6a6e34e..211a005f 100644
--- a/include/SDL3_mixer/SDL_mixer.h
+++ b/include/SDL3_mixer/SDL_mixer.h
@@ -1720,6 +1720,10 @@ extern SDL_DECLSPEC Sint64 SDLCALL MIX_FramesToMS(int sample_rate, Sint64 frames
  *   MIX_PROP_PLAY_FADE_IN_FRAMES_NUMBER property, but the value is specified
  *   in milliseconds instead of sample frames. If both properties are
  *   specified, the sample frames value is favored. Default 0.
+ * - `MIX_PROP_PLAY_FADE_IN_START_GAIN_FLOAT`: If fading in, start fading from
+ *   this volume level. 0.0f is silence and 1.0f is full volume, every in
+ *   between is a linear change in gain. The specified value will be clamped
+ *   between 0.0f and 1.0f. Default 0.0f.
  * - `MIX_PROP_PLAY_APPEND_SILENCE_FRAMES_NUMBER`: At the end of mixing this
  *   track, after all loops are complete, append this many sample frames of
  *   silence as if it were part of the audio file. This allows for apps to
@@ -1761,6 +1765,7 @@ extern SDL_DECLSPEC bool SDLCALL MIX_PlayTrack(MIX_Track *track, SDL_PropertiesI
 #define MIX_PROP_PLAY_LOOP_START_MILLISECOND_NUMBER "SDL_mixer.play.loop_start_millisecond"
 #define MIX_PROP_PLAY_FADE_IN_FRAMES_NUMBER "SDL_mixer.play.fade_in_frames"
 #define MIX_PROP_PLAY_FADE_IN_MILLISECONDS_NUMBER "SDL_mixer.play.fade_in_milliseconds"
+#define MIX_PROP_PLAY_FADE_IN_START_GAIN_FLOAT "SDL_mixer.play.fade_in_start_gain"
 #define MIX_PROP_PLAY_APPEND_SILENCE_FRAMES_NUMBER "SDL_mixer.play.append_silence_frames"
 #define MIX_PROP_PLAY_APPEND_SILENCE_MILLISECONDS_NUMBER "SDL_mixer.play.append_silence_milliseconds"
 
diff --git a/src/SDL_mixer.c b/src/SDL_mixer.c
index 3f4ec305..53cfdedb 100644
--- a/src/SDL_mixer.c
+++ b/src/SDL_mixer.c
@@ -253,12 +253,15 @@ static void ApplyFade(MIX_Track *track, int channels, float *pcm, int frames)
     int fade_frame_position = total_fade_frames - (int) track->fade_frames;
 
     // some hacks to avoid a branch on each sample frame. Might not be a good idea in practice.
-    const float pctmult = (track->fade_direction < 0) ? 1.0f : -1.0f;
+    const float fade_start_gain = track->fade_start_gain;
+    const float pctmult = (1.0f - fade_start_gain) * ((track->fade_direction < 0) ? 1.0f : -1.0f);
     const float pctsub = (track->fade_direction < 0) ? 1.0f : 0.0f;
     const float ftotal_fade_frames = (float) total_fade_frames;
 
+    SDL_assert((fade_start_gain == 0.0f) || (track->fade_direction > 0));  // we only allow fade _in_ from arbitrary levels. Fade out always operates on the full signal down to zero.
+
     for (int i = 0; i < to_be_faded; i++) {
-        const float pct = (pctsub - (((float) fade_frame_position) / ftotal_fade_frames)) * pctmult;
+        const float pct = ((pctsub - (((float) fade_frame_position) / ftotal_fade_frames)) * pctmult) + fade_start_gain;
         SDL_assert(pct >= 0.0f);
         SDL_assert(pct <= 1.0f);
         fade_frame_position++;
@@ -2177,6 +2180,7 @@ bool MIX_PlayTrack(MIX_Track *track, SDL_PropertiesID options)
     Sint64 loop_start = 0;
     Sint64 fade_in = 0;
     Sint64 append_silence_frames = 0;
+    float fade_start_gain = 0.0f;
     LockTrack(track);
     if (options) {
         loops = (int) SDL_GetNumberProperty(options, MIX_PROP_PLAY_LOOPS_NUMBER, loops);
@@ -2184,6 +2188,7 @@ bool MIX_PlayTrack(MIX_Track *track, SDL_PropertiesID options)
         start_pos = GetTrackOptionFramesOrTicks(track, options, MIX_PROP_PLAY_START_FRAME_NUMBER, MIX_PROP_PLAY_START_MILLISECOND_NUMBER, start_pos);
         loop_start = GetTrackOptionFramesOrTicks(track, options, MIX_PROP_PLAY_LOOP_START_FRAME_NUMBER, MIX_PROP_PLAY_LOOP_START_MILLISECOND_NUMBER, loop_start);
         fade_in = GetTrackOptionFramesOrTicks(track, options, MIX_PROP_PLAY_FADE_IN_FRAMES_NUMBER, MIX_PROP_PLAY_FADE_IN_MILLISECONDS_NUMBER, fade_in);
+        fade_start_gain = SDL_GetFloatProperty(options, MIX_PROP_PLAY_FADE_IN_START_GAIN_FLOAT, 0.0f);
         append_silence_frames = GetTrackOptionFramesOrTicks(track, options, MIX_PROP_PLAY_APPEND_SILENCE_FRAMES_NUMBER, MIX_PROP_PLAY_APPEND_SILENCE_MILLISECONDS_NUMBER, append_silence_frames);
 
         if (start_pos < 0) {
@@ -2197,6 +2202,8 @@ bool MIX_PlayTrack(MIX_Track *track, SDL_PropertiesID options)
         if (append_silence_frames < 0) {
             append_silence_frames = 0;
         }
+
+        fade_start_gain = SDL_clamp(fade_start_gain, 0.0f, 1.0f);
     }
 
     if (track->input_audio && (!track->input_audio->decoder->seek(track->decoder_userdata, start_pos))) {
@@ -2213,6 +2220,7 @@ bool MIX_PlayTrack(MIX_Track *track, SDL_PropertiesID options)
     track->total_fade_frames = (fade_in > 0) ? fade_in : 0;
     track->fade_frames = track->total_fade_frames;
     track->fade_direction = (fade_in > 0) ? 1 : 0;
+    track->fade_start_gain = fade_start_gain;
     track->silence_frames = (append_silence_frames > 0) ? -append_silence_frames : 0;  // negative means "there is still actual audio data to play", positive means "we're done with actual data, feed silence now." Zero means no silence (left) to feed.
     track->state = MIX_STATE_PLAYING;
     track->position = start_pos;
@@ -2303,6 +2311,7 @@ static void StopTrack(MIX_Track *track, Sint64 fadeOut)
             track->total_fade_frames = fadeOut;
             track->fade_frames = fadeOut;
             track->fade_direction = -1;
+            track->fade_start_gain = 0.0f;  // only used for fade-ins.
         }
     }
     UnlockTrack(track);
diff --git a/src/SDL_mixer_internal.h b/src/SDL_mixer_internal.h
index be9aecda..3e0e514a 100644
--- a/src/SDL_mixer_internal.h
+++ b/src/SDL_mixer_internal.h
@@ -161,6 +161,7 @@ struct MIX_Track
     Sint64 total_fade_frames;  // fade in or out for this many sample frames.
     Sint64 fade_frames;  // remaining frames to fade.
     int fade_direction;  // -1: fade out  0: don't fade  1: fade in
+    float fade_start_gain;  // between 0.0f and 1.0f. Fade with this volume as the starting point (fade-in only).
     int loops_remaining;  // seek to loop_start and continue this many more times at end of input. Negative to loop forever.
     int loop_start;      // sample frame position for loops to begin, so you can play an intro once and then loop from an internal point thereafter.
     SDL_PropertiesID tags;  // lookup tags to see if they are currently applied to this track (true or false).
diff --git a/test/testmixer.c b/test/testmixer.c
index 0449ae79..b2bc4360 100644
--- a/test/testmixer.c
+++ b/test/testmixer.c
@@ -234,6 +234,7 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[])
     SDL_SetNumberProperty(options, MIX_PROP_PLAY_LOOPS_NUMBER, 3);
     SDL_SetNumberProperty(options, MIX_PROP_PLAY_LOOP_START_MILLISECOND_NUMBER, 6097);
     SDL_SetNumberProperty(options, MIX_PROP_PLAY_FADE_IN_MILLISECONDS_NUMBER, 30000);
+    //SDL_SetFloatProperty(options, MIX_PROP_PLAY_FADE_IN_START_GAIN_FLOAT, 0.25f);
     SDL_SetNumberProperty(options, MIX_PROP_PLAY_APPEND_SILENCE_MILLISECONDS_NUMBER, 30000);
     MIX_PlayTrack(track1, options);
     SDL_DestroyProperties(options);