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
19 changes: 16 additions & 3 deletions src/app/facade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::pairing::ReceiverCredentials;
use crate::platform;
use crate::receiver::{Receiver, selector};
use crate::session::{
PlaybackSession, pair_receiver_with_pin, play_capture, play_file,
LatencyProfile, PlaybackSession, pair_receiver_with_pin, play_capture, play_file,
request_pairing_pin_display as session_request_pairing_pin_display,
};
use crate::storage::{paired_devices, receiver_cache};
Expand Down Expand Up @@ -240,7 +240,12 @@ where
Ok(entry)
}

pub fn play_file(&mut self, path: &Path, selectors: &[String]) -> Result<(), RairstreamError> {
pub fn play_file(
&mut self,
path: &Path,
selectors: &[String],
latency_profile: LatencyProfile,
) -> Result<(), RairstreamError> {
let receivers = self.ensure_receivers()?;
let targets = selector::resolve_receivers(&receivers, selectors)?;
self.state.session = SessionState::Streaming {
Expand All @@ -251,6 +256,7 @@ where
&targets,
&self.config.paired_receivers,
self.config.sender_volume_percent,
latency_profile,
);
self.state.session = SessionState::Idle;
result
Expand All @@ -259,6 +265,7 @@ where
pub fn play_capture(
&mut self,
selectors: &[String],
latency_profile: LatencyProfile,
) -> Result<PlaybackSession, RairstreamError> {
let receivers = self.ensure_receivers()?;
let targets = selector::resolve_receivers(&receivers, selectors)?;
Expand All @@ -269,6 +276,7 @@ where
&targets,
&self.config.paired_receivers,
self.config.sender_volume_percent,
latency_profile,
) {
Ok(session) => Ok(session),
Err(error) => {
Expand Down Expand Up @@ -425,6 +433,7 @@ mod tests {
use crate::receiver::{
AirPlayGeneration, AuthMethod, DeviceSupport, Receiver, ReceiverCapabilities, ReceiverKind,
};
use crate::session::LatencyProfile;

use super::{AppFacade, DiscoveryService, SessionState, save_config};

Expand Down Expand Up @@ -785,7 +794,11 @@ mod tests {
.as_nanos()
));

let result = facade.play_file(&missing_file, &[String::from("Living Room")]);
let result = facade.play_file(
&missing_file,
&[String::from("Living Room")],
LatencyProfile::safe(),
);

assert!(result.is_err());
assert_eq!(facade.state().session, SessionState::Idle);
Expand Down
6 changes: 4 additions & 2 deletions src/audio/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ 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};
#[cfg(test)]
pub(crate) use raop::{RAOP_SAMPLE_RATE_HZ, RAOP_STARTUP_LATENCY_MILLIS};
pub(crate) use raop::RAOP_STARTUP_LATENCY_FRAMES;
pub(crate) use raop::{
CodecDescription, RAOP_FRAMES_PER_PACKET, RAOP_SAMPLE_RATE_HZ, RAOP_STARTUP_LATENCY_MILLIS,
};

/// PCM 样本的数据语义。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down
31 changes: 30 additions & 1 deletion src/audio/raop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub(crate) const RAOP_CHANNELS: u16 = 2;
pub(crate) const RAOP_BITS_PER_SAMPLE: u16 = 16;
pub(crate) const RAOP_FRAMES_PER_PACKET: usize = 352;
pub(crate) const RAOP_STARTUP_LATENCY_MILLIS: u32 = 250;
#[cfg(test)]
pub(crate) const RAOP_STARTUP_LATENCY_FRAMES: u32 =
RAOP_STARTUP_LATENCY_MILLIS * RAOP_SAMPLE_RATE_HZ / 1_000;

Expand Down Expand Up @@ -44,6 +45,7 @@ impl CodecDescription {
pub struct AudioResampler {
source_format: AudioFormat,
sender_volume_gain: f64,
frames_per_packet: usize,
phase_numerator: u32,
pending_input_frames: Vec<[f64; 2]>,
pending_output_frames: Vec<[f64; 2]>,
Expand All @@ -59,10 +61,20 @@ impl AudioResampler {
pub fn with_sender_volume_percent(
source_format: AudioFormat,
sender_volume_percent: u16,
) -> Self {
Self::with_config(source_format, sender_volume_percent, RAOP_FRAMES_PER_PACKET)
}

#[must_use]
pub fn with_config(
source_format: AudioFormat,
sender_volume_percent: u16,
frames_per_packet: usize,
) -> Self {
let mut resampler = Self {
source_format,
sender_volume_gain: 1.0,
frames_per_packet: frames_per_packet.max(1),
phase_numerator: 0,
pending_input_frames: Vec::new(),
pending_output_frames: Vec::new(),
Expand Down Expand Up @@ -105,7 +117,7 @@ impl AudioResampler {

Ok(drain_pcm_packets(
resampled_frames,
RAOP_FRAMES_PER_PACKET,
self.frames_per_packet,
&mut self.pending_output_frames,
))
}
Expand Down Expand Up @@ -682,6 +694,23 @@ mod tests {
assert!(packets.iter().all(|packet| packet.len() == 352 * 4));
}

#[test]
fn resampler_uses_configured_frames_per_packet() {
let format = AudioFormat::default();
let mut resampler = AudioResampler::with_config(format, 100, 128);
let mut bytes = Vec::new();
for _ in 0..256 {
bytes.extend_from_slice(&1000_i16.to_le_bytes());
bytes.extend_from_slice(&(-1000_i16).to_le_bytes());
}
let chunk = AudioChunk::new(format, bytes).unwrap();

let packets = resampler.push_chunk(&chunk).unwrap();

assert_eq!(packets.len(), 2);
assert!(packets.iter().all(|packet| packet.len() == 128 * 4));
}

#[test]
fn resampler_accepts_matching_raop_pcm_input() {
let format = AudioFormat::default();
Expand Down
15 changes: 11 additions & 4 deletions src/cli/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,20 @@ 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,
latency_profile,
} => {
facade.play_file(&path, &selectors, latency_profile)?;
print_play_file_completed(&path, &selectors);
Ok(())
}
CliCommand::PlayCapture { selectors } => {
let session = facade.play_capture(&selectors)?;
CliCommand::PlayCapture {
selectors,
latency_profile,
} => {
let session = facade.play_capture(&selectors, latency_profile)?;
print_play_capture_started(&selectors);
match wait_for_ctrl_c_or_capture_end(&session)? {
CaptureExit::UserRequestedStop => {
Expand Down
160 changes: 141 additions & 19 deletions src/cli/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use std::path::PathBuf;

use crate::error::RairstreamError;
use crate::session::LatencyProfile;

pub(crate) const CLI_USAGE: &str =
"usage: rairstream [-h|--help] [-v|-vv] [--log-level <error|warn|info|debug|trace>] <command>";
Expand All @@ -14,8 +15,8 @@ pub(crate) const CLI_COMMAND_USAGE: &[&str] = &[
"pair --device <selector> [--pin <PIN>]",
"paired list",
"paired forget --device <selector>",
"play file <path> --device <selector>...",
"play capture --device <selector>...",
"play file <path> --device <selector>... [--latency <safe|normal|low|realtime|custom>] [--buffer-ms <ms>] [--packet-frames <frames>]",
"play capture --device <selector>... [--latency <safe|normal|low|realtime|custom>] [--buffer-ms <ms>] [--packet-frames <frames>]",
];

#[derive(Debug, Clone, PartialEq, Eq)]
Expand All @@ -36,9 +37,11 @@ pub enum CliCommand {
PlayFile {
path: PathBuf,
selectors: Vec<String>,
latency_profile: LatencyProfile,
},
PlayCapture {
selectors: Vec<String>,
latency_profile: LatencyProfile,
},
}

Expand All @@ -49,6 +52,12 @@ pub struct CliOptions {
pub verbosity: u8,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LatencySelection {
Profile(LatencyProfile),
Custom,
}

impl CliOptions {
#[must_use]
pub fn log_filter(&self) -> &str {
Expand Down Expand Up @@ -169,14 +178,20 @@ fn parse_play_command(args: &[String]) -> Result<CliCommand, RairstreamError> {
let Some((path, selectors)) = tail.split_first() else {
return Err(usage_error());
};
let (selectors, latency_profile) = parse_play_selectors_and_latency(selectors)?;
Ok(CliCommand::PlayFile {
path: PathBuf::from(path),
selectors: parse_device_selectors(selectors)?,
selectors,
latency_profile,
})
}
"capture" => {
let (selectors, latency_profile) = parse_play_selectors_and_latency(tail)?;
Ok(CliCommand::PlayCapture {
selectors,
latency_profile,
})
}
"capture" => Ok(CliCommand::PlayCapture {
selectors: parse_device_selectors(tail)?,
}),
_ => Err(usage_error()),
}
}
Expand Down Expand Up @@ -224,32 +239,139 @@ fn normalize_cli_text(value: &str, label: &str) -> Result<String, RairstreamErro
Ok(value.to_string())
}

fn parse_device_selectors(args: &[String]) -> Result<Vec<String>, RairstreamError> {
fn parse_play_selectors_and_latency(
args: &[String],
) -> Result<(Vec<String>, LatencyProfile), RairstreamError> {
let mut selectors = Vec::new();
let mut latency_selection = None;
let mut custom_buffer_ms = None;
let mut custom_packet_frames = None;
let mut index = 0;
while index < args.len() {
if args[index] != "--device" {
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;
}
"--latency" => {
let value = args
.get(index + 1)
.ok_or_else(|| missing_value_error("--latency"))?;
latency_selection = Some(parse_latency_selection(value)?);
index += 2;
}
"--buffer-ms" => {
let value = args
.get(index + 1)
.ok_or_else(|| missing_value_error("--buffer-ms"))?;
custom_buffer_ms = Some(parse_buffer_ms(value)?);
index += 2;
}
"--packet-frames" => {
let value = args
.get(index + 1)
.ok_or_else(|| missing_value_error("--packet-frames"))?;
custom_packet_frames = Some(parse_packet_frames(value)?);
index += 2;
}
unexpected if unexpected.starts_with("--latency=") => {
latency_selection = Some(parse_latency_selection(
unexpected.trim_start_matches("--latency="),
)?);
index += 1;
}
unexpected if unexpected.starts_with("--buffer-ms=") => {
custom_buffer_ms = Some(parse_buffer_ms(
unexpected.trim_start_matches("--buffer-ms="),
)?);
index += 1;
}
unexpected if unexpected.starts_with("--packet-frames=") => {
custom_packet_frames = Some(parse_packet_frames(
unexpected.trim_start_matches("--packet-frames="),
)?);
index += 1;
}
unexpected => {
return Err(RairstreamError::InvalidCli {
message: format!("unexpected argument `{unexpected}`"),
});
}
}
}

if selectors.is_empty() {
return Err(RairstreamError::InvalidCli {
message: String::from("at least one --device selector is required"),
});
}

let latency_profile = match (latency_selection, custom_buffer_ms, custom_packet_frames) {
(Some(LatencySelection::Custom), None, None) => {
return Err(RairstreamError::InvalidCli {
message: format!("unexpected argument `{}`", args[index]),
message: String::from(
"--latency custom requires --buffer-ms <ms> or --packet-frames <frames>",
),
});
}
(_, Some(buffer_ms), packet_frames) => LatencyProfile::custom_with_packet_frames(
buffer_ms,
packet_frames.unwrap_or(crate::audio::RAOP_FRAMES_PER_PACKET),
),
(_, None, Some(packet_frames)) => {
let buffer_ms = match latency_selection {
Some(LatencySelection::Profile(profile)) => profile.buffer_ms(),
Some(LatencySelection::Custom) | None => LatencyProfile::safe().buffer_ms(),
};
LatencyProfile::custom_with_packet_frames(buffer_ms, packet_frames)
}
(Some(LatencySelection::Profile(profile)), None, None) => profile,
(None, None, None) => LatencyProfile::safe(),
};

Ok((selectors, latency_profile))
}

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;
fn parse_latency_selection(value: &str) -> Result<LatencySelection, RairstreamError> {
match value {
"safe" => Ok(LatencySelection::Profile(LatencyProfile::safe())),
"normal" => Ok(LatencySelection::Profile(LatencyProfile::normal())),
"low" => Ok(LatencySelection::Profile(LatencyProfile::low())),
"realtime" => Ok(LatencySelection::Profile(LatencyProfile::realtime())),
"custom" => Ok(LatencySelection::Custom),
_ => Err(RairstreamError::InvalidCli {
message: format!("unsupported latency profile `{value}`"),
}),
}
}

if selectors.is_empty() {
fn parse_buffer_ms(value: &str) -> Result<u32, RairstreamError> {
value
.parse::<u32>()
.map_err(|_| RairstreamError::InvalidCli {
message: format!("--buffer-ms must be a non-negative integer, got `{value}`"),
})
}

fn parse_packet_frames(value: &str) -> Result<usize, RairstreamError> {
let frames = value
.parse::<usize>()
.map_err(|_| RairstreamError::InvalidCli {
message: format!("--packet-frames must be a positive integer, got `{value}`"),
})?;

if frames == 0 {
return Err(RairstreamError::InvalidCli {
message: String::from("at least one --device selector is required"),
message: String::from("--packet-frames must be greater than 0"),
});
}

Ok(selectors)
Ok(frames)
}

fn usage_error() -> RairstreamError {
Expand Down
Loading
Loading