aboutsummaryrefslogtreecommitdiff
path: root/research/sample-data/mp3_psa_streamer.py
diff options
context:
space:
mode:
Diffstat (limited to 'research/sample-data/mp3_psa_streamer.py')
-rw-r--r--research/sample-data/mp3_psa_streamer.py374
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()