TonieToolbox 0.1.0__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.
@@ -0,0 +1,522 @@
1
+ """
2
+ Functions for analyzing Tonie files
3
+ """
4
+
5
+ import datetime
6
+ import hashlib
7
+ import struct
8
+ import os
9
+ import difflib
10
+ from collections import defaultdict
11
+
12
+ from . import tonie_header_pb2
13
+
14
+ from .ogg_page import OggPage
15
+ from .logger import get_logger
16
+
17
+ # Setup logging
18
+ logger = get_logger('tonie_analysis')
19
+
20
+
21
+ def format_time(ts):
22
+ """
23
+ Format a timestamp as a human-readable date and time string.
24
+
25
+ Args:
26
+ ts: Timestamp to format
27
+
28
+ Returns:
29
+ str: Formatted date and time string
30
+ """
31
+ return datetime.datetime.utcfromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S')
32
+
33
+
34
+ def format_hex(data):
35
+ """
36
+ Format binary data as a hex string.
37
+
38
+ Args:
39
+ data: Binary data to format
40
+
41
+ Returns:
42
+ str: Formatted hex string
43
+ """
44
+ return "".join(format(x, "02X") for x in data)
45
+
46
+
47
+ def granule_to_time_string(granule, sample_rate=1):
48
+ """
49
+ Convert a granule position to a time string.
50
+
51
+ Args:
52
+ granule: Granule position
53
+ sample_rate: Sample rate in Hz
54
+
55
+ Returns:
56
+ str: Formatted time string (HH:MM:SS.FF)
57
+ """
58
+ total_seconds = granule / sample_rate
59
+ hours = int(total_seconds / 3600)
60
+ minutes = int((total_seconds - (hours * 3600)) / 60)
61
+ seconds = int(total_seconds - (hours * 3600) - (minutes * 60))
62
+ fraction = int((total_seconds * 100) % 100)
63
+ return "{:02d}:{:02d}:{:02d}.{:02d}".format(hours, minutes, seconds, fraction)
64
+
65
+
66
+ def get_header_info(in_file):
67
+ """
68
+ Get header information from a Tonie file.
69
+
70
+ Args:
71
+ in_file: Input file handle
72
+
73
+ Returns:
74
+ tuple: Header size, Tonie header object, file size, audio size, SHA1 sum,
75
+ Opus header found flag, Opus version, channel count, sample rate, bitstream serial number
76
+
77
+ Raises:
78
+ RuntimeError: If OGG pages cannot be found
79
+ """
80
+ logger.debug("Reading Tonie header information")
81
+
82
+ tonie_header = tonie_header_pb2.TonieHeader()
83
+ header_size = struct.unpack(">L", in_file.read(4))[0]
84
+ logger.debug("Header size: %d bytes", header_size)
85
+
86
+ tonie_header = tonie_header.FromString(in_file.read(header_size))
87
+ logger.debug("Read Tonie header with %d chapter pages", len(tonie_header.chapterPages))
88
+
89
+ sha1sum = hashlib.sha1(in_file.read())
90
+ logger.debug("Calculated SHA1: %s", sha1sum.hexdigest())
91
+
92
+ file_size = in_file.tell()
93
+ in_file.seek(4 + header_size)
94
+ audio_size = file_size - in_file.tell()
95
+ logger.debug("File size: %d bytes, Audio size: %d bytes", file_size, audio_size)
96
+
97
+ found = OggPage.seek_to_page_header(in_file)
98
+ if not found:
99
+ logger.error("First OGG page not found")
100
+ raise RuntimeError("First ogg page not found")
101
+
102
+ first_page = OggPage(in_file)
103
+ logger.debug("Read first OGG page")
104
+
105
+ unpacked = struct.unpack("<8sBBHLH", first_page.segments[0].data[0:18])
106
+ opus_head_found = unpacked[0] == b"OpusHead"
107
+ opus_version = unpacked[1]
108
+ channel_count = unpacked[2]
109
+ sample_rate = unpacked[4]
110
+ bitstream_serial_no = first_page.serial_no
111
+
112
+ logger.debug("Opus header found: %s, Version: %d, Channels: %d, Sample rate: %d Hz, Serial: %d",
113
+ opus_head_found, opus_version, channel_count, sample_rate, bitstream_serial_no)
114
+
115
+ found = OggPage.seek_to_page_header(in_file)
116
+ if not found:
117
+ logger.error("Second OGG page not found")
118
+ raise RuntimeError("Second ogg page not found")
119
+
120
+ OggPage(in_file)
121
+ logger.debug("Read second OGG page")
122
+
123
+ return (
124
+ header_size, tonie_header, file_size, audio_size, sha1sum,
125
+ opus_head_found, opus_version, channel_count, sample_rate, bitstream_serial_no
126
+ )
127
+
128
+
129
+ def get_audio_info(in_file, sample_rate, tonie_header, header_size):
130
+ """
131
+ Get audio information from a Tonie file.
132
+
133
+ Args:
134
+ in_file: Input file handle
135
+ sample_rate: Sample rate in Hz
136
+ tonie_header: Tonie header object
137
+ header_size: Header size in bytes
138
+
139
+ Returns:
140
+ tuple: Page count, alignment OK flag, page size OK flag, total time, chapter times
141
+ """
142
+ logger.debug("Reading audio information")
143
+
144
+ chapter_granules = []
145
+ if 0 in tonie_header.chapterPages:
146
+ chapter_granules.append(0)
147
+ logger.trace("Added chapter at granule position 0")
148
+
149
+ alignment_okay = in_file.tell() == (512 + 4 + header_size)
150
+ logger.debug("Initial alignment OK: %s (position: %d)", alignment_okay, in_file.tell())
151
+
152
+ page_size_okay = True
153
+ page_count = 2
154
+
155
+ page = None
156
+ found = OggPage.seek_to_page_header(in_file)
157
+ while found:
158
+ page_count = page_count + 1
159
+ page = OggPage(in_file)
160
+ logger.trace("Read page #%d with granule position %d", page.page_no, page.granule_position)
161
+
162
+ found = OggPage.seek_to_page_header(in_file)
163
+ if found and in_file.tell() % 0x1000 != 0:
164
+ alignment_okay = False
165
+ logger.debug("Page alignment not OK at position %d", in_file.tell())
166
+
167
+ if page_size_okay and page_count > 3 and page.get_page_size() != 0x1000 and found:
168
+ page_size_okay = False
169
+ logger.debug("Page size not OK for page #%d (size: %d)", page.page_no, page.get_page_size())
170
+
171
+ if page.page_no in tonie_header.chapterPages:
172
+ chapter_granules.append(page.granule_position)
173
+ logger.trace("Added chapter at granule position %d from page #%d",
174
+ page.granule_position, page.page_no)
175
+
176
+ chapter_granules.append(page.granule_position)
177
+ logger.debug("Found %d chapters", len(chapter_granules) - 1)
178
+
179
+ chapter_times = []
180
+ for i in range(1, len(chapter_granules)):
181
+ length = chapter_granules[i] - chapter_granules[i - 1]
182
+ time_str = granule_to_time_string(length, sample_rate)
183
+ chapter_times.append(time_str)
184
+ logger.debug("Chapter %d duration: %s", i, time_str)
185
+
186
+ total_time = page.granule_position / sample_rate
187
+ logger.debug("Total time: %f seconds (%s)", total_time,
188
+ granule_to_time_string(page.granule_position, sample_rate))
189
+
190
+ return page_count, alignment_okay, page_size_okay, total_time, chapter_times
191
+
192
+
193
+ def check_tonie_file(filename):
194
+ """
195
+ Check if a file is a valid Tonie file and display information about it.
196
+
197
+ Args:
198
+ filename: Path to the file to check
199
+
200
+ Returns:
201
+ bool: True if the file is valid, False otherwise
202
+ """
203
+ logger.info("Checking Tonie file: %s", filename)
204
+
205
+ with open(filename, "rb") as in_file:
206
+ header_size, tonie_header, file_size, audio_size, sha1, opus_head_found, \
207
+ opus_version, channel_count, sample_rate, bitstream_serial_no = get_header_info(in_file)
208
+
209
+ page_count, alignment_okay, page_size_okay, total_time, \
210
+ chapters = get_audio_info(in_file, sample_rate, tonie_header, header_size)
211
+
212
+ hash_ok = tonie_header.dataHash == sha1.digest()
213
+ timestamp_ok = tonie_header.timestamp == bitstream_serial_no
214
+ audio_size_ok = tonie_header.dataLength == audio_size
215
+ opus_ok = opus_head_found and \
216
+ opus_version == 1 and \
217
+ (sample_rate == 48000 or sample_rate == 44100) and \
218
+ channel_count == 2
219
+
220
+ all_ok = hash_ok and \
221
+ timestamp_ok and \
222
+ opus_ok and \
223
+ alignment_okay and \
224
+ page_size_okay
225
+
226
+ logger.debug("Validation results:")
227
+ logger.debug(" Hash OK: %s", hash_ok)
228
+ logger.debug(" Timestamp OK: %s", timestamp_ok)
229
+ logger.debug(" Audio size OK: %s", audio_size_ok)
230
+ logger.debug(" Opus OK: %s", opus_ok)
231
+ logger.debug(" Alignment OK: %s", alignment_okay)
232
+ logger.debug(" Page size OK: %s", page_size_okay)
233
+ logger.debug(" All OK: %s", all_ok)
234
+
235
+ print("[{}] SHA1 hash: 0x{}".format("OK" if hash_ok else "NOT OK", format_hex(tonie_header.dataHash)))
236
+ if not hash_ok:
237
+ print(" actual: 0x{}".format(sha1.hexdigest().upper()))
238
+ print("[{}] Timestamp: [0x{:X}] {}".format("OK" if timestamp_ok else "NOT OK", tonie_header.timestamp,
239
+ format_time(tonie_header.timestamp)))
240
+ if not timestamp_ok:
241
+ print(" bitstream serial: 0x{:X}".format(bitstream_serial_no))
242
+ print("[{}] Opus data length: {} bytes (~{:2.0f} kbps)".format("OK" if audio_size_ok else "NOT OK",
243
+ tonie_header.dataLength,
244
+ (audio_size * 8) / 1024 / total_time))
245
+ if not audio_size_ok:
246
+ print(" actual: {} bytes".format(audio_size))
247
+
248
+ print("[{}] Opus header {}OK || {} channels || {:2.1f} kHz || {} Ogg pages"
249
+ .format("OK" if opus_ok else "NOT OK", "" if opus_head_found and opus_version == 1 else "NOT ",
250
+ channel_count, sample_rate / 1000, page_count))
251
+ print("[{}] Page alignment {}OK and size {}OK"
252
+ .format("OK" if alignment_okay and page_size_okay else "NOT OK", "" if alignment_okay else "NOT ",
253
+ "" if page_size_okay else "NOT "))
254
+ print("")
255
+ print("[{}] File is {}valid".format("OK" if all_ok else "NOT OK", "" if all_ok else "NOT "))
256
+ print("")
257
+ print("[ii] Total runtime: {}".format(granule_to_time_string(total_time)))
258
+ print("[ii] {} Tracks:".format(len(chapters)))
259
+ for i in range(0, len(chapters)):
260
+ print(" Track {:02d}: {}".format(i + 1, chapters[i]))
261
+
262
+ logger.info("File validation complete. Result: %s", "Valid" if all_ok else "Invalid")
263
+ return all_ok
264
+
265
+
266
+ def split_to_opus_files(filename, output=None):
267
+ """
268
+ Split a Tonie file into individual Opus files.
269
+
270
+ Args:
271
+ filename: Path to the Tonie file
272
+ output: Output directory path (optional)
273
+ """
274
+ logger.info("Splitting Tonie file into individual Opus tracks: %s", filename)
275
+
276
+ with open(filename, "rb") as in_file:
277
+ tonie_header = tonie_header_pb2.TonieHeader()
278
+ header_size = struct.unpack(">L", in_file.read(4))[0]
279
+ logger.debug("Header size: %d bytes", header_size)
280
+
281
+ tonie_header = tonie_header.FromString(in_file.read(header_size))
282
+ logger.debug("Read Tonie header with %d chapter pages", len(tonie_header.chapterPages))
283
+
284
+ abs_path = os.path.abspath(filename)
285
+ if output:
286
+ if not os.path.exists(output):
287
+ logger.debug("Creating output directory: %s", output)
288
+ os.makedirs(output)
289
+ path = output
290
+ else:
291
+ path = os.path.dirname(abs_path)
292
+
293
+ logger.debug("Output path: %s", path)
294
+
295
+ name = os.path.basename(abs_path)
296
+ pos = name.rfind('.')
297
+ if pos == -1:
298
+ name = name + ".opus"
299
+ else:
300
+ name = name[:pos] + ".opus"
301
+
302
+ filename_template = "{{:02d}}_{}".format(name)
303
+ out_path = "{}{}".format(path, os.path.sep)
304
+ logger.debug("Output filename template: %s", out_path + filename_template)
305
+
306
+ found = OggPage.seek_to_page_header(in_file)
307
+ if not found:
308
+ logger.error("First OGG page not found")
309
+ raise RuntimeError("First ogg page not found")
310
+
311
+ first_page = OggPage(in_file)
312
+ logger.debug("Read first OGG page")
313
+
314
+ found = OggPage.seek_to_page_header(in_file)
315
+ if not found:
316
+ logger.error("Second OGG page not found")
317
+ raise RuntimeError("Second ogg page not found")
318
+
319
+ second_page = OggPage(in_file)
320
+ logger.debug("Read second OGG page")
321
+
322
+ found = OggPage.seek_to_page_header(in_file)
323
+ page = OggPage(in_file)
324
+ logger.debug("Read third OGG page")
325
+
326
+ import math
327
+
328
+ pad_len = math.ceil(math.log(len(tonie_header.chapterPages) + 1, 10))
329
+ format_string = "[{{:0{}d}}/{:0{}d}] {{}}".format(pad_len, len(tonie_header.chapterPages), pad_len)
330
+
331
+ for i in range(0, len(tonie_header.chapterPages)):
332
+ if (i + 1) < len(tonie_header.chapterPages):
333
+ end_page = tonie_header.chapterPages[i + 1]
334
+ else:
335
+ end_page = 0
336
+
337
+ granule = 0
338
+ output_filename = filename_template.format(i + 1)
339
+ print(format_string.format(i + 1, output_filename))
340
+ logger.info("Creating track %d: %s (end page: %d)", i + 1, out_path + output_filename, end_page)
341
+
342
+ with open("{}{}".format(out_path, output_filename), "wb") as out_file:
343
+ first_page.write_page(out_file)
344
+ second_page.write_page(out_file)
345
+ page_count = 0
346
+
347
+ while found and ((page.page_no < end_page) or (end_page == 0)):
348
+ page.correct_values(granule)
349
+ granule = page.granule_position
350
+ page.write_page(out_file)
351
+ page_count += 1
352
+
353
+ found = OggPage.seek_to_page_header(in_file)
354
+ if found:
355
+ page = OggPage(in_file)
356
+
357
+ logger.debug("Track %d: Wrote %d pages, final granule position: %d",
358
+ i + 1, page_count, granule)
359
+
360
+ logger.info("Successfully split Tonie file into %d individual tracks", len(tonie_header.chapterPages))
361
+
362
+
363
+ def compare_taf_files(file1, file2, detailed=False):
364
+ """
365
+ Compare two .taf files for debugging purposes.
366
+
367
+ Args:
368
+ file1: Path to the first .taf file
369
+ file2: Path to the second .taf file
370
+ detailed: Whether to show detailed comparison results
371
+
372
+ Returns:
373
+ bool: True if files are equivalent, False otherwise
374
+ """
375
+ logger.info("Comparing .taf files:")
376
+ logger.info(" File 1: %s", file1)
377
+ logger.info(" File 2: %s", file2)
378
+
379
+ if not os.path.exists(file1):
380
+ logger.error("File 1 does not exist: %s", file1)
381
+ return False
382
+
383
+ if not os.path.exists(file2):
384
+ logger.error("File 2 does not exist: %s", file2)
385
+ return False
386
+
387
+ # Compare file sizes
388
+ size1 = os.path.getsize(file1)
389
+ size2 = os.path.getsize(file2)
390
+
391
+ if size1 != size2:
392
+ logger.info("Files have different sizes: %d vs %d bytes", size1, size2)
393
+ print("Files have different sizes:")
394
+ print(f" File 1: {size1} bytes")
395
+ print(f" File 2: {size2} bytes")
396
+ else:
397
+ logger.info("Files have the same size: %d bytes", size1)
398
+ print(f"Files have the same size: {size1} bytes")
399
+
400
+ differences = []
401
+
402
+ # Compare headers and extract key information
403
+ with open(file1, "rb") as f1, open(file2, "rb") as f2:
404
+ # Read and compare header sizes
405
+ header_size1 = struct.unpack(">L", f1.read(4))[0]
406
+ header_size2 = struct.unpack(">L", f2.read(4))[0]
407
+
408
+ if header_size1 != header_size2:
409
+ differences.append(f"Header sizes differ: {header_size1} vs {header_size2} bytes")
410
+ logger.info("Header sizes differ: %d vs %d bytes", header_size1, header_size2)
411
+
412
+ # Read and parse headers
413
+ tonie_header1 = tonie_header_pb2.TonieHeader()
414
+ tonie_header2 = tonie_header_pb2.TonieHeader()
415
+
416
+ tonie_header1 = tonie_header1.FromString(f1.read(header_size1))
417
+ tonie_header2 = tonie_header2.FromString(f2.read(header_size2))
418
+
419
+ # Compare timestamps
420
+ if tonie_header1.timestamp != tonie_header2.timestamp:
421
+ differences.append(f"Timestamps differ: {tonie_header1.timestamp} vs {tonie_header2.timestamp}")
422
+ logger.info("Timestamps differ: %d vs %d", tonie_header1.timestamp, tonie_header2.timestamp)
423
+
424
+ # Compare data lengths
425
+ if tonie_header1.dataLength != tonie_header2.dataLength:
426
+ differences.append(f"Data lengths differ: {tonie_header1.dataLength} vs {tonie_header2.dataLength} bytes")
427
+ logger.info("Data lengths differ: %d vs %d bytes", tonie_header1.dataLength, tonie_header2.dataLength)
428
+
429
+ # Compare data hashes
430
+ hash1_hex = format_hex(tonie_header1.dataHash)
431
+ hash2_hex = format_hex(tonie_header2.dataHash)
432
+ if tonie_header1.dataHash != tonie_header2.dataHash:
433
+ differences.append(f"Data hashes differ: 0x{hash1_hex} vs 0x{hash2_hex}")
434
+ logger.info("Data hashes differ: 0x%s vs 0x%s", hash1_hex, hash2_hex)
435
+
436
+ # Compare chapter pages
437
+ ch1 = list(tonie_header1.chapterPages)
438
+ ch2 = list(tonie_header2.chapterPages)
439
+
440
+ if ch1 != ch2:
441
+ differences.append(f"Chapter pages differ: {ch1} vs {ch2}")
442
+ logger.info("Chapter pages differ: %s vs %s", ch1, ch2)
443
+
444
+ if len(ch1) != len(ch2):
445
+ differences.append(f"Number of chapters differ: {len(ch1)} vs {len(ch2)}")
446
+ logger.info("Number of chapters differ: %d vs %d", len(ch1), len(ch2))
447
+
448
+ # Compare audio content
449
+ # Reset file positions to after headers
450
+ f1.seek(4 + header_size1)
451
+ f2.seek(4 + header_size2)
452
+
453
+ # Compare Ogg pages
454
+ ogg_differences = []
455
+ page_count = 0
456
+
457
+ # Find first Ogg page in each file
458
+ found1 = OggPage.seek_to_page_header(f1)
459
+ found2 = OggPage.seek_to_page_header(f2)
460
+
461
+ if not found1 or not found2:
462
+ if not found1:
463
+ differences.append("First file: First OGG page not found")
464
+ if not found2:
465
+ differences.append("Second file: First OGG page not found")
466
+ else:
467
+ # Compare Ogg pages
468
+ while found1 and found2:
469
+ page_count += 1
470
+ page1 = OggPage(f1)
471
+ page2 = OggPage(f2)
472
+
473
+ # Compare key page attributes
474
+ if page1.serial_no != page2.serial_no:
475
+ ogg_differences.append(f"Page {page_count}: Serial numbers differ: {page1.serial_no} vs {page2.serial_no}")
476
+
477
+ if page1.page_no != page2.page_no:
478
+ ogg_differences.append(f"Page {page_count}: Page numbers differ: {page1.page_no} vs {page2.page_no}")
479
+
480
+ if page1.granule_position != page2.granule_position:
481
+ ogg_differences.append(f"Page {page_count}: Granule positions differ: {page1.granule_position} vs {page2.granule_position}")
482
+
483
+ if page1.get_page_size() != page2.get_page_size():
484
+ ogg_differences.append(f"Page {page_count}: Page sizes differ: {page1.get_page_size()} vs {page2.get_page_size()}")
485
+
486
+ # Check for more pages
487
+ found1 = OggPage.seek_to_page_header(f1)
488
+ found2 = OggPage.seek_to_page_header(f2)
489
+
490
+ # Check if one file has more pages than the other
491
+ if found1 and not found2:
492
+ extra_pages1 = 1
493
+ while OggPage.seek_to_page_header(f1):
494
+ OggPage(f1)
495
+ extra_pages1 += 1
496
+ ogg_differences.append(f"File 1 has {extra_pages1} more pages than File 2")
497
+
498
+ elif found2 and not found1:
499
+ extra_pages2 = 1
500
+ while OggPage.seek_to_page_header(f2):
501
+ OggPage(f2)
502
+ extra_pages2 += 1
503
+ ogg_differences.append(f"File 2 has {extra_pages2} more pages than File 1")
504
+
505
+ # Add Ogg differences to main differences list if detailed flag is set
506
+ if detailed and ogg_differences:
507
+ differences.extend(ogg_differences)
508
+ elif ogg_differences:
509
+ differences.append(f"Found {len(ogg_differences)} differences in Ogg pages")
510
+ logger.info("Found %d differences in Ogg pages", len(ogg_differences))
511
+
512
+ # Print summary
513
+ if differences:
514
+ print("\nFiles are different:")
515
+ for diff in differences:
516
+ print(f" - {diff}")
517
+ logger.info("Files comparison result: Different (%d differences found)", len(differences))
518
+ return False
519
+ else:
520
+ print("\nFiles are equivalent")
521
+ logger.info("Files comparison result: Equivalent")
522
+ return True