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
1 change: 1 addition & 0 deletions changelog/v0.1.0-beta3.txt
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ MACOS
- On 120 Hz ProMotion screens Flow now draws at 120 frames per second. WebKit was holding every animation, scroll and lyric line to 60.
- The music equalizer and crossfade are now heard on macOS. WebKit cannot pass Opus songs through audio processing, so once either is on, songs stream as AAC and the song playing reloads once from where it is.
- The volume slider keeps working on a downloaded Opus song while the equalizer is on.
- AAC songs now show and play for their real length on macOS. WebKit reported them at twice their length, so a song ran on in silence before the next one started.

LINUX
- The AppImage is built on Ubuntu 24.04 with a newer WebKitGTK that no longer crashes on color emoji fonts, the cause of the freeze on Fedora 44 and Bazzite. It needs glibc 2.39 or newer, so on Ubuntu 22.04, Mint 21 or Debian 12 use the .deb instead.
Expand Down
27 changes: 18 additions & 9 deletions src/components/music/GlobalMusicAudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ export function GlobalMusicAudio() {
useLyricsLookup();

const store = () => useMusicPlayerStore.getState();
const finishTrack = () => {
musicListens.finish(Date.now());
if (sleepTimerTakesEnd()) return;
store().handleEnded();
};
// The engine corrects a length WebKit reports wrong, so the player reads it there.
const syncTime = (el: HTMLAudioElement) => store()._syncTime(el.currentTime, musicAudioEngine.getDuration());
const audioProps = {
hidden: true,
preload: "auto",
Expand All @@ -57,14 +64,20 @@ export function GlobalMusicAudio() {
store()._setBuffering(true);
}),
onPlaying: fromActive(() => store()._setBuffering(false)),
onDurationChange: fromActive((el) => store()._syncTime(el.currentTime, el.duration)),
onSeeked: fromActive((el) => store()._syncTime(el.currentTime, el.duration)),
onDurationChange: fromActive(syncTime),
onSeeked: fromActive(syncTime),
// Near the end, buffer the next track on the standby element so the change
// is a swap. Driven by the element rather than the frame loop, which WebKit
// pauses while the window is hidden, as it usually is while music plays.
// The listed length stands in when WebKit reports a stream as endless.
onTimeUpdate: fromActive((el) => {
const duration = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : store().duration;
// A song whose element reports twice its length ends at the listed one, not after the
// silence the element would play to reach its own.
if (musicAudioEngine.takeListedEnd()) {
finishTrack();
return;
}
const duration = musicAudioEngine.getDuration() || store().duration;
if (duration > 0 && duration - el.currentTime <= PRELOAD_LEAD_SECONDS) store()._preloadUpcoming();

/*
Expand All @@ -75,7 +88,7 @@ export function GlobalMusicAudio() {
card. Anything that needs the exact position (the full player's seek
bar, the lyrics) reads the audio engine directly.
*/
store()._syncTime(el.currentTime, el.duration);
syncTime(el);
reportMediaPosition("music");
tickSleepTimer();

Expand All @@ -90,11 +103,7 @@ export function GlobalMusicAudio() {
musicListens.hold();
}
}),
onEnded: fromActive(() => {
musicListens.finish(Date.now());
if (sleepTimerTakesEnd()) return;
store().handleEnded();
}),
onEnded: fromActive(finishTrack),
onError: (event: SyntheticEvent<HTMLAudioElement>) => {
if (event.currentTarget === musicAudioEngine.getActiveElement()) store()._onPlaybackError();
else store()._onPreloadError();
Expand Down
45 changes: 45 additions & 0 deletions src/lib/audio/listedLength.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";

import { listedSeconds, pastListedEnd, playableLength } from "./listedLength";

describe("playableLength", () => {
it("takes the listed length when WebKit doubles an AAC stream's", () => {
expect(playableLength(490.5, 245.269)).toBe(245.269);
});

it("keeps the element's own length when it agrees with the listing", () => {
expect(playableLength(245.3, 245.281)).toBe(245.3);
expect(playableLength(250, 245)).toBe(250);
});

it("falls back to the listing while the element has no length", () => {
expect(playableLength(Number.NaN, 200)).toBe(200);
expect(playableLength(Number.POSITIVE_INFINITY, 200)).toBe(200);
expect(playableLength(Number.NaN, null)).toBe(0);
});

it("trusts the element when nothing is listed", () => {
expect(playableLength(490.5, null)).toBe(490.5);
expect(playableLength(490.5, 0)).toBe(490.5);
});
});

describe("pastListedEnd", () => {
it("ends a doubled song at its listed length", () => {
expect(pastListedEnd(245.1, 490.5, 245.269)).toBe(false);
expect(pastListedEnd(245.3, 490.5, 245.269)).toBe(true);
});

it("leaves a correctly reported song to the element's own end", () => {
expect(pastListedEnd(245.3, 245.3, 245.281)).toBe(false);
expect(pastListedEnd(300, 245.3, null)).toBe(false);
});
});

describe("listedSeconds", () => {
it("prefers the stream's own length over the song's rounded one", () => {
expect(listedSeconds({ approxDurationMs: 245269 }, { duration: 245 })).toBe(245.269);
expect(listedSeconds({ approxDurationMs: null }, { duration: 245 })).toBe(245);
expect(listedSeconds({ approxDurationMs: null }, { duration: null })).toBeNull();
});
});
31 changes: 31 additions & 0 deletions src/lib/audio/listedLength.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { MusicStreamInfo, SongItem } from "../../types/music";

/** How far past the listed length a reported one has to be before it is taken as wrong. */
const OVERREPORT_RATIO = 1.25;

/**
* The length a song really plays for. WebKit on macOS reports YouTube's fragmented MP4 (AAC)
* audio at twice its length, though the file's own header has it right, so the element plays the
* song and then runs on in silence. A length well past the one YouTube lists is that bug, and the
* listed one wins; otherwise the element knows best, and it is 0 while neither is known.
*/
export function playableLength(reported: number, listed: number | null): number {
const known = listed !== null && listed > 0 ? listed : 0;
if (!Number.isFinite(reported) || reported <= 0) return known;
return known > 0 && reported > known * OVERREPORT_RATIO ? known : reported;
}

/** Whether `position` is past the end of a song whose element reports more than it holds. */
export function pastListedEnd(position: number, reported: number, listed: number | null): boolean {
const length = playableLength(reported, listed);
return length > 0 && length < reported && position >= length;
}

/** A stream's listed length in seconds: YouTube's for that file, else the song's own. */
export function listedSeconds(
info: Pick<MusicStreamInfo, "approxDurationMs">,
track: Pick<SongItem, "duration">,
): number | null {
if (info.approxDurationMs) return info.approxDurationMs / 1000;
return track.duration ?? null;
}
40 changes: 40 additions & 0 deletions src/lib/audio/musicAudioEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,43 @@ describe("sleep timer fade-out", () => {
expect(pair[0].volume).toBeCloseTo(1);
});
});

describe("a song WebKit reports at twice its length", () => {
const reports = (el: HTMLAudioElement, duration: number, currentTime: number) => {
Object.defineProperty(el, "duration", { value: duration, configurable: true });
Object.defineProperty(el, "currentTime", { value: currentTime, configurable: true, writable: true });
};

it("plays for its listed length and ends there once", async () => {
await musicAudioEngine.load("http://127.0.0.1/stream/aac", 'audio/mp4; codecs="mp4a.40.2"', 245.269);
reports(pair[0], 490.5, 120);
expect(musicAudioEngine.getDuration()).toBe(245.269);
expect(musicAudioEngine.takeListedEnd()).toBe(false);

reports(pair[0], 490.5, 245.4);
expect(musicAudioEngine.takeListedEnd()).toBe(true);
expect(pair[0].pause).toHaveBeenCalled();
expect(musicAudioEngine.takeListedEnd()).toBe(false);

// Repeat one seeks back, and the song can end again.
musicAudioEngine.seek(0);
reports(pair[0], 490.5, 245.4);
expect(musicAudioEngine.takeListedEnd()).toBe(true);
});

it("carries the next song's listed length through the gapless swap", async () => {
await musicAudioEngine.load("http://127.0.0.1/stream/current", null, 200);
musicAudioEngine.preload("next", "http://127.0.0.1/stream/next", null, null, 180);
bufferedEnough(pair[1]);
musicAudioEngine.activatePreloaded("next");
reports(pair[1], 360, 0);
expect(musicAudioEngine.getDuration()).toBe(180);
});

it("leaves a correctly reported song to its own end", async () => {
await musicAudioEngine.load("http://127.0.0.1/stream/opus", 'audio/webm; codecs="opus"', 245.281);
reports(pair[0], 245.3, 245.29);
expect(musicAudioEngine.getDuration()).toBe(245.3);
expect(musicAudioEngine.takeListedEnd()).toBe(false);
});
});
41 changes: 37 additions & 4 deletions src/lib/audio/musicAudioEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createAudioContext, keepRunning, resumeWithin, webAudioSupported } from
import { fadeInGain, fadeOutGain, resetFade, scheduleFade, steppedFade } from "./crossfadeRamp";
import { EQ_GRAPH_OFF, type EqGraphSpec } from "./eq/graphPlan";
import { EqChain, RAMP_TIME_CONSTANT } from "./eqChain";
import { pastListedEnd, playableLength } from "./listedLength";
import { volumeGain } from "./volumeCurve";
import { webAudioHears } from "./webAudioFormats";

Expand All @@ -23,6 +24,10 @@ interface Slot {
loudnessDb: number | null;
/** The loaded file's MIME type, which decides whether WebAudio can hear it. */
mime: string | null;
/** The song's listed length in seconds, for when the element reports a wrong one. */
listed: number | null;
/** Set once the end at the listed length has been handed to the player. */
listedEndTaken: boolean;
/** The track a standby slot holds, so a swap only ever plays the right one. */
key: string | null;
}
Expand All @@ -35,6 +40,8 @@ const emptySlot = (): Slot => ({
fade: 1,
loudnessDb: null,
mime: null,
listed: null,
listedEndTaken: false,
key: null,
});

Expand Down Expand Up @@ -161,11 +168,13 @@ class MusicAudioEngine {

// --- transport ----------------------------------------------------------

async load(url: string, mime: string | null): Promise<void> {
async load(url: string, mime: string | null, listedSeconds: number | null = null): Promise<void> {
const el = this.active.el;
if (!el) return;
this.active.key = null;
this.active.mime = mime;
this.active.listed = listedSeconds;
this.active.listedEndTaken = false;
this.applyGain();
el.src = url;
try {
Expand All @@ -179,12 +188,20 @@ class MusicAudioEngine {
* take over the moment the current track ends. A no-op when it already holds
* that track, or while it is still fading out the last one.
*/
preload(key: string, url: string, loudnessDb: number | null, mime: string | null): void {
preload(
key: string,
url: string,
loudnessDb: number | null,
mime: string | null,
listedSeconds: number | null = null,
): void {
const slot = this.standby;
if (!slot.el || slot.key === key || this.fading) return;
slot.key = key;
slot.loudnessDb = loudnessDb;
slot.mime = mime;
slot.listed = listedSeconds;
slot.listedEndTaken = false;
slot.el.src = url;
try {
slot.el.load();
Expand Down Expand Up @@ -245,6 +262,8 @@ class MusicAudioEngine {
slot.key = null;
slot.loudnessDb = null;
slot.mime = null;
slot.listed = null;
slot.listedEndTaken = false;
}

// The two elements play together while their gains move in opposite directions; the one
Expand Down Expand Up @@ -346,6 +365,7 @@ class MusicAudioEngine {
const el = this.active.el;
if (el && Number.isFinite(seconds)) {
el.currentTime = Math.max(0, seconds);
this.active.listedEndTaken = false;
}
}

Expand All @@ -366,8 +386,21 @@ class MusicAudioEngine {
}

getDuration(): number {
const d = this.active.el?.duration ?? 0;
return Number.isFinite(d) ? d : 0;
return playableLength(this.active.el?.duration ?? Number.NaN, this.active.listed);
}

/**
* True once, when the playing song passes its listed length on an element that reports more
* than it holds. The element would play silence until its own end and fire `ended` only then,
* so it is paused here and the caller ends the song instead.
*/
takeListedEnd(): boolean {
const slot = this.active;
const el = slot.el;
if (!el || slot.listedEndTaken || !pastListedEnd(el.currentTime, el.duration, slot.listed)) return false;
slot.listedEndTaken = true;
el.pause();
return true;
}

// --- speed --------------------------------------------------------------
Expand Down
5 changes: 4 additions & 1 deletion src/lib/musicPreload.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { SongItem } from "../types/music";
import type { MusicAudioQuality } from "./api/music";
import { listedSeconds } from "./audio/listedLength";
import { musicAudioEngine } from "./audio/musicAudioEngine";
import { resolveMusicStream } from "./musicStreamResolution";
import { findDownloadedRecord } from "./useDownloads";
Expand Down Expand Up @@ -35,7 +36,9 @@ export async function preloadTrack(
recordPlayerEvent(`music preload start: ${videoId}`);
try {
const info = await resolveMusicStream(videoId, quality, { preferAac: musicAudioEngine.wantsAac() });
if (stillUpcoming(videoId)) musicAudioEngine.preload(videoId, info.audioUrl, info.loudnessDb, info.mimeType);
if (stillUpcoming(videoId)) {
musicAudioEngine.preload(videoId, info.audioUrl, info.loudnessDb, info.mimeType, listedSeconds(info, track));
}
} catch {
// Speculative: if it fails, the track resolves again when it is played.
} finally {
Expand Down
5 changes: 3 additions & 2 deletions src/store/useMusicPlayerStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useDownloadsLibraryStore } from "./useDownloadsLibraryStore";
import { normalizeBackendError } from "../lib/api/errors";
import { recordPlayerEvent } from "../lib/playerDiagnostics";
import { logToBackend } from "../lib/diagnostics";
import { listedSeconds } from "../lib/audio/listedLength";
import { musicAudioEngine } from "../lib/audio/musicAudioEngine";
import { migrateSavedMusicVolume } from "../lib/audio/volumeCurve";
import { preloadTrack } from "../lib/musicPreload";
Expand Down Expand Up @@ -505,7 +506,7 @@ export const useMusicPlayerStore = create<MusicPlayerState>((set, get) => ({
: {}),
});
musicAudioEngine.setLoudness(offline.loudnessDb, get().normalizationEnabled);
await musicAudioEngine.load(offline.url, offline.contentType);
await musicAudioEngine.load(offline.url, offline.contentType, track.duration ?? null);
musicAudioEngine.seekWhenReady(resumeAt);
await start();
afterStart();
Expand All @@ -532,7 +533,7 @@ export const useMusicPlayerStore = create<MusicPlayerState>((set, get) => ({

set({ loudnessDb: info.loudnessDb, loadingStreamId: null });
musicAudioEngine.setLoudness(info.loudnessDb, get().normalizationEnabled);
await musicAudioEngine.load(info.audioUrl, info.mimeType);
await musicAudioEngine.load(info.audioUrl, info.mimeType, listedSeconds(info, track));
musicAudioEngine.seekWhenReady(resumeAt);
await start();
afterStart();
Expand Down
Loading