diff options
Diffstat (limited to 'research/sample-data/mp3_psa_streamer.py')
| -rw-r--r-- | research/sample-data/mp3_psa_streamer.py | 374 |
1 files changed, 374 insertions, 0 deletions
diff --git a/research/sample-data/mp3_psa_streamer.py b/research/sample-data/mp3_psa_streamer.py new file mode 100644 index 0000000..f79d674 --- /dev/null +++ b/research/sample-data/mp3_psa_streamer.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +import os +import sys +import time +import socket +import struct +import argparse +import random +import tempfile +import subprocess +from tqdm import tqdm # For progress bar + +# Default configuration values +DEFAULT_MULTICAST_ADDR = "239.192.55.1" +DEFAULT_PORT = 1681 +DEFAULT_ZONE_INFO = "100000000000000000000000" # Zone 1 +DEFAULT_SEND_DUPLICATES = True +DEFAULT_CHUNK_SIZE = 900 +DEFAULT_TTL = 2 +DEFAULT_HEADER = "4d454c" # MEL header +DEFAULT_COMMAND = "070301" # Stream command + +# Audio quality presets +HIGH_QUALITY = { + "bitrate": "64k", + "sample_rate": 48000, + "channels": 1, # mono + "sample_format": "s32", # 16-bit samples (default for MP3) + "description": "Ass but no Durchfall" + # Note: Original software appears to use LAME 3.99.5 +} + +LOW_QUALITY = { + "bitrate": "64k", + "sample_rate": 32000, + "channels": 1, # mono + "sample_format": "s16", # 16-bit samples (default for MP3) + "description": "Ass with durchfalls" +} + +def compute_psa_checksum(data: bytes) -> bytes: + """Compute PSA checksum for packet data.""" + var_e = 0x0000 # Starting seed value + for i in range(len(data)): + var_e ^= (data[i] + i) & 0xFFFF + return var_e.to_bytes(2, 'big') # 2-byte checksum, big-endian + +def create_mp3_packet(sequence_num, zone_info, stream_id, mp3_chunk): + """Create a PSA packet containing MP3 data.""" + # Part 1: Header (static "MEL") + header = bytes.fromhex("4d454c") + + # Prepare all the components that will go into the payload + # Start marker (0100) + start_marker = bytes.fromhex("0100") + + # Sequence number (2 bytes, Little Endian with FF padding) + seq = sequence_num.to_bytes(1, 'little') + b'\xff' + + # Command (static 070301) + command = bytes.fromhex(DEFAULT_COMMAND) + + # Zone info + zones = bytes.fromhex(zone_info) + + # Stream ID (4 bytes) + stream_id_bytes = stream_id.to_bytes(4, 'little') + + # Constant data (appears in all original packets) + constant_data = bytes.fromhex("05080503e8") + + # Build the payload (everything that comes after start_marker) + payload = seq + command + zones + stream_id_bytes + constant_data + mp3_chunk + + # Calculate the length for the header + # Length is everything AFTER the length field: start_marker + payload + # But NOT including checksum (which is calculated last) + length_value = len(start_marker + payload) + 7 + + # Insert the length as a 2-byte value (big endian) + length_bytes = length_value.to_bytes(2, 'big') + + # Assemble the packet without checksum + packet_data = header + length_bytes + start_marker + payload + + # Calculate and append checksum + checksum = compute_psa_checksum(packet_data) + + # Debug the packet length + final_packet = packet_data + checksum + print(f"Packet length: {len(final_packet)} bytes, Length field: {length_value} (0x{length_value:04x})") + + return final_packet + +def send_multicast_packet(sock, packet, multicast_addr, port, send_duplicates=True): + """Send a packet to the multicast address.""" + try: + # Send the packet + sock.sendto(packet, (multicast_addr, port)) + + # Send a duplicate (common in multicast to improve reliability) + if send_duplicates: + sock.sendto(packet, (multicast_addr, port)) + + return True + except Exception as e: + print(f"Error sending packet: {e}") + return False + +def setup_multicast_socket(ttl=DEFAULT_TTL): + """Set up a socket for sending multicast UDP.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) + return sock + +def check_dependencies(): + """Check if required dependencies are installed.""" + try: + subprocess.run(['ffmpeg', '-version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except FileNotFoundError: + print("Error: ffmpeg is required but not found.") + print("Please install ffmpeg and make sure it's available in your PATH.") + sys.exit(1) + +def get_audio_info(mp3_file): + """Get audio file information using ffprobe.""" + try: + # Get audio stream information + cmd = [ + 'ffprobe', '-v', 'quiet', '-print_format', 'json', + '-show_streams', '-select_streams', 'a:0', mp3_file + ] + result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + if result.returncode != 0: + print(f"Error analyzing audio file: {result.stderr}") + return None + + import json + info = json.loads(result.stdout) + + if 'streams' not in info or len(info['streams']) == 0: + print("No audio stream found in the file.") + return None + + stream = info['streams'][0] + + # Extract relevant information + bit_rate = int(stream.get('bit_rate', '0')) // 1000 if 'bit_rate' in stream else None + sample_rate = int(stream.get('sample_rate', '0')) + channels = int(stream.get('channels', '0')) + codec_name = stream.get('codec_name', 'unknown') + + return { + 'bit_rate': bit_rate, + 'sample_rate': sample_rate, + 'channels': channels, + 'codec_name': codec_name + } + + except Exception as e: + print(f"Error getting audio information: {e}") + return None + +def transcode_mp3(input_file, quality_preset, include_metadata=False): + """Transcode MP3 file to match required specifications.""" + print(f"Transcoding audio to {quality_preset['description']}...") + + # Create temporary output file + fd, temp_output = tempfile.mkstemp(suffix='.mp3') + os.close(fd) + + try: + # Base command + cmd = [ + 'ffmpeg', + '-y', # Overwrite output file + '-i', input_file, + '-codec:a', 'libmp3lame', # Force LAME encoder + '-ac', str(quality_preset['channels']), + '-ar', str(quality_preset['sample_rate']), + '-b:a', quality_preset['bitrate'], + '-sample_fmt', quality_preset['sample_format'], # Use configurable sample format + ] + + # Add options to minimize metadata if requested + if not include_metadata: + cmd.extend(['-metadata', 'title=', '-metadata', 'artist=', + '-metadata', 'album=', '-metadata', 'comment=', + '-map_metadata', '-1']) # Strip all metadata + + cmd.append(temp_output) + + result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) + + if result.returncode != 0: + print(f"Error transcoding audio: {result.stderr}") + os.remove(temp_output) + return None + + return temp_output + + except Exception as e: + print(f"Error during transcoding: {e}") + if os.path.exists(temp_output): + os.remove(temp_output) + return None + +def prepare_mp3_file(mp3_file, quality, include_metadata=False): + """Ensure the MP3 file meets the required specifications.""" + # Check if quality is valid + quality_preset = HIGH_QUALITY if quality == "high" else LOW_QUALITY + + # Get audio file information + info = get_audio_info(mp3_file) + if not info: + return None + + print(f"Audio file details: {info['codec_name']}, {info['bit_rate']}kbps, " + f"{info['sample_rate']}Hz, {info['channels']} channel(s)") + + # Check if transcoding is needed + needs_transcode = False + + if info['codec_name'].lower() != 'mp3': + print("Non-MP3 format detected. Transcoding required.") + needs_transcode = True + elif info['bit_rate'] is None or abs(info['bit_rate'] - int(quality_preset['bitrate'][:-1])) > 5: + print(f"Bitrate mismatch: {info['bit_rate']}kbps vs {quality_preset['bitrate']}. Transcoding required.") + needs_transcode = True + elif info['sample_rate'] != quality_preset['sample_rate']: + print(f"Sample rate mismatch: {info['sample_rate']}Hz vs {quality_preset['sample_rate']}Hz. Transcoding required.") + needs_transcode = True + elif info['channels'] != quality_preset['channels']: + print(f"Channel count mismatch: {info['channels']} vs {quality_preset['channels']} (mono). Transcoding required.") + needs_transcode = True + + # If transcoding is needed, do it + if needs_transcode: + return transcode_mp3(mp3_file, quality_preset, include_metadata) + else: + print("Audio file already meets required specifications.") + return mp3_file + +def calculate_delay_ms(chunk_size, quality): + """Calculate the appropriate delay between chunks for real-time streaming.""" + # Calculate bytes_per_second dynamically from the bitrate + if quality == "high": + # 128kbps = 16,000 bytes/second + bytes_per_second = 16000 + else: + # 40kbps = 5,000 bytes/second + bytes_per_second = 5000 + + # Calculate how long this chunk would play for in real-time + delay_ms = (chunk_size / bytes_per_second) * 1000 + return delay_ms + +def stream_mp3_to_psa(mp3_file, multicast_addr, port, zone_info, send_duplicates, + chunk_size, quality, include_metadata=False): + """Stream an MP3 file to PSA system.""" + try: + # Check dependencies + check_dependencies() + + # Prepare MP3 file (transcode if needed) + prepared_file = prepare_mp3_file(mp3_file, quality, include_metadata) + if not prepared_file: + print("Failed to prepare MP3 file.") + return + + using_temp_file = prepared_file != mp3_file + + try: + # Generate random stream ID for this transmission + stream_id = random.randint(0, 0xFFFFFFFF) # Changed to 32-bit to accommodate 4 bytes + + # Open the MP3 file + with open(prepared_file, 'rb') as f: + mp3_data = f.read() + + # Calculate number of chunks + chunks = [mp3_data[i:i+chunk_size] for i in range(0, len(mp3_data), chunk_size)] + total_chunks = len(chunks) + + # Calculate the appropriate delay for real-time streaming + delay_ms = calculate_delay_ms(chunk_size, quality) + + print(f"Streaming {os.path.basename(mp3_file)} ({len(mp3_data)/1024:.2f} KB)") + print(f"Split into {total_chunks} packets of {chunk_size} bytes each") + print(f"Sending to multicast {multicast_addr}:{port}") + print(f"Stream ID: {stream_id:08x}, Zone info: {zone_info}") + print(f"Duplicate packets: {'Yes' if send_duplicates else 'No'}") + print(f"Quality preset: {HIGH_QUALITY['description'] if quality == 'high' else LOW_QUALITY['description']}") + print(f"Including metadata: {'Yes' if include_metadata else 'No'}") + print(f"Real-time streaming delay: {delay_ms:.2f}ms per packet") + + # Setup multicast socket + sock = setup_multicast_socket() + + # Process and send each chunk + sequence_num = 0 + with tqdm(total=total_chunks, desc="Streaming progress") as pbar: + try: + for i, chunk in enumerate(chunks): + # Create packet with current sequence number + packet = create_mp3_packet(sequence_num, zone_info, stream_id, chunk) + + # Send the packet + success = send_multicast_packet(sock, packet, multicast_addr, port, send_duplicates) + + # Increment sequence num + sequence_num = (sequence_num + 1) % 256 + + # Update progress bar + pbar.update(1) + + # Delay to match real-time playback + if i < total_chunks - 1: # No need to wait after the last chunk + time.sleep(delay_ms / 1000.0) + except Exception as e: + print(f"Error during streaming: {e}") + + print("\nStreaming completed successfully!") + + finally: + # Clean up temporary file if created + if using_temp_file and os.path.exists(prepared_file): + os.remove(prepared_file) + + except FileNotFoundError: + print(f"Error: MP3 file {mp3_file} not found") + sys.exit(1) + except Exception as e: + print(f"Error during streaming: {e}") + sys.exit(1) + +def main(): + # Set up command line arguments + parser = argparse.ArgumentParser(description="Stream MP3 to Bodet PSA System") + parser.add_argument("mp3_file", help="Path to MP3 file to stream") + parser.add_argument("-a", "--addr", default=DEFAULT_MULTICAST_ADDR, + help=f"Multicast address (default: {DEFAULT_MULTICAST_ADDR})") + parser.add_argument("-p", "--port", type=int, default=DEFAULT_PORT, + help=f"UDP port (default: {DEFAULT_PORT})") + parser.add_argument("-z", "--zone", default=DEFAULT_ZONE_INFO, + help=f"Hex zone info (default: Zone 1)") + parser.add_argument("-n", "--no-duplicates", action="store_true", + help="Don't send duplicate packets (default: send duplicates)") + parser.add_argument("-c", "--chunk-size", type=int, default=DEFAULT_CHUNK_SIZE, + help=f"MP3 chunk size in bytes (default: {DEFAULT_CHUNK_SIZE})") + parser.add_argument("-q", "--quality", choices=["high", "low"], default="high", + help="Audio quality preset: high (128kbps, 48kHz) or low (40kbps, 32kHz) (default: high)") + parser.add_argument("-m", "--include-metadata", action="store_true", + help="Include metadata in MP3 stream (default: no metadata)") + parser.add_argument("-s", "--sample-format", choices=["s16", "s24", "s32"], default=None, + help="Sample format to use for transcoding (default: s16)") + + args = parser.parse_args() + + # Stream the MP3 file + stream_mp3_to_psa( + args.mp3_file, + args.addr, + args.port, + args.zone, + not args.no_duplicates, + args.chunk_size, + args.quality, + args.include_metadata + ) + +if __name__ == "__main__": + main() |
