use std::env; use std::fs::File; use std::io::Read; use std::net::UdpSocket; use std::path::Path; use std::process::Command; use std::thread; use std::time::{Duration, Instant}; use anyhow::{anyhow, Result}; use clap::Parser; use indicatif::{ProgressBar, ProgressStyle}; use tempfile::NamedTempFile; // Default stream ID that's known to work with Bodet systems (as hex string like Python) const DEFAULT_STREAM_ID: u16 = 0x110c; #[derive(Parser)] #[command(name = "sigma-caster")] #[command(about = "Stream MP3 files to Bodet PSA System")] #[command(long_about = None)] struct Args { /// Path to MP3 file to stream mp3_file: String, /// Multicast/unicast address #[arg(short = 'i', long = "ip", default_value = "239.192.55.1", help = "IP address to send to")] addr: String, /// UDP port #[arg(short, long, default_value = "1681", help = "UDP port")] port: u16, /// Zones (space-separated list like: 1 2 3) #[arg(short = 'z', long = "zones", value_delimiter = ' ', default_values = ["1"], help = "Zone numbers (space-separated)")] zones: Vec, /// Software volume control (1-8, where 8 is loudest) #[arg(short = 'v', long = "volume", default_value = "8", help = "Software volume (1-8, where 8 is loudest)")] volume: u8, /// Don't send duplicate packets #[arg(long, help = "Don't send duplicate packets")] no_duplicates: bool, /// MP3 chunk size in bytes #[arg(short, long, default_value = "1000", help = "MP3 chunk size in bytes")] chunk_size: usize, /// Audio quality preset #[arg(short, long, value_enum, default_value = "high", help = "Audio quality preset")] quality: Quality, /// Fixed stream ID (default: 0x110c) #[arg(long, help = "Fixed stream ID (default: 0x110c)")] stream_id: Option, /// Show debug information #[arg(long, help = "Show debug information")] debug: bool, } #[derive(clap::ValueEnum, Clone, Debug)] enum Quality { High, Low, } struct QualityPreset { bitrate: &'static str, sample_rate: u32, description: &'static str, } impl Quality { fn preset(&self) -> QualityPreset { match self { Quality::High => QualityPreset { bitrate: "256k", sample_rate: 48000, description: "High Quality (256kbps, 48kHz, Mono)", }, Quality::Low => QualityPreset { bitrate: "64k", sample_rate: 32000, description: "Low Quality (64kbps, 32kHz, Mono)", }, } } } fn compute_psa_checksum(data: &[u8]) -> [u8; 2] { let mut var_e: u16 = 0x0000; for (i, &b) in data.iter().enumerate() { var_e ^= (b as u16).wrapping_add(i as u16) & 0xFFFF; } var_e.to_be_bytes() } fn create_mp3_packet(sequence_num: u8, zone_info: &[u8], stream_id: u16, mp3_chunk: &[u8]) -> Result> { let mut packet = Vec::new(); // MEL header packet.extend_from_slice(b"MEL"); // Build payload components let start_marker = [0x01, 0x00]; let seq = [sequence_num, 0xff]; let command = [0x07, 0x03]; let stream_id_bytes = stream_id.to_le_bytes(); let constant_data = [0x05, 0x08, 0x05, 0x03, 0xe8]; // Calculate payload length - matches Python: start_marker + payload + 7 let payload_len = seq.len() + command.len() + zone_info.len() + stream_id_bytes.len() + constant_data.len() + mp3_chunk.len(); let total_len = start_marker.len() + payload_len + 7; // Length field (big endian) packet.extend_from_slice(&(total_len as u16).to_be_bytes()); // Start marker packet.extend_from_slice(&start_marker); // Payload packet.extend_from_slice(&seq); packet.extend_from_slice(&command); packet.extend_from_slice(zone_info); packet.extend_from_slice(&stream_id_bytes); packet.extend_from_slice(&constant_data); packet.extend_from_slice(mp3_chunk); // Checksum let checksum = compute_psa_checksum(&packet); packet.extend_from_slice(&checksum); Ok(packet) } fn check_lame_available() -> Result<()> { Command::new("lame") .arg("--version") .output() .map_err(|_| anyhow!("LAME encoder not found. Please install LAME and ensure it's in PATH."))?; Ok(()) } fn apply_volume_to_mp3(input_file: &str, output_file: &str, volume: u8, preset: &QualityPreset) -> Result<()> { // Convert volume (1-8) to LAME scale factor let volume_scale = (volume as f32) / 8.0; let mut cmd = Command::new("lame"); cmd.arg("--quiet") .arg("-m").arg("m") // mono .arg("--resample").arg(&(preset.sample_rate / 1000).to_string()) .arg("-b").arg(preset.bitrate.trim_end_matches('k')) .arg("--cbr") .arg("--noreplaygain"); // Apply volume scaling if needed if volume < 8 { cmd.arg("--scale").arg(&format!("{:.3}", volume_scale)); } cmd.arg("--tt").arg("") // empty title .arg("--tc").arg("") // empty comment .arg("--nohist") .arg(input_file) .arg(output_file); let output = cmd.output()?; if !output.status.success() { return Err(anyhow!("LAME transcoding with volume failed: {}", String::from_utf8_lossy(&output.stderr))); } Ok(()) } fn transcode_mp3(input_file: &str, preset: &QualityPreset, volume: u8) -> Result { println!("Transcoding audio to {} (Volume: {}/8)...", preset.description, volume); let temp_file = NamedTempFile::new()?; apply_volume_to_mp3(input_file, temp_file.path().to_str().unwrap(), volume, preset)?; Ok(temp_file) } fn chunk_data(data: &[u8], chunk_size: usize) -> Vec<&[u8]> { data.chunks(chunk_size).collect() } fn calculate_delay_ms(chunk_size: usize, bitrate_kbps: u32) -> u64 { let bytes_per_second = bitrate_kbps * 1000 / 8; (chunk_size as u64 * 1000) / bytes_per_second as u64 } fn build_zone_bytes(zones: &[u8]) -> [u8; 13] { let mut zone_bytes = [0u8; 13]; // 13 bytes like Python (12 + extra 00) for &zone in zones { if zone == 0 || zone > 100 { continue; } let idx = (zone - 1) / 8; let bit = (zone - 1) % 8; zone_bytes[idx as usize] |= 1 << bit; } // Last byte stays 0x00 as per Python DEFAULT_ZONE_INFO pattern zone_bytes } fn main() -> Result<()> { // Show help if no arguments provided if env::args().len() == 1 { eprintln!("sigma-caster - Stream MP3 files to Bodet PSA System Usage: sigma-caster [OPTIONS] Arguments: Path to MP3 file to stream Options: -i, --ip IP address to send to [default: 239.192.55.1] -p, --port UDP port [default: 1681] -z, --zones ... Zone numbers (space-separated) [default: 1] -v, --volume Software volume (1-8, where 8 is loudest) [default: 8] --no-duplicates Don't send duplicate packets -c, --chunk-size MP3 chunk size in bytes [default: 1000] -q, --quality Audio quality preset [default: high] [possible values: high, low] --stream-id Fixed stream ID (default: 0x110c) --debug Show debug information -h, --help Print help Examples: sigma-caster audio.mp3 # Stream audio.mp3 to zone 1 at full volume sigma-caster -i 172.16.20.109 -z 1 2 3 -v 6 audio.mp3 # Stream to zones 1,2,3 at volume 6/8 on specific IP sigma-caster --debug -q low audio.mp3 # Stream with low quality and show debug info"); std::process::exit(1); } let args = Args::parse(); // Validate volume range if !(1..=8).contains(&args.volume) { return Err(anyhow!("Volume must be between 1 and 8")); } // Check dependencies check_lame_available()?; // Validate input file if !Path::new(&args.mp3_file).exists() { return Err(anyhow!("MP3 file not found: {}", args.mp3_file)); } // Build zone info from zone numbers let zone_bytes = build_zone_bytes(&args.zones); // Get quality preset let preset = args.quality.preset(); // Transcode MP3 with volume control let temp_file = transcode_mp3(&args.mp3_file, &preset, args.volume)?; // Read transcoded MP3 data let mut mp3_data = Vec::new(); File::open(temp_file.path())?.read_to_end(&mut mp3_data)?; // Use default stream ID if not specified let stream_id = args.stream_id.unwrap_or(DEFAULT_STREAM_ID); // Chunk the data let chunks = chunk_data(&mp3_data, args.chunk_size); let total_chunks = chunks.len(); // Calculate timing let bitrate_kbps = preset.bitrate.trim_end_matches('k').parse::()?; let delay_ms = calculate_delay_ms(args.chunk_size, bitrate_kbps); println!("Streaming {} ({:.2} KB)", Path::new(&args.mp3_file).file_name().unwrap().to_string_lossy(), mp3_data.len() as f64 / 1024.0); println!("Split into {} packets of {} bytes each", total_chunks, args.chunk_size); println!("Sending to {}:{}", args.addr, args.port); println!("Stream ID: {:04x}, Quality: {}", stream_id, preset.description); println!("Zones: {:?}, Volume: {}/8", args.zones, args.volume); println!("Duplicate packets: {}", if args.no_duplicates { "No" } else { "Yes" }); println!("Delay per packet: {}ms", delay_ms); // Setup UDP socket let socket = UdpSocket::bind("0.0.0.0:0")?; let target_addr = format!("{}:{}", args.addr, args.port); // Progress bar let progress = ProgressBar::new(total_chunks as u64); progress.set_style(ProgressStyle::default_bar() .template("[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} {msg}") .unwrap()); // Stream chunks let start_time = Instant::now(); let mut sequence_num = 0u8; for (i, chunk) in chunks.iter().enumerate() { // Create packet let packet = create_mp3_packet(sequence_num, &zone_bytes, stream_id, chunk)?; if args.debug && i == 0 { let hex_str = packet.iter().map(|b| format!("{:02x}", b)).collect::(); println!("DEBUG: First packet hex: {}", hex_str); } // Send packet socket.send_to(&packet, &target_addr)?; // Send duplicate if requested if !args.no_duplicates { socket.send_to(&packet, &target_addr)?; } sequence_num = sequence_num.wrapping_add(1); progress.inc(1); // Wait for next packet (except for last one) if i < total_chunks - 1 { thread::sleep(Duration::from_millis(delay_ms)); } } progress.finish_with_message("Streaming completed!"); println!("Total time: {:.2}s", start_time.elapsed().as_secs_f64()); Ok(()) }