When transitioning from ALSA Direct to CPAL fallback (e.g. for rate
resampling), PipeWire auto-re-acquires the device after ALSA Direct
releases it. CPAL then can't open the device.
Suspend PipeWire sinks before creating CPAL stream in exclusive mode,
same pattern used in ALSA Direct DeviceBusy handler.
When ALSA Direct opens a PCM device and fails to set the rate, the
USB DAC doesn't release the device fast enough for CPAL to open it
afterwards. This caused "device busy" errors even with a delay.
New approach: read /proc/asound/cardN/stream0 to check supported
rates BEFORE opening the device. If the rate isn't supported, skip
ALSA Direct entirely and fall through to CPAL (which has rate
fallback logic). The device is never touched at the wrong rate.
When ALSA Direct opens a PCM device and fails to configure the rate,
the PCM handle is dropped but the kernel may not release the device
instantly. CPAL then fails with "Device busy" when trying to open it.
Add 100ms sleep after InvalidParams to let the kernel fully release
the device before CPAL attempts to open it. Also fix error messages
to show the effective (fallback) rate instead of the original rate.
When ALSA Direct can't handle a rate (e.g. 176.4kHz) and falls back
to CPAL, CPAL also fails because the hardware doesn't support it.
Port find_best_fallback_rate from PipeWire backend: queries supported
rates via CPAL, picks highest rate in same family (44.1k or 48k)
that's <= requested. 176.4kHz on a 96kHz-max DAC now falls back to
88.2kHz and rodio resamples, instead of failing entirely.
When hardware doesn't natively support a sample rate (e.g. 176.4kHz),
return None from try_create_direct_stream instead of a hard error.
This lets the player fall through to CPAL/rodio which can resample.
Applied to both hw and plughw paths — InvalidParams on rate now
triggers graceful fallback instead of killing playback.
Previous commit removed the miniplayer close from CloseRequested,
but that kept the app alive because Tauri won't exit while windows
remain open. Now closes secondary windows with close() (not
destroy()) in CloseRequested before the main window closes.
Also hides the miniplayer when close-to-tray activates.
The miniplayer window was being destroyed twice during shutdown:
once in CloseRequested and again in RunEvent::Exit. This caused
WebKit's TLS destructors to collide during process teardown,
resulting in SIGABRT in WebKitWebProcess.
Changes:
- Remove miniplayer destroy from CloseRequested (was premature)
- Guard global on_window_event to only handle main window
- In RunEvent::Exit, close all secondary windows (miniplayer, oauth)
with close() instead of destroy() for graceful WebKit cleanup
- Add 100ms delay after closing secondary windows to let WebKit
tear down EGL/GPU resources before process exit
Extends PR #136 (save-time validation) with load-time validation.
If the DB contains corrupt sizes (e.g. 9084748x62267212), get_settings()
now returns safe defaults instead of passing garbage to GDK/Cairo.
Also extracts validation into a shared helper and adds NaN/Inf checks.
When switching from PipeWire to ALSA Direct, PipeWire holds the ALSA
device open causing EBUSY on the first snd_pcm_open. Now detects
DeviceBusy, suspends the PipeWire sink via pactl, waits 200ms for
release, then retries. Applied to both hw and plughw paths.
v2_get_hardware_audio_status was reading sample_rate/bit_depth from the
legacy player state, which is never updated since playback runs through
the V2 player (CoreBridge). Now reads from V2 player first, falls back
to legacy only if CoreBridge isn't initialized yet.
cpal 0.15 returned "ALSA" from HostId::name(), cpal 0.17 returns "Alsa"
(now uses stringify! on the enum variant). Use eq_ignore_ascii_case()
for the comparison so it works regardless of cpal version.
Query the DAC's supported sample rates from /proc/asound/cardN/stream0
(maps PipeWire sink to ALSA card via pactl). If the track's native rate
isn't supported (e.g., 176.4kHz), fall back to the nearest rate in the
same family (e.g., 88.2kHz). Rodio resamples from track rate to stream
rate automatically, so audio plays instead of producing silence.
The OAuth login path in runtime_bootstrap was missing the audio settings
sync code that exists in the email/password path. Users logging in via
OAuth had backend_type remain None, causing legacy CPAL fallback and
requiring manual settings reconfiguration on every startup.
rodio 0.22's with_supported_config() resets buffer_size to Default via
..Default::default(), so our BufferSize settings in StreamConfig were
never reaching CPAL. MixerDeviceSink has zero internal buffering, making
CPAL's buffer the ONLY buffer between the mixer and audio hardware.
Chain .with_buffer_size(Fixed(sample_rate/10)) AFTER with_supported_config()
to actually pass ~100ms buffer to CPAL, matching old vendored cpal behavior.
PipeWire manages its own buffer scheduling via quantum. Forcing a fixed
buffer size (sample_rate / 10 = 19200 frames at 192kHz) conflicts with
PipeWire's internal scheduling and causes buffer underruns that produce
audible noise.
BufferSize::Fixed is kept only for:
- Exclusive mode (512 frames, low latency)
- ALSA backend (direct ALSA, not through PipeWire)
rodio 0.22's MixerDeviceSink has an internal mixer thread that cannot
synchronize with PipeWire's forced quantum scheduling. This caused
massive buffer underruns (600+) at sample rates >= 88.2kHz when Force
Bit Perfect was enabled.
clock.force-rate alone is sufficient for bit-perfect sample rate
switching. Logs confirm zero underruns at 88.2kHz and minimal underruns
at 192kHz (only during heavy I/O) when quantum is not forced.
Replace BufferSize::Default with BufferSize::Fixed(sample_rate / 10)
for non-exclusive mode. This preserves the 100ms period tuning that was
previously applied in the vendored cpal ALSA backend, reducing CPU
wakeups during playback.
- OutputStream/OutputStreamHandle -> DeviceSinkBuilder/MixerDeviceSink
- Sink -> Player (as RodioPlayer)
- current_frame_len -> current_span_len in all Source impls
- Source::channels() returns NonZero<u16>, sample_rate() returns NonZero<u32>
- SamplesBuffer no longer generic, constructor takes NonZero args
- SampleRate is now u32 type alias (no tuple struct)
- Decoder::new_mp4 no longer takes Mp4Type hint
- Applied across both crates/ workspace and src-tauri/ legacy code
Remove vendored rodio and cpal from src-tauri/vendor/ since rodio 0.22
now includes upstream f32 decoder support (our vendored modification).
Update all Cargo.toml files to use rodio 0.22 from crates.io and remove
the [patch.crates-io] section.
Handle RunEvent::Exit (fires on SIGTERM from tauri dev hot reload)
to stop audio playback and visualizer before process teardown. The
previous CloseRequested handler only fires on user-initiated close,
not on SIGTERM, leaving ALSA/CPAL C threads active during cleanup
which causes "free(): corrupted unsorted chunks" heap corruption.
- Switch from .run(context) to .build(context).run(callback)
- Stop visualizer and both legacy/V2 players on Exit event
- Destroy miniplayer window on Exit event
Replace browser-side pdfjs-dist canvas rendering with server-side
MuPDF rendering for high-quality PDF booklet display. MuPDF renders
pages to PNG at configurable DPI in Rust, producing much sharper
results than the JavaScript canvas approach which had persistent
JPEG 2000 WASM decoder issues.
- Add mupdf crate with sys-lib features (avoid GTK symbol conflicts)
- Create pdf_viewer.rs with v2_booklet_open/render_page/close commands
- Download PDF to temp file, render pages on demand via MuPDF
- Display rendered PNGs as img tags instead of canvas
- DPI scales with zoom and devicePixelRatio for crisp output
- Support rotation via MuPDF transformation matrix
The worker was loaded via Vite's blob: URL transform, which
prevented it from resolving sibling WASM files (openjpeg.wasm,
jbig2.wasm). Now the worker is served from static/pdfjs/ as a
real HTTP resource alongside the WASM files, so JPEG 2000 and
JBIG2 decoders initialize correctly.
Removed disableWorker hack — worker runs properly now.
PDF was rendering tiny because zoom=1 means 1 PDF point = 1 CSS
pixel. Now calculates zoom to fill the container width on load.
Fit-to-width button does the same recalculation.
pdfjs v5 checks GlobalWorkerOptions.workerSrc before reading
document options like disableWorker. Set it to satisfy the
init check even though the worker is disabled per-document.
The web worker runs in a blob: URL context where /pdfjs/ WASM
paths don't resolve, causing JPEG 2000 images to decode at
degraded quality. Disable worker so all decoding runs on the
main thread where static assets are reachable.
Also bumped render scale from 2x to 3x for near-print quality.
First page was black because renderPage() ran while isLoading was
still true, so the canvas wasn't in the DOM yet. Fixed by setting
isLoading=false and awaiting tick() before rendering.
Increased render quality from 1.5x to 2x DPR multiplier for
sharper booklet pages.
PDF booklets use JPEG 2000 images which require OpenJPEG WASM.
Copy pdfjs-dist WASM files to static/pdfjs/ so they are served
at a known URL, and set wasmUrl parameter on getDocument().
PDF loading was failing because pdfjs-dist cannot fetch from
static.qobuz.com due to CORS restrictions in the WebView.
Added v2_fetch_url_bytes command that fetches URL bytes through
Rust's reqwest (no CORS), then passes the binary data to pdfjs.
Also added rotation support (R key or toolbar button) for
vertical booklets.
Modal PDF viewer for album booklets with page navigation,
zoom controls, and keyboard shortcuts. Uses pdfjs-dist to
render PDF pages from URL in canvas.
All animations default to 60fps. Each panel reads its own setting
from userStorage via shared fpsConfig utility. Settings UI shows
a collapsed section with per-panel dropdowns:
- Background Blur, Spectrum, X/Y (Lissajous), Oscilloscope,
Energy Bands, Transient Pulse, Album Reactive, Spectral Ribbon
Options: Disabled, 15, 30, 60, 120 FPS
i18n keys added to all 4 locales (en, es, de, fr)
WebKit's WebProcess was crashing (SIGABRT) during EGL/GPU resource
cleanup when the app exited with the miniplayer window still alive.
Now explicitly destroy the miniplayer window before the main process
exits, giving WebKit time to clean up gracefully.
Handle RunEvent::Exit (fires on SIGTERM from tauri dev hot reload)
to stop audio playback and visualizer before process teardown. The
previous CloseRequested handler only fires on user-initiated close,
not on SIGTERM, leaving ALSA/CPAL C threads active during cleanup
which causes "free(): corrupted unsorted chunks" heap corruption.
- Switch from .run(context) to .build(context).run(callback)
- Stop visualizer and both legacy/V2 players on Exit event
- Destroy miniplayer window on Exit event