diff --git a/Cargo.lock b/Cargo.lock index cb2ab9b..96fac39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "alac-encoder" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a32a4e6eec2ede679898711c25120685f4cd472be57be0b98642312232b9f29" +dependencies = [ + "log", +] + [[package]] name = "android-activity" version = "0.6.1" @@ -2367,6 +2376,7 @@ version = "0.2.2" dependencies = [ "aes", "aes-gcm", + "alac-encoder", "anyhow", "base64ct", "chacha20poly1305", diff --git a/Cargo.toml b/Cargo.toml index 89f70ac..f916561 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ pre-release-commit-message = "chore: release {{crate_name}} {{version}}" [dependencies] aes = "0.8.4" aes-gcm = "0.10.3" +alac-encoder = "0.3.0" anyhow = "1.0.100" base64ct = { version = "1.8.3", features = ["alloc"] } chacha20poly1305 = "0.10.1" diff --git a/src/app/facade.rs b/src/app/facade.rs index 8d65c9e..e4f7d7e 100644 --- a/src/app/facade.rs +++ b/src/app/facade.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; +use crate::audio::SendCodecPreference; use crate::config::{ AppConfig, CachedReceiver, ConfigError, TrayLanguagePreference, default_config_path, load_config, save_config, @@ -10,7 +11,8 @@ use crate::error::RairstreamError; use crate::pairing::ReceiverCredentials; use crate::receiver::{Receiver, selector}; use crate::session::{ - PlaybackSession, pair_receiver_with_pin, play_capture, play_file, + PlaybackSession, pair_receiver_with_pin, play_capture_with_codec_preference, + play_file_with_codec_preference, request_pairing_pin_display as session_request_pairing_pin_display, }; use crate::storage::{paired_devices, receiver_cache}; @@ -217,16 +219,26 @@ where } pub fn play_file(&mut self, path: &Path, selectors: &[String]) -> Result<(), RairstreamError> { + self.play_file_with_codec_preference(path, selectors, SendCodecPreference::Auto) + } + + pub fn play_file_with_codec_preference( + &mut self, + path: &Path, + selectors: &[String], + codec_preference: SendCodecPreference, + ) -> Result<(), RairstreamError> { let receivers = self.ensure_receivers()?; let targets = selector::resolve_receivers(&receivers, selectors)?; self.state.session = SessionState::Streaming { receiver_ids: targets.iter().map(|receiver| receiver.id.clone()).collect(), }; - let result = play_file( + let result = play_file_with_codec_preference( path, &targets, &self.config.paired_receivers, self.config.sender_volume_percent, + codec_preference, ); self.state.session = SessionState::Idle; result @@ -235,16 +247,25 @@ where pub fn play_capture( &mut self, selectors: &[String], + ) -> Result { + self.play_capture_with_codec_preference(selectors, SendCodecPreference::Auto) + } + + pub fn play_capture_with_codec_preference( + &mut self, + selectors: &[String], + codec_preference: SendCodecPreference, ) -> Result { let receivers = self.ensure_receivers()?; let targets = selector::resolve_receivers(&receivers, selectors)?; self.state.session = SessionState::Streaming { receiver_ids: targets.iter().map(|receiver| receiver.id.clone()).collect(), }; - match play_capture( + match play_capture_with_codec_preference( &targets, &self.config.paired_receivers, self.config.sender_volume_percent, + codec_preference, ) { Ok(session) => Ok(session), Err(error) => { diff --git a/src/audio/mod.rs b/src/audio/mod.rs index 6881d43..f3c3958 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -14,8 +14,11 @@ use std::sync::{ use std::thread::{self, JoinHandle}; pub use decode::FileChunkDecoder; -pub use raop::AudioResampler; -pub(crate) use raop::{CodecDescription, RAOP_FRAMES_PER_PACKET, RAOP_STARTUP_LATENCY_FRAMES}; +pub use raop::{AudioResampler, SendCodec, SendCodecPreference}; +pub(crate) use raop::{ + CodecDescription, RAOP_FRAMES_PER_PACKET, RAOP_STARTUP_LATENCY_FRAMES, RaopAudioPayload, + RaopPayloadEncoder, +}; #[cfg(test)] pub(crate) use raop::{RAOP_SAMPLE_RATE_HZ, RAOP_STARTUP_LATENCY_MILLIS}; diff --git a/src/audio/raop.rs b/src/audio/raop.rs index 769a9f2..9206584 100644 --- a/src/audio/raop.rs +++ b/src/audio/raop.rs @@ -2,6 +2,7 @@ use std::f64::consts::FRAC_1_SQRT_2; use std::mem; +use alac_encoder::{AlacEncoder, FormatDescription}; use num_traits::ToPrimitive; use super::{AudioChunk, AudioFormat, AudioSampleType}; @@ -20,6 +21,38 @@ const CENTER_MIX_GAIN: f64 = FRAC_1_SQRT_2; const SURROUND_MIX_GAIN: f64 = 0.5; const LFE_MIX_GAIN: f64 = 0.5; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SendCodec { + PcmL16, + Alac, +} + +impl SendCodec { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::PcmL16 => "pcm_l16", + Self::Alac => "alac", + } + } + + #[must_use] + pub fn description(self, frames_per_packet: usize) -> CodecDescription { + match self { + Self::PcmL16 => CodecDescription::pcm_stereo(), + Self::Alac => CodecDescription::alac_stereo(frames_per_packet), + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum SendCodecPreference { + #[default] + Auto, + PcmL16, + Alac, +} + /// 经典 `RAOP` 发送端使用的固定音频描述。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct CodecDescription { @@ -37,6 +70,121 @@ impl CodecDescription { fmtp: None, } } + + #[must_use] + pub fn alac_stereo(frames_per_packet: usize) -> Self { + Self { + encoding_name: "AppleLossless", + rtpmap: "AppleLossless", + fmtp: Some(format!( + "96 {frames_per_packet} 0 16 40 10 14 2 255 0 0 {RAOP_SAMPLE_RATE_HZ}" + )), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RaopAudioPayload { + pub frames: usize, + pub bytes: Vec, +} + +#[derive(Debug)] +pub struct RaopPayloadEncoder { + codec: SendCodec, + frames_per_packet: usize, + alac: Option, +} + +impl RaopPayloadEncoder { + #[must_use] + pub fn new(codec: SendCodec, frames_per_packet: usize) -> Self { + let alac = (codec == SendCodec::Alac).then(|| AlacPacketEncoder::new(frames_per_packet)); + Self { + codec, + frames_per_packet, + alac, + } + } + + pub fn encode_pcm_packet( + &mut self, + pcm_packet: &[u8], + ) -> Result { + let frames = + pcm_packet.len() / usize::from(RAOP_CHANNELS) / usize::from(RAOP_BITS_PER_SAMPLE / 8); + if frames != self.frames_per_packet { + return Err(AirPlayError::UnsupportedAudioFormat { + message: format!( + "RAOP packet contained {frames} frames, expected {}", + self.frames_per_packet + ), + }); + } + + let bytes = match self.codec { + SendCodec::PcmL16 => pcm_packet.to_vec(), + SendCodec::Alac => self + .alac + .as_mut() + .expect("ALAC codec must initialize an ALAC encoder") + .encode_be_pcm16(pcm_packet), + }; + + Ok(RaopAudioPayload { frames, bytes }) + } +} + +struct AlacPacketEncoder { + input_format: FormatDescription, + encoder: AlacEncoder, + output: Vec, + pcm_le: Vec, +} + +impl std::fmt::Debug for AlacPacketEncoder { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AlacPacketEncoder") + .field("output_capacity", &self.output.len()) + .field("pcm_buffer_capacity", &self.pcm_le.capacity()) + .finish_non_exhaustive() + } +} + +impl AlacPacketEncoder { + fn new(frames_per_packet: usize) -> Self { + let frames_per_packet = u32::try_from(frames_per_packet) + .expect("RAOP frames per packet must fit in the ALAC encoder frame size"); + let input_format = + FormatDescription::pcm::(f64::from(RAOP_SAMPLE_RATE_HZ), u32::from(RAOP_CHANNELS)); + let output_format = FormatDescription::alac( + f64::from(RAOP_SAMPLE_RATE_HZ), + frames_per_packet, + u32::from(RAOP_CHANNELS), + ); + let output = vec![0_u8; output_format.max_packet_size()]; + Self { + input_format, + encoder: AlacEncoder::new(&output_format), + output, + pcm_le: Vec::new(), + } + } + + fn encode_be_pcm16(&mut self, pcm_be: &[u8]) -> Vec { + self.pcm_le.clear(); + self.pcm_le.reserve(pcm_be.len()); + for sample in pcm_be.chunks_exact(2) { + let value = i16::from_be_bytes([sample[0], sample[1]]); + self.pcm_le.extend_from_slice(&value.to_le_bytes()); + } + + let size = self + .encoder + .encode(&self.input_format, &self.pcm_le, &mut self.output); + self.output[..size].to_vec() + } } /// 将输入 `AudioChunk` 重采样并切成 `RAOP` 所需的 `PCM packet`。 @@ -446,7 +594,8 @@ fn quantize_sample(sample: f64) -> i16 { #[cfg(test)] mod tests { use super::{ - AudioResampler, CodecDescription, decode_and_downmix, encode_pcm_packet, protect_peak, + AudioResampler, CodecDescription, RaopPayloadEncoder, SendCodec, decode_and_downmix, + encode_pcm_packet, protect_peak, }; use crate::audio::{AudioChunk, AudioFormat, AudioSampleType}; use crate::config::MAX_SENDER_VOLUME_PERCENT; @@ -469,6 +618,18 @@ mod tests { assert!(codec.fmtp.is_none()); } + #[test] + fn alac_codec_description_matches_raop_stereo_profile() { + let codec = CodecDescription::alac_stereo(352); + + assert_eq!(codec.encoding_name, "AppleLossless"); + assert_eq!(codec.rtpmap, "AppleLossless"); + assert_eq!( + codec.fmtp.as_deref(), + Some("96 352 0 16 40 10 14 2 255 0 0 44100") + ); + } + #[test] fn validate_input_format_accepts_default_pcm_profile() { let result = validate_input_format(AudioFormat::default()); @@ -948,6 +1109,18 @@ mod tests { assert!(bytes[2] != 0 || bytes[3] != 0); } + #[test] + fn alac_payload_encoder_outputs_compressed_frame_with_pcm_timeline() { + let pcm = encode_pcm_packet(&vec![[0.0, 0.0]; 352]); + let mut encoder = RaopPayloadEncoder::new(SendCodec::Alac, 352); + + let payload = encoder.encode_pcm_packet(&pcm).unwrap(); + + assert_eq!(payload.frames, 352); + assert!(!payload.bytes.is_empty()); + assert!(payload.bytes.len() < pcm.len()); + } + #[test] fn resampler_can_represent_sender_volume_boost_up_to_400_percent() { let format = AudioFormat::default(); diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 986608c..c5fd78a 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -62,13 +62,21 @@ pub fn run_cli(cli: CliOptions) -> Result<(), RairstreamError> { print_paired_removed(&entry); Ok(()) } - CliCommand::PlayFile { path, selectors } => { - facade.play_file(&path, &selectors)?; + CliCommand::PlayFile { + path, + selectors, + codec_preference, + } => { + facade.play_file_with_codec_preference(&path, &selectors, codec_preference)?; print_play_file_completed(&path, &selectors); Ok(()) } - CliCommand::PlayCapture { selectors } => { - let session = facade.play_capture(&selectors)?; + CliCommand::PlayCapture { + selectors, + codec_preference, + } => { + let session = + facade.play_capture_with_codec_preference(&selectors, codec_preference)?; print_play_capture_started(&selectors); match wait_for_ctrl_c_or_capture_end(&session)? { CaptureExit::UserRequestedStop => { diff --git a/src/cli/output.rs b/src/cli/output.rs index eaecbde..3928517 100644 --- a/src/cli/output.rs +++ b/src/cli/output.rs @@ -852,7 +852,7 @@ mod tests { assert!(rendered.contains("Commands")); assert!(rendered.contains("Options")); assert!(rendered.contains("-h, --help")); - assert!(rendered.contains("play capture --device ...")); + assert!(rendered.contains("play capture [--codec ] --device ...")); } #[test] diff --git a/src/cli/parse.rs b/src/cli/parse.rs index cf03ca1..385c277 100644 --- a/src/cli/parse.rs +++ b/src/cli/parse.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; +use crate::audio::SendCodecPreference; use crate::error::RairstreamError; pub(crate) const CLI_USAGE: &str = @@ -14,8 +15,8 @@ pub(crate) const CLI_COMMAND_USAGE: &[&str] = &[ "pair --device [--pin ]", "paired list", "paired forget --device ", - "play file --device ...", - "play capture --device ...", + "play file [--codec ] --device ...", + "play capture [--codec ] --device ...", ]; #[derive(Debug, Clone, PartialEq, Eq)] @@ -36,9 +37,11 @@ pub enum CliCommand { PlayFile { path: PathBuf, selectors: Vec, + codec_preference: SendCodecPreference, }, PlayCapture { selectors: Vec, + codec_preference: SendCodecPreference, }, } @@ -169,14 +172,20 @@ fn parse_play_command(args: &[String]) -> Result { let Some((path, selectors)) = tail.split_first() else { return Err(usage_error()); }; + let (selectors, codec_preference) = parse_play_options(selectors)?; Ok(CliCommand::PlayFile { path: PathBuf::from(path), - selectors: parse_device_selectors(selectors)?, + selectors, + codec_preference, + }) + } + "capture" => { + let (selectors, codec_preference) = parse_play_options(tail)?; + Ok(CliCommand::PlayCapture { + selectors, + codec_preference, }) } - "capture" => Ok(CliCommand::PlayCapture { - selectors: parse_device_selectors(tail)?, - }), _ => Err(usage_error()), } } @@ -224,23 +233,38 @@ fn normalize_cli_text(value: &str, label: &str) -> Result Result, RairstreamError> { +fn parse_play_options( + args: &[String], +) -> Result<(Vec, SendCodecPreference), RairstreamError> { let mut selectors = Vec::new(); + let mut codec_preference = SendCodecPreference::Auto; let mut index = 0; while index < args.len() { - if args[index] != "--device" { - return Err(RairstreamError::InvalidCli { - message: format!("unexpected argument `{}`", args[index]), - }); + match args[index].as_str() { + "--device" => { + let value = args + .get(index + 1) + .ok_or_else(|| RairstreamError::InvalidCli { + message: String::from("--device requires a value"), + })?; + selectors.push(normalize_cli_text(value, "selector")?); + index += 2; + } + "--codec" => { + let value = args + .get(index + 1) + .ok_or_else(|| RairstreamError::InvalidCli { + message: String::from("--codec requires a value"), + })?; + codec_preference = parse_codec_preference(value)?; + index += 2; + } + unexpected => { + return Err(RairstreamError::InvalidCli { + message: format!("unexpected argument `{unexpected}`"), + }); + } } - - let value = args - .get(index + 1) - .ok_or_else(|| RairstreamError::InvalidCli { - message: String::from("--device requires a value"), - })?; - selectors.push(normalize_cli_text(value, "selector")?); - index += 2; } if selectors.is_empty() { @@ -249,7 +273,18 @@ fn parse_device_selectors(args: &[String]) -> Result, RairstreamErro }); } - Ok(selectors) + Ok((selectors, codec_preference)) +} + +fn parse_codec_preference(value: &str) -> Result { + match value { + "auto" => Ok(SendCodecPreference::Auto), + "pcm" | "pcm_l16" | "l16" => Ok(SendCodecPreference::PcmL16), + "alac" => Ok(SendCodecPreference::Alac), + _ => Err(RairstreamError::InvalidCli { + message: format!("unsupported codec `{value}`"), + }), + } } fn usage_error() -> RairstreamError { diff --git a/src/rtsp/protocol.rs b/src/rtsp/protocol.rs index b4691fb..3c17747 100644 --- a/src/rtsp/protocol.rs +++ b/src/rtsp/protocol.rs @@ -312,7 +312,7 @@ pub fn build_announce_request( cseq: u32, codec: &CodecDescription, ) -> RtspRequest { - let body = build_pcm_sdp(descriptor, codec); + let body = build_audio_sdp(descriptor, codec); apply_common_headers( descriptor, RtspRequest::new(RtspMethod::Announce, build_session_uri(descriptor)) @@ -432,18 +432,22 @@ pub fn parse_setup_reply(response: &RtspResponse) -> Result String { +fn build_audio_sdp(descriptor: &SessionDescriptor, codec: &CodecDescription) -> String { let sender_ip = resolve_sender_ip(&descriptor.device.host, descriptor.device.port) .unwrap_or_else(|| String::from("0.0.0.0")); - format!( - "v=0\r\no=Rairstream {} 0 IN IP4 {}\r\ns=Rairstream\r\nc=IN IP4 {}\r\nt=0 0\r\nm=audio 0 RTP/AVP 96\r\na=rtpmap:96 {}\r\na=min-latency:{}\r\n", + let mut body = format!( + "v=0\r\no=Rairstream {} 0 IN IP4 {}\r\ns=Rairstream\r\nc=IN IP4 {}\r\nt=0 0\r\nm=audio 0 RTP/AVP 96\r\na=rtpmap:96 {}\r\n", descriptor.stream_session_id(), sender_ip, sender_ip, - codec.rtpmap, - RAOP_STARTUP_LATENCY_FRAMES - ) + codec.rtpmap + ); + if let Some(fmtp) = &codec.fmtp { + let _ = write!(body, "a=fmtp:{fmtp}\r\n"); + } + let _ = write!(body, "a=min-latency:{RAOP_STARTUP_LATENCY_FRAMES}\r\n"); + body } fn resolve_sender_ip(receiver_host: &str, receiver_port: u16) -> Option { @@ -702,6 +706,18 @@ mod tests { assert!(body.contains("m=audio 0 RTP/AVP 96")); } + #[test] + fn announce_request_contains_alac_sdp() { + let descriptor = build_descriptor(); + let request = build_announce_request(&descriptor, 8, &CodecDescription::alac_stereo(352)); + let body = request.body_text().unwrap(); + + assert_eq!(request.method, RtspMethod::Announce); + assert!(body.contains("a=rtpmap:96 AppleLossless")); + assert!(body.contains("a=fmtp:96 352 0 16 40 10 14 2 255 0 0 44100")); + assert!(body.contains(&format!("a=min-latency:{RAOP_STARTUP_LATENCY_FRAMES}"))); + } + #[test] fn announce_request_min_latency_matches_current_baseline_millis() { assert_eq!(RAOP_STARTUP_LATENCY_MILLIS, 250); diff --git a/src/session/connect.rs b/src/session/connect.rs index 80c55d3..e21d4b0 100644 --- a/src/session/connect.rs +++ b/src/session/connect.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::hash::BuildHasher; use std::sync::{Arc, Mutex}; -use crate::audio::AudioFormat; +use crate::audio::{AudioFormat, SendCodecPreference}; use crate::config::MAX_SENDER_VOLUME_PERCENT; use crate::error::RairstreamError; use crate::pairing::ReceiverCredentials; @@ -31,12 +31,32 @@ impl ConnectedReceiver { } } +#[cfg(test)] pub fn connect_receivers( receivers: &[Receiver], input_format: AudioFormat, paired_receivers: &HashMap, sender_volume_percent: u16, ) -> Result, RairstreamError> +where + S: BuildHasher, +{ + connect_receivers_with_codec_preference( + receivers, + input_format, + paired_receivers, + sender_volume_percent, + SendCodecPreference::Auto, + ) +} + +pub fn connect_receivers_with_codec_preference( + receivers: &[Receiver], + input_format: AudioFormat, + paired_receivers: &HashMap, + sender_volume_percent: u16, + codec_preference: SendCodecPreference, +) -> Result, RairstreamError> where S: BuildHasher, { @@ -45,6 +65,7 @@ where for receiver in receivers { let mut descriptor = SessionDescriptor::new(receiver.clone(), input_format); descriptor.sender_volume_percent = sender_volume_percent; + descriptor.send_codec_preference = codec_preference; if let Some(credentials) = paired_receivers.get(&receiver.id).cloned() { descriptor = descriptor.with_receiver_credentials(credentials); } diff --git a/src/session/mod.rs b/src/session/mod.rs index 838f473..f4c4dec 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -7,15 +7,21 @@ pub(crate) mod transport; use std::fmt::Write; -use crate::audio::AudioFormat; +use crate::audio::{AudioFormat, SendCodecPreference}; use crate::pairing::ReceiverCredentials; use crate::receiver::Receiver; use thiserror::Error; pub use connect::{pair_receiver_with_pin, request_pairing_pin_display}; -pub use planner::{PlannedTiming, PlannedTransport, SessionPlan, plan_session}; +pub use planner::{ + PlannedTiming, PlannedTransport, SessionPlan, plan_session, plan_session_with_codec_preference, + select_send_codec, +}; pub use raop::{RaopConnection, RaopSession, RaopSessionState}; -pub use stream::{PlaybackSession, play_capture, play_file}; +pub use stream::{ + PlaybackSession, play_capture, play_capture_with_codec_preference, play_file, + play_file_with_codec_preference, +}; pub use transport::{ ModernAirPlayConnection, ModernAirPlaySession, PreparedSession, SessionConnection, }; @@ -27,6 +33,7 @@ pub struct SessionDescriptor { pub input_format: AudioFormat, pub frames_per_packet: usize, pub sender_volume_percent: u16, + pub send_codec_preference: SendCodecPreference, pub receiver_credentials: Option, } @@ -38,6 +45,7 @@ impl SessionDescriptor { input_format, frames_per_packet: crate::audio::RAOP_FRAMES_PER_PACKET, sender_volume_percent: 100, + send_codec_preference: SendCodecPreference::Auto, receiver_credentials: None, } } diff --git a/src/session/planner.rs b/src/session/planner.rs index 1e3f8fd..82e0e9e 100644 --- a/src/session/planner.rs +++ b/src/session/planner.rs @@ -1,5 +1,4 @@ -use crate::audio::AudioFormat; -use crate::crypto::CipherSuite; +use crate::audio::{AudioFormat, SendCodec, SendCodecPreference}; use crate::receiver::{AuthMethod, CodecKind, Receiver, SupportLevel, TransportProfile}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -18,7 +17,7 @@ pub enum PlannedTiming { pub struct SessionPlan { pub receiver_id: String, pub transport: PlannedTransport, - pub codec: CipherSuite, + pub codec: SendCodec, pub input_format: AudioFormat, pub timing: PlannedTiming, pub auth_method: AuthMethod, @@ -27,22 +26,20 @@ pub struct SessionPlan { #[must_use] pub fn plan_session(receiver: &Receiver, input_format: AudioFormat) -> SessionPlan { + plan_session_with_codec_preference(receiver, input_format, SendCodecPreference::Auto) +} + +#[must_use] +pub fn plan_session_with_codec_preference( + receiver: &Receiver, + input_format: AudioFormat, + codec_preference: SendCodecPreference, +) -> SessionPlan { let transport = match receiver.transport_profile { TransportProfile::Raop => PlannedTransport::Raop, TransportProfile::ModernAuthRaop => PlannedTransport::AirPlay2, }; - let codec = match receiver - .capabilities - .codecs - .first() - .copied() - .unwrap_or(CodecKind::L16) - { - CodecKind::L16 => CipherSuite::L16, - CodecKind::Alac => CipherSuite::Alac, - CodecKind::Aac => CipherSuite::Aac, - CodecKind::AacEld => CipherSuite::AacEld, - }; + let codec = select_send_codec(receiver, codec_preference); SessionPlan { receiver_id: receiver.id.clone(), @@ -60,3 +57,13 @@ pub fn plan_session(receiver: &Receiver, input_format: AudioFormat) -> SessionPl support_level: receiver.support_level.clone(), } } + +#[must_use] +pub fn select_send_codec(receiver: &Receiver, codec_preference: SendCodecPreference) -> SendCodec { + let supports_alac = receiver.capabilities.codecs.contains(&CodecKind::Alac); + if codec_preference != SendCodecPreference::PcmL16 && supports_alac { + SendCodec::Alac + } else { + SendCodec::PcmL16 + } +} diff --git a/src/session/raop.rs b/src/session/raop.rs index d633b20..5be766b 100644 --- a/src/session/raop.rs +++ b/src/session/raop.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; use super::{AirPlayError, SessionDescriptor}; -use crate::audio::{CodecDescription, RAOP_STARTUP_LATENCY_FRAMES}; +use crate::audio::RAOP_STARTUP_LATENCY_FRAMES; use crate::rtsp::client::{ compute_rtsp_keepalive_interval, ensure_success, format_response_status, map_connection_error, }; @@ -14,6 +14,7 @@ use crate::rtsp::{ RtspResponse, SetupReply, SetupTransport, build_announce_request, build_options_request, build_record_request, build_setup_request, build_teardown_request, parse_setup_reply, }; +use crate::session::select_send_codec; use crate::timing::raop::TimingResponder; use crate::transport::{RaopPacketCounters, RaopSinkConfig}; use tracing::{debug, info}; @@ -110,7 +111,6 @@ impl Drop for RaopConnection { pub struct RaopSession { descriptor: SessionDescriptor, sink_config: RaopSinkConfig, - codec: CodecDescription, state: RaopSessionState, cseq: u32, setup_reply: Option, @@ -142,14 +142,17 @@ impl RaopSession { "creating RAOP session" ); + let send_codec = select_send_codec(&descriptor.device, descriptor.send_codec_preference); + debug!(send_codec = send_codec.as_str(), "selected RAOP send codec"); + Ok(Self { descriptor: descriptor.clone(), sink_config: RaopSinkConfig { + codec: send_codec, frames_per_packet: descriptor.frames_per_packet, sender_volume_percent: descriptor.sender_volume_percent, ..RaopSinkConfig::default() }, - codec: CodecDescription::pcm_stereo(), state: RaopSessionState::Connecting, cseq: 0, setup_reply: None, @@ -324,7 +327,14 @@ impl RaopSession { #[must_use] pub fn announce_request(&mut self) -> RtspRequest { let cseq = self.next_cseq(); - build_announce_request(&self.descriptor, cseq, &self.codec) + build_announce_request( + &self.descriptor, + cseq, + &self + .sink_config + .codec + .description(self.sink_config.frames_per_packet), + ) } #[must_use] diff --git a/src/session/stream.rs b/src/session/stream.rs index e43341b..bc2f996 100644 --- a/src/session/stream.rs +++ b/src/session/stream.rs @@ -4,13 +4,17 @@ use std::path::Path; use std::thread; use std::time::{Duration, Instant}; -use crate::audio::{AudioChunk, CaptureConfig, FileChunkDecoder}; +use crate::audio::{AudioChunk, CaptureConfig, FileChunkDecoder, SendCodecPreference}; use crate::capture::WindowsLoopbackCapture; use crate::error::RairstreamError; use crate::pairing::ReceiverCredentials; use crate::receiver::Receiver; -use super::connect::{ConnectedReceiver, build_group_sink, connect_receivers}; +#[cfg(test)] +use super::connect::connect_receivers; +use super::connect::{ + ConnectedReceiver, build_group_sink, connect_receivers_with_codec_preference, +}; pub struct PlaybackSession { connections: Vec, @@ -41,12 +45,34 @@ pub fn play_capture( paired_receivers: &HashMap, sender_volume_percent: u16, ) -> Result +where + S: BuildHasher, +{ + play_capture_with_codec_preference( + receivers, + paired_receivers, + sender_volume_percent, + SendCodecPreference::Auto, + ) +} + +pub fn play_capture_with_codec_preference( + receivers: &[Receiver], + paired_receivers: &HashMap, + sender_volume_percent: u16, + codec_preference: SendCodecPreference, +) -> Result where S: BuildHasher, { let format = WindowsLoopbackCapture::preferred_format()?; - let connections = - connect_receivers(receivers, format, paired_receivers, sender_volume_percent)?; + let connections = connect_receivers_with_codec_preference( + receivers, + format, + paired_receivers, + sender_volume_percent, + codec_preference, + )?; let sink = match build_group_sink(&connections, format, sender_volume_percent) { Ok(sink) => sink, Err(error) => { @@ -72,6 +98,25 @@ pub fn play_file( paired_receivers: &HashMap, sender_volume_percent: u16, ) -> Result<(), RairstreamError> +where + S: BuildHasher, +{ + play_file_with_codec_preference( + path, + receivers, + paired_receivers, + sender_volume_percent, + SendCodecPreference::Auto, + ) +} + +pub fn play_file_with_codec_preference( + path: &Path, + receivers: &[Receiver], + paired_receivers: &HashMap, + sender_volume_percent: u16, + codec_preference: SendCodecPreference, +) -> Result<(), RairstreamError> where S: BuildHasher, { @@ -84,11 +129,12 @@ where ), }); }; - let connections = connect_receivers( + let connections = connect_receivers_with_codec_preference( receivers, first_chunk.format, paired_receivers, sender_volume_percent, + codec_preference, )?; let mut sink = match build_group_sink(&connections, first_chunk.format, sender_volume_percent) { Ok(sink) => sink, diff --git a/src/transport/sink.rs b/src/transport/sink.rs index 1732f70..2072680 100644 --- a/src/transport/sink.rs +++ b/src/transport/sink.rs @@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex}; use crate::audio::{ AudioCaptureError, AudioChunk, AudioResampler, AudioSink, RAOP_FRAMES_PER_PACKET, + RaopAudioPayload, RaopPayloadEncoder, SendCodec, }; use crate::timing::clock::ntp_timestamp_now; @@ -27,6 +28,7 @@ pub struct RaopStreamTransport { /// `RAOP` 音频发送端的最小配置。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RaopSinkConfig { + pub codec: SendCodec, pub frames_per_packet: usize, pub sync_interval_packets: usize, pub sender_volume_percent: u16, @@ -35,6 +37,7 @@ pub struct RaopSinkConfig { impl Default for RaopSinkConfig { fn default() -> Self { Self { + codec: SendCodec::PcmL16, frames_per_packet: RAOP_FRAMES_PER_PACKET, sync_interval_packets: 125, sender_volume_percent: 100, @@ -46,6 +49,7 @@ impl Default for RaopSinkConfig { #[derive(Debug)] pub struct RaopAudioSink { resampler: AudioResampler, + payload_encoder: RaopPayloadEncoder, sender_volume_percent: Arc>, transport: RaopStreamTransport, first_packet_in_stream: bool, @@ -66,6 +70,7 @@ impl RaopAudioSink { sample_type = ?source_format.sample_type, audio_target = %transport.audio_target, control_target = %transport.control_target, + send_codec = transport.sink_config.codec.as_str(), sync_interval_packets = transport.sink_config.sync_interval_packets, "initializing RAOP audio sink" ); @@ -74,8 +79,13 @@ impl RaopAudioSink { .lock() .map_or(100, |sender_volume_percent| *sender_volume_percent); resampler.set_sender_volume_percent(initial_sender_volume_percent); + let payload_encoder = RaopPayloadEncoder::new( + transport.sink_config.codec, + transport.sink_config.frames_per_packet, + ); Self { resampler, + payload_encoder, sender_volume_percent, transport, first_packet_in_stream: true, @@ -90,8 +100,8 @@ impl RaopAudioSink { } } - fn send_audio_payload(&mut self, payload: Vec) -> Result<(), AudioCaptureError> { - let frames = payload.len() / 4; + fn send_audio_payload(&mut self, payload: RaopAudioPayload) -> Result<(), AudioCaptureError> { + let frames = payload.frames; let (sequence, timestamp) = self .transport .packet_counters @@ -105,7 +115,7 @@ impl RaopAudioSink { timestamp, payload_type: RAOP_AUDIO_PAYLOAD_TYPE, ssrc: self.transport.audio_ssrc, - payload, + payload: payload.bytes, }; let bytes = packet.encode(); let packet_index = self.sent_audio_packets.saturating_add(1); @@ -213,7 +223,13 @@ impl AudioSink for RaopAudioSink { } })?; - for payload in packets { + for pcm_packet in packets { + let payload = self + .payload_encoder + .encode_pcm_packet(&pcm_packet) + .map_err(|error| AudioCaptureError::InvalidFormat { + message: error.to_string(), + })?; self.send_audio_payload(payload)?; } @@ -227,7 +243,7 @@ mod tests { use crate::audio::{AudioChunk, AudioFormat, AudioSampleType, AudioSink}; - use super::{RaopAudioSink, RaopSinkConfig, RaopStreamTransport}; + use super::{RaopAudioSink, RaopSinkConfig, RaopStreamTransport, SendCodec}; use crate::transport::packet::RaopPacketCounters; use std::sync::{Arc, Mutex}; @@ -238,6 +254,7 @@ mod tests { assert_eq!(config.frames_per_packet, 352); assert_eq!(config.sync_interval_packets, 125); assert_eq!(config.sender_volume_percent, 100); + assert_eq!(config.codec, SendCodec::PcmL16); } #[test] @@ -258,6 +275,7 @@ mod tests { audio_ssrc: 0x1122_3344, packet_counters: RaopPacketCounters::new(7, 11), sink_config: RaopSinkConfig { + codec: SendCodec::PcmL16, frames_per_packet: 352, sync_interval_packets: 1, sender_volume_percent: 100, diff --git a/tests/cli_play_capture.rs b/tests/cli_play_capture.rs index 2c53706..7f2cbeb 100644 --- a/tests/cli_play_capture.rs +++ b/tests/cli_play_capture.rs @@ -1,3 +1,4 @@ +use rairstream::audio::SendCodecPreference; use rairstream::cli::{CliCommand, parse_cli}; #[test] @@ -16,6 +17,28 @@ fn parse_play_capture_command_with_multiple_devices() { cli.command, CliCommand::PlayCapture { selectors: vec![String::from("Office"), String::from("Bedroom")], + codec_preference: SendCodecPreference::Auto, + } + ); +} + +#[test] +fn parse_play_capture_command_accepts_pcm_codec_preference() { + let cli = parse_cli([ + String::from("play"), + String::from("capture"), + String::from("--device"), + String::from("Office"), + String::from("--codec"), + String::from("pcm"), + ]) + .unwrap(); + + assert_eq!( + cli.command, + CliCommand::PlayCapture { + selectors: vec![String::from("Office")], + codec_preference: SendCodecPreference::PcmL16, } ); } diff --git a/tests/cli_play_file.rs b/tests/cli_play_file.rs index ce7e53f..ca38577 100644 --- a/tests/cli_play_file.rs +++ b/tests/cli_play_file.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use rairstream::audio::SendCodecPreference; use rairstream::cli::{CliCommand, parse_cli}; #[test] @@ -20,6 +21,30 @@ fn parse_play_file_command_with_multiple_devices() { CliCommand::PlayFile { path: PathBuf::from("song.m4a"), selectors: vec![String::from("Living Room"), String::from("Kitchen")], + codec_preference: SendCodecPreference::Auto, + } + ); +} + +#[test] +fn parse_play_file_command_accepts_codec_preference() { + let cli = parse_cli([ + String::from("play"), + String::from("file"), + String::from("song.m4a"), + String::from("--codec"), + String::from("alac"), + String::from("--device"), + String::from("Living Room"), + ]) + .unwrap(); + + assert_eq!( + cli.command, + CliCommand::PlayFile { + path: PathBuf::from("song.m4a"), + selectors: vec![String::from("Living Room")], + codec_preference: SendCodecPreference::Alac, } ); } diff --git a/tests/session_planner.rs b/tests/session_planner.rs index b42b930..88af8ac 100644 --- a/tests/session_planner.rs +++ b/tests/session_planner.rs @@ -1,9 +1,9 @@ -use rairstream::audio::AudioFormat; +use rairstream::audio::{AudioFormat, SendCodec, SendCodecPreference}; use rairstream::receiver::{ AirPlayGeneration, AuthMethod, CodecKind, DeviceSupport, PairingRequirement, Receiver, ReceiverCapabilities, ReceiverKind, }; -use rairstream::session::{PlannedTransport, plan_session}; +use rairstream::session::{PlannedTransport, plan_session, plan_session_with_codec_preference}; #[test] fn planner_routes_modern_receiver_to_airplay2() { @@ -33,3 +33,71 @@ fn planner_routes_modern_receiver_to_airplay2() { assert_eq!(plan.transport, PlannedTransport::AirPlay2); } + +#[test] +fn planner_selects_alac_when_receiver_supports_it_in_auto_mode() { + let receiver = Receiver { + id: String::from("receiver-1"), + name: String::from("Living Room"), + host: String::from("192.168.1.10"), + port: 7000, + capabilities: ReceiverCapabilities { + codecs: vec![CodecKind::Alac, CodecKind::L16], + ..ReceiverCapabilities::default() + }, + ..Receiver::default() + } + .with_compat_fields(); + + let plan = plan_session(&receiver, AudioFormat::default()); + + assert_eq!(plan.codec, SendCodec::Alac); +} + +#[test] +fn planner_falls_back_to_pcm_when_alac_is_not_supported() { + let receiver = Receiver { + id: String::from("receiver-1"), + name: String::from("Living Room"), + host: String::from("192.168.1.10"), + port: 7000, + capabilities: ReceiverCapabilities { + codecs: vec![CodecKind::L16], + ..ReceiverCapabilities::default() + }, + ..Receiver::default() + } + .with_compat_fields(); + + let plan = plan_session_with_codec_preference( + &receiver, + AudioFormat::default(), + SendCodecPreference::Alac, + ); + + assert_eq!(plan.codec, SendCodec::PcmL16); +} + +#[test] +fn planner_allows_forcing_pcm_even_when_alac_is_supported() { + let receiver = Receiver { + id: String::from("receiver-1"), + name: String::from("Living Room"), + host: String::from("192.168.1.10"), + port: 7000, + capabilities: ReceiverCapabilities { + codecs: vec![CodecKind::Alac, CodecKind::L16], + ..ReceiverCapabilities::default() + }, + ..Receiver::default() + } + .with_compat_fields(); + + let plan = plan_session_with_codec_preference( + &receiver, + AudioFormat::default(), + SendCodecPreference::PcmL16, + ); + + assert_eq!(plan.codec, SendCodec::PcmL16); +}