summaryrefslogtreecommitdiff
path: root/src/sigma-caster.rs
diff options
context:
space:
mode:
authorcrt mlol <crt@teleco.ch>2025-06-18 16:48:11 +0200
committercrt mlol <crt@teleco.ch>2025-06-18 16:48:11 +0200
commit4c8b342a4353efedb2e4ade26d74231b6aa671d5 (patch)
tree87081396cc603bf99e8dbdc8836ed310a5f9d866 /src/sigma-caster.rs
i want to skibidi
Diffstat (limited to 'src/sigma-caster.rs')
-rw-r--r--src/sigma-caster.rs338
1 files changed, 338 insertions, 0 deletions
diff --git a/src/sigma-caster.rs b/src/sigma-caster.rs
new file mode 100644
index 0000000..b7f10eb
--- /dev/null
+++ b/src/sigma-caster.rs
@@ -0,0 +1,338 @@
+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<u8>,
+
+ /// 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<u16>,
+
+ /// 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<Vec<u8>> {
+ 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<NamedTempFile> {
+ 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] <MP3_FILE>
+
+Arguments:
+ <MP3_FILE> Path to MP3 file to stream
+
+Options:
+ -i, --ip <IP> IP address to send to [default: 239.192.55.1]
+ -p, --port <PORT> UDP port [default: 1681]
+ -z, --zones <ZONES>... Zone numbers (space-separated) [default: 1]
+ -v, --volume <VOLUME> Software volume (1-8, where 8 is loudest) [default: 8]
+ --no-duplicates Don't send duplicate packets
+ -c, --chunk-size <SIZE> MP3 chunk size in bytes [default: 1000]
+ -q, --quality <QUALITY> Audio quality preset [default: high] [possible values: high, low]
+ --stream-id <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::<u32>()?;
+ 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::<String>();
+ 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(())
+}