From ee3b64a074d6b2333296305b250f49e28740b6f0 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:58:12 +0300 Subject: [PATCH 1/6] fix(music): measure loudness in Android's order and cap the cut at 20 dB --- src-tauri/src/api/innertube/json/loudness.rs | 48 +++++++++++++++++++ src-tauri/src/api/innertube/music/playback.rs | 22 +++++---- src/lib/audio/musicAudioEngine.ts | 4 +- src/lib/loudness.test.ts | 7 ++- src/lib/loudness.ts | 14 ++++-- 5 files changed, 79 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/api/innertube/json/loudness.rs b/src-tauri/src/api/innertube/json/loudness.rs index bd10f928..1b52cfbb 100644 --- a/src-tauri/src/api/innertube/json/loudness.rs +++ b/src-tauri/src/api/innertube/json/loudness.rs @@ -19,11 +19,59 @@ pub fn loudness_db(audio_config: &Value, format: &Value) -> Option { }) } +/// The target the site measures perceptual loudness against when the answer leaves it out. +const DEFAULT_TARGET_LKFS: f64 = -14.0; + +/// Music's loudness in Flow for Android's order (`LoudnessGain`): the perceptual loudness +/// against its target first, then the answer's `loudnessDb`, then the format's own. The +/// perceptual value is measured the way a listener hears the song, so it is preferred. +#[must_use] +pub fn music_loudness_db(audio_config: &Value, format: &Value) -> Option { + audio_config["perceptualLoudnessDb"] + .as_f64() + .map(|perceptual| { + perceptual + - audio_config["loudnessTargetLkfs"] + .as_f64() + .unwrap_or(DEFAULT_TARGET_LKFS) + }) + .or_else(|| audio_config["loudnessDb"].as_f64()) + .or_else(|| format["loudnessDb"].as_f64()) +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; + // Ported from Android's LoudnessGainTest, in loudness rather than gain. + #[test] + fn music_loudness_prefers_the_perceptual_value_against_its_target() { + assert_eq!( + music_loudness_db( + &json!({ "loudnessDb": 9.0, "perceptualLoudnessDb": -8.0 }), + &json!({}) + ), + Some(6.0) + ); + assert_eq!( + music_loudness_db( + &json!({ "perceptualLoudnessDb": -10.0, "loudnessTargetLkfs": -16.0 }), + &json!({}) + ), + Some(6.0) + ); + assert_eq!( + music_loudness_db(&json!({ "loudnessDb": 4.5 }), &json!({ "loudnessDb": 9.0 })), + Some(4.5) + ); + assert_eq!( + music_loudness_db(&json!({}), &json!({ "loudnessDb": 3.0 })), + Some(3.0) + ); + assert_eq!(music_loudness_db(&json!({}), &json!({})), None); + } + #[test] fn loudness_comes_from_the_config_the_format_or_the_perceptual_target() { assert_eq!( diff --git a/src-tauri/src/api/innertube/music/playback.rs b/src-tauri/src/api/innertube/music/playback.rs index 8382a86e..35eca74c 100644 --- a/src-tauri/src/api/innertube/music/playback.rs +++ b/src-tauri/src/api/innertube/music/playback.rs @@ -13,7 +13,7 @@ use std::time::Duration; -use crate::api::innertube::json::loudness::loudness_db; +use crate::api::innertube::json::loudness::music_loudness_db; use serde_json::Value; use tracing::{debug, info, warn}; @@ -191,7 +191,7 @@ impl InnertubeClient { .as_str() .and_then(|s| s.parse::().ok()); let audio_config = &res["playerConfig"]["audioConfig"]; - let loudness_db = loudness_db(audio_config, &format); + let loudness_db = music_loudness_db(audio_config, &format); let perceptual_loudness_db = audio_config["perceptualLoudnessDb"].as_f64(); let expires_in_seconds = streaming["expiresInSeconds"] .as_str() @@ -436,21 +436,23 @@ mod tests { use serde_json::json; #[test] - fn loudness_falls_back_to_the_format_then_the_perceptual_value() { + fn music_loudness_takes_the_perceptual_value_before_the_others() { let top = json!({ "loudnessDb": 1.5, "perceptualLoudnessDb": -9.0, "loudnessTargetLkfs": -14.0 }); - assert_eq!(loudness_db(&top, &json!({ "loudnessDb": 3.0 })), Some(1.5)); - - let visionos = json!({ "perceptualLoudnessDb": -10.59, "loudnessTargetLkfs": -14.0 }); assert_eq!( - loudness_db(&visionos, &json!({ "loudnessDb": 3.41 })), - Some(3.41) + music_loudness_db(&top, &json!({ "loudnessDb": 3.0 })), + Some(5.0) ); - let perceptual = loudness_db(&visionos, &json!({})).unwrap(); + let visionos = json!({ "perceptualLoudnessDb": -10.59, "loudnessTargetLkfs": -14.0 }); + let perceptual = music_loudness_db(&visionos, &json!({ "loudnessDb": 1.0 })).unwrap(); assert!((perceptual - 3.41).abs() < 1e-9); - assert_eq!(loudness_db(&json!({}), &json!({})), None); + assert_eq!( + music_loudness_db(&json!({ "loudnessDb": 1.5 }), &json!({ "loudnessDb": 3.0 })), + Some(1.5) + ); + assert_eq!(music_loudness_db(&json!({}), &json!({})), None); } #[test] diff --git a/src/lib/audio/musicAudioEngine.ts b/src/lib/audio/musicAudioEngine.ts index 50deaa65..e6ff3e96 100644 --- a/src/lib/audio/musicAudioEngine.ts +++ b/src/lib/audio/musicAudioEngine.ts @@ -1,4 +1,4 @@ -import { loudnessFactor } from "../loudness"; +import { loudnessFactor, MUSIC_MAX_CUT_DB } from "../loudness"; import { createAudioContext, keepRunning, resumeWithin, webAudioSupported } from "./audioContext"; import { EQ_GRAPH_OFF, type EqGraphSpec } from "./eq/graphPlan"; import { EqChain, RAMP_TIME_CONSTANT } from "./eqChain"; @@ -295,7 +295,7 @@ class MusicAudioEngine { } private normalizationFactor(loudnessDb: number | null): number { - return loudnessFactor(loudnessDb, this.normalizationEnabled); + return loudnessFactor(loudnessDb, this.normalizationEnabled, MUSIC_MAX_CUT_DB); } private applyGain(): void { diff --git a/src/lib/loudness.test.ts b/src/lib/loudness.test.ts index 728a1479..526e22c3 100644 --- a/src/lib/loudness.test.ts +++ b/src/lib/loudness.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { loudnessFactor } from "./loudness"; +import { loudnessFactor, MUSIC_MAX_CUT_DB } from "./loudness"; describe("loudnessFactor", () => { it("turns a loud stream down to the target and leaves quiet ones alone", () => { @@ -8,4 +8,9 @@ describe("loudnessFactor", () => { expect(loudnessFactor(6, false)).toBe(1); expect(loudnessFactor(null, true)).toBe(1); }); + + it("stops music at minus 20 dB, as Android does, and leaves video uncapped", () => { + expect(loudnessFactor(35, true, MUSIC_MAX_CUT_DB)).toBeCloseTo(0.1, 6); + expect(loudnessFactor(35, true)).toBeCloseTo(Math.pow(10, -35 / 20), 9); + }); }); diff --git a/src/lib/loudness.ts b/src/lib/loudness.ts index fa451643..aebc807f 100644 --- a/src/lib/loudness.ts +++ b/src/lib/loudness.ts @@ -1,9 +1,17 @@ +/** Android's floor for music: a track is never turned down by more than this (`LoudnessGain`). */ +export const MUSIC_MAX_CUT_DB = 20; + /** * The volume factor that brings a stream mastered `loudnessDb` above the site's * target down to it. Only ever turns down: a quiet stream is left as it is - * rather than pushed past full volume. + * rather than pushed past full volume. `maxCutDb` caps how far down it goes. */ -export function loudnessFactor(loudnessDb: number | null | undefined, enabled: boolean): number { +export function loudnessFactor( + loudnessDb: number | null | undefined, + enabled: boolean, + maxCutDb = Number.POSITIVE_INFINITY, +): number { if (!enabled || loudnessDb == null || !Number.isFinite(loudnessDb)) return 1; - return Math.min(1, Math.pow(10, -loudnessDb / 20)); + const cutDb = Math.min(Math.max(0, loudnessDb), maxCutDb); + return Math.pow(10, -cutDb / 20); } From 2b4d7d48604a233244df200b244ed8b553a83d01 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:58:47 +0300 Subject: [PATCH 2/6] feat(music): play the volume slider on a perceptual curve --- src/lib/audio/musicAudioEngine.eq.test.ts | 4 +-- src/lib/audio/musicAudioEngine.ts | 4 ++- src/lib/audio/volumeCurve.test.ts | 30 +++++++++++++++++++++ src/lib/audio/volumeCurve.ts | 33 +++++++++++++++++++++++ src/store/useMusicPlayerStore.ts | 2 ++ 5 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 src/lib/audio/volumeCurve.test.ts create mode 100644 src/lib/audio/volumeCurve.ts diff --git a/src/lib/audio/musicAudioEngine.eq.test.ts b/src/lib/audio/musicAudioEngine.eq.test.ts index 34da8779..fea89618 100644 --- a/src/lib/audio/musicAudioEngine.eq.test.ts +++ b/src/lib/audio/musicAudioEngine.eq.test.ts @@ -95,8 +95,8 @@ describe("music EQ graph", () => { musicAudioEngine.setLoudness(6, true); expect(contexts).toHaveLength(0); - // Normalization turns the element down by itself: 6 dB over the target is about half. - expect(pair[0].volume).toBeCloseTo(0.25, 2); + // Half way on the slider is a quarter of the gain, and 6 dB over the target halves it again. + expect(pair[0].volume).toBeCloseTo(0.125, 2); expect(musicAudioEngine.isEqAvailable()).toBe(true); }); diff --git a/src/lib/audio/musicAudioEngine.ts b/src/lib/audio/musicAudioEngine.ts index e6ff3e96..6270cf79 100644 --- a/src/lib/audio/musicAudioEngine.ts +++ b/src/lib/audio/musicAudioEngine.ts @@ -2,6 +2,7 @@ import { loudnessFactor, MUSIC_MAX_CUT_DB } from "../loudness"; import { createAudioContext, keepRunning, resumeWithin, webAudioSupported } from "./audioContext"; import { EQ_GRAPH_OFF, type EqGraphSpec } from "./eq/graphPlan"; import { EqChain, RAMP_TIME_CONSTANT } from "./eqChain"; +import { volumeGain } from "./volumeCurve"; /** * One of the two