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
This commit is contained in:
vicrodh committed 2026-06-01 04:29:18 -06:00
1 parent 0d5eb50889
commit 03a551e6ff
6 files changed
+155 -4

No files matched your search

+38 -1
View File
@@ -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<QConnectMessage, Proto
}),
..Default::default()
}),
RendererReportType::RndrSrvrDeviceAudioQualityChanged => 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(
@@ -715,6 +715,16 @@ pub struct RendererFileAudioQualityChangedMessage {
pub audio_quality: Option<i32>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct RendererDeviceAudioQualityChangedMessage {
#[prost(int32, optional, tag = "1")]
pub sampling_rate: Option<i32>,
#[prost(int32, optional, tag = "2")]
pub bit_depth: Option<i32>,
#[prost(int32, optional, tag = "3")]
pub nb_channels: Option<i32>,
}
#[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<RendererVolumeChangedMessage>,
#[prost(message, optional, tag = "26")]
pub rndr_srvr_file_audio_quality_changed: Option<RendererFileAudioQualityChangedMessage>,
#[prost(message, optional, tag = "27")]
pub rndr_srvr_device_audio_quality_changed:
Option<RendererDeviceAudioQualityChangedMessage>,
#[prost(message, optional, tag = "28")]
pub rndr_srvr_max_audio_quality_changed: Option<RendererMaxAudioQualityChangedMessage>,
#[prost(message, optional, tag = "29")]
+4
View File
@@ -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"
}
+16
View File
@@ -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(
+3
View File
@@ -85,6 +85,9 @@ pub(super) struct QconnectRemoteSyncState {
pub(super) last_materialized_start_index: Option<usize>,
pub(super) last_materialized_core_shuffle_order: Option<Vec<usize>>,
pub(super) last_reported_file_audio_quality: Option<QconnectFileAudioQualitySnapshot>,
/// 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<QConnectQueueState>,
pub(super) last_remote_queue_state: Option<QConnectQueueState>,
pub(super) session_loop_mode: Option<i32>,
+81 -3
View File
@@ -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<bool, String> {
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<QconnectApp<NativeWsTransport, TauriQconnectEventSink>>,
sync_state: &Arc<Mutex<QconnectRemoteSyncState>>,
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::<CoreBridgeState>() {
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(),