|
@@ -8,46 +8,36 @@ import argparse
|
|
|
import random
|
|
import random
|
|
|
import tempfile
|
|
import tempfile
|
|
|
import subprocess
|
|
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 configuration values
|
|
|
-DEFAULT_MULTICAST_ADDR = "172.16.20.109"
|
|
|
|
|
|
|
+DEFAULT_MULTICAST_ADDR = "239.192.55.1"
|
|
|
DEFAULT_PORT = 1681
|
|
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 = {
|
|
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 = {
|
|
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:
|
|
def compute_psa_checksum(data: bytes) -> bytes:
|
|
|
"""Compute PSA checksum for packet data."""
|
|
"""Compute PSA checksum for packet data."""
|
|
|
var_e = 0x0000 # Starting seed value
|
|
var_e = 0x0000 # Starting seed value
|
|
@@ -55,7 +45,7 @@ def compute_psa_checksum(data: bytes) -> bytes:
|
|
|
var_e ^= (data[i] + i) & 0xFFFF
|
|
var_e ^= (data[i] + i) & 0xFFFF
|
|
|
return var_e.to_bytes(2, 'big') # 2-byte checksum, big-endian
|
|
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."""
|
|
"""Create a PSA packet containing MP3 data."""
|
|
|
# Part 1: Header (static "MEL")
|
|
# Part 1: Header (static "MEL")
|
|
|
header = bytes.fromhex("4d454c")
|
|
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 (0100)
|
|
|
start_marker = bytes.fromhex("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'
|
|
seq = sequence_num.to_bytes(1, 'little') + b'\xff'
|
|
|
|
|
|
|
|
# Command (static 070301)
|
|
# Command (static 070301)
|
|
@@ -73,19 +63,19 @@ def create_mp3_packet(sequence_num, zone_info, stream_id, mp3_chunk, show_packet
|
|
|
# Zone info
|
|
# Zone info
|
|
|
zones = bytes.fromhex(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 (appears in all original packets)
|
|
|
constant_data = bytes.fromhex("05080503e8")
|
|
constant_data = bytes.fromhex("05080503e8")
|
|
|
|
|
|
|
|
-
|
|
|
|
|
# Build the payload (everything that comes after start_marker)
|
|
# Build the payload (everything that comes after start_marker)
|
|
|
payload = seq + command + zones + stream_id_bytes + constant_data + mp3_chunk
|
|
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)
|
|
# Insert the length as a 2-byte value (big endian)
|
|
|
length_bytes = length_value.to_bytes(2, 'big')
|
|
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
|
|
# Calculate and append checksum
|
|
|
checksum = compute_psa_checksum(packet_data)
|
|
checksum = compute_psa_checksum(packet_data)
|
|
|
|
|
|
|
|
- # Create final packet
|
|
|
|
|
|
|
+ # Debug the packet length
|
|
|
final_packet = packet_data + checksum
|
|
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
|
|
return final_packet
|
|
|
|
|
|
|
@@ -110,7 +98,7 @@ def send_multicast_packet(sock, packet, multicast_addr, port, send_duplicates=Tr
|
|
|
# Send the packet
|
|
# Send the packet
|
|
|
sock.sendto(packet, (multicast_addr, port))
|
|
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:
|
|
if send_duplicates:
|
|
|
sock.sendto(packet, (multicast_addr, port))
|
|
sock.sendto(packet, (multicast_addr, port))
|
|
|
|
|
|
|
@@ -128,86 +116,54 @@ def setup_multicast_socket(ttl=DEFAULT_TTL):
|
|
|
def check_dependencies():
|
|
def check_dependencies():
|
|
|
"""Check if required dependencies are installed."""
|
|
"""Check if required dependencies are installed."""
|
|
|
try:
|
|
try:
|
|
|
- subprocess.run(['lame', '--version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
|
|
|
|
|
+ subprocess.run(['ffmpeg', '-version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
|
except FileNotFoundError:
|
|
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)
|
|
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):
|
|
def get_audio_info(mp3_file):
|
|
|
- """Get audio file information using mutagen if available."""
|
|
|
|
|
|
|
+ """Get audio file information using ffprobe."""
|
|
|
try:
|
|
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:
|
|
except Exception as e:
|
|
|
print(f"Error getting audio information: {e}")
|
|
print(f"Error getting audio information: {e}")
|
|
|
return None
|
|
return None
|
|
|
|
|
|
|
|
def transcode_mp3(input_file, quality_preset, include_metadata=False):
|
|
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']}...")
|
|
print(f"Transcoding audio to {quality_preset['description']}...")
|
|
|
|
|
|
|
|
# Create temporary output file
|
|
# Create temporary output file
|
|
@@ -215,28 +171,25 @@ def transcode_mp3(input_file, quality_preset, include_metadata=False):
|
|
|
os.close(fd)
|
|
os.close(fd)
|
|
|
|
|
|
|
|
try:
|
|
try:
|
|
|
- # Build LAME command
|
|
|
|
|
|
|
+ # Base command
|
|
|
cmd = [
|
|
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
|
|
# Add options to minimize metadata if requested
|
|
|
if not include_metadata:
|
|
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)
|
|
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)
|
|
os.remove(temp_output)
|
|
|
return None
|
|
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."""
|
|
"""Ensure the MP3 file meets the required specifications."""
|
|
|
# Check if quality is valid
|
|
# Check if quality is valid
|
|
|
quality_preset = HIGH_QUALITY if quality == "high" else LOW_QUALITY
|
|
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
|
|
# Get audio file information
|
|
|
info = get_audio_info(mp3_file)
|
|
info = get_audio_info(mp3_file)
|
|
|
if not info:
|
|
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.")
|
|
print("Audio file already meets required specifications.")
|
|
|
return mp3_file
|
|
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:
|
|
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
|
|
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,
|
|
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."""
|
|
"""Stream an MP3 file to PSA system."""
|
|
|
try:
|
|
try:
|
|
|
# Check dependencies
|
|
# Check dependencies
|
|
|
check_dependencies()
|
|
check_dependencies()
|
|
|
|
|
|
|
|
# Prepare MP3 file (transcode if needed)
|
|
# 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:
|
|
if not prepared_file:
|
|
|
print("Failed to prepare MP3 file.")
|
|
print("Failed to prepare MP3 file.")
|
|
|
return
|
|
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
|
|
using_temp_file = prepared_file != mp3_file
|
|
|
|
|
|
|
|
try:
|
|
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
|
|
# Open the MP3 file
|
|
|
with open(prepared_file, 'rb') as f:
|
|
with open(prepared_file, 'rb') as f:
|
|
|
mp3_data = f.read()
|
|
mp3_data = f.read()
|
|
|
|
|
|
|
|
# Calculate number of chunks
|
|
# 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)
|
|
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"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"Split into {total_chunks} packets of {chunk_size} bytes each")
|
|
|
print(f"Sending to multicast {multicast_addr}:{port}")
|
|
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"Duplicate packets: {'Yes' if send_duplicates else 'No'}")
|
|
|
print(f"Quality preset: {HIGH_QUALITY['description'] if quality == 'high' else LOW_QUALITY['description']}")
|
|
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"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
|
|
# Setup multicast socket
|
|
|
sock = 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
|
|
# Process and send each chunk
|
|
|
sequence_num = 0
|
|
sequence_num = 0
|
|
|
with tqdm(total=total_chunks, desc="Streaming progress") as pbar:
|
|
with tqdm(total=total_chunks, desc="Streaming progress") as pbar:
|
|
|
try:
|
|
try:
|
|
|
for i, chunk in enumerate(chunks):
|
|
for i, chunk in enumerate(chunks):
|
|
|
# Create packet with current sequence number
|
|
# 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
|
|
# Send the packet
|
|
|
success = send_multicast_packet(sock, packet, multicast_addr, port, send_duplicates)
|
|
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
|
|
# Update progress bar
|
|
|
pbar.update(1)
|
|
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
|
|
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:
|
|
except Exception as e:
|
|
|
print(f"Error during streaming: {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}")
|
|
print(f"Error during streaming: {e}")
|
|
|
sys.exit(1)
|
|
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():
|
|
def main():
|
|
|
# Set up command line arguments
|
|
# Set up command line arguments
|
|
|
parser = argparse.ArgumentParser(description="Stream MP3 to Bodet PSA System")
|
|
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)")
|
|
help="Include metadata in MP3 stream (default: no metadata)")
|
|
|
parser.add_argument("-s", "--sample-format", choices=["s16", "s24", "s32"], default=None,
|
|
parser.add_argument("-s", "--sample-format", choices=["s16", "s24", "s32"], default=None,
|
|
|
help="Sample format to use for transcoding (default: s16)")
|
|
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()
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
@@ -501,13 +367,8 @@ def main():
|
|
|
not args.no_duplicates,
|
|
not args.no_duplicates,
|
|
|
args.chunk_size,
|
|
args.chunk_size,
|
|
|
args.quality,
|
|
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__":
|
|
if __name__ == "__main__":
|
|
|
main()
|
|
main()
|