From 03a551e6ff9fb98ae4b4fbd19a818700b1e47b9c Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 1 Jun 2026 04:29:18 -0600 Subject: [PATCH] feat(qconnect): real duration/queue_item_ids in join report + DeviceAudioQualityChanged(27) Add the RndrSrvrDeviceAudioQualityChanged(27) renderer report end to end: the report-type variant + message string in qconnect-protocol renderer.rs, the RendererDeviceAudioQualityChangedMessage proto struct plus the genuinely free tag-27 field on QConnectMessage (26=file AQ, 28=max AQ), and the mapper arm. Wire the adapter emitter report_device_audio_quality_if_changed (deduped via a new last_reported_device_audio_quality sync-state field) next to the file-AQ report, sourcing the real output stream config. Fix the deferred-join StateUpdated report to resolve the real current track duration (via CoreBridge) and current/next queue_item_ids instead of hardcoding nulls. cc --- crates/qconnect-protocol/src/mapper.rs | 39 ++++++++- .../src/queue_command_proto.rs | 13 +++ crates/qconnect-protocol/src/renderer.rs | 4 + src-tauri/src/qconnect/commands.rs | 16 ++++ src-tauri/src/qconnect/mod.rs | 3 + src-tauri/src/qconnect/service.rs | 84 ++++++++++++++++++- 6 files changed, 155 insertions(+), 4 deletions(-) diff --git a/crates/qconnect-protocol/src/mapper.rs b/crates/qconnect-protocol/src/mapper.rs index cc12dbe59..f199dfb07 100644 --- a/crates/qconnect-protocol/src/mapper.rs +++ b/crates/qconnect-protocol/src/mapper.rs @@ -15,7 +15,8 @@ use crate::{ DeviceInfoMessage, JoinSessionMessage, MuteVolumeMessage, PlaybackPositionMessage, QConnectMessage, QConnectMessageType, QConnectMessages, QueueAddTracksMessage, QueueInsertTracksMessage, QueueLoadTracksMessage, QueueRemoveTracksMessage, - QueueReorderTracksMessage, QueueVersionRef, RendererFileAudioQualityChangedMessage, + QueueReorderTracksMessage, QueueVersionRef, RendererDeviceAudioQualityChangedMessage, + RendererFileAudioQualityChangedMessage, RendererMaxAudioQualityChangedMessage, RendererStateMessage, RendererStateUpdatedMessage, RendererVolumeChangedMessage, RendererVolumeMutedMessage, SetActiveRendererMessage, SetAutoplayModeMessage, SetLoopModeMessage, SetMaxAudioQualityMessage, @@ -486,6 +487,19 @@ fn map_renderer_report(report: &RendererReport) -> Result Ok(QConnectMessage { + message_type: Some( + QConnectMessageType::MessageTypeRndrSrvrDeviceAudioQualityChanged as i32, + ), + rndr_srvr_device_audio_quality_changed: Some( + RendererDeviceAudioQualityChangedMessage { + sampling_rate: optional_i32(&report.payload, "sampling_rate")?, + bit_depth: optional_i32(&report.payload, "bit_depth")?, + nb_channels: optional_i32(&report.payload, "nb_channels")?, + }, + ), + ..Default::default() + }), RendererReportType::RndrSrvrMaxAudioQualityChanged => Ok(QConnectMessage { message_type: Some( QConnectMessageType::MessageTypeRndrSrvrMaxAudioQualityChanged as i32, @@ -928,6 +942,29 @@ mod tests { use qconnect_core::QueueVersion; use serde_json::json; + #[test] + fn maps_device_audio_quality_changed_report_to_tag_27() { + let report = RendererReport::new( + RendererReportType::RndrSrvrDeviceAudioQualityChanged, + "00000000-0000-0000-0000-000000000000", + QueueVersion { major: 1, minor: 2 }, + json!({ "sampling_rate": 96000, "bit_depth": 24, "nb_channels": 2 }), + ); + let msg = map_renderer_report(&report).expect("mapping should succeed"); + assert_eq!( + msg.message_type, + Some(QConnectMessageType::MessageTypeRndrSrvrDeviceAudioQualityChanged as i32) + ); + let dev = msg + .rndr_srvr_device_audio_quality_changed + .expect("device AQ submessage present"); + assert_eq!(dev.sampling_rate, Some(96000)); + assert_eq!(dev.bit_depth, Some(24)); + assert_eq!(dev.nb_channels, Some(2)); + assert!(msg.rndr_srvr_file_audio_quality_changed.is_none()); + assert!(msg.rndr_srvr_max_audio_quality_changed.is_none()); + } + #[test] fn encodes_add_tracks_command_into_binary_batch() { let command = QueueCommand::new( diff --git a/crates/qconnect-protocol/src/queue_command_proto.rs b/crates/qconnect-protocol/src/queue_command_proto.rs index 640703b6a..bbdb27852 100644 --- a/crates/qconnect-protocol/src/queue_command_proto.rs +++ b/crates/qconnect-protocol/src/queue_command_proto.rs @@ -715,6 +715,16 @@ pub struct RendererFileAudioQualityChangedMessage { pub audio_quality: Option, } +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct RendererDeviceAudioQualityChangedMessage { + #[prost(int32, optional, tag = "1")] + pub sampling_rate: Option, + #[prost(int32, optional, tag = "2")] + pub bit_depth: Option, + #[prost(int32, optional, tag = "3")] + pub nb_channels: Option, +} + #[derive(Clone, PartialEq, ::prost::Message)] pub struct RendererMaxAudioQualityChangedMessage { #[prost(int32, optional, tag = "1")] @@ -905,6 +915,9 @@ pub struct QConnectMessage { pub rndr_srvr_volume_changed: Option, #[prost(message, optional, tag = "26")] pub rndr_srvr_file_audio_quality_changed: Option, + #[prost(message, optional, tag = "27")] + pub rndr_srvr_device_audio_quality_changed: + Option, #[prost(message, optional, tag = "28")] pub rndr_srvr_max_audio_quality_changed: Option, #[prost(message, optional, tag = "29")] diff --git a/crates/qconnect-protocol/src/renderer.rs b/crates/qconnect-protocol/src/renderer.rs index ac0e944bd..1bb0ecf00 100644 --- a/crates/qconnect-protocol/src/renderer.rs +++ b/crates/qconnect-protocol/src/renderer.rs @@ -48,6 +48,7 @@ pub enum RendererReportType { RndrSrvrVolumeChanged, RndrSrvrVolumeMuted, RndrSrvrFileAudioQualityChanged, + RndrSrvrDeviceAudioQualityChanged, RndrSrvrMaxAudioQualityChanged, } @@ -62,6 +63,9 @@ impl RendererReportType { Self::RndrSrvrFileAudioQualityChanged => { "MESSAGE_TYPE_RNDR_SRVR_FILE_AUDIO_QUALITY_CHANGED" } + Self::RndrSrvrDeviceAudioQualityChanged => { + "MESSAGE_TYPE_RNDR_SRVR_DEVICE_AUDIO_QUALITY_CHANGED" + } Self::RndrSrvrMaxAudioQualityChanged => { "MESSAGE_TYPE_RNDR_SRVR_MAX_AUDIO_QUALITY_CHANGED" } diff --git a/src-tauri/src/qconnect/commands.rs b/src-tauri/src/qconnect/commands.rs index 7b0834512..ab9a204a1 100644 --- a/src-tauri/src/qconnect/commands.rs +++ b/src-tauri/src/qconnect/commands.rs @@ -750,6 +750,22 @@ pub async fn v2_qconnect_report_playback_state( { log::warn!("[QConnect] Failed to report file audio quality: {err}"); } + + // P1-7: also report the actual DAC output format (DeviceAudioQualityChanged, + // tag 27). In QBZ's bit-perfect pipeline the output config matches the track + // under passthrough; the snapshot above already carries the real output + // stream sample_rate / bit_depth / channels. + if let Err(err) = service + .report_device_audio_quality_if_changed( + queue_version, + audio_quality.sampling_rate, + audio_quality.bit_depth, + audio_quality.nb_channels, + ) + .await + { + log::warn!("[QConnect] Failed to report device audio quality: {err}"); + } } if let Err(err) = app_handle.emit( diff --git a/src-tauri/src/qconnect/mod.rs b/src-tauri/src/qconnect/mod.rs index d62a8fd1b..238fd34bb 100644 --- a/src-tauri/src/qconnect/mod.rs +++ b/src-tauri/src/qconnect/mod.rs @@ -85,6 +85,9 @@ pub(super) struct QconnectRemoteSyncState { pub(super) last_materialized_start_index: Option, pub(super) last_materialized_core_shuffle_order: Option>, pub(super) last_reported_file_audio_quality: Option, + /// Last reported device (DAC output) audio quality: (sampling_rate, bit_depth, nb_channels). + /// Used to dedup outbound RndrSrvrDeviceAudioQualityChanged(27) reports. + pub(super) last_reported_device_audio_quality: Option<(i32, i32, i32)>, pub(super) last_applied_queue_state: Option, pub(super) last_remote_queue_state: Option, pub(super) session_loop_mode: Option, diff --git a/src-tauri/src/qconnect/service.rs b/src-tauri/src/qconnect/service.rs index 60c0d77fe..ca11a974f 100644 --- a/src-tauri/src/qconnect/service.rs +++ b/src-tauri/src/qconnect/service.rs @@ -379,6 +379,7 @@ impl QconnectServiceState { deferred_renderer_join( &app_for_loop, &sync_state_for_loop, + &app_handle_for_status, session_uuid, deferred_join_reason(has_disconnected), ) @@ -1040,6 +1041,52 @@ impl QconnectServiceState { Ok(true) } + /// Emit a RndrSrvrDeviceAudioQualityChanged(27) report describing the actual + /// DAC output format (sampling_rate / bit_depth / nb_channels), deduped against + /// the last reported value. Returns Ok(true) when a report was sent. + pub(super) async fn report_device_audio_quality_if_changed( + &self, + queue_version: qconnect_app::QueueVersion, + sampling_rate: i32, + bit_depth: i32, + nb_channels: i32, + ) -> Result { + let (app, sync_state) = { + let guard = self.inner.lock().await; + let Some(runtime) = guard.runtime.as_ref() else { + return Err("QConnect service is not running".to_string()); + }; + (Arc::clone(&runtime.app), Arc::clone(&runtime.sync_state)) + }; + + let key = (sampling_rate, bit_depth, nb_channels); + { + let state = sync_state.lock().await; + if state.last_reported_device_audio_quality == Some(key) { + return Ok(false); + } + } + + let report = RendererReport::new( + RendererReportType::RndrSrvrDeviceAudioQualityChanged, + Uuid::new_v4().to_string(), + queue_version, + serde_json::json!({ + "sampling_rate": sampling_rate, + "bit_depth": bit_depth, + "nb_channels": nb_channels + }), + ); + + app.send_renderer_report_command(report) + .await + .map_err(|err| format!("send device audio quality report failed: {err}"))?; + + let mut state = sync_state.lock().await; + state.last_reported_device_audio_quality = Some(key); + Ok(true) + } + /// Update the renderer's internal position from the frontend's actual playback position. /// This keeps the QConnect app's renderer state in sync with audio playback so that /// subsequent renderer reports (e.g. after pause/resume) include the correct position. @@ -1887,6 +1934,7 @@ async fn bootstrap_remote_presence( async fn deferred_renderer_join( app: &Arc>, sync_state: &Arc>, + app_handle: &AppHandle, session_uuid: &str, join_reason: i32, ) { @@ -1945,17 +1993,47 @@ async fn deferred_renderer_join( return; } - // 2. Send initial StateUpdated report - let state_report_payload = serde_json::json!({ + // 2. Send initial StateUpdated report. At join time (e.g. reconnect + // mid-playback) we may already have a current track, so resolve the real + // duration + current/next queue_item_ids instead of hardcoding nulls. + let renderer = app.renderer_state_snapshot().await; + let queue = app.queue_state_snapshot().await; + let current_track_id = renderer.current_track.as_ref().map(|item| item.track_id); + let (current_qid, next_qid, _) = current_track_id + .map(|tid| resolve_queue_item_ids_from_queue_state(&queue, tid)) + .unwrap_or((None, None, None)); + let duration_secs = match current_track_id { + Some(track_id) => { + let mut resolved: u64 = 0; + if let Some(bridge_state) = app_handle.try_state::() { + if let Some(bridge) = bridge_state.try_get().await { + resolved = bridge + .get_track(track_id) + .await + .map(|track| u64::from(track.duration)) + .unwrap_or(0); + } + } + resolved + } + None => 0, + }; + let mut state_report_payload = serde_json::json!({ "playing_state": PLAYING_STATE_STOPPED, "buffer_state": BUFFER_STATE_OK, "current_position": 0, - "duration": 0, + "duration": duration_secs, "queue_version": { "major": queue_version_ref.major, "minor": queue_version_ref.minor } }); + if let Some(qid) = current_qid { + state_report_payload["current_queue_item_id"] = serde_json::json!(qid); + } + if let Some(qid) = next_qid { + state_report_payload["next_queue_item_id"] = serde_json::json!(qid); + } let state_report = RendererReport::new( RendererReportType::RndrSrvrStateUpdated, Uuid::new_v4().to_string(),