mp3_psa_streamer.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  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 # progress bar sexxy
  12. # need that for lame transcoding
  13. try:
  14. from mutagen.mp3 import MP3 # type: ignore
  15. MUTAGEN_AVAILABLE = True
  16. except ImportError:
  17. MUTAGEN_AVAILABLE = False
  18. # Default configuration values
  19. DEFAULT_MULTICAST_ADDR = "172.16.20.109"
  20. DEFAULT_PORT = 1681
  21. DEFAULT_ZONE_INFO = "01 1000 0000 0000 0000 0000 0000" # Zone 1
  22. 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)
  23. DEFAULT_CHUNK_SIZE = 1000 # Need to use 1000 due to not understanding protocol fully yet
  24. DEFAULT_TTL = 3 # We want it going to Multicast and then to the client, nomore otherwise bodeter speaker gets confused
  25. DEFAULT_HEADER = "4d454c" # MEL goofy ah header
  26. DEFAULT_COMMAND = "0703" # Probably the stream command
  27. DEFAULT_STREAM_ID = "110c" # Write none without qoutes to use random stream ID
  28. # Prem prem maximum it supports
  29. HIGH_QUALITY = {
  30. "bitrate": "256k", # 256k max
  31. "sample_rate": 48000, # 48000 max
  32. "channels": 1, # mono because uhm one speaker only
  33. "sample_format": "s32", # max s32
  34. "description": "Highest Quality (256kbps, 48kHz)"
  35. }
  36. # Bodeter Shitsy Sigma software defaults
  37. LOW_QUALITY = {
  38. "bitrate": "64k", # Bodet Defaults
  39. "sample_rate": 32000, # Bodet MP3 file default
  40. "channels": 1,
  41. "sample_format": "s16", # bodet MP3 file default
  42. "description": "Normal Quality (64kbps, 32kHz)"
  43. }
  44. 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 ...
  45. def compute_psa_checksum(data: bytes) -> bytes:
  46. """Compute PSA checksum for packet data."""
  47. var_e = 0x0000 # Starting seed value
  48. for i in range(len(data)):
  49. var_e ^= (data[i] + i) & 0xFFFF
  50. return var_e.to_bytes(2, 'big') # 2-byte checksum, big-endian
  51. def create_mp3_packet(sequence_num, zone_info, stream_id, mp3_chunk, show_packet_length=False):
  52. """Create a PSA packet containing MP3 data."""
  53. # Part 1: Header (static "MEL")
  54. header = bytes.fromhex("4d454c")
  55. # Prepare all the components that will go into the payload
  56. # Start marker (0100)
  57. start_marker = bytes.fromhex("0100")
  58. # Sequence number (1 byte, Little Endian with FF padding)
  59. seq = sequence_num.to_bytes(1, 'little') + b'\xff'
  60. # Command (static 070301)
  61. command = bytes.fromhex(DEFAULT_COMMAND)
  62. # Zone info
  63. zones = bytes.fromhex(zone_info)
  64. # Stream ID (2 bytes)
  65. stream_id_bytes = stream_id.to_bytes(2, 'little')
  66. # Constant data (appears in all original packets)
  67. constant_data = bytes.fromhex("05080503e8")
  68. # Build the payload (everything that comes after start_marker)
  69. payload = seq + command + zones + stream_id_bytes + constant_data + mp3_chunk
  70. # Calculate the length for the header - IMPORTANT: This matches what the PSA software does
  71. # Length is the total packet length MINUS the header (4d454c) and the length field itself (2 bytes)
  72. length_value = len(start_marker) + len(payload) + 7 # +2 for checksum
  73. # Insert the length as a 2-byte value (big endian)
  74. length_bytes = length_value.to_bytes(2, 'big')
  75. # Assemble the packet without checksum
  76. packet_data = header + length_bytes + start_marker + payload
  77. # Calculate and append checksum
  78. checksum = compute_psa_checksum(packet_data)
  79. # Create final packet
  80. final_packet = packet_data + checksum
  81. if show_packet_length:
  82. print(f"Packet length: {len(final_packet)} bytes, Length field: {length_value} (0x{length_value:04x})")
  83. return final_packet
  84. def send_multicast_packet(sock, packet, multicast_addr, port, send_duplicates=True):
  85. """Send a packet to the multicast address."""
  86. try:
  87. # Send the packet
  88. sock.sendto(packet, (multicast_addr, port))
  89. # Send a duplicate (common in multicast to improve "reliability")
  90. if send_duplicates:
  91. sock.sendto(packet, (multicast_addr, port))
  92. return True
  93. except Exception as e:
  94. print(f"Error sending packet: {e}")
  95. return False
  96. def setup_multicast_socket(ttl=DEFAULT_TTL):
  97. """Set up a socket for sending multicast UDP."""
  98. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  99. sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl)
  100. return sock
  101. def check_dependencies():
  102. """Check if required dependencies are installed."""
  103. try:
  104. subprocess.run(['lame', '--version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  105. except FileNotFoundError:
  106. print("Error: LAME encoder is required but not found.")
  107. print("Please install LAME and make sure it's available in your PATH.")
  108. sys.exit(1)
  109. if not MUTAGEN_AVAILABLE:
  110. print("Warning: mutagen library not found. Limited audio file analysis available.")
  111. print("Install with: pip install mutagen")
  112. def get_audio_info(mp3_file):
  113. """Get audio file information using mutagen if available."""
  114. try:
  115. if MUTAGEN_AVAILABLE:
  116. # Use mutagen to analyze the file
  117. audio = MP3(mp3_file)
  118. # Extract relevant information
  119. sample_rate = audio.info.sample_rate
  120. bit_rate = int(audio.info.bitrate / 1000) # Convert to kbps
  121. channels = audio.info.channels
  122. codec_name = "mp3" if audio.info.layer == 3 else f"mpeg-{audio.info.layer}"
  123. return {
  124. 'bit_rate': bit_rate,
  125. 'sample_rate': sample_rate,
  126. 'channels': channels,
  127. 'codec_name': codec_name
  128. }
  129. else:
  130. # Fallback method: use LAME to identify file info
  131. cmd = ['lame', '--decode', mp3_file, '-t', '--brief', '-']
  132. result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
  133. # LAME outputs limited info to stderr when using --brief
  134. info_text = result.stderr
  135. # Very basic parsing - this is not ideal but works as a fallback
  136. codec_name = "mp3" # Assume it's MP3
  137. sample_rate = 44100 # Default assumption
  138. channels = 2 # Default assumption
  139. bit_rate = 0 # Unknown
  140. for line in info_text.split('\n'):
  141. if "MPEG" in line and "Layer" in line:
  142. codec_name = "mp3"
  143. if "Hz" in line:
  144. parts = line.split()
  145. for part in parts:
  146. if "Hz" in part:
  147. try:
  148. sample_rate = int(part.replace("Hz", ""))
  149. except ValueError:
  150. pass
  151. if "stereo" in line.lower():
  152. channels = 2
  153. elif "mono" in line.lower():
  154. channels = 1
  155. if "kbps" in line:
  156. parts = line.split()
  157. for part in parts:
  158. if "kbps" in part:
  159. try:
  160. bit_rate = int(part.replace("kbps", ""))
  161. except ValueError:
  162. pass
  163. return {
  164. 'bit_rate': bit_rate,
  165. 'sample_rate': sample_rate,
  166. 'channels': channels,
  167. 'codec_name': codec_name
  168. }
  169. except Exception as e:
  170. print(f"Error getting audio information: {e}")
  171. return None
  172. def transcode_mp3(input_file, quality_preset, include_metadata=False):
  173. """Transcode MP3 file using LAME encoder."""
  174. print(f"Transcoding audio to {quality_preset['description']}...")
  175. # Create temporary output file
  176. fd, temp_output = tempfile.mkstemp(suffix='.mp3')
  177. os.close(fd)
  178. try:
  179. # Build LAME command
  180. cmd = [
  181. 'lame',
  182. '--quiet', # Less output
  183. '-m', 'm' if quality_preset['channels'] == 1 else 's', # m=mono, s=stereo
  184. '--resample', str(quality_preset['sample_rate'] // 1000), # Convert to kHz
  185. '-b', quality_preset['bitrate'].replace('k', ''), # Remove 'k' suffix
  186. '--cbr', # Use constant bitrate
  187. ]
  188. # Add options to minimize metadata if requested
  189. if not include_metadata:
  190. cmd.append('--noreplaygain')
  191. # Use more compatible tag options instead of --id3v2-none
  192. cmd.append('--tt')
  193. cmd.append('') # Empty title
  194. cmd.append('--tc')
  195. cmd.append('') # Empty comment
  196. cmd.append('--nohist')
  197. # Input and output files
  198. cmd.extend([input_file, temp_output])
  199. result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
  200. if result.returncode != 0:
  201. print(f"Error transcoding audio: {result.stderr}")
  202. os.remove(temp_output)
  203. return None
  204. return temp_output
  205. except Exception as e:
  206. print(f"Error during transcoding: {e}")
  207. if os.path.exists(temp_output):
  208. os.remove(temp_output)
  209. return None
  210. def prepare_mp3_file(mp3_file, quality, include_metadata=False, sample_format=None):
  211. """Ensure the MP3 file meets the required specifications."""
  212. # Check if quality is valid
  213. quality_preset = HIGH_QUALITY if quality == "high" else LOW_QUALITY
  214. # Override sample format if specified
  215. if sample_format:
  216. quality_preset = quality_preset.copy()
  217. quality_preset["sample_format"] = sample_format
  218. # Get audio file information
  219. info = get_audio_info(mp3_file)
  220. if not info:
  221. return None
  222. print(f"Audio file details: {info['codec_name']}, {info['bit_rate']}kbps, "
  223. f"{info['sample_rate']}Hz, {info['channels']} channel(s)")
  224. # Check if transcoding is needed
  225. needs_transcode = False
  226. if info['codec_name'].lower() != 'mp3':
  227. print("Non-MP3 format detected. Transcoding required.")
  228. needs_transcode = True
  229. elif info['bit_rate'] is None or abs(info['bit_rate'] - int(quality_preset['bitrate'][:-1])) > 5:
  230. print(f"Bitrate mismatch: {info['bit_rate']}kbps vs {quality_preset['bitrate']}. Transcoding required.")
  231. needs_transcode = True
  232. elif info['sample_rate'] != quality_preset['sample_rate']:
  233. print(f"Sample rate mismatch: {info['sample_rate']}Hz vs {quality_preset['sample_rate']}Hz. Transcoding required.")
  234. needs_transcode = True
  235. elif info['channels'] != quality_preset['channels']:
  236. print(f"Channel count mismatch: {info['channels']} vs {quality_preset['channels']} (mono). Transcoding required.")
  237. needs_transcode = True
  238. # If transcoding is needed, do it
  239. if needs_transcode:
  240. return transcode_mp3(mp3_file, quality_preset, include_metadata)
  241. else:
  242. print("Audio file already meets required specifications.")
  243. return mp3_file
  244. def calculate_delay_ms(chunk_size, bitrate, timing_factor=DEFAULT_TIMING_FACTOR):
  245. """
  246. Calculate the appropriate delay between chunks for real-time streaming.
  247. Args:
  248. chunk_size: Size of each audio chunk in bytes
  249. bitrate: Audio bitrate (string like '128k' or integer)
  250. timing_factor: Factor to adjust timing (lower = faster playback)
  251. Returns:
  252. Delay in milliseconds between packets
  253. """
  254. if isinstance(bitrate, str) and bitrate.endswith('k'):
  255. bitrate_kbps = int(bitrate[:-1])
  256. else:
  257. bitrate_kbps = int(bitrate)
  258. bytes_per_second = bitrate_kbps * 1000 // 8
  259. delay_ms = (chunk_size / bytes_per_second) * 1000 * timing_factor
  260. return delay_ms
  261. class StreamTimer:
  262. """Maintains proper timing for streaming audio packets."""
  263. def __init__(self, chunk_size, bitrate, timing_factor=DEFAULT_TIMING_FACTOR):
  264. """Initialize the stream timer with audio parameters."""
  265. self.chunk_size = chunk_size
  266. self.bitrate = bitrate
  267. self.timing_factor = timing_factor
  268. self.start_time = None
  269. self.packets_sent = 0
  270. self.delay_per_packet = calculate_delay_ms(chunk_size, bitrate, timing_factor) / 1000.0
  271. def start(self):
  272. """Start the stream timer."""
  273. self.start_time = time.time()
  274. self.packets_sent = 0
  275. def wait_for_next_packet(self):
  276. """Wait until it's time to send the next packet."""
  277. if self.start_time is None:
  278. self.start()
  279. return
  280. self.packets_sent += 1
  281. # Calculate when this packet should be sent
  282. target_time = self.start_time + (self.packets_sent * self.delay_per_packet)
  283. # Calculate how long to wait
  284. now = time.time()
  285. wait_time = target_time - now
  286. # Only wait if we're ahead of schedule
  287. if wait_time > 0:
  288. time.sleep(wait_time)
  289. def stream_mp3_to_psa(mp3_file, multicast_addr, port, zone_info, send_duplicates,
  290. chunk_size, quality, include_metadata=False, sample_format=None,
  291. show_packet_length=False, timing_factor=DEFAULT_TIMING_FACTOR, fixed_stream_id=None):
  292. """Stream an MP3 file to PSA system."""
  293. try:
  294. # Check dependencies
  295. check_dependencies()
  296. # Prepare MP3 file (transcode if needed)
  297. prepared_file = prepare_mp3_file(mp3_file, quality, include_metadata, sample_format)
  298. if not prepared_file:
  299. print("Failed to prepare MP3 file.")
  300. return
  301. using_temp_file = prepared_file != mp3_file
  302. try:
  303. # Use fixed stream ID if provided, otherwise generate a random one
  304. if fixed_stream_id is not None:
  305. stream_id = fixed_stream_id
  306. else:
  307. stream_id = random.randint(0, 0xFFFF) # 16-bit (2 bytes) stream ID
  308. # Open the MP3 file
  309. with open(prepared_file, 'rb') as f:
  310. mp3_data = f.read()
  311. # Calculate number of chunks
  312. chunks = chunk_mp3_data(mp3_data, chunk_size)
  313. total_chunks = len(chunks)
  314. # Get the appropriate quality preset
  315. quality_preset = HIGH_QUALITY if quality == "high" else LOW_QUALITY
  316. # Calculate the appropriate delay for real-time streaming using the actual bitrate
  317. delay_ms = calculate_delay_ms(chunk_size, quality_preset['bitrate'])
  318. print(f"Streaming {os.path.basename(mp3_file)} ({len(mp3_data)/1024:.2f} KB)")
  319. print(f"Split into {total_chunks} packets of {chunk_size} bytes each")
  320. print(f"Sending to multicast {multicast_addr}:{port}")
  321. print(f"Stream ID: {stream_id:04x}, Zone info: {zone_info}")
  322. print(f"Duplicate packets: {'Yes' if send_duplicates else 'No'}")
  323. print(f"Quality preset: {HIGH_QUALITY['description'] if quality == 'high' else LOW_QUALITY['description']}")
  324. print(f"Including metadata: {'Yes' if include_metadata else 'No'}")
  325. print(f"Real-time streaming delay: {delay_ms:.2f}ms per packet")
  326. # Setup multicast socket
  327. sock = setup_multicast_socket()
  328. # Initialize the stream timer with the appropriate parameters
  329. timer = StreamTimer(chunk_size, quality_preset['bitrate'], timing_factor)
  330. timer.start()
  331. # Process and send each chunk
  332. sequence_num = 0
  333. with tqdm(total=total_chunks, desc="Streaming progress") as pbar:
  334. try:
  335. for i, chunk in enumerate(chunks):
  336. # Create packet with current sequence number
  337. packet = create_mp3_packet(sequence_num, zone_info, stream_id, chunk, show_packet_length)
  338. # Send the packet
  339. success = send_multicast_packet(sock, packet, multicast_addr, port, send_duplicates)
  340. # Increment sequence num
  341. sequence_num = (sequence_num + 1) % 256
  342. # Update progress bar
  343. pbar.update(1)
  344. # Wait for the next packet timing using our timer
  345. if i < total_chunks - 1: # No need to wait after the last chunk
  346. timer.wait_for_next_packet()
  347. except Exception as e:
  348. print(f"Error during streaming: {e}")
  349. print("\nStreaming completed successfully!")
  350. finally:
  351. # Clean up temporary file if created
  352. if using_temp_file and os.path.exists(prepared_file):
  353. os.remove(prepared_file)
  354. except FileNotFoundError:
  355. print(f"Error: MP3 file {mp3_file} not found")
  356. sys.exit(1)
  357. except Exception as e:
  358. print(f"Error during streaming: {e}")
  359. sys.exit(1)
  360. def chunk_mp3_data(data, chunk_size):
  361. """
  362. Split MP3 data into chunks of specified size.
  363. Args:
  364. data: The complete MP3 data as bytes
  365. chunk_size: Size of each chunk in bytes
  366. Returns:
  367. A list of data chunks
  368. """
  369. chunks = []
  370. # Calculate how many chunks we'll have
  371. num_chunks = (len(data) + chunk_size - 1) // chunk_size
  372. for i in range(num_chunks):
  373. start = i * chunk_size
  374. end = min((i + 1) * chunk_size, len(data))
  375. chunks.append(data[start:end])
  376. return chunks
  377. def main():
  378. # Set up command line arguments
  379. parser = argparse.ArgumentParser(description="Stream MP3 to Bodet PSA System")
  380. parser.add_argument("mp3_file", help="Path to MP3 file to stream")
  381. parser.add_argument("-a", "--addr", default=DEFAULT_MULTICAST_ADDR,
  382. help=f"Multicast address (default: {DEFAULT_MULTICAST_ADDR})")
  383. parser.add_argument("-p", "--port", type=int, default=DEFAULT_PORT,
  384. help=f"UDP port (default: {DEFAULT_PORT})")
  385. parser.add_argument("-z", "--zone", default=DEFAULT_ZONE_INFO,
  386. help=f"Hex zone info (default: Zone 1)")
  387. parser.add_argument("-n", "--no-duplicates", action="store_true",
  388. help="Don't send duplicate packets (default: send duplicates)")
  389. parser.add_argument("-c", "--chunk-size", type=int, default=DEFAULT_CHUNK_SIZE,
  390. help=f"MP3 chunk size in bytes (default: {DEFAULT_CHUNK_SIZE})")
  391. parser.add_argument("-q", "--quality", choices=["high", "low"], default="high",
  392. help="Audio quality preset: high (128kbps, 48kHz) or low (40kbps, 32kHz) (default: high)")
  393. parser.add_argument("-m", "--include-metadata", action="store_true",
  394. help="Include metadata in MP3 stream (default: no metadata)")
  395. parser.add_argument("-s", "--sample-format", choices=["s16", "s24", "s32"], default=None,
  396. help="Sample format to use for transcoding (default: s16)")
  397. parser.add_argument("--show-packet-length", action="store_true",
  398. help="Show packet length information during streaming")
  399. parser.add_argument("-t", "--timing-factor", type=float, default=DEFAULT_TIMING_FACTOR,
  400. help=f"Timing adjustment factor (lower = faster playback, default: {DEFAULT_TIMING_FACTOR})")
  401. parser.add_argument("--fixed-stream-id", type=int,
  402. help="Use a fixed stream ID instead of a random one (0-65535)")
  403. args = parser.parse_args()
  404. # Stream the MP3 file
  405. stream_mp3_to_psa(
  406. args.mp3_file,
  407. args.addr,
  408. args.port,
  409. args.zone,
  410. not args.no_duplicates,
  411. args.chunk_size,
  412. args.quality,
  413. args.include_metadata,
  414. args.sample_format,
  415. args.show_packet_length,
  416. args.timing_factor,
  417. args.fixed_stream_id # Pass fixed_stream_id parameter
  418. )
  419. # Remove duplicate main() call
  420. if __name__ == "__main__":
  421. main()