Procházet zdrojové kódy

figured out streaming mp3 files "somewhat"

Sigma-Ohio před 5 měsíci
rodič
revize
af4f2f8ce1

binární
30_tagesschau-gong.mp3


+ 13 - 0
MP3

@@ -0,0 +1,13 @@
+EXAMPLES of codecs :
+Codec: MPEG Audio layer 1/2 (mpga)
+Channels: Stereo
+Sample rate: 24000 Hz
+Bits per sample: 32
+Bitrate: 64 kb/s
+
+
+Codec: MPEG Audio layer 1/2 (mpga)
+Channels: Mono
+Sample rate: 22050 Hz
+Bits per sample: 32
+Bitrate: 64 kb/s

+ 0 - 0
bodet_psa_cli.py


+ 513 - 0
research/sample-data/mp3_psa_streamer.py

@@ -0,0 +1,513 @@
+#!/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  # progress bar sexxy
+
+# need that for lame transcoding
+try:
+    from mutagen.mp3 import MP3 # type: ignore
+    MUTAGEN_AVAILABLE = True
+except ImportError:
+    MUTAGEN_AVAILABLE = False
+
+# Default configuration values
+DEFAULT_MULTICAST_ADDR = "172.16.20.109"
+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
+
+# Prem prem maximum it supports
+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)"
+}
+
+# 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)"
+}
+
+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
+    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, show_packet_length=False):
+    """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 (1 byte, 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 (2 bytes)
+    stream_id_bytes = stream_id.to_bytes(2, '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
+    
+    # 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)
+    
+    # Create final packet
+    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})")
+    
+    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(['lame', '--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.")
+        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."""
+    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
+            }
+    
+    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."""
+    print(f"Transcoding audio to {quality_preset['description']}...")
+    
+    # Create temporary output file
+    fd, temp_output = tempfile.mkstemp(suffix='.mp3')
+    os.close(fd)
+    
+    try:
+        # Build LAME 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
+        ]
+        
+        # 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')
+        
+        # Input and output files
+        cmd.extend([input_file, 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, sample_format=None):
+    """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:
+        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, 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])
+    else:
+        bitrate_kbps = int(bitrate)
+    
+    bytes_per_second = bitrate_kbps * 1000 // 8
+    delay_ms = (chunk_size / bytes_per_second) * 1000 * timing_factor
+    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):
+    """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)
+        if not prepared_file:
+            print("Failed to prepare MP3 file.")
+            return
+        
+        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
+            
+            # 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)
+            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'])
+            
+            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"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()
+            
+            # 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)
+                        
+                        # 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)
+                        
+                        # Wait for the next packet timing using our timer
+                        if i < total_chunks - 1:  # No need to wait after the last chunk
+                            timer.wait_for_next_packet()
+                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 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")
+    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)")
+    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()
+    
+    # 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,
+        args.sample_format,
+        args.show_packet_length,
+        args.timing_factor,
+        args.fixed_stream_id  # Pass fixed_stream_id parameter
+    )
+
+# Remove duplicate main() call
+if __name__ == "__main__":
+    main()

binární
research/sample-data/random-music/dont-play-this.mp3


binární
research/sample-data/random-music/frontier-earrape.mp3


binární
research/sample-data/random-music/how-many-sukkas.mp3


binární
research/sample-data/random-music/swisscom.mp3


binární
research/sample-data/random-music/trust-me.mp3


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
research/sample-data/streaming/400hz-sine-wave.txt


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
research/sample-data/streaming/400hz-square-wave.txt


+ 0 - 0
research/sample-data/audio-stream.txt → research/sample-data/streaming/audio-stream.txt


+ 343 - 0
research/sample-data/streaming/audio_analyzer.py

@@ -0,0 +1,343 @@
+import sys
+import re
+import binascii
+from collections import Counter, defaultdict
+import struct
+import os.path
+
+def parse_hex_stream(file_path):
+    """Parse hexadecimal stream from a text file."""
+    try:
+        with open(file_path, 'r') as f:
+            content = f.read()
+        
+        # Remove any whitespace and line breaks
+        content = re.sub(r'\s+', '', content)
+        return content
+    except Exception as e:
+        print(f"Error reading file: {e}")
+        return None
+
+def identify_packets(hex_stream):
+    """Split the hex stream into individual packets based on MEL header pattern."""
+    # Pattern is 4d454c04 which is "MEL\x04" in ASCII
+    packet_pattern = r'4d454c04'
+    
+    # Find all positions of the pattern
+    positions = [match.start() for match in re.finditer(packet_pattern, hex_stream)]
+    
+    packets = []
+    for i in range(len(positions)):
+        start = positions[i]
+        # If this is the last pattern occurrence, go to end of stream
+        end = positions[i+1] if i < len(positions) - 1 else len(hex_stream)
+        packet = hex_stream[start:end]
+        packets.append(packet)
+    
+    return packets
+
+def analyze_packet_structure(packet):
+    """Analyze the structure of a single packet."""
+    if len(packet) < 20:  # Ensure packet has enough bytes for header
+        return {"error": "Packet too short"}
+    
+    # Extract header components
+    header = packet[:8]  # MEL\x04
+    version = packet[8:12]  # Version or type
+    sequence = packet[12:16]  # Possibly sequence number
+    flags = packet[16:20]  # Possibly flags
+    
+    # Extract length fields (if they exist)
+    length_field = packet[20:28]
+    
+    # Extract the data portion (minus the checksum)
+    data = packet[28:-4]
+    
+    # Extract the checksum (last 2 bytes / 4 hex chars)
+    checksum = packet[-4:]
+    
+    # Calculate expected checksum (simple CRC)
+    # This is just a placeholder; actual checksum algorithm would need to be determined
+    calculated_checksum = binascii.crc32(bytes.fromhex(packet[:-4])) & 0xFFFF
+    checksum_match = hex(calculated_checksum)[2:].zfill(4) == checksum.lower()
+    
+    return {
+        "header": header,
+        "version": version,
+        "sequence": sequence,
+        "flags": flags,
+        "length_field": length_field,
+        "data_length": len(data) // 2,  # Byte count
+        "checksum": checksum,
+        "checksum_match": checksum_match,
+        "total_bytes": len(packet) // 2
+    }
+
+def detect_duplicates(packets):
+    """Detect duplicate packets in the stream."""
+    duplicates = []
+    for i in range(len(packets) - 1):
+        if packets[i] == packets[i + 1]:
+            duplicates.append(i)
+    
+    duplicate_percentage = (len(duplicates) / len(packets)) * 100 if packets else 0
+    return {
+        "duplicate_count": len(duplicates),
+        "duplicate_indices": duplicates,
+        "duplicate_percentage": duplicate_percentage
+    }
+
+def guess_codec(packets, file_path):
+    """Attempt to identify the audio codec based on packet patterns."""
+    # Extract common headers or patterns
+    headers = Counter([packet[:24] for packet in packets])
+    most_common_header = headers.most_common(1)[0][0] if headers else "Unknown"
+    
+    # Check for known codec signatures
+    codec = "Unknown"
+    quality = "Unknown"
+    
+    if "400hz-sine-wave" in file_path:
+        quality = "High Quality"
+    elif "400hz-square-wave" in file_path:
+        quality = "High Quality"
+    elif "audio-stream" in file_path:
+        quality = "Normal Quality"
+    
+    # Since we know the system uses the LAME encoder (binary shipped with software)
+    if most_common_header.startswith("4d454c0409010"):
+        codec = "LAME MP3 (packaged in MEL Audio Format)"
+    
+    # MP3 frame analysis
+    mp3_frame_sync_count = 0
+    potential_bitrate = None
+    potential_sample_rate = None
+    
+    # Check each packet for MP3 headers (starting with 0xFF 0xFB for MPEG-1 Layer 3)
+    for packet in packets[:min(10, len(packets))]:  # Check first 10 packets
+        data_portion = packet[28:-4]  # Skip header and checksum
+        
+        # Look for MP3 frame sync patterns
+        sync_positions = [m.start() for m in re.finditer(r'fffb', data_portion)]
+        if sync_positions:
+            mp3_frame_sync_count += len(sync_positions)
+            
+            # Try to extract bitrate and sample rate from first valid header
+            for pos in sync_positions:
+                if pos + 4 <= len(data_portion):
+                    try:
+                        header_bytes = bytes.fromhex(data_portion[pos:pos+8])
+                        # Extract bits 16-19 for bitrate index (0-based)
+                        bitrate_index = (header_bytes[2] >> 4) & 0x0F
+                        # Extract bits 20-21 for sample rate index
+                        sample_rate_index = (header_bytes[2] >> 2) & 0x03
+                        
+                        # MPEG-1 Layer 3 bitrate table (kbps): 0 is free format
+                        bitrates = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 192, 224, 256, 320]
+                        # MPEG-1 sample rates: 44100, 48000, 32000 Hz
+                        sample_rates = [44100, 48000, 32000, 0]  # 0 is reserved
+                        
+                        if bitrate_index > 0 and sample_rate_index < 3:  # Valid indices
+                            potential_bitrate = bitrates[bitrate_index]
+                            potential_sample_rate = sample_rates[sample_rate_index]
+                            break
+                    except:
+                        pass  # Skip if unable to parse header
+    
+    # Evaluate if this is likely MP3 based on frame sync patterns
+    mp3_likelihood = "High" if mp3_frame_sync_count > 5 else "Medium" if mp3_frame_sync_count > 0 else "Low"
+    
+    # Check for stream characteristics that might indicate codec/bitrate
+    avg_packet_size = sum(len(p) for p in packets) / (2 * len(packets)) if packets else 0
+    
+    if potential_bitrate:
+        codec_guess = f"LAME MP3 ({potential_bitrate}kbps)"
+    elif 1000 <= avg_packet_size <= 1500:
+        codec_guess = "LAME MP3 (48-64kbps)"
+    elif avg_packet_size > 1500:
+        codec_guess = "LAME MP3 (96-128kbps or higher)"
+    else:
+        codec_guess = "LAME MP3 (low bitrate)"
+    
+    return {
+        "likely_codec": codec,
+        "quality_setting": quality,
+        "most_common_header": most_common_header,
+        "codec_guess_from_size": codec_guess,
+        "average_packet_size_bytes": avg_packet_size,
+        "mp3_frame_sync_found": mp3_frame_sync_count > 0,
+        "mp3_likelihood": mp3_likelihood,
+        "detected_bitrate_kbps": potential_bitrate,
+        "detected_sample_rate_hz": potential_sample_rate
+    }
+
+def detect_repetition_pattern(packets):
+    """Analyze if packets are sent in repeating patterns (beyond simple duplication)."""
+    if len(packets) < 4:
+        return {"pattern": "Not enough packets to detect pattern"}
+    
+    # Check if every second packet is a repeat
+    alternate_duplicates = all(packets[i] == packets[i+2] for i in range(0, len(packets)-2, 2))
+    
+    # Check for more complex patterns
+    repeats_every_n = None
+    for n in range(2, min(10, len(packets) // 2)):
+        if all(packets[i] == packets[i+n] for i in range(len(packets)-n)):
+            repeats_every_n = n
+            break
+    
+    return {
+        "alternating_duplicates": alternate_duplicates,
+        "repeats_every_n": repeats_every_n
+    }
+
+def extract_timestamps(packets):
+    """Try to extract timestamp information from packets."""
+    timestamps = []
+    for i, packet in enumerate(packets):
+        # This would need to be adjusted based on actual packet structure
+        # Assuming timestamp might be in a specific position
+        potential_timestamp = packet[24:32]
+        try:
+            # Try to interpret as a 32-bit timestamp
+            ts_value = int(potential_timestamp, 16)
+            timestamps.append(ts_value)
+        except:
+            timestamps.append(None)
+    
+    return timestamps
+
+def calculate_total_duration(packets, sample_rate=44100):
+    """Estimate total audio duration based on packet analysis."""
+    # This is a rough estimation and would need adjustment based on the actual codec
+    if not packets:
+        return 0
+    
+    # For MP3, we'll use a different approach since we now know it's LAME MP3
+    # Assuming each packet contains a fixed number of samples
+    samples_per_frame = 1152  # Standard for MP3
+    
+    # Count potential MP3 frames in the data
+    frame_count = 0
+    for packet in packets:
+        data_portion = packet[28:-4]  # Skip header and checksum
+        # Look for MP3 frame sync patterns (0xFF 0xFB for MPEG-1 Layer 3)
+        sync_positions = [m.start() for m in re.finditer(r'fffb', data_portion)]
+        frame_count += len(sync_positions)
+    
+    # If we can't detect frames, fallback to packet-based estimation
+    if frame_count == 0:
+        # Total unique packets as a conservative estimate
+        unique_packets = len(set(packets))
+        # Estimate one frame per packet (conservative)
+        frame_count = unique_packets
+    
+    # Estimate duration
+    total_samples = frame_count * samples_per_frame
+    duration_seconds = total_samples / sample_rate
+    
+    return duration_seconds
+
+def analyze_audio_stream(file_path):
+    """Complete analysis of an audio stream file."""
+    hex_stream = parse_hex_stream(file_path)
+    if not hex_stream:
+        return {"error": "Failed to parse hex stream"}
+    
+    packets = identify_packets(hex_stream)
+    if not packets:
+        return {"error": "No valid packets identified"}
+    
+    packet_analyses = [analyze_packet_structure(p) for p in packets]
+    packet_lengths = [p["total_bytes"] for p in packet_analyses]
+    
+    # Group by packet lengths to detect patterns
+    length_count = Counter(packet_lengths)
+    most_common_lengths = length_count.most_common(3)
+    
+    duplicates = detect_duplicates(packets)
+    codec_info = guess_codec(packets, file_path)
+    repetition = detect_repetition_pattern(packets)
+    timestamps = extract_timestamps(packets)
+    
+    # Use detected sample rate if available, otherwise default to 44100
+    sample_rate = codec_info.get("detected_sample_rate_hz", 44100)
+    duration = calculate_total_duration(packets, sample_rate)
+    
+    # Analyze duplicated packets pattern
+    pairs = []
+    for i in range(0, len(packets)-1, 2):
+        if i+1 < len(packets):
+            are_identical = packets[i] == packets[i+1]
+            pairs.append(are_identical)
+    
+    pairs_percentage = sum(pairs)/len(pairs)*100 if pairs else 0
+    
+    # Extract LAME tag info if present for VBR and encoding quality
+    lame_version = None
+    lame_tag_found = False
+    vbr_method = None
+    
+    # Look for LAME tag in first few packets
+    for packet in packets[:min(5, len(packets))]:
+        data_portion = packet[28:-4]  # Skip header and checksum
+        # Look for "LAME" or "Lavf" strings in hex
+        if "4c414d45" in data_portion.lower():  # "LAME" in hex
+            lame_tag_found = True
+            # Additional LAME tag parsing could be added here
+        elif "4c617666" in data_portion.lower():  # "Lavf" in hex (LAVF container format)
+            lame_tag_found = True
+    
+    return {
+        "file_name": os.path.basename(file_path),
+        "total_packets": len(packets),
+        "unique_packets": len(set(packets)),
+        "packet_lengths": most_common_lengths,
+        "average_packet_length": sum(packet_lengths) / len(packet_lengths) if packet_lengths else 0,
+        "duplicates": duplicates,
+        "codec_info": codec_info,
+        "repetition_pattern": repetition,
+        "timestamp_pattern": "Available" if any(timestamps) else "Not found",
+        "estimated_duration_seconds": duration,
+        "paired_packet_pattern": f"{pairs_percentage:.1f}% of packets appear in identical pairs",
+        "lame_tag_found": lame_tag_found
+    }
+
+def main():
+    if len(sys.argv) < 2:
+        print("Usage: python audio_analyzer.py <audio_file.txt> [audio_file2.txt] ...")
+        return
+    
+    for file_path in sys.argv[1:]:
+        print(f"\nAnalyzing: {file_path}")
+        print("-" * 50)
+        
+        analysis = analyze_audio_stream(file_path)
+        
+        if "error" in analysis:
+            print(f"Error: {analysis['error']}")
+            continue
+        
+        print(f"File: {analysis['file_name']}")
+        print(f"Total packets: {analysis['total_packets']}")
+        print(f"Unique packets: {analysis['unique_packets']}")
+        print(f"Most common packet lengths (bytes): {analysis['packet_lengths']}")
+        print(f"Average packet length: {analysis['average_packet_length']:.2f} bytes")
+        print(f"Duplicates: {analysis['duplicates']['duplicate_count']} ({analysis['duplicates']['duplicate_percentage']:.1f}%)")
+        print(f"Likely codec: {analysis['codec_info']['likely_codec']}")
+        print(f"Quality setting: {analysis['codec_info']['quality_setting']}")
+        print(f"Codec estimate: {analysis['codec_info']['codec_guess_from_size']}")
+        print(f"MP3 likelihood: {analysis['codec_info'].get('mp3_likelihood', 'Unknown')}")
+        
+        if analysis['codec_info'].get('detected_bitrate_kbps'):
+            print(f"Detected bitrate: {analysis['codec_info']['detected_bitrate_kbps']} kbps")
+        if analysis['codec_info'].get('detected_sample_rate_hz'):
+            print(f"Detected sample rate: {analysis['codec_info']['detected_sample_rate_hz']} Hz")
+            
+        print(f"LAME tag found: {'Yes' if analysis.get('lame_tag_found', False) else 'No'}")
+        print(f"Repetition pattern: {analysis['repetition_pattern']}")
+        print(f"Estimated duration: {analysis['estimated_duration_seconds']:.2f} seconds")
+        print(f"Packet pairing: {analysis['paired_packet_pattern']}")
+
+if __name__ == "__main__":
+    main()

+ 329 - 0
research/sample-data/streaming/bodet_psa_cli.py

@@ -0,0 +1,329 @@
+#!/usr/bin/env python3
+import socket
+import argparse
+import time
+import sys
+from typing import List, Dict, Tuple, Optional
+
+# Default configuration values
+DEFAULT_MULTICAST_ADDR = "239.192.0.1"
+DEFAULT_PORT = 1681
+DEFAULT_TTL = 2
+DEFAULT_VOLUME = 3
+DEFAULT_REPEATS = 0  # 0 means infinite
+
+# Command codes
+CMD_MELODY = "3001"
+CMD_ALARM = "5001" 
+CMD_STOP = "5002"
+
+# Header constants
+MEL_HEADER = "4d454c"  # "MEL" in hex
+START_MARKER = "0100"
+END_MARKER = "0100"
+
+# Maps for user-friendly selection
+MELODY_MAP = {
+    # Standard melodies
+    "Westminster": 1,
+    "BimBam": 2,
+    "Gong": 3,
+    "CanCan": 4,
+    "SingingBird": 5,
+    "Violin": 6,
+    "Trumpet": 7,
+    "Piano": 8,
+    "Telephone": 9,
+    "Circus": 10,
+    # Add more melodies as needed
+}
+
+# Common functions (borrowed from mp3_psa_streamer.py)
+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 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 send_multicast_packet(sock, packet, addr, port, send_twice=True):
+    """Send a packet to the multicast group, optionally twice."""
+    try:
+        sock.sendto(packet, (addr, port))
+        if send_twice:
+            time.sleep(0.01)  # Small delay between duplicates
+            sock.sendto(packet, (addr, port))
+        return True
+    except Exception as e:
+        print(f"Error sending packet: {e}")
+        return False
+
+# New functions for the CLI tool
+def encode_zones(zones: List[int]) -> str:
+    """
+    Encode zone numbers (1-100) into a 12-byte hex string (24 characters).
+    Each byte represents 8 zones, with MSB being the lowest zone number.
+    """
+    # Initialize 96 bits (12 bytes) of zeros
+    zone_bits = [0] * 96
+    
+    for zone in zones:
+        if 1 <= zone <= 96:  # Ensure zone is in valid range
+            # Calculate bit position (zero-indexed)
+            # Zone 1 = bit 0, Zone 2 = bit 1, etc.
+            bit_pos = zone - 1
+            
+            # Set the bit
+            zone_bits[bit_pos] = 1
+    
+    # Convert bits to bytes
+    zone_bytes = bytearray()
+    for i in range(0, 96, 8):
+        byte_val = 0
+        for j in range(8):
+            if i + j < 96:  # Avoid index out of range
+                byte_val |= (zone_bits[i + j] << (7 - j))
+        zone_bytes.append(byte_val)
+    
+    # Convert to hex string
+    return zone_bytes.hex()
+
+def create_command_packet(
+    cmd: str, 
+    zones: List[int], 
+    sequence: int = 0,
+    melody_id: Optional[int] = None,
+    volume: Optional[int] = None,
+    repeats: Optional[int] = None
+) -> bytes:
+    """Create a PSA command packet."""
+    # Convert sequence to bytes (2 bytes, little endian with FF padding)
+    seq_bytes = sequence.to_bytes(1, 'little') + b'\xff'
+    
+    # Encode zone info
+    zone_info = bytes.fromhex(encode_zones(zones))
+    
+    # Command-specific handling
+    if cmd == CMD_STOP:
+        # For stop command, use all-zones pattern regardless of input
+        zone_info = bytes.fromhex("FFFFFFFFFFFFFFFFFFFF")
+        metadata = bytes.fromhex("0F")
+        # No end marker for stop commands
+        command_data = bytes.fromhex(MEL_HEADER) + seq_bytes + bytes.fromhex(cmd) + zone_info + metadata
+    else:
+        # For melody and alarm commands
+        if melody_id is None or volume is None or repeats is None:
+            raise ValueError("Melody ID, volume, and repeats are required for melody/alarm commands")
+        
+        # Check ranges
+        if not 1 <= melody_id <= 20:
+            raise ValueError("Melody ID must be between 1 and 20")
+        if not 1 <= volume <= 8:
+            raise ValueError("Volume must be between 1 and 8")
+        if not 0 <= repeats <= 255:
+            raise ValueError("Repeats must be between 0 and 255")
+            
+        # Build metadata
+        fixed_field = "0001"  # Standard fixed field
+        metadata = bytes.fromhex(fixed_field) + \
+                   volume.to_bytes(1, 'big') + \
+                   repeats.to_bytes(1, 'big') + \
+                   b'\x01' + \
+                   melody_id.to_bytes(1, 'big') + \
+                   bytes.fromhex(END_MARKER)
+        
+        command_data = bytes.fromhex(MEL_HEADER) + \
+                       seq_bytes + \
+                       bytes.fromhex(cmd) + \
+                       zone_info + \
+                       metadata
+    
+    # Calculate payload length (excluding header and length field)
+    # We need to add this back into the actual packet
+    length = len(command_data) - 3  # subtract "MEL" header
+    length_bytes = length.to_bytes(2, 'big')
+    
+    # Re-build with correct length
+    packet = bytes.fromhex(MEL_HEADER) + length_bytes + \
+             bytes.fromhex(START_MARKER) + seq_bytes + \
+             bytes.fromhex(cmd) + zone_info + \
+             (metadata if cmd != CMD_STOP else bytes.fromhex("0F"))
+    
+    # Calculate and append checksum
+    checksum = compute_psa_checksum(packet)
+    return packet + checksum
+
+def list_melodies():
+    """Display available melodies."""
+    print("\nAvailable Melody Names:")
+    print("-" * 25)
+    for name, id in sorted(MELODY_MAP.items(), key=lambda x: x[1]):
+        print(f"{id:2d}: {name}")
+    print("\nNote: You can also enter melody ID numbers directly.")
+
+def parse_zones(zone_arg: str) -> List[int]:
+    """Parse zone argument into a list of zone numbers."""
+    zones = []
+    
+    # Handle empty case
+    if not zone_arg:
+        return zones
+        
+    # Split by comma or space
+    parts = zone_arg.replace(',', ' ').split()
+    
+    for part in parts:
+        # Handle ranges like 1-5
+        if '-' in part:
+            try:
+                start, end = map(int, part.split('-'))
+                if 1 <= start <= end <= 96:
+                    zones.extend(range(start, end + 1))
+                else:
+                    print(f"Warning: Invalid zone range {part} (must be 1-96)")
+            except ValueError:
+                print(f"Warning: Could not parse zone range {part}")
+        # Handle single numbers
+        else:
+            try:
+                zone = int(part)
+                if 1 <= zone <= 96:
+                    zones.append(zone)
+                else:
+                    print(f"Warning: Zone {zone} out of range (must be 1-96)")
+            except ValueError:
+                print(f"Warning: Could not parse zone {part}")
+    
+    # Remove duplicates and sort
+    return sorted(set(zones))
+
+def parse_melody(melody_arg: str) -> int:
+    """Parse melody argument into a melody ID."""
+    # First check if it's a direct integer
+    try:
+        melody_id = int(melody_arg)
+        if 1 <= melody_id <= 20:
+            return melody_id
+        else:
+            print(f"Warning: Melody ID {melody_id} out of range, using default (1)")
+            return 1
+    except ValueError:
+        # Try to match by name (case insensitive)
+        melody_name = melody_arg.strip().lower()
+        for name, id in MELODY_MAP.items():
+            if name.lower() == melody_name:
+                return id
+        
+        print(f"Warning: Unknown melody '{melody_arg}', using default (1)")
+        return 1
+
+def main():
+    # Set up the argument parser
+    parser = argparse.ArgumentParser(description="Send commands to Bodet Harmony PSA system")
+    
+    # Common arguments
+    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", "--zones", default="8",
+                        help="Zones to target (e.g., '1,2,3' or '1-5 8')")
+    parser.add_argument("-s", "--single", action="store_true",
+                        help="Send packet only once (default: send twice)")
+    parser.add_argument("-l", "--list-melodies", action="store_true",
+                        help="List available melody names")
+    
+    # Create subparsers for different commands
+    subparsers = parser.add_subparsers(dest="command", help="Command to send")
+    
+    # Melody command
+    melody_parser = subparsers.add_parser("melody", help="Play a melody")
+    melody_parser.add_argument("-m", "--melody", default="Westminster",
+                              help="Melody name or ID (1-20)")
+    melody_parser.add_argument("-v", "--volume", type=int, default=DEFAULT_VOLUME,
+                              help=f"Volume level (1-8, default: {DEFAULT_VOLUME})")
+    melody_parser.add_argument("-r", "--repeats", type=int, default=DEFAULT_REPEATS,
+                              help=f"Number of repeats (0=infinite, default: {DEFAULT_REPEATS})")
+    
+    # Alarm command
+    alarm_parser = subparsers.add_parser("alarm", help="Play an alarm")
+    alarm_parser.add_argument("-m", "--melody", default="Westminster",
+                              help="Alarm melody name or ID (1-20)")
+    alarm_parser.add_argument("-v", "--volume", type=int, default=DEFAULT_VOLUME,
+                              help=f"Volume level (1-8, default: {DEFAULT_VOLUME})")
+    alarm_parser.add_argument("-r", "--repeats", type=int, default=DEFAULT_REPEATS,
+                              help=f"Number of repeats (0=infinite, default: {DEFAULT_REPEATS})")
+    
+    # Stop command
+    subparsers.add_parser("stop", help="Stop all audio")
+    
+    # Parse arguments
+    args = parser.parse_args()
+    
+    # Just list melodies if requested
+    if args.list_melodies:
+        list_melodies()
+        return
+    
+    # Ensure a command was specified
+    if not args.command:
+        parser.print_help()
+        return
+    
+    # Set up the socket
+    sock = setup_multicast_socket()
+    
+    try:
+        # Parse zones
+        zones = parse_zones(args.zones)
+        if not zones and args.command != "stop":
+            print("No valid zones specified, using zone 8")
+            zones = [8]
+            
+        # Command-specific processing
+        if args.command == "melody":
+            melody_id = parse_melody(args.melody)
+            cmd_code = CMD_MELODY
+            packet = create_command_packet(
+                cmd_code, zones, 
+                melody_id=melody_id, 
+                volume=args.volume, 
+                repeats=args.repeats
+            )
+            print(f"Sending melody {melody_id} to zones {zones}, volume={args.volume}, repeats={args.repeats}")
+            
+        elif args.command == "alarm":
+            melody_id = parse_melody(args.melody)
+            cmd_code = CMD_ALARM
+            packet = create_command_packet(
+                cmd_code, zones, 
+                melody_id=melody_id, 
+                volume=args.volume, 
+                repeats=args.repeats
+            )
+            print(f"Sending alarm {melody_id} to zones {zones}, volume={args.volume}, repeats={args.repeats}")
+            
+        elif args.command == "stop":
+            cmd_code = CMD_STOP
+            packet = create_command_packet(cmd_code, zones)
+            print("Sending stop command to all zones")
+        
+        # Send the packet
+        success = send_multicast_packet(sock, packet, args.addr, args.port, not args.single)
+        if success:
+            print("Command sent successfully!")
+        else:
+            print("Failed to send command.")
+            
+    except Exception as e:
+        print(f"Error: {e}")
+        return 1
+
+if __name__ == "__main__":
+    sys.exit(main() or 0)

binární
research/sample-data/streaming/rat.pcapng


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
research/sample-data/wtf.txt


Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů