piwave 2.0.9__py3-none-any.whl → 2.1.1__py3-none-any.whl
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.
- piwave/__init__.py +3 -1
- piwave/__main__.py +105 -0
- piwave/backends/__init__.py +126 -0
- piwave/backends/base.py +245 -0
- piwave/backends/fm_transmitter.py +44 -0
- piwave/backends/pi_fm_rds.py +49 -0
- piwave/logger.py +99 -0
- piwave/piwave.py +283 -177
- piwave-2.1.1.dist-info/METADATA +750 -0
- piwave-2.1.1.dist-info/RECORD +13 -0
- piwave-2.0.9.dist-info/METADATA +0 -419
- piwave-2.0.9.dist-info/RECORD +0 -7
- {piwave-2.0.9.dist-info → piwave-2.1.1.dist-info}/WHEEL +0 -0
- {piwave-2.0.9.dist-info → piwave-2.1.1.dist-info}/licenses/LICENSE +0 -0
- {piwave-2.0.9.dist-info → piwave-2.1.1.dist-info}/top_level.txt +0 -0
piwave/piwave.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
# piwave
|
|
2
|
-
#
|
|
1
|
+
# PiWave is available at https://piwave.xyz
|
|
2
|
+
# Licensed under GPLv3.0, main GitHub repository at https://github.com/douxxtech/piwave/
|
|
3
|
+
# piwave/piwave.py : main entry
|
|
3
4
|
|
|
4
5
|
import os
|
|
5
6
|
import subprocess
|
|
@@ -9,120 +10,31 @@ import time
|
|
|
9
10
|
|
|
10
11
|
import tempfile
|
|
11
12
|
import shutil
|
|
12
|
-
import
|
|
13
|
+
import queue
|
|
13
14
|
from typing import Optional, Callable
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
from urllib.parse import urlparse
|
|
16
17
|
import atexit
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
'reset': '\033[0m',
|
|
21
|
-
'bold': '\033[1m',
|
|
22
|
-
'underline': '\033[4m',
|
|
23
|
-
'red': '\033[31m',
|
|
24
|
-
'green': '\033[32m',
|
|
25
|
-
'yellow': '\033[33m',
|
|
26
|
-
'blue': '\033[34m',
|
|
27
|
-
'magenta': '\033[35m',
|
|
28
|
-
'cyan': '\033[36m',
|
|
29
|
-
'white': '\033[37m',
|
|
30
|
-
'bright_red': '\033[91m',
|
|
31
|
-
'bright_green': '\033[92m',
|
|
32
|
-
'bright_yellow': '\033[93m',
|
|
33
|
-
'bright_blue': '\033[94m',
|
|
34
|
-
'bright_magenta': '\033[95m',
|
|
35
|
-
'bright_cyan': '\033[96m',
|
|
36
|
-
'bright_white': '\033[97m',
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
ICONS = {
|
|
40
|
-
'success': 'OK',
|
|
41
|
-
'error': 'ERR',
|
|
42
|
-
'warning': 'WARN',
|
|
43
|
-
'info': 'INFO',
|
|
44
|
-
'client': 'CLIENT',
|
|
45
|
-
'server': 'SERVER',
|
|
46
|
-
'file': 'FILE',
|
|
47
|
-
'broadcast': 'BCAST',
|
|
48
|
-
'version': 'VER',
|
|
49
|
-
'update': 'UPD',
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
SILENT = False
|
|
53
|
-
|
|
54
|
-
@classmethod
|
|
55
|
-
def config(cls, silent: bool = False):
|
|
56
|
-
cls.SILENT = silent
|
|
57
|
-
|
|
58
|
-
@classmethod
|
|
59
|
-
def print(cls, message: str, style: str = '', icon: str = '', end: str = '\n'):
|
|
60
|
-
|
|
61
|
-
if cls.SILENT: return
|
|
62
|
-
|
|
63
|
-
color = cls.COLORS.get(style, '')
|
|
64
|
-
icon_char = cls.ICONS.get(icon, '')
|
|
65
|
-
if icon_char:
|
|
66
|
-
if color:
|
|
67
|
-
print(f"{color}[{icon_char}]\033[0m {message}", end=end)
|
|
68
|
-
else:
|
|
69
|
-
print(f"[{icon_char}] {message}", end=end)
|
|
70
|
-
else:
|
|
71
|
-
if color:
|
|
72
|
-
print(f"{color}{message}\033[0m", end=end)
|
|
73
|
-
else:
|
|
74
|
-
print(f"{message}", end=end)
|
|
75
|
-
sys.stdout.flush()
|
|
76
|
-
|
|
77
|
-
@classmethod
|
|
78
|
-
def header(cls, text: str):
|
|
79
|
-
cls.print(text, 'bright_blue', end='\n\n')
|
|
80
|
-
sys.stdout.flush()
|
|
81
|
-
|
|
82
|
-
@classmethod
|
|
83
|
-
def section(cls, text: str):
|
|
84
|
-
cls.print(f" {text} ", 'bright_blue', end='')
|
|
85
|
-
cls.print("─" * (len(text) + 2), 'blue', end='\n\n')
|
|
86
|
-
sys.stdout.flush()
|
|
87
|
-
|
|
88
|
-
@classmethod
|
|
89
|
-
def success(cls, message: str):
|
|
90
|
-
cls.print(message, 'bright_green', 'success')
|
|
91
|
-
|
|
92
|
-
@classmethod
|
|
93
|
-
def error(cls, message: str):
|
|
94
|
-
cls.print(message, 'bright_red', 'error')
|
|
95
|
-
|
|
96
|
-
@classmethod
|
|
97
|
-
def warning(cls, message: str):
|
|
98
|
-
cls.print(message, 'bright_yellow', 'warning')
|
|
99
|
-
|
|
100
|
-
@classmethod
|
|
101
|
-
def info(cls, message: str):
|
|
102
|
-
cls.print(message, 'bright_cyan', 'info')
|
|
103
|
-
|
|
104
|
-
@classmethod
|
|
105
|
-
def file_message(cls, message: str):
|
|
106
|
-
cls.print(message, 'yellow', 'file')
|
|
107
|
-
|
|
108
|
-
@classmethod
|
|
109
|
-
def broadcast_message(cls, message: str):
|
|
110
|
-
cls.print(message, 'bright_magenta', 'broadcast')
|
|
19
|
+
from .backends import discover_backends, backends, get_best_backend, search_backends, list_backends
|
|
20
|
+
from .logger import Log
|
|
111
21
|
|
|
112
22
|
class PiWaveError(Exception):
|
|
113
23
|
pass
|
|
114
24
|
|
|
115
25
|
class PiWave:
|
|
116
26
|
def __init__(self,
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
27
|
+
frequency: float = 90.0,
|
|
28
|
+
ps: str = "PiWave",
|
|
29
|
+
rt: str = "PiWave: The best python module for managing your pi radio",
|
|
30
|
+
pi: str = "FFFF",
|
|
31
|
+
debug: bool = False,
|
|
32
|
+
silent: bool = False,
|
|
33
|
+
loop: bool = False,
|
|
34
|
+
backend: str = "auto",
|
|
35
|
+
used_for: str = "file_broadcast",
|
|
36
|
+
on_track_change: Optional[Callable] = None,
|
|
37
|
+
on_error: Optional[Callable] = None):
|
|
126
38
|
"""Initialize PiWave FM transmitter.
|
|
127
39
|
|
|
128
40
|
:param frequency: FM frequency to broadcast on (80.0-108.0 MHz)
|
|
@@ -139,6 +51,10 @@ class PiWave:
|
|
|
139
51
|
:type silent: bool
|
|
140
52
|
:param loop: Loop the current track continuously (default: False)
|
|
141
53
|
:type loop: bool
|
|
54
|
+
:param backend: Chose a specific backend to handle the broadcast (default: auto). Supports `pi_fm_rds`, `fm_transmitter` and `auto`
|
|
55
|
+
:type backend: str
|
|
56
|
+
:param backend: Give the main use for the current instance, will be used if backend: auto (default: file_broadcast). Supports `file_broadcast` and `live_broadcast`
|
|
57
|
+
:type backend: str
|
|
142
58
|
:param on_track_change: Callback function called when track changes
|
|
143
59
|
:type on_track_change: Optional[Callable]
|
|
144
60
|
:param on_error: Callback function called when an error occurs
|
|
@@ -146,7 +62,7 @@ class PiWave:
|
|
|
146
62
|
:raises PiWaveError: If not running on Raspberry Pi or without root privileges
|
|
147
63
|
|
|
148
64
|
.. note::
|
|
149
|
-
This class requires pi_fm_rds to be installed and accessible.
|
|
65
|
+
This class requires pi_fm_rds or fm_transmitter to be installed and accessible.
|
|
150
66
|
Must be run on a Raspberry Pi with root privileges.
|
|
151
67
|
"""
|
|
152
68
|
|
|
@@ -162,21 +78,54 @@ class PiWave:
|
|
|
162
78
|
self.current_file: Optional[str] = None
|
|
163
79
|
self.is_playing = False
|
|
164
80
|
self.is_stopped = False
|
|
165
|
-
|
|
166
81
|
self.current_process: Optional[subprocess.Popen] = None
|
|
167
82
|
self.playback_thread: Optional[threading.Thread] = None
|
|
168
83
|
self.stop_event = threading.Event()
|
|
169
|
-
|
|
170
84
|
self.temp_dir = tempfile.mkdtemp(prefix="piwave_")
|
|
171
85
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
self.
|
|
86
|
+
self.is_live_streaming = False
|
|
87
|
+
self.live_thread: Optional[threading.Thread] = None
|
|
88
|
+
self.audio_queue: Optional[queue.Queue] = None
|
|
175
89
|
|
|
90
|
+
Log.config(silent=silent)
|
|
91
|
+
|
|
176
92
|
self._validate_environment()
|
|
177
|
-
|
|
178
93
|
atexit.register(self.cleanup)
|
|
94
|
+
|
|
95
|
+
discover_backends()
|
|
179
96
|
|
|
97
|
+
self.backend_use = used_for
|
|
98
|
+
|
|
99
|
+
if backend == "auto":
|
|
100
|
+
backend_name = get_best_backend(self.backend_use, self.frequency)
|
|
101
|
+
if not backend_name:
|
|
102
|
+
available = list(backends.keys())
|
|
103
|
+
raise PiWaveError(f"No suitable backend found for {self.frequency}MHz and {self.backend_use} mode. Available backends: {available}")
|
|
104
|
+
else:
|
|
105
|
+
if backend not in backends:
|
|
106
|
+
available = list(backends.keys())
|
|
107
|
+
raise PiWaveError(f"Backend '{backend}' not available. Available: {available}. Use 'python3 -m piwave search' to refresh.")
|
|
108
|
+
|
|
109
|
+
# Validate that the chosen backend supports the frequency
|
|
110
|
+
backend_instance = backends[backend]()
|
|
111
|
+
min_freq, max_freq = backend_instance.frequency_range
|
|
112
|
+
if not (min_freq <= self.frequency <= max_freq):
|
|
113
|
+
raise PiWaveError(f"Backend '{backend}' doesn't support {self.frequency}MHz (supports {min_freq}-{max_freq}MHz)")
|
|
114
|
+
|
|
115
|
+
backend_name = backend
|
|
116
|
+
|
|
117
|
+
self.backend_name = backend_name
|
|
118
|
+
self.backend = backends[backend_name](
|
|
119
|
+
frequency=self.frequency,
|
|
120
|
+
ps=self.ps,
|
|
121
|
+
rt=self.rt,
|
|
122
|
+
pi=self.pi
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
min_freq, max_freq = self.backend.frequency_range
|
|
127
|
+
rds_support = "with RDS" if self.backend.supports_rds else "no RDS"
|
|
128
|
+
Log.info(f"Using {self.backend.name} backend ({min_freq}-{max_freq}MHz, {rds_support})")
|
|
180
129
|
|
|
181
130
|
Log.info(f"PiWave initialized - Frequency: {frequency}MHz, PS: {ps}, Loop: {loop}")
|
|
182
131
|
|
|
@@ -316,7 +265,7 @@ class PiWave:
|
|
|
316
265
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, ValueError):
|
|
317
266
|
return 0.0
|
|
318
267
|
|
|
319
|
-
def
|
|
268
|
+
def _play_file(self, wav_file: str) -> bool:
|
|
320
269
|
if self.stop_event.is_set():
|
|
321
270
|
return False
|
|
322
271
|
|
|
@@ -325,42 +274,38 @@ class PiWave:
|
|
|
325
274
|
Log.error(f"Could not determine duration for {wav_file}")
|
|
326
275
|
return False
|
|
327
276
|
|
|
328
|
-
cmd = [
|
|
329
|
-
'sudo', self.pi_fm_rds_path,
|
|
330
|
-
'-freq', str(self.frequency),
|
|
331
|
-
'-ps', self.ps,
|
|
332
|
-
'-rt', self.rt,
|
|
333
|
-
'-pi', self.pi,
|
|
334
|
-
'-audio', wav_file
|
|
335
|
-
]
|
|
336
|
-
|
|
337
277
|
try:
|
|
278
|
+
# update settings
|
|
279
|
+
self.backend.frequency = self.frequency
|
|
280
|
+
self.backend.ps = self.ps
|
|
281
|
+
self.backend.rt = self.rt
|
|
282
|
+
self.backend.pi = self.pi
|
|
283
|
+
|
|
284
|
+
# validate frequency
|
|
285
|
+
min_freq, max_freq = self.backend.frequency_range
|
|
286
|
+
if not (min_freq <= self.frequency <= max_freq):
|
|
287
|
+
raise PiWaveError(f"Current backend '{self.backend.name}' doesn't support {self.frequency}MHz (supports {min_freq}-{max_freq}MHz). Use update() to change backend or frequency.")
|
|
288
|
+
|
|
289
|
+
|
|
338
290
|
loop_status = "looping" if self.loop else f"Duration: {duration:.1f}s"
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
stderr=subprocess.PIPE,
|
|
344
|
-
preexec_fn=os.setsid
|
|
345
|
-
)
|
|
291
|
+
rds_info = f" (PS: {self.ps})" if self.backend.supports_rds and self.ps else ""
|
|
292
|
+
Log.broadcast_message(f"Playing {wav_file} ({loop_status}) at {self.frequency}MHz{rds_info}")
|
|
293
|
+
|
|
294
|
+
self.current_process = self.backend.play_file(wav_file)
|
|
346
295
|
|
|
347
296
|
if self.on_track_change:
|
|
348
297
|
self.on_track_change(wav_file)
|
|
349
298
|
|
|
350
299
|
if self.loop:
|
|
351
|
-
# if looping is enabled, let pi_fm_rds handle the looping
|
|
352
|
-
# and just wait for stop event
|
|
353
300
|
while not self.stop_event.is_set():
|
|
354
301
|
if self.stop_event.wait(timeout=0.1):
|
|
355
302
|
self._stop_current_process()
|
|
356
303
|
return False
|
|
357
304
|
|
|
358
|
-
# check if process exists cuz why not
|
|
359
305
|
if self.current_process.poll() is not None:
|
|
360
306
|
Log.error("Process ended unexpectedly while looping")
|
|
361
307
|
return False
|
|
362
308
|
else:
|
|
363
|
-
# if not looping wait for stopevent or end of file
|
|
364
309
|
start_time = time.time()
|
|
365
310
|
while True:
|
|
366
311
|
if self.stop_event.wait(timeout=0.1):
|
|
@@ -380,6 +325,119 @@ class PiWave:
|
|
|
380
325
|
self.on_error(e)
|
|
381
326
|
self._stop_current_process()
|
|
382
327
|
return False
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _play_live(self, audio_source, sample_rate: int, channels: int, chunk_size: int) -> bool:
|
|
331
|
+
if self.is_playing or self.is_live_streaming:
|
|
332
|
+
self.stop()
|
|
333
|
+
|
|
334
|
+
if not self.backend.supports_live_streaming:
|
|
335
|
+
raise PiWaveError(
|
|
336
|
+
f"Backend '{self.backend_name}' doesn't support live streaming. Try using fm_transmitter instead.")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
min_freq, max_freq = self.backend.frequency_range
|
|
340
|
+
if not (min_freq <= self.frequency <= max_freq):
|
|
341
|
+
raise PiWaveError(
|
|
342
|
+
f"Backend '{self.backend_name}' doesn't support {self.frequency}MHz"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
self.stop_event.clear()
|
|
346
|
+
self.is_live_streaming = True
|
|
347
|
+
self.audio_queue = queue.Queue(maxsize=20)
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
cmd = self.backend.build_live_command()
|
|
351
|
+
if not cmd:
|
|
352
|
+
raise PiWaveError(f"Backend doesn't support live streaming") # since we checked before, we shouldnt get this but meh
|
|
353
|
+
|
|
354
|
+
self.current_process = subprocess.Popen(
|
|
355
|
+
cmd,
|
|
356
|
+
stdin=subprocess.PIPE,
|
|
357
|
+
stdout=subprocess.PIPE,
|
|
358
|
+
stderr=subprocess.PIPE,
|
|
359
|
+
preexec_fn=os.setsid
|
|
360
|
+
)
|
|
361
|
+
except Exception as e:
|
|
362
|
+
Log.error(f"Failed to start live stream: {e}")
|
|
363
|
+
self.is_live_streaming = False
|
|
364
|
+
if self.on_error:
|
|
365
|
+
self.on_error(e)
|
|
366
|
+
return False
|
|
367
|
+
|
|
368
|
+
self.live_thread = threading.Thread(
|
|
369
|
+
target=self._live_producer_worker,
|
|
370
|
+
args=(audio_source, chunk_size)
|
|
371
|
+
)
|
|
372
|
+
self.live_thread.daemon = True
|
|
373
|
+
self.live_thread.start()
|
|
374
|
+
|
|
375
|
+
consumer_thread = threading.Thread(target=self._live_consumer_worker)
|
|
376
|
+
consumer_thread.daemon = True
|
|
377
|
+
consumer_thread.start()
|
|
378
|
+
|
|
379
|
+
Log.broadcast_message(f"Live streaming at {self.frequency}MHz ({sample_rate}Hz, {channels}ch)")
|
|
380
|
+
return True
|
|
381
|
+
|
|
382
|
+
def _live_producer_worker(self, audio_source, chunk_size: int):
|
|
383
|
+
# producer: reads from audio source, puts in queue; consumer will play it
|
|
384
|
+
try:
|
|
385
|
+
if hasattr(audio_source, '__iter__') and not isinstance(audio_source, (str, bytes)):
|
|
386
|
+
for chunk in audio_source:
|
|
387
|
+
if self.stop_event.is_set():
|
|
388
|
+
break
|
|
389
|
+
if chunk:
|
|
390
|
+
self.audio_queue.put(chunk, timeout=1)
|
|
391
|
+
|
|
392
|
+
elif callable(audio_source):
|
|
393
|
+
while not self.stop_event.is_set():
|
|
394
|
+
chunk = audio_source()
|
|
395
|
+
if not chunk:
|
|
396
|
+
break
|
|
397
|
+
self.audio_queue.put(chunk, timeout=1)
|
|
398
|
+
|
|
399
|
+
elif hasattr(audio_source, 'read'):
|
|
400
|
+
while not self.stop_event.is_set():
|
|
401
|
+
chunk = audio_source.read(chunk_size)
|
|
402
|
+
if not chunk:
|
|
403
|
+
break
|
|
404
|
+
self.audio_queue.put(chunk, timeout=1)
|
|
405
|
+
|
|
406
|
+
except Exception as e:
|
|
407
|
+
Log.error(f"Producer error: {e}")
|
|
408
|
+
if self.on_error:
|
|
409
|
+
self.on_error(e)
|
|
410
|
+
finally:
|
|
411
|
+
self.audio_queue.put(None)
|
|
412
|
+
|
|
413
|
+
def _live_consumer_worker(self):
|
|
414
|
+
# consumer reads from queue and puts in process
|
|
415
|
+
try:
|
|
416
|
+
while not self.stop_event.is_set():
|
|
417
|
+
try:
|
|
418
|
+
chunk = self.audio_queue.get(timeout=0.1)
|
|
419
|
+
if chunk is None:
|
|
420
|
+
break
|
|
421
|
+
|
|
422
|
+
if self.current_process and self.current_process.stdin:
|
|
423
|
+
self.current_process.stdin.write(chunk)
|
|
424
|
+
self.current_process.stdin.flush()
|
|
425
|
+
|
|
426
|
+
except queue.Empty:
|
|
427
|
+
continue
|
|
428
|
+
except BrokenPipeError:
|
|
429
|
+
Log.error(f"Stream process terminated: make sure that the stream you provided compiles with {self.backend_name} stdin support.")
|
|
430
|
+
break
|
|
431
|
+
except Exception as e:
|
|
432
|
+
Log.error(f"Write error: {e}")
|
|
433
|
+
break
|
|
434
|
+
finally:
|
|
435
|
+
if self.current_process and self.current_process.stdin:
|
|
436
|
+
try:
|
|
437
|
+
self.current_process.stdin.close()
|
|
438
|
+
except:
|
|
439
|
+
pass
|
|
440
|
+
self.is_live_streaming = False
|
|
383
441
|
|
|
384
442
|
|
|
385
443
|
def _stop_current_process(self):
|
|
@@ -430,38 +488,28 @@ class PiWave:
|
|
|
430
488
|
self.stop()
|
|
431
489
|
os._exit(0)
|
|
432
490
|
|
|
433
|
-
def play(self,
|
|
434
|
-
"""
|
|
435
|
-
|
|
436
|
-
:param file_path: Path to local audio file
|
|
437
|
-
:type file_path: str
|
|
438
|
-
:return: True if playback started successfully, False otherwise
|
|
439
|
-
:rtype: bool
|
|
491
|
+
def play(self, source, sample_rate: int = 44100, channels: int = 2, chunk_size: int = 4096):
|
|
492
|
+
"""Play audio from file or live source.
|
|
440
493
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
494
|
+
:param source: Either a file path (str) or live audio source (generator/callable/file-like)
|
|
495
|
+
:param sample_rate: Sample rate for live audio (ignored for files)
|
|
496
|
+
:param channels: Channels for live audio (ignored for files)
|
|
497
|
+
:param chunk_size: Chunk size for live audio (ignored for files)
|
|
498
|
+
:return: True if playback/streaming started successfully
|
|
499
|
+
:rtype: bool
|
|
445
500
|
|
|
446
501
|
Example:
|
|
447
|
-
>>> pw.play('song.mp3')
|
|
448
|
-
>>> pw.play(
|
|
502
|
+
>>> pw.play('song.mp3') # File playback
|
|
503
|
+
>>> pw.play(mic_generator()) # Live streaming
|
|
449
504
|
"""
|
|
450
|
-
|
|
451
|
-
if
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
self.playback_thread = threading.Thread(target=self._playback_worker)
|
|
460
|
-
self.playback_thread.daemon = True
|
|
461
|
-
self.playback_thread.start()
|
|
462
|
-
|
|
463
|
-
Log.success("Playback started")
|
|
464
|
-
return True
|
|
505
|
+
|
|
506
|
+
# autodetect if source is live or file
|
|
507
|
+
if isinstance(source, str):
|
|
508
|
+
# file (string)
|
|
509
|
+
return self._play_file(source)
|
|
510
|
+
else:
|
|
511
|
+
# live
|
|
512
|
+
return self._play_live(source, sample_rate, channels, chunk_size)
|
|
465
513
|
|
|
466
514
|
def stop(self):
|
|
467
515
|
"""Stop all playback and streaming.
|
|
@@ -472,14 +520,21 @@ class PiWave:
|
|
|
472
520
|
Example:
|
|
473
521
|
>>> pw.stop()
|
|
474
522
|
"""
|
|
475
|
-
if not self.is_playing:
|
|
523
|
+
if not self.is_playing and not self.is_live_streaming:
|
|
476
524
|
return
|
|
477
525
|
|
|
478
|
-
Log.warning("Stopping
|
|
479
|
-
|
|
526
|
+
Log.warning("Stopping...")
|
|
527
|
+
|
|
480
528
|
self.is_stopped = True
|
|
481
529
|
self.stop_event.set()
|
|
482
|
-
|
|
530
|
+
|
|
531
|
+
if self.audio_queue:
|
|
532
|
+
while not self.audio_queue.empty():
|
|
533
|
+
try:
|
|
534
|
+
self.audio_queue.get_nowait()
|
|
535
|
+
except queue.Empty:
|
|
536
|
+
break
|
|
537
|
+
|
|
483
538
|
if self.current_process:
|
|
484
539
|
try:
|
|
485
540
|
os.killpg(os.getpgid(self.current_process.pid), signal.SIGTERM)
|
|
@@ -488,12 +543,15 @@ class PiWave:
|
|
|
488
543
|
pass
|
|
489
544
|
finally:
|
|
490
545
|
self.current_process = None
|
|
491
|
-
|
|
546
|
+
|
|
492
547
|
if self.playback_thread and self.playback_thread.is_alive():
|
|
493
548
|
self.playback_thread.join(timeout=5)
|
|
494
|
-
|
|
549
|
+
if self.live_thread and self.live_thread.is_alive():
|
|
550
|
+
self.live_thread.join(timeout=3)
|
|
551
|
+
|
|
495
552
|
self.is_playing = False
|
|
496
|
-
|
|
553
|
+
self.is_live_streaming = False
|
|
554
|
+
Log.success("Stopped")
|
|
497
555
|
|
|
498
556
|
def pause(self):
|
|
499
557
|
"""Pause the current playback.
|
|
@@ -520,15 +578,17 @@ class PiWave:
|
|
|
520
578
|
self.play(self.current_file)
|
|
521
579
|
|
|
522
580
|
def update(self,
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
581
|
+
frequency: Optional[float] = None,
|
|
582
|
+
ps: Optional[str] = None,
|
|
583
|
+
rt: Optional[str] = None,
|
|
584
|
+
pi: Optional[str] = None,
|
|
585
|
+
debug: Optional[bool] = None,
|
|
586
|
+
silent: Optional[bool] = None,
|
|
587
|
+
loop: Optional[bool] = None,
|
|
588
|
+
backend: Optional[str] = None,
|
|
589
|
+
used_for: Optional[str] = None,
|
|
590
|
+
on_track_change: Optional[Callable] = None,
|
|
591
|
+
on_error: Optional[Callable] = None):
|
|
532
592
|
"""Update PiWave settings.
|
|
533
593
|
|
|
534
594
|
:param frequency: FM frequency to broadcast on (80.0-108.0 MHz)
|
|
@@ -545,6 +605,10 @@ class PiWave:
|
|
|
545
605
|
:type silent: Optional[bool]
|
|
546
606
|
:param loop: Loop the current track continuously
|
|
547
607
|
:type loop: Optional[bool]
|
|
608
|
+
:param backend: Backend used to broadcast
|
|
609
|
+
:type backend: Optional[str]
|
|
610
|
+
:param backend: Give the main use for the current instance, will be used if backend: auto. Supports `file_broadcast` and `live_broadcast`
|
|
611
|
+
:type backend: Optional[str]
|
|
548
612
|
:param on_track_change: Callback function called when track changes
|
|
549
613
|
:type on_track_change: Optional[Callable]
|
|
550
614
|
:param on_error: Callback function called when an error occurs
|
|
@@ -558,6 +622,38 @@ class PiWave:
|
|
|
558
622
|
>>> pw.update(rt="Updated radio text", debug=True, loop=True)
|
|
559
623
|
"""
|
|
560
624
|
updated_settings = []
|
|
625
|
+
|
|
626
|
+
freq_to_use = frequency if frequency is not None else self.frequency
|
|
627
|
+
|
|
628
|
+
if used_for is not None:
|
|
629
|
+
self.backend_use = used_for
|
|
630
|
+
|
|
631
|
+
if backend is not None:
|
|
632
|
+
if backend == "auto":
|
|
633
|
+
backend_name = get_best_backend(self.backend_use, freq_to_use)
|
|
634
|
+
if not backend_name:
|
|
635
|
+
available = list(backends.keys())
|
|
636
|
+
raise PiWaveError(f"No suitable backend found for {freq_to_use}MHz. Available: {available}")
|
|
637
|
+
else:
|
|
638
|
+
if backend not in backends:
|
|
639
|
+
available = list(backends.keys())
|
|
640
|
+
raise PiWaveError(f"Backend '{backend}' not available. Available: {available}")
|
|
641
|
+
backend_name = backend
|
|
642
|
+
|
|
643
|
+
backend_instance = backends[backend_name](
|
|
644
|
+
frequency=freq_to_use,
|
|
645
|
+
ps=ps or self.ps,
|
|
646
|
+
rt=rt or self.rt,
|
|
647
|
+
pi=pi or self.pi
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
min_freq, max_freq = backend_instance.frequency_range
|
|
651
|
+
if not (min_freq <= freq_to_use <= max_freq):
|
|
652
|
+
raise PiWaveError(f"Backend '{backend_name}' doesn't support {freq_to_use}MHz (supports {min_freq}-{max_freq}MHz)")
|
|
653
|
+
|
|
654
|
+
self.backend_name = backend_name
|
|
655
|
+
self.backend = backend_instance
|
|
656
|
+
updated_settings.append(f"backend: {backend_name}")
|
|
561
657
|
|
|
562
658
|
if frequency is not None:
|
|
563
659
|
self.frequency = frequency
|
|
@@ -641,8 +737,13 @@ class PiWave:
|
|
|
641
737
|
The returned dictionary contains:
|
|
642
738
|
|
|
643
739
|
- **is_playing** (bool): Whether playback is active
|
|
740
|
+
- **is_live_streaming** (bool): Whether live playback is active
|
|
644
741
|
- **frequency** (float): Current broadcast frequency
|
|
645
742
|
- **current_file** (str|None): Path of currently playing file
|
|
743
|
+
- **current_backend** (str): Currently used backend
|
|
744
|
+
- **backend_frequency_range** (str): Frequency range supported by the backend
|
|
745
|
+
- **backend_supports_rds** (bool): Backend support of Radio Data System
|
|
746
|
+
- **avalible_backends** (list): List of avalible backends
|
|
646
747
|
- **ps** (str): Program Service name
|
|
647
748
|
- **rt** (str): Radio Text message
|
|
648
749
|
- **pi** (str): Program Identification code
|
|
@@ -656,8 +757,13 @@ class PiWave:
|
|
|
656
757
|
"""
|
|
657
758
|
return {
|
|
658
759
|
'is_playing': self.is_playing,
|
|
760
|
+
'is_live_streaming': self.is_live_streaming,
|
|
659
761
|
'frequency': self.frequency,
|
|
660
762
|
'current_file': self.current_file,
|
|
763
|
+
'current_backend': self.backend_name,
|
|
764
|
+
'backend_frequency_range': f"{self.backend.frequency_range[0]}-{self.backend.frequency_range[1]}MHz",
|
|
765
|
+
'backend_supports_rds': self.backend.supports_rds,
|
|
766
|
+
'available_backends': list(backends.keys()),
|
|
661
767
|
'ps': self.ps,
|
|
662
768
|
'rt': self.rt,
|
|
663
769
|
'pi': self.pi,
|