Compare commits

...

11 Commits

Author SHA1 Message Date
Psychotoxical 71a8ad673d diag(#1094): instrument device-reset handler (pause-restart on macOS)
Log the device-reset path the same way as device-changed: payload, the
currentTrack/isPlaying snapshot, and whether it restarts playback or correctly
takes the paused branch. Pinpoints whether the macOS headphone-disconnect
restart is a snapshot bug (isPlaying wrongly true) or cpal-internal. TEMPORARY.
2026-06-15 22:59:52 +02:00
Psychotoxical a1d8116b96 diag(#1072): build marker + always-log player persist write
Add a startup DIAG-BUILD line so the log proves the right commit is running,
and log every psysonic-player persist write (not just >80ms). If set() stalls
seconds while these writes stay tiny, the freeze is the synchronous React
render, not localStorage. TEMPORARY.
2026-06-15 21:47:00 +02:00
Psychotoxical 48a39d5930 diag(#1072): time the synchronous persist write
Pin down whether the skip-freeze is the localStorage persist write (large
queue serialised on every set) vs React render. Log any setItem >80ms with
key/duration/size. TEMPORARY — revert before the real fix.
2026-06-15 21:22:29 +02:00
Psychotoxical 73c766cb8d diag(#1072): freeze-survivable localStorage marker trail
invoke()-based markers lose the last entry when the main thread freezes
(IPC flush never runs). Mirror each marker synchronously to localStorage and
replay the trail to the Rust log on next launch. Adds a runPlayTrack ENTRY
marker so we can tell whether skip even reaches the function. TEMPORARY.
2026-06-15 21:03:11 +02:00
Psychotoxical 9711047fc6 diag(#1090/#1072): log resume-false reason + playTrack freeze location
Round 2: (1) why does try_resume_after_device_change return false despite
was_playing=true — log the exact early-return reason (url/radio/cache-miss/
generation-race/source-build). (2) where does the frontend playTrack flow
freeze the main thread — markers around set(currentTrack) and before the
audio_play invoke. TEMPORARY — revert before the real fix.
2026-06-15 20:44:51 +02:00
Psychotoxical 9ead421451 diag(#1090/#1072): trace device-changed event into the frontend hang
The Rust reopen path returns cleanly (watcher keeps polling), so the UI
hang lives in the frontend after audio:device-changed. Log the emitted
payload (resumed null vs f64) and trace the bridge: event received → store
state → before/after playTrack. Last surviving line localises the hang.
TEMPORARY — revert before the real fix.
2026-06-15 20:14:05 +02:00
Psychotoxical da1ad78fbe diag(#1090): log device-watcher default-device polling
TEMPORARY diagnostic logging to pinpoint why the app does not follow the
Windows system-default output device change (#1090). Logs every poll the
pinned device + cpal current_default vs last_default, and an explicit line
when Case 1 (system default changed) fires. Not for merge — revert before
the real fix lands.
2026-06-15 19:40:20 +02:00
github-actions[bot] de1924d5e3 chore(nix): refresh lock + npmDepsHash for v1.48.0 2026-06-14 20:19:27 +00:00
github-actions[bot] 85e91f5375 chore(release): finalize release version 1.48.0 2026-06-14 20:01:50 +00:00
github-actions[bot] 20cbdd123d chore(nix): refresh lock + npmDepsHash for v1.48.0-rc.4 2026-06-14 19:49:07 +00:00
github-actions[bot] 098788b56f chore(release): bump next channel to 1.48.0-rc.4 2026-06-14 19:32:01 +00:00
13 changed files with 129 additions and 19 deletions
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"lastModified": 1781074563,
"narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca",
"type": "github"
},
"original": {
+1 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-nwCrZ0enhyLNkS15sgSR9CuQX9cif4fLDNAL2yUPG8s="
"npmDepsHash": "sha256-ndXqYgws77qokAXznbQ6BXhXUo3VIaiF1AVs5jCkNCo="
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "psysonic",
"version": "1.48.0-dev",
"version": "1.48.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.48.0-dev",
"version": "1.48.0",
"dependencies": {
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/figtree": "^5.2.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.48.0-dev",
"version": "1.48.0",
"private": true,
"scripts": {
"check:css-imports": "node scripts/check-css-import-graph.mjs",
+7 -7
View File
@@ -4114,7 +4114,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.48.0-dev"
version = "1.48.0"
dependencies = [
"biquad",
"dasp_sample",
@@ -4167,7 +4167,7 @@ dependencies = [
[[package]]
name = "psysonic-analysis"
version = "1.48.0-dev"
version = "1.48.0"
dependencies = [
"ebur128",
"futures-util",
@@ -4186,7 +4186,7 @@ dependencies = [
[[package]]
name = "psysonic-audio"
version = "1.48.0-dev"
version = "1.48.0"
dependencies = [
"biquad",
"dasp_sample",
@@ -4216,7 +4216,7 @@ dependencies = [
[[package]]
name = "psysonic-core"
version = "1.48.0-dev"
version = "1.48.0"
dependencies = [
"libc",
"serde",
@@ -4225,7 +4225,7 @@ dependencies = [
[[package]]
name = "psysonic-integration"
version = "1.48.0-dev"
version = "1.48.0"
dependencies = [
"discord-rich-presence",
"futures-util",
@@ -4242,7 +4242,7 @@ dependencies = [
[[package]]
name = "psysonic-library"
version = "1.48.0-dev"
version = "1.48.0"
dependencies = [
"psysonic-core",
"psysonic-integration",
@@ -4257,7 +4257,7 @@ dependencies = [
[[package]]
name = "psysonic-syncfs"
version = "1.48.0-dev"
version = "1.48.0"
dependencies = [
"futures-util",
"id3",
+1 -1
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "1.48.0-dev"
version = "1.48.0"
edition = "2021"
rust-version = "1.95"
license = "GPL-3.0-or-later"
@@ -61,17 +61,20 @@ pub(crate) async fn try_resume_after_device_change(
}
let url = match snap.url.as_deref() {
Some(u) if !u.is_empty() => u,
_ => return false,
_ => { crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: url empty/none"); return false; }
};
let Some(engine) = app.try_state::<AudioEngine>() else {
crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: no AudioEngine state");
return false;
};
// Skip radio — live streams don't have a resume position.
if engine.radio_state.lock().unwrap().is_some() {
crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: radio active");
return false;
}
crate::app_eprintln!("[psysonic] DIAG-1090 try_resume: url={url} (kind={})", if url.starts_with("psysonic-local://") { "local" } else { "http" });
// Build a PlayInput without re-downloading:
// - psysonic-local:// → seekable file
@@ -118,7 +121,7 @@ pub(crate) async fn try_resume_after_device_change(
return false;
}
},
None => return false, // not fully cached yet — frontend will re-fetch
None => { crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: HTTP not fully cached (no RAM cache, no spill) for url={url}"); return false; } // not fully cached yet — frontend will re-fetch
}
};
PlayInput::Bytes(bytes)
@@ -131,6 +134,7 @@ pub(crate) async fn try_resume_after_device_change(
*engine.current_playback_url.lock().unwrap() = Some(url.to_owned());
if engine.generation.load(Ordering::SeqCst) != gen {
crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: generation race (early, after url/cache build)");
return false; // raced with another audio_play
}
@@ -169,12 +173,13 @@ pub(crate) async fn try_resume_after_device_change(
{
Ok(ps) => ps,
Err(e) => {
crate::app_eprintln!("[device-resume] source build failed: {e}");
crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: source build failed: {e}");
return false;
}
};
if engine.generation.load(Ordering::SeqCst) != gen {
crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: generation race (late, after source build)");
return false;
}
@@ -93,6 +93,15 @@ pub(crate) async fn reopen_output_stream(
// Falls back gracefully to the frontend path if conditions aren't met.
let resumed = try_resume_after_device_change(app, &snapshot).await;
// [DIAG #1090 — TEMPORARY] What does Rust hand to the frontend? resumed=true
// → null payload (frontend does nothing). resumed=false → f64 → frontend
// calls playTrack. This tells us which frontend branch the hang follows.
crate::app_eprintln!(
"[psysonic] DIAG-1090 reopen returning: resumed={resumed} was_playing={} payload={}",
snapshot.is_playing,
if resumed { "null".to_string() } else { format!("{:?}", snapshot.current_time_secs) }
);
match notify {
ReopenNotify::DeviceChanged => {
// null → Rust already resumed; frontend skips playTrack
@@ -267,6 +276,13 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
(default, available)
}).await.unwrap_or((None, vec![]));
// [DIAG #1090 — TEMPORARY] Every poll: what does cpal report as the
// current default, and is anything pinned? If `current_default` never
// changes when the OS default switches, cpal isn't detecting it (root A).
crate::app_eprintln!(
"[psysonic] DIAG-1090 poll: pinned={pinned:?} current_default={current_default:?} last_default={last_default:?}"
);
// Empty list (only when we actually enumerated for a pinned device)
// almost always means a transient enumeration failure, not that every
// output device vanished. Treating it as "pinned missing" caused false
@@ -317,6 +333,13 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
continue;
}
// [DIAG #1090 — TEMPORARY] Case 1 fired: cpal saw the default change.
// If you see THIS line but audio stays on the old device, the reopen
// path opens cpal's stale default (root B).
crate::app_eprintln!(
"[psysonic] DIAG-1090 Case-1 fired: {last_default:?} -> {current_default:?} — reopening on new default"
);
last_default = current_default.clone();
let Some(_new_name) = current_default else { continue };
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.48.0-dev",
"version": "1.48.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
@@ -3,6 +3,8 @@ import { listen } from '@tauri-apps/api/event';
import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore';
import { setSeekFallbackVisualTarget } from '../../store/seekFallbackState';
// [DIAG #1090/#1072 — TEMPORARY] freeze-survivable markers (see diag1090.ts).
import { markDiag1090 as diag1090, recoverDiag1090 } from '../../store/diag1090';
/** Audio output device lifecycle: device switches (Bluetooth headphones, USB
* DAC, ) and pinned-device-unplugged fallbacks emitted by the Rust
@@ -14,14 +16,20 @@ import { setSeekFallbackVisualTarget } from '../../store/seekFallbackState';
* must call playTrack and seek to that position.
*/
export function useAudioDeviceBridge() {
// [DIAG #1090/#1072 — TEMPORARY] On mount, surface any marker trail the
// previous (frozen, then killed) session left behind.
useEffect(() => { recoverDiag1090(); }, []);
useEffect(() => {
let unlisten: (() => void) | undefined;
listen('audio:device-changed', (event) => {
diag1090(`device-changed received payload=${JSON.stringify(event.payload)}`);
// null payload = Rust handled internal replay; nothing to do here.
if (event.payload === null) return;
const resumeAt = typeof event.payload === 'number' ? event.payload : 0;
const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
diag1090(`state currentTrack=${!!currentTrack} isPlaying=${isPlaying} resumeAt=${resumeAt}`);
if (!currentTrack) return;
if (isPlaying) {
if (resumeAt > 0.5 && currentTrack.duration > 0) {
@@ -31,7 +39,9 @@ export function useAudioDeviceBridge() {
setAtMs: Date.now(),
});
}
diag1090('before playTrack');
playTrack(currentTrack);
diag1090('after playTrack (returned)');
} else {
// Paused: clear warm-pause flag so the next resume uses the cold path
// (audio_play + seek) which creates a new Sink on the new device.
@@ -47,6 +57,7 @@ export function useAudioDeviceBridge() {
useEffect(() => {
let unlisten: (() => void) | undefined;
listen('audio:device-reset', (event) => {
diag1090(`device-reset received payload=${JSON.stringify(event.payload)}`);
useAuthStore.getState().setAudioOutputDevice(null);
// null payload = Rust handled internal replay on the new default device.
@@ -54,6 +65,7 @@ export function useAudioDeviceBridge() {
const resumeAt = typeof event.payload === 'number' ? event.payload : 0;
const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
diag1090(`device-reset state currentTrack=${!!currentTrack} isPlaying=${isPlaying} resumeAt=${resumeAt}`);
if (!currentTrack) return;
if (isPlaying) {
if (resumeAt > 0.5 && currentTrack.duration > 0) {
@@ -63,8 +75,11 @@ export function useAudioDeviceBridge() {
setAtMs: Date.now(),
});
}
diag1090('device-reset: before playTrack (RESTARTS PLAYBACK)');
playTrack(currentTrack);
diag1090('device-reset: after playTrack (returned)');
} else {
diag1090('device-reset: paused → resetAudioPause (no restart)');
resetAudioPause();
}
}).then(u => { unlisten = u; });
+49
View File
@@ -0,0 +1,49 @@
// [DIAG #1090/#1072 — TEMPORARY] Freeze-survivable marker trail.
//
// The UI hang blocks the JS main thread, so async `invoke()` logging loses the
// final marker before the freeze (the IPC flush never runs). We therefore also
// write each marker SYNCHRONOUSLY to localStorage, which persists across the
// freeze and the process kill. On the next launch `recoverDiag1090()` reads the
// trail back and forwards it to the Rust log, revealing the last reached point
// right before the hang. Remove this file with the real fix.
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from './authStore';
const KEY = 'diag1090_trail';
export function markDiag1090(m: string): void {
// Synchronous, freeze-proof: even if the thread blocks on the next line, this
// value is already committed to disk.
try {
const prev = localStorage.getItem(KEY);
const next = (prev ? prev + '\n' : '') + `${Date.now()}: ${m}`;
localStorage.setItem(KEY, next.split('\n').slice(-30).join('\n'));
} catch {
// ignore — diagnostics must never throw
}
// Best-effort live log (only survives when no freeze follows).
if (useAuthStore.getState().loggingMode !== 'debug') return;
void invoke('frontend_debug_log', { scope: 'diag1090', message: m }).catch(() => {});
}
// Call once on app start: if the previous session left a trail (it froze), emit
// it to the Rust log, then clear it.
export function recoverDiag1090(): void {
try {
// Build marker: confirms in the log that THIS diagnostic code is running,
// so we never again wonder whether the right commit was built.
void invoke('frontend_debug_log', {
scope: 'diag',
message: 'DIAG-BUILD active: persist-timing v2 + always-log-player',
}).catch(() => {});
const trail = localStorage.getItem(KEY);
if (!trail) return;
localStorage.removeItem(KEY);
void invoke('frontend_debug_log', {
scope: 'diag1090',
message: `RECOVERED TRAIL from previous session (last line = point before freeze):\n${trail}`,
}).catch(() => {});
} catch {
// ignore
}
}
+5
View File
@@ -36,6 +36,7 @@ import {
} from './loudnessGainCache';
import { refreshLoudnessForTrack } from './loudnessRefresh';
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
import { markDiag1090 as diag1090 } from './diag1090';
import { useOrbitStore } from './orbitStore';
import {
playbackSourceHintForResolvedUrl,
@@ -102,6 +103,7 @@ export function runPlayTrack(
_orbitConfirmed: boolean,
targetQueueIndex: number | undefined,
): void {
diag1090(`runPlayTrack: ENTRY trackId=${track.id} hasQueue=${!!queue}`);
// Orbit bulk-gate: only gate when the `queue` argument *replaces*
// the current queue (Play All / Play Album / Play Playlist / Hero
// play buttons). Navigation calls — queue-row click, next(),
@@ -318,6 +320,7 @@ export function runPlayTrack(
} else if (queueSid) {
seedQueueResolver(queueSid, [scopedTrack]);
}
diag1090('runPlayTrack: before set(currentTrack)');
set({
currentTrack: scopedTrack,
currentRadio: null,
@@ -338,6 +341,7 @@ export function runPlayTrack(
currentPlaybackSource: playbackSourceHint,
enginePreloadedTrackId: keepPreloadHint ? scopedTrack.id : null,
});
diag1090('runPlayTrack: after set ok');
if (
prevTrack
@@ -361,6 +365,7 @@ export function runPlayTrack(
isReplayGainActive(), authStateNow.replayGainMode,
);
const replayGainPeak = isReplayGainActive() ? (scopedTrack.replayGainPeak ?? null) : null;
diag1090('runPlayTrack: before invoke audio_play');
invoke('audio_play', {
url,
volume: state.volume,
+13
View File
@@ -1,4 +1,5 @@
import { createJSONStorage, type PersistStorage, type StateStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core'; // [DIAG #1072 — TEMPORARY]
/**
* `localStorage` wrapped so a failed write never throws.
@@ -29,6 +30,9 @@ const safeLocalStorage: StateStorage = {
}
},
setItem: (name, value) => {
// [DIAG #1072 — TEMPORARY] Time the synchronous write. If this is the
// skip-freeze, slow writes (large queue under disk load) show up here.
const t0 = performance.now();
try {
localStorage.setItem(name, value);
quotaWarned.delete(name);
@@ -41,6 +45,15 @@ const safeLocalStorage: StateStorage = {
);
}
}
const dt = performance.now() - t0;
// Always log the player key (the skip path); other keys only when slow. If
// these stay small while set() stalls for seconds, the freeze is the render.
if (name === 'psysonic-player' || dt > 80) {
void invoke('frontend_debug_log', {
scope: 'diag1072',
message: `persist write key=${name} ${Math.round(dt)}ms size=${(value.length / 1024).toFixed(1)}KB`,
}).catch(() => {});
}
},
removeItem: (name) => {
try {