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 +71 -0
- squeezy-0.1.0/README.md +62 -0
- squeezy-0.1.0/pyproject.toml +13 -0
- squeezy-0.1.0/setup.cfg +4 -0
- squeezy-0.1.0/squeezy.egg-info/PKG-INFO +71 -0
- squeezy-0.1.0/squeezy.egg-info/SOURCES.txt +9 -0
- squeezy-0.1.0/squeezy.egg-info/dependency_links.txt +1 -0
- squeezy-0.1.0/squeezy.egg-info/entry_points.txt +2 -0
- squeezy-0.1.0/squeezy.egg-info/requires.txt +1 -0
- squeezy-0.1.0/squeezy.egg-info/top_level.txt +1 -0
- squeezy-0.1.0/squeezy.py +810 -0
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
|
squeezy-0.1.0/README.md
ADDED
|
@@ -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"
|
squeezy-0.1.0/setup.cfg
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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
miniaudio>=1.59
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
squeezy
|
squeezy-0.1.0/squeezy.py
ADDED
|
@@ -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()
|