diff options
Diffstat (limited to 'research/sample-data')
| -rw-r--r-- | research/sample-data/mp3_psa_streamer.py | 347 | ||||
| -rw-r--r-- | research/sample-data/random-music/30_tagesschau-gong.mp3 | bin | 0 -> 20046 bytes |
2 files changed, 104 insertions, 243 deletions
diff --git a/research/sample-data/mp3_psa_streamer.py b/research/sample-data/mp3_psa_streamer.py index 36d2974..f79d674 100644 --- a/research/sample-data/mp3_psa_streamer.py +++ b/research/sample-data/mp3_psa_streamer.py @@ -8,46 +8,36 @@ import argparse import random import tempfile import subprocess -from tqdm import tqdm # progress bar sexxy - -# need that for lame transcoding -try: - from mutagen.mp3 import MP3 # type: ignore - MUTAGEN_AVAILABLE = True -except ImportError: - MUTAGEN_AVAILABLE = False +from tqdm import tqdm # For progress bar # Default configuration values -DEFAULT_MULTICAST_ADDR = "172.16.20.109" +DEFAULT_MULTICAST_ADDR = "239.192.55.1" DEFAULT_PORT = 1681 -DEFAULT_ZONE_INFO = "01 1000 0000 0000 0000 0000 0000" # Zone 1 -DEFAULT_SEND_DUPLICATES = True # Do it like Bodet SIGMA does and just send duplicates "for redundancy" (UDP for emergency systems who the fuck though that was a good idea) -DEFAULT_CHUNK_SIZE = 1000 # Need to use 1000 due to not understanding protocol fully yet -DEFAULT_TTL = 3 # We want it going to Multicast and then to the client, nomore otherwise bodeter speaker gets confused -DEFAULT_HEADER = "4d454c" # MEL goofy ah header -DEFAULT_COMMAND = "0703" # Probably the stream command -DEFAULT_STREAM_ID = "110c" # Write none without qoutes to use random stream ID +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 -# Prem prem maximum it supports +# Audio quality presets HIGH_QUALITY = { - "bitrate": "256k", # 256k max - "sample_rate": 48000, # 48000 max - "channels": 1, # mono because uhm one speaker only - "sample_format": "s32", # max s32 - "description": "Highest Quality (256kbps, 48kHz)" + "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 } -# Bodeter Shitsy Sigma software defaults LOW_QUALITY = { - "bitrate": "64k", # Bodet Defaults - "sample_rate": 32000, # Bodet MP3 file default - "channels": 1, - "sample_format": "s16", # bodet MP3 file default - "description": "Normal Quality (64kbps, 32kHz)" + "bitrate": "64k", + "sample_rate": 32000, + "channels": 1, # mono + "sample_format": "s16", # 16-bit samples (default for MP3) + "description": "Ass with durchfalls" } -DEFAULT_TIMING_FACTOR = 1 # Used back when chat gpt suggested i should adjust the timing... but it was really just that I have to use 1000 byte chunks ... - def compute_psa_checksum(data: bytes) -> bytes: """Compute PSA checksum for packet data.""" var_e = 0x0000 # Starting seed value @@ -55,7 +45,7 @@ def compute_psa_checksum(data: bytes) -> bytes: 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, show_packet_length=False): +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") @@ -64,7 +54,7 @@ def create_mp3_packet(sequence_num, zone_info, stream_id, mp3_chunk, show_packet # Start marker (0100) start_marker = bytes.fromhex("0100") - # Sequence number (1 byte, Little Endian with FF padding) + # Sequence number (2 bytes, Little Endian with FF padding) seq = sequence_num.to_bytes(1, 'little') + b'\xff' # Command (static 070301) @@ -73,19 +63,19 @@ def create_mp3_packet(sequence_num, zone_info, stream_id, mp3_chunk, show_packet # Zone info zones = bytes.fromhex(zone_info) - # Stream ID (2 bytes) - stream_id_bytes = stream_id.to_bytes(2, 'little') + # 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 - IMPORTANT: This matches what the PSA software does - # Length is the total packet length MINUS the header (4d454c) and the length field itself (2 bytes) - length_value = len(start_marker) + len(payload) + 7 # +2 for checksum + # 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') @@ -96,11 +86,9 @@ def create_mp3_packet(sequence_num, zone_info, stream_id, mp3_chunk, show_packet # Calculate and append checksum checksum = compute_psa_checksum(packet_data) - # Create final packet + # Debug the packet length final_packet = packet_data + checksum - - if show_packet_length: - print(f"Packet length: {len(final_packet)} bytes, Length field: {length_value} (0x{length_value:04x})") + print(f"Packet length: {len(final_packet)} bytes, Length field: {length_value} (0x{length_value:04x})") return final_packet @@ -110,7 +98,7 @@ def send_multicast_packet(sock, packet, multicast_addr, port, send_duplicates=Tr # Send the packet sock.sendto(packet, (multicast_addr, port)) - # Send a duplicate (common in multicast to improve "reliability") + # Send a duplicate (common in multicast to improve reliability) if send_duplicates: sock.sendto(packet, (multicast_addr, port)) @@ -128,86 +116,54 @@ def setup_multicast_socket(ttl=DEFAULT_TTL): def check_dependencies(): """Check if required dependencies are installed.""" try: - subprocess.run(['lame', '--version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.run(['ffmpeg', '-version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except FileNotFoundError: - print("Error: LAME encoder is required but not found.") - print("Please install LAME and make sure it's available in your PATH.") + print("Error: ffmpeg is required but not found.") + print("Please install ffmpeg and make sure it's available in your PATH.") sys.exit(1) - - if not MUTAGEN_AVAILABLE: - print("Warning: mutagen library not found. Limited audio file analysis available.") - print("Install with: pip install mutagen") def get_audio_info(mp3_file): - """Get audio file information using mutagen if available.""" + """Get audio file information using ffprobe.""" try: - if MUTAGEN_AVAILABLE: - # Use mutagen to analyze the file - audio = MP3(mp3_file) - - # Extract relevant information - sample_rate = audio.info.sample_rate - bit_rate = int(audio.info.bitrate / 1000) # Convert to kbps - channels = audio.info.channels - codec_name = "mp3" if audio.info.layer == 3 else f"mpeg-{audio.info.layer}" - - return { - 'bit_rate': bit_rate, - 'sample_rate': sample_rate, - 'channels': channels, - 'codec_name': codec_name - } - else: - # Fallback method: use LAME to identify file info - cmd = ['lame', '--decode', mp3_file, '-t', '--brief', '-'] - result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) - - # LAME outputs limited info to stderr when using --brief - info_text = result.stderr - - # Very basic parsing - this is not ideal but works as a fallback - codec_name = "mp3" # Assume it's MP3 - sample_rate = 44100 # Default assumption - channels = 2 # Default assumption - bit_rate = 0 # Unknown - - for line in info_text.split('\n'): - if "MPEG" in line and "Layer" in line: - codec_name = "mp3" - if "Hz" in line: - parts = line.split() - for part in parts: - if "Hz" in part: - try: - sample_rate = int(part.replace("Hz", "")) - except ValueError: - pass - if "stereo" in line.lower(): - channels = 2 - elif "mono" in line.lower(): - channels = 1 - if "kbps" in line: - parts = line.split() - for part in parts: - if "kbps" in part: - try: - bit_rate = int(part.replace("kbps", "")) - except ValueError: - pass - - return { - 'bit_rate': bit_rate, - 'sample_rate': sample_rate, - 'channels': channels, - 'codec_name': codec_name - } + # 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 using LAME encoder.""" + """Transcode MP3 file to match required specifications.""" print(f"Transcoding audio to {quality_preset['description']}...") # Create temporary output file @@ -215,28 +171,25 @@ def transcode_mp3(input_file, quality_preset, include_metadata=False): os.close(fd) try: - # Build LAME command + # Base command cmd = [ - 'lame', - '--quiet', # Less output - '-m', 'm' if quality_preset['channels'] == 1 else 's', # m=mono, s=stereo - '--resample', str(quality_preset['sample_rate'] // 1000), # Convert to kHz - '-b', quality_preset['bitrate'].replace('k', ''), # Remove 'k' suffix - '--cbr', # Use constant bitrate + '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.append('--noreplaygain') - # Use more compatible tag options instead of --id3v2-none - cmd.append('--tt') - cmd.append('') # Empty title - cmd.append('--tc') - cmd.append('') # Empty comment - cmd.append('--nohist') + cmd.extend(['-metadata', 'title=', '-metadata', 'artist=', + '-metadata', 'album=', '-metadata', 'comment=', + '-map_metadata', '-1']) # Strip all metadata - # Input and output files - cmd.extend([input_file, temp_output]) + cmd.append(temp_output) result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) @@ -253,16 +206,11 @@ def transcode_mp3(input_file, quality_preset, include_metadata=False): os.remove(temp_output) return None -def prepare_mp3_file(mp3_file, quality, include_metadata=False, sample_format=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 - # Override sample format if specified - if sample_format: - quality_preset = quality_preset.copy() - quality_preset["sample_format"] = sample_format - # Get audio file information info = get_audio_info(mp3_file) if not info: @@ -294,73 +242,29 @@ def prepare_mp3_file(mp3_file, quality, include_metadata=False, sample_format=No print("Audio file already meets required specifications.") return mp3_file -def calculate_delay_ms(chunk_size, bitrate, timing_factor=DEFAULT_TIMING_FACTOR): - """ - Calculate the appropriate delay between chunks for real-time streaming. - - Args: - chunk_size: Size of each audio chunk in bytes - bitrate: Audio bitrate (string like '128k' or integer) - timing_factor: Factor to adjust timing (lower = faster playback) - - Returns: - Delay in milliseconds between packets - """ - if isinstance(bitrate, str) and bitrate.endswith('k'): - bitrate_kbps = int(bitrate[:-1]) +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: - bitrate_kbps = int(bitrate) + # 40kbps = 5,000 bytes/second + bytes_per_second = 5000 - bytes_per_second = bitrate_kbps * 1000 // 8 - delay_ms = (chunk_size / bytes_per_second) * 1000 * timing_factor + # Calculate how long this chunk would play for in real-time + delay_ms = (chunk_size / bytes_per_second) * 1000 return delay_ms -class StreamTimer: - """Maintains proper timing for streaming audio packets.""" - - def __init__(self, chunk_size, bitrate, timing_factor=DEFAULT_TIMING_FACTOR): - """Initialize the stream timer with audio parameters.""" - self.chunk_size = chunk_size - self.bitrate = bitrate - self.timing_factor = timing_factor - self.start_time = None - self.packets_sent = 0 - self.delay_per_packet = calculate_delay_ms(chunk_size, bitrate, timing_factor) / 1000.0 - - def start(self): - """Start the stream timer.""" - self.start_time = time.time() - self.packets_sent = 0 - - def wait_for_next_packet(self): - """Wait until it's time to send the next packet.""" - if self.start_time is None: - self.start() - return - - self.packets_sent += 1 - - # Calculate when this packet should be sent - target_time = self.start_time + (self.packets_sent * self.delay_per_packet) - - # Calculate how long to wait - now = time.time() - wait_time = target_time - now - - # Only wait if we're ahead of schedule - if wait_time > 0: - time.sleep(wait_time) - def stream_mp3_to_psa(mp3_file, multicast_addr, port, zone_info, send_duplicates, - chunk_size, quality, include_metadata=False, sample_format=None, - show_packet_length=False, timing_factor=DEFAULT_TIMING_FACTOR, fixed_stream_id=None): + 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, sample_format) + prepared_file = prepare_mp3_file(mp3_file, quality, include_metadata) if not prepared_file: print("Failed to prepare MP3 file.") return @@ -368,30 +272,24 @@ def stream_mp3_to_psa(mp3_file, multicast_addr, port, zone_info, send_duplicates using_temp_file = prepared_file != mp3_file try: - # Use fixed stream ID if provided, otherwise generate a random one - if fixed_stream_id is not None: - stream_id = fixed_stream_id - else: - stream_id = random.randint(0, 0xFFFF) # 16-bit (2 bytes) stream ID + # 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 = chunk_mp3_data(mp3_data, chunk_size) + chunks = [mp3_data[i:i+chunk_size] for i in range(0, len(mp3_data), chunk_size)] total_chunks = len(chunks) - # Get the appropriate quality preset - quality_preset = HIGH_QUALITY if quality == "high" else LOW_QUALITY - - # Calculate the appropriate delay for real-time streaming using the actual bitrate - delay_ms = calculate_delay_ms(chunk_size, quality_preset['bitrate']) + # 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:04x}, Zone info: {zone_info}") + 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'}") @@ -400,17 +298,13 @@ def stream_mp3_to_psa(mp3_file, multicast_addr, port, zone_info, send_duplicates # Setup multicast socket sock = setup_multicast_socket() - # Initialize the stream timer with the appropriate parameters - timer = StreamTimer(chunk_size, quality_preset['bitrate'], timing_factor) - timer.start() - # 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, show_packet_length) + 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) @@ -421,9 +315,9 @@ def stream_mp3_to_psa(mp3_file, multicast_addr, port, zone_info, send_duplicates # Update progress bar pbar.update(1) - # Wait for the next packet timing using our timer + # Delay to match real-time playback if i < total_chunks - 1: # No need to wait after the last chunk - timer.wait_for_next_packet() + time.sleep(delay_ms / 1000.0) except Exception as e: print(f"Error during streaming: {e}") @@ -441,28 +335,6 @@ def stream_mp3_to_psa(mp3_file, multicast_addr, port, zone_info, send_duplicates print(f"Error during streaming: {e}") sys.exit(1) -def chunk_mp3_data(data, chunk_size): - """ - Split MP3 data into chunks of specified size. - - Args: - data: The complete MP3 data as bytes - chunk_size: Size of each chunk in bytes - - Returns: - A list of data chunks - """ - chunks = [] - # Calculate how many chunks we'll have - num_chunks = (len(data) + chunk_size - 1) // chunk_size - - for i in range(num_chunks): - start = i * chunk_size - end = min((i + 1) * chunk_size, len(data)) - chunks.append(data[start:end]) - - return chunks - def main(): # Set up command line arguments parser = argparse.ArgumentParser(description="Stream MP3 to Bodet PSA System") @@ -483,12 +355,6 @@ def main(): 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)") - parser.add_argument("--show-packet-length", action="store_true", - help="Show packet length information during streaming") - parser.add_argument("-t", "--timing-factor", type=float, default=DEFAULT_TIMING_FACTOR, - help=f"Timing adjustment factor (lower = faster playback, default: {DEFAULT_TIMING_FACTOR})") - parser.add_argument("--fixed-stream-id", type=int, - help="Use a fixed stream ID instead of a random one (0-65535)") args = parser.parse_args() @@ -501,13 +367,8 @@ def main(): not args.no_duplicates, args.chunk_size, args.quality, - args.include_metadata, - args.sample_format, - args.show_packet_length, - args.timing_factor, - args.fixed_stream_id # Pass fixed_stream_id parameter + args.include_metadata ) -# Remove duplicate main() call if __name__ == "__main__": main() diff --git a/research/sample-data/random-music/30_tagesschau-gong.mp3 b/research/sample-data/random-music/30_tagesschau-gong.mp3 Binary files differnew file mode 100644 index 0000000..417b5c6 --- /dev/null +++ b/research/sample-data/random-music/30_tagesschau-gong.mp3 |
