mp3_psa_streamer.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. #!/usr/bin/env python3
  2. import os
  3. import sys
  4. import time
  5. import socket
  6. import struct
  7. import argparse
  8. import random
  9. import tempfile
  10. import subprocess
  11. from tqdm import tqdm # For progress bar
  12. # Default configuration values
  13. DEFAULT_MULTICAST_ADDR = "239.192.55.1"
  14. DEFAULT_PORT = 1681
  15. DEFAULT_ZONE_INFO = "100000000000000000000000" # Zone 1
  16. DEFAULT_SEND_DUPLICATES = True
  17. DEFAULT_CHUNK_SIZE = 900
  18. DEFAULT_TTL = 2
  19. DEFAULT_HEADER = "4d454c" # MEL header
  20. DEFAULT_COMMAND = "070301" # Stream command
  21. # Audio quality presets
  22. HIGH_QUALITY = {
  23. "bitrate": "64k",
  24. "sample_rate": 48000,
  25. "channels": 1, # mono
  26. "sample_format": "s32", # 16-bit samples (default for MP3)
  27. "description": "Ass but no Durchfall"
  28. # Note: Original software appears to use LAME 3.99.5
  29. }
  30. LOW_QUALITY = {
  31. "bitrate": "64k",
  32. "sample_rate": 32000,
  33. "channels": 1, # mono
  34. "sample_format": "s16", # 16-bit samples (default for MP3)
  35. "description": "Ass with durchfalls"
  36. }
  37. def compute_psa_checksum(data: bytes) -> bytes:
  38. """Compute PSA checksum for packet data."""
  39. var_e = 0x0000 # Starting seed value
  40. for i in range(len(data)):
  41. var_e ^= (data[i] + i) & 0xFFFF
  42. return var_e.to_bytes(2, 'big') # 2-byte checksum, big-endian
  43. def create_mp3_packet(sequence_num, zone_info, stream_id, mp3_chunk):
  44. """Create a PSA packet containing MP3 data."""
  45. # Part 1: Header (static "MEL")
  46. header = bytes.fromhex("4d454c")
  47. # Prepare all the components that will go into the payload
  48. # Start marker (0100)
  49. start_marker = bytes.fromhex("0100")
  50. # Sequence number (2 bytes, Little Endian with FF padding)
  51. seq = sequence_num.to_bytes(1, 'little') + b'\xff'
  52. # Command (static 070301)
  53. command = bytes.fromhex(DEFAULT_COMMAND)
  54. # Zone info
  55. zones = bytes.fromhex(zone_info)
  56. # Stream ID (4 bytes)
  57. stream_id_bytes = stream_id.to_bytes(4, 'little')
  58. # Constant data (appears in all original packets)
  59. constant_data = bytes.fromhex("05080503e8")
  60. # Build the payload (everything that comes after start_marker)
  61. payload = seq + command + zones + stream_id_bytes + constant_data + mp3_chunk
  62. # Calculate the length for the header
  63. # Length is everything AFTER the length field: start_marker + payload
  64. # But NOT including checksum (which is calculated last)
  65. length_value = len(start_marker + payload) + 7
  66. # Insert the length as a 2-byte value (big endian)
  67. length_bytes = length_value.to_bytes(2, 'big')
  68. # Assemble the packet without checksum
  69. packet_data = header + length_bytes + start_marker + payload
  70. # Calculate and append checksum
  71. checksum = compute_psa_checksum(packet_data)
  72. # Debug the packet length
  73. final_packet = packet_data + checksum
  74. print(f"Packet length: {len(final_packet)} bytes, Length field: {length_value} (0x{length_value:04x})")
  75. return final_packet
  76. def send_multicast_packet(sock, packet, multicast_addr, port, send_duplicates=True):
  77. """Send a packet to the multicast address."""
  78. try:
  79. # Send the packet
  80. sock.sendto(packet, (multicast_addr, port))
  81. # Send a duplicate (common in multicast to improve reliability)
  82. if send_duplicates:
  83. sock.sendto(packet, (multicast_addr, port))
  84. return True
  85. except Exception as e:
  86. print(f"Error sending packet: {e}")
  87. return False
  88. def setup_multicast_socket(ttl=DEFAULT_TTL):
  89. """Set up a socket for sending multicast UDP."""
  90. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  91. sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl)
  92. return sock
  93. def check_dependencies():
  94. """Check if required dependencies are installed."""
  95. try:
  96. subprocess.run(['ffmpeg', '-version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  97. except FileNotFoundError:
  98. print("Error: ffmpeg is required but not found.")
  99. print("Please install ffmpeg and make sure it's available in your PATH.")
  100. sys.exit(1)
  101. def get_audio_info(mp3_file):
  102. """Get audio file information using ffprobe."""
  103. try:
  104. # Get audio stream information
  105. cmd = [
  106. 'ffprobe', '-v', 'quiet', '-print_format', 'json',
  107. '-show_streams', '-select_streams', 'a:0', mp3_file
  108. ]
  109. result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
  110. if result.returncode != 0:
  111. print(f"Error analyzing audio file: {result.stderr}")
  112. return None
  113. import json
  114. info = json.loads(result.stdout)
  115. if 'streams' not in info or len(info['streams']) == 0:
  116. print("No audio stream found in the file.")
  117. return None
  118. stream = info['streams'][0]
  119. # Extract relevant information
  120. bit_rate = int(stream.get('bit_rate', '0')) // 1000 if 'bit_rate' in stream else None
  121. sample_rate = int(stream.get('sample_rate', '0'))
  122. channels = int(stream.get('channels', '0'))
  123. codec_name = stream.get('codec_name', 'unknown')
  124. return {
  125. 'bit_rate': bit_rate,
  126. 'sample_rate': sample_rate,
  127. 'channels': channels,
  128. 'codec_name': codec_name
  129. }
  130. except Exception as e:
  131. print(f"Error getting audio information: {e}")
  132. return None
  133. def transcode_mp3(input_file, quality_preset, include_metadata=False):
  134. """Transcode MP3 file to match required specifications."""
  135. print(f"Transcoding audio to {quality_preset['description']}...")
  136. # Create temporary output file
  137. fd, temp_output = tempfile.mkstemp(suffix='.mp3')
  138. os.close(fd)
  139. try:
  140. # Base command
  141. cmd = [
  142. 'ffmpeg',
  143. '-y', # Overwrite output file
  144. '-i', input_file,
  145. '-codec:a', 'libmp3lame', # Force LAME encoder
  146. '-ac', str(quality_preset['channels']),
  147. '-ar', str(quality_preset['sample_rate']),
  148. '-b:a', quality_preset['bitrate'],
  149. '-sample_fmt', quality_preset['sample_format'], # Use configurable sample format
  150. ]
  151. # Add options to minimize metadata if requested
  152. if not include_metadata:
  153. cmd.extend(['-metadata', 'title=', '-metadata', 'artist=',
  154. '-metadata', 'album=', '-metadata', 'comment=',
  155. '-map_metadata', '-1']) # Strip all metadata
  156. cmd.append(temp_output)
  157. result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
  158. if result.returncode != 0:
  159. print(f"Error transcoding audio: {result.stderr}")
  160. os.remove(temp_output)
  161. return None
  162. return temp_output
  163. except Exception as e:
  164. print(f"Error during transcoding: {e}")
  165. if os.path.exists(temp_output):
  166. os.remove(temp_output)
  167. return None
  168. def prepare_mp3_file(mp3_file, quality, include_metadata=False):
  169. """Ensure the MP3 file meets the required specifications."""
  170. # Check if quality is valid
  171. quality_preset = HIGH_QUALITY if quality == "high" else LOW_QUALITY
  172. # Get audio file information
  173. info = get_audio_info(mp3_file)
  174. if not info:
  175. return None
  176. print(f"Audio file details: {info['codec_name']}, {info['bit_rate']}kbps, "
  177. f"{info['sample_rate']}Hz, {info['channels']} channel(s)")
  178. # Check if transcoding is needed
  179. needs_transcode = False
  180. if info['codec_name'].lower() != 'mp3':
  181. print("Non-MP3 format detected. Transcoding required.")
  182. needs_transcode = True
  183. elif info['bit_rate'] is None or abs(info['bit_rate'] - int(quality_preset['bitrate'][:-1])) > 5:
  184. print(f"Bitrate mismatch: {info['bit_rate']}kbps vs {quality_preset['bitrate']}. Transcoding required.")
  185. needs_transcode = True
  186. elif info['sample_rate'] != quality_preset['sample_rate']:
  187. print(f"Sample rate mismatch: {info['sample_rate']}Hz vs {quality_preset['sample_rate']}Hz. Transcoding required.")
  188. needs_transcode = True
  189. elif info['channels'] != quality_preset['channels']:
  190. print(f"Channel count mismatch: {info['channels']} vs {quality_preset['channels']} (mono). Transcoding required.")
  191. needs_transcode = True
  192. # If transcoding is needed, do it
  193. if needs_transcode:
  194. return transcode_mp3(mp3_file, quality_preset, include_metadata)
  195. else:
  196. print("Audio file already meets required specifications.")
  197. return mp3_file
  198. def calculate_delay_ms(chunk_size, quality):
  199. """Calculate the appropriate delay between chunks for real-time streaming."""
  200. # Calculate bytes_per_second dynamically from the bitrate
  201. if quality == "high":
  202. # 128kbps = 16,000 bytes/second
  203. bytes_per_second = 16000
  204. else:
  205. # 40kbps = 5,000 bytes/second
  206. bytes_per_second = 5000
  207. # Calculate how long this chunk would play for in real-time
  208. delay_ms = (chunk_size / bytes_per_second) * 1000
  209. return delay_ms
  210. def stream_mp3_to_psa(mp3_file, multicast_addr, port, zone_info, send_duplicates,
  211. chunk_size, quality, include_metadata=False):
  212. """Stream an MP3 file to PSA system."""
  213. try:
  214. # Check dependencies
  215. check_dependencies()
  216. # Prepare MP3 file (transcode if needed)
  217. prepared_file = prepare_mp3_file(mp3_file, quality, include_metadata)
  218. if not prepared_file:
  219. print("Failed to prepare MP3 file.")
  220. return
  221. using_temp_file = prepared_file != mp3_file
  222. try:
  223. # Generate random stream ID for this transmission
  224. stream_id = random.randint(0, 0xFFFFFFFF) # Changed to 32-bit to accommodate 4 bytes
  225. # Open the MP3 file
  226. with open(prepared_file, 'rb') as f:
  227. mp3_data = f.read()
  228. # Calculate number of chunks
  229. chunks = [mp3_data[i:i+chunk_size] for i in range(0, len(mp3_data), chunk_size)]
  230. total_chunks = len(chunks)
  231. # Calculate the appropriate delay for real-time streaming
  232. delay_ms = calculate_delay_ms(chunk_size, quality)
  233. print(f"Streaming {os.path.basename(mp3_file)} ({len(mp3_data)/1024:.2f} KB)")
  234. print(f"Split into {total_chunks} packets of {chunk_size} bytes each")
  235. print(f"Sending to multicast {multicast_addr}:{port}")
  236. print(f"Stream ID: {stream_id:08x}, Zone info: {zone_info}")
  237. print(f"Duplicate packets: {'Yes' if send_duplicates else 'No'}")
  238. print(f"Quality preset: {HIGH_QUALITY['description'] if quality == 'high' else LOW_QUALITY['description']}")
  239. print(f"Including metadata: {'Yes' if include_metadata else 'No'}")
  240. print(f"Real-time streaming delay: {delay_ms:.2f}ms per packet")
  241. # Setup multicast socket
  242. sock = setup_multicast_socket()
  243. # Process and send each chunk
  244. sequence_num = 0
  245. with tqdm(total=total_chunks, desc="Streaming progress") as pbar:
  246. try:
  247. for i, chunk in enumerate(chunks):
  248. # Create packet with current sequence number
  249. packet = create_mp3_packet(sequence_num, zone_info, stream_id, chunk)
  250. # Send the packet
  251. success = send_multicast_packet(sock, packet, multicast_addr, port, send_duplicates)
  252. # Increment sequence num
  253. sequence_num = (sequence_num + 1) % 256
  254. # Update progress bar
  255. pbar.update(1)
  256. # Delay to match real-time playback
  257. if i < total_chunks - 1: # No need to wait after the last chunk
  258. time.sleep(delay_ms / 1000.0)
  259. except Exception as e:
  260. print(f"Error during streaming: {e}")
  261. print("\nStreaming completed successfully!")
  262. finally:
  263. # Clean up temporary file if created
  264. if using_temp_file and os.path.exists(prepared_file):
  265. os.remove(prepared_file)
  266. except FileNotFoundError:
  267. print(f"Error: MP3 file {mp3_file} not found")
  268. sys.exit(1)
  269. except Exception as e:
  270. print(f"Error during streaming: {e}")
  271. sys.exit(1)
  272. def main():
  273. # Set up command line arguments
  274. parser = argparse.ArgumentParser(description="Stream MP3 to Bodet PSA System")
  275. parser.add_argument("mp3_file", help="Path to MP3 file to stream")
  276. parser.add_argument("-a", "--addr", default=DEFAULT_MULTICAST_ADDR,
  277. help=f"Multicast address (default: {DEFAULT_MULTICAST_ADDR})")
  278. parser.add_argument("-p", "--port", type=int, default=DEFAULT_PORT,
  279. help=f"UDP port (default: {DEFAULT_PORT})")
  280. parser.add_argument("-z", "--zone", default=DEFAULT_ZONE_INFO,
  281. help=f"Hex zone info (default: Zone 1)")
  282. parser.add_argument("-n", "--no-duplicates", action="store_true",
  283. help="Don't send duplicate packets (default: send duplicates)")
  284. parser.add_argument("-c", "--chunk-size", type=int, default=DEFAULT_CHUNK_SIZE,
  285. help=f"MP3 chunk size in bytes (default: {DEFAULT_CHUNK_SIZE})")
  286. parser.add_argument("-q", "--quality", choices=["high", "low"], default="high",
  287. help="Audio quality preset: high (128kbps, 48kHz) or low (40kbps, 32kHz) (default: high)")
  288. parser.add_argument("-m", "--include-metadata", action="store_true",
  289. help="Include metadata in MP3 stream (default: no metadata)")
  290. parser.add_argument("-s", "--sample-format", choices=["s16", "s24", "s32"], default=None,
  291. help="Sample format to use for transcoding (default: s16)")
  292. args = parser.parse_args()
  293. # Stream the MP3 file
  294. stream_mp3_to_psa(
  295. args.mp3_file,
  296. args.addr,
  297. args.port,
  298. args.zone,
  299. not args.no_duplicates,
  300. args.chunk_size,
  301. args.quality,
  302. args.include_metadata
  303. )
  304. if __name__ == "__main__":
  305. main()