squeezy 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
squeezy-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: squeezy
3
+ Version: 0.1.0
4
+ Summary: Minimal Squeezebox player for Lyrion Music Server
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: miniaudio>=1.59
9
+
10
+ # Squeezy
11
+
12
+ Minimal Squeezebox-compatible player for [Lyrion Music Server](https://lyrion.org/) (formerly Logitech Media Server). Advertises as a player on your network, receives streaming audio, and plays it back through your default audio output. Supports synchronized playback with other players.
13
+
14
+ ## Requirements
15
+
16
+ - Python 3.10+
17
+ - ffmpeg
18
+
19
+ ## Install
20
+
21
+ ### macOS (recommended)
22
+
23
+ ```bash
24
+ brew install ffmpeg pipx
25
+ pipx install git+https://github.com/catcatcatcatcatcatcatcatcatcat/squeezy.git
26
+ ```
27
+
28
+ Then just run:
29
+
30
+ ```bash
31
+ squeezy -n "Kitchen Speaker"
32
+ ```
33
+
34
+ ### From source
35
+
36
+ ```bash
37
+ brew install ffmpeg
38
+ git clone https://github.com/catcatcatcatcatcatcatcatcatcat/squeezy.git
39
+ cd squeezy
40
+ make install
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ```bash
46
+ # Auto-discover server on local network
47
+ squeezy
48
+
49
+ # Specify server and player name
50
+ squeezy -s 192.168.1.100 -n "Kitchen Speaker"
51
+
52
+ # Custom MAC address (for persistent player identity)
53
+ squeezy -m aa:bb:cc:dd:ee:ff
54
+
55
+ # Debug logging
56
+ squeezy -v
57
+ ```
58
+
59
+ ## How it works
60
+
61
+ Squeezy implements the SlimProto protocol to communicate with Lyrion Music Server:
62
+
63
+ 1. Discovers the server via UDP broadcast on port 3483
64
+ 2. Registers as a player via TCP (HELO packet)
65
+ 3. Receives stream commands from the server
66
+ 4. Fetches audio via HTTP, decodes with ffmpeg, outputs via miniaudio
67
+ 5. Reports playback status back to the server for sync coordination
68
+
69
+ ## License
70
+
71
+ MIT
@@ -0,0 +1,62 @@
1
+ # Squeezy
2
+
3
+ Minimal Squeezebox-compatible player for [Lyrion Music Server](https://lyrion.org/) (formerly Logitech Media Server). Advertises as a player on your network, receives streaming audio, and plays it back through your default audio output. Supports synchronized playback with other players.
4
+
5
+ ## Requirements
6
+
7
+ - Python 3.10+
8
+ - ffmpeg
9
+
10
+ ## Install
11
+
12
+ ### macOS (recommended)
13
+
14
+ ```bash
15
+ brew install ffmpeg pipx
16
+ pipx install git+https://github.com/catcatcatcatcatcatcatcatcatcat/squeezy.git
17
+ ```
18
+
19
+ Then just run:
20
+
21
+ ```bash
22
+ squeezy -n "Kitchen Speaker"
23
+ ```
24
+
25
+ ### From source
26
+
27
+ ```bash
28
+ brew install ffmpeg
29
+ git clone https://github.com/catcatcatcatcatcatcatcatcatcat/squeezy.git
30
+ cd squeezy
31
+ make install
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```bash
37
+ # Auto-discover server on local network
38
+ squeezy
39
+
40
+ # Specify server and player name
41
+ squeezy -s 192.168.1.100 -n "Kitchen Speaker"
42
+
43
+ # Custom MAC address (for persistent player identity)
44
+ squeezy -m aa:bb:cc:dd:ee:ff
45
+
46
+ # Debug logging
47
+ squeezy -v
48
+ ```
49
+
50
+ ## How it works
51
+
52
+ Squeezy implements the SlimProto protocol to communicate with Lyrion Music Server:
53
+
54
+ 1. Discovers the server via UDP broadcast on port 3483
55
+ 2. Registers as a player via TCP (HELO packet)
56
+ 3. Receives stream commands from the server
57
+ 4. Fetches audio via HTTP, decodes with ffmpeg, outputs via miniaudio
58
+ 5. Reports playback status back to the server for sync coordination
59
+
60
+ ## License
61
+
62
+ MIT
@@ -0,0 +1,13 @@
1
+ [project]
2
+ name = "squeezy"
3
+ version = "0.1.0"
4
+ description = "Minimal Squeezebox player for Lyrion Music Server"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ dependencies = [
9
+ "miniaudio>=1.59",
10
+ ]
11
+
12
+ [project.scripts]
13
+ squeezy = "squeezy:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: squeezy
3
+ Version: 0.1.0
4
+ Summary: Minimal Squeezebox player for Lyrion Music Server
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: miniaudio>=1.59
9
+
10
+ # Squeezy
11
+
12
+ Minimal Squeezebox-compatible player for [Lyrion Music Server](https://lyrion.org/) (formerly Logitech Media Server). Advertises as a player on your network, receives streaming audio, and plays it back through your default audio output. Supports synchronized playback with other players.
13
+
14
+ ## Requirements
15
+
16
+ - Python 3.10+
17
+ - ffmpeg
18
+
19
+ ## Install
20
+
21
+ ### macOS (recommended)
22
+
23
+ ```bash
24
+ brew install ffmpeg pipx
25
+ pipx install git+https://github.com/catcatcatcatcatcatcatcatcatcat/squeezy.git
26
+ ```
27
+
28
+ Then just run:
29
+
30
+ ```bash
31
+ squeezy -n "Kitchen Speaker"
32
+ ```
33
+
34
+ ### From source
35
+
36
+ ```bash
37
+ brew install ffmpeg
38
+ git clone https://github.com/catcatcatcatcatcatcatcatcatcat/squeezy.git
39
+ cd squeezy
40
+ make install
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ```bash
46
+ # Auto-discover server on local network
47
+ squeezy
48
+
49
+ # Specify server and player name
50
+ squeezy -s 192.168.1.100 -n "Kitchen Speaker"
51
+
52
+ # Custom MAC address (for persistent player identity)
53
+ squeezy -m aa:bb:cc:dd:ee:ff
54
+
55
+ # Debug logging
56
+ squeezy -v
57
+ ```
58
+
59
+ ## How it works
60
+
61
+ Squeezy implements the SlimProto protocol to communicate with Lyrion Music Server:
62
+
63
+ 1. Discovers the server via UDP broadcast on port 3483
64
+ 2. Registers as a player via TCP (HELO packet)
65
+ 3. Receives stream commands from the server
66
+ 4. Fetches audio via HTTP, decodes with ffmpeg, outputs via miniaudio
67
+ 5. Reports playback status back to the server for sync coordination
68
+
69
+ ## License
70
+
71
+ MIT
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ squeezy.py
4
+ squeezy.egg-info/PKG-INFO
5
+ squeezy.egg-info/SOURCES.txt
6
+ squeezy.egg-info/dependency_links.txt
7
+ squeezy.egg-info/entry_points.txt
8
+ squeezy.egg-info/requires.txt
9
+ squeezy.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ squeezy = squeezy:main
@@ -0,0 +1 @@
1
+ miniaudio>=1.59
@@ -0,0 +1 @@
1
+ squeezy
@@ -0,0 +1,810 @@
1
+ #!/usr/bin/env python3
2
+ """Squeezy - Minimal Squeezebox player for Lyrion Music Server."""
3
+
4
+ import argparse
5
+ import logging
6
+ import os
7
+ import signal
8
+ import socket
9
+ import struct
10
+ import subprocess
11
+ import sys
12
+ import threading
13
+ import time
14
+ import uuid
15
+
16
+ import miniaudio
17
+
18
+ log = logging.getLogger("squeezy")
19
+
20
+ SLIMPROTO_PORT = 3483
21
+ DEVICE_ID = 12 # squeezeplay device type
22
+ VERSION = "0.1.0"
23
+ STREAM_BUF_MAX = 2 * 1024 * 1024
24
+ SAMPLE_RATE = 44100
25
+ CHANNELS = 2
26
+ BYTES_PER_FRAME = 4 # 16-bit stereo = 4 bytes per frame
27
+
28
+
29
+ def gettime_ms():
30
+ return int(time.time() * 1000) & 0xFFFFFFFF
31
+
32
+
33
+ def mac_from_string(mac_str):
34
+ return bytes(int(b, 16) for b in mac_str.split(":"))
35
+
36
+
37
+ def default_mac():
38
+ node = uuid.getnode()
39
+ return node.to_bytes(6, "big")
40
+
41
+
42
+ # --- Packet builders ---
43
+
44
+ def build_helo(mac, caps, reconnect=False, bytes_received=0):
45
+ caps_bytes = caps.encode("ascii")
46
+ payload = struct.pack(
47
+ ">BB6s16sHII2s",
48
+ DEVICE_ID, # deviceid
49
+ 0, # revision
50
+ mac, # mac
51
+ b"\x00" * 16, # uuid
52
+ 0x4000 if reconnect else 0x0000, # wlan_channellist
53
+ (bytes_received >> 32) & 0xFFFFFFFF, # bytes_received_H
54
+ bytes_received & 0xFFFFFFFF, # bytes_received_L
55
+ b"\x00\x00", # lang
56
+ ) + caps_bytes
57
+ header = struct.pack(">4sI", b"HELO", len(payload))
58
+ return header + payload
59
+
60
+
61
+ def build_stat(event, stream_buf_size=0, stream_buf_full=0,
62
+ bytes_received=0, output_buf_size=0, output_buf_full=0,
63
+ elapsed_ms=0, server_timestamp=0):
64
+ payload = struct.pack(
65
+ ">4sBBBIIIIHIIIHIIIH",
66
+ event.encode("ascii"), # event code (4 bytes)
67
+ 0, # num_crlf
68
+ 0, # mas_initialized
69
+ 0, # mas_mode
70
+ stream_buf_size, # stream_buffer_size
71
+ stream_buf_full, # stream_buffer_fullness
72
+ (bytes_received >> 32) & 0xFFFFFFFF, # bytes_received_H
73
+ bytes_received & 0xFFFFFFFF, # bytes_received_L
74
+ 0xFFFF, # signal_strength (wired)
75
+ gettime_ms(), # jiffies
76
+ output_buf_size, # output_buffer_size
77
+ output_buf_full, # output_buffer_fullness
78
+ elapsed_ms // 1000, # elapsed_seconds
79
+ 0, # voltage
80
+ elapsed_ms, # elapsed_milliseconds
81
+ server_timestamp, # server_timestamp (echoed from server)
82
+ 0, # error_code
83
+ )
84
+ header = struct.pack(">4sI", b"STAT", len(payload))
85
+ return header + payload
86
+
87
+
88
+ def build_dsco(reason=0):
89
+ payload = struct.pack(">B", reason)
90
+ header = struct.pack(">4sI", b"DSCO", len(payload))
91
+ return header + payload
92
+
93
+
94
+ def build_resp(http_headers):
95
+ header = struct.pack(">4sI", b"RESP", len(http_headers))
96
+ return header + http_headers
97
+
98
+
99
+ def build_setd(player_id, data):
100
+ payload = struct.pack(">B", player_id) + data
101
+ header = struct.pack(">4sI", b"SETD", len(payload))
102
+ return header + payload
103
+
104
+
105
+ # --- PCM Buffer ---
106
+
107
+ class PCMBuffer:
108
+ def __init__(self):
109
+ self.buf = bytearray()
110
+ self.lock = threading.Lock()
111
+
112
+ def write(self, data):
113
+ with self.lock:
114
+ self.buf.extend(data)
115
+
116
+ def read(self, n):
117
+ with self.lock:
118
+ chunk = bytes(self.buf[:n])
119
+ del self.buf[:n]
120
+ return chunk
121
+
122
+ def available(self):
123
+ with self.lock:
124
+ return len(self.buf)
125
+
126
+ def flush(self):
127
+ with self.lock:
128
+ self.buf.clear()
129
+
130
+
131
+ # --- Squeezy Player ---
132
+
133
+ class Squeezy:
134
+ def __init__(self, name="Squeezy", server=None, mac=None):
135
+ self.name = name
136
+ self.server_ip = server
137
+ self.mac = mac_from_string(mac) if mac else default_mac()
138
+ self.sock = None
139
+ self.running = False
140
+ self.reconnect = False
141
+ self.bytes_received = 0
142
+ self.server_timestamp = 0
143
+
144
+ # Stream state
145
+ self.stream_sock = None
146
+ self.stream_thread = None
147
+ self.ffmpeg_proc = None
148
+ self.decode_thread = None
149
+ self.pcm_buf = PCMBuffer()
150
+ self.streaming = False
151
+ self.stream_bytes = 0
152
+ self.decode_complete = False
153
+ self.autostart = 0
154
+ self.cont_received = False # For autostart >= 2
155
+
156
+ # Audio state
157
+ self.device = None
158
+ self.playing = False
159
+ self.paused = False
160
+ self.playback_start_time = 0
161
+ self.pause_elapsed = 0
162
+ self.start_at_jiffies = 0
163
+ self.output_frames = 0
164
+
165
+ # STAT flags (match squeezelite: only send each once per track)
166
+ self.sent_STMd = False
167
+ self.sent_STMu = False
168
+ self.sent_STMo = False
169
+ self.sent_STMl = False
170
+
171
+ self._send_lock = threading.Lock()
172
+
173
+ def _capabilities(self):
174
+ return (
175
+ f"Model=squeezelite,ModelName={self.name},"
176
+ f"AccuratePlayPoints=1,HasDigitalOut=1,HasPolarityInversion=1,"
177
+ f"Firmware={VERSION},MaxSampleRate={SAMPLE_RATE},"
178
+ f"pcm,mp3,flac,ogg,aac"
179
+ )
180
+
181
+ # --- Network ---
182
+
183
+ def discover(self):
184
+ log.info("Discovering server...")
185
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
186
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
187
+ sock.settimeout(5)
188
+
189
+ # Try multiple broadcast addresses (255.255.255.255 fails on some macOS configs)
190
+ broadcast_addrs = ["255.255.255.255"]
191
+ try:
192
+ import netifaces
193
+ for iface in netifaces.interfaces():
194
+ addrs = netifaces.ifaddresses(iface).get(netifaces.AF_INET, [])
195
+ for addr in addrs:
196
+ if "broadcast" in addr:
197
+ broadcast_addrs.append(addr["broadcast"])
198
+ except ImportError:
199
+ # Fallback: try common subnet broadcasts
200
+ broadcast_addrs.extend(["192.168.1.255", "192.168.0.255", "10.0.0.255", "172.16.0.255"])
201
+
202
+ for attempt in range(5):
203
+ for bcast in broadcast_addrs:
204
+ try:
205
+ sock.sendto(b"e", (bcast, SLIMPROTO_PORT))
206
+ except OSError:
207
+ continue
208
+ try:
209
+ data, addr = sock.recvfrom(1024)
210
+ if data and data[0:1] == b"E":
211
+ log.info("Found server at %s", addr[0])
212
+ sock.close()
213
+ return addr[0]
214
+ except socket.timeout:
215
+ log.debug("Discovery attempt %d timed out", attempt + 1)
216
+ sock.close()
217
+ return None
218
+
219
+ def _send(self, data):
220
+ with self._send_lock:
221
+ try:
222
+ self.sock.sendall(data)
223
+ except OSError as e:
224
+ log.warning("Send error: %s", e)
225
+
226
+ def _send_stat(self, event, server_timestamp=0):
227
+ elapsed = self._elapsed_ms()
228
+ pkt = build_stat(
229
+ event,
230
+ stream_buf_size=STREAM_BUF_MAX,
231
+ stream_buf_full=self.pcm_buf.available(),
232
+ bytes_received=self.stream_bytes,
233
+ output_buf_size=SAMPLE_RATE * BYTES_PER_FRAME * 10,
234
+ output_buf_full=self.pcm_buf.available(),
235
+ elapsed_ms=elapsed,
236
+ server_timestamp=server_timestamp,
237
+ )
238
+ self._send(pkt)
239
+
240
+ def _elapsed_ms(self):
241
+ if not self.playing or self.playback_start_time == 0:
242
+ return self.pause_elapsed
243
+ if self.paused:
244
+ return self.pause_elapsed
245
+ return self.pause_elapsed + int((time.time() - self.playback_start_time) * 1000)
246
+
247
+ def connect(self):
248
+ if not self.server_ip:
249
+ self.server_ip = self.discover()
250
+ if not self.server_ip:
251
+ log.error("No server found")
252
+ return False
253
+
254
+ log.info("Connecting to %s:%d", self.server_ip, SLIMPROTO_PORT)
255
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
256
+ self.sock.settimeout(5)
257
+ try:
258
+ self.sock.connect((self.server_ip, SLIMPROTO_PORT))
259
+ except OSError as e:
260
+ log.error("Connection failed: %s", e)
261
+ return False
262
+
263
+ self.sock.settimeout(1)
264
+
265
+ helo = build_helo(self.mac, self._capabilities(), reconnect=self.reconnect,
266
+ bytes_received=self.stream_bytes)
267
+ self._send(helo)
268
+ self.reconnect = True
269
+ log.info("Connected, HELO sent (MAC: %s)", ":".join(f"{b:02x}" for b in self.mac))
270
+ return True
271
+
272
+ # --- Message loop ---
273
+
274
+ def run(self):
275
+ self.running = True
276
+ while self.running:
277
+ if not self.connect():
278
+ log.info("Retrying in 5 seconds...")
279
+ time.sleep(5)
280
+ continue
281
+ try:
282
+ self._message_loop()
283
+ except Exception as e:
284
+ log.warning("Connection lost: %s", e)
285
+ finally:
286
+ self._stop_playback()
287
+ try:
288
+ self.sock.close()
289
+ except OSError:
290
+ pass
291
+ if self.running:
292
+ log.info("Reconnecting in 2 seconds...")
293
+ time.sleep(2)
294
+
295
+ def _message_loop(self):
296
+ buf = bytearray()
297
+ expect_len = None
298
+ timeouts = 0
299
+ last_status = 0
300
+
301
+ while self.running:
302
+ # Periodic status while playing
303
+ now = time.time()
304
+ if self.playing and not self.paused and now - last_status > 1.0:
305
+ self._send_stat("STMt")
306
+ last_status = now
307
+
308
+ try:
309
+ data = self.sock.recv(4096)
310
+ if not data:
311
+ log.info("Server closed connection")
312
+ return
313
+ timeouts = 0
314
+ buf.extend(data)
315
+ except socket.timeout:
316
+ timeouts += 1
317
+ if timeouts > 35:
318
+ log.info("Server timeout")
319
+ return
320
+ continue
321
+
322
+ # Parse messages from buffer
323
+ while True:
324
+ if expect_len is None:
325
+ if len(buf) < 2:
326
+ break
327
+ expect_len = struct.unpack(">H", buf[:2])[0]
328
+ buf = buf[2:]
329
+
330
+ if len(buf) < expect_len:
331
+ break
332
+
333
+ msg = bytes(buf[:expect_len])
334
+ buf = buf[expect_len:]
335
+ expect_len = None
336
+ self._handle_message(msg)
337
+
338
+ def _handle_message(self, msg):
339
+ if len(msg) < 4:
340
+ return
341
+ opcode = msg[:4]
342
+ log.debug("Received: %s (%d bytes)", opcode, len(msg))
343
+
344
+ handlers = {
345
+ b"strm": self._handle_strm,
346
+ b"audg": self._handle_audg,
347
+ b"setd": self._handle_setd,
348
+ b"aude": self._handle_aude,
349
+ b"cont": self._handle_cont,
350
+ b"serv": self._handle_serv,
351
+ }
352
+ handler = handlers.get(opcode)
353
+ if handler:
354
+ handler(msg)
355
+ else:
356
+ log.debug("Unhandled opcode: %s", opcode)
357
+
358
+ # --- Message handlers ---
359
+
360
+ def _handle_strm(self, msg):
361
+ if len(msg) < 5:
362
+ return
363
+ command = chr(msg[4])
364
+ log.info("strm command: %s", command)
365
+
366
+ if command == "t":
367
+ # Timing request - echo server timestamp
368
+ if len(msg) >= 22:
369
+ ts = struct.unpack_from(">I", msg, 18)[0]
370
+ self._send_stat("STMt", server_timestamp=ts)
371
+ else:
372
+ self._send_stat("STMt")
373
+
374
+ elif command == "s":
375
+ self._handle_strm_start(msg)
376
+
377
+ elif command == "p":
378
+ # Pause — replay_gain field = interval in ms (0 = immediate stop)
379
+ interval = 0
380
+ if len(msg) >= 22:
381
+ interval = struct.unpack_from(">I", msg, 18)[0]
382
+ if interval:
383
+ # Pause after interval ms — for MVP, just pause immediately
384
+ log.debug("Pause with interval %d ms (treating as immediate)", interval)
385
+ if self.playing and not self.paused:
386
+ self.pause_elapsed = self._elapsed_ms()
387
+ self.paused = True
388
+ if self.device:
389
+ try:
390
+ self.device.close()
391
+ except Exception:
392
+ pass
393
+ self.device = None
394
+ if not interval:
395
+ self._send_stat("STMp")
396
+
397
+ elif command == "u":
398
+ # Unpause with optional sync timestamp
399
+ target_jiffies = 0
400
+ if len(msg) >= 22:
401
+ target_jiffies = struct.unpack_from(">I", msg, 18)[0]
402
+
403
+ self.start_at_jiffies = target_jiffies
404
+ if self.paused:
405
+ self.paused = False
406
+ self._resume_audio()
407
+ elif not self.playing and self.pcm_buf.available() > 0:
408
+ self._start_audio()
409
+ self._send_stat("STMr")
410
+
411
+ elif command == "q":
412
+ # Hard stop — always send STMf
413
+ self._stop_playback()
414
+ self._send_stat("STMf")
415
+
416
+ elif command == "f":
417
+ # Graceful flush — only send STMf if something was active
418
+ was_active = self.streaming or self.playing
419
+ self._stop_playback()
420
+ if was_active:
421
+ self._send_stat("STMf")
422
+
423
+ def _handle_strm_start(self, msg):
424
+ if len(msg) < 28:
425
+ log.warning("strm 's' packet too short")
426
+ return
427
+
428
+ self.autostart = msg[5] - ord("0") if msg[5] >= ord("0") else 0
429
+ fmt = chr(msg[6])
430
+ threshold = msg[11] * 1024
431
+ server_port = struct.unpack_from(">H", msg, 22)[0]
432
+ server_ip_raw = struct.unpack_from(">I", msg, 24)[0]
433
+ http_header = msg[28:]
434
+
435
+ # If server_ip is 0, use the slimproto server
436
+ if server_ip_raw == 0:
437
+ server_ip = self.server_ip
438
+ else:
439
+ server_ip = socket.inet_ntoa(struct.pack(">I", server_ip_raw))
440
+
441
+ log.info("Stream start: format=%s server=%s:%d threshold=%d autostart=%d",
442
+ fmt, server_ip, server_port, threshold, self.autostart)
443
+
444
+ # Stop any existing playback
445
+ self._stop_playback()
446
+ self._send_stat("STMf")
447
+
448
+ # Start streaming in background
449
+ self.streaming = True
450
+ self.stream_bytes = 0
451
+ self.cont_received = (self.autostart < 2) # autostart < 2 doesn't need cont
452
+ self.pcm_buf.flush()
453
+
454
+ self.stream_thread = threading.Thread(
455
+ target=self._stream_worker,
456
+ args=(server_ip, server_port, http_header, threshold, self.autostart),
457
+ daemon=True,
458
+ )
459
+ self.stream_thread.start()
460
+
461
+ def _handle_audg(self, msg):
462
+ # Volume control - log for now
463
+ if len(msg) >= 22:
464
+ adjust = msg[12]
465
+ gain_l = struct.unpack_from(">I", msg, 14)[0]
466
+ gain_r = struct.unpack_from(">I", msg, 18)[0]
467
+ vol = gain_l / 0x10000 if adjust else 1.0
468
+ log.debug("Volume: L=%.2f R=%.2f adjust=%d", gain_l / 0x10000, gain_r / 0x10000, adjust)
469
+
470
+ def _handle_setd(self, msg):
471
+ if len(msg) < 5:
472
+ return
473
+ setd_id = msg[4]
474
+ if setd_id == 0:
475
+ if len(msg) == 5:
476
+ # Query player name
477
+ name_data = self.name.encode("utf-8") + b"\x00"
478
+ self._send(build_setd(0, name_data))
479
+ elif len(msg) > 5:
480
+ # Set player name
481
+ new_name = msg[5:].rstrip(b"\x00").decode("utf-8", errors="replace")
482
+ if new_name:
483
+ self.name = new_name
484
+ log.info("Player name set to: %s", self.name)
485
+ name_data = self.name.encode("utf-8") + b"\x00"
486
+ self._send(build_setd(0, name_data))
487
+
488
+ def _handle_aude(self, msg):
489
+ log.debug("aude received")
490
+
491
+ def _handle_cont(self, msg):
492
+ log.info("cont received (autostart was %d)", self.autostart)
493
+ if self.autostart >= 2:
494
+ self.autostart -= 2
495
+ self.cont_received = True
496
+
497
+ def _handle_serv(self, msg):
498
+ if len(msg) >= 8:
499
+ new_ip = struct.unpack_from(">I", msg, 4)[0]
500
+ if new_ip:
501
+ self.server_ip = socket.inet_ntoa(struct.pack(">I", new_ip))
502
+ log.info("Server redirect to %s", self.server_ip)
503
+
504
+ # --- Streaming ---
505
+
506
+ def _stream_worker(self, server_ip, server_port, http_header, threshold, autostart):
507
+ try:
508
+ self._do_stream(server_ip, server_port, http_header, threshold, autostart)
509
+ except Exception as e:
510
+ log.warning("Stream error: %s", e)
511
+ finally:
512
+ self.streaming = False
513
+ self._cleanup_ffmpeg()
514
+ try:
515
+ self._send(build_dsco(0))
516
+ except Exception:
517
+ pass
518
+
519
+ def _do_stream(self, server_ip, server_port, http_header, threshold, autostart):
520
+ # Connect to stream server
521
+ log.info("Connecting to stream %s:%d", server_ip, server_port)
522
+ self.stream_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
523
+ self.stream_sock.settimeout(10)
524
+ self.stream_sock.connect((server_ip, server_port))
525
+
526
+ # Send HTTP request
527
+ self.stream_sock.sendall(http_header)
528
+
529
+ # Read HTTP response headers
530
+ resp_buf = bytearray()
531
+ while b"\r\n\r\n" not in resp_buf:
532
+ chunk = self.stream_sock.recv(4096)
533
+ if not chunk:
534
+ log.warning("Stream closed during headers")
535
+ return
536
+ resp_buf.extend(chunk)
537
+
538
+ header_end = resp_buf.index(b"\r\n\r\n") + 4
539
+ resp_headers = bytes(resp_buf[:header_end])
540
+ leftover = bytes(resp_buf[header_end:])
541
+
542
+ log.info("Stream response: %s", resp_headers[:80])
543
+
544
+ # Send RESP and STMc
545
+ self._send(build_resp(resp_headers))
546
+ self._send_stat("STMc")
547
+
548
+ # Start ffmpeg decoder
549
+ self.ffmpeg_proc = subprocess.Popen(
550
+ ["ffmpeg", "-hide_banner", "-loglevel", "error",
551
+ "-i", "pipe:0",
552
+ "-f", "s16le", "-ar", str(SAMPLE_RATE), "-ac", str(CHANNELS),
553
+ "pipe:1"],
554
+ stdin=subprocess.PIPE,
555
+ stdout=subprocess.PIPE,
556
+ stderr=subprocess.PIPE,
557
+ )
558
+
559
+ # Start decode reader thread
560
+ self.decode_thread = threading.Thread(
561
+ target=self._decode_reader,
562
+ args=(threshold, autostart),
563
+ daemon=True,
564
+ )
565
+ self.decode_thread.start()
566
+
567
+ # Feed data to ffmpeg
568
+ if leftover:
569
+ self.stream_bytes += len(leftover)
570
+ try:
571
+ self.ffmpeg_proc.stdin.write(leftover)
572
+ except BrokenPipeError:
573
+ return
574
+
575
+ self.stream_sock.settimeout(5)
576
+ while self.streaming and self.running:
577
+ try:
578
+ data = self.stream_sock.recv(32768)
579
+ if not data:
580
+ break
581
+ self.stream_bytes += len(data)
582
+ try:
583
+ self.ffmpeg_proc.stdin.write(data)
584
+ except BrokenPipeError:
585
+ break
586
+ except socket.timeout:
587
+ continue
588
+ except OSError:
589
+ break
590
+
591
+ # Close ffmpeg stdin to signal EOF
592
+ try:
593
+ self.ffmpeg_proc.stdin.close()
594
+ except Exception:
595
+ pass
596
+
597
+ # Close stream socket
598
+ try:
599
+ self.stream_sock.close()
600
+ except Exception:
601
+ pass
602
+ self.stream_sock = None
603
+
604
+ def _decode_reader(self, threshold, autostart):
605
+ """Read decoded PCM from ffmpeg stdout into PCM buffer."""
606
+ started = False
607
+ while self.running:
608
+ try:
609
+ data = self.ffmpeg_proc.stdout.read(8192)
610
+ if not data:
611
+ break
612
+ self.pcm_buf.write(data)
613
+
614
+ # Auto-start playback when threshold reached and cont received (if needed)
615
+ if (not started and autostart >= 1 and self.cont_received
616
+ and self.pcm_buf.available() >= max(threshold, 8192)):
617
+ started = True
618
+ self._start_audio()
619
+ self._send_stat("STMs")
620
+
621
+ except Exception:
622
+ break
623
+
624
+ # Mark decode complete - STMd is sent from the audio generator
625
+ # only when actively playing (not while paused)
626
+ self.decode_complete = True
627
+ log.debug("Decode complete, %d bytes buffered", self.pcm_buf.available())
628
+
629
+ def _cleanup_ffmpeg(self):
630
+ if self.ffmpeg_proc:
631
+ try:
632
+ self.ffmpeg_proc.stdin.close()
633
+ except Exception:
634
+ pass
635
+ try:
636
+ self.ffmpeg_proc.kill()
637
+ self.ffmpeg_proc.wait(timeout=2)
638
+ except Exception:
639
+ pass
640
+ self.ffmpeg_proc = None
641
+
642
+ # --- Audio output ---
643
+
644
+ def _audio_generator(self):
645
+ """Generator that yields PCM data for miniaudio playback.
646
+ miniaudio sends framecount via send(), we yield bytes back."""
647
+ required_frames = yield b"" # priming yield
648
+ while self.playing and self.running:
649
+ if self.paused:
650
+ required_frames = yield b"\x00" * (required_frames * BYTES_PER_FRAME)
651
+ continue
652
+
653
+ required_bytes = required_frames * BYTES_PER_FRAME
654
+
655
+ # Sync: if start_at_jiffies is set, output silence until target time
656
+ if self.start_at_jiffies:
657
+ now = gettime_ms()
658
+ diff = (self.start_at_jiffies - now) & 0xFFFFFFFF
659
+ if diff < 0x7FFFFFFF and diff > 0 and diff < 10000:
660
+ required_frames = yield b"\x00" * required_bytes
661
+ continue
662
+ # Target reached or passed — clear and start playing
663
+ self.start_at_jiffies = 0
664
+ self.playback_start_time = time.time()
665
+
666
+ avail = self.pcm_buf.available()
667
+ if avail > 0:
668
+ n = min(avail, required_bytes)
669
+ chunk = self.pcm_buf.read(n)
670
+ if chunk:
671
+ self.output_frames += len(chunk) // BYTES_PER_FRAME
672
+ # Send STMd once when decode completes (while actively playing)
673
+ if self.decode_complete and not self.sent_STMd:
674
+ self.sent_STMd = True
675
+ self._send_stat("STMd")
676
+ if len(chunk) < required_bytes:
677
+ chunk += b"\x00" * (required_bytes - len(chunk))
678
+ required_frames = yield chunk
679
+ continue
680
+
681
+ # Buffer empty
682
+ if self.decode_complete and avail == 0:
683
+ # Track fully played — send STMu once
684
+ if not self.sent_STMu:
685
+ self.sent_STMu = True
686
+ self._send_stat("STMu")
687
+ break
688
+ elif self.streaming and avail == 0 and not self.sent_STMo:
689
+ # Buffer underrun while still streaming — send STMo
690
+ self.sent_STMo = True
691
+ self._send_stat("STMo")
692
+
693
+ # Yield silence while waiting for data
694
+ required_frames = yield b"\x00" * required_bytes
695
+
696
+ def _start_audio(self):
697
+ if self.playing:
698
+ return
699
+ log.info("Starting audio playback")
700
+ try:
701
+ self.device = miniaudio.PlaybackDevice(
702
+ output_format=miniaudio.SampleFormat.SIGNED16,
703
+ nchannels=CHANNELS,
704
+ sample_rate=SAMPLE_RATE,
705
+ )
706
+ gen = self._audio_generator()
707
+ next(gen) # prime the generator before miniaudio calls send()
708
+ self.device.start(gen)
709
+ self.playing = True
710
+ self.paused = False
711
+ self.pause_elapsed = 0
712
+ self.playback_start_time = time.time()
713
+ self.output_frames = 0
714
+ except Exception as e:
715
+ log.error("Audio start failed: %s", e)
716
+
717
+ def _start_audio_at_time(self):
718
+ """Start audio device immediately but output silence until sync timestamp.
719
+ The generator handles the silence-until-time logic (OUTPUT_START_AT equivalent)."""
720
+ log.info("Sync start at jiffies=%d (now=%d)", self.start_at_jiffies, gettime_ms())
721
+ self._start_audio()
722
+
723
+ def _resume_audio(self):
724
+ if not self.playing:
725
+ self._start_audio()
726
+ return
727
+ log.info("Resuming audio (%d bytes buffered)", self.pcm_buf.available())
728
+ # Close old device before creating new one
729
+ if self.device:
730
+ try:
731
+ self.device.close()
732
+ except Exception:
733
+ pass
734
+ self.device = None
735
+ try:
736
+ self.device = miniaudio.PlaybackDevice(
737
+ output_format=miniaudio.SampleFormat.SIGNED16,
738
+ nchannels=CHANNELS,
739
+ sample_rate=SAMPLE_RATE,
740
+ )
741
+ gen = self._audio_generator()
742
+ next(gen) # prime the generator
743
+ self.device.start(gen)
744
+ self.playback_start_time = time.time()
745
+ except Exception as e:
746
+ log.error("Audio resume failed: %s", e)
747
+
748
+ def _stop_playback(self):
749
+ self.streaming = False
750
+ self.playing = False
751
+ self.paused = False
752
+ self.playback_start_time = 0
753
+ self.pause_elapsed = 0
754
+ self.decode_complete = False
755
+ self.cont_received = False
756
+ self.sent_STMd = False
757
+ self.sent_STMu = False
758
+ self.sent_STMo = False
759
+ self.sent_STMl = False
760
+
761
+ if self.device:
762
+ try:
763
+ self.device.close()
764
+ except Exception:
765
+ pass
766
+ self.device = None
767
+
768
+ if self.stream_sock:
769
+ try:
770
+ self.stream_sock.close()
771
+ except Exception:
772
+ pass
773
+ self.stream_sock = None
774
+
775
+ self._cleanup_ffmpeg()
776
+ self.pcm_buf.flush()
777
+
778
+ def stop(self):
779
+ log.info("Shutting down...")
780
+ self.running = False
781
+ self._stop_playback()
782
+
783
+
784
+ def main():
785
+ parser = argparse.ArgumentParser(description="Squeezy - Minimal Squeezebox player")
786
+ parser.add_argument("-s", "--server", help="LMS server IP (auto-discover if not set)")
787
+ parser.add_argument("-n", "--name", default="Squeezy", help="Player name (default: Squeezy)")
788
+ parser.add_argument("-m", "--mac", help="MAC address aa:bb:cc:dd:ee:ff (auto-detect if not set)")
789
+ parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging")
790
+ args = parser.parse_args()
791
+
792
+ logging.basicConfig(
793
+ level=logging.DEBUG if args.verbose else logging.INFO,
794
+ format="%(asctime)s %(levelname)s %(message)s",
795
+ datefmt="%H:%M:%S",
796
+ )
797
+
798
+ player = Squeezy(name=args.name, server=args.server, mac=args.mac)
799
+
800
+ def handle_signal(sig, frame):
801
+ player.stop()
802
+
803
+ signal.signal(signal.SIGINT, handle_signal)
804
+ signal.signal(signal.SIGTERM, handle_signal)
805
+
806
+ player.run()
807
+
808
+
809
+ if __name__ == "__main__":
810
+ main()