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/piwave.py CHANGED
@@ -1,5 +1,6 @@
1
- # piwave/pw.py
2
- # pi_fm_rds is required !!! Check https://github.com/ChristopheJacquet/PiFmRds
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 sys
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
- class Log:
19
- COLORS = { # absolutely not taken from stackoverflow trust
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
- frequency: float = 90.0,
118
- ps: str = "PiWave",
119
- rt: str = "PiWave: The best python module for managing your pi radio",
120
- pi: str = "FFFF",
121
- debug: bool = False,
122
- silent: bool = False,
123
- loop: bool = False,
124
- on_track_change: Optional[Callable] = None,
125
- on_error: Optional[Callable] = None):
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
- Log.config(silent=silent)
173
-
174
- self.pi_fm_rds_path = self._find_pi_fm_rds_path()
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 _play_wav(self, wav_file: str) -> bool:
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
- Log.broadcast_message(f"Playing {wav_file} ({loop_status}) at {self.frequency}MHz")
340
- self.current_process = subprocess.Popen(
341
- cmd,
342
- stdout=subprocess.PIPE,
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, file_path: str) -> bool:
434
- """Start playing the specified audio file.
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
- .. note::
442
- Files are automatically converted to WAV format if needed.
443
- Only local files are supported. If loop is enabled, the file will
444
- repeat continuously until stop() is called.
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('audio.wav')
502
+ >>> pw.play('song.mp3') # File playback
503
+ >>> pw.play(mic_generator()) # Live streaming
449
504
  """
450
-
451
- if self.is_playing:
452
- self.stop()
453
-
454
- self.current_file = file_path
455
- self.stop_event.clear()
456
- self.is_stopped = False
457
- self.is_playing = True
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 playback...")
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
- Log.success("Playback stopped")
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
- frequency: Optional[float] = None,
524
- ps: Optional[str] = None,
525
- rt: Optional[str] = None,
526
- pi: Optional[str] = None,
527
- debug: Optional[bool] = None,
528
- silent: Optional[bool] = None,
529
- loop: Optional[bool] = None,
530
- on_track_change: Optional[Callable] = None,
531
- on_error: Optional[Callable] = None):
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,