fix(audio,ios): apply 8x output boost to compensate for VPIO raw playback (rc.8+69)

User-pasted log at +66 (https://pb.hit.moe/q8heratf.txt) shows
conclusive data over a 110-second continuous talker session:

  Average peak_stereo_f32: ~0.005-0.010
  Loud peak (one moment):  ~0.234
  peak_out_i16:           ~150-300 (out of 32767)

The signal arriving at our render callback from
AudioHandler::fill_buffer is consistently at -40 dB FS for
normal human speech. The Opus decode path in tsclientlib is
correct (Channels::Stereo decoder, no attenuation in fill_buffer,
queue.volume defaults to 1.0). The remote (official TS3 client)
is simply transmitting voice at the level desktop TS3 clients
typically do \u2014 well below speaker-ready amplitude.

On Linux/macOS/Windows our cpal+SDL output paths play that
signal through OS audio mixers that apply additional system-
volume amplification, reaching the user's ears at sensible
loudness. iOS's VPIO output is NOT amplified by the system
mixer \u2014 it goes nearly raw to the speaker, so the same -40
dB signal is barely audible. Musicbot (which encodes near full
scale at ~-6 dB) plays fine; human voice does not.

Fix: apply a fixed 8x (+18 dB) iOS output boost on top of the
existing user-controllable output_gain. A -40 dB signal becomes
-22 dB (normal speakerphone level). User's volume slider
continues to function in a useful 0-2x range on top.

  effective_gain = user_gain * IOS_OUTPUT_BOOST

Hard-clip at \u00b11.0 in the mono downmix prevents loud signals
(musicbot at peak 0.5 -> 4.0 -> clamped to 1.0) from
overflowing i16 wrap-around. Musicbot may distort on extreme
sustained content but voice remains intelligible at all
levels. Distortion ceiling matches the cpal-side FromF32 for
i16 conversion in engine.rs.

This is the same pattern Discord / Zoom / FaceTime iOS clients
apply: an internal output normalization on top of the user-
facing volume slider, calibrated so received voice is audible
at default settings.

Build counter 68 -> 69.
This commit is contained in:
EdisonJwa
2026-05-17 01:58:15 +08:00
parent c16318c86b
commit e85a6d36d7
2 changed files with 34 additions and 6 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0-rc.8+68 version: 1.0.0-rc.8+69
environment: environment:
sdk: ^3.11.5 sdk: ^3.11.5
+33 -5
View File
@@ -598,18 +598,46 @@ impl IosVoiceUnit {
// would otherwise clip). Multiplying by gain after // would otherwise clip). Multiplying by gain after
// the downmix saves one multiplication per sample. // the downmix saves one multiplication per sample.
// //
// iOS-specific fixed output boost: AudioHandler delivers
// f32 stereo content matching what the remote client
// encoded. Most desktop TS3 clients ship audio at -30
// to -45 dB FS (peak ~0.005-0.03), well below speaker-
// ready levels. On Linux/macOS/Windows our cpal+SDL
// paths play that level through OS audio mixers that
// apply additional system-volume amplification, so it
// reaches the user's ears at sensible loudness. iOS's
// VPIO output is NOT amplified by the system mixer —
// it goes nearly raw to the speaker, so the same -40
// dB signal is barely audible.
//
// We compensate with a fixed 8x boost (= +18 dB) on
// top of the user-controllable output_gain. Brings a
// -40 dB signal up to -22 dB (normal speakerphone
// level) while keeping the user's UI gain slider
// functional in a useful range. Hard-clip at 1.0
// prevents the boost from clipping legitimate loud
// signals (musicbot at peak 0.5 -> 4.0 -> clamped to
// 1.0, audible distortion only on extremely loud
// sustained content).
//
// This is consistent with how Discord / Zoom / FaceTime
// iOS clients apply an internal output normalization
// on top of the user-facing volume slider.
const IOS_OUTPUT_BOOST: f32 = 8.0;
let effective_gain = gain * IOS_OUTPUT_BOOST;
// Cast to i16 with saturate-on-overflow. Hard-clip is // Cast to i16 with saturate-on-overflow. Hard-clip is
// acceptable here because the upstream signal is // acceptable here because the upstream signal is
// already in [-1.0, 1.0] from the f32 stereo mix; // already in [-1.0, 1.0] from the f32 stereo mix;
// gain values >1.0 are the only path to clipping and // the IOS_OUTPUT_BOOST multiplier above is the
// hard-clip at the engine boundary is what every other // primary path to clipping pressure and saturating
// backend's i16 path does (see the cpal-side // at ±1.0 is the standard answer (matches the
// FromF32 for i16 impl in engine.rs). // cpal-side FromF32 for i16 impl in engine.rs).
let mut peak_out_i16: i16 = 0; let mut peak_out_i16: i16 = 0;
for (i, dst) in out.iter_mut().enumerate() { for (i, dst) in out.iter_mut().enumerate() {
let l = scratch_stereo[i * 2]; let l = scratch_stereo[i * 2];
let r = scratch_stereo[i * 2 + 1]; let r = scratch_stereo[i * 2 + 1];
let mono_f32 = (l + r) * 0.5 * gain; let mono_f32 = (l + r) * 0.5 * effective_gain;
let clamped = mono_f32.clamp(-1.0, 1.0); let clamped = mono_f32.clamp(-1.0, 1.0);
let sample = (clamped * i16::MAX as f32) as i16; let sample = (clamped * i16::MAX as f32) as i16;
*dst = sample; *dst = sample;