feat(chat): volume control for inline audio clips
Add a per-clip volume slider plus a master volume control with a "Universal volume" toggle in the chat header. - ClipPlayer gains a SetVolume command; the worker remembers gain across clips and reapplies it to each freshly connected player. - New config.clip_volume (universal level) and config.clip_volume_universal (mode toggle, default on), both persisted; old configs load at unity in universal mode. - Universal on: master and per-clip sliders drive one shared level applied to every clip. Universal off: each clip keeps its own in-memory level and the master slider is inert. - play_ready_audio applies the resolved effective gain right after Play. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -156,6 +156,14 @@ pub struct AppConfig {
|
||||
/// App-internal playback gain applied to the mixed output (1.0 = unity).
|
||||
#[serde(default = "default_volume")]
|
||||
pub output_volume: f32,
|
||||
/// Universal playback gain for inline chat audio clips (1.0 = unity). One
|
||||
/// level shared by every uploaded clip so the slider sticks across plays.
|
||||
#[serde(default = "default_volume")]
|
||||
pub clip_volume: f32,
|
||||
/// When true, `clip_volume` governs every clip. When false, each clip keeps
|
||||
/// its own (in-memory) level and the universal slider is inactive.
|
||||
#[serde(default = "default_true")]
|
||||
pub clip_volume_universal: bool,
|
||||
#[serde(default)]
|
||||
pub network_mode: NetworkMode,
|
||||
/// Presence posture for the friends idle listener (W7): invisible / normal /
|
||||
@@ -312,6 +320,8 @@ impl Default for AppConfig {
|
||||
noise_gate_threshold: 0.01,
|
||||
input_volume: 1.0,
|
||||
output_volume: 1.0,
|
||||
clip_volume: 1.0,
|
||||
clip_volume_universal: true,
|
||||
network_mode: NetworkMode::default(),
|
||||
presence_mode: crate::presence::PresenceMode::default(),
|
||||
echo_cancellation_enabled: false,
|
||||
@@ -657,23 +667,32 @@ mod tests {
|
||||
let def = AppConfig::default();
|
||||
assert_eq!(def.input_volume, 1.0);
|
||||
assert_eq!(def.output_volume, 1.0);
|
||||
assert_eq!(def.clip_volume, 1.0);
|
||||
assert!(def.clip_volume_universal);
|
||||
|
||||
// Missing in JSON → unity (serde default).
|
||||
let missing = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
||||
let cfg_missing: AppConfig = serde_json::from_str(missing).unwrap();
|
||||
assert_eq!(cfg_missing.input_volume, 1.0);
|
||||
assert_eq!(cfg_missing.output_volume, 1.0);
|
||||
assert_eq!(cfg_missing.clip_volume, 1.0);
|
||||
// Configs predating the toggle default to universal mode.
|
||||
assert!(cfg_missing.clip_volume_universal);
|
||||
|
||||
// Explicit non-unity values are preserved across a round-trip.
|
||||
let cfg = AppConfig {
|
||||
input_volume: 1.5,
|
||||
output_volume: 0.25,
|
||||
clip_volume: 0.7,
|
||||
clip_volume_universal: false,
|
||||
..AppConfig::default()
|
||||
};
|
||||
let round_tripped: AppConfig =
|
||||
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
||||
assert_eq!(round_tripped.input_volume, 1.5);
|
||||
assert_eq!(round_tripped.output_volume, 0.25);
|
||||
assert_eq!(round_tripped.clip_volume, 0.7);
|
||||
assert!(!round_tripped.clip_volume_universal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user