| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374 |
- #!/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()
|