Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 24 additions & 3 deletions src/app/facade.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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};
Expand Down Expand Up @@ -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
Expand All @@ -235,16 +247,25 @@ where
pub fn play_capture(
&mut self,
selectors: &[String],
) -> Result<PlaybackSession, RairstreamError> {
self.play_capture_with_codec_preference(selectors, SendCodecPreference::Auto)
}

pub fn play_capture_with_codec_preference(
&mut self,
selectors: &[String],
codec_preference: SendCodecPreference,
) -> Result<PlaybackSession, 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(),
};
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) => {
Expand Down
7 changes: 5 additions & 2 deletions src/audio/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down
175 changes: 174 additions & 1 deletion src/audio/raop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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 {
Expand All @@ -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<u8>,
}

#[derive(Debug)]
pub struct RaopPayloadEncoder {
codec: SendCodec,
frames_per_packet: usize,
alac: Option<AlacPacketEncoder>,
}

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<RaopAudioPayload, AirPlayError> {
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<u8>,
pcm_le: Vec<u8>,
}

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::<i16>(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<u8> {
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`。
Expand Down Expand Up @@ -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;
Expand All @@ -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());
Expand Down Expand Up @@ -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();
Expand Down
16 changes: 12 additions & 4 deletions src/cli/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
2 changes: 1 addition & 1 deletion src/cli/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <selector>..."));
assert!(rendered.contains("play capture [--codec <auto|pcm|alac>] --device <selector>..."));
}

#[test]
Expand Down
Loading
Loading