refactor: extract psysonic-audio crate (M3/7)

Moves all audio playback code (Symphonia decode, rodio output, HTTP
streaming, gapless, previews, and the seven stream/ source-type
submodules from the prior split) out of the top crate into a new
psysonic-audio crate.

  crates/psysonic-audio/                  new lib crate, depends on
                                          psysonic-core + psysonic-analysis
    src/{engine,helpers,decode,…}.rs      flattened layout (no more
                                          extra audio/ namespace level)
    src/stream/                           seven submodules from M0
    src/lib.rs                            re-exports macros from
                                          psysonic-core and the public
                                          API surface

The audio↔analysis edges identified in the dep survey are now real
crate deps (audio depends on analysis directly: AnalysisCache reads,
recommended_gain_for_target, submit_analysis_cpu_seed). Only the
analysis→audio back-edge goes through the PlaybackQueryHandle port
registered in M2.

Cross-crate ref migrations applied via batch sed:
  crate::audio::*               → crate::*       (intra-crate)
  crate::analysis_cache::*      → psysonic_analysis::analysis_cache::*
  crate::submit_analysis_cpu_seed → psysonic_analysis::analysis_runtime::*
  crate::subsonic_wire_user_agent → psysonic_core::user_agent::*

Top crate keeps `crate::audio::*` paths working via
`pub use psysonic_audio as audio;` — lib_commands/cli callers untouched.
`stop_audio_engine` (mac process-exit cleanup) moved into the audio
crate as `pub fn stop_audio_engine` since it reaches AudioEngine
internals; tray.rs now re-exports the moved fn.

Two small visibility promotions in engine.rs:
  pub(crate) fn analysis_track_id_is_current_playback   → pub
  pub(crate) fn ranged_loudness_backfill_should_defer    → pub

Behaviour preserving. Cargo check + clippy --workspace clean.
This commit is contained in:
Psychotoxical
2026-05-09 13:33:44 +02:00
parent ff456dd823
commit 41e75663f1
35 changed files with 126 additions and 54 deletions
+29
View File
@@ -3609,6 +3609,7 @@ dependencies = [
"lofty",
"md5",
"psysonic-analysis",
"psysonic-audio",
"psysonic-core",
"reqwest",
"ringbuf",
@@ -3656,6 +3657,34 @@ dependencies = [
"tokio",
]
[[package]]
name = "psysonic-audio"
version = "1.46.0-dev"
dependencies = [
"biquad",
"dasp_sample",
"futures-util",
"id3",
"libc",
"lofty",
"md5",
"psysonic-analysis",
"psysonic-core",
"reqwest",
"ringbuf",
"rodio",
"serde",
"serde_json",
"symphonia",
"symphonia-adapter-libopus",
"tauri",
"thread-priority",
"tokio",
"url",
"windows 0.62.2",
"zbus 5.15.0",
]
[[package]]
name = "psysonic-core"
version = "1.46.0-dev"
+1
View File
@@ -32,6 +32,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
psysonic-core = { path = "crates/psysonic-core" }
psysonic-analysis = { path = "crates/psysonic-analysis" }
psysonic-audio = { path = "crates/psysonic-audio" }
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-single-instance = "2"
tauri-plugin-shell = "2"
@@ -0,0 +1,41 @@
[package]
name = "psysonic-audio"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish = false
[dependencies]
psysonic-core = { path = "../psysonic-core" }
psysonic-analysis = { path = "../psysonic-analysis" }
tauri = { version = "2" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt", "time", "sync"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "rustls", "blocking"] }
futures-util = "0.3"
rodio = { version = "0.22", default-features = false, features = ["playback", "symphonia-all"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
symphonia-adapter-libopus = "0.2.9"
ringbuf = "0.5"
biquad = "0.6"
dasp_sample = "0.11.0"
md5 = "0.8"
url = "2"
thread-priority = "3"
lofty = "0.24"
id3 = "1.16.4"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
zbus = { version = "5.15", default-features = false, features = ["blocking-api"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_System_Com",
"Win32_System_Threading",
] }
@@ -65,7 +65,7 @@ pub struct AudioEngine {
pub gapless_switch_at: Arc<AtomicU64>,
/// Active radio session state. None for regular (non-radio) tracks.
/// Dropping the value aborts the HTTP download task via RadioLiveState::Drop.
pub(crate) radio_state: Mutex<Option<crate::audio::stream::RadioLiveState>>,
pub(crate) radio_state: Mutex<Option<crate::stream::RadioLiveState>>,
/// URL last committed to `AudioCurrent` — used so `audio_update_replay_gain` can
/// resolve LUFS / startup trim when the frontend passes `loudnessGainDb: null`
/// (otherwise `compute_gain` would treat that as unity gain and playback "jumps").
@@ -345,7 +345,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.use_rustls_tls()
.user_agent(crate::subsonic_wire_user_agent())
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_default(),
)),
@@ -381,7 +381,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
}
/// `analysis_enqueue_seed_from_url` should bail while this track's ranged HTTP buffer
/// is still filling — playback will seed on completion with the same bytes.
pub(crate) fn ranged_loudness_backfill_should_defer(engine: &AudioEngine, track_id: &str) -> bool {
pub fn ranged_loudness_backfill_should_defer(engine: &AudioEngine, track_id: &str) -> bool {
let tid = track_id.trim();
if tid.is_empty() {
return false;
@@ -392,9 +392,22 @@ pub(crate) fn ranged_loudness_backfill_should_defer(engine: &AudioEngine, track_
matches!(&*g, Some((t, _)) if t.as_str() == tid)
}
/// Stops the Rust audio engine cleanly (mirrors the logic in `audio_stop`).
/// Called before process exit on macOS to ensure audio stops immediately.
pub fn stop_audio_engine(app: &tauri::AppHandle) {
use std::sync::atomic::Ordering;
use tauri::Manager;
let engine = app.state::<AudioEngine>();
engine.generation.fetch_add(1, Ordering::SeqCst);
*engine.chained_info.lock().unwrap() = None;
drop(engine.radio_state.lock().unwrap().take());
let mut cur = engine.current.lock().unwrap();
if let Some(sink) = cur.sink.take() { sink.stop(); }
}
/// Subsonic id pinned for the playing source (`audio_play`). Used to prioritize
/// HTTP loudness backfill for the track the user is listening to.
pub(crate) fn analysis_track_id_is_current_playback(engine: &AudioEngine, track_id: &str) -> bool {
pub fn analysis_track_id_is_current_playback(engine: &AudioEngine, track_id: &str) -> bool {
let needle = track_id.trim();
if needle.is_empty() {
return false;
@@ -8,8 +8,8 @@ use rodio::Player;
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager};
use crate::audio::engine::AudioEngine;
use crate::audio::ipc::{
use crate::engine::AudioEngine;
use crate::ipc::{
partial_loudness_should_emit, PartialLoudnessPayload, PARTIAL_LOUDNESS_DELTA_THRESHOLD_DB,
};
@@ -336,7 +336,7 @@ pub(crate) fn resolve_loudness_gain_from_cache_impl(
}
return None;
};
let Some(cache) = app.try_state::<crate::analysis_cache::AnalysisCache>() else {
let Some(cache) = app.try_state::<psysonic_analysis::analysis_cache::AnalysisCache>() else {
if opts.log_soft_misses {
crate::app_deprintln!(
"[normalization] resolve_loudness_gain source=no-analysis-cache track_id={}",
@@ -351,7 +351,7 @@ pub(crate) fn resolve_loudness_gain_from_cache_impl(
}
match cache.get_latest_loudness_for_track(&track_id) {
Ok(Some(row)) if row.integrated_lufs.is_finite() => {
let recommended = crate::analysis_cache::recommended_gain_for_target(
let recommended = psysonic_analysis::analysis_cache::recommended_gain_for_target(
row.integrated_lufs,
row.true_peak,
target_lufs as f64,
@@ -404,7 +404,7 @@ const LOUDNESS_PLACEHOLDER_INTEGRATED_LUFS: f64 = -14.0;
pub(crate) fn loudness_gain_placeholder_until_cache(target_lufs: f32, pre_analysis_attenuation_db: f32) -> f32 {
let pre = pre_analysis_attenuation_db.clamp(-24.0, 0.0).min(0.0);
// `true_peak = 0.0` skips the headroom cap until integrated measurement exists.
let pivot = crate::analysis_cache::recommended_gain_for_target(
let pivot = psysonic_analysis::analysis_cache::recommended_gain_for_target(
LOUDNESS_PLACEHOLDER_INTEGRATED_LUFS,
0.0,
f64::from(target_lufs),
@@ -551,7 +551,7 @@ pub(crate) async fn fetch_data(
return Ok(Some(data));
}
let response = crate::audio::engine::audio_http_client(&state).get(url).send().await.map_err(|e| e.to_string())?;
let response = crate::engine::audio_http_client(&state).get(url).send().await.map_err(|e| e.to_string())?;
let status = response.status();
let ct = response.headers()
.get(reqwest::header::CONTENT_TYPE)
@@ -604,7 +604,7 @@ pub(crate) fn spawn_analysis_seed_from_in_memory_bytes(
let Some(track_id) = cache_track_id.map(str::trim).filter(|s| !s.is_empty()) else {
return;
};
if bytes.is_empty() || bytes.len() > crate::audio::stream::TRACK_STREAM_PROMOTE_MAX_BYTES {
if bytes.is_empty() || bytes.len() > crate::stream::TRACK_STREAM_PROMOTE_MAX_BYTES {
return;
}
let track_id = track_id.to_string();
@@ -616,12 +616,12 @@ pub(crate) fn spawn_analysis_seed_from_in_memory_bytes(
track_id,
bytes.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::audio::engine::analysis_seed_high_priority_for_track(&app, &track_id);
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
tokio::spawn(async move {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
if let Err(e) = crate::submit_analysis_cpu_seed(app.clone(), track_id.clone(), bytes, high).await {
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), bytes, high).await {
crate::app_eprintln!(
"[analysis] in-memory play path seed failed for {}: {}",
track_id,
@@ -1,7 +1,10 @@
//! Audio playback: Symphonia decode, rodio output, HTTP radio/streaming, gapless, previews.
//!
//! Implementation is split into submodules (`sources`, `decode`, `stream`, `commands`, …)
//! for navigation; behavior matches the historical single `audio.rs` file.
//! `psysonic-audio` — Symphonia decode, rodio output, HTTP radio/streaming,
//! gapless, previews. Submodules (`sources`, `decode`, `stream`, `commands`, …)
//! preserve the historical single `audio.rs` partitioning.
// Re-export the logging facade so submodules can keep using
// `crate::app_eprintln!()` / `crate::app_deprintln!()`.
pub use psysonic_core::{app_deprintln, app_eprintln, logging};
pub mod autoeq_commands;
mod codec;
@@ -51,4 +54,4 @@ pub fn register_post_sleep_audio_recovery(app: tauri::AppHandle) {
let _ = app;
}
pub(crate) use engine::{analysis_track_id_is_current_playback, ranged_loudness_backfill_should_defer};
pub use engine::{analysis_track_id_is_current_playback, ranged_loudness_backfill_should_defer, stop_audio_engine};
@@ -140,7 +140,7 @@ fn open_local_file_input(
);
if let Some(seed_id) = ctx.cache_id_for_tasks {
let skip_cpu_seed = app
.try_state::<crate::analysis_cache::AnalysisCache>()
.try_state::<psysonic_analysis::analysis_cache::AnalysisCache>()
.map(|c| c.cpu_seed_redundant_for_track(seed_id).unwrap_or(false))
.unwrap_or(false);
if !skip_cpu_seed {
@@ -174,9 +174,9 @@ fn open_local_file_input(
seed_id,
data.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::audio::engine::analysis_seed_high_priority_for_track(&app_seed, &seed_id);
let high = crate::engine::analysis_seed_high_priority_for_track(&app_seed, &seed_id);
if let Err(e) =
crate::submit_analysis_cpu_seed(app_seed.clone(), seed_id.clone(), data, high).await
psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app_seed.clone(), seed_id.clone(), data, high).await
{
crate::app_eprintln!(
"[analysis] local-file seed failed for {}: {}",
@@ -55,8 +55,8 @@ pub async fn audio_preload(
track_id,
data.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::audio::engine::analysis_track_id_is_current_playback(&state, &track_id);
if let Err(e) = crate::submit_analysis_cpu_seed(app.clone(), track_id.clone(), data.clone(), high).await {
let high = crate::engine::analysis_track_id_is_current_playback(&state, &track_id);
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), data.clone(), high).await {
crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e);
}
}
@@ -144,7 +144,7 @@ pub async fn audio_preview_play(
let preview_http = reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.use_rustls_tls()
.user_agent(crate::subsonic_wire_user_agent())
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| audio_http_client(&state));
let bytes = preview_http
@@ -279,9 +279,9 @@ pub(crate) async fn ranged_download_task(
}
downloaded += n;
downloaded_to.store(downloaded, Ordering::SeqCst);
if downloaded >= crate::audio::helpers::PARTIAL_LOUDNESS_MIN_BYTES
if downloaded >= crate::helpers::PARTIAL_LOUDNESS_MIN_BYTES
&& total_size > 0
&& last_partial_loudness_emit.elapsed() >= Duration::from_millis(crate::audio::helpers::PARTIAL_LOUDNESS_EMIT_INTERVAL_MS)
&& last_partial_loudness_emit.elapsed() >= Duration::from_millis(crate::helpers::PARTIAL_LOUDNESS_EMIT_INTERVAL_MS)
{
last_partial_loudness_emit = Instant::now();
if normalization_engine.load(Ordering::Relaxed) == 2 {
@@ -289,14 +289,14 @@ pub(crate) async fn ranged_download_task(
let start_db = f32::from_bits(loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed))
.clamp(-24.0, 0.0);
if let Some(provisional_db) =
crate::audio::helpers::provisional_loudness_gain_from_progress(downloaded, total_size, target_lufs, start_db)
crate::helpers::provisional_loudness_gain_from_progress(downloaded, total_size, target_lufs, start_db)
{
let track_key = crate::audio::helpers::playback_identity(&url).unwrap_or_else(|| url.clone());
if crate::audio::ipc::partial_loudness_should_emit(&track_key, provisional_db) {
let track_key = crate::helpers::playback_identity(&url).unwrap_or_else(|| url.clone());
if crate::ipc::partial_loudness_should_emit(&track_key, provisional_db) {
let _ = app.emit(
"analysis:loudness-partial",
crate::audio::ipc::PartialLoudnessPayload {
track_id: crate::audio::helpers::playback_identity(&url),
crate::ipc::PartialLoudnessPayload {
track_id: crate::helpers::playback_identity(&url),
gain_db: provisional_db,
target_lufs,
is_partial: true,
@@ -368,8 +368,8 @@ pub(crate) async fn ranged_download_task(
);
}
if let Some(track_id) = cache_track_id {
let high = crate::audio::engine::analysis_seed_high_priority_for_track(&app, &track_id);
if let Err(e) = crate::submit_analysis_cpu_seed(app.clone(), track_id.clone(), data.clone(), high).await {
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), data.clone(), high).await {
crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e);
}
}
@@ -132,7 +132,7 @@ pub(crate) async fn track_download_task(
}
}
if !capture_over_limit
&& last_partial_loudness_emit.elapsed() >= Duration::from_millis(crate::audio::helpers::PARTIAL_LOUDNESS_EMIT_INTERVAL_MS)
&& last_partial_loudness_emit.elapsed() >= Duration::from_millis(crate::helpers::PARTIAL_LOUDNESS_EMIT_INTERVAL_MS)
{
last_partial_loudness_emit = Instant::now();
if normalization_engine.load(Ordering::Relaxed) == 2 {
@@ -141,7 +141,7 @@ pub(crate) async fn track_download_task(
loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed),
)
.clamp(-24.0, 0.0);
crate::audio::helpers::emit_partial_loudness_from_bytes(&app, &url, &capture, target_lufs, pre_db);
crate::helpers::emit_partial_loudness_from_bytes(&app, &url, &capture, target_lufs, pre_db);
}
}
offset += pushed;
@@ -160,9 +160,9 @@ pub(crate) async fn track_download_task(
track_id,
capture.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::audio::engine::analysis_seed_high_priority_for_track(&app, &track_id);
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
if let Err(e) =
crate::submit_analysis_cpu_seed(app.clone(), track_id.clone(), capture.clone(), high).await
psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), capture.clone(), high).await
{
crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e);
}
+1 -4
View File
@@ -1,7 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod audio;
pub mod cli;
mod discord;
mod lib_commands;
@@ -12,9 +11,7 @@ pub use psysonic_core::user_agent::{
default_subsonic_wire_user_agent, runtime_subsonic_wire_user_agent, subsonic_wire_user_agent,
};
pub use psysonic_analysis::{analysis_cache, analysis_runtime};
// `crate::submit_analysis_cpu_seed` shorthand kept so the audio modules don't
// have to spell out `psysonic_analysis::analysis_runtime::...` until M3.
pub(crate) use psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed;
pub use psysonic_audio as audio;
#[cfg(target_os = "windows")]
mod taskbar_win;
mod tray_runtime;
+1 -13
View File
@@ -1,12 +1,9 @@
use std::sync::atomic::Ordering;
use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem};
use tauri::tray::{MouseButton, TrayIcon, TrayIconBuilder, TrayIconEvent};
use tauri::{Emitter, Manager};
#[cfg(not(target_os = "windows"))]
use tauri::tray::MouseButtonState;
use crate::audio;
use crate::tray_runtime::{
tray_state_icon, TrayMenuItems, TrayMenuItemsState, TrayMenuLabels, TrayMenuLabelsState,
TrayPlaybackState, TrayState, TrayTooltip,
@@ -355,16 +352,7 @@ pub(crate) fn toggle_tray_icon(
Ok(())
}
/// Stops the Rust audio engine cleanly (mirrors the logic in `audio_stop`).
/// Called before process exit on macOS to ensure audio stops immediately.
pub(crate) fn stop_audio_engine(app: &tauri::AppHandle) {
let engine = app.state::<audio::AudioEngine>();
engine.generation.fetch_add(1, Ordering::SeqCst);
*engine.chained_info.lock().unwrap() = None;
drop(engine.radio_state.lock().unwrap().take());
let mut cur = engine.current.lock().unwrap();
if let Some(sink) = cur.sink.take() { sink.stop(); }
}
pub(crate) use crate::audio::stop_audio_engine;
/// Returns `true` if running under a tiling window manager (Hyprland, Sway, i3,
/// bspwm, AwesomeWM, Openbox, etc.). Detection is based on environment variables