Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog/v0.1.0-beta3.txt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ AUDIO
- Android's 13 presets with translated names, your own presets with Save, Save as, Rename, Duplicate and Delete with Undo, and a Custom entry that keeps the curve you tuned when you try a preset. AutoEQ and EqualizerAPO files import from a file or pasted text and export to a file or the clipboard.
- The equalizer is saved with your settings, included in backups and synced with Flow for Android. Your old equalizer gains carry over, with the 32 Hz and 16 kHz bands now peaks like on Android, and the panel says when audio processing is not available on your system.
- Music only goes through audio processing once the equalizer changes the sound, and playback resumes on its own after sleep or an output device change.
- Music has its own playback speed from 0.5x to 2x, with presets and the pitch kept, saved with your settings.
- The music volume slider follows how loud it sounds, so each step changes the level by a similar amount. Your saved volume plays at the same level after the update.
- Music loudness is measured the way Android does it, from the perceptual loudness first, and loud songs are never turned down by more than 20 dB.
- An optional crossfade, off by default, fades the next song in over 1 to 12 seconds. Consecutive tracks from the same album and repeat-one keep their gapless joins.

MACOS
- Flow uses the native macOS window controls and titlebar again. Closing the window hides it and keeps playback and downloads running until you quit, and clicking the Dock icon brings it back.
Expand Down
48 changes: 48 additions & 0 deletions src-tauri/src/api/innertube/json/loudness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,59 @@ pub fn loudness_db(audio_config: &Value, format: &Value) -> Option<f64> {
})
}

/// 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<f64> {
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!(
Expand Down
22 changes: 12 additions & 10 deletions src-tauri/src/api/innertube/music/playback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -191,7 +191,7 @@ impl InnertubeClient {
.as_str()
.and_then(|s| s.parse::<u64>().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()
Expand Down Expand Up @@ -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]
Expand Down
17 changes: 7 additions & 10 deletions src/components/music/EqPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@ import { EqLimits } from "../../lib/audio/eq/models";
import { withBassBoost } from "../../lib/audio/eq/stateOps";
import { pickEqPreset, restoreCustomEq, useEqualizer, useMusicEqAvailable } from "../../lib/audio/eq/useEqualizer";
import { getString } from "../../lib/i18n/index";
import { SETTINGS } from "../../lib/settings/schema";
import { useBoolPref } from "../../lib/usePreference";
import { useEqStore } from "../../store/useEqStore";
import { Chip } from "../ui/Chip";
import { Slider } from "../ui/Slider";
import { ToggleSwitch } from "../ui/ToggleSwitch";
import { EqResponseGraph } from "./eq/EqResponseGraph";
import { formatDb } from "./eq/eqFormat";
import { MusicAudioOptions } from "./MusicAudioOptions";

/** The quick equalizer panel: switch, curve, presets and bass boost, with Edit for the rest. */
/**
* The quick audio panel: the equalizer's switch, curve, presets and bass boost with Edit for the
* rest, then the other music audio settings.
*/
export function EqPanel({
open,
onClose,
Expand All @@ -28,7 +30,6 @@ export function EqPanel({
const available = useMusicEqAvailable();
const update = useEqStore((s) => s.update);
const setEditorOpen = useEqStore((s) => s.setEditorOpen);
const [normalization, setNormalization] = useBoolPref(SETTINGS.MUSIC_LOUDNESS_NORMALIZATION);

const fromY = placement === "top" ? 10 : -10;
const selectedId = eq.active.presetId;
Expand Down Expand Up @@ -127,12 +128,8 @@ export function EqPanel({
{getString("eq_edit")}
</button>

<div className="mt-4 flex items-center justify-between gap-3 border-t border-chrome-neutral-800 pt-3">
<span className="flex min-w-0 flex-col">
<span className="text-sm text-chrome-neutral-200">{getString("music_loudness_normalization")}</span>
<span className="text-xs text-chrome-neutral-500">{getString("music_loudness_normalization_desc")}</span>
</span>
<ToggleSwitch checked={normalization} onChange={setNormalization} />
<div className="mt-4 border-t border-chrome-neutral-800 pt-3">
<MusicAudioOptions />
</div>
</motion.div>
)}
Expand Down
78 changes: 78 additions & 0 deletions src/components/music/MusicAudioOptions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { getString } from "../../lib/i18n/index";
import { SETTINGS } from "../../lib/settings/schema";
import { useBoolPref, useNumberPref } from "../../lib/usePreference";
import { Chip } from "../ui/Chip";
import { Slider } from "../ui/Slider";
import { ToggleSwitch } from "../ui/ToggleSwitch";

const SPEED_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;

const formatSpeed = (rate: number) => getString("music_speed_value", Number(rate.toFixed(2)));

/** Music audio settings beside the equalizer: loudness normalization, speed and crossfade. */
export function MusicAudioOptions() {
const [normalization, setNormalization] = useBoolPref(SETTINGS.MUSIC_LOUDNESS_NORMALIZATION);
const [speed, setSpeed] = useNumberPref(SETTINGS.MUSIC_PLAYBACK_SPEED);
const [crossfade, setCrossfade] = useBoolPref(SETTINGS.MUSIC_CROSSFADE_ENABLED);
const [crossfadeSeconds, setCrossfadeSeconds] = useNumberPref(SETTINGS.MUSIC_CROSSFADE_SECONDS);

return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<span className="flex min-w-0 flex-col">
<span className="text-sm text-chrome-neutral-200">{getString("music_loudness_normalization")}</span>
<span className="text-xs text-chrome-neutral-500">{getString("music_loudness_normalization_desc")}</span>
</span>
<ToggleSwitch checked={normalization} onChange={setNormalization} />
</div>

<div className="flex flex-col gap-2">
<span className="flex items-center justify-between">
<span className="text-sm text-chrome-neutral-200">{getString("music_playback_speed")}</span>
<span className="font-mono text-xs text-chrome-neutral-400">{formatSpeed(speed)}</span>
</span>
<div className="flex flex-wrap gap-1.5">
{SPEED_PRESETS.map((rate) => (
<Chip key={rate} active={speed === rate} onClick={() => setSpeed(rate)} className="h-7 px-2.5 text-xs">
{formatSpeed(rate)}
</Chip>
))}
</div>
<Slider
min={0.5}
max={2}
step={0.05}
value={speed}
onChange={(rate) => setSpeed(Math.round(rate * 100) / 100)}
aria-label={getString("music_playback_speed")}
/>
</div>

<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<span className="flex min-w-0 flex-col">
<span className="text-sm text-chrome-neutral-200">{getString("music_crossfade")}</span>
<span className="text-xs text-chrome-neutral-500">{getString("music_crossfade_desc")}</span>
</span>
<ToggleSwitch checked={crossfade} onChange={setCrossfade} />
</div>
{crossfade && (
<div className="flex items-center gap-3">
<Slider
min={1}
max={12}
step={1}
value={crossfadeSeconds}
onChange={setCrossfadeSeconds}
aria-label={getString("music_crossfade")}
className="flex-1"
/>
<span className="w-10 text-right font-mono text-xs text-chrome-neutral-400">
{getString("music_crossfade_seconds", crossfadeSeconds)}
</span>
</div>
)}
</div>
</div>
);
}
61 changes: 61 additions & 0 deletions src/lib/audio/crossfade.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";

import type { SongItem } from "../../types/music";
import { crossfadeTarget, type CrossfadeQueue } from "./crossfade";

const song = (id: string, albumId: string | null = null): SongItem => ({
id,
title: id,
artists: [],
album: albumId ? { id: albumId, name: albumId } : null,
duration: 200,
musicVideoType: "MUSIC_VIDEO_TYPE_ATV",
thumbnail: "",
explicit: false,
videoId: id,
playlistId: null,
params: null,
});

const near = (patch: Partial<CrossfadeQueue> = {}): CrossfadeQueue => {
const queue = [song("a"), song("b")];
return {
queue,
currentIndex: 0,
currentTrack: queue[0]!,
isShuffle: false,
repeatMode: "none",
isPlaying: true,
loadingStreamId: null,
progress: 196,
duration: 200,
...patch,
};
};

describe("crossfadeTarget", () => {
it("starts the fade once the song is inside its last seconds", () => {
expect(crossfadeTarget(near(), 5)).toBe(1);
expect(crossfadeTarget(near({ progress: 190 }), 5)).toBe(-1);
expect(crossfadeTarget(near({ progress: 199.9 }), 5)).toBe(-1);
});

it("stays off when disabled, paused, loading, on repeat-one or at the end of the queue", () => {
expect(crossfadeTarget(near(), 0)).toBe(-1);
expect(crossfadeTarget(near({ isPlaying: false }), 5)).toBe(-1);
expect(crossfadeTarget(near({ loadingStreamId: "a" }), 5)).toBe(-1);
expect(crossfadeTarget(near({ repeatMode: "one" }), 5)).toBe(-1);
expect(crossfadeTarget(near({ queue: [song("a")] }), 5)).toBe(-1);
});

it("keeps consecutive album tracks gapless unless shuffled", () => {
const album = [song("a", "LP"), song("b", "LP")];
const inOrder = near({ queue: album, currentTrack: album[0]! });
expect(crossfadeTarget(inOrder, 5)).toBe(-1);
expect(crossfadeTarget({ ...inOrder, isShuffle: true }, 5)).toBe(1);
});

it("never fades a song too short to hold the fade twice", () => {
expect(crossfadeTarget(near({ duration: 8, progress: 5 }), 5)).toBe(-1);
});
});
37 changes: 37 additions & 0 deletions src/lib/audio/crossfade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { SongItem } from "../../types/music";
import { upcomingIndex, type QueueRepeatMode } from "../musicQueueOrder";

export interface CrossfadeQueue {
queue: SongItem[];
currentIndex: number;
currentTrack: SongItem | null;
isShuffle: boolean;
repeatMode: QueueRepeatMode;
isPlaying: boolean;
loadingStreamId: string | null;
progress: number;
duration: number;
}

/**
* Tracks in album order are often meant to run into each other (a live set, a concept album, a
* continuous mix), so they keep their gapless join instead of being faded across.
*/
const playsInAlbumOrder = (s: CrossfadeQueue, next: SongItem): boolean =>
!s.isShuffle && !!s.currentTrack?.album?.id && s.currentTrack.album.id === next.album?.id;

/**
* The queue index to crossfade into now, or -1. Fades start `seconds` before the end, never on
* repeat-one, never between consecutive album tracks, and never on a track too short to hold
* two fades.
*/
export function crossfadeTarget(s: CrossfadeQueue, seconds: number): number {
if (seconds <= 0 || !s.isPlaying || s.loadingStreamId !== null || s.repeatMode === "one") return -1;
if (!(s.duration > seconds * 2)) return -1;
const remaining = s.duration - s.progress;
if (remaining > seconds || remaining <= 0.25) return -1;
const index = upcomingIndex(s);
const next = s.queue[index];
if (!next || index === s.currentIndex || playsInAlbumOrder(s, next)) return -1;
return index;
}
42 changes: 42 additions & 0 deletions src/lib/audio/crossfadeRamp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
const CURVE_POINTS = 32;
const STEP_MS = 100;

/** Equal-power gains for `t` in 0..1: the sum of both sides' power stays constant. */
export const fadeInGain = (t: number): number => Math.sin((Math.max(0, Math.min(1, t)) * Math.PI) / 2);
export const fadeOutGain = (t: number): number => Math.cos((Math.max(0, Math.min(1, t)) * Math.PI) / 2);

function curve(gain: (t: number) => number): Float32Array<ArrayBuffer> {
const values = new Float32Array(CURVE_POINTS);
for (let i = 0; i < CURVE_POINTS; i++) values[i] = gain(i / (CURVE_POINTS - 1));
return values;
}

/**
* Schedules a whole fade on the audio clock, so it runs smoothly even while the window is hidden
* and timers are throttled.
*/
export function scheduleFade(param: AudioParam, direction: "in" | "out", startTime: number, seconds: number): void {
param.cancelScheduledValues(startTime);
param.setValueCurveAtTime(curve(direction === "in" ? fadeInGain : fadeOutGain), startTime, seconds);
}

/** Puts `param` back at full level at once, dropping any fade still scheduled on it. */
export function resetFade(param: AudioParam, time: number): void {
param.cancelScheduledValues(time);
param.setValueAtTime(1, time);
}

/**
* The fallback without WebAudio: element volume moved in steps. Returns a cancel function.
* `onStep` gets the fade's progress from 0 to 1.
*/
export function steppedFade(seconds: number, onStep: (progress: number) => void): () => void {
const started = Date.now();
onStep(0);
const timer = setInterval(() => {
const progress = Math.min(1, (Date.now() - started) / (seconds * 1_000));
onStep(progress);
if (progress >= 1) clearInterval(timer);
}, STEP_MS);
return () => clearInterval(timer);
}
Loading