TonieToolbox 0.6.1__py3-none-any.whl → 0.6.4__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.
- TonieToolbox/__init__.py +1 -1
- TonieToolbox/__main__.py +218 -21
- TonieToolbox/audio_conversion.py +77 -0
- TonieToolbox/dependency_manager.py +60 -0
- TonieToolbox/integration.py +37 -1
- TonieToolbox/integration_kde.py +677 -0
- TonieToolbox/integration_windows.py +4 -2
- TonieToolbox/media_tags.py +95 -0
- TonieToolbox/player.py +638 -0
- TonieToolbox/recursive_processor.py +29 -1
- TonieToolbox/teddycloud.py +79 -4
- TonieToolbox/tonie_analysis.py +235 -1
- {tonietoolbox-0.6.1.dist-info → tonietoolbox-0.6.4.dist-info}/METADATA +7 -1
- tonietoolbox-0.6.4.dist-info/RECORD +32 -0
- {tonietoolbox-0.6.1.dist-info → tonietoolbox-0.6.4.dist-info}/WHEEL +1 -1
- tonietoolbox-0.6.1.dist-info/RECORD +0 -30
- {tonietoolbox-0.6.1.dist-info → tonietoolbox-0.6.4.dist-info}/entry_points.txt +0 -0
- {tonietoolbox-0.6.1.dist-info → tonietoolbox-0.6.4.dist-info}/licenses/LICENSE.md +0 -0
- {tonietoolbox-0.6.1.dist-info → tonietoolbox-0.6.4.dist-info}/top_level.txt +0 -0
TonieToolbox/player.py
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
TAF Player Module for TonieToolbox
|
|
4
|
+
|
|
5
|
+
This module provides a simple audio player for TAF (Tonie Audio Format) files.
|
|
6
|
+
It uses the existing TAF parsing functionality and FFmpeg for cross-platform audio playback.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
import subprocess
|
|
15
|
+
from typing import Optional, Dict, Any, List
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .logger import get_logger
|
|
19
|
+
from .tonie_analysis import get_header_info_cli, get_audio_info
|
|
20
|
+
from .dependency_manager import ensure_dependency, get_ffplay_binary
|
|
21
|
+
from .constants import SAMPLE_RATE_KHZ
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TAFPlayerError(Exception):
|
|
28
|
+
"""Custom exception for TAF player errors."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TAFPlayer:
|
|
33
|
+
"""
|
|
34
|
+
A simple TAF file player using FFmpeg for audio playback.
|
|
35
|
+
|
|
36
|
+
This player can load TAF files, extract audio data, and play it using FFmpeg.
|
|
37
|
+
It supports basic playback controls like play, pause, resume, stop, and seek.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self):
|
|
41
|
+
"""Initialize the TAF player."""
|
|
42
|
+
self.taf_file: Optional[Path] = None
|
|
43
|
+
self.taf_info: Optional[Dict[str, Any]] = None
|
|
44
|
+
self.temp_audio_file: Optional[Path] = None
|
|
45
|
+
self.ffmpeg_process: Optional[subprocess.Popen] = None
|
|
46
|
+
self.is_playing: bool = False
|
|
47
|
+
self.is_paused: bool = False
|
|
48
|
+
self.current_position: float = 0.0
|
|
49
|
+
self.total_duration: float = 0.0
|
|
50
|
+
self.playback_thread: Optional[threading.Thread] = None
|
|
51
|
+
self._stop_event = threading.Event()
|
|
52
|
+
self.header_size: int = 0 # Dynamic header size from TAF parsing
|
|
53
|
+
|
|
54
|
+
# Ensure FFmpeg is available
|
|
55
|
+
try:
|
|
56
|
+
ensure_dependency('ffmpeg')
|
|
57
|
+
except Exception as e:
|
|
58
|
+
raise TAFPlayerError(f"Failed to ensure FFmpeg availability: {e}")
|
|
59
|
+
|
|
60
|
+
def load(self, taf_file_path: str) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Load a TAF file for playback.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
taf_file_path: Path to the TAF file to load
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
TAFPlayerError: If the file cannot be loaded or parsed
|
|
69
|
+
"""
|
|
70
|
+
taf_path = Path(taf_file_path)
|
|
71
|
+
|
|
72
|
+
if not taf_path.exists():
|
|
73
|
+
raise TAFPlayerError(f"TAF file not found: {taf_file_path}")
|
|
74
|
+
|
|
75
|
+
if not taf_path.suffix.lower() == '.taf':
|
|
76
|
+
raise TAFPlayerError(f"File is not a TAF file: {taf_file_path}")
|
|
77
|
+
|
|
78
|
+
logger.info(f"Loading TAF file: {taf_path}")
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
# Parse TAF file header and get info
|
|
82
|
+
with open(taf_path, 'rb') as taf_file:
|
|
83
|
+
# Get header information using the CLI version for better error handling
|
|
84
|
+
header_size, tonie_header, file_size, audio_size, sha1sum, \
|
|
85
|
+
opus_head_found, opus_version, channel_count, sample_rate, \
|
|
86
|
+
bitstream_serial_no, opus_comments, valid = get_header_info_cli(taf_file)
|
|
87
|
+
|
|
88
|
+
if not valid:
|
|
89
|
+
raise TAFPlayerError("Invalid or corrupted TAF file")
|
|
90
|
+
|
|
91
|
+
# Get audio information including total duration
|
|
92
|
+
page_count, alignment_okay, page_size_okay, total_time, \
|
|
93
|
+
chapter_times = get_audio_info(taf_file, sample_rate, tonie_header, header_size)
|
|
94
|
+
|
|
95
|
+
# Store header size for audio extraction
|
|
96
|
+
self.header_size = 4 + header_size # 4 bytes for header size + actual header
|
|
97
|
+
|
|
98
|
+
# Build structured information dictionary
|
|
99
|
+
self.taf_info = {
|
|
100
|
+
'file_size': file_size,
|
|
101
|
+
'audio_size': audio_size,
|
|
102
|
+
'sha1_hash': sha1sum.hexdigest() if sha1sum else None,
|
|
103
|
+
'sample_rate': sample_rate,
|
|
104
|
+
'channels': channel_count,
|
|
105
|
+
'bitstream_serial': bitstream_serial_no,
|
|
106
|
+
'opus_version': opus_version,
|
|
107
|
+
'page_count': page_count,
|
|
108
|
+
'total_time': total_time,
|
|
109
|
+
'opus_comments': opus_comments,
|
|
110
|
+
'chapters': []
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# Process chapter information
|
|
114
|
+
if hasattr(tonie_header, 'chapterPages') and len(tonie_header.chapterPages) > 0:
|
|
115
|
+
chapter_granules = [0] # Start with position 0
|
|
116
|
+
|
|
117
|
+
# Find granule positions for each chapter page
|
|
118
|
+
for chapter_page in tonie_header.chapterPages:
|
|
119
|
+
# For now, we'll estimate chapter positions
|
|
120
|
+
# A more accurate implementation would need to parse OGG pages
|
|
121
|
+
pass # Create chapter list with times
|
|
122
|
+
chapter_start_time = 0.0
|
|
123
|
+
for i, chapter_time in enumerate(chapter_times):
|
|
124
|
+
# Parse the formatted time string back to seconds
|
|
125
|
+
duration_seconds = self._parse_time_string(chapter_time)
|
|
126
|
+
|
|
127
|
+
# Store the current chapter with correct start position
|
|
128
|
+
self.taf_info['chapters'].append({
|
|
129
|
+
'index': i + 1,
|
|
130
|
+
'title': f'Chapter {i + 1}',
|
|
131
|
+
'duration': duration_seconds, # Store as float seconds
|
|
132
|
+
'start': chapter_start_time # Position from start of the file
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
# Update start time for the next chapter
|
|
136
|
+
chapter_start_time += duration_seconds
|
|
137
|
+
|
|
138
|
+
# Extract bitrate from opus comments if available
|
|
139
|
+
if opus_comments and 'ENCODER_OPTIONS' in opus_comments:
|
|
140
|
+
import re
|
|
141
|
+
match = re.search(r'bitrate=(\d+)', opus_comments['ENCODER_OPTIONS'])
|
|
142
|
+
if match:
|
|
143
|
+
self.taf_info['bitrate'] = int(match.group(1))
|
|
144
|
+
self.taf_file = taf_path
|
|
145
|
+
self.total_duration = total_time
|
|
146
|
+
|
|
147
|
+
logger.info(f"Successfully loaded TAF file: {taf_path.name}")
|
|
148
|
+
self._print_file_info()
|
|
149
|
+
|
|
150
|
+
except Exception as e:
|
|
151
|
+
raise TAFPlayerError(f"Failed to parse TAF file: {e}")
|
|
152
|
+
|
|
153
|
+
def _print_file_info(self) -> None:
|
|
154
|
+
"""Print information about the loaded TAF file."""
|
|
155
|
+
if not self.taf_info:
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
print("\n" + "="*50)
|
|
159
|
+
print("TAF FILE INFORMATION")
|
|
160
|
+
print("="*50)
|
|
161
|
+
|
|
162
|
+
# Basic file info
|
|
163
|
+
if 'audio_id' in self.taf_info:
|
|
164
|
+
print(f"Audio ID: {self.taf_info['audio_id']}")
|
|
165
|
+
|
|
166
|
+
if 'sha1_hash' in self.taf_info:
|
|
167
|
+
print(f"SHA1 Hash: {self.taf_info['sha1_hash']}")
|
|
168
|
+
|
|
169
|
+
# Audio properties
|
|
170
|
+
if 'sample_rate' in self.taf_info:
|
|
171
|
+
print(f"Sample Rate: {self.taf_info['sample_rate']} Hz")
|
|
172
|
+
|
|
173
|
+
if 'channels' in self.taf_info:
|
|
174
|
+
print(f"Channels: {self.taf_info['channels']}")
|
|
175
|
+
|
|
176
|
+
if 'bitrate' in self.taf_info:
|
|
177
|
+
print(f"Bitrate: {self.taf_info['bitrate']} kbps")
|
|
178
|
+
|
|
179
|
+
if self.total_duration > 0:
|
|
180
|
+
print(f"Duration: {self._format_time(self.total_duration)}")
|
|
181
|
+
|
|
182
|
+
# Chapter information
|
|
183
|
+
if 'chapters' in self.taf_info and self.taf_info['chapters']:
|
|
184
|
+
print(f"\nChapters: {len(self.taf_info['chapters'])}")
|
|
185
|
+
for i, chapter in enumerate(self.taf_info['chapters'][:5]): # Show first 5 chapters
|
|
186
|
+
start_time = self._format_time(chapter.get('start', 0))
|
|
187
|
+
title = chapter.get('title', f'Chapter {i+1}')
|
|
188
|
+
print(f" {i+1:2d}. {title} ({start_time})")
|
|
189
|
+
|
|
190
|
+
if len(self.taf_info['chapters']) > 5:
|
|
191
|
+
print(f" ... and {len(self.taf_info['chapters']) - 5} more chapters")
|
|
192
|
+
|
|
193
|
+
print("="*50 + "\n")
|
|
194
|
+
|
|
195
|
+
def _extract_audio_data(self) -> Path:
|
|
196
|
+
"""
|
|
197
|
+
Extract audio data from TAF file to a temporary file for playback.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Path to the temporary audio file
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
TAFPlayerError: If audio extraction fails
|
|
204
|
+
"""
|
|
205
|
+
if not self.taf_file:
|
|
206
|
+
raise TAFPlayerError("No TAF file loaded")
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
# Create temporary file for extracted audio
|
|
210
|
+
temp_fd, temp_path = tempfile.mkstemp(suffix='.ogg', prefix='taf_player_')
|
|
211
|
+
os.close(temp_fd)
|
|
212
|
+
temp_file = Path(temp_path)
|
|
213
|
+
logger.debug(f"Extracting audio data to: {temp_file}")
|
|
214
|
+
|
|
215
|
+
# Read TAF file and extract audio data
|
|
216
|
+
with open(self.taf_file, 'rb') as taf:
|
|
217
|
+
# Skip TAF header (use dynamic header size from parsing)
|
|
218
|
+
taf.seek(self.header_size)
|
|
219
|
+
|
|
220
|
+
# Read remaining data (OGG audio)
|
|
221
|
+
audio_data = taf.read()
|
|
222
|
+
|
|
223
|
+
# Write to temporary file
|
|
224
|
+
with open(temp_file, 'wb') as temp:
|
|
225
|
+
temp.write(audio_data)
|
|
226
|
+
|
|
227
|
+
logger.debug("Audio data extraction completed")
|
|
228
|
+
return temp_file
|
|
229
|
+
|
|
230
|
+
except Exception as e:
|
|
231
|
+
if 'temp_file' in locals():
|
|
232
|
+
temp_file.unlink(missing_ok=True)
|
|
233
|
+
raise TAFPlayerError(f"Failed to extract audio data: {e}")
|
|
234
|
+
|
|
235
|
+
def play(self, start_time: float = 0.0) -> None:
|
|
236
|
+
"""
|
|
237
|
+
Start playback of the loaded TAF file.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
start_time: Time in seconds to start playback from
|
|
241
|
+
|
|
242
|
+
Raises:
|
|
243
|
+
TAFPlayerError: If playback cannot be started
|
|
244
|
+
"""
|
|
245
|
+
if not self.taf_file:
|
|
246
|
+
raise TAFPlayerError("No TAF file loaded")
|
|
247
|
+
|
|
248
|
+
if self.is_playing:
|
|
249
|
+
logger.warning("Playback already in progress")
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
# Extract audio data if not already done
|
|
254
|
+
if not self.temp_audio_file or not self.temp_audio_file.exists():
|
|
255
|
+
self.temp_audio_file = self._extract_audio_data()
|
|
256
|
+
|
|
257
|
+
self.current_position = start_time
|
|
258
|
+
self._stop_event.clear()
|
|
259
|
+
|
|
260
|
+
# Start playback in a separate thread
|
|
261
|
+
self.playback_thread = threading.Thread(
|
|
262
|
+
target=self._playback_worker,
|
|
263
|
+
args=(start_time,),
|
|
264
|
+
daemon=True
|
|
265
|
+
)
|
|
266
|
+
self.playback_thread.start()
|
|
267
|
+
self.is_playing = True
|
|
268
|
+
self.is_paused = False
|
|
269
|
+
|
|
270
|
+
logger.info(f"Started playback of: {self.taf_file.name}")
|
|
271
|
+
if start_time > 0:
|
|
272
|
+
logger.info(f"Starting from: {self._format_time(start_time)}")
|
|
273
|
+
|
|
274
|
+
except Exception as e:
|
|
275
|
+
raise TAFPlayerError(f"Failed to start playback: {e}")
|
|
276
|
+
def _playback_worker(self, start_time: float = 0.0) -> None:
|
|
277
|
+
"""Worker thread for handling FFplay audio playback."""
|
|
278
|
+
try:
|
|
279
|
+
# Get FFplay binary path
|
|
280
|
+
ffplay_path = get_ffplay_binary(auto_download=True)
|
|
281
|
+
if not ffplay_path:
|
|
282
|
+
raise TAFPlayerError("FFplay not found and could not be downloaded")
|
|
283
|
+
|
|
284
|
+
logger.debug(f"Using FFplay at: {ffplay_path}")
|
|
285
|
+
|
|
286
|
+
# Verify temp audio file exists
|
|
287
|
+
if not self.temp_audio_file or not self.temp_audio_file.exists():
|
|
288
|
+
raise TAFPlayerError("No audio data available for playback")
|
|
289
|
+
|
|
290
|
+
# Build FFplay command
|
|
291
|
+
ffplay_cmd = [
|
|
292
|
+
ffplay_path,
|
|
293
|
+
str(self.temp_audio_file),
|
|
294
|
+
'-nodisp', # No video display window
|
|
295
|
+
'-autoexit', # Exit when playback finishes
|
|
296
|
+
'-loglevel', 'warning' # Reduce output noise
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
# Add start time if specified
|
|
300
|
+
if start_time > 0:
|
|
301
|
+
ffplay_cmd.extend(['-ss', str(start_time)])
|
|
302
|
+
|
|
303
|
+
logger.debug(f"FFplay command: {' '.join(ffplay_cmd)}")
|
|
304
|
+
|
|
305
|
+
# Start FFplay process
|
|
306
|
+
self.ffmpeg_process = subprocess.Popen(
|
|
307
|
+
ffplay_cmd,
|
|
308
|
+
stdout=subprocess.DEVNULL,
|
|
309
|
+
stderr=subprocess.PIPE,
|
|
310
|
+
stdin=subprocess.PIPE
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# Wait for process completion or stop signal
|
|
314
|
+
while self.ffmpeg_process.poll() is None:
|
|
315
|
+
if self._stop_event.wait(0.1):
|
|
316
|
+
break
|
|
317
|
+
|
|
318
|
+
# Update current position (rough estimate)
|
|
319
|
+
if not self.is_paused:
|
|
320
|
+
self.current_position += 0.1
|
|
321
|
+
|
|
322
|
+
# Clean up process
|
|
323
|
+
if self.ffmpeg_process and self.ffmpeg_process.poll() is None:
|
|
324
|
+
self.ffmpeg_process.terminate()
|
|
325
|
+
self.ffmpeg_process.wait(timeout=5)
|
|
326
|
+
|
|
327
|
+
except Exception as e:
|
|
328
|
+
logger.error(f"Playback error: {e}")
|
|
329
|
+
finally:
|
|
330
|
+
self.is_playing = False
|
|
331
|
+
self.is_paused = False
|
|
332
|
+
self.ffmpeg_process = None
|
|
333
|
+
def pause(self) -> None:
|
|
334
|
+
"""Pause playback."""
|
|
335
|
+
if not self.is_playing:
|
|
336
|
+
logger.warning("No playback in progress")
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
if self.is_paused:
|
|
340
|
+
logger.warning("Playback already paused")
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
if self.ffmpeg_process:
|
|
344
|
+
try:
|
|
345
|
+
# Send SIGSTOP to pause (Unix) or terminate and remember position (Windows)
|
|
346
|
+
if hasattr(self.ffmpeg_process, 'suspend'):
|
|
347
|
+
self.ffmpeg_process.suspend()
|
|
348
|
+
self.is_paused = True
|
|
349
|
+
logger.info("Playback paused")
|
|
350
|
+
else:
|
|
351
|
+
# For Windows, we'll need to stop and restart
|
|
352
|
+
# Save current position before stopping
|
|
353
|
+
pause_position = self.current_position
|
|
354
|
+
self._stop_playback_process()
|
|
355
|
+
# Restore the position and set paused state
|
|
356
|
+
self.current_position = pause_position
|
|
357
|
+
self.is_playing = False
|
|
358
|
+
self.is_paused = True
|
|
359
|
+
logger.info("Playback paused")
|
|
360
|
+
|
|
361
|
+
except Exception as e:
|
|
362
|
+
logger.error(f"Failed to pause playback: {e}")
|
|
363
|
+
def resume(self) -> None:
|
|
364
|
+
"""Resume paused playback."""
|
|
365
|
+
if not self.is_paused:
|
|
366
|
+
logger.warning("Playback is not paused")
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
try:
|
|
370
|
+
if self.ffmpeg_process and hasattr(self.ffmpeg_process, 'resume'):
|
|
371
|
+
# Unix-like systems with process suspension support
|
|
372
|
+
self.ffmpeg_process.resume()
|
|
373
|
+
self.is_paused = False
|
|
374
|
+
logger.info("Playback resumed")
|
|
375
|
+
else:
|
|
376
|
+
# Windows or systems without suspension - restart from saved position
|
|
377
|
+
resume_position = self.current_position
|
|
378
|
+
self.is_paused = False
|
|
379
|
+
self.play(resume_position)
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
except Exception as e:
|
|
383
|
+
logger.error(f"Failed to resume playback: {e}")
|
|
384
|
+
|
|
385
|
+
def _stop_playback_process(self) -> None:
|
|
386
|
+
"""Stop the playback process without resetting position or state."""
|
|
387
|
+
# Signal stop to playback thread
|
|
388
|
+
self._stop_event.set()
|
|
389
|
+
|
|
390
|
+
# Terminate FFmpeg process
|
|
391
|
+
if self.ffmpeg_process:
|
|
392
|
+
try:
|
|
393
|
+
self.ffmpeg_process.terminate()
|
|
394
|
+
self.ffmpeg_process.wait(timeout=5)
|
|
395
|
+
except subprocess.TimeoutExpired:
|
|
396
|
+
self.ffmpeg_process.kill()
|
|
397
|
+
except Exception as e:
|
|
398
|
+
logger.error(f"Error stopping FFmpeg process: {e}")
|
|
399
|
+
|
|
400
|
+
# Wait for playback thread to finish
|
|
401
|
+
if self.playback_thread and self.playback_thread.is_alive():
|
|
402
|
+
self.playback_thread.join(timeout=5)
|
|
403
|
+
|
|
404
|
+
self.ffmpeg_process = None
|
|
405
|
+
|
|
406
|
+
def stop(self) -> None:
|
|
407
|
+
"""Stop playback."""
|
|
408
|
+
if not self.is_playing:
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
logger.info("Stopping playback")
|
|
412
|
+
|
|
413
|
+
self._stop_playback_process()
|
|
414
|
+
|
|
415
|
+
self.is_playing = False
|
|
416
|
+
self.is_paused = False
|
|
417
|
+
self.current_position = 0.0
|
|
418
|
+
|
|
419
|
+
logger.info("Playback stopped")
|
|
420
|
+
|
|
421
|
+
def seek(self, position: float) -> None:
|
|
422
|
+
"""
|
|
423
|
+
Seek to a specific position in the audio.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
position: Time in seconds to seek to
|
|
427
|
+
"""
|
|
428
|
+
if position < 0:
|
|
429
|
+
position = 0.0
|
|
430
|
+
elif self.total_duration > 0 and position > self.total_duration:
|
|
431
|
+
position = self.total_duration
|
|
432
|
+
|
|
433
|
+
was_playing = self.is_playing
|
|
434
|
+
|
|
435
|
+
if was_playing:
|
|
436
|
+
self.stop()
|
|
437
|
+
|
|
438
|
+
self.current_position = position
|
|
439
|
+
|
|
440
|
+
if was_playing:
|
|
441
|
+
self.play(position)
|
|
442
|
+
|
|
443
|
+
logger.info(f"Seeked to: {self._format_time(position)}")
|
|
444
|
+
def get_status(self) -> Dict[str, Any]:
|
|
445
|
+
"""
|
|
446
|
+
Get current player status.
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
Dictionary containing player status information
|
|
450
|
+
"""
|
|
451
|
+
return {
|
|
452
|
+
'file_loaded': self.taf_file is not None,
|
|
453
|
+
'file_path': str(self.taf_file) if self.taf_file else None,
|
|
454
|
+
'is_playing': self.is_playing,
|
|
455
|
+
'is_paused': self.is_paused,
|
|
456
|
+
'current_position': self.current_position,
|
|
457
|
+
'total_duration': self.total_duration,
|
|
458
|
+
'progress_percent': (self.current_position / self.total_duration * 100) if self.total_duration > 0 else 0.0
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
def cleanup(self) -> None:
|
|
462
|
+
"""Clean up resources used by the player."""
|
|
463
|
+
self.stop()
|
|
464
|
+
|
|
465
|
+
# Remove temporary audio file
|
|
466
|
+
if self.temp_audio_file and self.temp_audio_file.exists():
|
|
467
|
+
try:
|
|
468
|
+
self.temp_audio_file.unlink()
|
|
469
|
+
logger.debug("Cleaned up temporary audio file")
|
|
470
|
+
except Exception as e:
|
|
471
|
+
logger.warning(f"Failed to clean up temporary file: {e}")
|
|
472
|
+
|
|
473
|
+
def _format_time(self, seconds: float) -> str:
|
|
474
|
+
"""
|
|
475
|
+
Format time in seconds to MM:SS or HH:MM:SS format.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
seconds: Time in seconds
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
Formatted time string
|
|
482
|
+
"""
|
|
483
|
+
hours = int(seconds // 3600)
|
|
484
|
+
minutes = int((seconds % 3600) // 60)
|
|
485
|
+
secs = int(seconds % 60)
|
|
486
|
+
|
|
487
|
+
if hours > 0:
|
|
488
|
+
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
|
489
|
+
else:
|
|
490
|
+
return f"{minutes:02d}:{secs:02d}"
|
|
491
|
+
|
|
492
|
+
def _parse_time_string(self, time_str: str) -> float:
|
|
493
|
+
"""
|
|
494
|
+
Parse a time string (HH:MM:SS.FF or MM:SS) back to seconds.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
time_str: Formatted time string
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
Time in seconds as float
|
|
501
|
+
"""
|
|
502
|
+
try:
|
|
503
|
+
parts = time_str.split(':')
|
|
504
|
+
if len(parts) == 3:
|
|
505
|
+
# HH:MM:SS or HH:MM:SS.FF format
|
|
506
|
+
hours = int(parts[0])
|
|
507
|
+
minutes = int(parts[1])
|
|
508
|
+
# Handle potential fractional seconds
|
|
509
|
+
seconds_part = parts[2]
|
|
510
|
+
if '.' in seconds_part:
|
|
511
|
+
seconds = float(seconds_part)
|
|
512
|
+
else:
|
|
513
|
+
seconds = int(seconds_part)
|
|
514
|
+
return hours * 3600 + minutes * 60 + seconds
|
|
515
|
+
elif len(parts) == 2:
|
|
516
|
+
# MM:SS format
|
|
517
|
+
minutes = int(parts[0])
|
|
518
|
+
seconds_part = parts[1]
|
|
519
|
+
if '.' in seconds_part:
|
|
520
|
+
seconds = float(seconds_part)
|
|
521
|
+
else:
|
|
522
|
+
seconds = int(seconds_part)
|
|
523
|
+
return minutes * 60 + seconds
|
|
524
|
+
else:
|
|
525
|
+
# Single number, assume seconds
|
|
526
|
+
return float(time_str)
|
|
527
|
+
except (ValueError, IndexError):
|
|
528
|
+
logger.warning(f"Could not parse time string: {time_str}")
|
|
529
|
+
return 0.0
|
|
530
|
+
|
|
531
|
+
def __enter__(self):
|
|
532
|
+
"""Context manager entry."""
|
|
533
|
+
return self
|
|
534
|
+
|
|
535
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
536
|
+
"""Context manager exit - cleanup resources."""
|
|
537
|
+
self.cleanup()
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def interactive_player(taf_file_path: str) -> None:
|
|
541
|
+
"""
|
|
542
|
+
Start an interactive TAF player session.
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
taf_file_path: Path to the TAF file to play
|
|
546
|
+
"""
|
|
547
|
+
print("TonieToolbox TAF Player")
|
|
548
|
+
print("======================")
|
|
549
|
+
|
|
550
|
+
try:
|
|
551
|
+
with TAFPlayer() as player:
|
|
552
|
+
player.load(taf_file_path)
|
|
553
|
+
|
|
554
|
+
print("\nControls:")
|
|
555
|
+
print(" [p]lay - Play/pause toggle")
|
|
556
|
+
print(" [s]top - Stop playback")
|
|
557
|
+
print(" [q]uit - Quit player")
|
|
558
|
+
print(" [i]nfo - Show file information")
|
|
559
|
+
print(" seek <MM:SS> - Seek to specific time")
|
|
560
|
+
print(" status - Show current status")
|
|
561
|
+
print("\nType a command and press Enter:")
|
|
562
|
+
|
|
563
|
+
while True:
|
|
564
|
+
try:
|
|
565
|
+
command = input("> ").strip().lower()
|
|
566
|
+
|
|
567
|
+
if command in ['q', 'quit', 'exit']:
|
|
568
|
+
break
|
|
569
|
+
elif command in ['p', 'play']:
|
|
570
|
+
if player.is_playing and not player.is_paused:
|
|
571
|
+
# Currently playing, so pause
|
|
572
|
+
player.pause()
|
|
573
|
+
elif player.is_paused:
|
|
574
|
+
# Currently paused, so resume
|
|
575
|
+
player.resume()
|
|
576
|
+
else:
|
|
577
|
+
# Not playing, so start
|
|
578
|
+
player.play()
|
|
579
|
+
elif command in ['s', 'stop']:
|
|
580
|
+
player.stop()
|
|
581
|
+
elif command in ['i', 'info']:
|
|
582
|
+
player._print_file_info()
|
|
583
|
+
elif command == 'status':
|
|
584
|
+
status = player.get_status()
|
|
585
|
+
print(f"Status: {'Playing' if status['is_playing'] else 'Stopped'}")
|
|
586
|
+
if status['is_paused']:
|
|
587
|
+
print(" (Paused)")
|
|
588
|
+
print(f"Position: {player._format_time(status['current_position'])}")
|
|
589
|
+
if status['total_duration'] > 0:
|
|
590
|
+
print(f"Duration: {player._format_time(status['total_duration'])}")
|
|
591
|
+
print(f"Progress: {status['progress_percent']:.1f}%")
|
|
592
|
+
elif command.startswith('seek '):
|
|
593
|
+
try:
|
|
594
|
+
time_str = command[5:].strip()
|
|
595
|
+
if ':' in time_str:
|
|
596
|
+
parts = time_str.split(':')
|
|
597
|
+
if len(parts) == 2:
|
|
598
|
+
minutes, seconds = map(int, parts)
|
|
599
|
+
seek_time = minutes * 60 + seconds
|
|
600
|
+
elif len(parts) == 3:
|
|
601
|
+
hours, minutes, seconds = map(int, parts)
|
|
602
|
+
seek_time = hours * 3600 + minutes * 60 + seconds
|
|
603
|
+
else:
|
|
604
|
+
raise ValueError("Invalid time format")
|
|
605
|
+
else:
|
|
606
|
+
seek_time = float(time_str)
|
|
607
|
+
|
|
608
|
+
player.seek(seek_time)
|
|
609
|
+
except ValueError:
|
|
610
|
+
print("Invalid time format. Use MM:SS or HH:MM:SS or seconds")
|
|
611
|
+
elif command == '':
|
|
612
|
+
continue
|
|
613
|
+
else:
|
|
614
|
+
print("Unknown command. Type 'q' to quit.")
|
|
615
|
+
|
|
616
|
+
except KeyboardInterrupt:
|
|
617
|
+
break
|
|
618
|
+
except EOFError:
|
|
619
|
+
break
|
|
620
|
+
except Exception as e:
|
|
621
|
+
logger.error(f"Command error: {e}")
|
|
622
|
+
|
|
623
|
+
print("\nGoodbye!")
|
|
624
|
+
|
|
625
|
+
except TAFPlayerError as e:
|
|
626
|
+
logger.error(f"Player error: {e}")
|
|
627
|
+
sys.exit(1)
|
|
628
|
+
except Exception as e:
|
|
629
|
+
logger.error(f"Unexpected error: {e}")
|
|
630
|
+
sys.exit(1)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
if __name__ == "__main__":
|
|
634
|
+
if len(sys.argv) != 2:
|
|
635
|
+
print("Usage: python -m TonieToolbox.player <taf_file>")
|
|
636
|
+
sys.exit(1)
|
|
637
|
+
|
|
638
|
+
interactive_player(sys.argv[1])
|
|
@@ -338,4 +338,32 @@ def process_recursive_folders(root_path: str, use_media_tags: bool = False, name
|
|
|
338
338
|
folder_path, output_name, len(audio_files))
|
|
339
339
|
|
|
340
340
|
logger.info("Created %d processing tasks", len(results))
|
|
341
|
-
return results
|
|
341
|
+
return results
|
|
342
|
+
|
|
343
|
+
def get_all_audio_files_recursive(root_path: str) -> list[str]:
|
|
344
|
+
"""
|
|
345
|
+
Get all audio files recursively from a directory tree.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
root_path (str): Root directory to search
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
list[str]: List of all audio files found recursively
|
|
352
|
+
"""
|
|
353
|
+
logger.debug("Getting all audio files recursively from: %s", root_path)
|
|
354
|
+
|
|
355
|
+
# Use the existing find_audio_folders function to get all folders with audio files
|
|
356
|
+
all_folders = find_audio_folders(root_path)
|
|
357
|
+
|
|
358
|
+
# Extract all audio files from all folders
|
|
359
|
+
all_audio_files = []
|
|
360
|
+
for folder_info in all_folders:
|
|
361
|
+
audio_files = folder_info['audio_files']
|
|
362
|
+
all_audio_files.extend(audio_files)
|
|
363
|
+
logger.debug("Found %d audio files in folder: %s", len(audio_files), folder_info['path'])
|
|
364
|
+
|
|
365
|
+
# Sort files for consistent processing order
|
|
366
|
+
all_audio_files.sort()
|
|
367
|
+
|
|
368
|
+
logger.info("Found %d total audio files recursively", len(all_audio_files))
|
|
369
|
+
return all_audio_files
|