piwave 2.0.3__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 ADDED
@@ -0,0 +1,3 @@
1
+ # piwave/__init__.py
2
+
3
+ from .piwave import PiWave
piwave/piwave.py ADDED
@@ -0,0 +1,534 @@
1
+ # piwave/piwave.py
2
+ # pi_fm_rds is required !!! Check https://github.com/ChristopheJacquet/PiFmRds
3
+
4
+ import os
5
+ import subprocess
6
+ import signal
7
+ import threading
8
+ import time
9
+ import asyncio
10
+ import tempfile
11
+ import shutil
12
+ import sys
13
+ from typing import List, Optional, Callable
14
+ from pathlib import Path
15
+ from urllib.parse import urlparse
16
+
17
+ class Log:
18
+ COLORS = { # absolutely not taken from stackoverflow trust
19
+ 'reset': '\033[0m',
20
+ 'bold': '\033[1m',
21
+ 'underline': '\033[4m',
22
+ 'red': '\033[31m',
23
+ 'green': '\033[32m',
24
+ 'yellow': '\033[33m',
25
+ 'blue': '\033[34m',
26
+ 'magenta': '\033[35m',
27
+ 'cyan': '\033[36m',
28
+ 'white': '\033[37m',
29
+ 'bright_red': '\033[91m',
30
+ 'bright_green': '\033[92m',
31
+ 'bright_yellow': '\033[93m',
32
+ 'bright_blue': '\033[94m',
33
+ 'bright_magenta': '\033[95m',
34
+ 'bright_cyan': '\033[96m',
35
+ 'bright_white': '\033[97m',
36
+ }
37
+
38
+ ICONS = {
39
+ 'success': 'OK',
40
+ 'error': 'ERR',
41
+ 'warning': 'WARN',
42
+ 'info': 'INFO',
43
+ 'client': 'CLIENT',
44
+ 'server': 'SERVER',
45
+ 'file': 'FILE',
46
+ 'broadcast': 'BCAST',
47
+ 'version': 'VER',
48
+ 'update': 'UPD',
49
+ }
50
+
51
+ @classmethod
52
+ def print(cls, message: str, style: str = '', icon: str = '', end: str = '\n'):
53
+ color = cls.COLORS.get(style, '')
54
+ icon_char = cls.ICONS.get(icon, '')
55
+ if icon_char:
56
+ if color:
57
+ print(f"{color}[{icon_char}]\033[0m {message}", end=end)
58
+ else:
59
+ print(f"[{icon_char}] {message}", end=end)
60
+ else:
61
+ if color:
62
+ print(f"{color}{message}\033[0m", end=end)
63
+ else:
64
+ print(f"{message}", end=end)
65
+ sys.stdout.flush()
66
+
67
+ @classmethod
68
+ def header(cls, text: str):
69
+ cls.print(text, 'bright_blue', end='\n\n')
70
+ sys.stdout.flush()
71
+
72
+ @classmethod
73
+ def section(cls, text: str):
74
+ cls.print(f" {text} ", 'bright_blue', end='')
75
+ cls.print("─" * (len(text) + 2), 'blue', end='\n\n')
76
+ sys.stdout.flush()
77
+
78
+ @classmethod
79
+ def success(cls, message: str):
80
+ cls.print(message, 'bright_green', 'success')
81
+
82
+ @classmethod
83
+ def error(cls, message: str):
84
+ cls.print(message, 'bright_red', 'error')
85
+
86
+ @classmethod
87
+ def warning(cls, message: str):
88
+ cls.print(message, 'bright_yellow', 'warning')
89
+
90
+ @classmethod
91
+ def info(cls, message: str):
92
+ cls.print(message, 'bright_cyan', 'info')
93
+
94
+ @classmethod
95
+ def file_message(cls, message: str):
96
+ cls.print(message, 'yellow', 'file')
97
+
98
+ @classmethod
99
+ def broadcast_message(cls, message: str):
100
+ cls.print(message, 'bright_magenta', 'broadcast')
101
+
102
+ class PiWaveError(Exception):
103
+ pass
104
+
105
+ class PiWave:
106
+ def __init__(self,
107
+ frequency: float = 90.0,
108
+ ps: str = "PiWave",
109
+ rt: str = "PiWave: The best python module for managing your pi radio",
110
+ pi: str = "FFFF",
111
+ loop: bool = False,
112
+ debug: bool = False,
113
+ on_track_change: Optional[Callable] = None,
114
+ on_error: Optional[Callable] = None):
115
+
116
+ self.debug = debug
117
+ self.frequency = frequency
118
+ self.ps = str(ps)[:8]
119
+ self.rt = str(rt)[:64]
120
+ self.pi = str(pi).upper()[:4]
121
+ self.loop = loop
122
+ self.on_track_change = on_track_change
123
+ self.on_error = on_error
124
+
125
+ self.playlist: List[str] = []
126
+ self.converted_files: dict[str, str] = {}
127
+ self.current_index = 0
128
+ self.is_playing = False
129
+ self.is_stopped = False
130
+
131
+ self.current_process: Optional[subprocess.Popen] = None
132
+ self.playback_thread: Optional[threading.Thread] = None
133
+ self.stop_event = threading.Event()
134
+
135
+ self.temp_dir = tempfile.mkdtemp(prefix="piwave_")
136
+ self.stream_process: Optional[subprocess.Popen] = None
137
+
138
+ self.pi_fm_rds_path = self._find_pi_fm_rds_path()
139
+
140
+ self._validate_environment()
141
+
142
+ signal.signal(signal.SIGINT, self._handle_interrupt)
143
+ signal.signal(signal.SIGTERM, self._handle_interrupt)
144
+
145
+ Log.info(f"PiWave initialized - Frequency: {frequency}MHz, PS: {ps}, Loop: {loop}")
146
+
147
+ def _log_debug(self, message: str):
148
+ if self.debug:
149
+ Log.print(f"[DEBUG] {message}", 'blue')
150
+
151
+
152
+ def _validate_environment(self):
153
+
154
+ #validate that we're running on a Raspberry Pi as root
155
+
156
+ if not self._is_raspberry_pi():
157
+ raise PiWaveError("This program must be run on a Raspberry Pi")
158
+
159
+ if not self._is_root():
160
+ raise PiWaveError("This program must be run as root")
161
+
162
+ def _is_raspberry_pi(self) -> bool:
163
+ try:
164
+ with open("/sys/firmware/devicetree/base/model", "r") as f:
165
+ model = f.read().strip()
166
+ return "Raspberry Pi" in model
167
+ except FileNotFoundError:
168
+ return False
169
+
170
+ def _is_root(self) -> bool:
171
+ return os.geteuid() == 0
172
+
173
+ def _find_pi_fm_rds_path(self) -> str:
174
+ current_dir = Path(__file__).parent
175
+ cache_file = current_dir / "pi_fm_rds_path"
176
+
177
+ if cache_file.exists():
178
+ try:
179
+ cached_path = cache_file.read_text().strip()
180
+ if self._is_valid_executable(cached_path):
181
+ return cached_path
182
+ else:
183
+ cache_file.unlink()
184
+ except Exception as e:
185
+ Log.warning(f"Error reading cache file: {e}")
186
+ cache_file.unlink(missing_ok=True)
187
+
188
+ search_paths = ["/opt", "/usr/local/bin", "/usr/bin", "/bin", "/home"]
189
+
190
+ for search_path in search_paths:
191
+ if not Path(search_path).exists():
192
+ continue
193
+
194
+ try:
195
+ for root, dirs, files in os.walk(search_path):
196
+ if "pi_fm_rds" in files:
197
+ executable_path = Path(root) / "pi_fm_rds"
198
+ if self._is_valid_executable(str(executable_path)):
199
+ cache_file.write_text(str(executable_path))
200
+ return str(executable_path)
201
+ except (PermissionError, OSError):
202
+ continue
203
+
204
+ print("Could not automatically find `pi_fm_rds`. Please enter the full path manually.")
205
+ user_path = input("Enter the path to `pi_fm_rds`: ").strip()
206
+
207
+ if self._is_valid_executable(user_path):
208
+ cache_file.write_text(user_path)
209
+ return user_path
210
+
211
+ raise PiWaveError("Invalid pi_fm_rds path provided")
212
+
213
+ def _is_valid_executable(self, path: str) -> bool:
214
+ try:
215
+ result = subprocess.run(
216
+ [path, "--help"],
217
+ stdout=subprocess.PIPE,
218
+ stderr=subprocess.PIPE,
219
+ timeout=5
220
+ )
221
+ return result.returncode == 0
222
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
223
+ return False
224
+
225
+ def _is_url(self, path: str) -> bool:
226
+ parsed = urlparse(path)
227
+ return parsed.scheme in ('http', 'https', 'ftp')
228
+
229
+ def _is_wav_file(self, filepath: str) -> bool:
230
+ return filepath.lower().endswith('.wav')
231
+
232
+ async def _download_stream_chunk(self, url: str, output_file: str, duration: int = 30) -> bool:
233
+ #Download a chunk of stream for specified duration
234
+ try:
235
+ cmd = [
236
+ 'ffmpeg', '-i', url, '-t', str(duration),
237
+ '-acodec', 'pcm_s16le', '-ar', '44100', '-ac', '2',
238
+ '-y', output_file
239
+ ]
240
+
241
+ process = await asyncio.create_subprocess_exec(
242
+ *cmd,
243
+ stdout=asyncio.subprocess.PIPE,
244
+ stderr=asyncio.subprocess.PIPE
245
+ )
246
+
247
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=duration + 10)
248
+ return process.returncode == 0
249
+
250
+ except asyncio.TimeoutError:
251
+ Log.error(f"Timeout downloading stream chunk from {url}")
252
+ return False
253
+ except Exception as e:
254
+ Log.error(f"Error downloading stream: {e}")
255
+ return False
256
+
257
+ def _convert_to_wav(self, filepath: str) -> Optional[str]:
258
+ if filepath in self.converted_files:
259
+ return self.converted_files[filepath]
260
+
261
+ if self._is_wav_file(filepath) and not self._is_url(filepath):
262
+ self.converted_files[filepath] = filepath
263
+ return filepath
264
+
265
+ Log.file_message(f"Converting {filepath} to WAV")
266
+
267
+ if self._is_url(filepath):
268
+ output_file = os.path.join(self.temp_dir, f"stream_{int(time.time())}.wav")
269
+ else:
270
+ output_file = f"{os.path.splitext(filepath)[0]}_converted.wav"
271
+
272
+ cmd = [
273
+ 'ffmpeg', '-i', filepath, '-acodec', 'pcm_s16le',
274
+ '-ar', '44100', '-ac', '2', '-y', output_file
275
+ ]
276
+
277
+ try:
278
+ result = subprocess.run(
279
+ cmd,
280
+ stdout=subprocess.PIPE,
281
+ stderr=subprocess.PIPE,
282
+ timeout=60, # 60 seconds timeout
283
+ check=True
284
+ )
285
+
286
+ self._log_debug(f"FFmpeg conversion successful for {filepath}")
287
+
288
+ self.converted_files[filepath] = output_file
289
+ return output_file
290
+
291
+ except subprocess.TimeoutExpired:
292
+ Log.error(f"Conversion timeout for {filepath}")
293
+ return None
294
+ except subprocess.CalledProcessError as e:
295
+ Log.error(f"Conversion failed for {filepath}: {e.stderr.decode()}")
296
+ return None
297
+ except Exception as e:
298
+ Log.error(f"Unexpected error converting {filepath}: {e}")
299
+ return None
300
+
301
+ def _get_file_duration(self, wav_file: str) -> float:
302
+ cmd = [
303
+ 'ffprobe', '-i', wav_file, '-show_entries', 'format=duration',
304
+ '-v', 'quiet', '-of', 'csv=p=0'
305
+ ]
306
+
307
+ try:
308
+ result = subprocess.run(
309
+ cmd,
310
+ stdout=subprocess.PIPE,
311
+ stderr=subprocess.PIPE,
312
+ timeout=10,
313
+ check=True
314
+ )
315
+ return float(result.stdout.decode().strip())
316
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, ValueError):
317
+ return 0.0
318
+
319
+ def _play_wav(self, wav_file: str) -> bool:
320
+ if self.stop_event.is_set():
321
+ return False
322
+
323
+ duration = self._get_file_duration(wav_file)
324
+ if duration <= 0:
325
+ Log.error(f"Could not determine duration for {wav_file}")
326
+ return False
327
+
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
+ try:
338
+ Log.broadcast_message(f"Playing {wav_file} (Duration: {duration:.1f}s) at {self.frequency}MHz")
339
+ self.current_process = subprocess.Popen(
340
+ cmd,
341
+ stdout=subprocess.PIPE,
342
+ stderr=subprocess.PIPE,
343
+ preexec_fn=os.setsid
344
+ )
345
+
346
+ if self.on_track_change:
347
+ self.on_track_change(wav_file, self.current_index)
348
+
349
+ # wait for either
350
+ # The duration to elapse (then kill the process), or
351
+ # stop_event to be set (user requested stop)
352
+ start_time = time.time()
353
+ while True:
354
+ if self.stop_event.wait(timeout=0.1):
355
+ self._stop_current_process()
356
+ return False
357
+
358
+ elapsed = time.time() - start_time
359
+ if elapsed >= duration:
360
+ self._stop_current_process()
361
+ break
362
+
363
+ return True
364
+
365
+ except Exception as e:
366
+ Log.error(f"Error playing {wav_file}: {e}")
367
+ if self.on_error:
368
+ self.on_error(e)
369
+ self._stop_current_process()
370
+ return False
371
+
372
+
373
+ def _stop_current_process(self):
374
+ if self.current_process:
375
+ try:
376
+ Log.info("Stopping current process...")
377
+ os.killpg(os.getpgid(self.current_process.pid), signal.SIGTERM)
378
+ self.current_process.wait(timeout=5)
379
+ except (ProcessLookupError, subprocess.TimeoutExpired):
380
+ Log.warning("Forcing kill of current process")
381
+ try:
382
+ os.killpg(os.getpgid(self.current_process.pid), signal.SIGKILL)
383
+ except ProcessLookupError:
384
+ pass
385
+ finally:
386
+ self.current_process = None
387
+
388
+
389
+ def _playback_worker(self):
390
+ self._log_debug("Playback worker started")
391
+
392
+ while not self.stop_event.is_set() and not self.is_stopped:
393
+ if self.current_index >= len(self.playlist):
394
+ if self.loop:
395
+ self.current_index = 0
396
+ continue
397
+ else:
398
+ break
399
+
400
+ if self.current_index < len(self.playlist):
401
+ wav_file = self.playlist[self.current_index]
402
+
403
+ if not os.path.exists(wav_file):
404
+ Log.error(f"File not found: {wav_file}")
405
+ self.current_index += 1
406
+ continue
407
+
408
+ if not self._play_wav(wav_file):
409
+ if not self.stop_event.is_set():
410
+ Log.error(f"Playback failed for {wav_file}")
411
+ break
412
+
413
+ self.current_index += 1
414
+
415
+ self.is_playing = False
416
+ self._log_debug("Playback worker finished")
417
+
418
+
419
+ def _handle_interrupt(self, signum, frame):
420
+ Log.warning("Interrupt received, stopping playback...")
421
+ self.stop()
422
+ os._exit(0)
423
+
424
+ def add_files(self, files: List[str]) -> bool:
425
+ converted_files = []
426
+
427
+ for file_path in files:
428
+ if self._is_url(file_path):
429
+ converted_files.append(file_path)
430
+ else:
431
+ wav_file = self._convert_to_wav(file_path)
432
+ if wav_file:
433
+ converted_files.append(wav_file)
434
+ else:
435
+ Log.warning(f"Failed to convert {file_path}")
436
+
437
+ if converted_files:
438
+ self.playlist.extend(converted_files)
439
+ Log.success(f"Added {len(converted_files)} files to playlist")
440
+ return True
441
+
442
+ return False
443
+
444
+ def play(self, files: Optional[List[str]] = None) -> bool:
445
+ if files:
446
+ self.playlist.clear()
447
+ self.current_index = 0
448
+ if not self.add_files(files):
449
+ return False
450
+
451
+ if not self.playlist:
452
+ Log.warning("No files in playlist")
453
+ return False
454
+
455
+ if self.is_playing:
456
+ self.stop()
457
+
458
+ self.stop_event.clear()
459
+ self.is_stopped = False
460
+ self.is_playing = True
461
+
462
+ self.playback_thread = threading.Thread(target=self._playback_worker)
463
+ self.playback_thread.daemon = True
464
+ self.playback_thread.start()
465
+
466
+ Log.success("Playback started")
467
+ return True
468
+
469
+ def stop(self):
470
+ if not self.is_playing:
471
+ return
472
+
473
+ Log.warning("Stopping playback")
474
+ self.is_stopped = True
475
+ self.stop_event.set()
476
+
477
+ self._stop_current_process()
478
+
479
+ if self.playback_thread and self.playback_thread.is_alive():
480
+ self.playback_thread.join(timeout=5)
481
+
482
+ self.is_playing = False
483
+ Log.success("Playback stopped")
484
+
485
+ def pause(self):
486
+ if self.is_playing:
487
+ self._stop_current_process()
488
+ Log.info("Playback paused")
489
+
490
+ def resume(self):
491
+ if not self.is_playing and self.playlist:
492
+ self.play()
493
+
494
+ def next_track(self):
495
+ if self.is_playing:
496
+ self._stop_current_process()
497
+ self.current_index += 1
498
+
499
+ def previous_track(self):
500
+ if self.is_playing:
501
+ self._stop_current_process()
502
+ self.current_index = max(0, self.current_index - 1)
503
+
504
+ def set_frequency(self, frequency: float):
505
+ self.frequency = frequency
506
+ Log.broadcast_message(f"Frequency changed to {frequency}MHz. Will update on next file's broadcast.")
507
+
508
+ def get_status(self) -> dict:
509
+ return {
510
+ 'is_playing': self.is_playing,
511
+ 'current_index': self.current_index,
512
+ 'playlist_length': len(self.playlist),
513
+ 'frequency': self.frequency,
514
+ 'current_file': self.playlist[self.current_index] if self.current_index < len(self.playlist) else None
515
+ }
516
+
517
+ def cleanup(self):
518
+ self.stop()
519
+
520
+ if os.path.exists(self.temp_dir):
521
+ shutil.rmtree(self.temp_dir, ignore_errors=True)
522
+
523
+ Log.info("Cleanup completed")
524
+
525
+ def __del__(self):
526
+ self.cleanup()
527
+
528
+ def send(self, files: List[str]):
529
+ return self.play(files)
530
+
531
+ def restart(self):
532
+ if self.playlist:
533
+ self.current_index = 0
534
+ self.play()